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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
:param settings: Paramètres de configuration du canal XMPP.
|
||||
:param dry_run: Si ``True``, l'envoi est simulé.
|
||||
"""
|
||||
pass
|
||||
|
||||
def send(self, message: XmppMessage) -> bool:
|
||||
"""Envoie un message XMPP de manière synchrone."""
|
||||
import asyncio
|
||||
"""Envoie un message XMPP de façon synchrone et sans lever.
|
||||
|
||||
# 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)
|
||||
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
|
||||
|
||||
2
TODO.md
2
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.
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user