diff --git a/app/api/chat.py b/app/api/chat.py index b60e76f..26ac77b 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -150,4 +150,7 @@ async def trigger_emergency(payload: EmergencyRequest, user: User = Depends(requ Die eigentliche Benachrichtigung von Angehörigen folgt später. """ store = get_store() - return await record_manual_emergency(user, store, payload.language, geo=payload.geo) + return await record_manual_emergency( + user, store, payload.language, + geo=payload.geo, geo_error=payload.geo_error, device=payload.device, + ) diff --git a/app/safety/emergency.py b/app/safety/emergency.py index 1001641..70f5062 100644 --- a/app/safety/emergency.py +++ b/app/safety/emergency.py @@ -5,18 +5,14 @@ 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. +- 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). - -Ohne SMTP-Host und ohne Webhook-URL wird nichts versendet und der Nutzer erhält -einen klaren Hinweis darauf. +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 @@ -72,7 +68,7 @@ def _csv(raw: str | None) -> list[str]: return [v.strip() for v in (raw or "").replace(";", ",").split(",") if v.strip()] -# --- Profil & Standort ----------------------------------------------------- +# --- Profil, Standort, Gerät ---------------------------------------------- def _profile(user) -> dict: """Liest die Notfall-Profildaten der Person aus user.prefs (mit Fallbacks).""" @@ -101,10 +97,7 @@ def _profile(user) -> dict: def _geo_fields(geo) -> dict | None: - """Normalisiert die Geoposition (pydantic-Model ODER dict) auf {lat,lon,accuracy,ts}. - - Liefert None, wenn keine brauchbaren Koordinaten vorliegen. - """ + """Normalisiert die Geoposition (pydantic-Model ODER dict) auf {lat,lon,accuracy,ts}.""" if geo is None: return None if isinstance(geo, dict): @@ -120,11 +113,29 @@ def _maps_link(lat, lon) -> str: return f"https://www.google.com/maps?q={lat},{lon}" -def _location_block(geo) -> str: - """Mehrzeiliger Standort-Block für die E-Mail (oder klarer 'nicht verfügbar'-Hinweis).""" +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: - return "Standort (Gerät): nicht verfügbar (kein GPS / keine Freigabe)." + 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'])}", @@ -140,12 +151,15 @@ 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, mit Karten-Link (trägt den genauen Standort) + # 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'])}" @@ -158,15 +172,18 @@ def _emergency_texts(profile: dict, geo) -> dict[str, str]: return {"sms": sms, "call": call} -def _email_body(profile: dict, geo) -> str: - """Voll-Dossier für die E-Mail: Angaben zur Person + Standort.""" +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.", "", - "— Angaben zur Person —", ] + dev = _device_line(device) + if dev: + lines += [dev, ""] + lines.append("— Angaben zur Person —") fields = [ ("Adresse", profile["address_short"]), ("Mobil", profile["phone_mobile"]), @@ -176,7 +193,7 @@ def _email_body(profile: dict, geo) -> str: ] info = [f"{label}: {value}" for label, value in fields if value] lines.extend(info or ["(keine Profilangaben hinterlegt)"]) - lines += ["", "— Standort —", _location_block(geo), "", + lines += ["", "— Standort —", _location_block(geo, geo_error), "", "(Diese Meldung wurde automatisch von der Voice-Assistant-App gesendet.)"] return "\n".join(lines) @@ -219,7 +236,7 @@ def _send_email_sync(to: list[str], subject: str, body: str, cfg) -> bool: return False -def _webhook_payload(user, language, phones, channels, profile, geo) -> dict: +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", @@ -231,6 +248,8 @@ def _webhook_payload(user, language, phones, channels, profile, geo) -> dict: "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), } @@ -251,18 +270,23 @@ async def _post_webhook(url: str, payload: dict, cfg) -> bool: return False -async def record_manual_emergency(user, store, language: str | None = None, geo=None, cfg=settings) -> dict: +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) + 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"}) @@ -270,7 +294,7 @@ async def record_manual_emergency(user, store, language: str | None = None, geo= # 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) + 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"}) diff --git a/app/schemas.py b/app/schemas.py index 852535a..7b2190c 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -113,10 +113,18 @@ class GeoFix(BaseModel): ts: str | None = None # ISO-Zeitstempel des Fixes +class DeviceInfo(BaseModel): + """Geräteart/Plattform des auslösenden Geräts (aus Browser-Daten).""" + mobile: bool | None = None + platform: str | None = None + + class EmergencyRequest(BaseModel): """Vom Nutzer ausgelöster Notruf (Knopf). language für den Hinweistext.""" language: str | None = None geo: GeoFix | None = None + geo_error: str | None = None # Grund, falls die Geo-Erfassung scheiterte + device: DeviceInfo | None = None class AdminUserPrefsUpdate(BaseModel): diff --git a/app/web/app.js b/app/web/app.js index 42f0b34..3393495 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -337,7 +337,9 @@ function applyAllowedLanguages(prefs) { const allowed = String((prefs || {}).allowed_languages || "") .split(",").map((s) => s.trim()).filter(Boolean); Array.from(langSel.options).forEach((opt) => { - opt.hidden = allowed.length > 0 && !allowed.includes(opt.value); + const blocked = allowed.length > 0 && !allowed.includes(opt.value); + opt.hidden = blocked; // versteckt (moderne Browser) + opt.disabled = blocked; // robust: auch wo