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