feat(lead-engine): Implement Teams notification and email enhancements [31988f42]
- Enhanced Teams Adaptive Card with precise email send time and re-added emojis to action buttons ("✅ JETZT Aussenden", "❌ STOP Aussendung").
- Modified email sending logic to include HTML signature from `signature.html` and an inline banner image from `RoboPlanetBannerWebinarEinladung.png`.
- Documented future enhancements in `lead-engine/README.md`:
- Race-condition protection for calendar bookings with a live calendar check.
- Integration of booking confirmation pages into the WordPress website (iFrame first, then API integration).
This commit is contained in:
@@ -76,7 +76,27 @@ docker exec lead-engine python /app/trading_twins/test_calendar_logic.py
|
|||||||
|
|
||||||
**Zugriff:** `https://floke-ai.duckdns.org/lead/` (Passwortgeschützt)
|
**Zugriff:** `https://floke-ai.duckdns.org/lead/` (Passwortgeschützt)
|
||||||
|
|
||||||
## 📝 Credentials (.env)
|
## 📝 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.
|
||||||
|
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# Info-Postfach (App 1 - Schreiben)
|
# Info-Postfach (App 1 - Schreiben)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ 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"
|
||||||
SIGNATURE_FILE_PATH = os.path.join(os.path.dirname(__file__), "signature.html")
|
SIGNATURE_FILE_PATH = os.path.join(os.path.dirname(__file__), "signature.html")
|
||||||
|
BANNER_FILE_PATH = os.path.join(os.path.dirname(__file__), "RoboPlanetBannerWebinarEinladung.png")
|
||||||
|
|
||||||
# Credentials
|
# Credentials
|
||||||
AZURE_CLIENT_ID = os.getenv("INFO_Application_ID")
|
AZURE_CLIENT_ID = os.getenv("INFO_Application_ID")
|
||||||
@@ -156,12 +157,25 @@ def book_slot(job_uuid: str, ts: int):
|
|||||||
db.close(); return Response("Fehler bei Kalender.", 500)
|
db.close(); return Response("Fehler bei Kalender.", 500)
|
||||||
|
|
||||||
# --- Workflow Logic ---
|
# --- Workflow Logic ---
|
||||||
def send_email(subject, body, to_email, signature):
|
def send_email(subject, body, to_email, signature, banner_path=None):
|
||||||
|
attachments = []
|
||||||
|
if banner_path and os.path.exists(banner_path):
|
||||||
|
with open(banner_path, "rb") as f:
|
||||||
|
content_bytes = f.read()
|
||||||
|
content_b64 = base64.b64encode(content_bytes).decode("utf-8")
|
||||||
|
attachments.append({
|
||||||
|
"@odata.type": "#microsoft.graph.fileAttachment",
|
||||||
|
"name": "RoboPlanetBannerWebinarEinladung.png",
|
||||||
|
"contentBytes": content_b64,
|
||||||
|
"isInline": True,
|
||||||
|
"contentId": "banner_image"
|
||||||
|
})
|
||||||
catchall = os.getenv("EMAIL_CATCHALL"); to_email = catchall if catchall else to_email
|
catchall = os.getenv("EMAIL_CATCHALL"); to_email = catchall if catchall else to_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)
|
||||||
if not token: return
|
if not token: return
|
||||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||||
payload = {"message": {"subject": subject, "body": {"contentType": "HTML", "content": body + signature}, "toRecipients": [{"emailAddress": {"address": to_email}}]}, "saveToSentItems": "true"}
|
payload = {"message": {"subject": subject, "body": {"contentType": "HTML", "content": body + signature}, "toRecipients": [{"emailAddress": {"address": to_email}}]}, "saveToSentItems": "true"}
|
||||||
|
if attachments: payload["message"]["attachments"] = attachments
|
||||||
requests.post(f"{GRAPH_API_ENDPOINT}/users/{SENDER_EMAIL}/sendMail", headers=headers, json=payload)
|
requests.post(f"{GRAPH_API_ENDPOINT}/users/{SENDER_EMAIL}/sendMail", headers=headers, json=payload)
|
||||||
|
|
||||||
def process_lead(request_id, company, opener, receiver, name):
|
def process_lead(request_id, company, opener, receiver, name):
|
||||||
@@ -186,8 +200,10 @@ def process_lead(request_id, company, opener, receiver, name):
|
|||||||
for s in suggestions: db.add(ProposedSlot(job_id=job.id, start_time=s, end_time=s+timedelta(minutes=15)))
|
for s in suggestions: db.add(ProposedSlot(job_id=job.id, start_time=s, end_time=s+timedelta(minutes=15)))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
card = {"type": "message", "attachments": [{"contentType": "application/vnd.microsoft.card.adaptive", "content": {"type": "AdaptiveCard", "version": "1.4", "body": [{"type": "TextBlock", "text": f"🤖 E-Mail an {company}?"}], "actions": [{"type": "Action.OpenUrl", "title": "STOP", "url": f"{FEEDBACK_SERVER_BASE_URL}/stop/{request_id}"},{"type": "Action.OpenUrl", "title": "JETZT", "url": f"{FEEDBACK_SERVER_BASE_URL}/send_now/{request_id}"}]}}]}
|
send_time = datetime.now(TZ_BERLIN) + timedelta(minutes=DEFAULT_WAIT_MINUTES)
|
||||||
requests.post(TEAMS_WEBHOOK_URL, json=card)
|
# Using the more detailed card from teams_notification.py
|
||||||
|
from .teams_notification import send_approval_card
|
||||||
|
send_approval_card(job_uuid=request_id, customer_name=company, time_string=send_time.strftime("%H:%M"), webhook_url=TEAMS_WEBHOOK_URL, api_base_url=FEEDBACK_SERVER_BASE_URL)
|
||||||
|
|
||||||
send_time = datetime.now(TZ_BERLIN) + timedelta(minutes=DEFAULT_WAIT_MINUTES)
|
send_time = datetime.now(TZ_BERLIN) + timedelta(minutes=DEFAULT_WAIT_MINUTES)
|
||||||
while datetime.now(TZ_BERLIN) < send_time:
|
while datetime.now(TZ_BERLIN) < send_time:
|
||||||
@@ -213,7 +229,7 @@ def process_lead(request_id, company, opener, receiver, name):
|
|||||||
{booking_html}
|
{booking_html}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
send_email(f"Ihr Kontakt mit RoboPlanet - {company}", email_body, receiver, sig)
|
send_email(f"Ihr Kontakt mit RoboPlanet - {company}", email_body, receiver, sig, BANNER_FILE_PATH)
|
||||||
job.status = "sent"; db.commit(); db.close()
|
job.status = "sent"; db.commit(); db.close()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,40 +1,13 @@
|
|||||||
<!DOCTYPE html>
|
Freundliche Grüße<br>
|
||||||
<html lang="de">
|
Elizabeta Melcer<br>
|
||||||
<head>
|
Inside Sales Managerin<br>
|
||||||
<meta charset="UTF-8">
|
<img src="https://www.robo-planet.de/wp-content/uploads/2023/07/Wackler_Logo.png" alt="Wackler Logo" width="100"><br>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
RoboPlanet GmbH<br>
|
||||||
<title>E-Mail Signatur</title>
|
Schatzbogen 39, 81829 München<br>
|
||||||
</head>
|
T: +49 89 420490-402 | M: +49 175 8334071<br>
|
||||||
<body>
|
e.melcer@robo-planet.de | www.robo-planet.de<br>
|
||||||
<!--
|
<a href="https://www.linkedin.com/company/roboplanet">LinkedIn</a> <a href="https://www.instagram.com/roboplanet.de/">Instagram</a> <a href="https://www.robo-planet.de/newsletter">Newsletteranmeldung</a><br>
|
||||||
HINWEIS:
|
Sitz der Gesellschaft München | Geschäftsführung: Axel Banoth<br>
|
||||||
Dieser Inhalt wird von der IT-Abteilung bereitgestellt.
|
Registergericht AG München, HRB 296113 | USt.-IdNr. DE400464410<br>
|
||||||
Bitte den finalen HTML-Code hier einfügen.
|
<a href="https://www.robo-planet.de/datenschutz">Hinweispflichten zum Datenschutz</a><br>
|
||||||
Das Bild 'RoboPlanetBannerWebinarEinladung.png' muss sich im selben Verzeichnis befinden.
|
<img src="cid:banner_image" alt="RoboPlanet Webinar Einladung">
|
||||||
[31988f42]
|
|
||||||
-->
|
|
||||||
<p>Freundliche Grüße</p>
|
|
||||||
<p>
|
|
||||||
<b>Elizabeta Melcer</b><br>
|
|
||||||
Inside Sales Managerin
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<!-- Wackler Logo -->
|
|
||||||
<b>RoboPlanet GmbH</b><br>
|
|
||||||
Schatzbogen 39, 81829 München<br>
|
|
||||||
T: +49 89 420490-402 | M: +49 175 8334071<br>
|
|
||||||
<a href="mailto:e.melcer@robo-planet.de">e.melcer@robo-planet.de</a> | <a href="http://www.robo-planet.de">www.robo-planet.de</a>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<a href="#">LinkedIn</a> | <a href="#">Instagram</a> | <a href="#">Newsletteranmeldung</a>
|
|
||||||
</p>
|
|
||||||
<p style="font-size: smaller; color: grey;">
|
|
||||||
Sitz der Gesellschaft München | Geschäftsführung: Axel Banoth<br>
|
|
||||||
Registergericht AG München, HRB 296113 | USt.-IdNr. DE400464410<br>
|
|
||||||
<a href="#">Hinweispflichten zum Datenschutz</a>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<img src="RoboPlanetBannerWebinarEinladung.png" alt="RoboPlanet Webinar Einladung">
|
|
||||||
</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -6,13 +6,13 @@ from datetime import datetime
|
|||||||
# Default-Webhook (Platzhalter) - sollte in .env stehen
|
# Default-Webhook (Platzhalter) - sollte in .env stehen
|
||||||
DEFAULT_WEBHOOK_URL = os.getenv("TEAMS_WEBHOOK_URL", "")
|
DEFAULT_WEBHOOK_URL = os.getenv("TEAMS_WEBHOOK_URL", "")
|
||||||
|
|
||||||
def send_approval_card(job_uuid, customer_name, time_string, webhook_url=DEFAULT_WEBHOOK_URL):
|
def send_approval_card(job_uuid, customer_name, time_string, webhook_url=DEFAULT_WEBHOOK_URL, api_base_url="http://localhost:8004"):
|
||||||
"""
|
"""
|
||||||
Sendet eine Adaptive Card an Teams mit Approve/Deny Buttons.
|
Sendet eine Adaptive Card an Teams mit Approve/Deny Buttons.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Die URL unserer API (muss von außen erreichbar sein, z.B. via ngrok oder Server-IP)
|
# Die URL unserer API (muss von außen erreichbar sein, z.B. via ngrok oder Server-IP)
|
||||||
api_base_url = os.getenv("API_BASE_URL", "http://localhost:8004")
|
|
||||||
|
|
||||||
card_payload = {
|
card_payload = {
|
||||||
"type": "message",
|
"type": "message",
|
||||||
@@ -27,33 +27,27 @@ def send_approval_card(job_uuid, customer_name, time_string, webhook_url=DEFAULT
|
|||||||
"body": [
|
"body": [
|
||||||
{
|
{
|
||||||
"type": "TextBlock",
|
"type": "TextBlock",
|
||||||
"text": f"🤖 Automatisierte E-Mail an {customer_name}",
|
"text": f"🤖 Automatisierte E-Mail an {customer_name} (via Trading Twins) wird um {time_string} Uhr ausgesendet.",
|
||||||
"weight": "Bolder",
|
"weight": "Bolder",
|
||||||
"size": "Medium"
|
"size": "Medium"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "TextBlock",
|
"type": "TextBlock",
|
||||||
"text": f"(via Trading Twins) wird um {time_string} Uhr ausgesendet.",
|
"text": "Wenn Du bis {time_string} Uhr NICHT reagierst, wird die generierte E-Mail automatisch ausgesendet.",
|
||||||
"isSubtle": True,
|
"isSubtle": True,
|
||||||
"wrap": True
|
"wrap": True
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "TextBlock",
|
|
||||||
"text": "Wenn Du bis dahin NICHT reagierst, wird die E-Mail automatisch gesendet.",
|
|
||||||
"color": "Attention",
|
|
||||||
"wrap": True
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"actions": [
|
"actions": [
|
||||||
{
|
{
|
||||||
"type": "Action.OpenUrl",
|
"type": "Action.OpenUrl",
|
||||||
"title": "✅ JETZT Aussenden",
|
"title": "✅ JETZT Aussenden",
|
||||||
"url": f"{api_base_url}/action/approve/{job_uuid}"
|
"url": f"{api_base_url}/send_now/{job_uuid}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "Action.OpenUrl",
|
"type": "Action.OpenUrl",
|
||||||
"title": "❌ STOP Aussendung",
|
"title": "❌ STOP Aussendung",
|
||||||
"url": f"{api_base_url}/action/cancel/{job_uuid}"
|
"url": f"{api_base_url}/stop/{job_uuid}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user