From 7addb903b24d69b140555a50b9903d66521a4a3f Mon Sep 17 00:00:00 2001 From: dschlueter Date: Thu, 25 Jun 2026 10:53:34 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Notruf-Bridge=20(seven.io)=20=E2=80=94?= =?UTF-8?q?=20Webhook-Empf=C3=A4nger=20f=C3=BCr=20SMS=20+=20Anruf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- DEPLOYMENT.md | 36 ++++++ emergency_bridge/README.md | 72 +++++++++++ emergency_bridge/__init__.py | 2 + emergency_bridge/emergency-bridge.env.example | 16 +++ emergency_bridge/emergency-bridge.service | 16 +++ emergency_bridge/main.py | 120 ++++++++++++++++++ tests/test_emergency_bridge.py | 91 +++++++++++++ 7 files changed, 353 insertions(+) create mode 100644 emergency_bridge/README.md create mode 100644 emergency_bridge/__init__.py create mode 100644 emergency_bridge/emergency-bridge.env.example create mode 100644 emergency_bridge/emergency-bridge.service create mode 100644 emergency_bridge/main.py create mode 100644 tests/test_emergency_bridge.py diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 03d2c1f..1a54e43 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1090,6 +1090,42 @@ sudo systemctl reload nginx --- +### 2.9 Notruf-Bridge (optional, SMS/Anruf via seven.io) + +Damit der Notruf zusätzlich zur E-Mail **SMS und Sprachanruf** auslöst, läuft ein +kleiner Webhook-Empfänger (`emergency_bridge/`), der über +[seven.io](https://www.seven.io) sendet. Vollständige Anleitung: +[`emergency_bridge/README.md`](emergency_bridge/README.md). Kurz: + +```bash +# 1. Konfig (API-Key + BRIDGE_TOKEN setzen) +sudo cp /opt/voice-assistant/emergency_bridge/emergency-bridge.env.example \ + /etc/voice-assistant/emergency-bridge.env +sudo chown voice:voice /etc/voice-assistant/emergency-bridge.env +sudo chmod 600 /etc/voice-assistant/emergency-bridge.env +sudo editor /etc/voice-assistant/emergency-bridge.env + +# 2. Service +sudo cp /opt/voice-assistant/emergency_bridge/emergency-bridge.service \ + /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now emergency-bridge +curl -s localhost:8090/health # -> {"status":"ok","configured":true} +``` + +Anschließend in `/etc/voice-assistant/voice-assistant.env` ergänzen und +`sudo systemctl restart voice-assistant`: + +```bash +EMERGENCY_WEBHOOK_URL=http://127.0.0.1:8090/notruf +EMERGENCY_WEBHOOK_TOKEN= +EMERGENCY_CONTACT_PHONE=+49XXXXXXXXXX +``` + +Ohne diese Schritte bleibt der Webhook-Kanal inaktiv; der Notruf nutzt dann nur E-Mail. + +--- + ## Teil 3: Bekannte Fallstricke | Problem | Ursache | Fix | diff --git a/emergency_bridge/README.md b/emergency_bridge/README.md new file mode 100644 index 0000000..ee481e8 --- /dev/null +++ b/emergency_bridge/README.md @@ -0,0 +1,72 @@ +# Notruf-Bridge (seven.io) + +Kleiner Webhook-Empfänger, der das Notruf-Payload des Voice Assistants über +[seven.io](https://www.seven.io) in **SMS** und/oder **Sprachanruf (Text-to-Speech)** +umsetzt. Die App bleibt damit provider-agnostisch — der konkrete Anbieter steckt +allein hier. + +``` +App --POST /notruf (JSON)--> Bridge --seven.io-API--> 📱 SMS / ☎️ Anruf +``` + +## Endpunkte + +| Methode | Pfad | Zweck | +|---------|------|-------| +| `GET` | `/health` | Liveness + ob ein API-Key gesetzt ist | +| `POST` | `/notruf` | nimmt das App-Payload entgegen, löst SMS/Anruf aus | + +Erwartetes Payload (von der App erzeugt): + +```json +{ + "channels": ["sms", "call"], + "phones": ["+4915112345678"], + "message": { "sms": "NOTRUF: …", "call": "Achtung. …" } +} +``` + +Pro Telefonnummer wird — je nach `channels` — eine SMS (`message.sms`) und/oder ein +Anruf mit vorgelesenem Text (`message.call`, als SSML mit `SEVENIO_VOICE_NAME`) +ausgelöst. Antwort: `{"received": true, "any_sent": , "results": [...]}`. + +## Installation + +1. **Konfig anlegen:** + ```bash + sudo cp emergency_bridge/emergency-bridge.env.example /etc/voice-assistant/emergency-bridge.env + sudo chown voice:voice /etc/voice-assistant/emergency-bridge.env + sudo chmod 600 /etc/voice-assistant/emergency-bridge.env + sudo editor /etc/voice-assistant/emergency-bridge.env # API-Key + BRIDGE_TOKEN setzen + ``` +2. **Service installieren:** + ```bash + sudo cp emergency_bridge/emergency-bridge.service /etc/systemd/system/ + sudo systemctl daemon-reload + sudo systemctl enable --now emergency-bridge + curl -s localhost:8090/health # -> {"status":"ok","configured":true} + ``` +3. **App verdrahten** — in `/etc/voice-assistant/voice-assistant.env`: + ```bash + EMERGENCY_WEBHOOK_URL=http://127.0.0.1:8090/notruf + EMERGENCY_WEBHOOK_TOKEN= + EMERGENCY_CONTACT_PHONE=+49XXXXXXXXXX + ``` + Danach `sudo systemctl restart voice-assistant`. + +## Konfiguration (Env) + +| Variable | Default | Bedeutung | +|----------|---------|-----------| +| `SEVENIO_API_KEY` | (leer) | seven.io API-Key — ohne Key kein Versand | +| `SEVENIO_SMS_FROM` | `Notruf` | Absender-ID der SMS (alphanumerisch, max. 11 Zeichen) | +| `SEVENIO_VOICE_FROM` | (leer) | Caller-ID des Anrufs (verifizierte Nummer; optional) | +| `SEVENIO_VOICE_NAME` | `de-de-female` | SSML-Stimme für die Anruf-Ansage | +| `BRIDGE_TOKEN` | (leer) | falls gesetzt: `Authorization: Bearer ` Pflicht | +| `BRIDGE_HOST` / `BRIDGE_PORT` | `127.0.0.1` / `8090` | Bind-Adresse | + +## Test + +```bash +.venv/bin/python -m pytest tests/test_emergency_bridge.py -q +``` diff --git a/emergency_bridge/__init__.py b/emergency_bridge/__init__.py new file mode 100644 index 0000000..74e5827 --- /dev/null +++ b/emergency_bridge/__init__.py @@ -0,0 +1,2 @@ +"""Notruf-Bridge: Webhook-Empfänger, der das Notruf-Payload des Voice Assistants +über seven.io in SMS und Sprachanruf umsetzt.""" diff --git a/emergency_bridge/emergency-bridge.env.example b/emergency_bridge/emergency-bridge.env.example new file mode 100644 index 0000000..774b338 --- /dev/null +++ b/emergency_bridge/emergency-bridge.env.example @@ -0,0 +1,16 @@ +# Notruf-Bridge — Konfiguration. Kopieren nach /etc/voice-assistant/emergency-bridge.env +# (chmod 600, Eigentümer voice). KEINE Inline-Kommentare hinter den Werten! + +# --- seven.io --- +SEVENIO_API_KEY=dein-seven-io-api-key +SEVENIO_SMS_FROM=Notruf +# SEVENIO_VOICE_FROM=+49XXXXXXXXXX +SEVENIO_VOICE_NAME=de-de-female + +# --- Absicherung gegen die App --- +# Muss EMERGENCY_WEBHOOK_TOKEN in der voice-assistant.env entsprechen. +BRIDGE_TOKEN=ein-langes-zufaelliges-geheimnis + +# --- Bind (nur lokal; die App ruft localhost) --- +BRIDGE_HOST=127.0.0.1 +BRIDGE_PORT=8090 diff --git a/emergency_bridge/emergency-bridge.service b/emergency_bridge/emergency-bridge.service new file mode 100644 index 0000000..69f67f4 --- /dev/null +++ b/emergency_bridge/emergency-bridge.service @@ -0,0 +1,16 @@ +[Unit] +Description=Notruf-Bridge (Voice-Assistant Webhook -> seven.io SMS/Anruf) +After=network-online.target +Wants=network-online.target + +[Service] +User=voice +Group=voice +WorkingDirectory=/opt/voice-assistant +EnvironmentFile=/etc/voice-assistant/emergency-bridge.env +ExecStart=/opt/voice-assistant/.venv/bin/uvicorn emergency_bridge.main:app --host ${BRIDGE_HOST} --port ${BRIDGE_PORT} +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/emergency_bridge/main.py b/emergency_bridge/main.py new file mode 100644 index 0000000..21956f7 --- /dev/null +++ b/emergency_bridge/main.py @@ -0,0 +1,120 @@ +"""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} diff --git a/tests/test_emergency_bridge.py b/tests/test_emergency_bridge.py new file mode 100644 index 0000000..64e72d9 --- /dev/null +++ b/tests/test_emergency_bridge.py @@ -0,0 +1,91 @@ +import emergency_bridge.main as bridge +from fastapi.testclient import TestClient + +client = TestClient(bridge.app) + +PAYLOAD = { + "event": "emergency", + "category": "manual", + "channels": ["sms", "call"], + "phones": ["+4915112345678"], + "message": {"sms": "NOTRUF: Test", "call": "Achtung. Test & ."}, +} + + +def _fake_httpx(monkeypatch, capture): + class FakeResp: + status_code = 200 + text = '{"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 + + 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) + + +def test_health(): + r = client.get("/health") + assert r.status_code == 200 + assert r.json()["status"] == "ok" + + +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) + + r = client.post("/notruf", json=PAYLOAD) + assert r.status_code == 200 + assert r.json()["any_sent"] is True + assert len(cap) == 2 # eine SMS + ein Anruf + + sms = next(c for c in cap if c["url"].endswith("/sms")) + voice = next(c for c in cap if c["url"].endswith("/voice")) + 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) + assert "" in voice["data"]["text"] + assert "&" in voice["data"]["text"] + assert "<Notfall>" in voice["data"]["text"] + + +def test_notruf_rejects_bad_token(monkeypatch): + monkeypatch.setenv("BRIDGE_TOKEN", "secret") + r = client.post("/notruf", json=PAYLOAD, headers={"Authorization": "Bearer wrong"}) + assert r.status_code == 401 + + +def test_notruf_accepts_good_token(monkeypatch): + monkeypatch.setenv("BRIDGE_TOKEN", "secret") + monkeypatch.setenv("SEVENIO_API_KEY", "k") + cap = [] + _fake_httpx(monkeypatch, cap) + r = client.post("/notruf", json=PAYLOAD, headers={"Authorization": "Bearer secret"}) + assert r.status_code == 200 + + +def test_notruf_respects_channels(monkeypatch): + monkeypatch.setenv("SEVENIO_API_KEY", "k") + monkeypatch.delenv("BRIDGE_TOKEN", raising=False) + cap = [] + _fake_httpx(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")