diff --git a/docker-compose.yml b/docker-compose.yml index 05101a4d..d15588e3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/lead-engine/Dockerfile b/lead-engine/Dockerfile index b3b47e47..91e0d9ad 100644 --- a/lead-engine/Dockerfile +++ b/lead-engine/Dockerfile @@ -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"] + diff --git a/lead-engine/README.md b/lead-engine/README.md index 4e8db849..fbbf34c5 100644 --- a/lead-engine/README.md +++ b/lead-engine/README.md @@ -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]* \ No newline at end of file +*Dokumentationsstand: 5. März 2026* +*Task: [31988f42]* diff --git a/lead-engine/trading_twins/manager.py b/lead-engine/trading_twins/manager.py index a7ede8fd..6410b6e1 100644 --- a/lead-engine/trading_twins/manager.py +++ b/lead-engine/trading_twins/manager.py @@ -1,216 +1,241 @@ # 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 + +# --- Zeitzonen-Konfiguration --- +TZ_BERLIN = ZoneInfo("Europe/Berlin") # --- Konfiguration --- -# In einer echten Anwendung würden diese Werte aus .env-Dateien oder einer Config-Map geladen -TEAMS_WEBHOOK_URL = "https://wacklergroup.webhook.office.com/webhookb2/fe728cde-790c-4190-b1d3-be393ca0f9bd@6d85a9ef-3878-420b-8f43-38d6cb12b665/IncomingWebhook/e9a8ee6157594a6cab96048cf2ea2232/V2WFmjcbkMzSU4f6lDSdUOM9VNm7F7n1Th4YDiu3fLZ_Y1" -FEEDBACK_SERVER_BASE_URL = "http://localhost:8004" # TODO: Muss durch die öffentliche IP/Domain ersetzt werden +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" -# --- In-Memory-Speicher für den Status der Anfragen --- -# In einem Produktionsszenario wäre hier eine robustere Lösung wie Redis oder eine DB nötig. +# 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") + +# 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") + +GRAPH_API_ENDPOINT = "https://graph.microsoft.com/v1.0" + +# --- 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() -# --- Modul zur Erstellung von Adaptive Cards --- +# --- 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 create_adaptive_card_payload(customer_name: str, send_time: datetime, request_id: str) -> dict: - """ - Erstellt die JSON-Payload für die Adaptive Card in Teams. - """ - send_time_str = send_time.strftime("%H:%M Uhr") +# --- KALENDER LOGIK --- + +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 - stop_url = f"{FEEDBACK_SERVER_BASE_URL}/stop/{request_id}" - send_now_url = f"{FEEDBACK_SERVER_BASE_URL}/send_now/{request_id}" - - card = { - "type": "message", - "attachments": [ - { - "contentType": "application/vnd.microsoft.card.adaptive", - "content": { - "type": "AdaptiveCard", - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "version": "1.4", - "body": [ - { - "type": "TextBlock", - "text": f"🤖 Automatisierte E-Mail an {customer_name} (via Trading Twins) wird um {send_time_str} ausgesendet.", - "wrap": True, - "size": "Medium", - "weight": "Bolder" - }, - { - "type": "TextBlock", - "text": f"Wenn Du bis {send_time_str} NICHT reagierst, wird die generierte E-Mail automatisch ausgesendet.", - "wrap": True, - "isSubtle": True - } - ], - "actions": [ - { - "type": "Action.OpenUrl", - "title": "❌ STOP Aussendung", - "url": stop_url, - "style": "destructive" - }, - { - "type": "Action.OpenUrl", - "title": "✅ JETZT Aussenden", - "url": send_now_url, - "style": "positive" - } - ] - } - } - ] + 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 } - return card - -# --- Haupt-Workflow-Logik --- - -def send_teams_notification(payload: dict): - """Sendet die vorbereitete Payload an den Teams Webhook.""" try: - response = requests.post(TEAMS_WEBHOOK_URL, json=payload, timeout=10) - if response.status_code == 200 or response.status_code == 202: - print(f"INFO: Adaptive Card sent to Teams. Response: {response.text}") - return True - else: - print(f"ERROR: Failed to send card. Status: {response.status_code}, Text: {response.text}") - return False - except requests.RequestException as e: - print(f"ERROR: Request to Teams failed: {e}") + 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 -def process_email_request(request_id: str, customer_name: str): - """ - Der Hauptprozess, der die Benachrichtigung auslöst und auf das Ergebnis wartet. - """ - send_time = datetime.now() + timedelta(minutes=DEFAULT_WAIT_MINUTES) + 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) - with _lock: - request_status_storage[request_id] = { - "status": "pending", # pending, cancelled, send_now, sent, timeout - "customer": customer_name, - "send_time": send_time.isoformat() - } - - # 1. Adaptive Card erstellen und an Teams senden - adaptive_card = create_adaptive_card_payload(customer_name, send_time, request_id) - if not send_teams_notification(adaptive_card): - print(f"CRITICAL: Could not send Teams notification for request {request_id}. Aborting.") - return - - # 2. Warten auf menschliches Feedback oder Timeout - print(f"INFO: Waiting for feedback for request {request_id} until {send_time.strftime('%H:%M:%S')}...") + 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"}] + } - while datetime.now() < send_time: - with _lock: - current_status = request_status_storage[request_id]["status"] - - if current_status == "cancelled": - print(f"INFO: Request {request_id} was cancelled by the user.") - return - - if current_status == "send_now": - print(f"INFO: Request {request_id} was triggered to send immediately by the user.") - break # Schleife verlassen und sofort senden - - time.sleep(5) + 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 - # 3. Finale Entscheidung und Ausführung - with _lock: - final_status = request_status_storage[request_id]["status"] - # Update status to avoid race conditions - if final_status == "pending": - request_status_storage[request_id]["status"] = "timeout" - final_status = "timeout" +# --- E-MAIL & WEB LOGIK --- +def generate_booking_html(request_id: str, suggestions: list) -> str: + html = "
Bitte wählen Sie einen passenden Termin für ein 15-minütiges Kennenlerngespräch:
Mit Klick auf einen Termin wird automatisch eine Kalendereinladung an Sie versendet.
" + return html - if final_status in ["send_now", "timeout"]: - print(f"SUCCESS: Proceeding to send email for request {request_id} (Status: {final_status})") - # --- HIER KOMMT DIE ECHTE E-MAIL LOGIK (MS GRAPH API) --- - # send_email_via_graph_api(customer_name, signature_path, banner_path) - print("MOCK: Email would be sent now.") - # --------------------------------------------------------- - with _lock: - request_status_storage[request_id]["status"] = "sent" - - else: - # Dieser Fall sollte eigentlich nicht eintreten, aber zur Sicherheit - print(f"WARN: Email for request {request_id} was not sent due to final status: {final_status}") - - -# --- Feedback-Server (FastAPI) --- - +# --- Server & API --- app = FastAPI() @app.get("/stop/{request_id}") -async def stop_sending(request_id: str): +async def stop(request_id: str): with _lock: if request_id in request_status_storage: - if request_status_storage[request_id]["status"] == "pending": - request_status_storage[request_id]["status"] = "cancelled" - customer = request_status_storage[request_id]['customer'] - print(f"INFO: Received STOP for request {request_id}") - return Response(content=f"Der Versand wurde erfolgreich abgebrochen.
", media_type="text/html") - else: - status = request_status_storage[request_id]['status'] - return Response(content=f"Der Status fĂĽr diese Anfrage ist bereits '{status}'. Es kann nicht mehr gestoppt werden.
", media_type="text/html", status_code=409) - - return Response(content="Anfrage-ID nicht gefunden.
", media_type="text/html", status_code=404) - + request_status_storage[request_id]["status"] = "cancelled" + return Response("Der Versand wird sofort ausgelöst.
", media_type="text/html") - else: - status = request_status_storage[request_id]['status'] - return Response(content=f"Der Status fĂĽr diese Anfrage ist bereits '{status}'.
", media_type="text/html", status_code=409) + request_status_storage[request_id]["status"] = "send_now" + return Response("Anfrage-ID nicht gefunden.
", media_type="text/html", 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("Die Einladung fĂĽr den {slot_time.strftime('%d.%m. um %H:%M')} wurde an {data['receiver']} versendet.
", media_type="text/html") + return Response("Fehler beim Erstellen des Termins.", status_code=500) +# --- Haupt Workflow --- -def run_server(): - """Startet den FastAPI-Server.""" - uvicorn.run(app, host="0.0.0.0", port=8004) +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 + 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"Sehr geehrte Damen und Herren,
{opener}
{booking_html}" + send_email(f"Ihr Kontakt mit RoboPlanet - {company}", body, receiver, sig) if __name__ == "__main__": - # Starte den Feedback-Server in einem separaten Thread - server_thread = Thread(target=run_server) - server_thread.daemon = True - server_thread.start() - print("INFO: Feedback-Server started on port 8004 in background.") - time.sleep(2) # Kurz warten, bis der Server gestartet ist - - # Simuliere eine neue Anfrage - test_request_id = f"req_{int(time.time())}" - test_customer = "Klinikum Erding" - - print(f"\n--- Starting new email request for '{test_customer}' with ID: {test_request_id} ---") - process_email_request(test_request_id, test_customer) - print(f"--- Process for {test_request_id} finished. ---") - - # Halte das Hauptprogramm am Leben, damit der Server weiterlaufen kann - # In einer echten Anwendung wäre dies Teil eines größeren Dienstes. - print("\nManager is running. Press Ctrl+C to stop.") + 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 KeyboardInterrupt: - print("\nShutting down manager.") \ No newline at end of file + while True: time.sleep(1) + except: pass \ No newline at end of file diff --git a/nginx-proxy.conf b/nginx-proxy.conf index d7d36b87..ab7f32ec 100644 --- a/nginx-proxy.conf +++ b/nginx-proxy.conf @@ -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;