- 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
217 lines
6.7 KiB
Python
217 lines
6.7 KiB
Python
"""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)
|