Compare commits
2 Commits
6434d210d2
...
bc1bb4ae61
| Author | SHA1 | Date | |
|---|---|---|---|
| bc1bb4ae61 | |||
| b60d38994d |
@@ -252,6 +252,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8501:8501"
|
- "8501:8501"
|
||||||
|
- "8004:8004"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
@@ -260,6 +261,10 @@ services:
|
|||||||
# Explicitly pass keys to ensure availability
|
# Explicitly pass keys to ensure availability
|
||||||
SERP_API: "${SERP_API}"
|
SERP_API: "${SERP_API}"
|
||||||
GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||||
|
# Calendar App Credentials
|
||||||
|
CAL_APPID: "${CAL_APPID}"
|
||||||
|
CAL_SECRET: "${CAL_SECRET}"
|
||||||
|
CAL_TENNANT_ID: "${CAL_TENNANT_ID}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./lead-engine:/app
|
- ./lead-engine:/app
|
||||||
# We need to mount the root connector module so it can be imported inside the container
|
# We need to mount the root connector module so it can be imported inside the container
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ WORKDIR /app
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Install dependencies required for ingestion and DB
|
# Install dependencies required for ingestion and DB
|
||||||
RUN pip install streamlit pandas requests python-dotenv
|
RUN pip install streamlit pandas requests python-dotenv fastapi "uvicorn[standard]" msal
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
EXPOSE 8501
|
||||||
|
EXPOSE 8004
|
||||||
# Start monitor in background and streamlit in foreground
|
# Start monitor in background and streamlit in foreground
|
||||||
CMD ["sh", "-c", "python monitor.py & streamlit run app.py --server.port=8501 --server.address=0.0.0.0"]
|
CMD ["sh", "-c", "python monitor.py & streamlit run app.py --server.port=8501 --server.address=0.0.0.0"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Lead Engine: Multi-Source Automation v1.2 [31988f42]
|
# Lead Engine: Multi-Source Automation v1.3 [31988f42]
|
||||||
|
|
||||||
## 🚀 Übersicht
|
## 🚀 Übersicht
|
||||||
Die **Lead Engine** ist ein spezialisiertes Modul zur autonomen Verarbeitung von B2B-Anfragen aus verschiedenen Quellen. Sie fungiert als Brücke zwischen dem E-Mail-Postfach und dem **Company Explorer**, um innerhalb von Minuten hochgradig personalisierte Antwort-Entwürfe auf "Human Expert Level" zu generieren.
|
Die **Lead Engine** ist ein spezialisiertes Modul zur autonomen Verarbeitung von B2B-Anfragen aus verschiedenen Quellen. Sie fungiert als Brücke zwischen dem E-Mail-Postfach und dem **Company Explorer**, um innerhalb von Minuten hochgradig personalisierte Antwort-Entwürfe auf "Human Expert Level" zu generieren.
|
||||||
@@ -29,18 +29,21 @@ Die **Lead Engine** ist ein spezialisiertes Modul zur autonomen Verarbeitung von
|
|||||||
* **Status-Tracking:** Visueller Indikator (🆕/✅) für den Synchronisations-Status mit dem Company Explorer.
|
* **Status-Tracking:** Visueller Indikator (🆕/✅) für den Synchronisations-Status mit dem Company Explorer.
|
||||||
* **Low-Quality-Warnung:** Visuelle Kennzeichnung (⚠️) von Leads mit Free-Mail-Adressen oder ohne Firmennamen direkt in der Übersicht.
|
* **Low-Quality-Warnung:** Visuelle Kennzeichnung (⚠️) von Leads mit Free-Mail-Adressen oder ohne Firmennamen direkt in der Übersicht.
|
||||||
|
|
||||||
### 6. Trading Twins Autopilot (NEU v2.0)
|
### 6. Trading Twins Autopilot (PRODUKTIV v2.0)
|
||||||
Der vollautomatische "Zero Touch" Workflow für Trading Twins Anfragen.
|
Der vollautomatische "Zero Touch" Workflow für Trading Twins Anfragen.
|
||||||
|
|
||||||
* **Human-in-the-Loop:** Vor Versand erhält Elizabeta Melcer eine Teams-Nachricht ("Approve/Deny").
|
* **Human-in-the-Loop:** Vor Versand erhält Elizabeta Melcer eine Teams-Nachricht ("Approve/Deny") via Adaptive Card.
|
||||||
* **5-Minuten-Timeout:** Erfolgt keine Reaktion, wird die E-Mail automatisch versendet.
|
* **Feedback-Server:** Ein integrierter FastAPI-Server (Port 8004) verarbeitet die Klicks aus Teams und gibt sofortiges visuelles Feedback.
|
||||||
* **Smart Calendar:**
|
* **Direct Calendar Booking (Eigener Service):**
|
||||||
* **Faktor-3-Überbuchung:** Termine werden bis zu 3x parallel angeboten, um den Kalender dicht zu füllen.
|
* **Problem:** MS Bookings API lässt sich nicht per Application Permission steuern (Erstellung verboten).
|
||||||
* **Soft-Blocking:** Interne Datenbank verhindert Doppelbuchungen über den Faktor 3 hinaus.
|
* **Lösung:** Wir haben einen eigenen Micro-Booking-Service gebaut.
|
||||||
|
* **Ablauf:** Das System prüft echte freie Slots im Kalender von `e.melcer` (via Graph API).
|
||||||
|
* **E-Mail:** Der Kunde erhält eine E-Mail mit zwei konkreten Terminvorschlägen (Links).
|
||||||
|
* **Buchung:** Klick auf einen Link -> Server bestätigt -> **Echte Outlook-Kalendereinladung** wird automatisch von `info@` versendet.
|
||||||
* **Technologie:**
|
* **Technologie:**
|
||||||
* **Teams Webhook:** Für interaktive "Adaptive Cards".
|
* **Teams Webhook:** Für interaktive "Adaptive Cards".
|
||||||
* **Graph API:** Für sicheren E-Mail-Versand (statt SMTP).
|
* **Graph API:** Für E-Mail-Versand (`info@`) und Kalender-Check (`e.melcer`).
|
||||||
* **Orchestrator:** Steuert den Ablauf (Lead -> CE -> Teams -> Timer -> Mail).
|
* **Orchestrator (`manager.py`):** Steuert den Ablauf (Lead -> CE -> Teams -> Timer -> Mail -> Booking).
|
||||||
|
|
||||||
## 🏗 Architektur
|
## 🏗 Architektur
|
||||||
|
|
||||||
@@ -50,44 +53,65 @@ Der vollautomatische "Zero Touch" Workflow für Trading Twins Anfragen.
|
|||||||
├── trading_twins_ingest.py # E-Mail Importer (Graph API)
|
├── trading_twins_ingest.py # E-Mail Importer (Graph API)
|
||||||
├── monitor.py # Monitor + Trigger für Orchestrator
|
├── monitor.py # Monitor + Trigger für Orchestrator
|
||||||
├── trading_twins/ # [NEU] Autopilot Modul
|
├── trading_twins/ # [NEU] Autopilot Modul
|
||||||
│ ├── orchestrator.py # Prozess-Steuerung (Timer, Logic)
|
│ ├── manager.py # Orchestrator, FastAPI Server, Graph API Logic
|
||||||
│ ├── manager.py # Slot-Logik & DB-Zugriff
|
│ ├── signature.html # HTML-Signatur für E-Mails
|
||||||
│ ├── teams_notification.py# Teams Webhook Integration
|
│ └── debug_bookings_only.py # Diagnose-Tool (Legacy)
|
||||||
│ ├── email_sender.py # Graph API Mailer
|
|
||||||
│ ├── api_server.py # Feedback-Endpunkt (Port 8004)
|
|
||||||
│ └── models.py # SQLite DB für Jobs/Slots
|
|
||||||
├── db.py # Lokale Lead-Datenbank
|
├── db.py # Lokale Lead-Datenbank
|
||||||
└── data/ # DB-Storage
|
└── data/ # DB-Storage
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 🚨 Lessons Learned & Troubleshooting (Critical)
|
||||||
|
|
||||||
|
### 1. Microsoft Bookings API Falle
|
||||||
|
* **Problem:** Wir wollten `Bookings.Manage.All` nutzen, um eine Buchungsseite für `info@` zu erstellen.
|
||||||
|
* **Fehler:** `403 Forbidden` ("Api Business.Create does not support the token type: App") und `500 Internal Server Error` (bei `GET`).
|
||||||
|
* **Erkenntnis:** Eine App (Service Principal) kann zwar Bookings *verwalten*, aber **nicht initial erstellen**. Die erste Seite muss zwingend manuell oder per Delegated-User angelegt werden. Zudem erfordert der Zugriff oft eine User-Lizenz, die Service Principals nicht haben.
|
||||||
|
* **Lösung:** Umstieg auf **Direct Calendar Booking** (Graph API `Calendar.ReadWrite`). Wir schreiben Termine direkt in den Outlook-Kalender, statt über die Bookings-Schicht zu gehen. Das ist robuster und voll automatisierbar.
|
||||||
|
|
||||||
|
### 2. Zwei Azure Apps für Sicherheit
|
||||||
|
Wir nutzen zwei getrennte App-Registrierungen, um "Least Privilege" zu wahren:
|
||||||
|
* **App 1 (`INFO_...`):** Hat Schreibrechte (`Mail.Send`, `Calendars.ReadWrite`) für das `info@robo-planet.de` Postfach. Sie sendet E-Mails und erstellt die Termine.
|
||||||
|
* **App 2 (`CAL_...`):** Hat **nur** Leserechte (`Calendars.ReadBasic.All`) für den Kalender von `e.melcer@robo-planet.de`. Sie wird genutzt, um Konflikte zu prüfen, darf aber nichts ändern oder E-Mails lesen.
|
||||||
|
|
||||||
|
### 3. Docker Networking & Public URLs
|
||||||
|
* **Problem:** Links in Teams-Nachrichten zeigten auf `http://lead-engine:8004` (interner Docker-Name) und waren von außen nicht erreichbar.
|
||||||
|
* **Lösung:** Die URL muss immer die **öffentliche, vom Nginx-Proxy geroutete URL** sein (`https://floke-ai.duckdns.org/feedback`).
|
||||||
|
* **Konfiguration:** Nginx leitet `/feedback/` an Port 8004 des `lead-engine` Containers weiter.
|
||||||
|
|
||||||
## 🚀 Inbetriebnahme (Docker)
|
## 🚀 Inbetriebnahme (Docker)
|
||||||
|
|
||||||
Die Lead Engine ist als Service in der zentralen `docker-compose.yml` integriert.
|
Die Lead Engine ist als Service in der zentralen `docker-compose.yml` integriert.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Neustart des Dienstes nach Code-Änderungen
|
# Neustart des Dienstes nach Code-Änderungen
|
||||||
docker-compose restart lead-engine
|
docker-compose up -d --build --force-recreate lead-engine
|
||||||
```
|
```
|
||||||
|
|
||||||
**Zugriff:** `https://floke-ai.duckdns.org/lead/` (Passwortgeschützt)
|
**Zugriff:** `https://floke-ai.duckdns.org/lead/` (Passwortgeschützt)
|
||||||
**API Feedback Loop:** Port 8004 (intern).
|
**Feedback API:** `https://floke-ai.duckdns.org/feedback/` (Öffentlich)
|
||||||
|
|
||||||
## 📝 ToDos & Integration (Status: Warten auf IT)
|
## 📝 Credentials (.env)
|
||||||
|
|
||||||
Die Logik ist implementiert und getestet ("Dry Run"). Für den Go-Live fehlen folgende Credentials in der `.env`:
|
Für den Betrieb sind folgende Variablen in der zentralen `.env` zwingend erforderlich:
|
||||||
|
|
||||||
1. **Teams Webhook:**
|
```env
|
||||||
* Benötigt: URL für den "Incoming Webhook" Connector.
|
# App 1: Info-Postfach (Schreiben)
|
||||||
* Env-Var: `TEAMS_WEBHOOK_URL`
|
INFO_Application_ID=...
|
||||||
|
INFO_Tenant_ID=...
|
||||||
|
INFO_Secret=...
|
||||||
|
|
||||||
2. **Microsoft Graph API:**
|
# App 2: E.Melcer Kalender (Lesen)
|
||||||
* Benötigt: App Registration mit `Mail.Send` und `Calendars.Read`.
|
CAL_APPID=...
|
||||||
* Env-Vars: `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID`.
|
CAL_TENNANT_ID=...
|
||||||
|
CAL_SECRET=...
|
||||||
|
|
||||||
3. **Assets:**
|
# Teams
|
||||||
* [ ] Banner-Bild `RoboPlanetBannerWebinarEinladung.png` nach `/app/lead-engine/trading_twins/` hochladen.
|
TEAMS_WEBHOOK_URL=...
|
||||||
* [ ] HTML-Signatur in `/app/lead-engine/trading_twins/signature.html` finalisieren.
|
|
||||||
|
# Public URL
|
||||||
|
FEEDBACK_SERVER_BASE_URL=https://floke-ai.duckdns.org/feedback
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
*Dokumentationsstand: 4. März 2026*
|
*Dokumentationsstand: 5. März 2026*
|
||||||
*Task: [31988f42]*
|
*Task: [31988f42]*
|
||||||
|
|||||||
@@ -1,133 +1,241 @@
|
|||||||
import datetime
|
# lead-engine/trading_twins/manager.py
|
||||||
from sqlalchemy import create_engine, func
|
from email.mime.text import MIMEText
|
||||||
from sqlalchemy.orm import sessionmaker
|
import base64
|
||||||
from .models import ProposalJob, ProposedSlot, Base
|
import requests
|
||||||
import uuid
|
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
|
||||||
|
|
||||||
# Konfiguration
|
# --- Zeitzonen-Konfiguration ---
|
||||||
DB_PATH = 'sqlite:///trading_twins/trading_twins.db'
|
TZ_BERLIN = ZoneInfo("Europe/Berlin")
|
||||||
MAX_PROPOSALS_PER_SLOT = 3 # Aggressiver Faktor 3
|
|
||||||
|
|
||||||
class TradingTwinsManager:
|
# --- Konfiguration ---
|
||||||
def __init__(self, db_path=DB_PATH):
|
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")
|
||||||
self.engine = create_engine(db_path)
|
# Öffentliche URL für Feedback-Links
|
||||||
self.Session = sessionmaker(bind=self.engine)
|
FEEDBACK_SERVER_BASE_URL = os.getenv("FEEDBACK_SERVER_BASE_URL", "https://floke-ai.duckdns.org/feedback")
|
||||||
Base.metadata.create_all(self.engine)
|
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"
|
||||||
|
|
||||||
def create_proposal_job(self, customer_email, customer_name, customer_company):
|
# Credentials für die Haupt-App (E-Mail & Kalender info@)
|
||||||
"""Erstellt einen neuen Job, sucht Slots und speichert alles."""
|
AZURE_CLIENT_ID = os.getenv("INFO_Application_ID")
|
||||||
session = self.Session()
|
AZURE_CLIENT_SECRET = os.getenv("INFO_Secret")
|
||||||
try:
|
AZURE_TENANT_ID = os.getenv("INFO_Tenant_ID")
|
||||||
# 1. Freie Slots finden (Mock für jetzt)
|
|
||||||
# Später: real_slots = self.fetch_calendar_availability()
|
|
||||||
candidate_slots = self._mock_calendar_availability()
|
|
||||||
|
|
||||||
# 2. Beste Slots auswählen (mit Overbooking-Check)
|
|
||||||
selected_slots = self._select_best_slots(session, candidate_slots)
|
|
||||||
|
|
||||||
if not selected_slots:
|
|
||||||
# Fallback: Wenn alles "voll" ist (sehr unwahrscheinlich bei Faktor 3),
|
|
||||||
# nehmen wir trotzdem den am wenigsten gebuchten Slot.
|
|
||||||
selected_slots = candidate_slots[:2]
|
|
||||||
|
|
||||||
# 3. Job anlegen
|
# Credentials für die Kalender-Lese-App (e.melcer)
|
||||||
job_uuid = str(uuid.uuid4())
|
CAL_APPID = os.getenv("CAL_APPID")
|
||||||
new_job = ProposalJob(
|
CAL_SECRET = os.getenv("CAL_SECRET")
|
||||||
job_uuid=job_uuid,
|
CAL_TENNANT_ID = os.getenv("CAL_TENNANT_ID")
|
||||||
customer_email=customer_email,
|
|
||||||
customer_name=customer_name,
|
|
||||||
customer_company=customer_company,
|
|
||||||
status='pending'
|
|
||||||
)
|
|
||||||
session.add(new_job)
|
|
||||||
session.flush() # ID generieren
|
|
||||||
|
|
||||||
# 4. Slots speichern
|
GRAPH_API_ENDPOINT = "https://graph.microsoft.com/v1.0"
|
||||||
for slot in selected_slots:
|
|
||||||
new_slot = ProposedSlot(
|
|
||||||
job_id=new_job.id,
|
|
||||||
start_time=slot['start'],
|
|
||||||
end_time=slot['end']
|
|
||||||
)
|
|
||||||
session.add(new_slot)
|
|
||||||
|
|
||||||
session.commit()
|
# --- In-Memory-Speicher ---
|
||||||
return new_job.job_uuid, selected_slots
|
# Wir speichern hier Details zu jeder Anfrage, um beim Klick auf den Slot reagieren zu können.
|
||||||
|
request_status_storage = {}
|
||||||
|
_lock = Lock()
|
||||||
|
|
||||||
except Exception as e:
|
# --- Auth Helper ---
|
||||||
session.rollback()
|
def get_access_token(client_id, client_secret, tenant_id):
|
||||||
raise e
|
if not all([client_id, client_secret, tenant_id]):
|
||||||
finally:
|
return None
|
||||||
session.close()
|
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')
|
||||||
|
|
||||||
def _select_best_slots(self, session, candidate_slots):
|
# --- KALENDER LOGIK ---
|
||||||
"""Wählt Slots aus, die noch nicht 'voll' sind (Faktor 3)."""
|
|
||||||
valid_slots = []
|
|
||||||
|
|
||||||
# Wir betrachten nur Vorschläge der letzten 24h als "aktiv"
|
|
||||||
yesterday = datetime.datetime.now() - datetime.timedelta(days=1)
|
|
||||||
|
|
||||||
for slot in candidate_slots:
|
def get_availability(target_email: str, app_creds: tuple) -> tuple:
|
||||||
# Wie oft wurde dieser Start-Zeitpunkt in den letzten 24h vorgeschlagen?
|
"""Holt die Verfügbarkeit für eine E-Mail über die angegebene App."""
|
||||||
count = session.query(func.count(ProposedSlot.id)).filter(ProposedSlot.start_time == slot['start']).filter(ProposedSlot.job.has(ProposalJob.created_at >= yesterday)).scalar()
|
token = get_access_token(*app_creds)
|
||||||
|
if not token: return None
|
||||||
if count < MAX_PROPOSALS_PER_SLOT:
|
|
||||||
valid_slots.append(slot)
|
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json", "Prefer": 'outlook.timezone="Europe/Berlin"'}
|
||||||
|
start_time = datetime.now(TZ_BERLIN).replace(minute=0, second=0, microsecond=0)
|
||||||
if len(valid_slots) >= 2:
|
if start_time.hour >= 17: start_time += timedelta(days=1); start_time = start_time.replace(hour=8)
|
||||||
|
|
||||||
|
end_time = start_time + timedelta(days=3)
|
||||||
|
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', '')
|
||||||
|
return start_time, view, 60
|
||||||
|
except: pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def find_slots(start_time, view, interval) -> list:
|
||||||
|
"""Findet zwei freie Slots (Vormittag, Nachmittag)."""
|
||||||
|
slots = []
|
||||||
|
# 1. Zeitnah
|
||||||
|
for i, char in enumerate(view):
|
||||||
|
t = start_time + timedelta(minutes=i * interval)
|
||||||
|
if 9 <= t.hour < 12 and char == '0' and t.weekday() < 5:
|
||||||
|
slots.append(t); break
|
||||||
|
# 2. Nachmittag
|
||||||
|
for i, char in enumerate(view):
|
||||||
|
t = start_time + timedelta(minutes=i * interval)
|
||||||
|
if 14 <= t.hour <= 16 and char == '0' and t.weekday() < 5:
|
||||||
|
if not slots or t.day != slots[0].day or t.hour != slots[0].hour:
|
||||||
|
slots.append(t); break
|
||||||
|
return slots
|
||||||
|
|
||||||
|
def create_calendar_invite(lead_email: str, company_name: str, start_time: datetime):
|
||||||
|
"""Sendet eine echte Outlook-Kalendereinladung von info@ an den Lead."""
|
||||||
|
print(f"INFO: Creating calendar invite for {lead_email} at {start_time}")
|
||||||
|
|
||||||
|
if not AZURE_CLIENT_ID:
|
||||||
|
print("CRITICAL: AZURE_CLIENT_ID not set.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
token = get_access_token(AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)
|
||||||
|
if not token:
|
||||||
|
print("CRITICAL: Could not get token for calendar invite.")
|
||||||
|
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": "Vielen Dank für die Terminbuchung. Wir freuen uns auf das Gespräch!"},
|
||||||
|
"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 / Telefon"},
|
||||||
|
"attendees": [{"emailAddress": {"address": lead_email, "name": "Interessent"}, "type": "required"}]
|
||||||
|
}
|
||||||
|
|
||||||
|
url = f"{GRAPH_API_ENDPOINT}/users/{SENDER_EMAIL}/calendar/events"
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, headers=headers, json=event_payload)
|
||||||
|
if resp.status_code in [200, 201]:
|
||||||
|
print("SUCCESS: Calendar event created.")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"ERROR: Failed to create event. HTTP {resp.status_code}")
|
||||||
|
print(f"Response: {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
|
break
|
||||||
|
time.sleep(5)
|
||||||
return valid_slots
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
def _mock_calendar_availability(self):
|
if __name__ == "__main__":
|
||||||
"""Simuliert freie Termine für morgen."""
|
Thread(target=lambda: uvicorn.run(app, host="0.0.0.0", port=8004), daemon=True).start()
|
||||||
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
|
time.sleep(2)
|
||||||
|
# E2E Test
|
||||||
# Ein Slot Vormittags (10:30), einer Nachmittags (14:00)
|
process_lead(f"req_{int(time.time())}", "Testfirma GmbH", "Wir haben Ihre Anfrage erhalten.", TEST_RECEIVER_EMAIL)
|
||||||
return [
|
print("\nIdle. Press Ctrl+C.")
|
||||||
{
|
try:
|
||||||
'start': datetime.datetime.combine(tomorrow, datetime.time(10, 30)),
|
while True: time.sleep(1)
|
||||||
'end': datetime.datetime.combine(tomorrow, datetime.time(11, 15))
|
except: pass
|
||||||
},
|
|
||||||
{
|
|
||||||
'start': datetime.datetime.combine(tomorrow, datetime.time(14, 0)),
|
|
||||||
'end': datetime.datetime.combine(tomorrow, datetime.time(14, 45))
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_job_status(self, job_uuid):
|
|
||||||
session = self.Session()
|
|
||||||
job = session.query(ProposalJob).filter_by(job_uuid=job_uuid).first()
|
|
||||||
status = job.status if job else None
|
|
||||||
session.close()
|
|
||||||
return status
|
|
||||||
|
|
||||||
def get_job_details(self, job_uuid):
|
|
||||||
"""Holt alle Details zu einem Job inklusive der Slots."""
|
|
||||||
session = self.Session()
|
|
||||||
job = session.query(ProposalJob).filter_by(job_uuid=job_uuid).first()
|
|
||||||
if not job:
|
|
||||||
session.close()
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Wir müssen die Daten extrahieren, bevor die Session geschlossen wird
|
|
||||||
details = {
|
|
||||||
'uuid': job.job_uuid,
|
|
||||||
'email': job.customer_email,
|
|
||||||
'name': job.customer_name,
|
|
||||||
'company': job.customer_company,
|
|
||||||
'status': job.status,
|
|
||||||
'slots': [{'start': s.start_time, 'end': s.end_time} for s in job.slots]
|
|
||||||
}
|
|
||||||
session.close()
|
|
||||||
return details
|
|
||||||
|
|
||||||
def update_job_status(self, job_uuid, new_status):
|
|
||||||
session = self.Session()
|
|
||||||
job = session.query(ProposalJob).filter_by(job_uuid=job_uuid).first()
|
|
||||||
if job:
|
|
||||||
job.status = new_status
|
|
||||||
if new_status == 'approved':
|
|
||||||
job.approved_at = datetime.datetime.now()
|
|
||||||
session.commit()
|
|
||||||
session.close()
|
|
||||||
@@ -1,27 +1,40 @@
|
|||||||
<br>
|
<!DOCTYPE html>
|
||||||
<div style="font-family: Arial, sans-serif; font-size: 14px; color: #333;">
|
<html lang="de">
|
||||||
<p>Freundliche Grüße<br>
|
<head>
|
||||||
<strong>Elizabeta Melcer</strong><br>
|
<meta charset="UTF-8">
|
||||||
Inside Sales Managerin</p>
|
<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>
|
<p>
|
||||||
<strong>RoboPlanet GmbH</strong><br>
|
<b>Elizabeta Melcer</b><br>
|
||||||
Schatzbogen 39, 81829 München<br>
|
Inside Sales Managerin
|
||||||
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>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="#">LinkedIn</a> | <a href="#">Instagram</a> | <a href="#">Newsletteranmeldung</a>
|
<!-- 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>
|
||||||
|
<p>
|
||||||
<p style="font-size: 10px; color: #777;">
|
<a href="#">LinkedIn</a> | <a href="#">Instagram</a> | <a href="#">Newsletteranmeldung</a>
|
||||||
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>
|
||||||
|
<p style="font-size: smaller; color: grey;">
|
||||||
<!-- Platzhalter für das Bild -->
|
Sitz der Gesellschaft München | Geschäftsführung: Axel Banoth<br>
|
||||||
<img src="https://robo-planet.de/wp-content/uploads/2024/01/RoboPlanet_Logo.png" alt="RoboPlanet Logo" width="150"><br>
|
Registergericht AG München, HRB 296113 | USt.-IdNr. DE400464410<br>
|
||||||
<img src="cid:banner_image" alt="Webinar Einladung" width="400">
|
<a href="#">Hinweispflichten zum Datenschutz</a>
|
||||||
</div>
|
</p>
|
||||||
|
<p>
|
||||||
|
<img src="RoboPlanetBannerWebinarEinladung.png" alt="RoboPlanet Webinar Einladung">
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
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)
|
||||||
@@ -166,6 +166,14 @@ http {
|
|||||||
proxy_read_timeout 86400; # Long timeout for stream
|
proxy_read_timeout 86400; # Long timeout for stream
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /feedback/ {
|
||||||
|
# Public endpoint for Teams Feedback actions
|
||||||
|
auth_basic off; # Must be public for external links
|
||||||
|
proxy_pass http://lead-engine:8004/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
location /connector/ {
|
location /connector/ {
|
||||||
# SuperOffice Connector Webhook & Dashboard
|
# SuperOffice Connector Webhook & Dashboard
|
||||||
auth_basic off;
|
auth_basic off;
|
||||||
|
|||||||
Reference in New Issue
Block a user