Compare commits
4 Commits
895e8b5c19
...
4c8158ee9f
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c8158ee9f | |||
| d7f789b12f | |||
| 383cf1c374 | |||
| 90219ebfca |
@@ -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 ---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
```
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user