[31988f42] Lead-Engine: Produktivsetzung und Anfrage per Teams

Implementiert:
*   **End-to-End Test-Button pro Lead:** Ein neuer Button "🧪 Test-Versand (an floke.com@gmail.com)" wurde in der Lead-Detailansicht hinzugefügt, um spezifische Leads sicher zu testen.
*   **Verbesserte E-Mail-Generierung:**
    *   Der LLM-Prompt wurde optimiert, um redundante Termin-Vorschläge und Betreffzeilen im generierten E-Mail-Text zu vermeiden.
    *   Der E-Mail-Body wurde umstrukturiert für eine klarere und leserlichere Integration des LLM-generierten Textes und der dynamischen Terminvorschläge.
*   **HTML-Signatur mit Inline-Bildern:**
    *   Ein Skript zum Extrahieren von HTML-Signaturen und eingebetteten Bildern aus -Dateien wurde erstellt und ausgeführt.
    *   Die -Funktion wurde überarbeitet, um die neue HTML-Signatur und alle zugehörigen Bilder dynamisch als Inline-Anhänge zu versenden.
*   **Bugfixes und verbesserte Diagnosefähigkeit:**
    *   Der  für  wurde durch Verschieben der Funktion in den globalen Bereich behoben.
    *   Die  im Kalender-Abruf wurde durch die explizite Übergabe der Zeitzoneninformation an die Graph API korrigiert.
    *   Fehlende Uhrzeit in Teams-Nachrichten behoben.
    *   Umfassendes Logging wurde in kritischen Funktionen (, , ) implementiert, um die Diagnosefähigkeit bei zukünftigen Problemen zu verbessern.
This commit is contained in:
2026-03-09 08:21:33 +00:00
parent 14237727b9
commit a9b7dbaaca
12 changed files with 375 additions and 94 deletions

View File

@@ -9,11 +9,31 @@ from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from threading import Thread, Lock
import uvicorn
import logging
from fastapi import FastAPI, Response, BackgroundTasks
from pydantic import BaseModel
from sqlalchemy.orm import sessionmaker
# --- Setup Logging ---
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
import msal
from .models import init_db, ProposalJob, ProposedSlot
class TestLeadPayload(BaseModel):
company_name: str
contact_name: str
opener: str
def format_date_for_email(dt: datetime) -> str:
"""Formats a datetime object to 'Heute HH:MM', 'Morgen HH:MM', or 'DD.MM. HH:MM'."""
now = datetime.now(TZ_BERLIN).date()
if dt.date() == now:
return dt.strftime("Heute %H:%M Uhr")
elif dt.date() == (now + timedelta(days=1)):
return dt.strftime("Morgen %H:%M Uhr")
else:
return dt.strftime("%d.%m. %H:%M Uhr")
# --- Setup ---
TZ_BERLIN = ZoneInfo("Europe/Berlin")
DB_FILE_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "trading_twins.db")
@@ -48,32 +68,31 @@ def get_access_token(client_id, client_secret, tenant_id):
return result.get('access_token')
def get_availability(target_email, app_creds):
print(f"DEBUG: Requesting availability for {target_email}")
logging.info(f"Requesting availability for {target_email}")
token = get_access_token(*app_creds)
if not token:
print("DEBUG: Failed to acquire access token.")
logging.error("Failed to acquire access token for calendar.")
return None
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json", "Prefer": 'outlook.timezone="Europe/Berlin"'}
start_time = datetime.now(TZ_BERLIN).replace(hour=0, minute=0, second=0, microsecond=0)
end_time = start_time + timedelta(days=3)
# Use 15-minute intervals for finer granularity
payload = {"schedules": [target_email], "startTime": {"dateTime": start_time.isoformat()}, "endTime": {"dateTime": end_time.isoformat()}, "availabilityViewInterval": 15}
payload = {"schedules": [target_email], "startTime": {"dateTime": start_time.isoformat(), "timeZone": str(TZ_BERLIN)}, "endTime": {"dateTime": end_time.isoformat(), "timeZone": str(TZ_BERLIN)}, "availabilityViewInterval": 15}
try:
url = f"{GRAPH_API_ENDPOINT}/users/{target_email}/calendar/getSchedule"
r = requests.post(url, headers=headers, json=payload)
print(f"DEBUG: API Status Code: {r.status_code}")
logging.info(f"Graph API getSchedule status code: {r.status_code}")
if r.status_code == 200:
view = r.json()['value'][0].get('availabilityView', '')
print(f"DEBUG: Availability View received (Length: {len(view)})")
logging.info(f"Availability View received (Length: {len(view)})")
return start_time, view, 15
else:
print(f"DEBUG: API Error Response: {r.text}")
logging.error(f"Graph API Error Response: {r.text}")
except Exception as e:
print(f"DEBUG: Exception during API call: {e}")
pass
logging.error(f"Exception during Graph API call: {e}")
return None
def find_slots(start, view, interval):
@@ -135,6 +154,22 @@ def trigger_test_lead(background_tasks: BackgroundTasks):
background_tasks.add_task(process_lead, req_id, "Testfirma GmbH", "Wir haben Ihre Anfrage erhalten.", TEST_RECEIVER_EMAIL, "Max Mustermann")
return {"status": "Test lead triggered", "id": req_id}
@app.post("/test_specific_lead", status_code=202)
def trigger_specific_test_lead(payload: TestLeadPayload, background_tasks: BackgroundTasks):
"""Triggers a lead process with specific data but sends email to the TEST_RECEIVER_EMAIL."""
req_id = f"test_specific_{int(time.time())}"
# Key difference: Use data from payload, but force the receiver email
background_tasks.add_task(
process_lead,
request_id=req_id,
company=payload.company_name,
opener=payload.opener,
receiver=TEST_RECEIVER_EMAIL, # <--- FORCED TEST EMAIL
name=payload.contact_name
)
return {"status": "Specific test lead triggered", "id": req_id}
@app.get("/stop/{job_uuid}")
def stop(job_uuid: str):
db = SessionLocal(); job = db.query(ProposalJob).filter(ProposalJob.job_uuid == job_uuid).first()
@@ -157,80 +192,144 @@ def book_slot(job_uuid: str, ts: int):
db.close(); return Response("Fehler bei Kalender.", 500)
# --- Workflow Logic ---
def send_email(subject, body, to_email, signature, banner_path=None):
def send_email(subject, body, to_email):
"""
Sends an email using Microsoft Graph API, attaching a dynamically generated
HTML signature with multiple inline images.
"""
logging.info(f"Preparing to send email to {to_email} with subject: '{subject}'")
# 1. Read the signature file
try:
with open(SIGNATURE_FILE_PATH, 'r', encoding='utf-8') as f:
signature_html = f.read()
except Exception as e:
logging.error(f"Could not read signature file: {e}")
signature_html = "" # Fallback to no signature
# 2. Find and prepare all signature images as attachments
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"
})
image_dir = os.path.dirname(SIGNATURE_FILE_PATH)
image_files = [f for f in os.listdir(image_dir) if f.startswith('image') and f.endswith('.png')]
for filename in image_files:
try:
with open(os.path.join(image_dir, filename), "rb") as f:
content_bytes = f.read()
content_b64 = base64.b64encode(content_bytes).decode("utf-8")
attachments.append({
"@odata.type": "#microsoft.graph.fileAttachment",
"name": filename,
"contentBytes": content_b64,
"isInline": True,
"contentId": filename
})
except Exception as e:
logging.error(f"Could not process image {filename}: {e}")
# 3. Get access token
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)
if not token: return
if not token:
logging.error("Failed to get access token for sending email.")
return
# 4. Construct and send the email
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"}
if attachments: payload["message"]["attachments"] = attachments
requests.post(f"{GRAPH_API_ENDPOINT}/users/{SENDER_EMAIL}/sendMail", headers=headers, json=payload)
full_body = body + signature_html
payload = {
"message": {
"subject": subject,
"body": {"contentType": "HTML", "content": full_body},
"toRecipients": [{"emailAddress": {"address": to_email}}]
},
"saveToSentItems": "true"
}
if attachments:
payload["message"]["attachments"] = attachments
response = requests.post(f"{GRAPH_API_ENDPOINT}/users/{SENDER_EMAIL}/sendMail", headers=headers, json=payload)
logging.info(f"Send mail API response status: {response.status_code}")
if response.status_code not in [200, 202]:
logging.error(f"Error sending mail: {response.text}")
def process_lead(request_id, company, opener, receiver, name):
logging.info(f"--- Starting process_lead for request_id: {request_id} ---")
db = SessionLocal()
job = ProposalJob(job_uuid=request_id, customer_email=receiver, customer_company=company, customer_name=name, status="pending")
db.add(job); db.commit()
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 []
# --- FALLBACK LOGIC ---
if not suggestions:
print("WARNING: No slots found via API. Creating fallback slots.")
now = datetime.now(TZ_BERLIN)
# Tomorrow 10:00
tomorrow = (now + timedelta(days=1)).replace(hour=10, minute=0, second=0, microsecond=0)
# Day after tomorrow 14:00
overmorrow = (now + timedelta(days=2)).replace(hour=14, minute=0, second=0, microsecond=0)
suggestions = [tomorrow, overmorrow]
# --------------------
for s in suggestions: db.add(ProposedSlot(job_id=job.id, start_time=s, end_time=s+timedelta(minutes=15)))
db.commit()
send_time = datetime.now(TZ_BERLIN) + timedelta(minutes=DEFAULT_WAIT_MINUTES)
# 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)
while datetime.now(TZ_BERLIN) < send_time:
db.refresh(job)
if job.status in ["cancelled", "send_now"]: break
time.sleep(5)
if job.status == "cancelled": db.close(); return
booking_html = "<ul>"
for s in suggestions: booking_html += f'<li><a href="{FEEDBACK_SERVER_BASE_URL}/book_slot/{request_id}/{int(s.timestamp())}">{s.strftime("%d.%m %H:%M")}</a></li>'
booking_html += "</ul>"
try:
with open(SIGNATURE_FILE_PATH, 'r') as f: sig = f.read()
except: sig = ""
job = ProposalJob(job_uuid=request_id, customer_email=receiver, customer_company=company, customer_name=name, status="pending")
db.add(job)
db.commit()
logging.info(f"Job {request_id} created and saved to DB.")
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 []
if not suggestions:
logging.warning(f"No slots found via API for job {request_id}. Creating fallback slots.")
now = datetime.now(TZ_BERLIN)
tomorrow = (now + timedelta(days=1)).replace(hour=10, minute=0, second=0, microsecond=0)
overmorrow = (now + timedelta(days=2)).replace(hour=14, minute=0, second=0, microsecond=0)
suggestions = [tomorrow, overmorrow]
logging.info(f"Found/created {len(suggestions)} slot suggestions for job {request_id}.")
for s in suggestions:
db.add(ProposedSlot(job_id=job.id, start_time=s, end_time=s + timedelta(minutes=15)))
db.commit()
send_time = datetime.now(TZ_BERLIN) + timedelta(minutes=DEFAULT_WAIT_MINUTES)
logging.info(f"Sending Teams approval card for job {request_id}.")
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)
logging.info(f"Waiting for response or timeout until {send_time.strftime('%H:%M:%S')} for job {request_id}")
wait_until = datetime.now(TZ_BERLIN) + timedelta(minutes=DEFAULT_WAIT_MINUTES)
while datetime.now(TZ_BERLIN) < wait_until:
db.refresh(job)
if job.status in ["cancelled", "send_now"]:
logging.info(f"Status for job {request_id} changed to '{job.status}'. Exiting wait loop.")
break
time.sleep(5)
db.refresh(job)
if job.status == "cancelled":
logging.info(f"Job {request_id} was cancelled. No email will be sent.")
return
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>'
booking_html += "</ul>"
try:
with open(SIGNATURE_FILE_PATH, 'r') as f:
sig = f.read()
except:
sig = ""
email_body = f"""
<p>Hallo {name},</p>
{opener}
<p>Ich freue mich auf den Austausch und schlage Ihnen hierfür konkrete Termine vor:</p>
<ul>
{booking_html}
</ul>
<p>Mit freundlichen Grüßen,</p>
"""
send_email(f"Ihr Kontakt mit RoboPlanet - {company}", email_body, receiver)
job.status = "sent"
db.commit()
logging.info(f"--- Finished process_lead for request_id: {request_id} ---")
except Exception as e:
logging.error(f"FATAL error in process_lead for request_id {request_id}: {e}", exc_info=True)
finally:
db.close()
# THIS IS THE CORRECTED EMAIL BODY
email_body = f"""
<p>Hallo {name},</p>
<p>{opener}</p>
<p>Hätten Sie an einem dieser Termine Zeit für ein kurzes Gespräch?</p>
{booking_html}
"""
send_email(f"Ihr Kontakt mit RoboPlanet - {company}", email_body, receiver, sig, BANNER_FILE_PATH)
job.status = "sent"; db.commit(); db.close()
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8004)