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",
|
"filename": "GUIDE_DEV_PYTHON.md",
|
||||||
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
||||||
"is_verified": true,
|
"is_verified": true,
|
||||||
"line_number": 4935,
|
"line_number": 5029,
|
||||||
"is_secret": false
|
"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_ENABLED` | Activer l'envoi XMPP. | `False` | `bool` | ❌ Non |
|
||||||
| `XMPP_JID` | Identifiant du compte bot (ex: `pronote-bot@exemple.org`). | `None` | `str` | ✅ Oui |
|
| `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_PASSWORD` | Mot de passe du compte bot. | `None` | `SecretStr` | ✅ Oui |
|
||||||
| `XMPP_HOST` | Hôte XMPP **explicite** (ex: `exemple.org`). | `None` | `str` | ✅ Oui |
|
| `XMPP_HOST` | Hôte XMPP **explicite** (ex: `exemple.org`). | `""` | `str` | ✅ Oui |
|
||||||
| `XMPP_PORT` | Port XMPP (5222 pour TLS, 5223 pour SSL). | `5222` | `int` | ❌ Non |
|
| `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_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_USE_TLS` | Utiliser TLS pour la connexion. | `True` | `bool` | ❌ Non |
|
||||||
| `XMPP_TIMEOUT` | Timeout de connexion (secondes). | `30` | `int` | ❌ Non |
|
| `XMPP_TIMEOUT` | Timeout de connexion (secondes). | `30` | `int` | ❌ Non |
|
||||||
|
|
||||||
@@ -4060,305 +4060,400 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
|
|
||||||
|
|
||||||
class XmppSettings(BaseSettings):
|
class XmppSettings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_prefix="XMPP_", env_file=".env", extra="ignore")
|
"""Paramètres du canal de notifications XMPP (désactivé par défaut).
|
||||||
enabled: bool = Field(False, description="Activer l'envoi XMPP")
|
|
||||||
jid: str = Field(..., description="Identifiant du compte bot (ex: pronote-bot@exemple.org)")
|
Tous les champs ont des valeurs par défaut afin que le canal XMPP reste
|
||||||
password: SecretStr = Field(..., description="Mot de passe du compte bot")
|
inactif tant qu'il n'est pas explicitement activé.
|
||||||
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)")
|
model_config = SettingsConfigDict(
|
||||||
resource: str = Field("pronote-digest", description="Ressource XMPP")
|
env_file=".env",
|
||||||
use_tls: bool = Field(True, description="Utiliser TLS pour la connexion")
|
extra="ignore",
|
||||||
timeout: int = Field(30, description="Timeout de connexion (secondes)")
|
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`)
|
### 10.3 Protocole `Channel` (`channels/protocol.py`)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
Protocol
|
from typing import Protocol, runtime_checkable
|
||||||
from ..models.xmpp import XmppMessage
|
|
||||||
|
from pronote_sync.models.xmpp import XmppMessage
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
class Channel(Protocol):
|
class Channel(Protocol):
|
||||||
"""
|
"""Contrat structurel d'un canal de sortie du pipeline.
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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:
|
def send(self, message: XmppMessage) -> bool:
|
||||||
"""
|
"""Envoie un message sur le canal.
|
||||||
Envoie un message de manière **synchrone**.
|
|
||||||
|
|
||||||
Args:
|
Un canal ne lève jamais :pyexc:`PipelineWarning` ; en cas d'échec, il
|
||||||
message: Message à envoyer.
|
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:
|
:param message: Message final à transmettre.
|
||||||
True si l'envoi a réussi, False sinon.
|
: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
|
```python
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
Optional, Awaitable
|
|
||||||
import slixmpp
|
|
||||||
from slixmpp.exceptions import IqError, IqTimeout
|
|
||||||
from ..models.xmpp import XmppMessage
|
|
||||||
from .protocol import Channel
|
|
||||||
import logging
|
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__)
|
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.
|
sections = [
|
||||||
Utilise slixmpp en mode asynchrone.
|
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__(
|
:param settings: Paramètres de configuration du canal XMPP.
|
||||||
self,
|
:param dry_run: Si ``True``, :meth:`send_async` journalise le message
|
||||||
jid: str,
|
formaté et retourne ``True`` sans se connecter.
|
||||||
password: str,
|
"""
|
||||||
recipient: str,
|
self.settings = settings
|
||||||
dry_run: bool = False,
|
|
||||||
):
|
|
||||||
self.jid = jid
|
|
||||||
self.password = password
|
|
||||||
self.recipient = recipient
|
|
||||||
self.dry_run = dry_run
|
self.dry_run = dry_run
|
||||||
self._client: Optional[slixmpp.ClientXMPP] = None
|
|
||||||
self._connected = False
|
|
||||||
self._message_sent = False
|
|
||||||
|
|
||||||
async def connect(self) -> bool:
|
async def send_async(self, message: XmppMessage) -> bool:
|
||||||
"""Établit la connexion XMPP."""
|
"""Exécute le flux asynchrone d'envoi XMPP.
|
||||||
if self._connected:
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
Connecte le client ``slixmpp`` avec un hôte et un port explicites,
|
||||||
# Créer le client
|
configure TLS avant la connexion, puis attend l'un des événements
|
||||||
self._client = slixmpp.ClientXMPP(self.jid, self.password)
|
``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
|
:param message: Message final à envoyer.
|
||||||
self._client.add_event_handler("session_start", self._on_session_start)
|
:return: ``True`` si l'envoi a réussi (ou a été simulé en dry-run),
|
||||||
self._client.add_event_handler("failed_auth", self._on_failed_auth)
|
``False`` sinon (destinataire manquant, timeout, échec
|
||||||
self._client.add_event_handler("disconnected", self._on_disconnected)
|
d'authentification, déconnexion ou erreur réseau).
|
||||||
|
:rtype: bool
|
||||||
# Se connecter (async)
|
"""
|
||||||
self._client.connect()
|
# Implémentation réelle : voir le code source.
|
||||||
self._client.process(block=False)
|
pass
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
class SyncXmppChannel:
|
class SyncXmppChannel:
|
||||||
"""
|
"""Point d'entrée synchrone unique du canal XMPP pour le pipeline.
|
||||||
Adaptateur synchrone pour XMPP.
|
|
||||||
Encapsule asyncio avec une stratégie robuste pour éviter les conflits de boucle d'événements.
|
|
||||||
|
|
||||||
**Important** : Si le pipeline est appelé depuis un contexte asynchrone, l'envoi XMPP doit être isolé
|
Enveloppe une instance de :class:`XmppChannel` pour offrir une interface
|
||||||
dans un thread séparé pour éviter les conflits de boucle.
|
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__(
|
def __init__(self, settings: XmppSettings, dry_run: bool = False) -> None:
|
||||||
self,
|
"""Initialise le point d'entrée synchrone et son canal interne.
|
||||||
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 send(self, message: XmppMessage) -> bool:
|
:param settings: Paramètres de configuration du canal XMPP.
|
||||||
"""Envoie un message XMPP de manière synchrone."""
|
:param dry_run: Si ``True``, l'envoi est simulé.
|
||||||
import asyncio
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
# Créer une nouvelle boucle d'événements pour éviter les conflits
|
def send(self, message: XmppMessage) -> bool:
|
||||||
loop = asyncio.new_event_loop()
|
"""Envoie un message XMPP de façon synchrone et sans lever.
|
||||||
try:
|
|
||||||
asyncio.set_event_loop(loop)
|
En mode ``dry_run``, le message formaté (expurgé de ses secrets) est
|
||||||
return loop.run_until_complete(self._xmpp_channel.send(message))
|
journalisé et la méthode retourne ``True`` sans créer de client XMPP.
|
||||||
finally:
|
Sinon, le flux asynchrone :meth:`XmppChannel.send_async` est exécuté
|
||||||
loop.close()
|
via :func:`asyncio.run` ; toute exception est journalisée sous forme
|
||||||
asyncio.set_event_loop(None)
|
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
|
```python
|
||||||
List, Dict, Type
|
from __future__ import annotations
|
||||||
from .protocol import Channel
|
|
||||||
from .xmpp import SyncXmppChannel
|
import logging
|
||||||
from ..config.settings import Settings
|
|
||||||
|
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
|
def get_channel(settings: XmppSettings, dry_run: bool = False) -> Channel | None:
|
||||||
_CHANNEL_FACTORIES: Dict[str, Type[Channel]] = {
|
"""Instancie le canal de sortie XMPP selon la configuration.
|
||||||
"xmpp": SyncXmppChannel,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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:
|
# Vérification des champs requis (expurgés dans les logs)
|
||||||
settings: Configuration globale.
|
extra_secrets = [
|
||||||
channel_name: Nom du canal (défaut: "xmpp").
|
secret for secret in (settings.password, settings.jid, settings.to) if secret is not None
|
||||||
|
]
|
||||||
Returns:
|
missing_fields = [
|
||||||
Canal configuré.
|
name
|
||||||
"""
|
for name, present in (
|
||||||
factory = _CHANNEL_FACTORIES.get(channel_name)
|
("jid", settings.jid is not None and bool(settings.jid.strip())),
|
||||||
if factory is None:
|
("password", settings.password is not None and bool(settings.password.get_secret_value().strip())),
|
||||||
raise ValueError(f"Canal inconnu: {channel_name}")
|
("to", settings.to is not None and bool(settings.to.strip())),
|
||||||
|
("host", bool(settings.host.strip())),
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
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).
|
- **slixmpp** : Bibliothèque recommandée pour XMPP (asyncio, maintenue).
|
||||||
- **Format du message** : Structuré avec sections claires (synthèse, changements, devoirs, messages).
|
- **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 pipeline peut continuer (mais le message ne sera pas envoyé).
|
- **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.
|
- **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,
|
messages=pronote_data.messages,
|
||||||
external_info=ExternalInfo(
|
external_info=ExternalInfo(
|
||||||
blog_articles=blog_articles,
|
blog_articles=blog_articles,
|
||||||
pronote_messages=pronote_data.messages,
|
) if blog_articles else None,
|
||||||
) if blog_articles or pronote_data.messages else None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Étape 7: Envoi XMPP
|
# É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
|
### Critères d'acceptation
|
||||||
- `XmppChannel.send` envoie un message direct formaté (slixmpp mocké en test).
|
- `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.
|
- Aucun secret dans les logs XMPP.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user