4 Commits

4 changed files with 134 additions and 27 deletions

View File

@@ -1,4 +1,12 @@
# GTM Engine - Environment Configuration Template
# ==========================================
# GTM Engine - Environment Configuration
# ==========================================
# E-Mail Generation & Lead Engine
FEEDBACK_SERVER_BASE_URL=https://floke-ai.duckdns.org/feedback
WORDPRESS_BOOKING_URL=https://www.robo-planet.de/terminbestaetigung
MS_BOOKINGS_URL=https://outlook.office365.com/owa/calendar/IHR_BOOKINGS_NAME@robo-planet.de/bookings/
# Copy this file to .env and fill in the actual values.
# --- Core API Keys ---

View File

@@ -244,6 +244,8 @@ services:
CAL_TENNANT_ID: "${CAL_TENNANT_ID}"
TEAMS_WEBHOOK_URL: "${TEAMS_WEBHOOK_URL}"
FEEDBACK_SERVER_BASE_URL: "${FEEDBACK_SERVER_BASE_URL}"
WORDPRESS_BOOKING_URL: "${WORDPRESS_BOOKING_URL}"
MS_BOOKINGS_URL: "${MS_BOOKINGS_URL}"
volumes:
- ./lead-engine:/app
- lead_engine_data:/app/data

View File

@@ -63,9 +63,13 @@ Der vollautomatische "Zero Touch" Workflow für Trading Twins Anfragen.
* **Problem:** Eine statische Signatur in der Konfiguration war unflexibel und konnte keine Bilder enthalten.
* **Lösung:** Ein Skript (`scripts/extract_signature_assets.py`) extrahiert die vollständige HTML-Signatur und alle eingebetteten Bilder aus einer `.eml`-Datei. Die `send_email`-Funktion wurde überarbeitet, um alle Bilder dynamisch als Inline-Anhänge zu versenden, was eine professionelle Darstellung sicherstellt.
### 4. Advanced Debugging & Fehlerbehebung
* **Problem:** Hintergrund-Tasks schlugen ohne klare Fehlermeldung fehl, was die Diagnose erschwerte.
* **Lösung:** Umfassendes Logging wurde in allen kritischen Funktionen implementiert. Dadurch konnten Fehler wie ein `NameError` bei der Datumsformatierung und die `TimeZoneNotSupportedException` schnell identifiziert und behoben werden.
### 4. Race-Condition-Schutz bei Überbuchung (Live-Check)
* **Problem:** Wenn mehrere Leads E-Mails mit denselben Terminvorschlägen erhalten, konnten Doppelbuchungen entstehen.
* **Lösung:** Implementierung eines "Live-Checks" im Feedback-Server. Bevor ein Termin gebucht wird, prüft das System in Echtzeit (`is_slot_free`), ob der Slot im Kalender von `e.melcer@` noch verfügbar ist. Ist er belegt, wird die Buchung abgebrochen und ein Fallback-Szenario aktiviert.
### 5. Seamless Website Integration (WordPress iFrame)
* **Problem:** Die API-Endpoints gaben nackten Text zurück, was für Kunden unprofessionell wirkte.
* **Lösung:** Der Server liefert nun saubere HTML-Snippets zurück. Durch Setzen der `WORDPRESS_BOOKING_URL` in der `.env` führen die Links in der Kunden-E-Mail direkt auf die Kunden-Website. Dort wird das HTML-Ergebnis (Erfolg inkl. Teams-Link ODER Fallback zum Microsoft Bookings Kalender bei Doppelbuchung) unsichtbar in einem iFrame geladen.
## 🚀 Inbetriebnahme & Test
@@ -83,25 +87,9 @@ docker-compose up -d --build --force-recreate lead-engine
## 📝 Zukünftige Erweiterungen & Todos
### Task: Race-Condition-Schutz bei Überbuchung
* **Problem:** Wenn mehrere Leads E-Mails mit denselben Terminvorschlägen erhalten, kann es zu "Race Conditions" kommen, bei denen mehrere Personen denselben Slot fast zeitgleich buchen.
* **Lösung:** Implementierung eines "Live-Checks" im Feedback-Server.
1. **Trigger:** Ein Nutzer klickt auf einen Buchungslink.
2. **Aktion:** Bevor der Termin im Kalender erstellt wird, sendet der Server eine *erneute* `getSchedule`-Anfrage an die Graph API für exakt diesen Zeit-Slot.
3. **Logik:**
* **Slot frei:** Der Termin wird wie geplant gebucht und der Job-Status auf `booked` gesetzt.
* **Slot belegt:** Der Nutzer erhält eine freundliche Nachricht ("Dieser Termin wurde gerade vergeben."). Idealerweise werden ihm dynamisch zwei neue, freie Termine vorgeschlagen, die er direkt auf der Seite buchen kann.
* **Ziel:** Sicherstellen, dass der Kalender die "Single Source of Truth" ist und doppelte Buchungen zuverlässig verhindert werden.
### Task: Integration der Buchungs-Seiten in WordPress
* **Ziel:** Eine nahtlose User Experience schaffen, bei der Termin-Bestätigungen auf der Haupt-Website (`robo-planet.de`) statt auf der direkten API-URL angezeigt werden.
* **Phase 1 (Kurzfristig): Einbettung via iFrame**
* **Umsetzung:** Eine Seite in WordPress anlegen und die URL des Feedback-Servers (z.B. `https://floke-ai.duckdns.org/feedback/book_slot/...`) in einem iFrame laden.
* **Vorteil:** Kein Programmieraufwand auf unserer Seite nötig, sofort umsetzbar.
* **Phase 2 (Langfristig): Native API-Integration**
* **Umsetzung:** Die Links in der E-Mail führen direkt zu einer WordPress-Seite (z.B. `robo-planet.de/termin-bestaetigen`). Ein Skript auf dieser Seite ruft im Hintergrund unsere `/book_slot` API auf.
* **Vorteil:** Perfekte Integration ins Corporate Design, volle Kontrolle über die Erfolgs- und Fehlermeldungen. Die API ist dafür bereits ausgelegt.
### Task: Automatisches Nachfassen (Follow-up)
* **Problem:** Wenn ein Lead nicht auf die E-Mail antwortet und auch keinen Termin bucht, geht der Kontakt verloren.
* **Lösung:** Einbindung eines Follow-up-Mechanismus nach 5 Tagen. Dies könnte entweder durch ein Flag im CRM-System oder durch eine geplante E-Mail direkt über die Lead-Engine realisiert werden.
```env
# Info-Postfach (App 1 - Schreiben)
@@ -117,4 +105,6 @@ CAL_SECRET=...
# URLs
TEAMS_WEBHOOK_URL=...
FEEDBACK_SERVER_BASE_URL=https://floke-ai.duckdns.org/feedback
WORDPRESS_BOOKING_URL=https://www.robo-planet.de/terminbestaetigung
MS_BOOKINGS_URL=https://outlook.office365.com/book/KennenlernenmitRoboplanet@wackler-group.de/
```

View File

@@ -11,6 +11,7 @@ from threading import Thread, Lock
import uvicorn
import logging
from fastapi import FastAPI, Response, BackgroundTasks
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from sqlalchemy.orm import sessionmaker
@@ -44,6 +45,8 @@ SessionLocal = init_db(f"sqlite:///{DB_FILE_PATH}")
# --- Config ---
TEAMS_WEBHOOK_URL = os.getenv("TEAMS_WEBHOOK_URL", "")
FEEDBACK_SERVER_BASE_URL = os.getenv("FEEDBACK_SERVER_BASE_URL", "http://localhost:8004")
WORDPRESS_BOOKING_URL = os.getenv("WORDPRESS_BOOKING_URL", "")
MS_BOOKINGS_URL = os.getenv("MS_BOOKINGS_URL", "https://outlook.office365.com/owa/calendar/IHR_BOOKINGS_NAME@robo-planet.de/bookings/")
DEFAULT_WAIT_MINUTES = 5
SENDER_EMAIL = os.getenv("SENDER_EMAIL", "info@robo-planet.de")
TEST_RECEIVER_EMAIL = "floke.com@gmail.com"
@@ -128,6 +131,36 @@ def find_slots(start, view, interval):
break
return slots
def is_slot_free(target_email: str, app_creds: tuple, target_time: datetime) -> bool:
"""Checks via MS Graph API if a specific 15-minute slot is still free."""
logging.info(f"Live-Checking slot availability for {target_email} at {target_time}")
token = get_access_token(*app_creds)
if not token:
logging.warning("Failed to get token for live-check. Proceeding anyway.")
return True # Fallback: assume free to not block booking
end_time = target_time + timedelta(minutes=15)
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json", "Prefer": 'outlook.timezone="Europe/Berlin"'}
payload = {
"schedules": [target_email],
"startTime": {"dateTime": target_time.isoformat(), "timeZone": "Europe/Berlin"},
"endTime": {"dateTime": end_time.isoformat(), "timeZone": "Europe/Berlin"},
"availabilityViewInterval": 15
}
try:
url = f"{GRAPH_API_ENDPOINT}/users/{target_email}/calendar/getSchedule"
r = requests.post(url, headers=headers, json=payload)
if r.status_code == 200:
view = r.json().get('value', [{}])[0].get('availabilityView', '')
if view:
logging.info(f"Live-Check view for {target_time}: '{view}'")
return view[0] == '0' # '0' means free
else:
logging.error(f"Live-check failed with status {r.status_code}: {r.text}")
except Exception as e:
logging.error(f"Live-check threw an exception: {e}")
return True # Fallback
def create_calendar_invite(lead_email, company, start_time):
catchall = os.getenv("EMAIL_CATCHALL"); lead_email = catchall if catchall else lead_email
token = get_access_token(AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)
@@ -182,14 +215,84 @@ def send_now(job_uuid: str):
if job: job.status = "send_now"; db.commit(); db.close(); return Response("Wird gesendet.")
db.close(); return Response("Not Found", 404)
SUCCESS_HTML = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; text-align: center; color: #333; padding: 40px 20px; background: transparent; }}
.icon {{ font-size: 48px; margin-bottom: 20px; }}
h2 {{ margin-bottom: 10px; color: #10b981; }}
p {{ font-size: 16px; line-height: 1.5; color: #4b5563; }}
.details {{ font-weight: bold; margin-top: 15px; color: #111827; }}
</style>
</head>
<body>
<div class="icon">✅</div>
<h2>Termin erfolgreich gebucht!</h2>
<p>Vielen Dank für die Terminbuchung.</p>
<p class="details">Wir bestätigen Ihren Termin am {date} um {time} Uhr.</p>
<p>Sie erhalten in Kürze eine separate Kalendereinladung inkl. Microsoft Teams-Link an Ihre E-Mail-Adresse.</p>
</body>
</html>
"""
FALLBACK_HTML = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; text-align: center; color: #333; margin: 0; padding: 0; background: transparent; }}
.message-box {{ padding: 20px; background-color: #fef3c7; color: #92400e; border-bottom: 1px solid #fcd34d; margin-bottom: 20px; }}
h3 {{ margin: 0 0 10px 0; font-size: 18px; }}
p {{ margin: 0; font-size: 14px; }}
.iframe-container {{ width: 100%; height: 800px; border: none; }}
</style>
</head>
<body>
<div class="message-box">
<h3>⚠️ Termin leider nicht mehr verfügbar</h3>
<p>Der von Ihnen gewählte Termin wurde in der Zwischenzeit leider anderweitig vergeben.<br>
Bitte wählen Sie direkt hier einen neuen, passenden Termin aus unserem Kalender:</p>
</div>
<!-- BITTE DEN ECHTEN MS BOOKINGS LINK EINTRAGEN -->
<iframe class="iframe-container" src="{ms_bookings_url}" scrolling="yes"></iframe>
</body>
</html>
"""
@app.get("/book_slot/{job_uuid}/{ts}")
def book_slot(job_uuid: str, ts: int):
slot_time = datetime.fromtimestamp(ts, tz=TZ_BERLIN)
db = SessionLocal(); job = db.query(ProposalJob).filter(ProposalJob.job_uuid == job_uuid).first()
if not job or job.status == "booked": db.close(); return Response("Fehler.", 400)
db = SessionLocal()
job = db.query(ProposalJob).filter(ProposalJob.job_uuid == job_uuid).first()
if not job or job.status == "booked":
db.close()
# If job doesn't exist or is already booked, show the fallback calendar to allow a new booking safely
return HTMLResponse(content=FALLBACK_HTML.format(ms_bookings_url=MS_BOOKINGS_URL))
# LIVE CHECK: Is the slot still free in the calendar?
app_creds = (CAL_APPID, CAL_SECRET, CAL_TENNANT_ID)
if not is_slot_free("e.melcer@robo-planet.de", app_creds, slot_time):
logging.warning(f"RACE CONDITION PREVENTED: Slot {slot_time} for job {job_uuid} is taken!")
db.close()
return HTMLResponse(content=FALLBACK_HTML.format(ms_bookings_url=MS_BOOKINGS_URL))
if create_calendar_invite(job.customer_email, job.customer_company, slot_time):
job.status = "booked"; db.commit(); db.close(); return Response(f"Gebucht!")
db.close(); return Response("Fehler bei Kalender.", 500)
job.status = "booked"
db.commit()
db.close()
date_str = slot_time.strftime('%d.%m.%Y')
time_str = slot_time.strftime('%H:%M')
html_content = SUCCESS_HTML.format(date=date_str, time=time_str)
return HTMLResponse(content=html_content)
db.close()
return HTMLResponse(content="<p>Es gab einen internen Fehler bei der Kalenderbuchung. Bitte versuchen Sie es später erneut.</p>", status_code=500)
# --- Workflow Logic ---
def send_email(subject, body, to_email):
@@ -301,7 +404,11 @@ def process_lead(request_id, company, opener, receiver, name):
logging.info(f"Timeout reached or 'Send Now' clicked for job {request_id}. Proceeding to send email.")
booking_html = "<ul>"
for s in suggestions:
booking_html += f'<li><a href="{FEEDBACK_SERVER_BASE_URL}/book_slot/{request_id}/{int(s.timestamp())}">{format_date_for_email(s)}</a></li>'
if WORDPRESS_BOOKING_URL:
link = f"{WORDPRESS_BOOKING_URL}?job_uuid={request_id}&ts={int(s.timestamp())}"
else:
link = f"{FEEDBACK_SERVER_BASE_URL}/book_slot/{request_id}/{int(s.timestamp())}"
booking_html += f'<li><a href="{link}">{format_date_for_email(s)}</a></li>'
booking_html += "</ul>"
try: