my_voice_assistant_v3_jamulix/app/safety/emergency.py

309 lines
13 KiB
Python
Raw Normal View History

"""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
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."""
# 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",
"notice": notify_notice(language, notified),
"email_sent": email_sent,
"webhook_sent": webhook_sent,
"channels": channels,
}