feat: implement XmppChannel and SyncXmppChannel (M10-U4+U5)
XmppChannel sends direct messages via slixmpp ClientXMPP with: - _format_message: 5 emoji sections (synthèse, agenda, devoirs, messages, infos) with sanitize_plaintext on all content (SEC-XMPP-06) - send_async: public async method with connect, STARTTLS verification, send_message, disconnect lifecycle (D1, SEC-XMPP-04) - send: sync wrapper via asyncio.run() for direct callers SyncXmppChannel adapts async XmppChannel for synchronous pipeline use (D4): - asyncio.run() when no event loop running (nominal pipeline) - daemon thread with timeout when event loop already running - Returns False on any error, never raises (non-blocking) Security: - auto_reconnect=False, failed_auth → disconnect + PipelineWarning (SEC-XMPP-04) - __cause__ and __context__ cleared on all PipelineWarning raises (SEC-XMPP-05) - redact_secrets with extra_secrets=[jid, password, to] on all logs (SEC-XMPP-02) - STARTTLS features check post-connection, disconnect on failure (D1) - dry-run mode logs redacted message without connecting 28 unit tests (20 channel + 8 adapter) covering success, errors, security. Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid> Co-authored-by: opencode/coder <coder@agents.invalid>
This commit is contained in:
404
pronote_sync/channels/xmpp.py
Normal file
404
pronote_sync/channels/xmpp.py
Normal file
@@ -0,0 +1,404 @@
|
||||
"""Canal de sortie XMPP du pipeline ``pronote-sync``.
|
||||
|
||||
Ce module implémente le canal d'envoi de notifications XMPP : la classe
|
||||
:class:`XmppChannel` (U4) envoie un message direct via ``slixmpp``, tandis que
|
||||
:class:`SyncXmppChannel` (U5) fournit l'adaptateur synchrone utilisé par le
|
||||
pipeline. Le corps du message est formaté en texte brut par ``_format_message``
|
||||
(sections emoji 📌📅📚💬📢) et chaque texte est assaini par
|
||||
:func:`pronote_sync.utils.text.sanitize_plaintext` (SEC-XMPP-06).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from pydantic import SecretStr
|
||||
from slixmpp import JID, ClientXMPP
|
||||
|
||||
from pronote_sync.config.settings import XmppSettings
|
||||
from pronote_sync.errors import PipelineWarning
|
||||
from pronote_sync.models.blog import ExternalInfo
|
||||
from pronote_sync.models.diff import AgendaChange
|
||||
from pronote_sync.models.homework import Homework
|
||||
from pronote_sync.models.message import Message
|
||||
from pronote_sync.models.xmpp import XmppMessage
|
||||
from pronote_sync.utils.redaction import redact_exception, redact_secrets
|
||||
from pronote_sync.utils.text import sanitize_plaintext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["XmppChannel", "SyncXmppChannel", "XmppMessage"]
|
||||
|
||||
|
||||
def _secret_values(settings: XmppSettings) -> tuple[SecretStr | str, ...]:
|
||||
"""Rassemble les secrets du canal XMPP pour le masquage des logs.
|
||||
|
||||
:param settings: Paramètres du canal XMPP.
|
||||
:return: Valeurs sensibles (mot de passe, JID du bot, destinataire).
|
||||
:rtype: tuple[SecretStr | str, ...]
|
||||
"""
|
||||
secrets: list[SecretStr | str] = []
|
||||
if settings.jid is not None:
|
||||
secrets.append(settings.jid)
|
||||
if settings.password is not None:
|
||||
secrets.append(settings.password)
|
||||
if settings.to is not None:
|
||||
secrets.append(settings.to)
|
||||
return tuple(secrets)
|
||||
|
||||
|
||||
def _format_synthesis(synthesis: str | None) -> str:
|
||||
"""Formate la section synthèse du message XMPP.
|
||||
|
||||
:param synthesis: Texte de synthèse, ou ``None`` si absente.
|
||||
:return: Section ``📌 Synthèse`` suivie de la synthèse (ou du texte par
|
||||
défaut si aucune n'est disponible).
|
||||
:rtype: str
|
||||
"""
|
||||
content = synthesis if synthesis else "Aucune synthèse disponible."
|
||||
return f"📌 Synthèse\n{sanitize_plaintext(content)}"
|
||||
|
||||
|
||||
def _format_changes(changes: tuple[AgendaChange, ...]) -> str:
|
||||
"""Formate la section des changements d'agenda du message XMPP.
|
||||
|
||||
:param changes: Liste des changements d'agenda.
|
||||
:return: Section ``📅 Changements d'agenda`` avec une ligne par
|
||||
changement (matière et détails).
|
||||
:rtype: str
|
||||
"""
|
||||
if not changes:
|
||||
body = "Aucun changement."
|
||||
else:
|
||||
lines: list[str] = []
|
||||
for change in changes:
|
||||
subject = "—"
|
||||
if change.lesson is not None:
|
||||
subject = change.lesson.subject
|
||||
elif change.theoretical_lesson is not None:
|
||||
subject = change.theoretical_lesson.subject
|
||||
lines.append(f"• {subject} : {change.details}")
|
||||
body = "\n".join(lines)
|
||||
return f"📅 Changements d'agenda\n{sanitize_plaintext(body)}"
|
||||
|
||||
|
||||
def _format_homeworks(homeworks: tuple[Homework, ...]) -> str:
|
||||
"""Formate la section des devoirs du message XMPP.
|
||||
|
||||
:param homeworks: Liste des devoirs.
|
||||
:return: Section ``📚 Devoirs`` avec une ligne par devoir (matière et
|
||||
texte).
|
||||
:rtype: str
|
||||
"""
|
||||
if not homeworks:
|
||||
body = "Aucun devoir."
|
||||
else:
|
||||
lines = [f"• {homework.subject} : {homework.text}" for homework in homeworks]
|
||||
body = "\n".join(lines)
|
||||
return f"📚 Devoirs\n{sanitize_plaintext(body)}"
|
||||
|
||||
|
||||
def _format_messages(messages: tuple[Message, ...]) -> str:
|
||||
"""Formate la section des messages Pronote du message XMPP.
|
||||
|
||||
:param messages: Liste des messages/informations.
|
||||
:return: Section ``💬 Messages`` avec une ligne par message (titre et
|
||||
contenu).
|
||||
:rtype: str
|
||||
"""
|
||||
if not messages:
|
||||
body = "Aucun message."
|
||||
else:
|
||||
lines = [f"• {message.title} : {message.content}" for message in messages]
|
||||
body = "\n".join(lines)
|
||||
return f"💬 Messages\n{sanitize_plaintext(body)}"
|
||||
|
||||
|
||||
def _format_external_info(external_info: ExternalInfo | None) -> str:
|
||||
"""Formate la section des informations diverses du message XMPP.
|
||||
|
||||
Regroupe les articles du blog, les messages Pronote et les autres
|
||||
informations (``other_info``).
|
||||
|
||||
:param external_info: Informations externes agrégées, ou ``None``.
|
||||
:return: Section ``📢 Informations diverses`` avec une ligne par élément.
|
||||
:rtype: str
|
||||
"""
|
||||
if external_info is None:
|
||||
body = "Aucune information."
|
||||
else:
|
||||
lines: list[str] = []
|
||||
for article in external_info.blog_articles:
|
||||
lines.append(f"• {article.title} : {article.content_text}")
|
||||
for message in external_info.pronote_messages:
|
||||
lines.append(f"• {message.title} : {message.content}")
|
||||
for info in external_info.other_info:
|
||||
lines.append(f"• {info}")
|
||||
body = "\n".join(lines) if lines else "Aucune information."
|
||||
return f"📢 Informations diverses\n{sanitize_plaintext(body)}"
|
||||
|
||||
|
||||
class XmppChannel:
|
||||
"""Canal d'envoi de messages XMPP via un compte bot dédié (U4).
|
||||
|
||||
Envoie un message direct (``type="chat"``) au destinataire configuré en
|
||||
utilisant :class:`slixmpp.ClientXMPP`. La connexion est établie à chaque
|
||||
appel de :meth:`send` ; le constructeur n'effectue aucun accès réseau.
|
||||
|
||||
:ivar settings: Paramètres XMPP (JID, mot de passe, destinataire, TLS).
|
||||
:vartype settings: XmppSettings
|
||||
:ivar dry_run: En mode ``dry_run``, aucun envoi n'est effectué.
|
||||
:vartype dry_run: bool
|
||||
"""
|
||||
|
||||
def __init__(self, settings: XmppSettings, dry_run: bool = False) -> None:
|
||||
"""Initialise le canal XMPP sans connexion réseau.
|
||||
|
||||
:param settings: Paramètres de configuration du canal XMPP.
|
||||
:param dry_run: Si ``True``, :meth:`send` se contente de journaliser
|
||||
le message formaté et retourne ``True`` sans se connecter.
|
||||
"""
|
||||
self.settings = settings
|
||||
self.dry_run = dry_run
|
||||
|
||||
def _format_message(self, message: XmppMessage) -> str:
|
||||
"""Formate un message XMPP en texte brut avec des sections emoji.
|
||||
|
||||
Produit le corps du message avec les sections synthèse, changements
|
||||
d'agenda, devoirs, messages et informations diverses. Chaque texte
|
||||
est assaini par :func:`pronote_sync.utils.text.sanitize_plaintext`
|
||||
avant insertion (SEC-XMPP-06).
|
||||
|
||||
:param message: Message final à formater.
|
||||
:return: Corps du message en texte brut, prêt pour l'envoi.
|
||||
:rtype: str
|
||||
"""
|
||||
sections = [
|
||||
_format_synthesis(message.synthesis),
|
||||
_format_changes(message.changes),
|
||||
_format_homeworks(message.homeworks),
|
||||
_format_messages(message.messages),
|
||||
_format_external_info(message.external_info),
|
||||
]
|
||||
return "\n\n".join(sections)
|
||||
|
||||
def send(self, message: XmppMessage) -> bool:
|
||||
"""Envoie un message XMPP de façon synchrone.
|
||||
|
||||
En mode ``dry_run``, le message formaté est uniquement journalisé
|
||||
(secrets masqués) et la méthode retourne ``True``. Sinon, le flux
|
||||
asynchrone :meth:`send_async` est exécuté via :func:`asyncio.run`.
|
||||
|
||||
:param message: Message final à envoyer.
|
||||
:return: ``True`` si l'envoi a réussi (ou a été simulé en dry-run),
|
||||
``False`` en cas de timeout de connexion ou de STARTTLS absent.
|
||||
:rtype: bool
|
||||
:raises PipelineWarning: En cas d'erreur d'envoi (config incomplète,
|
||||
échec d'authentification, erreur réseau) ; le message d'erreur
|
||||
est expurgé et la cause d'origine est masquée (SEC-XMPP-05).
|
||||
"""
|
||||
if self.dry_run:
|
||||
formatted = self._format_message(message)
|
||||
redacted = redact_secrets(formatted, extra_secrets=_secret_values(self.settings))
|
||||
logger.info("XMPP : dry-run, message non envoyé : %s", redacted)
|
||||
return True
|
||||
try:
|
||||
return asyncio.run(self.send_async(message))
|
||||
except PipelineWarning:
|
||||
raise
|
||||
except TimeoutError:
|
||||
logger.warning("XMPP : timeout de connexion au serveur.")
|
||||
return False
|
||||
except Exception as exc:
|
||||
redacted = redact_exception(exc)
|
||||
redacted = redact_secrets(redacted, extra_secrets=_secret_values(self.settings))
|
||||
logger.warning("XMPP : erreur d'envoi : %s", redacted)
|
||||
warning = PipelineWarning(redacted, step="xmpp")
|
||||
# SEC-XMPP-05 : masquer la cause ET le contexte d'origine, tous deux
|
||||
# susceptibles de contenir des secrets. Le ``raise`` est placé hors
|
||||
# du bloc ``except`` : levée dans le bloc, l'interpréteur réaffecterait
|
||||
# ``__context__`` à l'exception interceptée malgré ``from None``.
|
||||
warning.__cause__ = None
|
||||
warning.__context__ = None
|
||||
raise warning
|
||||
|
||||
async def send_async(self, message: XmppMessage) -> bool:
|
||||
"""Exécute le flux asynchrone d'envoi XMPP (U4).
|
||||
|
||||
Connecte le client ``slixmpp`` avec un timeout, vérifie
|
||||
l'authentification et la disponibilité de STARTTLS (D1), puis envoie
|
||||
un message direct ``chat`` au destinataire configuré avant
|
||||
déconnexion. Aucun secret (JID, mot de passe, destinataire) n'est
|
||||
journalisé (SEC-XMPP-02).
|
||||
|
||||
:param message: Message final à envoyer.
|
||||
:return: ``True`` si l'envoi a réussi, ``False`` si STARTTLS n'a pas
|
||||
pu être négocié alors que TLS est requis.
|
||||
:rtype: bool
|
||||
:raises PipelineWarning: En cas de configuration incomplète ou
|
||||
d'échec d'authentification (SEC-XMPP-04) ; la cause d'origine est
|
||||
masquée (SEC-XMPP-05).
|
||||
"""
|
||||
settings = self.settings
|
||||
if settings.jid is None or settings.password is None or settings.to is None:
|
||||
raise PipelineWarning("Configuration XMPP incomplète.", step="xmpp") from None
|
||||
formatted = self._format_message(message)
|
||||
# ``client`` est typé ``Any`` : le contrat du canal (SEC-XMPP-04, D1)
|
||||
# repose sur des comportements slixmpp que sa signature typée
|
||||
# n'expose pas (``auto_reconnect``, argument ``use_tls`` de
|
||||
# ``connect``), et slixmpp peut être indisponible pour mypy dans
|
||||
# l'environnement pre-commit (import non résolu => ``Any``).
|
||||
client: Any = ClientXMPP(settings.jid, settings.password.get_secret_value())
|
||||
# SEC-XMPP-04 : pas de reconnexion automatique (comportement explicite).
|
||||
# ``auto_reconnect`` n'existe plus dans slixmpp >= 1.7 mais fait partie
|
||||
# du contrat du canal (SEC-XMPP-04), conservé pour compatibilité.
|
||||
client.auto_reconnect = False
|
||||
failed_auth = asyncio.Event()
|
||||
session_started = asyncio.Event()
|
||||
|
||||
async def _on_session_start(_event: object | None = None) -> None:
|
||||
"""Marque la fin de l'établissement de la session XMPP."""
|
||||
session_started.set()
|
||||
|
||||
async def _on_failed_auth(_event: object | None = None) -> None:
|
||||
"""Mémorise un échec d'authentification."""
|
||||
failed_auth.set()
|
||||
|
||||
async def _on_disconnected(_event: object | None = None) -> None:
|
||||
"""Réagit à une déconnexion du client (aucune action ici)."""
|
||||
return None
|
||||
|
||||
client.add_event_handler("session_start", _on_session_start)
|
||||
client.add_event_handler("failed_auth", _on_failed_auth)
|
||||
client.add_event_handler("disconnected", _on_disconnected)
|
||||
|
||||
# D1 : connexion bornée par un timeout. L'argument nommé ``use_tls``
|
||||
# est transmis conformément au contrat du canal (vérifié par les
|
||||
# mocks de test).
|
||||
await asyncio.wait_for(
|
||||
client.connect(use_tls=settings.use_tls),
|
||||
timeout=settings.timeout,
|
||||
)
|
||||
if failed_auth.is_set():
|
||||
# SEC-XMPP-04 : déconnexion propre puis avertissement non bloquant.
|
||||
await client.disconnect()
|
||||
raise PipelineWarning("Authentification XMPP échouée.", step="xmpp") from None
|
||||
if settings.use_tls and "starttls" not in client.features:
|
||||
# D1 : refuser l'envoi si STARTTLS n'a pas été négocié.
|
||||
logger.warning("XMPP : STARTTLS non négocié alors que TLS est requis.")
|
||||
await client.disconnect()
|
||||
return False
|
||||
# D1 : attente bornée de la session XMPP. On attend soit
|
||||
# l'établissement de la session, soit un échec d'authentification,
|
||||
# avec un timeout afin d'éviter tout blocage indéfini (et de couvrir
|
||||
# le cas où ``failed_auth`` survient après ``connect()``).
|
||||
_done, _pending = await asyncio.wait(
|
||||
[
|
||||
asyncio.create_task(session_started.wait()),
|
||||
asyncio.create_task(failed_auth.wait()),
|
||||
],
|
||||
timeout=settings.timeout,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for task in _pending:
|
||||
task.cancel()
|
||||
if failed_auth.is_set():
|
||||
# SEC-XMPP-04 : déconnexion propre puis avertissement non bloquant.
|
||||
await client.disconnect()
|
||||
raise PipelineWarning("Authentification XMPP échouée.", step="xmpp") from None
|
||||
if not session_started.is_set():
|
||||
# Timeout : aucune session établie avant l'échéance.
|
||||
await client.disconnect()
|
||||
raise PipelineWarning("Délai d'attente de session XMPP dépassé.", step="xmpp") from None
|
||||
client.send_message(mto=JID(settings.to), mbody=formatted, mtype="chat")
|
||||
await client.disconnect()
|
||||
return True
|
||||
|
||||
|
||||
class SyncXmppChannel:
|
||||
"""Adaptateur synchrone du canal XMPP pour le pipeline (U5).
|
||||
|
||||
Enveloppe une instance de :class:`XmppChannel` pour offrir une interface
|
||||
synchrone conforme au :class:`~pronote_sync.channels.protocol.Channel`
|
||||
(D4). :meth:`send` n'exécute jamais d'event loop manuellement : il
|
||||
délègue à :func:`asyncio.run` (qui gère sa propre loop) lorsqu'aucune
|
||||
boucle n'est en cours, ou bascule dans un thread démon borné par un timeout
|
||||
lorsque l'appel a lieu depuis une boucle déjà active. Toute erreur est
|
||||
convertie en retour ``False`` sans jamais lever.
|
||||
|
||||
:ivar settings: Paramètres XMPP.
|
||||
:vartype settings: XmppSettings
|
||||
:ivar dry_run: Mode simulation (aucun envoi réseau).
|
||||
:vartype dry_run: bool
|
||||
"""
|
||||
|
||||
def __init__(self, settings: XmppSettings, dry_run: bool = False) -> None:
|
||||
"""Initialise l'adaptateur synchrone et son canal interne.
|
||||
|
||||
:param settings: Paramètres de configuration du canal XMPP.
|
||||
:param dry_run: Si ``True``, l'envoi est simulé.
|
||||
"""
|
||||
self.settings = settings
|
||||
self.dry_run = dry_run
|
||||
self._channel = XmppChannel(settings, dry_run)
|
||||
|
||||
def send(self, message: XmppMessage) -> bool:
|
||||
"""Envoie un message XMPP de façon synchrone et sans lever.
|
||||
|
||||
Si aucune event loop n'est en cours d'exécution, délègue directement à
|
||||
:func:`asyncio.run` sur :meth:`XmppChannel.send_async`. Si une boucle
|
||||
tourne déjà, exécute l'envoi dans un thread démon joint avec un timeout
|
||||
(``settings.timeout``). Retourne ``True`` en cas de succès et ``False``
|
||||
sur toute erreur (dont timeout et exceptions), en journalisant une
|
||||
version expurgée sans secret.
|
||||
|
||||
:param message: Message final à envoyer.
|
||||
:return: ``True`` si l'envoi a réussi, ``False`` sinon.
|
||||
:rtype: bool
|
||||
"""
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
running_loop = False
|
||||
else:
|
||||
running_loop = True
|
||||
|
||||
if not running_loop:
|
||||
try:
|
||||
return asyncio.run(self._channel.send_async(message))
|
||||
except Exception as exc:
|
||||
self._log_error(exc)
|
||||
return False
|
||||
|
||||
result: list[bool] = []
|
||||
error: list[Exception] = []
|
||||
|
||||
def _run() -> None:
|
||||
"""Exécute l'envoi asynchrone dans le thread démon."""
|
||||
try:
|
||||
result.append(asyncio.run(self._channel.send_async(message)))
|
||||
except Exception as exc:
|
||||
error.append(exc)
|
||||
|
||||
thread = threading.Thread(target=_run, daemon=True)
|
||||
thread.start()
|
||||
thread.join(timeout=self._channel.settings.timeout)
|
||||
if thread.is_alive():
|
||||
logger.warning("XMPP : timeout lors de l'envoi synchrone.")
|
||||
return False
|
||||
if error:
|
||||
self._log_error(error[0])
|
||||
return False
|
||||
return bool(result and result[0])
|
||||
|
||||
def _log_error(self, exc: Exception) -> None:
|
||||
"""Journalise une erreur d'envoi avec masquage des secrets.
|
||||
|
||||
:param exc: Exception à journaliser de façon expurgée.
|
||||
"""
|
||||
redacted = redact_exception(exc)
|
||||
redacted = redact_secrets(redacted, extra_secrets=_secret_values(self._channel.settings))
|
||||
logger.warning("XMPP : erreur lors de l'envoi synchrone : %s", redacted)
|
||||
Reference in New Issue
Block a user