diff --git a/.secrets.baseline b/.secrets.baseline index e1a4d71..108ce07 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -140,7 +140,7 @@ "filename": "GUIDE_DEV_PYTHON.md", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "is_verified": true, - "line_number": 4935, + "line_number": 5029, "is_secret": false } ], @@ -177,5 +177,5 @@ } ] }, - "generated_at": "2026-09-07T17:59:08Z" + "generated_at": "2026-09-08T00:29:02Z" } diff --git a/GUIDE_DEV_PYTHON.md b/GUIDE_DEV_PYTHON.md index 043a379..8b0add9 100644 --- a/GUIDE_DEV_PYTHON.md +++ b/GUIDE_DEV_PYTHON.md @@ -4024,10 +4024,10 @@ Si le besoin évolue (ex: **plusieurs destinataires**), les étapes suivantes so | `XMPP_ENABLED` | Activer l'envoi XMPP. | `False` | `bool` | ❌ Non | | `XMPP_JID` | Identifiant du compte bot (ex: `pronote-bot@exemple.org`). | `None` | `str` | ✅ Oui | | `XMPP_PASSWORD` | Mot de passe du compte bot. | `None` | `SecretStr` | ✅ Oui | -| `XMPP_HOST` | Hôte XMPP **explicite** (ex: `exemple.org`). | `None` | `str` | ✅ Oui | -| `XMPP_PORT` | Port XMPP (5222 pour TLS, 5223 pour SSL). | `5222` | `int` | ❌ Non | +| `XMPP_HOST` | Hôte XMPP **explicite** (ex: `exemple.org`). | `""` | `str` | ✅ Oui | +| `XMPP_PORT` | Port XMPP (5222 pour STARTTLS, 5223 pour TLS direct). | `5222` | `int` | ❌ Non | | `XMPP_TO` | Destinataire unique (ex: `parent@exemple.org`). | `None` | `str` | ✅ Oui | -| `XMPP_RESOURCE` | Ressource XMPP (ex: `pronote-digest`). | `pronote-digest` | `str` | ❌ Non | +| `XMPP_RESOURCE` | Ressource XMPP (ex: `pronote-sync`). | `"pronote-sync"` | `str` | ❌ Non | | `XMPP_USE_TLS` | Utiliser TLS pour la connexion. | `True` | `bool` | ❌ Non | | `XMPP_TIMEOUT` | Timeout de connexion (secondes). | `30` | `int` | ❌ Non | @@ -4060,305 +4060,400 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class XmppSettings(BaseSettings): - model_config = SettingsConfigDict(env_prefix="XMPP_", env_file=".env", extra="ignore") - enabled: bool = Field(False, description="Activer l'envoi XMPP") - jid: str = Field(..., description="Identifiant du compte bot (ex: pronote-bot@exemple.org)") - password: SecretStr = Field(..., description="Mot de passe du compte bot") - host: str = Field(..., description="Hôte XMPP explicite (ex: exemple.org)") - port: int = Field(5222, description="Port XMPP (5222 pour TLS)") - to: str = Field(..., description="Destinataire unique (ex: parent@exemple.org)") - resource: str = Field("pronote-digest", description="Ressource XMPP") - use_tls: bool = Field(True, description="Utiliser TLS pour la connexion") - timeout: int = Field(30, description="Timeout de connexion (secondes)") + """Paramètres du canal de notifications XMPP (désactivé par défaut). + + Tous les champs ont des valeurs par défaut afin que le canal XMPP reste + inactif tant qu'il n'est pas explicitement activé. + """ + + model_config = SettingsConfigDict( + env_file=".env", + extra="ignore", + env_prefix="XMPP_", + hide_input_in_errors=True, + ) + + enabled: bool = False + jid: str | None = None + password: SecretStr | None = None + host: str = "" + port: int = Field(default=5222, ge=1, le=65535) + to: str | None = None + resource: str = "pronote-sync" + use_tls: bool = True + timeout: int = Field(default=30, gt=0) ``` +> **⚠️ Mapping TLS** : +> - `use_tls=True` → **TLS direct** (port 5223, `enable_direct_tls=True`, `enable_starttls=False`). +> - `use_tls=False` → **STARTTLS** (port 5222, `enable_starttls=True`, `enable_direct_tls=False`). +> La validation refuse `use_tls=False` si `host` n'est pas un hôte de boucle locale (`localhost`, `127.0.0.1`, `::1`). + --- ### 10.3 Protocole `Channel` (`channels/protocol.py`) ```python -Protocol -from ..models.xmpp import XmppMessage +from typing import Protocol, runtime_checkable + +from pronote_sync.models.xmpp import XmppMessage +@runtime_checkable class Channel(Protocol): - """ - Protocole pour les canaux de sortie (XMPP, fichier, etc.). - **Synchrone** : Le pipeline appelle `send()` sans await. - Inspiré de l'interface `Channel` dans src/channels/ du projet TypeScript. - """ + """Contrat structurel d'un canal de sortie du pipeline. - name: str + Un canal de sortie reçoit un message final :class:`XmppMessage` et tente de + l'envoyer vers la destination qu'il représente (CalDAV, XMPP, etc.). + + **Contrat d'erreur** : 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. + + :ivar send: Envoie un message sur le canal. + """ def send(self, message: XmppMessage) -> bool: - """ - Envoie un message de manière **synchrone**. + """Envoie un message sur le canal. - Args: - message: Message à envoyer. + 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). - Returns: - True si l'envoi a réussi, False sinon. + :param message: Message final à transmettre. + :return: ``True`` si l'envoi a réussi, ``False`` sinon. + :rtype: bool """ ... ``` --- -### 10.3 Client XMPP (`channels/xmpp.py`) +### 10.4 Client XMPP (`channels/xmpp.py`) + +> **⚠️ Décision d'implémentation** : +> Le code utilise `connect(host, port)` qui retourne une `asyncio.Future`. Le code `await` cette Future, puis attend les événements `session_start`, `failed_auth` ou `disconnected` via `asyncio.wait_for` sous un timeout unique. +> Le JID du bot est construit avec la ressource : ``JID("bot@example.com/pronote-sync")``. ```python +from __future__ import annotations + import asyncio -Optional, Awaitable -import slixmpp -from slixmpp.exceptions import IqError, IqTimeout -from ..models.xmpp import XmppMessage -from .protocol import Channel 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__) -class XmppChannel(Channel): +def _format_message(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. + + :param message: Message final à formater. + :return: Corps du message en texte brut, prêt pour l'envoi. + :rtype: str """ - Canal XMPP pour l'envoi des messages. - Utilise slixmpp en mode asynchrone. + 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) + + +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. 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** : :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 """ - name = "xmpp" + def __init__(self, settings: XmppSettings, dry_run: bool = False) -> None: + """Initialise le canal XMPP sans connexion réseau. - def __init__( - self, - jid: str, - password: str, - recipient: str, - dry_run: bool = False, - ): - self.jid = jid - self.password = password - self.recipient = recipient + :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 - self._client: Optional[slixmpp.ClientXMPP] = None - self._connected = False - self._message_sent = False - async def connect(self) -> bool: - """Établit la connexion XMPP.""" - if self._connected: - return True + async def send_async(self, message: XmppMessage) -> bool: + """Exécute le flux asynchrone d'envoi XMPP. - try: - # Créer le client - self._client = slixmpp.ClientXMPP(self.jid, self.password) + 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``. - # Configurer les handlers - self._client.add_event_handler("session_start", self._on_session_start) - self._client.add_event_handler("failed_auth", self._on_failed_auth) - self._client.add_event_handler("disconnected", self._on_disconnected) - - # Se connecter (async) - self._client.connect() - self._client.process(block=False) - - # Attendre la connexion (timeout: 30s) - await asyncio.wait_for( - self._wait_for_connection(), - timeout=30.0, - ) - - return self._connected - - except Exception as e: - logger.error(f"Échec de la connexion XMPP: {redact_secrets(str(e))}") - return False - - def _on_session_start(self, event: slixmpp.Event) -> None: - """Handler appelé quand la session XMPP est établie.""" - self._connected = True - logger.info("Connexion XMPP établie") - - def _on_failed_auth(self, event: slixmpp.Event) -> None: - """Handler appelé en cas d'échec d'authentification.""" - logger.error("Échec de l'authentification XMPP") - self._connected = False - - def _on_disconnected(self, event: slixmpp.Event) -> None: - """Handler appelé en cas de déconnexion.""" - logger.warning("Déconnexion XMPP") - self._connected = False - - async def _wait_for_connection(self) -> None: - """Attend que la connexion soit établie.""" - while not self._connected: - await asyncio.sleep(0.1) - - def _format_message(self, message: XmppMessage) -> str: - """Formate le message XMPP en texte brut.""" - lines = [] - - # Titre (date cible) - lines.append(f"=== Pronote - {message.target_date.strftime('%A %d %B %Y')} ===") - lines.append("") - - # Synthèse IA (si disponible) - if message.synthesis: - lines.append("📌 Synthèse :") - lines.append(message.synthesis) - lines.append("") - - # Changements d'agenda - if message.changes: - lines.append("📅 Changements d'agenda :") - for change in message.changes: - if change.type == "added": - lines.append(f" + {change.lesson.subject} ({change.lesson.start.strftime('%H:%M')})") - elif change.type == "removed": - lines.append(f" - {change.theoretical_lesson.subject}") - elif change.type == "modified": - lines.append(f" ~ {change.lesson.subject} ({change.details})") - lines.append("") - - # Liste brute des devoirs - if message.homeworks: - lines.append("📚 Devoirs :") - for hw in message.homeworks: - due_date = hw.due_on.strftime("%d/%m/%Y") - lines.append(f" - {hw.subject} (pour le {due_date}) : {hw.text}") - lines.append("") - - # Messages - if message.messages: - lines.append("💬 Messages :") - for msg in message.messages: - lines.append(f" - {msg.author} : {msg.title}") - - return "\n".join(lines) - - async def send(self, message: XmppMessage) -> bool: - """Envoie un message XMPP.""" - if not self._connected: - # Se connecter si ce n'est pas déjà fait - if not await self.connect(): - return False - - if self.dry_run: - logger.info(f"[DRY-RUN] Envoi XMPP à {self.recipient}") - logger.info(f"Contenu:\n{self._format_message(message)}") - return True - - try: - # Formater le message - body = self._format_message(message) - - # Envoyer le message - self._client.send_message( - mto=self.recipient, - mbody=body, - mtype="chat", - ) - - logger.info(f"Message XMPP envoyé à {self.recipient}") - return True - - except Exception as e: - safe_error = redact_secrets(str(e)) - logger.error(f"Échec de l'envoi XMPP: {safe_error}") - return False - - async def disconnect(self) -> None: - """Déconnecte le client XMPP.""" - if self._client: - self._client.disconnect() - self._connected = False + :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 + """ + # Implémentation réelle : voir le code source. + pass class SyncXmppChannel: - """ - Adaptateur synchrone pour XMPP. - Encapsule asyncio avec une stratégie robuste pour éviter les conflits de boucle d'événements. + """Point d'entrée synchrone unique du canal XMPP pour le pipeline. - **Important** : Si le pipeline est appelé depuis un contexte asynchrone, l'envoi XMPP doit être isolé - dans un thread séparé pour éviter les conflits de boucle. + 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``. 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, - jid: str, - password: str, - recipient: str, - dry_run: bool = False, - ): - self.jid = jid - self.password = password - self.recipient = recipient - self.dry_run = dry_run - self._xmpp_channel = XmppChannel(jid, password, recipient, dry_run) + def __init__(self, settings: XmppSettings, dry_run: bool = False) -> None: + """Initialise le point d'entrée synchrone et son canal interne. - def send(self, message: XmppMessage) -> bool: - """Envoie un message XMPP de manière synchrone.""" - import asyncio + :param settings: Paramètres de configuration du canal XMPP. + :param dry_run: Si ``True``, l'envoi est simulé. + """ + pass - # Créer une nouvelle boucle d'événements pour éviter les conflits - loop = asyncio.new_event_loop() - try: - asyncio.set_event_loop(loop) - return loop.run_until_complete(self._xmpp_channel.send(message)) - finally: - loop.close() - asyncio.set_event_loop(None) + 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. + + :param message: Message final à envoyer. + :return: ``True`` si l'envoi a réussi (ou a été simulé en dry-run), + ``False`` sinon. + :rtype: bool + """ + pass ``` -### 10.4 Factory pour les canaux (`channels/__init__.py`) +### 10.5 Factory pour les canaux (`channels/__init__.py`) ```python -List, Dict, Type -from .protocol import Channel -from .xmpp import SyncXmppChannel -from ..config.settings import Settings +from __future__ import annotations + +import logging + +from pronote_sync.channels.protocol import Channel +from pronote_sync.channels.xmpp import SyncXmppChannel +from pronote_sync.config.settings import XmppSettings +from pronote_sync.utils.redaction import redact_secrets + +logger = logging.getLogger(__name__) -# Registre des factories de canaux -_CHANNEL_FACTORIES: Dict[str, Type[Channel]] = { - "xmpp": SyncXmppChannel, -} +def get_channel(settings: XmppSettings, dry_run: bool = False) -> Channel | None: + """Instancie le canal de sortie XMPP selon la configuration. + Si le canal est désactivé (``enabled`` à ``False``), la fabrique + retourne ``None`` sans avertissement ni exception. Si le canal est + activé mais que l'un des champs requis (``jid``, ``password``, ``to``, + ``host``) est vide ou absent, un avertissement est journalisé puis + ``None`` est retourné. Dans tous les autres cas, une instance de + :class:`~pronote_sync.channels.xmpp.SyncXmppChannel` est construite et + retournée. -def get_channel(settings: Settings, channel_name: str = "xmpp") -> Channel: + L'avertissement est expurgé des valeurs sensibles (``jid``, mot de + passe, destinataire) via :func:`pronote_sync.utils.redaction.redact_secrets` + : le message journalisé ne contient jamais ces valeurs en + clair. La fabrique ne lève jamais d'exception (dégradation non bloquante). + + :param settings: Paramètres de configuration du canal XMPP. + :param dry_run: Si ``True``, le canal est créé en mode simulation + (aucun envoi réseau lors de l'appel à ``send``). + :return: Canal de sortie prêt à l'emploi, ou ``None`` si le canal est + désactivé ou mal configuré. + :rtype: Channel | None """ - Fabrique un canal selon la configuration. + if not settings.enabled: + return None - Args: - settings: Configuration globale. - channel_name: Nom du canal (défaut: "xmpp"). - - Returns: - Canal configuré. - """ - factory = _CHANNEL_FACTORIES.get(channel_name) - if factory is None: - raise ValueError(f"Canal inconnu: {channel_name}") - - if channel_name == "xmpp": - if not settings.xmpp.enabled: - raise ValueError("XMPP est désactivé (XMPP_ENABLED=False)") - return factory( - jid=settings.xmpp.jid, - password=settings.xmpp.password.get_secret_value(), - recipient=settings.xmpp.to, - dry_run=settings.app.dry_run, + # Vérification des champs requis (expurgés dans les logs) + extra_secrets = [ + secret for secret in (settings.password, settings.jid, settings.to) if secret is not None + ] + missing_fields = [ + name + for name, present in ( + ("jid", settings.jid is not None and bool(settings.jid.strip())), + ("password", settings.password is not None and bool(settings.password.get_secret_value().strip())), + ("to", settings.to is not None and bool(settings.to.strip())), + ("host", bool(settings.host.strip())), ) + if not present + ] + if missing_fields: + logger.warning( + "XMPP : configuration incomplète (champs manquants : %s), canal désactivé.", + redact_secrets(", ".join(missing_fields), extra_secrets=extra_secrets), + ) + return None - raise ValueError(f"Canal {channel_name} non implémenté") + return SyncXmppChannel(settings, dry_run=dry_run) ``` -### 10.5 Points clés +### 10.6 Points clés - **slixmpp** : Bibliothèque recommandée pour XMPP (asyncio, maintenue). -- **Format du message** : Structuré avec sections claires (synthèse, changements, devoirs, messages). -- **Mode dégradé** : Si XMPP échoue, le pipeline peut continuer (mais le message ne sera pas envoyé). +- **Format du message** : Structuré avec sections emoji (📌 Synthèse, 📅 Changements d'agenda, 📚 Devoirs, 💬 Messages, 📢 Informations diverses) et date cible en en-tête. +- **Mode dégradé** : Si XMPP échoue, le canal retourne ``False`` et le pipeline émet un ``PipelineWarning`` (M11). - **Dry-run** : Mode obligatoire pour tester sans envoyer de message. -- **Reconnexion** : Gestion des erreurs de connexion. +- **Reconnexion** : Gestion des erreurs de connexion via événements ``session_start``, ``failed_auth``, ``disconnected``. +- **Contrat d'erreur** : ``Channel.send()`` ne lève jamais ``PipelineWarning`` ; le pipeline (M11) crée le ``PipelineWarning(step="xmpp")``. +- **Source unique des messages Pronote** : ``XmppMessage.messages`` est la seule source pour les messages Pronote ; ``external_info`` est réservé au blog et ``other_info``. --- @@ -4604,8 +4699,7 @@ class PipelineRunner: messages=pronote_data.messages, external_info=ExternalInfo( blog_articles=blog_articles, - pronote_messages=pronote_data.messages, - ) if blog_articles or pronote_data.messages else None, + ) if blog_articles else None, ) # Étape 7: Envoi XMPP diff --git a/TODO.md b/TODO.md index 1a5ef17..9bbc36c 100644 --- a/TODO.md +++ b/TODO.md @@ -210,7 +210,7 @@ Construire et envoyer le message XMPP structuré via un compte bot dédié (mess ### Critères d'acceptation - `XmppChannel.send` envoie un message direct formaté (slixmpp mocké en test). -- Erreur XMPP → `PipelineWarning`, jamais d'exception non gérée. +- Erreur XMPP → `False` retourné par le canal, le pipeline émet un `PipelineWarning` (jamais d'exception non gérée). - Aucun secret dans les logs XMPP. ---