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>
91 lines
3.7 KiB
Python
91 lines
3.7 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_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 <token>'
|
|
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}
|