feat: Notruf-Bridge (seven.io) — Webhook-Empfänger für SMS + Anruf

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>
This commit is contained in:
Dieter Schlüter 2026-06-25 10:53:34 +02:00
commit 7addb903b2
7 changed files with 353 additions and 0 deletions

View file

@ -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=<derselbe Wert wie BRIDGE_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 |

View file

@ -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": <bool>, "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=<derselbe Wert wie BRIDGE_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 <token>` 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
```

View file

@ -0,0 +1,2 @@
"""Notruf-Bridge: Webhook-Empfänger, der das Notruf-Payload des Voice Assistants
über seven.io in SMS und Sprachanruf umsetzt."""

View file

@ -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

View file

@ -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

120
emergency_bridge/main.py Normal file
View file

@ -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 <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}

View file

@ -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 & <Notfall>."},
}
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 "<speak>" in voice["data"]["text"]
assert "&amp;" in voice["data"]["text"]
assert "&lt;Notfall&gt;" 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")