Files
Antoine Van Elstraete b2106e75ac fix(M10): apply FIXME_M10 corrections (transport, dry_run, format, security)
Fix all 8 findings from the independent review (FIXME_M10.md):

#1 Transport compatible with slixmpp 1.17.0 (D5):
  - Use real ClientXMPP type (remove Any), JID with resource
  - connect(host, port) explicit, no use_tls kwarg
  - enable_direct_tls/enable_starttls configured before connect
  - Single timeout via asyncio.Future for session_start/failed_auth/disconnected
  - Remove premature 'starttls' in features check, remove auto_reconnect
  - try/finally guarantees disconnect on all paths (#4)

#2 Factory dry_run no longer bypassed (D6):
  - Single send() entry point in SyncXmppChannel
  - dry_run check before any ClientXMPP creation
  - Remove XmppChannel.send() dual implementation

#3 Thread daemon removed — single asyncio.run(), documented limitation

#5 Richer message format:
  - Target date header, change type [Ajouté/Supprimé/Modifié]
  - Lesson times, homework due date, message author
  - No pronote_messages duplication (external_info = blog + other_info only)

#6 Error contract unified (D6):
  - Channel.send() -> bool never raises PipelineWarning
  - Errors logged with redaction, returns False
  - PipelineWarning(step='xmpp') will be created by pipeline M11

#7 Tests faithful to slixmpp 1.17.0 API:
  - FakeClientXMPP with real connect(host,port)/disconnect() signatures
  - Assertions on host, port, resource, mtype='chat'
  - No RuntimeWarning from unawaited coroutines

#8 .secrets.baseline restored from main

Coverage: 96.44% on channels/, 600 tests pass, pre-commit all-files green.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-08 02:16:28 +02:00

370 lines
15 KiB
Python

"""Canal de sortie XMPP du pipeline ``pronote-sync``.
Ce module implémente le canal d'envoi de notifications XMPP : la classe
:class:`XmppChannel` envoie un message direct via ``slixmpp``
(:meth:`XmppChannel.send_async`), tandis que :class:`SyncXmppChannel`
fournit le point d'entrée synchrone unique utilisé par le pipeline. Le corps
du message est formaté en texte brut par ``_format_message`` (en-tête de date
cible puis sections emoji 📌📅📚💬📢) et chaque texte est assaini par
:func:`pronote_sync.utils.text.sanitize_plaintext` (SEC-XMPP-06).
Contrat d'erreur (D6) : le canal ne lève jamais :pyexc:`PipelineWarning` ;
en cas d'échec, il journalise la version expurgée de l'erreur et retourne
``False``. Le :pyexc:`PipelineWarning` est créé par l'étape pipeline, pas par
le canal.
"""
from __future__ import annotations
import asyncio
import logging
from pydantic import SecretStr
from slixmpp import JID, ClientXMPP
from pronote_sync.config.settings import XmppSettings
from pronote_sync.models.blog import ExternalInfo
from pronote_sync.models.diff import AgendaChange, AgendaChangeType
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.
Distingue les ajouts, suppressions et modifications (U4). Pour un ajout,
les horaires du cours (``HH:MM-HH:MM``) sont inclus si le cours est
disponible.
:param changes: Liste des changements d'agenda.
:return: Section ``📅 Changements d'agenda`` avec une ligne par
changement (type, 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
if change.type == AgendaChangeType.ADDED and change.lesson is not None:
times = (
f"{change.lesson.start.strftime('%H:%M')}-{change.lesson.end.strftime('%H:%M')}"
)
lines.append(f"• [Ajouté] {subject}: {change.details} ({times})")
elif change.type == AgendaChangeType.REMOVED:
lines.append(f"• [Supprimé] {subject}: {change.details}")
else:
lines.append(f"• [Modifié] {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,
texte et date d'échéance).
:rtype: str
"""
if not homeworks:
body = "Aucun devoir."
else:
lines = [
f"{homework.subject}: {homework.text} "
f"(à rendre le {homework.due_on.strftime('%d/%m')})"
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,
auteur et contenu) ; sans titre, seul l'auteur est affiché.
:rtype: str
"""
if not messages:
body = "Aucun message."
else:
lines: list[str] = []
for message in messages:
if message.title:
lines.append(f"{message.title} ({message.author}): {message.content}")
else:
lines.append(f"{message.author}: {message.content}")
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 uniquement les articles du blog et les autres informations
(``other_info``) : les messages Pronote (``pronote_messages``) sont
exclus car ils sont déjà transmis par la section des messages.
: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 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é.
Envoie un message direct (``type="chat"``) au destinataire configuré en
utilisant :class:`slixmpp.ClientXMPP`. La connexion est établie à chaque
appel de :meth:`send_async` ; le constructeur n'effectue aucun accès
réseau.
Contrat d'erreur (D6) : :meth:`send_async` ne lève jamais
:pyexc:`PipelineWarning` ; en cas d'échec, elle journalise la version
expurgée de l'erreur et retourne ``False``. En mode ``dry_run``, aucun
client n'est créé.
: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_async` journalise 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 : un en-tête avec la date cible du
digest, puis 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 = [
f"Digest du {message.target_date.strftime('%d/%m/%Y')}",
_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)
async def send_async(self, message: XmppMessage) -> bool:
"""Exécute le flux asynchrone d'envoi XMPP (U2).
Connecte le client ``slixmpp`` avec un hôte et un port explicites,
configure TLS avant la connexion, puis attend l'un des événements
``session_start``, ``failed_auth`` ou ``disconnected`` sous un
timeout unique avant d'envoyer un message direct ``chat`` au
destinataire configuré. La déconnexion est garantie par un bloc
``try/finally``. Aucun secret n'est journalisé (SEC-XMPP-02).
:param message: Message final à envoyer.
:return: ``True`` si l'envoi a réussi (ou a été simulé en dry-run),
``False`` sinon (destinataire manquant, timeout, échec
d'authentification, déconnexion ou erreur réseau).
:rtype: bool
"""
if self.dry_run:
formatted = self._format_message(message)
logger.info("XMPP dry-run: message would be sent")
return True
# Build JID with resource
jid_str = f"{self.settings.jid}/{self.settings.resource}"
recipient = JID(self.settings.to) if self.settings.to else None
if recipient is None:
logger.warning("Destinataire XMPP manquant.")
return False
# Create typed client
client = ClientXMPP(
jid_str,
self.settings.password.get_secret_value() if self.settings.password else "",
)
# Configure TLS BEFORE connect
if self.settings.use_tls:
# TLS direct (port 5223 typically)
client.enable_direct_tls = True
client.enable_starttls = False
else:
# STARTTLS (port 5222 typically)
client.enable_starttls = True
client.enable_direct_tls = False
# Register handlers
session_future: asyncio.Future[bool] = asyncio.get_event_loop().create_future()
def on_session_start(event: object) -> None:
if not session_future.done():
session_future.set_result(True)
def on_failed_auth(event: object) -> None:
if not session_future.done():
session_future.set_result(False)
def on_disconnected(event: object) -> None:
if not session_future.done():
session_future.set_result(False)
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)
try:
# Connect with explicit host and port
connect_future = client.connect(self.settings.host, self.settings.port)
await connect_future # connect() returns a Future, not a coroutine
# Wait for one of the three events under a single timeout
try:
success = await asyncio.wait_for(session_future, timeout=self.settings.timeout)
except TimeoutError:
logger.warning("Délai d'attente de session XMPP dépassé.")
return False
if not success:
logger.warning("Échec d'authentification ou déconnexion XMPP.")
return False
# Send the message
formatted = self._format_message(message)
client.send_message(mto=JID(self.settings.to), mbody=formatted, mtype="chat")
return True
except Exception as exc:
redacted = redact_exception(exc)
extra = _secret_values(self.settings)
logger.warning("Erreur XMPP: %s", redact_secrets(redacted, extra_secrets=extra))
return False
finally:
try:
disconnect_future = client.disconnect()
await disconnect_future
except Exception as cleanup_exc:
logger.debug(
"Erreur lors de la déconnexion XMPP: %s", redact_exception(cleanup_exc)
)
class SyncXmppChannel:
"""Point d'entrée synchrone unique du canal XMPP pour le pipeline (U3).
Enveloppe une instance de :class:`XmppChannel` pour offrir une interface
synchrone conforme au :class:`~pronote_sync.channels.protocol.Channel`.
:meth:`send` délègue à :func:`asyncio.run` et ne lève jamais : toute
erreur est journalisée de façon expurgée et convertie en retour
``False`` (D6). En mode ``dry_run``, aucun client ``slixmpp`` n'est créé.
: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 le point d'entrée 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.
En mode ``dry_run``, le message formaté (expurgé de ses secrets) est
journalisé et la méthode retourne ``True`` sans créer de client XMPP.
Sinon, le flux asynchrone :meth:`XmppChannel.send_async` est exécuté
via :func:`asyncio.run` ; toute exception est journalisée sous forme
expurgée et convertie en retour ``False``. La méthode ne lève jamais
(D6).
:param message: Message final à envoyer.
:return: ``True`` si l'envoi a réussi (ou a été simulé en dry-run),
``False`` sinon.
:rtype: bool
"""
if self.dry_run:
formatted = self._channel._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._channel.send_async(message))
except Exception as exc:
redacted = redact_exception(exc)
redacted = redact_secrets(redacted, extra_secrets=_secret_values(self.settings))
logger.warning("XMPP : erreur lors de l'envoi synchrone : %s", redacted)
return False