diff --git a/lead-engine/README.md b/lead-engine/README.md index fbbf34c5..b7c8edd9 100644 --- a/lead-engine/README.md +++ b/lead-engine/README.md @@ -68,15 +68,12 @@ Der vollautomatische "Zero Touch" Workflow für Trading Twins Anfragen. * **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. +### 4. Exchange AppOnly AccessPolicy +* **Problem:** Trotz globaler `Calendars.ReadWrite` Berechtigung schlug das Erstellen von Terminen im Kalender von `e.melcer@` fehl (`403 Forbidden: Blocked by tenant configured AppOnly AccessPolicy settings`). +* **Erkenntnis:** Viele Organisationen schränken per Policy ein, auf welche Postfächer eine App zugreifen darf. Ein Zugriff auf "fremde" Postfächer ist oft standardmäßig gesperrt. +* **Lösung:** Der Termin wird im **eigenen Kalender** des Service-Accounts (`info@robo-planet.de`) erstellt. Der zuständige Mitarbeiter (`e.melcer@`) wird als **erforderlicher Teilnehmer** hinzugefügt. Dies umgeht die Policy-Sperre und stellt sicher, dass der Mitarbeiter den Termin in seinem Kalender sieht und das Teams-Meeting voll steuern kann. -### 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) ## 🚀 Inbetriebnahme (Docker) diff --git a/lead-engine/trading_twins/manager.py b/lead-engine/trading_twins/manager.py index 6410b6e1..b1b014ce 100644 --- a/lead-engine/trading_twins/manager.py +++ b/lead-engine/trading_twins/manager.py @@ -60,10 +60,11 @@ def get_availability(target_email: str, app_creds: tuple) -> tuple: 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) + # Basis: Heute 00:00 Uhr + start_time = datetime.now(TZ_BERLIN).replace(hour=0, minute=0, second=0, microsecond=0) + end_time = start_time + timedelta(days=3) # 3 Tage Vorschau + payload = { "schedules": [target_email], "startTime": {"dateTime": start_time.strftime("%Y-%m-%dT%H:%M:%S"), "timeZone": "Europe/Berlin"}, @@ -74,60 +75,129 @@ def get_availability(target_email: str, app_creds: tuple) -> tuple: 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', '') + # start_time ist wichtig für die Berechnung in find_slots 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 round_to_next_quarter_hour(dt: datetime) -> datetime: + """Rundet eine Zeit auf die nächste volle Viertelstunde auf.""" + minutes = (dt.minute // 15 + 1) * 15 + rounded = dt.replace(minute=0, second=0, microsecond=0) + timedelta(minutes=minutes) + return rounded + +def find_slots(start_time_base: datetime, view: str, interval: int) -> list: + """ + Findet zwei intelligente Slots basierend auf der Verfügbarkeit. + start_time_base: Der Beginn der availabilityView (meist 00:00 Uhr heute) + """ + suggestions = [] + now = datetime.now(TZ_BERLIN) + + # Frühestmöglicher Termin: Jetzt + 15 Min Puffer, gerundet auf Viertelstunde + earliest_possible = round_to_next_quarter_hour(now + timedelta(minutes=15)) + + def is_slot_free(dt: datetime): + """Prüft, ob der 60-Minuten-Block, der diesen Zeitpunkt enthält, frei ist.""" + # Index in der View berechnen + offset = dt - start_time_base + hours_offset = int(offset.total_seconds() // 3600) + + if 0 <= hours_offset < len(view): + return view[hours_offset] == '0' # '0' bedeutet Free + return False + + # 1. Slot 1: Nächstmöglicher freier Termin + current_search = earliest_possible + while len(suggestions) < 1 and (current_search - now).days < 3: + # Nur Werktags (Mo-Fr), zwischen 09:00 und 17:00 + if current_search.weekday() < 5 and 9 <= current_search.hour < 17: + if is_slot_free(current_search): + suggestions.append(current_search) + break + + # Weiterspringen + current_search += timedelta(minutes=15) + # Wenn wir 17 Uhr erreichen, springe zum nächsten Tag 09:00 + if current_search.hour >= 17: + current_search += timedelta(days=1) + current_search = current_search.replace(hour=9, minute=0) + + if not suggestions: + return [] + + first_slot = suggestions[0] + + # 2. Slot 2: Alternative (Nachmittag oder Folgetag) + # Ziel: 2-3 Stunden später + target_slot_2 = first_slot + timedelta(hours=2.5) + target_slot_2 = round_to_next_quarter_hour(target_slot_2) + + # Suchstart für Slot 2 + current_search = target_slot_2 + + while len(suggestions) < 2 and (current_search - now).days < 4: + # Kriterien für Slot 2: + # - Muss frei sein + # - Muss Werktag sein + # - Bevorzugt Nachmittag (13:00 - 16:30), außer wir sind schon am Folgetag, dann ab 9:00 + + is_working_hours = 9 <= current_search.hour < 17 + is_afternoon = 13 <= current_search.hour < 17 + is_next_day = current_search.date() > first_slot.date() + + # Wir nehmen den Slot, wenn: + # a) Er am selben Tag nachmittags ist + # b) ODER er am nächsten Tag zu einer vernünftigen Zeit ist (falls wir heute zu spät sind) + valid_time = (current_search.date() == first_slot.date() and is_afternoon) or (is_next_day and is_working_hours) + + if current_search.weekday() < 5 and valid_time: + if is_slot_free(current_search): + suggestions.append(current_search) + break + + current_search += timedelta(minutes=15) + if current_search.hour >= 17: + current_search += timedelta(days=1) + current_search = current_search.replace(hour=9, minute=0) + + return suggestions 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}") + """Sendet eine echte Outlook-Kalendereinladung aus dem info@-Kalender.""" + # Wir erstellen den Termin bei info@ (SENDER_EMAIL), da wir dort Schreibrechte haben sollten. + target_organizer = SENDER_EMAIL + print(f"INFO: Creating calendar invite for {lead_email} in {target_organizer}'s calendar") - 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 + if not token: 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!"}, + "body": {"contentType": "HTML", "content": f"Hallo,

vielen Dank für die Terminbuchung über unsere Lead-Engine. Wir freuen uns auf das Gespräch!

Beste Grüße,
RoboPlanet Team"}, "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"}] + "location": {"displayName": "Microsoft Teams Meeting"}, + "attendees": [ + {"emailAddress": {"address": lead_email, "name": "Interessent"}, "type": "required"}, + {"emailAddress": {"address": "e.melcer@robo-planet.de", "name": "Elizabeta Melcer"}, "type": "required"} + ], + "isOnlineMeeting": True, + "onlineMeetingProvider": "teamsForBusiness" } - url = f"{GRAPH_API_ENDPOINT}/users/{SENDER_EMAIL}/calendar/events" + # URL zeigt auf info@ Kalender + url = f"{GRAPH_API_ENDPOINT}/users/{target_organizer}/calendar/events" try: resp = requests.post(url, headers=headers, json=event_payload) if resp.status_code in [200, 201]: - print("SUCCESS: Calendar event created.") + print(f"SUCCESS: Calendar event created for {target_organizer}.") return True else: - print(f"ERROR: Failed to create event. HTTP {resp.status_code}") - print(f"Response: {resp.text}") + print(f"ERROR: Failed to create event. HTTP {resp.status_code}: {resp.text}") return False except Exception as e: print(f"EXCEPTION during event creation: {e}") @@ -231,11 +301,18 @@ def process_lead(request_id: str, company: str, opener: str, receiver: str): send_email(f"Ihr Kontakt mit RoboPlanet - {company}", body, receiver, sig) if __name__ == "__main__": + # Starte den API-Server im Hintergrund Thread(target=lambda: uvicorn.run(app, host="0.0.0.0", port=8004), daemon=True).start() + print("INFO: Trading Twins Feedback Server started on port 8004.") 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.") + + # Optional: E2E Test Lead auslösen + if os.getenv("RUN_TEST_LEAD") == "true": + print("\n--- Running E2E Test Lead ---") + process_lead(f"req_{int(time.time())}", "Testfirma GmbH", "Wir haben Ihre Anfrage erhalten.", TEST_RECEIVER_EMAIL) + + print("\n[PROD] Manager is active and waiting for leads via import or API.") try: while True: time.sleep(1) - except: pass \ No newline at end of file + except KeyboardInterrupt: + print("Shutting down.") \ No newline at end of file