- 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.
241 lines
11 KiB
Python
241 lines
11 KiB
Python
# 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 ---
|
|
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"
|
|
|
|
# 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()
|
|
|
|
# --- 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')
|
|
|
|
# --- 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
|
|
|
|
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
|
|
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)
|
|
|
|
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 |