fix(M10): apply FIXME_M10 corrections (transport, dry_run, format, security)
Fix all 8 findings from the independent review (FIXME_M10.md): #1 Transport compatible with slixmpp 1.17.0 (D5): - Use real ClientXMPP type (remove Any), JID with resource - connect(host, port) explicit, no use_tls kwarg - enable_direct_tls/enable_starttls configured before connect - Single timeout via asyncio.Future for session_start/failed_auth/disconnected - Remove premature 'starttls' in features check, remove auto_reconnect - try/finally guarantees disconnect on all paths (#4) #2 Factory dry_run no longer bypassed (D6): - Single send() entry point in SyncXmppChannel - dry_run check before any ClientXMPP creation - Remove XmppChannel.send() dual implementation #3 Thread daemon removed — single asyncio.run(), documented limitation #5 Richer message format: - Target date header, change type [Ajouté/Supprimé/Modifié] - Lesson times, homework due date, message author - No pronote_messages duplication (external_info = blog + other_info only) #6 Error contract unified (D6): - Channel.send() -> bool never raises PipelineWarning - Errors logged with redaction, returns False - PipelineWarning(step='xmpp') will be created by pipeline M11 #7 Tests faithful to slixmpp 1.17.0 API: - FakeClientXMPP with real connect(host,port)/disconnect() signatures - Assertions on host, port, resource, mtype='chat' - No RuntimeWarning from unawaited coroutines #8 .secrets.baseline restored from main Coverage: 96.44% on channels/, 600 tests pass, pre-commit all-files green. Co-authored-by: opencode/coder <coder@agents.invalid> Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
This commit is contained in:
@@ -1,27 +1,30 @@
|
||||
"""Canal de sortie XMPP du pipeline ``pronote-sync``.
|
||||
|
||||
Ce module implémente le canal d'envoi de notifications XMPP : la classe
|
||||
:class:`XmppChannel` (U4) envoie un message direct via ``slixmpp``, tandis que
|
||||
:class:`SyncXmppChannel` (U5) fournit l'adaptateur synchrone utilisé par le
|
||||
pipeline. Le corps du message est formaté en texte brut par ``_format_message``
|
||||
(sections emoji 📌📅📚💬📢) et chaque texte est assaini par
|
||||
:class:`XmppChannel` envoie un message direct via ``slixmpp``
|
||||
(:meth:`XmppChannel.send_async`), tandis que :class:`SyncXmppChannel`
|
||||
fournit le point d'entrée synchrone unique utilisé par le pipeline. Le corps
|
||||
du message est formaté en texte brut par ``_format_message`` (en-tête de date
|
||||
cible puis sections emoji 📌📅📚💬📢) et chaque texte est assaini par
|
||||
:func:`pronote_sync.utils.text.sanitize_plaintext` (SEC-XMPP-06).
|
||||
|
||||
Contrat d'erreur (D6) : le canal ne lève jamais :pyexc:`PipelineWarning` ;
|
||||
en cas d'échec, il journalise la version expurgée de l'erreur et retourne
|
||||
``False``. Le :pyexc:`PipelineWarning` est créé par l'étape pipeline, pas par
|
||||
le canal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from pydantic import SecretStr
|
||||
from slixmpp import JID, ClientXMPP
|
||||
|
||||
from pronote_sync.config.settings import XmppSettings
|
||||
from pronote_sync.errors import PipelineWarning
|
||||
from pronote_sync.models.blog import ExternalInfo
|
||||
from pronote_sync.models.diff import AgendaChange
|
||||
from pronote_sync.models.diff import AgendaChange, AgendaChangeType
|
||||
from pronote_sync.models.homework import Homework
|
||||
from pronote_sync.models.message import Message
|
||||
from pronote_sync.models.xmpp import XmppMessage
|
||||
@@ -65,9 +68,13 @@ def _format_synthesis(synthesis: str | None) -> str:
|
||||
def _format_changes(changes: tuple[AgendaChange, ...]) -> str:
|
||||
"""Formate la section des changements d'agenda du message XMPP.
|
||||
|
||||
Distingue les ajouts, suppressions et modifications (U4). Pour un ajout,
|
||||
les horaires du cours (``HH:MM-HH:MM``) sont inclus si le cours est
|
||||
disponible.
|
||||
|
||||
:param changes: Liste des changements d'agenda.
|
||||
:return: Section ``📅 Changements d'agenda`` avec une ligne par
|
||||
changement (matière et détails).
|
||||
changement (type, matière et détails).
|
||||
:rtype: str
|
||||
"""
|
||||
if not changes:
|
||||
@@ -80,7 +87,15 @@ def _format_changes(changes: tuple[AgendaChange, ...]) -> str:
|
||||
subject = change.lesson.subject
|
||||
elif change.theoretical_lesson is not None:
|
||||
subject = change.theoretical_lesson.subject
|
||||
lines.append(f"• {subject} : {change.details}")
|
||||
if change.type == AgendaChangeType.ADDED and change.lesson is not None:
|
||||
times = (
|
||||
f"{change.lesson.start.strftime('%H:%M')}-{change.lesson.end.strftime('%H:%M')}"
|
||||
)
|
||||
lines.append(f"• [Ajouté] {subject}: {change.details} ({times})")
|
||||
elif change.type == AgendaChangeType.REMOVED:
|
||||
lines.append(f"• [Supprimé] {subject}: {change.details}")
|
||||
else:
|
||||
lines.append(f"• [Modifié] {subject}: {change.details}")
|
||||
body = "\n".join(lines)
|
||||
return f"📅 Changements d'agenda\n{sanitize_plaintext(body)}"
|
||||
|
||||
@@ -89,14 +104,18 @@ def _format_homeworks(homeworks: tuple[Homework, ...]) -> str:
|
||||
"""Formate la section des devoirs du message XMPP.
|
||||
|
||||
:param homeworks: Liste des devoirs.
|
||||
:return: Section ``📚 Devoirs`` avec une ligne par devoir (matière et
|
||||
texte).
|
||||
:return: Section ``📚 Devoirs`` avec une ligne par devoir (matière,
|
||||
texte et date d'échéance).
|
||||
:rtype: str
|
||||
"""
|
||||
if not homeworks:
|
||||
body = "Aucun devoir."
|
||||
else:
|
||||
lines = [f"• {homework.subject} : {homework.text}" for homework in homeworks]
|
||||
lines = [
|
||||
f"• {homework.subject}: {homework.text} "
|
||||
f"(à rendre le {homework.due_on.strftime('%d/%m')})"
|
||||
for homework in homeworks
|
||||
]
|
||||
body = "\n".join(lines)
|
||||
return f"📚 Devoirs\n{sanitize_plaintext(body)}"
|
||||
|
||||
@@ -105,14 +124,19 @@ def _format_messages(messages: tuple[Message, ...]) -> str:
|
||||
"""Formate la section des messages Pronote du message XMPP.
|
||||
|
||||
:param messages: Liste des messages/informations.
|
||||
:return: Section ``💬 Messages`` avec une ligne par message (titre et
|
||||
contenu).
|
||||
:return: Section ``💬 Messages`` avec une ligne par message (titre,
|
||||
auteur et contenu) ; sans titre, seul l'auteur est affiché.
|
||||
:rtype: str
|
||||
"""
|
||||
if not messages:
|
||||
body = "Aucun message."
|
||||
else:
|
||||
lines = [f"• {message.title} : {message.content}" for message in messages]
|
||||
lines: list[str] = []
|
||||
for message in messages:
|
||||
if message.title:
|
||||
lines.append(f"• {message.title} ({message.author}): {message.content}")
|
||||
else:
|
||||
lines.append(f"• {message.author}: {message.content}")
|
||||
body = "\n".join(lines)
|
||||
return f"💬 Messages\n{sanitize_plaintext(body)}"
|
||||
|
||||
@@ -120,8 +144,9 @@ def _format_messages(messages: tuple[Message, ...]) -> str:
|
||||
def _format_external_info(external_info: ExternalInfo | None) -> str:
|
||||
"""Formate la section des informations diverses du message XMPP.
|
||||
|
||||
Regroupe les articles du blog, les messages Pronote et les autres
|
||||
informations (``other_info``).
|
||||
Regroupe uniquement les articles du blog et les autres informations
|
||||
(``other_info``) : les messages Pronote (``pronote_messages``) sont
|
||||
exclus car ils sont déjà transmis par la section des messages.
|
||||
|
||||
:param external_info: Informations externes agrégées, ou ``None``.
|
||||
:return: Section ``📢 Informations diverses`` avec une ligne par élément.
|
||||
@@ -132,9 +157,7 @@ def _format_external_info(external_info: ExternalInfo | None) -> str:
|
||||
else:
|
||||
lines: list[str] = []
|
||||
for article in external_info.blog_articles:
|
||||
lines.append(f"• {article.title} : {article.content_text}")
|
||||
for message in external_info.pronote_messages:
|
||||
lines.append(f"• {message.title} : {message.content}")
|
||||
lines.append(f"• {article.title}: {article.content_text}")
|
||||
for info in external_info.other_info:
|
||||
lines.append(f"• {info}")
|
||||
body = "\n".join(lines) if lines else "Aucune information."
|
||||
@@ -142,11 +165,17 @@ def _format_external_info(external_info: ExternalInfo | None) -> str:
|
||||
|
||||
|
||||
class XmppChannel:
|
||||
"""Canal d'envoi de messages XMPP via un compte bot dédié (U4).
|
||||
"""Canal d'envoi de messages XMPP via un compte bot dédié.
|
||||
|
||||
Envoie un message direct (``type="chat"``) au destinataire configuré en
|
||||
utilisant :class:`slixmpp.ClientXMPP`. La connexion est établie à chaque
|
||||
appel de :meth:`send` ; le constructeur n'effectue aucun accès réseau.
|
||||
appel de :meth:`send_async` ; le constructeur n'effectue aucun accès
|
||||
réseau.
|
||||
|
||||
Contrat d'erreur (D6) : :meth:`send_async` ne lève jamais
|
||||
:pyexc:`PipelineWarning` ; en cas d'échec, elle journalise la version
|
||||
expurgée de l'erreur et retourne ``False``. En mode ``dry_run``, aucun
|
||||
client n'est créé.
|
||||
|
||||
:ivar settings: Paramètres XMPP (JID, mot de passe, destinataire, TLS).
|
||||
:vartype settings: XmppSettings
|
||||
@@ -158,8 +187,8 @@ class XmppChannel:
|
||||
"""Initialise le canal XMPP sans connexion réseau.
|
||||
|
||||
:param settings: Paramètres de configuration du canal XMPP.
|
||||
:param dry_run: Si ``True``, :meth:`send` se contente de journaliser
|
||||
le message formaté et retourne ``True`` sans se connecter.
|
||||
:param dry_run: Si ``True``, :meth:`send_async` journalise le message
|
||||
formaté et retourne ``True`` sans se connecter.
|
||||
"""
|
||||
self.settings = settings
|
||||
self.dry_run = dry_run
|
||||
@@ -167,16 +196,18 @@ class XmppChannel:
|
||||
def _format_message(self, message: XmppMessage) -> str:
|
||||
"""Formate un message XMPP en texte brut avec des sections emoji.
|
||||
|
||||
Produit le corps du message avec les sections synthèse, changements
|
||||
d'agenda, devoirs, messages et informations diverses. Chaque texte
|
||||
est assaini par :func:`pronote_sync.utils.text.sanitize_plaintext`
|
||||
avant insertion (SEC-XMPP-06).
|
||||
Produit le corps du message : un en-tête avec la date cible du
|
||||
digest, puis les sections synthèse, changements d'agenda, devoirs,
|
||||
messages et informations diverses. Chaque texte est assaini par
|
||||
:func:`pronote_sync.utils.text.sanitize_plaintext` avant insertion
|
||||
(SEC-XMPP-06).
|
||||
|
||||
:param message: Message final à formater.
|
||||
:return: Corps du message en texte brut, prêt pour l'envoi.
|
||||
:rtype: str
|
||||
"""
|
||||
sections = [
|
||||
f"Digest du {message.target_date.strftime('%d/%m/%Y')}",
|
||||
_format_synthesis(message.synthesis),
|
||||
_format_changes(message.changes),
|
||||
_format_homeworks(message.homeworks),
|
||||
@@ -185,149 +216,113 @@ class XmppChannel:
|
||||
]
|
||||
return "\n\n".join(sections)
|
||||
|
||||
def send(self, message: XmppMessage) -> bool:
|
||||
"""Envoie un message XMPP de façon synchrone.
|
||||
async def send_async(self, message: XmppMessage) -> bool:
|
||||
"""Exécute le flux asynchrone d'envoi XMPP (U2).
|
||||
|
||||
En mode ``dry_run``, le message formaté est uniquement journalisé
|
||||
(secrets masqués) et la méthode retourne ``True``. Sinon, le flux
|
||||
asynchrone :meth:`send_async` est exécuté via :func:`asyncio.run`.
|
||||
Connecte le client ``slixmpp`` avec un hôte et un port explicites,
|
||||
configure TLS avant la connexion, puis attend l'un des événements
|
||||
``session_start``, ``failed_auth`` ou ``disconnected`` sous un
|
||||
timeout unique avant d'envoyer un message direct ``chat`` au
|
||||
destinataire configuré. La déconnexion est garantie par un bloc
|
||||
``try/finally``. Aucun secret n'est journalisé (SEC-XMPP-02).
|
||||
|
||||
:param message: Message final à envoyer.
|
||||
:return: ``True`` si l'envoi a réussi (ou a été simulé en dry-run),
|
||||
``False`` en cas de timeout de connexion ou de STARTTLS absent.
|
||||
``False`` sinon (destinataire manquant, timeout, échec
|
||||
d'authentification, déconnexion ou erreur réseau).
|
||||
:rtype: bool
|
||||
:raises PipelineWarning: En cas d'erreur d'envoi (config incomplète,
|
||||
échec d'authentification, erreur réseau) ; le message d'erreur
|
||||
est expurgé et la cause d'origine est masquée (SEC-XMPP-05).
|
||||
"""
|
||||
if self.dry_run:
|
||||
formatted = self._format_message(message)
|
||||
redacted = redact_secrets(formatted, extra_secrets=_secret_values(self.settings))
|
||||
logger.info("XMPP : dry-run, message non envoyé : %s", redacted)
|
||||
logger.info("XMPP dry-run: message would be sent")
|
||||
return True
|
||||
try:
|
||||
return asyncio.run(self.send_async(message))
|
||||
except PipelineWarning:
|
||||
raise
|
||||
except TimeoutError:
|
||||
logger.warning("XMPP : timeout de connexion au serveur.")
|
||||
|
||||
# Build JID with resource
|
||||
jid_str = f"{self.settings.jid}/{self.settings.resource}"
|
||||
recipient = JID(self.settings.to) if self.settings.to else None
|
||||
if recipient is None:
|
||||
logger.warning("Destinataire XMPP manquant.")
|
||||
return False
|
||||
|
||||
# Create typed client
|
||||
client = ClientXMPP(
|
||||
jid_str,
|
||||
self.settings.password.get_secret_value() if self.settings.password else "",
|
||||
)
|
||||
|
||||
# Configure TLS BEFORE connect
|
||||
if self.settings.use_tls:
|
||||
# TLS direct (port 5223 typically)
|
||||
client.enable_direct_tls = True
|
||||
client.enable_starttls = False
|
||||
else:
|
||||
# STARTTLS (port 5222 typically)
|
||||
client.enable_starttls = True
|
||||
client.enable_direct_tls = False
|
||||
|
||||
# Register handlers
|
||||
session_future: asyncio.Future[bool] = asyncio.get_event_loop().create_future()
|
||||
|
||||
def on_session_start(event: object) -> None:
|
||||
if not session_future.done():
|
||||
session_future.set_result(True)
|
||||
|
||||
def on_failed_auth(event: object) -> None:
|
||||
if not session_future.done():
|
||||
session_future.set_result(False)
|
||||
|
||||
def on_disconnected(event: object) -> None:
|
||||
if not session_future.done():
|
||||
session_future.set_result(False)
|
||||
|
||||
client.add_event_handler("session_start", on_session_start)
|
||||
client.add_event_handler("failed_auth", on_failed_auth)
|
||||
client.add_event_handler("disconnected", on_disconnected)
|
||||
|
||||
try:
|
||||
# Connect with explicit host and port
|
||||
connect_future = client.connect(self.settings.host, self.settings.port)
|
||||
await connect_future # connect() returns a Future, not a coroutine
|
||||
|
||||
# Wait for one of the three events under a single timeout
|
||||
try:
|
||||
success = await asyncio.wait_for(session_future, timeout=self.settings.timeout)
|
||||
except TimeoutError:
|
||||
logger.warning("Délai d'attente de session XMPP dépassé.")
|
||||
return False
|
||||
|
||||
if not success:
|
||||
logger.warning("Échec d'authentification ou déconnexion XMPP.")
|
||||
return False
|
||||
|
||||
# Send the message
|
||||
formatted = self._format_message(message)
|
||||
client.send_message(mto=JID(self.settings.to), mbody=formatted, mtype="chat")
|
||||
return True
|
||||
|
||||
except Exception as exc:
|
||||
redacted = redact_exception(exc)
|
||||
redacted = redact_secrets(redacted, extra_secrets=_secret_values(self.settings))
|
||||
logger.warning("XMPP : erreur d'envoi : %s", redacted)
|
||||
warning = PipelineWarning(redacted, step="xmpp")
|
||||
# SEC-XMPP-05 : masquer la cause ET le contexte d'origine, tous deux
|
||||
# susceptibles de contenir des secrets. Le ``raise`` est placé hors
|
||||
# du bloc ``except`` : levée dans le bloc, l'interpréteur réaffecterait
|
||||
# ``__context__`` à l'exception interceptée malgré ``from None``.
|
||||
warning.__cause__ = None
|
||||
warning.__context__ = None
|
||||
raise warning
|
||||
|
||||
async def send_async(self, message: XmppMessage) -> bool:
|
||||
"""Exécute le flux asynchrone d'envoi XMPP (U4).
|
||||
|
||||
Connecte le client ``slixmpp`` avec un timeout, vérifie
|
||||
l'authentification et la disponibilité de STARTTLS (D1), puis envoie
|
||||
un message direct ``chat`` au destinataire configuré avant
|
||||
déconnexion. Aucun secret (JID, mot de passe, destinataire) n'est
|
||||
journalisé (SEC-XMPP-02).
|
||||
|
||||
:param message: Message final à envoyer.
|
||||
:return: ``True`` si l'envoi a réussi, ``False`` si STARTTLS n'a pas
|
||||
pu être négocié alors que TLS est requis.
|
||||
:rtype: bool
|
||||
:raises PipelineWarning: En cas de configuration incomplète ou
|
||||
d'échec d'authentification (SEC-XMPP-04) ; la cause d'origine est
|
||||
masquée (SEC-XMPP-05).
|
||||
"""
|
||||
settings = self.settings
|
||||
if settings.jid is None or settings.password is None or settings.to is None:
|
||||
raise PipelineWarning("Configuration XMPP incomplète.", step="xmpp") from None
|
||||
formatted = self._format_message(message)
|
||||
# ``client`` est typé ``Any`` : le contrat du canal (SEC-XMPP-04, D1)
|
||||
# repose sur des comportements slixmpp que sa signature typée
|
||||
# n'expose pas (``auto_reconnect``, argument ``use_tls`` de
|
||||
# ``connect``), et slixmpp peut être indisponible pour mypy dans
|
||||
# l'environnement pre-commit (import non résolu => ``Any``).
|
||||
client: Any = ClientXMPP(settings.jid, settings.password.get_secret_value())
|
||||
# SEC-XMPP-04 : pas de reconnexion automatique (comportement explicite).
|
||||
# ``auto_reconnect`` n'existe plus dans slixmpp >= 1.7 mais fait partie
|
||||
# du contrat du canal (SEC-XMPP-04), conservé pour compatibilité.
|
||||
client.auto_reconnect = False
|
||||
failed_auth = asyncio.Event()
|
||||
session_started = asyncio.Event()
|
||||
|
||||
async def _on_session_start(_event: object | None = None) -> None:
|
||||
"""Marque la fin de l'établissement de la session XMPP."""
|
||||
session_started.set()
|
||||
|
||||
async def _on_failed_auth(_event: object | None = None) -> None:
|
||||
"""Mémorise un échec d'authentification."""
|
||||
failed_auth.set()
|
||||
|
||||
async def _on_disconnected(_event: object | None = None) -> None:
|
||||
"""Réagit à une déconnexion du client (aucune action ici)."""
|
||||
return None
|
||||
|
||||
client.add_event_handler("session_start", _on_session_start)
|
||||
client.add_event_handler("failed_auth", _on_failed_auth)
|
||||
client.add_event_handler("disconnected", _on_disconnected)
|
||||
|
||||
# D1 : connexion bornée par un timeout. L'argument nommé ``use_tls``
|
||||
# est transmis conformément au contrat du canal (vérifié par les
|
||||
# mocks de test).
|
||||
await asyncio.wait_for(
|
||||
client.connect(use_tls=settings.use_tls),
|
||||
timeout=settings.timeout,
|
||||
)
|
||||
if failed_auth.is_set():
|
||||
# SEC-XMPP-04 : déconnexion propre puis avertissement non bloquant.
|
||||
await client.disconnect()
|
||||
raise PipelineWarning("Authentification XMPP échouée.", step="xmpp") from None
|
||||
if settings.use_tls and "starttls" not in client.features:
|
||||
# D1 : refuser l'envoi si STARTTLS n'a pas été négocié.
|
||||
logger.warning("XMPP : STARTTLS non négocié alors que TLS est requis.")
|
||||
await client.disconnect()
|
||||
extra = _secret_values(self.settings)
|
||||
logger.warning("Erreur XMPP: %s", redact_secrets(redacted, extra_secrets=extra))
|
||||
return False
|
||||
# D1 : attente bornée de la session XMPP. On attend soit
|
||||
# l'établissement de la session, soit un échec d'authentification,
|
||||
# avec un timeout afin d'éviter tout blocage indéfini (et de couvrir
|
||||
# le cas où ``failed_auth`` survient après ``connect()``).
|
||||
_done, _pending = await asyncio.wait(
|
||||
[
|
||||
asyncio.create_task(session_started.wait()),
|
||||
asyncio.create_task(failed_auth.wait()),
|
||||
],
|
||||
timeout=settings.timeout,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for task in _pending:
|
||||
task.cancel()
|
||||
if failed_auth.is_set():
|
||||
# SEC-XMPP-04 : déconnexion propre puis avertissement non bloquant.
|
||||
await client.disconnect()
|
||||
raise PipelineWarning("Authentification XMPP échouée.", step="xmpp") from None
|
||||
if not session_started.is_set():
|
||||
# Timeout : aucune session établie avant l'échéance.
|
||||
await client.disconnect()
|
||||
raise PipelineWarning("Délai d'attente de session XMPP dépassé.", step="xmpp") from None
|
||||
client.send_message(mto=JID(settings.to), mbody=formatted, mtype="chat")
|
||||
await client.disconnect()
|
||||
return True
|
||||
finally:
|
||||
try:
|
||||
disconnect_future = client.disconnect()
|
||||
await disconnect_future
|
||||
except Exception as cleanup_exc:
|
||||
logger.debug(
|
||||
"Erreur lors de la déconnexion XMPP: %s", redact_exception(cleanup_exc)
|
||||
)
|
||||
|
||||
|
||||
class SyncXmppChannel:
|
||||
"""Adaptateur synchrone du canal XMPP pour le pipeline (U5).
|
||||
"""Point d'entrée synchrone unique du canal XMPP pour le pipeline (U3).
|
||||
|
||||
Enveloppe une instance de :class:`XmppChannel` pour offrir une interface
|
||||
synchrone conforme au :class:`~pronote_sync.channels.protocol.Channel`
|
||||
(D4). :meth:`send` n'exécute jamais d'event loop manuellement : il
|
||||
délègue à :func:`asyncio.run` (qui gère sa propre loop) lorsqu'aucune
|
||||
boucle n'est en cours, ou bascule dans un thread démon borné par un timeout
|
||||
lorsque l'appel a lieu depuis une boucle déjà active. Toute erreur est
|
||||
convertie en retour ``False`` sans jamais lever.
|
||||
synchrone conforme au :class:`~pronote_sync.channels.protocol.Channel`.
|
||||
:meth:`send` délègue à :func:`asyncio.run` et ne lève jamais : toute
|
||||
erreur est journalisée de façon expurgée et convertie en retour
|
||||
``False`` (D6). En mode ``dry_run``, aucun client ``slixmpp`` n'est créé.
|
||||
|
||||
:ivar settings: Paramètres XMPP.
|
||||
:vartype settings: XmppSettings
|
||||
@@ -336,7 +331,7 @@ class SyncXmppChannel:
|
||||
"""
|
||||
|
||||
def __init__(self, settings: XmppSettings, dry_run: bool = False) -> None:
|
||||
"""Initialise l'adaptateur synchrone et son canal interne.
|
||||
"""Initialise le point d'entrée synchrone et son canal interne.
|
||||
|
||||
:param settings: Paramètres de configuration du canal XMPP.
|
||||
:param dry_run: Si ``True``, l'envoi est simulé.
|
||||
@@ -348,57 +343,27 @@ class SyncXmppChannel:
|
||||
def send(self, message: XmppMessage) -> bool:
|
||||
"""Envoie un message XMPP de façon synchrone et sans lever.
|
||||
|
||||
Si aucune event loop n'est en cours d'exécution, délègue directement à
|
||||
:func:`asyncio.run` sur :meth:`XmppChannel.send_async`. Si une boucle
|
||||
tourne déjà, exécute l'envoi dans un thread démon joint avec un timeout
|
||||
(``settings.timeout``). Retourne ``True`` en cas de succès et ``False``
|
||||
sur toute erreur (dont timeout et exceptions), en journalisant une
|
||||
version expurgée sans secret.
|
||||
En mode ``dry_run``, le message formaté (expurgé de ses secrets) est
|
||||
journalisé et la méthode retourne ``True`` sans créer de client XMPP.
|
||||
Sinon, le flux asynchrone :meth:`XmppChannel.send_async` est exécuté
|
||||
via :func:`asyncio.run` ; toute exception est journalisée sous forme
|
||||
expurgée et convertie en retour ``False``. La méthode ne lève jamais
|
||||
(D6).
|
||||
|
||||
:param message: Message final à envoyer.
|
||||
:return: ``True`` si l'envoi a réussi, ``False`` sinon.
|
||||
:return: ``True`` si l'envoi a réussi (ou a été simulé en dry-run),
|
||||
``False`` sinon.
|
||||
:rtype: bool
|
||||
"""
|
||||
if self.dry_run:
|
||||
formatted = self._channel._format_message(message)
|
||||
redacted = redact_secrets(formatted, extra_secrets=_secret_values(self.settings))
|
||||
logger.info("XMPP : dry-run, message non envoyé : %s", redacted)
|
||||
return True
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
running_loop = False
|
||||
else:
|
||||
running_loop = True
|
||||
|
||||
if not running_loop:
|
||||
try:
|
||||
return asyncio.run(self._channel.send_async(message))
|
||||
except Exception as exc:
|
||||
self._log_error(exc)
|
||||
return False
|
||||
|
||||
result: list[bool] = []
|
||||
error: list[Exception] = []
|
||||
|
||||
def _run() -> None:
|
||||
"""Exécute l'envoi asynchrone dans le thread démon."""
|
||||
try:
|
||||
result.append(asyncio.run(self._channel.send_async(message)))
|
||||
except Exception as exc:
|
||||
error.append(exc)
|
||||
|
||||
thread = threading.Thread(target=_run, daemon=True)
|
||||
thread.start()
|
||||
thread.join(timeout=self._channel.settings.timeout)
|
||||
if thread.is_alive():
|
||||
logger.warning("XMPP : timeout lors de l'envoi synchrone.")
|
||||
return asyncio.run(self._channel.send_async(message))
|
||||
except Exception as exc:
|
||||
redacted = redact_exception(exc)
|
||||
redacted = redact_secrets(redacted, extra_secrets=_secret_values(self.settings))
|
||||
logger.warning("XMPP : erreur lors de l'envoi synchrone : %s", redacted)
|
||||
return False
|
||||
if error:
|
||||
self._log_error(error[0])
|
||||
return False
|
||||
return bool(result and result[0])
|
||||
|
||||
def _log_error(self, exc: Exception) -> None:
|
||||
"""Journalise une erreur d'envoi avec masquage des secrets.
|
||||
|
||||
:param exc: Exception à journaliser de façon expurgée.
|
||||
"""
|
||||
redacted = redact_exception(exc)
|
||||
redacted = redact_secrets(redacted, extra_secrets=_secret_values(self._channel.settings))
|
||||
logger.warning("XMPP : erreur lors de l'envoi synchrone : %s", redacted)
|
||||
|
||||
Reference in New Issue
Block a user