Files
Flo Hartmann f51732bff1 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
2026-08-21 19:00:06 +00:00

217 lines
6.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)