refactor: Notruf-Bridge nutzt seven-send-Library statt eigenem seven.io-Code

Die seven.io-Aufrufe (SMS + Voice/SSML) leben jetzt in der wiederverwendbaren
Library seven-send; die Bridge ist nur noch der HTTP-Adapter und ruft den
synchronen SevenClient via asyncio.to_thread. Eine getestete Stelle für die
API-Logik, ~50 Zeilen Duplikat raus.

- main.py: import SevenClient; _make_client() aus der Env; _dispatch() pro
  Kanal/Nummer; ohne API-Key sauberes "nicht gesetzt"-Ergebnis (kein Versand)
- Tests: mocken jetzt seven_send.client.httpx.post; neuer Test "ohne API-Key
  kein Versand" (6 Tests)
- Doku: Bridge-README + DEPLOYMENT 2.9 — seven-send als venv-Voraussetzung
  (pip install git+https://kitux.de/forgejo/dschlueter/seven_send.git)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-25 11:50:05 +02:00
commit ea0e3674ad
4 changed files with 71 additions and 82 deletions

View file

@ -1,34 +1,32 @@
"""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
Kette: App --POST /notruf--> diese Bridge --seven_send--> SMS / Anruf
Die App bleibt provider-agnostisch; der konkrete Anbieter (hier seven.io)
steckt allein in diesem kleinen Dienst.
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 <token>'
und muss EMERGENCY_WEBHOOK_TOKEN der App entsprechen
SEVENIO_SMS_FROM Absender-ID der SMS (alphanumerisch, max. 11 Zeichen; Default "Notruf")
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 xml.sax.saxutils import escape
import httpx
from fastapi import FastAPI, Header, HTTPException, Request
from seven_send import SevenClient
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)")
@ -41,51 +39,26 @@ def _check_auth(authorization: str | None) -> None:
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)."""
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 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)
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 _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)
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")
@ -105,15 +78,13 @@ async def notruf(request: Request, authorization: str | None = Header(default=No
sms_text = message.get("sms") or ""
call_text = message.get("call") or ""
client = _make_client()
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})
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)