"""Notruf-Bridge: empfängt das Webhook-Payload des Voice Assistants und löst über seven.io SMS und/oder einen Sprachanruf (Text-to-Speech) aus. Kette: App --POST /notruf--> diese Bridge --seven_send--> SMS / Anruf Die eigentlichen seven.io-Aufrufe stecken in der wiederverwendbaren Library `seven_send` (SevenClient). Diese Bridge ist nur der HTTP-Adapter für die (asynchrone) App und ruft den synchronen Client via ``asyncio.to_thread``. Konfiguration über Umgebungsvariablen (siehe emergency-bridge.env.example): SEVENIO_API_KEY seven.io API-Key (Pflicht für echten Versand) BRIDGE_TOKEN falls gesetzt: erwartet 'Authorization: Bearer ' und muss EMERGENCY_WEBHOOK_TOKEN der App entsprechen SEVENIO_SMS_FROM Absender-ID der SMS (Default "Notruf") SEVENIO_VOICE_FROM Caller-ID des Anrufs (verifizierte Nummer; optional) SEVENIO_VOICE_NAME SSML-Stimme für den Anruf (Default "de-de-female") BRIDGE_HOST/PORT Bind-Adresse (Default 127.0.0.1:8090) """ import asyncio import logging import os from fastapi import FastAPI, Header, HTTPException, Request from seven_send import SevenClient logging.basicConfig(level=logging.INFO) logger = logging.getLogger("emergency_bridge") app = FastAPI(title="Notruf-Bridge (seven.io)") def _check_auth(authorization: str | None) -> None: """Prüft das Bearer-Token, falls BRIDGE_TOKEN gesetzt ist.""" token = os.getenv("BRIDGE_TOKEN", "") if not token: return # kein Token konfiguriert -> keine Prüfung if authorization != f"Bearer {token}": raise HTTPException(status_code=401, detail="ungültiges Bearer-Token") def _make_client() -> SevenClient | None: """Baut den SevenClient aus der Env. None, wenn kein API-Key gesetzt ist.""" api_key = os.getenv("SEVENIO_API_KEY", "") if not api_key: return None return SevenClient( api_key, sms_from=os.getenv("SEVENIO_SMS_FROM", "Notruf"), voice_from=os.getenv("SEVENIO_VOICE_FROM") or None, voice_name=os.getenv("SEVENIO_VOICE_NAME", "de-de-female"), ) async def _dispatch(client: SevenClient | None, channel: str, phone: str, text: str) -> dict: """Löst einen Kanal für eine Nummer aus (sync Client im Thread). Liefert ein Ergebnis-Dict.""" if client is None: return {"phone": phone, "channel": channel, "ok": False, "info": "SEVENIO_API_KEY nicht gesetzt"} send = client.send_sms if channel == "sms" else client.send_voice res = await asyncio.to_thread(send, phone, text) return {"phone": phone, "channel": channel, "ok": res.ok, "info": res.error or res.code or "ok"} @app.get("/health") async def health() -> dict: return {"status": "ok", "configured": bool(os.getenv("SEVENIO_API_KEY"))} @app.post("/notruf") async def notruf(request: Request, authorization: str | None = Header(default=None)) -> dict: """Empfängt das Notruf-Payload der App und fächert es auf SMS/Anruf auf.""" _check_auth(authorization) payload = await request.json() phones = payload.get("phones") or [] channels = payload.get("channels") or ["sms", "call"] message = payload.get("message") or {} sms_text = message.get("sms") or "" call_text = message.get("call") or "" client = _make_client() results: list[dict] = [] for phone in phones: if "sms" in channels and sms_text: results.append(await _dispatch(client, "sms", phone, sms_text)) if "call" in channels and call_text: results.append(await _dispatch(client, "call", phone, call_text)) any_ok = any(r["ok"] for r in results) logger.info("Notruf verarbeitet: %d Nummer(n), Ergebnisse=%s", len(phones), results) return {"received": True, "any_sent": any_ok, "results": results}