Skip to content

Adaptateurs de plateforme

源码版本v2026.7.20

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

Flux de données

  1. Au démarrage, GatewayRunner (gateway/run.py:3029) lance l'adaptateur via _start_one_profile_adapter:9389.
  2. 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).
  3. L'adaptateur écoute les événements de la plateforme, les traduit en MessageEvent:1759 et les confie à _handle_message:9947.
  4. 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 par CachedMedia:1638 pour le cache.
  5. 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 :

python
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 :

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

python
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 : connect distingue cold boot et reprise via is_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_all déclenché sur un chemin qui ne le devrait pas, entraîne discord.py dans le CLI chat. register prioritaire sur le différé sert de filet.
  • Incohérence des bits de capacité : SUPPORTED_MESSAGE_EDITING=False mais 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.

Site d'apprentissage communautaire non officiel. Basé sur le code source de NousResearch/hermes-agent (licence MIT).