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:
216
plugins/benachrichtigung/adapter.py
Normal file
216
plugins/benachrichtigung/adapter.py
Normal 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)
|
||||
Reference in New Issue
Block a user