my_voice_assistant_v3_jamulix/app/safety/emergency.py
dschlueter 8c152ee77c feat(notruf): Wiederauslöse-Sperre + grüner Hinweis; "Neues Gespräch" in die Kopfzeile
1) Notruf-Sperre (Cooldown) gegen Mehrfach-Alarme verwirrter/verängstigter
   Senioren:
   - Neue Einstellung emergency_cooldown_minutes (Default 5), zur Laufzeit im
     Admin-Bereich änderbar (RUNTIME_SETTABLE), 0 = aus.
   - record_manual_emergency unterdrückt innerhalb der Sperre jeden weiteren
     Alarm (kein zweiter Versand, kein Log-Eintrag) und liefert status=cooldown
     mit minutes_ago/remaining_seconds. Neuer Endpunkt GET /api/emergency/status
     für die Anzeige nach einem Seiten-Neuladen. Store: last_manual_emergency_at.
   - UI: Nach dem Alarm wird der Button grün, zeigt „✓" und pulsiert für die
     Dauer der Sperre. Erneuter Druck öffnet einen grünen Hinweis-Dialog:
     „Ich habe Deine Helfer vor <n> Minuten alarmiert. Wahrscheinlich ist Deine
     Hilfe schon unterwegs." Der grüne Zustand übersteht ein Neuladen
     (Status-Abruf beim Start).

2) „Neues Gespräch" als Symbol (Stift) zurück in die Kopfzeile — zwischen
   Sprachauswahl und Menü; löst dieselbe Aktion aus wie der Menüeintrag.

Tests: neue test_emergency_cooldown.py (Status/Suppression/Endpoint); zwei
bestehende Notruf-Tests an die neue Interface-Methode bzw. die Sperre
angepasst. 250 Offline-Tests grün. Frontend via Headless-Chrome verifiziert
(Alarm->grün/pulsierend, Hinweis-Dialog, Reload-Persistenz, Header-Button).
Bump app.js v=50 -> v=51.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:58:17 +02:00

361 lines
15 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).
Die Meldungen werden mit den Profildaten der auslösenden Person (Name, Adresse,
Telefon, med. Hinweise) und — falls vom Gerät mitgeschickt — der Geoposition
angereichert. Kanal-Rollen: die E-Mail trägt das Voll-Dossier, die SMS den
Karten-Link, der Anruf nennt Name + Wohnadresse gesprochen und verweist für den
genauen Standort auf SMS/E-Mail (Koordinaten werden NICHT vorgelesen). Schlägt die
Geo-Erfassung fehl, wird der Grund (geo_error) in E-Mail und Log ausgewiesen.
"""
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()]
# --- Profil, Standort, Gerät ----------------------------------------------
def _profile(user) -> dict:
"""Liest die Notfall-Profildaten der Person aus user.prefs (mit Fallbacks)."""
prefs = getattr(user, "prefs", None)
prefs = prefs if isinstance(prefs, dict) else {}
def g(key: str) -> str:
v = prefs.get(key)
return v.strip() if isinstance(v, str) else ""
name = g("full_name") or getattr(user, "display_name", "") or getattr(user, "id", "?")
street, postal, city = g("street"), g("postal_code"), g("city")
city_line = " ".join(p for p in (postal, city) if p)
address_short = ", ".join(p for p in (street, city_line) if p)
return {
"name": name,
"street": street,
"postal_code": postal,
"city": city,
"address_short": address_short,
"phone_landline": g("phone_landline"),
"phone_mobile": g("phone_mobile"),
"birth_date": g("birth_date"),
"medical_notes": g("medical_notes"),
}
def _geo_fields(geo) -> dict | None:
"""Normalisiert die Geoposition (pydantic-Model ODER dict) auf {lat,lon,accuracy,ts}."""
if geo is None:
return None
if isinstance(geo, dict):
d = geo
else:
d = {k: getattr(geo, k, None) for k in ("lat", "lon", "accuracy", "ts")}
if d.get("lat") is None or d.get("lon") is None:
return None
return {"lat": d["lat"], "lon": d["lon"], "accuracy": d.get("accuracy"), "ts": d.get("ts")}
def _maps_link(lat, lon) -> str:
return f"https://www.google.com/maps?q={lat},{lon}"
def _person_phones(profile: dict) -> str:
"""Telefonnummer(n) der Person für die Meldung (Mobil / Festnetz)."""
return " / ".join(t for t in (profile["phone_mobile"], profile["phone_landline"]) if t)
def _device_line(device) -> str | None:
"""Beschriftet das auslösende Gerät (Mobilgerät/Festgerät + Plattform), falls bekannt."""
if not device:
return None
if isinstance(device, dict):
mobile, platform = device.get("mobile"), device.get("platform")
else:
mobile, platform = getattr(device, "mobile", None), getattr(device, "platform", None)
kind = "Mobilgerät" if mobile else ("Festgerät" if mobile is False else "Gerät")
return f"Auslösegerät: {kind}" + (f" ({platform})" if platform else "")
def _location_block(geo, geo_error: str | None = None) -> str:
"""Standort-Block für die E-Mail (Koordinaten + Karte, oder Grund der Nicht-Verfügbarkeit)."""
g = _geo_fields(geo)
if not g:
reason = geo_error or "kein GPS / keine Freigabe"
return f"Standort (Gerät): nicht verfügbar ({reason})."
lines = [
f"Standort (Gerät): {g['lat']:.6f}, {g['lon']:.6f}",
f"Karte: {_maps_link(g['lat'], g['lon'])}",
]
if g.get("accuracy") is not None:
lines.append(f"Genauigkeit: ca. {round(g['accuracy'])} m")
if g.get("ts"):
lines.append(f"Zeitpunkt des Fixes: {g['ts']}")
return "\n".join(lines)
def _emergency_texts(profile: dict, geo) -> dict[str, str]:
"""Fertige Texte für SMS und Sprachanruf (vom Webhook-Empfänger genutzt)."""
name = profile["name"]
addr = profile["address_short"]
tel = _person_phones(profile)
g = _geo_fields(geo)
# SMS: kompakt — Name, Adresse, Telefon, Karten-Link (trägt den genauen Standort)
sms = f"NOTRUF: {name} braucht Hilfe."
if addr:
sms += f" Adresse: {addr}."
if tel:
sms += f" Tel: {tel}."
if g:
sms += f" Standort: {_maps_link(g['lat'], g['lon'])}"
# Anruf: gesprochen — Name + Wohnadresse, KEINE URL/Koordinaten, Verweis auf SMS/E-Mail
call = f"Achtung. {name} hat einen Notruf ausgelöst und braucht dringend Hilfe."
if addr:
call += f" Wohnadresse: {addr}."
call += " Genaue Standortdaten und weitere Details stehen in der SMS und der E-Mail."
return {"sms": sms, "call": call}
def _email_body(profile: dict, geo, geo_error: str | None = None, device=None) -> str:
"""Voll-Dossier für die E-Mail: Gerät + Angaben zur Person + Standort."""
lines = [
f"{profile['name']} hat in der Voice-Assistant-App einen Notruf ausgelöst.",
"",
"Bitte umgehend Kontakt aufnehmen.",
"",
]
dev = _device_line(device)
if dev:
lines += [dev, ""]
lines.append("— Angaben zur Person —")
fields = [
("Adresse", profile["address_short"]),
("Mobil", profile["phone_mobile"]),
("Festnetz", profile["phone_landline"]),
("Geburtsdatum", profile["birth_date"]),
("Medizinische Hinweise", profile["medical_notes"]),
]
info = [f"{label}: {value}" for label, value in fields if value]
lines.extend(info or ["(keine Profilangaben hinterlegt)"])
lines += ["", "— Standort —", _location_block(geo, geo_error), "",
"(Diese Meldung wurde automatisch von der Voice-Assistant-App gesendet.)"]
return "\n".join(lines)
# --- Versandkanäle ---------------------------------------------------------
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 _webhook_payload(user, language, phones, channels, profile, geo, geo_error=None, device=None) -> 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": profile["name"]},
"channels": channels,
"phones": phones,
"geo": _geo_fields(geo),
"geo_error": geo_error,
"device": device if isinstance(device, dict) else None,
"message": _emergency_texts(profile, geo),
}
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
def _cooldown_seconds(cfg) -> int:
try:
return max(0, int(getattr(cfg, "emergency_cooldown_minutes", 5)) * 60)
except (TypeError, ValueError):
return 300
def emergency_cooldown_status(user, store, cfg=settings) -> dict:
"""Aktueller Sperr-Status: ist nach dem letzten manuellen Notruf noch
eine Wiederauslöse-Sperre aktiv, wie lange ist er her, wie viel bleibt.
Schützt verwirrte/verängstigte Senioren vor Mehrfach-Alarmen.
"""
total = _cooldown_seconds(cfg)
last = store.last_manual_emergency_at(user.id) if total > 0 else None
base = {"active": False, "cooldown_seconds": total, "remaining_seconds": 0, "minutes_ago": 0}
if not last:
return base
try:
last_dt = datetime.fromisoformat(last)
except (ValueError, TypeError):
return base
elapsed = (datetime.now(timezone.utc) - last_dt).total_seconds()
remaining = int(total - elapsed)
return {
"active": remaining > 0,
"cooldown_seconds": total,
"remaining_seconds": max(0, remaining),
"minutes_ago": max(0, int(elapsed // 60)),
}
async def record_manual_emergency(user, store, language: str | None = None,
geo=None, geo_error: str | None = None, device=None,
cfg=settings) -> dict:
"""Protokolliert einen bestätigten Notruf, benachrichtigt Kontakte und liefert den Hinweis.
Innerhalb der Wiederauslöse-Sperre (emergency_cooldown_minutes) wird KEIN
neuer Alarm gesendet und kein Ereignis protokolliert — stattdessen liefert
die Antwort status='cooldown' mit minutes_ago/remaining_seconds, damit das
Frontend den beruhigenden Hinweis zeigen kann.
"""
cd = emergency_cooldown_status(user, store, cfg)
if cd["active"]:
metrics.inc("emergency_total", {"category": "manual", "source": "user_suppressed"})
return {
"category": "manual",
"status": "cooldown",
"cooldown_seconds": cd["cooldown_seconds"],
"remaining_seconds": cd["remaining_seconds"],
"minutes_ago": cd["minutes_ago"],
"notice": notify_notice(language, True),
}
# Bewusst minimaler Log-Eintrag — KEINE Adresse/Koordinaten (Admin-Log bleibt unkritisch).
store.log_emergency(user.id, "manual", "Vom Nutzer ausgelöster Notruf")
metrics.inc("emergency_total", {"category": "manual", "source": "user"})
profile = _profile(user)
# Diagnose-Zeile im Server-Log: erklärt fehlenden Standort (geo_error) + Geräteart.
logger.info("Notruf von %s: geo=%s geo_error=%r device=%r",
profile["name"], bool(_geo_fields(geo)), geo_error, device)
# Kanal 1: E-Mail (SMTP) — Voll-Dossier
to = _contacts(user, cfg)
subject = f"🆘 Notruf von {profile['name']}"
body = _email_body(profile, geo, geo_error, device)
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, profile, geo, geo_error, device)
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",
"status": "alerted",
"cooldown_seconds": cd["cooldown_seconds"],
"notice": notify_notice(language, notified),
"email_sent": email_sent,
"webhook_sent": webhook_sent,
"channels": channels,
}