Plugin benachrichtigung: E-Mail, Telegram, In-App per Adapter-Muster

- Adapter-Pattern mit drei Kanaelen: E-Mail (SMTP per Env, TLS starttls/ssl,
  Dev-Fallback: Protokoll), Telegram (Bot-API, Token per Env, Chat-ID pro
  Benutzer), In-App (persistente Nachrichten mit Unread-Counter und
  'Alle als gelesen markieren')
- Pro Benutzer Kanal-Praeferenzen (Einstellungsseite, mehrere Kanaele
  gleichzeitig) plus eigene Kontakt-Tabelle (E-Mail-Adresse, Chat-ID);
  Kern und users-Tabelle unveraendert
- Oeffentliche Plugin-API: await send_notification(user, titel, text,
  kategorie) - andere Plugins holen das Plugin ueber app.state.registry
- Eigene Migration (0001_tabellen), eigene Routen/Templates, deutsche UI
- Tests: Adapter-Auswahl nach Praeferenz, In-App-Persistenz, Dev-Log-
  Adapter, SMTP-Versand (gemockt), Telegram-API-Aufruf, Einstellungsseite
- README: Plugin-Doku + neue Env-Variablen; docker-compose: Platzhalter
This commit is contained in:
Flo Hartmann
2026-08-21 19:00:06 +00:00
parent 7edf80d01c
commit f51732bff1
9 changed files with 1286 additions and 28 deletions

View File

@@ -0,0 +1,216 @@
"""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)