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>
This commit is contained in:
2026-09-08 02:16:28 +02:00
parent b61d314b7f
commit b2106e75ac
7 changed files with 988 additions and 972 deletions

View File

@@ -20,6 +20,12 @@ class Channel(Protocol):
def send(self, message: XmppMessage) -> bool:
"""Envoie un message sur le canal.
Un canal ne lève jamais :pyexc:`PipelineWarning` ; en cas d'échec, il
retourne ``False``. Le :pyexc:`PipelineWarning` est créé par l'étape
pipeline, pas par le canal. Une :pyexc:`PipelineCriticalError` peut
en revanche être levée en cas de panne critique (ex. : chemin
CalDAV, non utilisé par le canal XMPP).
:param message: Message final à transmettre.
:return: ``True`` si l'envoi a réussi, ``False`` sinon.
:rtype: bool

View File

@@ -1,27 +1,30 @@
"""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
: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
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.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
@@ -65,9 +68,13 @@ def _format_synthesis(synthesis: str | None) -> str:
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 (matière et détails).
changement (type, matière et détails).
:rtype: str
"""
if not changes:
@@ -80,7 +87,15 @@ def _format_changes(changes: tuple[AgendaChange, ...]) -> str:
subject = change.lesson.subject
elif change.theoretical_lesson is not None:
subject = change.theoretical_lesson.subject
lines.append(f"{subject} : {change.details}")
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)}"
@@ -89,14 +104,18 @@ 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).
: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}" for homework in homeworks]
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)}"
@@ -105,14 +124,19 @@ 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).
: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 = [f"{message.title} : {message.content}" for message in messages]
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)}"
@@ -120,8 +144,9 @@ def _format_messages(messages: tuple[Message, ...]) -> str:
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``).
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.
@@ -132,9 +157,7 @@ def _format_external_info(external_info: ExternalInfo | None) -> str:
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}")
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."
@@ -142,11 +165,17 @@ def _format_external_info(external_info: ExternalInfo | None) -> str:
class XmppChannel:
"""Canal d'envoi de messages XMPP via un compte bot dédié (U4).
"""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` ; le constructeur n'effectue aucun accès réseau.
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
@@ -158,8 +187,8 @@ class XmppChannel:
"""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.
: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
@@ -167,16 +196,18 @@ class XmppChannel:
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).
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),
@@ -185,149 +216,113 @@ class XmppChannel:
]
return "\n\n".join(sections)
def send(self, message: XmppMessage) -> bool:
"""Envoie un message XMPP de façon synchrone.
async def send_async(self, message: XmppMessage) -> bool:
"""Exécute le flux asynchrone d'envoi XMPP (U2).
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`.
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`` en cas de timeout de connexion ou de STARTTLS absent.
``False`` sinon (destinataire manquant, timeout, échec
d'authentification, déconnexion ou erreur réseau).
: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)
logger.info("XMPP dry-run: message would be sent")
return True
try:
return asyncio.run(self.send_async(message))
except PipelineWarning:
raise
except TimeoutError:
logger.warning("XMPP : timeout de connexion au serveur.")
# 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)
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()
extra = _secret_values(self.settings)
logger.warning("Erreur XMPP: %s", redact_secrets(redacted, extra_secrets=extra))
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
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:
"""Adaptateur synchrone du canal XMPP pour le pipeline (U5).
"""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`
(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.
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
@@ -336,7 +331,7 @@ class SyncXmppChannel:
"""
def __init__(self, settings: XmppSettings, dry_run: bool = False) -> None:
"""Initialise l'adaptateur synchrone et son canal interne.
"""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é.
@@ -348,57 +343,27 @@ class SyncXmppChannel:
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.
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, ``False`` sinon.
: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:
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 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
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)