Zweiter Benachrichtigungskanal neben der E-Mail: bei einem Notruf geht zusätzlich ein JSON-POST an EMERGENCY_WEBHOOK_URL mit Kanälen (sms,call), Kontakt-Telefonnummern und fertigen SMS-/Anruf-Texten. Den konkreten Gateway (Twilio, seven.io, sipgate, eigener Dienst) verdrahtet der Empfänger dahinter — kein Provider-Lock-in im Code. - config: EMERGENCY_WEBHOOK_URL aktiviert + _TOKEN (Bearer), _CONTACT_PHONE (CSV/E.164), _WEBHOOK_CHANNELS (default sms,call) - emergency.py: async httpx-POST (best effort), Telefon-Auflösung analog zur E-Mail (Pref emergency_phones > Default), notified = email OR webhook; Hinweise kanal-neutral formuliert; Response um webhook_sent + channels ergänzt - Tests: Webhook-Versand inkl. Payload/Bearer-Header + "ohne URL kein POST" - Doku: §10 + Env-Var-Tabelle aktualisiert (zwei Kanäle, Payload-Format) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
185 lines
7.8 KiB
Python
185 lines
7.8 KiB
Python
"""Manuell ausgelöster Notruf — vom Nutzer bewusst bestätigt.
|
|
|
|
KEINE automatische Erkennung (zu unzuverlässig/intransparent). Ein Notruf
|
|
entsteht nur durch eine ausdrückliche Nutzeraktion (Knopf + Bestätigung).
|
|
|
|
Benachrichtigung von Kontaktpersonen über zwei unabhängige Kanäle (best effort):
|
|
- E-Mail per SMTP (settings.smtp_*, Empfänger settings.emergency_contact_email).
|
|
- SMS/Anruf per provider-agnostischem Webhook (settings.emergency_webhook_url):
|
|
ein JSON-POST mit Kanälen, Telefonnummern und fertigen SMS-/Anruf-Texten; den
|
|
konkreten Gateway (Twilio, seven.io, …) verdrahtet der Empfänger dahinter.
|
|
|
|
Ohne SMTP-Host und ohne Webhook-URL wird nichts versendet und der Nutzer erhält
|
|
einen klaren Hinweis darauf.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import smtplib
|
|
from datetime import datetime, timezone
|
|
from email.message import EmailMessage
|
|
|
|
import httpx
|
|
|
|
from app.config import settings
|
|
from app.metrics import metrics
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Hinweis je Sprache, wenn (noch) KEINE Benachrichtigung rausging.
|
|
_NO_NOTIFY_NOTICE: dict[str, str] = {
|
|
"de": "ACHTUNG: Es ist noch keine Benachrichtigung eingebaut.",
|
|
"en": "ATTENTION: No notification has been set up yet.",
|
|
"fr": "ATTENTION : aucune notification n'est encore configurée.",
|
|
"es": "ATENCIÓN: todavía no se ha configurado ninguna notificación.",
|
|
"it": "ATTENZIONE: non è ancora stata configurata alcuna notifica.",
|
|
"pt": "ATENÇÃO: ainda não há nenhuma notificação configurada.",
|
|
"pl": "UWAGA: Nie skonfigurowano jeszcze żadnego powiadomienia.",
|
|
"ar": "تنبيه: لم يتم إعداد أي إشعار بعد.",
|
|
"ru": "ВНИМАНИЕ: Уведомление пока не настроено.",
|
|
"zh": "注意:尚未设置任何通知。",
|
|
}
|
|
|
|
# Hinweis je Sprache, wenn mindestens ein Kanal die Meldung rausgeschickt hat.
|
|
_SENT_NOTICE: dict[str, str] = {
|
|
"de": "Hilfe wurde verständigt. Eine Kontaktperson wurde benachrichtigt.",
|
|
"en": "Help has been alerted. A contact person was notified.",
|
|
"fr": "Les secours ont été alertés. Un proche a été prévenu.",
|
|
"es": "Se ha avisado a ayuda. Se notificó a un contacto.",
|
|
"it": "L'aiuto è stato allertato. Una persona di contatto è stata avvisata.",
|
|
"pt": "A ajuda foi avisada. Uma pessoa de contato foi notificada.",
|
|
"pl": "Pomoc została powiadomiona. Osoba kontaktowa została powiadomiona.",
|
|
"ar": "تم تنبيه المساعدة. تم إخطار شخص الاتصال.",
|
|
"ru": "Помощь оповещена. Контактное лицо уведомлено.",
|
|
"zh": "已通知求助。已通知联系人。",
|
|
}
|
|
|
|
|
|
def notify_notice(language: str | None, sent: bool) -> str:
|
|
"""Hinweis in Nutzersprache (Fallback Deutsch), je nachdem ob etwas rausging."""
|
|
table = _SENT_NOTICE if sent else _NO_NOTIFY_NOTICE
|
|
return table.get((language or "de").lower(), table["de"])
|
|
|
|
|
|
def _csv(raw: str | None) -> list[str]:
|
|
"""Trennt eine CSV-/Semikolon-Liste in bereinigte Einzelwerte."""
|
|
return [v.strip() for v in (raw or "").replace(";", ",").split(",") if v.strip()]
|
|
|
|
|
|
def _contacts(user, cfg) -> list[str]:
|
|
"""Kontakt-E-Mail(s): pro-Nutzer-Pref (CSV) falls vorhanden, sonst Default aus der Config."""
|
|
prefs = getattr(user, "prefs", None) or {}
|
|
raw = (prefs.get("emergency_contacts") or "").strip() if isinstance(prefs, dict) else ""
|
|
return _csv(raw) or _csv(cfg.emergency_contact_email)
|
|
|
|
|
|
def _phones(user, cfg) -> list[str]:
|
|
"""Kontakt-Telefonnummer(n): pro-Nutzer-Pref (CSV) falls vorhanden, sonst Default aus der Config."""
|
|
prefs = getattr(user, "prefs", None) or {}
|
|
raw = (prefs.get("emergency_phones") or "").strip() if isinstance(prefs, dict) else ""
|
|
return _csv(raw) or _csv(cfg.emergency_contact_phone)
|
|
|
|
|
|
def _send_email_sync(to: list[str], subject: str, body: str, cfg) -> bool:
|
|
"""Versendet eine E-Mail per SMTP. Liefert True bei Erfolg, sonst False (best effort)."""
|
|
if not cfg.smtp_host or not to:
|
|
return False
|
|
msg = EmailMessage()
|
|
msg["From"] = cfg.smtp_from or cfg.smtp_user or "voice-assistant@localhost"
|
|
msg["To"] = ", ".join(to)
|
|
msg["Subject"] = subject
|
|
msg.set_content(body)
|
|
try:
|
|
with smtplib.SMTP(cfg.smtp_host, int(cfg.smtp_port), timeout=10) as s:
|
|
if cfg.smtp_starttls:
|
|
s.starttls()
|
|
if cfg.smtp_user:
|
|
s.login(cfg.smtp_user, cfg.smtp_password)
|
|
s.send_message(msg)
|
|
return True
|
|
except Exception:
|
|
logger.exception("Notruf-E-Mail konnte nicht versendet werden")
|
|
return False
|
|
|
|
|
|
def _emergency_texts(name: str) -> dict[str, str]:
|
|
"""Fertige Texte für die SMS- und Sprachanruf-Kanäle (vom Webhook-Empfänger genutzt)."""
|
|
return {
|
|
"sms": (
|
|
f"NOTRUF: {name} hat in der Voice-Assistant-App Hilfe angefordert. "
|
|
f"Bitte sofort Kontakt aufnehmen."
|
|
),
|
|
"call": (
|
|
f"Achtung. {name} hat über die Voice Assistant App einen Notruf ausgelöst. "
|
|
f"Bitte nehmen Sie umgehend Kontakt auf. "
|
|
f"Ich wiederhole: {name} braucht dringend Hilfe."
|
|
),
|
|
}
|
|
|
|
|
|
def _webhook_payload(user, language: str | None, phones: list[str], channels: list[str], name: str) -> dict:
|
|
"""Provider-agnostisches Notruf-Payload für den Eskalations-Webhook."""
|
|
return {
|
|
"event": "emergency",
|
|
"category": "manual",
|
|
"source": "voice-assistant",
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"language": (language or "de"),
|
|
"user": {"id": getattr(user, "id", None), "name": name},
|
|
"channels": channels,
|
|
"phones": phones,
|
|
"message": _emergency_texts(name),
|
|
}
|
|
|
|
|
|
async def _post_webhook(url: str, payload: dict, cfg) -> bool:
|
|
"""Sendet das Notruf-Payload per HTTP-POST an den Eskalations-Webhook (best effort)."""
|
|
if not url:
|
|
return False
|
|
headers = {"Content-Type": "application/json"}
|
|
if cfg.emergency_webhook_token:
|
|
headers["Authorization"] = f"Bearer {cfg.emergency_webhook_token}"
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
resp = await c.post(url, json=payload, headers=headers)
|
|
return resp.is_success
|
|
except Exception:
|
|
logger.exception("Notruf-Webhook (SMS/Anruf) konnte nicht ausgelöst werden")
|
|
return False
|
|
|
|
|
|
async def record_manual_emergency(user, store, language: str | None = None, cfg=settings) -> dict:
|
|
"""Protokolliert einen bestätigten Notruf, benachrichtigt Kontakte und liefert den Hinweis."""
|
|
store.log_emergency(user.id, "manual", "Vom Nutzer ausgelöster Notruf")
|
|
metrics.inc("emergency_total", {"category": "manual", "source": "user"})
|
|
|
|
name = getattr(user, "display_name", None) or getattr(user, "id", "?")
|
|
|
|
# Kanal 1: E-Mail (SMTP)
|
|
to = _contacts(user, cfg)
|
|
subject = f"🆘 Notruf von {name}"
|
|
body = (
|
|
f"{name} hat in der Voice-Assistant-App einen Notruf ausgelöst.\n\n"
|
|
f"Bitte umgehend Kontakt aufnehmen.\n\n"
|
|
f"(Diese Meldung wurde automatisch von der Voice-Assistant-App gesendet.)"
|
|
)
|
|
email_sent = await asyncio.to_thread(_send_email_sync, to, subject, body, cfg)
|
|
if email_sent:
|
|
metrics.inc("emergency_notify_total", {"channel": "email"})
|
|
|
|
# Kanal 2: SMS/Anruf (provider-agnostischer Webhook)
|
|
phones = _phones(user, cfg)
|
|
channels = _csv(cfg.emergency_webhook_channels)
|
|
payload = _webhook_payload(user, language, phones, channels, name)
|
|
webhook_sent = await _post_webhook(cfg.emergency_webhook_url, payload, cfg)
|
|
if webhook_sent:
|
|
metrics.inc("emergency_notify_total", {"channel": "webhook"})
|
|
|
|
notified = email_sent or webhook_sent
|
|
return {
|
|
"category": "manual",
|
|
"notice": notify_notice(language, notified),
|
|
"email_sent": email_sent,
|
|
"webhook_sent": webhook_sent,
|
|
"channels": channels,
|
|
}
|