"""Adapter-Muster des Plugins „benachrichtigung“. Ein Adapter kapselt genau einen Zustellkanal und implementiert `senden()`. Ist ein Kanal installationsweit nicht konfiguriert (z. B. kein SMTP-Host in der Entwicklung), tritt an seine Stelle ein Dev-Log-Adapter, der die Nachricht nur protokolliert — so geht in der Entwicklung keine Benachrichtigung verloren. """ from __future__ import annotations import asyncio import json import logging import smtplib import ssl import urllib.request from abc import ABC, abstractmethod from email.message import EmailMessage from typing import TYPE_CHECKING from .konfiguration import Konfiguration if TYPE_CHECKING: from sqlalchemy.orm import sessionmaker logger = logging.getLogger("redaktion.benachrichtigung") class BenachrichtigungsAdapter(ABC): """Basis eines Kanal-Adapters. `senden()` erhält alles Nötige vom Verteiler: user_id – Empfänger-Benutzer (für In-App-Persistenz) empfaenger – kanalspezifische Adresse (E-Mail bzw. Chat-ID), sonst None titel/text – Nachricht kategorie – freie Kategorie, z. B. "dedup" oder "erinnerung" """ schluessel: str label: str #: True, wenn der Adapter nur protokolliert (Entwicklungs-Fallback). dev_fallback: bool = False @abstractmethod async def senden( self, user_id: int, empfaenger: str | None, titel: str, text: str, kategorie: str, ) -> None: """Stellt die Nachricht über diesen Kanal zu.""" class DevLogAdapter(BenachrichtigungsAdapter): """Protokolliert Benachrichtigungen statt sie zu versenden.""" def __init__(self, schluessel: str, label: str) -> None: self.schluessel = schluessel self.label = label self.dev_fallback = True async def senden( self, user_id: int, empfaenger: str | None, titel: str, text: str, kategorie: str, ) -> None: logger.info( "[DEV] %s an Benutzer %s (%s): %s — %s", self.label, user_id, empfaenger or "ohne Adresse", titel, text, ) class EmailSmtpAdapter(BenachrichtigungsAdapter): """Versendet E-Mail über einen SMTP-Server (Konfiguration via Env).""" schluessel = "email" label = "E-Mail" def __init__(self, konfiguration: Konfiguration) -> None: self.konf = konfiguration async def senden( self, user_id: int, empfaenger: str | None, titel: str, text: str, kategorie: str, ) -> None: if not empfaenger: raise ValueError("Keine E-Mail-Adresse für den Empfänger hinterlegt.") await asyncio.to_thread(self._senden_sync, empfaenger, titel, text) def _senden_sync(self, empfaenger: str, titel: str, text: str) -> None: nachricht = EmailMessage() nachricht["Subject"] = f"[Spiele-Redaktion] {titel}" nachricht["From"] = self.konf.smtp_absender nachricht["To"] = empfaenger nachricht.set_content(text) with _smtp_verbindung(self.konf) as server: server.send_message(nachricht) class TelegramAdapter(BenachrichtigungsAdapter): """Versendet Nachrichten über die Telegram Bot-API (sendMessage).""" schluessel = "telegram" label = "Telegram" def __init__(self, bot_token: str) -> None: self.bot_token = bot_token async def senden( self, user_id: int, empfaenger: str | None, titel: str, text: str, kategorie: str, ) -> None: if not empfaenger: raise ValueError("Keine Telegram-Chat-ID für den Empfänger hinterlegt.") nachricht = f"{titel}\n\n{text}" await asyncio.to_thread(self._api_senden, empfaenger, nachricht) def _api_senden(self, chat_id: str, nachricht: str) -> dict: """Ein Aufruf der Bot-API; Test-Seam (wird in Tests überschrieben).""" url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage" daten = json.dumps({"chat_id": chat_id, "text": nachricht}).encode("utf-8") anfrage = urllib.request.Request( url, data=daten, headers={"Content-Type": "application/json"} ) with urllib.request.urlopen(anfrage, timeout=15) as antwort: ergebnis = json.load(antwort) if not ergebnis.get("ok"): raise RuntimeError(f"Telegram-API-Fehler: {ergebnis}") return ergebnis class InAppAdapter(BenachrichtigungsAdapter): """Persistiert die Nachricht als In-App-Nachricht im Portal.""" schluessel = "inapp" label = "In-App" def __init__(self, session_factory: "sessionmaker") -> None: self._session_factory = session_factory async def senden( self, user_id: int, empfaenger: str | None, titel: str, text: str, kategorie: str, ) -> None: from .models import Benachrichtigung def _schreiben() -> None: with self._session_factory() as db: db.add( Benachrichtigung( user_id=user_id, titel=titel, text=text, kategorie=kategorie ) ) db.commit() await asyncio.to_thread(_schreiben) def adapter_aus_konfiguration( konf: Konfiguration, session_factory: "sessionmaker" ) -> dict[str, BenachrichtigungsAdapter]: """Baut den Adapter-Satz der Installation; fehlende Konfiguration → Dev-Log.""" email_adapter: BenachrichtigungsAdapter telegram_adapter: BenachrichtigungsAdapter if konf.smtp_konfiguriert: email_adapter = EmailSmtpAdapter(konf) else: email_adapter = DevLogAdapter("email", "E-Mail (nur Protokoll)") if konf.telegram_konfiguriert: telegram_adapter = TelegramAdapter(konf.telegram_bot_token) else: telegram_adapter = DevLogAdapter("telegram", "Telegram (nur Protokoll)") return { "email": email_adapter, "telegram": telegram_adapter, "inapp": InAppAdapter(session_factory), } def _smtp_verbindung(konf: Konfiguration): """Öffnet eine SMTP-Verbindung gemäß TLS-Einstellung (Kontext-Manager).""" if konf.smtp_tls == "ssl": server = smtplib.SMTP_SSL(konf.smtp_host, konf.smtp_port, timeout=15) _anmelden(server, konf) return server verbindung = smtplib.SMTP(konf.smtp_host, konf.smtp_port, timeout=15) if konf.smtp_tls == "starttls": verbindung.starttls(context=ssl.create_default_context()) _anmelden(verbindung, konf) return verbindung def _anmelden(server, konf: Konfiguration) -> None: if konf.smtp_benutzer and konf.smtp_passwort: server.login(konf.smtp_benutzer, konf.smtp_passwort)