"""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.io-API--> SMS / Anruf Die App bleibt provider-agnostisch; der konkrete Anbieter (hier seven.io) steckt allein in diesem kleinen Dienst. 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 (alphanumerisch, max. 11 Zeichen; 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 logging import os from xml.sax.saxutils import escape import httpx from fastapi import FastAPI, Header, HTTPException, Request logging.basicConfig(level=logging.INFO) logger = logging.getLogger("emergency_bridge") SMS_URL = "https://gateway.seven.io/api/sms" VOICE_URL = "https://gateway.seven.io/api/voice" 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 _seven_ok(resp: httpx.Response) -> tuple[bool, str]: """Wertet die seven.io-Antwort aus. Erfolg = HTTP 2xx und Statuscode 100.""" body = resp.text.strip() if resp.status_code >= 300: return False, f"HTTP {resp.status_code}: {body[:200]}" try: data = resp.json() except Exception: first = body.splitlines()[0] if body else "" # Legacy: nackter Statuscode return (first == "100"), body[:200] code = str(data.get("success", "")).strip() if code and code != "100": return False, f"seven success={code}" msgs = data.get("messages") or [] if msgs and not all(m.get("success", True) for m in msgs): return False, f"teilweise fehlgeschlagen: {body[:200]}" return True, body[:200] async def _seven_post(client: httpx.AsyncClient, url: str, data: dict) -> tuple[bool, str]: """Sendet einen seven.io-Request (best effort). Liefert (ok, info).""" api_key = os.getenv("SEVENIO_API_KEY", "") if not api_key: return False, "SEVENIO_API_KEY nicht gesetzt" try: resp = await client.post(url, data=data, headers={"X-Api-Key": api_key}) except Exception as e: # noqa: BLE001 - best effort, nie crashen logger.exception("seven.io-Aufruf fehlgeschlagen") return False, f"Ausnahme: {e}" return _seven_ok(resp) async def _send_sms(client: httpx.AsyncClient, to: str, text: str) -> tuple[bool, str]: data = {"to": to, "text": text, "from": os.getenv("SEVENIO_SMS_FROM", "Notruf")} return await _seven_post(client, SMS_URL, data) async def _send_voice(client: httpx.AsyncClient, to: str, text: str) -> tuple[bool, str]: voice = os.getenv("SEVENIO_VOICE_NAME", "de-de-female") ssml = f'{escape(text)}' data = {"to": to, "text": ssml} voice_from = os.getenv("SEVENIO_VOICE_FROM", "") if voice_from: data["from"] = voice_from return await _seven_post(client, VOICE_URL, data) @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 "" results: list[dict] = [] async with httpx.AsyncClient(timeout=15) as client: for phone in phones: if "sms" in channels and sms_text: ok, info = await _send_sms(client, phone, sms_text) results.append({"phone": phone, "channel": "sms", "ok": ok, "info": info}) if "call" in channels and call_text: ok, info = await _send_voice(client, phone, call_text) results.append({"phone": phone, "channel": "call", "ok": ok, "info": info}) 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}