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:
parent
7addb903b2
commit
ea0e3674ad
4 changed files with 71 additions and 82 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -32,6 +32,13 @@ ausgelöst. Antwort: `{"received": true, "any_sent": <bool>, "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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 "<speak>" 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"])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue