2 Commits

Author SHA1 Message Date
bc1bb4ae61 feat(trading-twins): Finalize Booking Engine & Infrastructure [31988f42]
- Implemented 'Direct Calendar Booking' logic replacing MS Bookings API.
- Integrated Dual-App architecture for Graph API (Sender vs. Reader permissions).
- Added FastAPI feedback server for Teams and Email interactions.
- Configured Nginx proxy for public feedback URL access.
- Updated Docker configuration (ports, env vars, dependencies).
- Finalized documentation in lead-engine/README.md.
2026-03-05 13:52:16 +00:00
b60d38994d feat(trading-twins): Implement human-in-the-loop via Teams [31988f42]
- Adds a human-in-the-loop verification step for the Trading Twins lead engine.
- Before sending an email, a notification is sent to a specified Teams channel via webhook.
- The notification is an Adaptive Card that allows a user (Elizabeta Melcer) to stop or immediately trigger the email dispatch within a 5-minute window.
- If no action is taken, the email is sent automatically after the timeout.
- Includes a FastAPI-based feedback server on port 8004 to handle the card actions.
- Adds placeholder for the HTML email signature.
- Successfully tested the Teams webhook connectivity and the full notification/feedback loop in a sandbox environment.
2026-03-05 10:35:50 +00:00
7 changed files with 386 additions and 175 deletions

View File

@@ -252,6 +252,7 @@ services:
restart: unless-stopped
ports:
- "8501:8501"
- "8004:8004"
env_file:
- .env
environment:
@@ -260,6 +261,10 @@ services:
# Explicitly pass keys to ensure availability
SERP_API: "${SERP_API}"
GEMINI_API_KEY: "${GEMINI_API_KEY}"
# Calendar App Credentials
CAL_APPID: "${CAL_APPID}"
CAL_SECRET: "${CAL_SECRET}"
CAL_TENNANT_ID: "${CAL_TENNANT_ID}"
volumes:
- ./lead-engine:/app
# We need to mount the root connector module so it can be imported inside the container

View File

@@ -5,8 +5,11 @@ WORKDIR /app
COPY . .
# Install dependencies required for ingestion and DB
RUN pip install streamlit pandas requests python-dotenv
RUN pip install streamlit pandas requests python-dotenv fastapi "uvicorn[standard]" msal
ENV PYTHONUNBUFFERED=1
EXPOSE 8501
EXPOSE 8004
# Start monitor in background and streamlit in foreground
CMD ["sh", "-c", "python monitor.py & streamlit run app.py --server.port=8501 --server.address=0.0.0.0"]

View File

@@ -1,4 +1,4 @@
# Lead Engine: Multi-Source Automation v1.2 [31988f42]
# Lead Engine: Multi-Source Automation v1.3 [31988f42]
## 🚀 Übersicht
Die **Lead Engine** ist ein spezialisiertes Modul zur autonomen Verarbeitung von B2B-Anfragen aus verschiedenen Quellen. Sie fungiert als Brücke zwischen dem E-Mail-Postfach und dem **Company Explorer**, um innerhalb von Minuten hochgradig personalisierte Antwort-Entwürfe auf "Human Expert Level" zu generieren.
@@ -29,18 +29,21 @@ Die **Lead Engine** ist ein spezialisiertes Modul zur autonomen Verarbeitung von
* **Status-Tracking:** Visueller Indikator (🆕/✅) für den Synchronisations-Status mit dem Company Explorer.
* **Low-Quality-Warnung:** Visuelle Kennzeichnung (⚠️) von Leads mit Free-Mail-Adressen oder ohne Firmennamen direkt in der Übersicht.
### 6. Trading Twins Autopilot (NEU v2.0)
### 6. Trading Twins Autopilot (PRODUKTIV v2.0)
Der vollautomatische "Zero Touch" Workflow für Trading Twins Anfragen.
* **Human-in-the-Loop:** Vor Versand erhält Elizabeta Melcer eine Teams-Nachricht ("Approve/Deny").
* **5-Minuten-Timeout:** Erfolgt keine Reaktion, wird die E-Mail automatisch versendet.
* **Smart Calendar:**
* **Faktor-3-Überbuchung:** Termine werden bis zu 3x parallel angeboten, um den Kalender dicht zu füllen.
* **Soft-Blocking:** Interne Datenbank verhindert Doppelbuchungen über den Faktor 3 hinaus.
* **Human-in-the-Loop:** Vor Versand erhält Elizabeta Melcer eine Teams-Nachricht ("Approve/Deny") via Adaptive Card.
* **Feedback-Server:** Ein integrierter FastAPI-Server (Port 8004) verarbeitet die Klicks aus Teams und gibt sofortiges visuelles Feedback.
* **Direct Calendar Booking (Eigener Service):**
* **Problem:** MS Bookings API lässt sich nicht per Application Permission steuern (Erstellung verboten).
* **Lösung:** Wir haben einen eigenen Micro-Booking-Service gebaut.
* **Ablauf:** Das System prüft echte freie Slots im Kalender von `e.melcer` (via Graph API).
* **E-Mail:** Der Kunde erhält eine E-Mail mit zwei konkreten Terminvorschlägen (Links).
* **Buchung:** Klick auf einen Link -> Server bestätigt -> **Echte Outlook-Kalendereinladung** wird automatisch von `info@` versendet.
* **Technologie:**
* **Teams Webhook:** Für interaktive "Adaptive Cards".
* **Graph API:** Für sicheren E-Mail-Versand (statt SMTP).
* **Orchestrator:** Steuert den Ablauf (Lead -> CE -> Teams -> Timer -> Mail).
* **Graph API:** Für E-Mail-Versand (`info@`) und Kalender-Check (`e.melcer`).
* **Orchestrator (`manager.py`):** Steuert den Ablauf (Lead -> CE -> Teams -> Timer -> Mail -> Booking).
## 🏗 Architektur
@@ -50,44 +53,65 @@ Der vollautomatische "Zero Touch" Workflow für Trading Twins Anfragen.
├── trading_twins_ingest.py # E-Mail Importer (Graph API)
├── monitor.py # Monitor + Trigger für Orchestrator
├── trading_twins/ # [NEU] Autopilot Modul
│ ├── orchestrator.py # Prozess-Steuerung (Timer, Logic)
│ ├── manager.py # Slot-Logik & DB-Zugriff
── teams_notification.py# Teams Webhook Integration
│ ├── email_sender.py # Graph API Mailer
│ ├── api_server.py # Feedback-Endpunkt (Port 8004)
│ └── models.py # SQLite DB für Jobs/Slots
│ ├── manager.py # Orchestrator, FastAPI Server, Graph API Logic
│ ├── signature.html # HTML-Signatur für E-Mails
── debug_bookings_only.py # Diagnose-Tool (Legacy)
├── db.py # Lokale Lead-Datenbank
└── data/ # DB-Storage
```
## 🚨 Lessons Learned & Troubleshooting (Critical)
### 1. Microsoft Bookings API Falle
* **Problem:** Wir wollten `Bookings.Manage.All` nutzen, um eine Buchungsseite für `info@` zu erstellen.
* **Fehler:** `403 Forbidden` ("Api Business.Create does not support the token type: App") und `500 Internal Server Error` (bei `GET`).
* **Erkenntnis:** Eine App (Service Principal) kann zwar Bookings *verwalten*, aber **nicht initial erstellen**. Die erste Seite muss zwingend manuell oder per Delegated-User angelegt werden. Zudem erfordert der Zugriff oft eine User-Lizenz, die Service Principals nicht haben.
* **Lösung:** Umstieg auf **Direct Calendar Booking** (Graph API `Calendar.ReadWrite`). Wir schreiben Termine direkt in den Outlook-Kalender, statt über die Bookings-Schicht zu gehen. Das ist robuster und voll automatisierbar.
### 2. Zwei Azure Apps für Sicherheit
Wir nutzen zwei getrennte App-Registrierungen, um "Least Privilege" zu wahren:
* **App 1 (`INFO_...`):** Hat Schreibrechte (`Mail.Send`, `Calendars.ReadWrite`) für das `info@robo-planet.de` Postfach. Sie sendet E-Mails und erstellt die Termine.
* **App 2 (`CAL_...`):** Hat **nur** Leserechte (`Calendars.ReadBasic.All`) für den Kalender von `e.melcer@robo-planet.de`. Sie wird genutzt, um Konflikte zu prüfen, darf aber nichts ändern oder E-Mails lesen.
### 3. Docker Networking & Public URLs
* **Problem:** Links in Teams-Nachrichten zeigten auf `http://lead-engine:8004` (interner Docker-Name) und waren von außen nicht erreichbar.
* **Lösung:** Die URL muss immer die **öffentliche, vom Nginx-Proxy geroutete URL** sein (`https://floke-ai.duckdns.org/feedback`).
* **Konfiguration:** Nginx leitet `/feedback/` an Port 8004 des `lead-engine` Containers weiter.
## 🚀 Inbetriebnahme (Docker)
Die Lead Engine ist als Service in der zentralen `docker-compose.yml` integriert.
```bash
# Neustart des Dienstes nach Code-Änderungen
docker-compose restart lead-engine
docker-compose up -d --build --force-recreate lead-engine
```
**Zugriff:** `https://floke-ai.duckdns.org/lead/` (Passwortgeschützt)
**API Feedback Loop:** Port 8004 (intern).
**Feedback API:** `https://floke-ai.duckdns.org/feedback/` (Öffentlich)
## 📝 ToDos & Integration (Status: Warten auf IT)
## 📝 Credentials (.env)
Die Logik ist implementiert und getestet ("Dry Run"). Für den Go-Live fehlen folgende Credentials in der `.env`:
Für den Betrieb sind folgende Variablen in der zentralen `.env` zwingend erforderlich:
1. **Teams Webhook:**
* Benötigt: URL für den "Incoming Webhook" Connector.
* Env-Var: `TEAMS_WEBHOOK_URL`
```env
# App 1: Info-Postfach (Schreiben)
INFO_Application_ID=...
INFO_Tenant_ID=...
INFO_Secret=...
2. **Microsoft Graph API:**
* Benötigt: App Registration mit `Mail.Send` und `Calendars.Read`.
* Env-Vars: `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID`.
# App 2: E.Melcer Kalender (Lesen)
CAL_APPID=...
CAL_TENNANT_ID=...
CAL_SECRET=...
3. **Assets:**
* [ ] Banner-Bild `RoboPlanetBannerWebinarEinladung.png` nach `/app/lead-engine/trading_twins/` hochladen.
* [ ] HTML-Signatur in `/app/lead-engine/trading_twins/signature.html` finalisieren.
# Teams
TEAMS_WEBHOOK_URL=...
# Public URL
FEEDBACK_SERVER_BASE_URL=https://floke-ai.duckdns.org/feedback
```
---
*Dokumentationsstand: 4. März 2026*
*Task: [31988f42]*
*Dokumentationsstand: 5. März 2026*
*Task: [31988f42]*

View File

@@ -1,133 +1,241 @@
import datetime
from sqlalchemy import create_engine, func
from sqlalchemy.orm import sessionmaker
from .models import ProposalJob, ProposedSlot, Base
import uuid
# lead-engine/trading_twins/manager.py
from email.mime.text import MIMEText
import base64
import requests
import json
import os
import time
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from threading import Thread, Lock
import uvicorn
from fastapi import FastAPI, Response
import msal
# Konfiguration
DB_PATH = 'sqlite:///trading_twins/trading_twins.db'
MAX_PROPOSALS_PER_SLOT = 3 # Aggressiver Faktor 3
# --- Zeitzonen-Konfiguration ---
TZ_BERLIN = ZoneInfo("Europe/Berlin")
class TradingTwinsManager:
def __init__(self, db_path=DB_PATH):
self.engine = create_engine(db_path)
self.Session = sessionmaker(bind=self.engine)
Base.metadata.create_all(self.engine)
# --- Konfiguration ---
TEAMS_WEBHOOK_URL = os.getenv("TEAMS_WEBHOOK_URL", "https://wacklergroup.webhook.office.com/webhookb2/fe728cde-790c-4190-b1d3-be393ca0f9bd@6d85a9ef-3878-420b-8f43-38d6cb12b665/IncomingWebhook/e9a8ee6157594a6cab96048cf2ea2232/d26033cd-a81f-41a6-8cd2-b4a3ba0b5a01/V2WFmjcbkMzSU4f6lDSdUOM9VNm7F7n1Th4YDiu3fLZ_Y1")
# Öffentliche URL für Feedback-Links
FEEDBACK_SERVER_BASE_URL = os.getenv("FEEDBACK_SERVER_BASE_URL", "https://floke-ai.duckdns.org/feedback")
DEFAULT_WAIT_MINUTES = 5
SENDER_EMAIL = os.getenv("SENDER_EMAIL", "info@robo-planet.de")
TEST_RECEIVER_EMAIL = "floke.com@gmail.com" # Für E2E Tests
SIGNATURE_FILE_PATH = "/app/trading_twins/signature.html"
def create_proposal_job(self, customer_email, customer_name, customer_company):
"""Erstellt einen neuen Job, sucht Slots und speichert alles."""
session = self.Session()
try:
# 1. Freie Slots finden (Mock für jetzt)
# Später: real_slots = self.fetch_calendar_availability()
candidate_slots = self._mock_calendar_availability()
# 2. Beste Slots auswählen (mit Overbooking-Check)
selected_slots = self._select_best_slots(session, candidate_slots)
if not selected_slots:
# Fallback: Wenn alles "voll" ist (sehr unwahrscheinlich bei Faktor 3),
# nehmen wir trotzdem den am wenigsten gebuchten Slot.
selected_slots = candidate_slots[:2]
# Credentials für die Haupt-App (E-Mail & Kalender info@)
AZURE_CLIENT_ID = os.getenv("INFO_Application_ID")
AZURE_CLIENT_SECRET = os.getenv("INFO_Secret")
AZURE_TENANT_ID = os.getenv("INFO_Tenant_ID")
# 3. Job anlegen
job_uuid = str(uuid.uuid4())
new_job = ProposalJob(
job_uuid=job_uuid,
customer_email=customer_email,
customer_name=customer_name,
customer_company=customer_company,
status='pending'
)
session.add(new_job)
session.flush() # ID generieren
# Credentials für die Kalender-Lese-App (e.melcer)
CAL_APPID = os.getenv("CAL_APPID")
CAL_SECRET = os.getenv("CAL_SECRET")
CAL_TENNANT_ID = os.getenv("CAL_TENNANT_ID")
# 4. Slots speichern
for slot in selected_slots:
new_slot = ProposedSlot(
job_id=new_job.id,
start_time=slot['start'],
end_time=slot['end']
)
session.add(new_slot)
GRAPH_API_ENDPOINT = "https://graph.microsoft.com/v1.0"
session.commit()
return new_job.job_uuid, selected_slots
# --- In-Memory-Speicher ---
# Wir speichern hier Details zu jeder Anfrage, um beim Klick auf den Slot reagieren zu können.
request_status_storage = {}
_lock = Lock()
except Exception as e:
session.rollback()
raise e
finally:
session.close()
# --- Auth Helper ---
def get_access_token(client_id, client_secret, tenant_id):
if not all([client_id, client_secret, tenant_id]):
return None
authority = f"https://login.microsoftonline.com/{tenant_id}"
app = msal.ConfidentialClientApplication(client_id=client_id, authority=authority, client_credential=client_secret)
result = app.acquire_token_silent(["https://graph.microsoft.com/.default"], account=None)
if not result:
result = app.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"])
return result.get('access_token')
def _select_best_slots(self, session, candidate_slots):
"""Wählt Slots aus, die noch nicht 'voll' sind (Faktor 3)."""
valid_slots = []
# Wir betrachten nur Vorschläge der letzten 24h als "aktiv"
yesterday = datetime.datetime.now() - datetime.timedelta(days=1)
# --- KALENDER LOGIK ---
for slot in candidate_slots:
# Wie oft wurde dieser Start-Zeitpunkt in den letzten 24h vorgeschlagen?
count = session.query(func.count(ProposedSlot.id)).filter(ProposedSlot.start_time == slot['start']).filter(ProposedSlot.job.has(ProposalJob.created_at >= yesterday)).scalar()
if count < MAX_PROPOSALS_PER_SLOT:
valid_slots.append(slot)
if len(valid_slots) >= 2:
def get_availability(target_email: str, app_creds: tuple) -> tuple:
"""Holt die Verfügbarkeit für eine E-Mail über die angegebene App."""
token = get_access_token(*app_creds)
if not token: return None
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json", "Prefer": 'outlook.timezone="Europe/Berlin"'}
start_time = datetime.now(TZ_BERLIN).replace(minute=0, second=0, microsecond=0)
if start_time.hour >= 17: start_time += timedelta(days=1); start_time = start_time.replace(hour=8)
end_time = start_time + timedelta(days=3)
payload = {
"schedules": [target_email],
"startTime": {"dateTime": start_time.strftime("%Y-%m-%dT%H:%M:%S"), "timeZone": "Europe/Berlin"},
"endTime": {"dateTime": end_time.strftime("%Y-%m-%dT%H:%M:%S"), "timeZone": "Europe/Berlin"},
"availabilityViewInterval": 60
}
try:
response = requests.post(f"{GRAPH_API_ENDPOINT}/users/{target_email}/calendar/getSchedule", headers=headers, json=payload)
if response.status_code == 200:
view = response.json()['value'][0].get('availabilityView', '')
return start_time, view, 60
except: pass
return None
def find_slots(start_time, view, interval) -> list:
"""Findet zwei freie Slots (Vormittag, Nachmittag)."""
slots = []
# 1. Zeitnah
for i, char in enumerate(view):
t = start_time + timedelta(minutes=i * interval)
if 9 <= t.hour < 12 and char == '0' and t.weekday() < 5:
slots.append(t); break
# 2. Nachmittag
for i, char in enumerate(view):
t = start_time + timedelta(minutes=i * interval)
if 14 <= t.hour <= 16 and char == '0' and t.weekday() < 5:
if not slots or t.day != slots[0].day or t.hour != slots[0].hour:
slots.append(t); break
return slots
def create_calendar_invite(lead_email: str, company_name: str, start_time: datetime):
"""Sendet eine echte Outlook-Kalendereinladung von info@ an den Lead."""
print(f"INFO: Creating calendar invite for {lead_email} at {start_time}")
if not AZURE_CLIENT_ID:
print("CRITICAL: AZURE_CLIENT_ID not set.")
return False
token = get_access_token(AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)
if not token:
print("CRITICAL: Could not get token for calendar invite.")
return False
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
end_time = start_time + timedelta(minutes=15)
event_payload = {
"subject": f"Kennenlerngespräch RoboPlanet <> {company_name}",
"body": {"contentType": "HTML", "content": "Vielen Dank für die Terminbuchung. Wir freuen uns auf das Gespräch!"},
"start": {"dateTime": start_time.strftime("%Y-%m-%dT%H:%M:%S"), "timeZone": "Europe/Berlin"},
"end": {"dateTime": end_time.strftime("%Y-%m-%dT%H:%M:%S"), "timeZone": "Europe/Berlin"},
"location": {"displayName": "Microsoft Teams / Telefon"},
"attendees": [{"emailAddress": {"address": lead_email, "name": "Interessent"}, "type": "required"}]
}
url = f"{GRAPH_API_ENDPOINT}/users/{SENDER_EMAIL}/calendar/events"
try:
resp = requests.post(url, headers=headers, json=event_payload)
if resp.status_code in [200, 201]:
print("SUCCESS: Calendar event created.")
return True
else:
print(f"ERROR: Failed to create event. HTTP {resp.status_code}")
print(f"Response: {resp.text}")
return False
except Exception as e:
print(f"EXCEPTION during event creation: {e}")
return False
# --- E-MAIL & WEB LOGIK ---
def generate_booking_html(request_id: str, suggestions: list) -> str:
html = "<p>Bitte wählen Sie einen passenden Termin für ein 15-minütiges Kennenlerngespräch:</p><ul>"
for slot in suggestions:
ts = int(slot.timestamp())
# Link zu unserem eigenen Bestätigungs-Endpunkt
link = f"{FEEDBACK_SERVER_BASE_URL}/book_slot/{request_id}/{ts}"
html += f'<li><a href="{link}" style="font-weight: bold; color: #0078d4;">{slot.strftime("%d.%m. um %H:%M Uhr")}</a></li>'
html += "</ul><p>Mit Klick auf einen Termin wird automatisch eine Kalendereinladung an Sie versendet.</p>"
return html
# --- Server & API ---
app = FastAPI()
@app.get("/stop/{request_id}")
async def stop(request_id: str):
with _lock:
if request_id in request_status_storage:
request_status_storage[request_id]["status"] = "cancelled"
return Response("<html><body><h1>Versand gestoppt.</h1></body></html>", media_type="text/html")
return Response("Ungültig.", status_code=404)
@app.get("/send_now/{request_id}")
async def send_now(request_id: str):
with _lock:
if request_id in request_status_storage:
request_status_storage[request_id]["status"] = "send_now"
return Response("<html><body><h1>E-Mail wird sofort versendet.</h1></body></html>", media_type="text/html")
return Response("Ungültig.", status_code=404)
@app.get("/book_slot/{request_id}/{ts}")
async def book_slot(request_id: str, ts: int):
slot_time = datetime.fromtimestamp(ts, tz=TZ_BERLIN)
with _lock:
data = request_status_storage.get(request_id)
if not data: return Response("Anfrage nicht gefunden.", status_code=404)
if data.get("booked"): return Response("<html><body><h1>Termin wurde bereits bestätigt.</h1></body></html>", media_type="text/html")
data["booked"] = True
# Einladung senden
success = create_calendar_invite(data['receiver'], data['company'], slot_time)
if success:
return Response(f"<html><body><h1>Vielen Dank!</h1><p>Die Einladung für den <b>{slot_time.strftime('%d.%m. um %H:%M')}</b> wurde an {data['receiver']} versendet.</p></body></html>", media_type="text/html")
return Response("Fehler beim Erstellen des Termins.", status_code=500)
# --- Haupt Workflow ---
def send_email(subject, body, to_email, signature):
token = get_access_token(AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
payload = {"message": {"subject": subject, "body": {"contentType": "HTML", "content": body + signature}, "toRecipients": [{"emailAddress": {"address": to_email}}]}, "saveToSentItems": "true"}
requests.post(f"{GRAPH_API_ENDPOINT}/users/{SENDER_EMAIL}/sendMail", headers=headers, json=payload)
def process_lead(request_id: str, company: str, opener: str, receiver: str):
# 1. Freie Slots finden (Check bei e.melcer UND info)
print(f"INFO: Searching slots for {company}...")
# Wir nehmen hier e.melcer als Referenz für die Zeit
cal_data = get_availability("e.melcer@robo-planet.de", (CAL_APPID, CAL_SECRET, CAL_TENNANT_ID))
suggestions = find_slots(*cal_data) if cal_data else []
with _lock:
request_status_storage[request_id] = {"status": "pending", "company": company, "receiver": receiver, "slots": suggestions}
# 2. Teams Notification
send_time = datetime.now(TZ_BERLIN) + timedelta(minutes=DEFAULT_WAIT_MINUTES)
card = {
"type": "message", "attachments": [{"contentType": "application/vnd.microsoft.card.adaptive", "content": {
"type": "AdaptiveCard", "version": "1.4", "body": [
{"type": "TextBlock", "text": f"🤖 E-Mail an {company} ({receiver}) geplant für {send_time.strftime('%H:%M')}", "weight": "Bolder"},
{"type": "TextBlock", "text": f"Vorgeschlagene Slots: {', '.join([s.strftime('%H:%M') for s in suggestions])}", "isSubtle": True}
],
"actions": [
{"type": "Action.OpenUrl", "title": "❌ STOP", "url": f"{FEEDBACK_SERVER_BASE_URL}/stop/{request_id}"},
{"type": "Action.OpenUrl", "title": "✅ JETZT", "url": f"{FEEDBACK_SERVER_BASE_URL}/send_now/{request_id}"}
]
}}]
}
requests.post(TEAMS_WEBHOOK_URL, json=card)
# 3. Warten
while datetime.now(TZ_BERLIN) < send_time:
with _lock:
if request_status_storage[request_id]["status"] in ["cancelled", "send_now"]:
break
return valid_slots
time.sleep(5)
# 4. Senden
with _lock:
if request_status_storage[request_id]["status"] == "cancelled": return
print(f"INFO: Sending lead email to {receiver}...")
booking_html = generate_booking_html(request_id, suggestions)
with open(SIGNATURE_FILE_PATH, 'r') as f: sig = f.read()
body = f"<p>Sehr geehrte Damen und Herren,</p><p>{opener}</p>{booking_html}"
send_email(f"Ihr Kontakt mit RoboPlanet - {company}", body, receiver, sig)
def _mock_calendar_availability(self):
"""Simuliert freie Termine für morgen."""
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
# Ein Slot Vormittags (10:30), einer Nachmittags (14:00)
return [
{
'start': datetime.datetime.combine(tomorrow, datetime.time(10, 30)),
'end': datetime.datetime.combine(tomorrow, datetime.time(11, 15))
},
{
'start': datetime.datetime.combine(tomorrow, datetime.time(14, 0)),
'end': datetime.datetime.combine(tomorrow, datetime.time(14, 45))
}
]
def get_job_status(self, job_uuid):
session = self.Session()
job = session.query(ProposalJob).filter_by(job_uuid=job_uuid).first()
status = job.status if job else None
session.close()
return status
def get_job_details(self, job_uuid):
"""Holt alle Details zu einem Job inklusive der Slots."""
session = self.Session()
job = session.query(ProposalJob).filter_by(job_uuid=job_uuid).first()
if not job:
session.close()
return None
# Wir müssen die Daten extrahieren, bevor die Session geschlossen wird
details = {
'uuid': job.job_uuid,
'email': job.customer_email,
'name': job.customer_name,
'company': job.customer_company,
'status': job.status,
'slots': [{'start': s.start_time, 'end': s.end_time} for s in job.slots]
}
session.close()
return details
def update_job_status(self, job_uuid, new_status):
session = self.Session()
job = session.query(ProposalJob).filter_by(job_uuid=job_uuid).first()
if job:
job.status = new_status
if new_status == 'approved':
job.approved_at = datetime.datetime.now()
session.commit()
session.close()
if __name__ == "__main__":
Thread(target=lambda: uvicorn.run(app, host="0.0.0.0", port=8004), daemon=True).start()
time.sleep(2)
# E2E Test
process_lead(f"req_{int(time.time())}", "Testfirma GmbH", "Wir haben Ihre Anfrage erhalten.", TEST_RECEIVER_EMAIL)
print("\nIdle. Press Ctrl+C.")
try:
while True: time.sleep(1)
except: pass

View File

@@ -1,27 +1,40 @@
<br>
<div style="font-family: Arial, sans-serif; font-size: 14px; color: #333;">
<p>Freundliche Grüße<br>
<strong>Elizabeta Melcer</strong><br>
Inside Sales Managerin</p>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>E-Mail Signatur</title>
</head>
<body>
<!--
HINWEIS:
Dieser Inhalt wird von der IT-Abteilung bereitgestellt.
Bitte den finalen HTML-Code hier einfügen.
Das Bild 'RoboPlanetBannerWebinarEinladung.png' muss sich im selben Verzeichnis befinden.
[31988f42]
-->
<p>Freundliche Grüße</p>
<p>
<strong>RoboPlanet GmbH</strong><br>
Schatzbogen 39, 81829 München<br>
T: +49 89 420490-402 | M: +49 175 8334071<br>
<a href="mailto:e.melcer@robo-planet.de">e.melcer@robo-planet.de</a> | <a href="http://www.robo-planet.de">www.robo-planet.de</a>
<b>Elizabeta Melcer</b><br>
Inside Sales Managerin
</p>
<p>
<a href="#">LinkedIn</a> | <a href="#">Instagram</a> | <a href="#">Newsletteranmeldung</a>
<!-- Wackler Logo -->
<b>RoboPlanet GmbH</b><br>
Schatzbogen 39, 81829 München<br>
T: +49 89 420490-402 | M: +49 175 8334071<br>
<a href="mailto:e.melcer@robo-planet.de">e.melcer@robo-planet.de</a> | <a href="http://www.robo-planet.de">www.robo-planet.de</a>
</p>
<p style="font-size: 10px; color: #777;">
Sitz der Gesellschaft München | Geschäftsführung: Axel Banoth<br>
Registergericht AG München, HRB 296113 | USt.-IdNr. DE400464410<br>
<a href="#">Hinweispflichten zum Datenschutz</a>
<p>
<a href="#">LinkedIn</a> | <a href="#">Instagram</a> | <a href="#">Newsletteranmeldung</a>
</p>
<!-- Platzhalter für das Bild -->
<img src="https://robo-planet.de/wp-content/uploads/2024/01/RoboPlanet_Logo.png" alt="RoboPlanet Logo" width="150"><br>
<img src="cid:banner_image" alt="Webinar Einladung" width="400">
</div>
<p style="font-size: smaller; color: grey;">
Sitz der Gesellschaft München | Geschäftsführung: Axel Banoth<br>
Registergericht AG München, HRB 296113 | USt.-IdNr. DE400464410<br>
<a href="#">Hinweispflichten zum Datenschutz</a>
</p>
<p>
<img src="RoboPlanetBannerWebinarEinladung.png" alt="RoboPlanet Webinar Einladung">
</p>
</body>
</html>

View File

@@ -0,0 +1,50 @@
import requests
import json
import os
def send_teams_message(webhook_url, message):
"""
Sends a simple message to a Microsoft Teams channel using a webhook.
Args:
webhook_url (str): The URL of the incoming webhook.
message (str): The plain text message to send.
Returns:
bool: True if the message was sent successfully (HTTP 200), False otherwise.
"""
if not webhook_url:
print("Error: TEAMS_WEBHOOK_URL is not set.")
return False
headers = {
"Content-Type": "application/json"
}
payload = {
"text": message
}
try:
response = requests.post(webhook_url, headers=headers, data=json.dumps(payload), timeout=10)
if response.status_code == 200:
print("Message sent successfully to Teams.")
return True
else:
print(f"Failed to send message. Status code: {response.status_code}")
print(f"Response: {response.text}")
return False
except requests.exceptions.RequestException as e:
print(f"An error occurred while sending the request: {e}")
return False
if __name__ == "__main__":
# The webhook URL is taken directly from the project description for this test.
# In a real application, this should be loaded from an environment variable.
webhook_url = "https://wacklergroup.webhook.office.com/webhookb2/fe728cde-790c-4190-b1d3-be393ca0f9bd@6d85a9ef-3878-420b-8f43-38d6cb12b665/IncomingWebhook/e9a8ee6157594a6cab96048cf2ea2232/d26033cd-a81f-41a6-8cd2-b4a3ba0b5a01/V2WFmjcbkMzSU4f6lDSdUOM9VNm7F7n1Th4YDiu3fLZ_Y1"
test_message = "🤖 This is a test message from the Gemini Trading Twins Engine. If you see this, the webhook is working. [31988f42]"
send_teams_message(webhook_url, test_message)

View File

@@ -166,6 +166,14 @@ http {
proxy_read_timeout 86400; # Long timeout for stream
}
location /feedback/ {
# Public endpoint for Teams Feedback actions
auth_basic off; # Must be public for external links
proxy_pass http://lead-engine:8004/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /connector/ {
# SuperOffice Connector Webhook & Dashboard
auth_basic off;