[30388f42] Infrastructure Hardening: Repaired CE/Connector DB schema, fixed frontend styling build, implemented robust echo shield in worker v2.1.1, and integrated Lead Engine into gateway.
This commit is contained in:
0
lead-engine/trading_twins/__init__.py
Normal file
0
lead-engine/trading_twins/__init__.py
Normal file
58
lead-engine/trading_twins/api_server.py
Normal file
58
lead-engine/trading_twins/api_server.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from flask import Flask, request, jsonify, render_template_string
|
||||
from .manager import TradingTwinsManager
|
||||
|
||||
app = Flask(__name__)
|
||||
manager = TradingTwinsManager()
|
||||
|
||||
# Einfaches HTML-Template für Feedback
|
||||
HTML_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Trading Twins Status</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; text-align: center; padding: 50px; }
|
||||
.success { color: green; font-size: 24px; }
|
||||
.cancelled { color: red; font-size: 24px; }
|
||||
.info { color: gray; margin-top: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{title}</h1>
|
||||
<p class="{status_class}">{message}</p>
|
||||
<p class="info">Job ID: {job_uuid}</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@app.route('/action/approve/<job_uuid>', methods=['GET'])
|
||||
def approve_job(job_uuid):
|
||||
current_status = manager.get_job_status(job_uuid)
|
||||
|
||||
if not current_status:
|
||||
return render_template_string(HTML_TEMPLATE, title="Fehler", status_class="cancelled", message="Job nicht gefunden.", job_uuid=job_uuid), 404
|
||||
|
||||
if current_status == 'pending':
|
||||
manager.update_job_status(job_uuid, 'approved')
|
||||
# TODO: Hier würde der E-Mail-Versand sofort getriggert werden (Phase 3)
|
||||
return render_template_string(HTML_TEMPLATE, title="Erfolg", status_class="success", message="✅ E-Mail wird jetzt versendet!", job_uuid=job_uuid)
|
||||
elif current_status == 'approved':
|
||||
return render_template_string(HTML_TEMPLATE, title="Info", status_class="success", message="⚠️ Job wurde bereits genehmigt.", job_uuid=job_uuid)
|
||||
else:
|
||||
return render_template_string(HTML_TEMPLATE, title="Info", status_class="cancelled", message=f"Job-Status ist bereits: {current_status}", job_uuid=job_uuid)
|
||||
|
||||
@app.route('/action/cancel/<job_uuid>', methods=['GET'])
|
||||
def cancel_job(job_uuid):
|
||||
current_status = manager.get_job_status(job_uuid)
|
||||
|
||||
if not current_status:
|
||||
return render_template_string(HTML_TEMPLATE, title="Fehler", status_class="cancelled", message="Job nicht gefunden.", job_uuid=job_uuid), 404
|
||||
|
||||
if current_status == 'pending':
|
||||
manager.update_job_status(job_uuid, 'cancelled')
|
||||
return render_template_string(HTML_TEMPLATE, title="Abbruch", status_class="cancelled", message="❌ E-Mail-Versand gestoppt.", job_uuid=job_uuid)
|
||||
else:
|
||||
return render_template_string(HTML_TEMPLATE, title="Info", status_class="info", message=f"Job konnte nicht gestoppt werden (Status: {current_status}).", job_uuid=job_uuid)
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=8004)
|
||||
85
lead-engine/trading_twins/email_sender.py
Normal file
85
lead-engine/trading_twins/email_sender.py
Normal file
@@ -0,0 +1,85 @@
|
||||
import os
|
||||
import msal
|
||||
import requests
|
||||
import base64
|
||||
|
||||
# Graph API Konfiguration (aus .env laden)
|
||||
CLIENT_ID = os.getenv("AZURE_CLIENT_ID")
|
||||
CLIENT_SECRET = os.getenv("AZURE_CLIENT_SECRET")
|
||||
TENANT_ID = os.getenv("AZURE_TENANT_ID")
|
||||
AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"
|
||||
SCOPE = ["https://graph.microsoft.com/.default"]
|
||||
|
||||
SENDER_EMAIL = "info@robo-planet.de"
|
||||
|
||||
def get_access_token():
|
||||
"""Holt ein Token für die Graph API."""
|
||||
app = msal.ConfidentialClientApplication(
|
||||
CLIENT_ID, authority=AUTHORITY, client_credential=CLIENT_SECRET
|
||||
)
|
||||
result = app.acquire_token_for_client(scopes=SCOPE)
|
||||
if "access_token" in result:
|
||||
return result["access_token"]
|
||||
else:
|
||||
raise Exception(f"Fehler beim Abrufen des Tokens: {result.get('error_description')}")
|
||||
|
||||
def send_email_via_graph(to_email, subject, body_html, banner_path=None):
|
||||
"""
|
||||
Sendet eine E-Mail über die Microsoft Graph API.
|
||||
"""
|
||||
token = get_access_token()
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# E-Mail Struktur für Graph API
|
||||
email_msg = {
|
||||
"message": {
|
||||
"subject": subject,
|
||||
"body": {
|
||||
"contentType": "HTML",
|
||||
"content": body_html
|
||||
},
|
||||
"toRecipients": [
|
||||
{
|
||||
"emailAddress": {
|
||||
"address": to_email
|
||||
}
|
||||
}
|
||||
],
|
||||
"from": {
|
||||
"emailAddress": {
|
||||
"address": SENDER_EMAIL
|
||||
}
|
||||
}
|
||||
},
|
||||
"saveToSentItems": "true"
|
||||
}
|
||||
|
||||
# Optional: Banner-Bild als Inline-Attachment einfügen
|
||||
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")
|
||||
|
||||
email_msg["message"]["attachments"] = [
|
||||
{
|
||||
"@odata.type": "#microsoft.graph.fileAttachment",
|
||||
"name": "banner.png",
|
||||
"contentBytes": content_b64,
|
||||
"isInline": True,
|
||||
"contentId": "banner_image"
|
||||
}
|
||||
]
|
||||
|
||||
endpoint = f"https://graph.microsoft.com/v1.0/users/{SENDER_EMAIL}/sendMail"
|
||||
|
||||
response = requests.post(endpoint, headers=headers, json=email_msg)
|
||||
|
||||
if response.status_code == 202:
|
||||
print(f"E-Mail erfolgreich an {to_email} gesendet.")
|
||||
return True
|
||||
else:
|
||||
print(f"Fehler beim Senden: {response.status_code} - {response.text}")
|
||||
return False
|
||||
318
lead-engine/trading_twins/manager.py
Normal file
318
lead-engine/trading_twins/manager.py
Normal file
@@ -0,0 +1,318 @@
|
||||
# lead-engine/trading_twins/manager.py
|
||||
from email.mime.text import MIMEText
|
||||
import base64
|
||||
import requests
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from threading import Thread, Lock
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Response
|
||||
import msal
|
||||
|
||||
# --- Zeitzonen-Konfiguration ---
|
||||
TZ_BERLIN = ZoneInfo("Europe/Berlin")
|
||||
|
||||
# --- Konfiguration ---
|
||||
TEAMS_WEBHOOK_URL = os.getenv("TEAMS_WEBHOOK_URL", "https://wacklergroup.webhook.office.com/webhookb2/fe728cde-790c-4190-b1d3-be393ca0f9bd@6d85a9ef-3878-420b-8f43-38d6cb12b665/IncomingWebhook/e9a8ee6157594a6cab96048cf2ea2232/d26033cd-a81f-41a6-8cd2-b4a3ba0b5a01/V2WFmjcbkMzSU4f6lDSdUOM9VNm7F7n1Th4YDiu3fLZ_Y1")
|
||||
# Öffentliche URL für Feedback-Links
|
||||
FEEDBACK_SERVER_BASE_URL = os.getenv("FEEDBACK_SERVER_BASE_URL", "https://floke-ai.duckdns.org/feedback")
|
||||
DEFAULT_WAIT_MINUTES = 5
|
||||
SENDER_EMAIL = os.getenv("SENDER_EMAIL", "info@robo-planet.de")
|
||||
TEST_RECEIVER_EMAIL = "floke.com@gmail.com" # Für E2E Tests
|
||||
SIGNATURE_FILE_PATH = "/app/trading_twins/signature.html"
|
||||
|
||||
# Credentials für die Haupt-App (E-Mail & Kalender info@)
|
||||
AZURE_CLIENT_ID = os.getenv("INFO_Application_ID")
|
||||
AZURE_CLIENT_SECRET = os.getenv("INFO_Secret")
|
||||
AZURE_TENANT_ID = os.getenv("INFO_Tenant_ID")
|
||||
|
||||
# Credentials für die Kalender-Lese-App (e.melcer)
|
||||
CAL_APPID = os.getenv("CAL_APPID")
|
||||
CAL_SECRET = os.getenv("CAL_SECRET")
|
||||
CAL_TENNANT_ID = os.getenv("CAL_TENNANT_ID")
|
||||
|
||||
GRAPH_API_ENDPOINT = "https://graph.microsoft.com/v1.0"
|
||||
|
||||
# --- In-Memory-Speicher ---
|
||||
# Wir speichern hier Details zu jeder Anfrage, um beim Klick auf den Slot reagieren zu können.
|
||||
request_status_storage = {}
|
||||
_lock = Lock()
|
||||
|
||||
# --- Auth Helper ---
|
||||
def get_access_token(client_id, client_secret, tenant_id):
|
||||
if not all([client_id, client_secret, tenant_id]):
|
||||
return None
|
||||
authority = f"https://login.microsoftonline.com/{tenant_id}"
|
||||
app = msal.ConfidentialClientApplication(client_id=client_id, authority=authority, client_credential=client_secret)
|
||||
result = app.acquire_token_silent(["https://graph.microsoft.com/.default"], account=None)
|
||||
if not result:
|
||||
result = app.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"])
|
||||
return result.get('access_token')
|
||||
|
||||
# --- KALENDER LOGIK ---
|
||||
|
||||
def get_availability(target_email: str, app_creds: tuple) -> tuple:
|
||||
"""Holt die Verfügbarkeit für eine E-Mail über die angegebene App."""
|
||||
token = get_access_token(*app_creds)
|
||||
if not token: return None
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json", "Prefer": 'outlook.timezone="Europe/Berlin"'}
|
||||
|
||||
# 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"},
|
||||
"endTime": {"dateTime": end_time.strftime("%Y-%m-%dT%H:%M:%S"), "timeZone": "Europe/Berlin"},
|
||||
"availabilityViewInterval": 60
|
||||
}
|
||||
try:
|
||||
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 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 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")
|
||||
|
||||
token = get_access_token(AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)
|
||||
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": f"Hallo, <br><br>vielen Dank für die Terminbuchung über unsere Lead-Engine. Wir freuen uns auf das Gespräch!<br><br>Beste Grüße,<br>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 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 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(f"SUCCESS: Calendar event created for {target_organizer}.")
|
||||
return True
|
||||
else:
|
||||
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}")
|
||||
return False
|
||||
|
||||
# --- E-MAIL & WEB LOGIK ---
|
||||
|
||||
def generate_booking_html(request_id: str, suggestions: list) -> str:
|
||||
html = "<p>Bitte wählen Sie einen passenden Termin für ein 15-minütiges Kennenlerngespräch:</p><ul>"
|
||||
for slot in suggestions:
|
||||
ts = int(slot.timestamp())
|
||||
# Link zu unserem eigenen Bestätigungs-Endpunkt
|
||||
link = f"{FEEDBACK_SERVER_BASE_URL}/book_slot/{request_id}/{ts}"
|
||||
html += f'<li><a href="{link}" style="font-weight: bold; color: #0078d4;">{slot.strftime("%d.%m. um %H:%M Uhr")}</a></li>'
|
||||
html += "</ul><p>Mit Klick auf einen Termin wird automatisch eine Kalendereinladung an Sie versendet.</p>"
|
||||
return html
|
||||
|
||||
# --- Server & API ---
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/stop/{request_id}")
|
||||
async def stop(request_id: str):
|
||||
with _lock:
|
||||
if request_id in request_status_storage:
|
||||
request_status_storage[request_id]["status"] = "cancelled"
|
||||
return Response("<html><body><h1>Versand gestoppt.</h1></body></html>", media_type="text/html")
|
||||
return Response("Ungültig.", status_code=404)
|
||||
|
||||
@app.get("/send_now/{request_id}")
|
||||
async def send_now(request_id: str):
|
||||
with _lock:
|
||||
if request_id in request_status_storage:
|
||||
request_status_storage[request_id]["status"] = "send_now"
|
||||
return Response("<html><body><h1>E-Mail wird sofort versendet.</h1></body></html>", media_type="text/html")
|
||||
return Response("Ungültig.", status_code=404)
|
||||
|
||||
@app.get("/book_slot/{request_id}/{ts}")
|
||||
async def book_slot(request_id: str, ts: int):
|
||||
slot_time = datetime.fromtimestamp(ts, tz=TZ_BERLIN)
|
||||
with _lock:
|
||||
data = request_status_storage.get(request_id)
|
||||
if not data: return Response("Anfrage nicht gefunden.", status_code=404)
|
||||
if data.get("booked"): return Response("<html><body><h1>Termin wurde bereits bestätigt.</h1></body></html>", media_type="text/html")
|
||||
data["booked"] = True
|
||||
|
||||
# Einladung senden
|
||||
success = create_calendar_invite(data['receiver'], data['company'], slot_time)
|
||||
if success:
|
||||
return Response(f"<html><body><h1>Vielen Dank!</h1><p>Die Einladung für den <b>{slot_time.strftime('%d.%m. um %H:%M')}</b> wurde an {data['receiver']} versendet.</p></body></html>", media_type="text/html")
|
||||
return Response("Fehler beim Erstellen des Termins.", status_code=500)
|
||||
|
||||
# --- Haupt Workflow ---
|
||||
|
||||
def send_email(subject, body, to_email, signature):
|
||||
token = get_access_token(AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)
|
||||
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"}
|
||||
requests.post(f"{GRAPH_API_ENDPOINT}/users/{SENDER_EMAIL}/sendMail", headers=headers, json=payload)
|
||||
|
||||
def process_lead(request_id: str, company: str, opener: str, receiver: str):
|
||||
# 1. Freie Slots finden (Check bei e.melcer UND info)
|
||||
print(f"INFO: Searching slots for {company}...")
|
||||
# Wir nehmen hier e.melcer als Referenz für die Zeit
|
||||
cal_data = get_availability("e.melcer@robo-planet.de", (CAL_APPID, CAL_SECRET, CAL_TENNANT_ID))
|
||||
suggestions = find_slots(*cal_data) if cal_data else []
|
||||
|
||||
with _lock:
|
||||
request_status_storage[request_id] = {"status": "pending", "company": company, "receiver": receiver, "slots": suggestions}
|
||||
|
||||
# 2. Teams Notification
|
||||
send_time = datetime.now(TZ_BERLIN) + timedelta(minutes=DEFAULT_WAIT_MINUTES)
|
||||
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} ({receiver}) geplant für {send_time.strftime('%H:%M')}", "weight": "Bolder"},
|
||||
{"type": "TextBlock", "text": f"Vorgeschlagene Slots: {', '.join([s.strftime('%H:%M') for s in suggestions])}", "isSubtle": True}
|
||||
],
|
||||
"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}"}
|
||||
]
|
||||
}}]
|
||||
}
|
||||
requests.post(TEAMS_WEBHOOK_URL, json=card)
|
||||
|
||||
# 3. Warten
|
||||
while datetime.now(TZ_BERLIN) < send_time:
|
||||
with _lock:
|
||||
if request_status_storage[request_id]["status"] in ["cancelled", "send_now"]:
|
||||
break
|
||||
time.sleep(5)
|
||||
|
||||
# 4. Senden
|
||||
with _lock:
|
||||
if request_status_storage[request_id]["status"] == "cancelled": return
|
||||
|
||||
print(f"INFO: Sending lead email to {receiver}...")
|
||||
booking_html = generate_booking_html(request_id, suggestions)
|
||||
with open(SIGNATURE_FILE_PATH, 'r') as f: sig = f.read()
|
||||
body = f"<p>Sehr geehrte Damen und Herren,</p><p>{opener}</p>{booking_html}"
|
||||
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)
|
||||
|
||||
# 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 KeyboardInterrupt:
|
||||
print("Shutting down.")
|
||||
45
lead-engine/trading_twins/models.py
Normal file
45
lead-engine/trading_twins/models.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from sqlalchemy import create_engine, Column, Integer, String, DateTime, ForeignKey, Boolean
|
||||
from sqlalchemy.orm import declarative_base, relationship, sessionmaker
|
||||
from datetime import datetime
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class ProposalJob(Base):
|
||||
__tablename__ = 'proposal_jobs'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
job_uuid = Column(String, unique=True, nullable=False) # Für die API-Links
|
||||
customer_email = Column(String, nullable=False)
|
||||
customer_name = Column(String, nullable=True)
|
||||
customer_company = Column(String, nullable=True)
|
||||
|
||||
# Status-Tracking
|
||||
status = Column(String, default='pending') # pending, approved, rejected, sent, failed
|
||||
|
||||
# Audit-Trail
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
approved_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Verknüpfung zu den vorgeschlagenen Slots
|
||||
slots = relationship("ProposedSlot", back_populates="job")
|
||||
|
||||
class ProposedSlot(Base):
|
||||
__tablename__ = 'proposed_slots'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
job_id = Column(Integer, ForeignKey('proposal_jobs.id'))
|
||||
|
||||
start_time = Column(DateTime, nullable=False)
|
||||
end_time = Column(DateTime, nullable=False)
|
||||
|
||||
# Wir brauchen kein 'is_blocked' Flag mehr, da wir dynamisch zählen,
|
||||
# wie oft 'start_time' in den letzten 24h verwendet wurde.
|
||||
|
||||
job = relationship("ProposalJob", back_populates="slots")
|
||||
|
||||
# DB Setup Helper
|
||||
def init_db(db_path='sqlite:///trading_twins/trading_twins.db'):
|
||||
engine = create_engine(db_path)
|
||||
Base.metadata.create_all(engine)
|
||||
return sessionmaker(bind=engine)
|
||||
130
lead-engine/trading_twins/orchestrator.py
Normal file
130
lead-engine/trading_twins/orchestrator.py
Normal file
@@ -0,0 +1,130 @@
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
import datetime
|
||||
from .manager import TradingTwinsManager
|
||||
from .teams_notification import send_approval_card
|
||||
from .email_sender import send_email_via_graph
|
||||
import os
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("TradingTwinsOrchestrator")
|
||||
|
||||
TIMEOUT_SECONDS = 300 # 5 Minuten
|
||||
SIGNATURE_FILE = "trading_twins/signature.html"
|
||||
BANNER_IMAGE = "trading_twins/RoboPlanetBannerWebinarEinladung.png"
|
||||
|
||||
class TradingTwinsOrchestrator:
|
||||
def __init__(self):
|
||||
self.manager = TradingTwinsManager()
|
||||
|
||||
def process_lead(self, customer_email, customer_name, customer_company):
|
||||
"""
|
||||
Startet den gesamten Prozess für einen Lead.
|
||||
"""
|
||||
logger.info(f"Neuer Lead eingegangen: {customer_email}")
|
||||
|
||||
# 1. Job und Slots erstellen (mit Faktor-3 Logik)
|
||||
job_uuid, slots = self.manager.create_proposal_job(
|
||||
customer_email, customer_name, customer_company
|
||||
)
|
||||
logger.info(f"Job erstellt: {job_uuid}. Slots: {slots}")
|
||||
|
||||
# 2. Teams Benachrichtigung senden
|
||||
# Formatieren der Uhrzeit für die Teams-Nachricht
|
||||
send_time = (datetime.datetime.now() + datetime.timedelta(seconds=TIMEOUT_SECONDS)).strftime("%H:%M")
|
||||
|
||||
success = send_approval_card(job_uuid, customer_name, send_time)
|
||||
if not success:
|
||||
logger.error("Konnte Teams-Benachrichtigung nicht senden!")
|
||||
# Fallback? Trotzdem Timer starten oder abbrechen?
|
||||
# Wir machen weiter, da E-Mail-Versand Priorität hat.
|
||||
|
||||
# 3. Timer starten für automatischen Versand
|
||||
timer = threading.Timer(TIMEOUT_SECONDS, self._check_timeout, args=[job_uuid])
|
||||
timer.start()
|
||||
|
||||
return job_uuid
|
||||
|
||||
def _check_timeout(self, job_uuid):
|
||||
"""
|
||||
Wird nach Ablauf des Timers aufgerufen.
|
||||
Prüft den Status und sendet ggf. automatisch.
|
||||
"""
|
||||
logger.info(f"Timer abgelaufen für Job {job_uuid}. Prüfe Status...")
|
||||
|
||||
current_status = self.manager.get_job_status(job_uuid)
|
||||
|
||||
if current_status == 'pending':
|
||||
logger.info(f"Job {job_uuid} ist noch 'pending'. Löse automatischen Versand aus.")
|
||||
self._trigger_email_send(job_uuid)
|
||||
elif current_status == 'approved':
|
||||
logger.info(f"Job {job_uuid} wurde bereits manuell genehmigt.")
|
||||
elif current_status == 'cancelled':
|
||||
logger.info(f"Job {job_uuid} wurde manuell abgebrochen.")
|
||||
else:
|
||||
logger.warning(f"Unbekannter Status für Job {job_uuid}: {current_status}")
|
||||
|
||||
def _trigger_email_send(self, job_uuid):
|
||||
"""
|
||||
Hier wird der tatsächliche E-Mail-Versand angestoßen.
|
||||
"""
|
||||
# Job Details laden
|
||||
job_details = self.manager.get_job_details(job_uuid)
|
||||
if not job_details:
|
||||
logger.error(f"Konnte Job {job_uuid} nicht finden!")
|
||||
return
|
||||
|
||||
# E-Mail Body zusammenbauen
|
||||
try:
|
||||
with open(SIGNATURE_FILE, "r") as f:
|
||||
signature_html = f.read()
|
||||
except FileNotFoundError:
|
||||
logger.warning("Signatur-Datei nicht gefunden!")
|
||||
signature_html = "<br>Viele Grüße<br>Ihr RoboPlanet Team"
|
||||
|
||||
# Dynamische Terminvorschläge formatieren
|
||||
slots_text = ""
|
||||
for slot in job_details['slots']:
|
||||
# Format: "Morgen, 14:00 Uhr" oder Datum
|
||||
start = slot['start']
|
||||
slots_text += f"<li>{start.strftime('%d.%m.%Y um %H:%M Uhr')}</li>"
|
||||
|
||||
email_body = f"""
|
||||
<html>
|
||||
<body>
|
||||
<p>Hallo {job_details['name']},</p>
|
||||
<p>vielen Dank für Ihr Interesse an Trading Twins.</p>
|
||||
<p>Gerne würde ich Ihnen in einem kurzen Gespräch (ca. 15-30 Min) zeigen, wie wir Sie unterstützen können.</p>
|
||||
<p>Hätten Sie an einem dieser Termine Zeit?</p>
|
||||
<ul>
|
||||
{slots_text}
|
||||
</ul>
|
||||
<p>Ich freue mich auf Ihre Rückmeldung.</p>
|
||||
{signature_html}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# Banner Check
|
||||
banner_path = BANNER_IMAGE if os.path.exists(BANNER_IMAGE) else None
|
||||
|
||||
# Senden
|
||||
success = send_email_via_graph(
|
||||
to_email=job_details['email'],
|
||||
subject="Ihr Termin für Trading Twins",
|
||||
body_html=email_body,
|
||||
banner_path=banner_path
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f"🚀 E-MAIL WURDE VERSENDET für Job {job_uuid}")
|
||||
self.manager.update_job_status(job_uuid, 'sent')
|
||||
else:
|
||||
logger.error(f"❌ Fehler beim E-Mail-Versand für Job {job_uuid}")
|
||||
self.manager.update_job_status(job_uuid, 'failed')
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test-Lauf
|
||||
orchestrator = TradingTwinsOrchestrator()
|
||||
orchestrator.process_lead("test@example.com", "Max Mustermann", "Musterfirma GmbH")
|
||||
40
lead-engine/trading_twins/signature.html
Normal file
40
lead-engine/trading_twins/signature.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>E-Mail Signatur</title>
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
HINWEIS:
|
||||
Dieser Inhalt wird von der IT-Abteilung bereitgestellt.
|
||||
Bitte den finalen HTML-Code hier einfügen.
|
||||
Das Bild 'RoboPlanetBannerWebinarEinladung.png' muss sich im selben Verzeichnis befinden.
|
||||
[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>
|
||||
70
lead-engine/trading_twins/teams_notification.py
Normal file
70
lead-engine/trading_twins/teams_notification.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import requests
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
# Default-Webhook (Platzhalter) - sollte in .env stehen
|
||||
DEFAULT_WEBHOOK_URL = os.getenv("TEAMS_WEBHOOK_URL", "")
|
||||
|
||||
def send_approval_card(job_uuid, customer_name, time_string, webhook_url=DEFAULT_WEBHOOK_URL):
|
||||
"""
|
||||
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)
|
||||
api_base_url = os.getenv("API_BASE_URL", "http://localhost:8004")
|
||||
|
||||
card_payload = {
|
||||
"type": "message",
|
||||
"attachments": [
|
||||
{
|
||||
"contentType": "application/vnd.microsoft.card.adaptive",
|
||||
"contentUrl": None,
|
||||
"content": {
|
||||
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
"type": "AdaptiveCard",
|
||||
"version": "1.4",
|
||||
"body": [
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"🤖 Automatisierte E-Mail an {customer_name}",
|
||||
"weight": "Bolder",
|
||||
"size": "Medium"
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"(via Trading Twins) wird um {time_string} Uhr ausgesendet.",
|
||||
"isSubtle": True,
|
||||
"wrap": True
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": "Wenn Du bis dahin NICHT reagierst, wird die E-Mail automatisch gesendet.",
|
||||
"color": "Attention",
|
||||
"wrap": True
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"type": "Action.OpenUrl",
|
||||
"title": "✅ JETZT Aussenden",
|
||||
"url": f"{api_base_url}/action/approve/{job_uuid}"
|
||||
},
|
||||
{
|
||||
"type": "Action.OpenUrl",
|
||||
"title": "❌ STOP Aussendung",
|
||||
"url": f"{api_base_url}/action/cancel/{job_uuid}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(webhook_url, json=card_payload)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Fehler beim Senden an Teams: {e}")
|
||||
return False
|
||||
139
lead-engine/trading_twins/test_dry_run.py
Normal file
139
lead-engine/trading_twins/test_dry_run.py
Normal file
@@ -0,0 +1,139 @@
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
import time
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Importiere unsere Module
|
||||
from trading_twins.orchestrator import TradingTwinsOrchestrator
|
||||
from trading_twins.manager import TradingTwinsManager
|
||||
from trading_twins.models import init_db, ProposalJob, ProposedSlot
|
||||
|
||||
# Logging reduzieren
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
class TestTradingTwinsDryRun(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# Wir nutzen eine temporäre Test-Datenbank
|
||||
cls.test_db_path = 'sqlite:///trading_twins/test_dry_run.db'
|
||||
if os.path.exists("trading_twins/test_dry_run.db"):
|
||||
os.remove("trading_twins/test_dry_run.db")
|
||||
|
||||
# Manager mit Test-DB initialisieren
|
||||
cls.manager = TradingTwinsManager(db_path=cls.test_db_path)
|
||||
|
||||
def setUp(self):
|
||||
# Orchestrator neu erstellen
|
||||
self.orchestrator = TradingTwinsOrchestrator()
|
||||
self.orchestrator.manager = self.manager # Inject Test Manager
|
||||
|
||||
# Timer drastisch verkürzen für Tests
|
||||
self.orchestrator_timeout_patch = patch('trading_twins.orchestrator.TIMEOUT_SECONDS', 2)
|
||||
self.orchestrator_timeout_patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.orchestrator_timeout_patch.stop()
|
||||
|
||||
@patch('trading_twins.orchestrator.send_email_via_graph')
|
||||
@patch('trading_twins.orchestrator.send_approval_card')
|
||||
def test_1_happy_path_timeout(self, mock_teams, mock_email):
|
||||
"""Testet den automatischen Versand nach Timeout."""
|
||||
print("\n--- TEST 1: Happy Path (Timeout -> Auto-Send) ---")
|
||||
mock_teams.return_value = True
|
||||
mock_email.return_value = True
|
||||
|
||||
# Lead verarbeiten
|
||||
job_uuid = self.orchestrator.process_lead("test1@example.com", "Kunde Eins", "Firma A")
|
||||
|
||||
print(f"Job {job_uuid} gestartet. Warte auf Timeout (2s).")
|
||||
time.sleep(3) # Warte länger als Timeout
|
||||
|
||||
# Prüfungen
|
||||
mock_teams.assert_called_once()
|
||||
mock_email.assert_called_once() # E-Mail muss versendet worden sein
|
||||
|
||||
status = self.manager.get_job_status(job_uuid)
|
||||
self.assertEqual(status, 'sent')
|
||||
print("✅ E-Mail wurde automatisch versendet.")
|
||||
|
||||
@patch('trading_twins.orchestrator.send_email_via_graph')
|
||||
@patch('trading_twins.orchestrator.send_approval_card')
|
||||
def test_2_manual_cancel(self, mock_teams, mock_email):
|
||||
"""Testet den manuellen Abbruch."""
|
||||
print("\n--- TEST 2: Manueller Abbruch (STOP) ---")
|
||||
mock_teams.return_value = True
|
||||
|
||||
# Lead verarbeiten
|
||||
job_uuid = self.orchestrator.process_lead("test2@example.com", "Kunde Zwei", "Firma B")
|
||||
|
||||
# Simuliere Klick auf "STOP" (direkter DB-Update wie API es tun würde)
|
||||
print("Simuliere Klick auf 'STOP'...")
|
||||
self.manager.update_job_status(job_uuid, 'cancelled')
|
||||
|
||||
print("Warte auf Timeout (2s).")
|
||||
time.sleep(3)
|
||||
|
||||
# Prüfungen
|
||||
mock_teams.assert_called_once()
|
||||
mock_email.assert_not_called() # E-Mail darf NICHT versendet werden
|
||||
|
||||
status = self.manager.get_job_status(job_uuid)
|
||||
self.assertEqual(status, 'cancelled')
|
||||
print("✅ Abbruch erfolgreich, keine E-Mail gesendet.")
|
||||
|
||||
@patch('trading_twins.manager.TradingTwinsManager._mock_calendar_availability')
|
||||
def test_3_overbooking_factor_3(self, mock_calendar):
|
||||
"""
|
||||
Testet die Faktor-3 Logik.
|
||||
Wir stellen 4 Slots zur Verfügung.
|
||||
Wir erzeugen 4 Leads.
|
||||
Die ersten 3 sollten Slot A bekommen.
|
||||
Der 4. Lead sollte Slot A NICHT mehr bekommen, sondern Slot B (oder andere).
|
||||
"""
|
||||
print("\n--- TEST 3: Überbuchungs-Logik (Faktor 3) ---")
|
||||
|
||||
# Setup Mock Calendar: Gibt immer dieselben 4 Slots zurück
|
||||
tomorrow = datetime.now().date() + timedelta(days=1)
|
||||
slot_a = {'start': datetime.combine(tomorrow, datetime.min.time().replace(hour=10)), 'end': datetime.combine(tomorrow, datetime.min.time().replace(hour=10, minute=45))}
|
||||
slot_b = {'start': datetime.combine(tomorrow, datetime.min.time().replace(hour=11)), 'end': datetime.combine(tomorrow, datetime.min.time().replace(hour=11, minute=45))}
|
||||
slot_c = {'start': datetime.combine(tomorrow, datetime.min.time().replace(hour=14)), 'end': datetime.combine(tomorrow, datetime.min.time().replace(hour=14, minute=45))}
|
||||
|
||||
# Mock gibt diese Liste zurück
|
||||
mock_calendar.return_value = [slot_a, slot_b, slot_c]
|
||||
|
||||
# Wir feuern 4 Leads ab
|
||||
uuids = []
|
||||
for i in range(1, 5):
|
||||
uuid, slots = self.manager.create_proposal_job(f"bulk{i}@test.com", f"Bulk {i}", "Bulk Corp")
|
||||
uuids.append((uuid, slots))
|
||||
# print(f"Lead {i} bekam Slots: {[s['start'].strftime('%H:%M') for s in slots]}")
|
||||
|
||||
# Analyse
|
||||
# Lead 1: Bekommt A, B (A hat Count 0 -> 1)
|
||||
# Lead 2: Bekommt A, B (A hat Count 1 -> 2)
|
||||
# Lead 3: Bekommt A, B (A hat Count 2 -> 3 -> VOLL)
|
||||
# Lead 4: Sollte A NICHT bekommen, sondern B, C
|
||||
|
||||
slots_lead_1 = uuids[0][1]
|
||||
slots_lead_4 = uuids[3][1]
|
||||
|
||||
start_time_lead_1_first_slot = slots_lead_1[0]['start']
|
||||
start_time_lead_4_first_slot = slots_lead_4[0]['start']
|
||||
|
||||
print(f"Lead 1 Slot 1: {start_time_lead_1_first_slot}")
|
||||
print(f"Lead 4 Slot 1: {start_time_lead_4_first_slot}")
|
||||
|
||||
if start_time_lead_1_first_slot != start_time_lead_4_first_slot:
|
||||
print("✅ Faktor-3 Logik greift: Lead 4 hat einen anderen Start-Slot bekommen!")
|
||||
else:
|
||||
print("❌ Faktor-3 Logik fehlgeschlagen: Lead 4 hat denselben Slot bekommen.")
|
||||
# Debug
|
||||
session = self.manager.Session()
|
||||
count = session.query(ProposedSlot).filter(ProposedSlot.start_time == start_time_lead_1_first_slot).count()
|
||||
print(f"Total entries for Slot A: {count}")
|
||||
session.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
50
lead-engine/trading_twins/test_teams_webhook.py
Normal file
50
lead-engine/trading_twins/test_teams_webhook.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import requests
|
||||
import json
|
||||
import os
|
||||
|
||||
def send_teams_message(webhook_url, message):
|
||||
"""
|
||||
Sends a simple message to a Microsoft Teams channel using a webhook.
|
||||
|
||||
Args:
|
||||
webhook_url (str): The URL of the incoming webhook.
|
||||
message (str): The plain text message to send.
|
||||
|
||||
Returns:
|
||||
bool: True if the message was sent successfully (HTTP 200), False otherwise.
|
||||
"""
|
||||
if not webhook_url:
|
||||
print("Error: TEAMS_WEBHOOK_URL is not set.")
|
||||
return False
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"text": message
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(webhook_url, headers=headers, data=json.dumps(payload), timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
print("Message sent successfully to Teams.")
|
||||
return True
|
||||
else:
|
||||
print(f"Failed to send message. Status code: {response.status_code}")
|
||||
print(f"Response: {response.text}")
|
||||
return False
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"An error occurred while sending the request: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
# The webhook URL is taken directly from the project description for this test.
|
||||
# In a real application, this should be loaded from an environment variable.
|
||||
webhook_url = "https://wacklergroup.webhook.office.com/webhookb2/fe728cde-790c-4190-b1d3-be393ca0f9bd@6d85a9ef-3878-420b-8f43-38d6cb12b665/IncomingWebhook/e9a8ee6157594a6cab96048cf2ea2232/d26033cd-a81f-41a6-8cd2-b4a3ba0b5a01/V2WFmjcbkMzSU4f6lDSdUOM9VNm7F7n1Th4YDiu3fLZ_Y1"
|
||||
|
||||
test_message = "🤖 This is a test message from the Gemini Trading Twins Engine. If you see this, the webhook is working. [31988f42]"
|
||||
|
||||
send_teams_message(webhook_url, test_message)
|
||||
Reference in New Issue
Block a user