diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 1a54e43..e5087ac 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1098,6 +1098,10 @@ kleiner Webhook-Empfänger (`emergency_bridge/`), der über [`emergency_bridge/README.md`](emergency_bridge/README.md). Kurz: ```bash +# 0. Library seven-send in die venv (kapselt die seven.io-Aufrufe) +sudo -u voice /opt/voice-assistant/.venv/bin/pip install \ + git+https://kitux.de/forgejo/dschlueter/seven_send.git + # 1. Konfig (API-Key + BRIDGE_TOKEN setzen) sudo cp /opt/voice-assistant/emergency_bridge/emergency-bridge.env.example \ /etc/voice-assistant/emergency-bridge.env diff --git a/emergency_bridge/README.md b/emergency_bridge/README.md index ee481e8..7572c01 100644 --- a/emergency_bridge/README.md +++ b/emergency_bridge/README.md @@ -32,6 +32,13 @@ ausgelöst. Antwort: `{"received": true, "any_sent": , "results": [...]}`. ## Installation +**Voraussetzung** — die Library [`seven-send`](https://kitux.de/forgejo/dschlueter/seven_send) +muss in der venv installiert sein (sie kapselt die seven.io-Aufrufe): +```bash +sudo -u voice /opt/voice-assistant/.venv/bin/pip install \ + git+https://kitux.de/forgejo/dschlueter/seven_send.git +``` + 1. **Konfig anlegen:** ```bash sudo cp emergency_bridge/emergency-bridge.env.example /etc/voice-assistant/emergency-bridge.env diff --git a/emergency_bridge/main.py b/emergency_bridge/main.py index 21956f7..c4159e9 100644 --- a/emergency_bridge/main.py +++ b/emergency_bridge/main.py @@ -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 ' 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'{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) +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) diff --git a/tests/test_emergency_bridge.py b/tests/test_emergency_bridge.py index 64e72d9..2226874 100644 --- a/tests/test_emergency_bridge.py +++ b/tests/test_emergency_bridge.py @@ -1,4 +1,5 @@ import emergency_bridge.main as bridge +import seven_send.client as sc from fastapi.testclient import TestClient client = TestClient(bridge.app) @@ -12,29 +13,21 @@ PAYLOAD = { } -def _fake_httpx(monkeypatch, capture): - class FakeResp: - status_code = 200 - text = '{"success":"100","messages":[{"success":true}]}' +class FakeResp: + status_code = 200 + text = '{"success":"100"}' - def json(self): - return {"success": "100", "messages": [{"success": True}]} + def json(self): + return {"success": "100", "messages": [{"success": True}]} - class FakeClient: - def __init__(self, *a, **k): - pass - async def __aenter__(self): - return self +def _fake_seven(monkeypatch, capture): + """Mockt den seven.io-HTTP-Aufruf im seven_send-Client (eine Stelle für alles).""" + def fake_post(url, data=None, headers=None, timeout=None): + capture.append({"url": url, "data": data, "headers": headers}) + return FakeResp() - async def __aexit__(self, *a): - return False - - async def post(self, url, data=None, headers=None): - capture.append({"url": url, "data": data, "headers": headers}) - return FakeResp() - - monkeypatch.setattr(bridge.httpx, "AsyncClient", FakeClient) + monkeypatch.setattr(sc.httpx, "post", fake_post) def test_health(): @@ -47,7 +40,7 @@ def test_notruf_sends_sms_and_voice(monkeypatch): monkeypatch.setenv("SEVENIO_API_KEY", "k") monkeypatch.delenv("BRIDGE_TOKEN", raising=False) cap = [] - _fake_httpx(monkeypatch, cap) + _fake_seven(monkeypatch, cap) r = client.post("/notruf", json=PAYLOAD) assert r.status_code == 200 @@ -59,7 +52,7 @@ def test_notruf_sends_sms_and_voice(monkeypatch): assert sms["headers"]["X-Api-Key"] == "k" assert sms["data"]["to"] == "+4915112345678" assert sms["data"]["text"] == "NOTRUF: Test" - # Anruf-Text als SSML, XML-escaped (& und <> dürfen das SSML nicht zerlegen) + # Anruf-Text wird von seven_send als SSML verpackt und XML-escaped assert "" in voice["data"]["text"] assert "&" in voice["data"]["text"] assert "<Notfall>" in voice["data"]["text"] @@ -74,8 +67,7 @@ def test_notruf_rejects_bad_token(monkeypatch): def test_notruf_accepts_good_token(monkeypatch): monkeypatch.setenv("BRIDGE_TOKEN", "secret") monkeypatch.setenv("SEVENIO_API_KEY", "k") - cap = [] - _fake_httpx(monkeypatch, cap) + _fake_seven(monkeypatch, []) r = client.post("/notruf", json=PAYLOAD, headers={"Authorization": "Bearer secret"}) assert r.status_code == 200 @@ -84,8 +76,23 @@ def test_notruf_respects_channels(monkeypatch): monkeypatch.setenv("SEVENIO_API_KEY", "k") monkeypatch.delenv("BRIDGE_TOKEN", raising=False) cap = [] - _fake_httpx(monkeypatch, cap) + _fake_seven(monkeypatch, cap) r = client.post("/notruf", json={**PAYLOAD, "channels": ["sms"]}) assert r.status_code == 200 assert len(cap) == 1 assert cap[0]["url"].endswith("/sms") + + +def test_notruf_without_api_key(monkeypatch): + monkeypatch.delenv("SEVENIO_API_KEY", raising=False) + monkeypatch.delenv("BRIDGE_TOKEN", raising=False) + + def boom(*a, **k): # ohne Key darf kein seven.io-Aufruf passieren + raise AssertionError("kein Versand ohne API-Key") + + monkeypatch.setattr(sc.httpx, "post", boom) + r = client.post("/notruf", json=PAYLOAD) + assert r.status_code == 200 + body = r.json() + assert body["any_sent"] is False + assert all("nicht gesetzt" in res["info"] for res in body["results"])