"""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, }