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 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. # Copy this file to .env and fill in the actual values.
# --- Core API Keys --- # --- Core API Keys ---

View File

@@ -244,6 +244,8 @@ services:
CAL_TENNANT_ID: "${CAL_TENNANT_ID}" CAL_TENNANT_ID: "${CAL_TENNANT_ID}"
TEAMS_WEBHOOK_URL: "${TEAMS_WEBHOOK_URL}" TEAMS_WEBHOOK_URL: "${TEAMS_WEBHOOK_URL}"
FEEDBACK_SERVER_BASE_URL: "${FEEDBACK_SERVER_BASE_URL}" FEEDBACK_SERVER_BASE_URL: "${FEEDBACK_SERVER_BASE_URL}"
WORDPRESS_BOOKING_URL: "${WORDPRESS_BOOKING_URL}"
MS_BOOKINGS_URL: "${MS_BOOKINGS_URL}"
volumes: volumes:
- ./lead-engine:/app - ./lead-engine:/app
- lead_engine_data:/app/data - 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. * **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. * **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 ### 4. Race-Condition-Schutz bei Überbuchung (Live-Check)
* **Problem:** Hintergrund-Tasks schlugen ohne klare Fehlermeldung fehl, was die Diagnose erschwerte. * **Problem:** Wenn mehrere Leads E-Mails mit denselben Terminvorschlägen erhalten, konnten Doppelbuchungen entstehen.
* **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. * **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 ## 🚀 Inbetriebnahme & Test
@@ -83,25 +87,9 @@ docker-compose up -d --build --force-recreate lead-engine
## 📝 Zukünftige Erweiterungen & Todos ## 📝 Zukünftige Erweiterungen & Todos
### Task: Race-Condition-Schutz bei Überbuchung ### Task: Automatisches Nachfassen (Follow-up)
* **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. * **Problem:** Wenn ein Lead nicht auf die E-Mail antwortet und auch keinen Termin bucht, geht der Kontakt verloren.
* **Lösung:** Implementierung eines "Live-Checks" im Feedback-Server. * **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.
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.
```env ```env
# Info-Postfach (App 1 - Schreiben) # Info-Postfach (App 1 - Schreiben)
@@ -117,4 +105,6 @@ CAL_SECRET=...
# URLs # URLs
TEAMS_WEBHOOK_URL=... TEAMS_WEBHOOK_URL=...
FEEDBACK_SERVER_BASE_URL=https://floke-ai.duckdns.org/feedback 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 uvicorn
import logging import logging
from fastapi import FastAPI, Response, BackgroundTasks from fastapi import FastAPI, Response, BackgroundTasks
from fastapi.responses import HTMLResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@@ -44,6 +45,8 @@ SessionLocal = init_db(f"sqlite:///{DB_FILE_PATH}")
# --- Config --- # --- Config ---
TEAMS_WEBHOOK_URL = os.getenv("TEAMS_WEBHOOK_URL", "") TEAMS_WEBHOOK_URL = os.getenv("TEAMS_WEBHOOK_URL", "")
FEEDBACK_SERVER_BASE_URL = os.getenv("FEEDBACK_SERVER_BASE_URL", "http://localhost:8004") 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 DEFAULT_WAIT_MINUTES = 5
SENDER_EMAIL = os.getenv("SENDER_EMAIL", "info@robo-planet.de") SENDER_EMAIL = os.getenv("SENDER_EMAIL", "info@robo-planet.de")
TEST_RECEIVER_EMAIL = "floke.com@gmail.com" TEST_RECEIVER_EMAIL = "floke.com@gmail.com"
@@ -128,6 +131,36 @@ def find_slots(start, view, interval):
break break
return slots 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): def create_calendar_invite(lead_email, company, start_time):
catchall = os.getenv("EMAIL_CATCHALL"); lead_email = catchall if catchall else lead_email 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) 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.") if job: job.status = "send_now"; db.commit(); db.close(); return Response("Wird gesendet.")
db.close(); return Response("Not Found", 404) 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}") @app.get("/book_slot/{job_uuid}/{ts}")
def book_slot(job_uuid: str, ts: int): def book_slot(job_uuid: str, ts: int):
slot_time = datetime.fromtimestamp(ts, tz=TZ_BERLIN) slot_time = datetime.fromtimestamp(ts, tz=TZ_BERLIN)
db = SessionLocal(); job = db.query(ProposalJob).filter(ProposalJob.job_uuid == job_uuid).first() db = SessionLocal()
if not job or job.status == "booked": db.close(); return Response("Fehler.", 400) 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): if create_calendar_invite(job.customer_email, job.customer_company, slot_time):
job.status = "booked"; db.commit(); db.close(); return Response(f"Gebucht!") job.status = "booked"
db.close(); return Response("Fehler bei Kalender.", 500) 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 --- # --- Workflow Logic ---
def send_email(subject, body, to_email): 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.") logging.info(f"Timeout reached or 'Send Now' clicked for job {request_id}. Proceeding to send email.")
booking_html = "<ul>" booking_html = "<ul>"
for s in suggestions: 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>" booking_html += "</ul>"
try: try: