Adaptateurs de plateforme
Responsabilité
Chaque plateforme (Telegram, Discord, Slack, Signal, WhatsApp, WeChat, QQ…) a un adaptateur (adapter) qui traduit les événements natifs de la plateforme en un MessageEvent unifié, et les actions d'envoi unifiées en appels d'API de la plateforme. L'adaptateur hérite de BasePlatformAdapter et implémente un petit nombre de méthodes abstraites. Les adaptateurs peuvent venir de gateway/platforms/ intégré ou du plugin (plugin) plugins/platforms/, tous enregistrés via PlatformRegistry.
Mot de conception
Les API natives des plateformes diffèrent radicalement : Telegram Bot API utilise sendMessage + échappement MarkdownV2, Discord passe par webhook + snowflake ID, Signal doit parler à un daemon signal-cli local, WeChat ne supporte même pas le texte riche. Si ces différences atterrissaient dans la boucle principale de l'agent, celui-ci ne serait jamais fini. La couche d'adaptateurs les absorbe — vers le haut elle émet un MessageEvent unifié, vers le bas elle reçoit une abstraction send(chat_id, content). Avec le chargement différé de PlatformRegistry, démarrer une commande comme hermes chat qui ne touche pas aux plateformes ne se fait pas ralentir par vingt SDK de plateformes.
Fichiers clés
class BasePlatformAdapter(ABC):2317— classe de base abstraite de l'adaptateurméthodes abstraites send / send_draft:2983-3015— interface d'envoi que les sous-classes doivent implémenterMessageType / ProcessingOutcome / MessageEvent:1737-1759— modèle d'événement unifiéCachedMedia:1638-1737— cache média (évite les uploads répétés)SendResult / EphemeralReply:1898-2068— résultat d'envoi et réponse éphémèrehelpers d'envoi communs:3173-3326—send_slash_confirm/send_clarify/send_typing/send_multiple_imagesrépertoire gateway/platforms/— signal / whatsapp_cloud / weixin / bluebubbles / yuanbao / qqbot…répertoire plugins/platforms/— Telegram / Discord / Slack (sous forme de plugins)ADDING_A_PLATFORM.md— guide d'ajout de plateformePlatformRegistry:162— registre (register / register_deferred / unregister)
Flux de données
- Au démarrage,
GatewayRunner(gateway/run.py:3029) lance l'adaptateur via_start_one_profile_adapter:9389. - L'adaptateur est requêté par nom dans
PlatformRegistry(gateway/platform_registry.py:162), avec support du chargement différé (placeholder à peu de frais, véritable import seulement à l'usage). - L'adaptateur écoute les événements de la plateforme, les traduit en
MessageEvent:1759et les confie à_handle_message:9947. - L'agent produit une sortie en streaming ; l'adaptateur utilise l'interface unifiée (
send:3008/send_draft) pour livrer le contenu à la plateforme ; les médias passent parCachedMedia:1638pour le cache. - Les différences entre plateformes (longueur de message, texte riche, limite d'images) sont entièrement absorbées par l'adaptateur ; le cœur de la passerelle (gateway) n'en sait rien.
PlatformRegistry maintient deux registres (registries) — _entries (déjà chargés) et _deferred (placeholders à peu de frais). Ces derniers ne sont promus en entrées réelles qu'au moment d'une requête. Champs principaux :
class PlatformRegistry:
"""Central registry of platform adapters.
Thread-safe for reads (dict lookups are atomic under GIL).
Writes happen at startup during sequential discovery.
"""
def __init__(self) -> None:
self._entries: dict[str, PlatformEntry] = {}
# Deferred platform loaders: name -> zero-arg callable that imports the
# owning plugin module (which calls register() and populates _entries).
self._deferred: dict[str, Callable[[], None]] = {}
def get(self, name: str) -> Optional[PlatformEntry]:
"""Look up a platform entry by name."""
if name not in self._entries:
self._resolve(name)
return self._entries.get(name)La motivation du différé est dans le commentaire — les modules d'adaptateur importent des SDK lourds au niveau supérieur (lark_oapi, discord.py, slack_bolt…), un chargement complet ralentirait de plusieurs secondes même hermes chat. Le loader placeholder n'effectue le véritable import qu'au premier get(name).
BasePlatformAdapter utilise l'ABC pour forcer les sous-classes à implémenter le triplet — connect, disconnect, send. C'est le contrat minimal de l'adaptateur :
@abstractmethod
async def connect(self, *, is_reconnect: bool = False) -> bool:
"""Connect to the platform and start receiving messages.
Args:
is_reconnect: False on a cold first boot; True when the reconnect
watcher is re-establishing a platform that was previously running
and dropped after an outage. Adapters that buffer a server-side
update queue (e.g. Telegram's Bot API) should preserve that queue
when ``is_reconnect`` is True so messages sent during the outage
are delivered rather than silently discarded.
"""
pass
@abstractmethod
async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:is_reconnect permet de préserver la file d'updates côté serveur (Telegram), sinon les messages envoyés pendant la coupure sont silencieusement perdus.
Un adaptateur concret (Signal) ressemble à ceci. send traduit le contenu unifié en paramètres RPC de signal-cli :
class SignalAdapter(BasePlatformAdapter):
"""Signal messenger adapter using signal-cli HTTP daemon."""
platform = Platform.SIGNAL
SUPPORTS_MESSAGE_EDITING = False # Signal : pas d'API d'édition des messages envoyés
async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:
"""Send a text message with native Signal formatting."""
await self._stop_typing_indicator(chat_id)
plain_text, text_styles = self._markdown_to_signal(content)
params: Dict[str, Any] = {"account": self.account, "message": plain_text}
if text_styles:
params["textStyles"] = text_styles
if chat_id.startswith("group:"):
params["groupId"] = chat_id[6:]
else:
params["recipient"] = [await self._resolve_recipient(chat_id)]
result = await self._rpc("send", params)Un bit de capacité comme SUPPORTED_MESSAGE_EDITING = False indique au consommateur de streaming de ne pas éditer un message déjà envoyé, pour éviter de laisser un placeholder « échec d'édition » sur le client Signal. Les différences de capacité entre plateformes s'expriment par des champs d'adaptateur, pas par des branches if-else.
Limites et échecs
- Plateforme hors ligne / coupure :
connectdistingue cold boot et reprise viais_reconnect; pendant la coupure, les messages utilisateur peuvent s'accumuler côté serveur. Si l'adaptateur ne préserve pas la file d'updates (Telegram) ou n'a pas de reconnexion SSE (Signal), les messages sont perdus. - Chargement erroné d'un SDK lourd : un nom enregistré à tort en différé, ou
_resolve_alldéclenché sur un chemin qui ne le devrait pas, entraîne discord.py dans le CLI chat.registerprioritaire sur le différé sert de filet. - Incohérence des bits de capacité :
SUPPORTED_MESSAGE_EDITING=Falsemais le consommateur de streaming suit quand même le chemin d'édition — le client affiche un placeholder « échec d'édition ». Tout nouveau bit de capacité doit déclarer explicitement sa valeur par défaut sur tous les adaptateurs. - Listener dupliqué multi-profile : un même numéro Signal enregistré sur plusieurs profiles démarre deux SSE. Signal utilise
_acquire_platform_lock('signal-phone', self.account, ...)pour poser un verrou au niveau processus et éviter le doublon.
Résumé
Adaptateur = traducteur entre « dialecte plateforme ↔ événement unifié ». Ajouter une plateforme consiste à hériter de BasePlatformAdapter, implémenter quelques méthodes abstraites et s'enregistrer ; voir gateway/platforms/ADDING_A_PLATFORM.md. Telegram/Discord/Slack passent par le chemin plugin, au même titre que les adaptateurs intégrés.