"""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)