docs(M10): align GUIDE §10 and TODO.md with real slixmpp API and D6 contract

GUIDE_DEV_PYTHON.md §10 corrections:
- Fix XmppSettings defaults (host="", resource="pronote-sync")
- Replace Google-style docstrings with Sphinx/reST in examples
- Document connect(host, port) returning asyncio.Future, remove process() reference
- Document TLS mapping: use_tls=True → direct TLS, use_tls=False → STARTTLS
- Fix factory signature: get_channel(XmppSettings, dry_run) -> Channel | None
- Document Channel.send() -> bool never raises PipelineWarning (D6)
- Fix duplicate §10.3 numbering → §10.3-§10.6
- Remove pronote_messages duplication in M11 example
- Document JID with resource construction
- Document enriched message format (date, change types, times, due date, author)

TODO.md M10:
- Adjust acceptance criterion: channel returns False, pipeline emits PipelineWarning

.secrets.baseline:
- Line numbers updated for documentation shifts

Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
This commit is contained in:
2026-09-08 10:16:44 +02:00
parent b2106e75ac
commit 67e4fd8bb0
3 changed files with 338 additions and 244 deletions

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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.
---