Konkreter Provider hinter dem provider-agnostischen EMERGENCY_WEBHOOK_URL: ein kleiner FastAPI-Dienst (emergency_bridge/), der das Notruf-Payload der App entgegennimmt und pro Telefonnummer SMS und/oder TTS-Anruf über die seven.io-API auslöst. - main.py: POST /notruf (Bearer-Token-Prüfung), GET /health; seven.io SMS (/api/sms) + Voice (/api/voice, Text als SSML, XML-escaped), best effort mit Statuscode-100-Auswertung; Kanäle/Texte kommen aus dem Payload - Betrieb: eigener systemd-Service auf 127.0.0.1:8090, läuft in der vorhandenen venv; .env.example + README mit Installations- und Verdrahtungsschritten - Tests: SMS+Anruf-Fan-out, SSML-Escaping, Token-Auth, Kanal-Filter (5 Tests) - DEPLOYMENT.md: optionaler Abschnitt 2.9 mit Verweis auf die Bridge Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
120 lines
4.9 KiB
Python
120 lines
4.9 KiB
Python
"""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 <token>'
|
|
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'<speak><voice name="{voice}">{escape(text)}</voice></speak>'
|
|
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}
|