Skip to content

Adaptadores (adapter) de plataforma

源码版本v2026.7.20

Responsabilidad

Cada plataforma (Telegram, Discord, Slack, Signal, WhatsApp, WeChat, QQ…) tiene un adaptador que traduce los eventos nativos de la plataforma a un MessageEvent unificado, y las acciones de envío unificadas de vuelta a la API de la plataforma. Los adaptadores heredan de BasePlatformAdapter e implementan un puñado de métodos abstractos. Pueden venir del gateway/platforms/ integrado o de plugins en plugins/platforms/; todos se registran a través de PlatformRegistry.

Motivo de diseño

Las APIs nativas de cada plataforma son radicalmente distintas: Telegram Bot API usa sendMessage + escapado MarkdownV2; Discord tira de webhook + snowflake IDs; Signal habla con un daemon signal-cli local; WeChat ni siquiera soporta texto enriquecido. Meter todo eso en el bucle principal del agent es condenarlo a no terminar nunca. La capa de adaptadores lo absorbe: hacia arriba suelta un MessageEvent unificado, hacia abajo recibe una abstracción común send(chat_id, content). Con el deferred loading de PlatformRegistry, comandos que no tocan plataformas (como hermes chat) ni siquiera cargan las veinte SDKs de plataforma y no se ralentizan.

Archivos clave

Flujo de datos

  1. En el arranque, GatewayRunner (gateway/run.py:3029) levanta los adaptadores vía _start_one_profile_adapter:9389.
  2. El adaptador se consulta en PlatformRegistry (gateway/platform_registry.py:162) por nombre, soportando carga diferida (registro de placeholder barato, import real solo cuando se usa).
  3. El adaptador escucha eventos de la plataforma, los traduce a MessageEvent:1759 y los entrega a _handle_message:9947.
  4. El agent produce streaming; el adaptador lo entrega de vuelta a la plataforma vía las interfaces unificadas (send:3008 / send_draft); los medios pasan por la caché CachedMedia:1638.
  5. Las diferencias entre plataformas (longitud de mensaje, texto enriquecido, límites de imágenes) las absorbe internamente el adaptador; el núcleo del gateway no se entera.

PlatformRegistry mantiene dos registros — _entries ya instanciado, _deferred con placeholders baratos. Al consultar, convierte el segundo en el primero. Campos clave:

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)

El motivo del deferred está en el comentario: los adaptadores importan en la cima del módulo SDKs pesadas (lark_oapi, discord.py, slack_bolt…); cargarlas todas hace que hermes chat también se ralentice unos segundos. El loader de placeholder solo importa de verdad en la primera llamada a get(name).

BasePlatformAdapter usa un ABC para obligar a las subclases a implementar el trío — connect, disconnect, send —; es el contrato mínimo de un adaptador:

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 permite preservar la cola de updates del server (la de Telegram) al reconectar; si no, los mensajes enviados durante la caída se pierden silenciosamente.

Un adaptador concreto (Signal) tiene esta pinta. send traduce el content unificado a los parámetros RPC de signal-cli:

python
class SignalAdapter(BasePlatformAdapter):
    """Signal messenger adapter using signal-cli HTTP daemon."""

    platform = Platform.SIGNAL
    SUPPORTS_MESSAGE_EDITING = False  # Signal no tiene API para editar mensajes

    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 capability bit como SUPPORTED_MESSAGE_EDITING = False le dice al stream consumer que no edite mensajes ya enviados, para no dejar un placeholder de «edición fallida» en el cliente de Signal. Las diferencias de plataforma se expresan con campos del adaptador, no con ramas if-else.

Límites y fallos

  • Plataforma offline / caída: connect distingue cold start de recuperación con is_reconnect; los mensajes de usuario enviados durante la caída pueden quedar acumulados en el server. Si el adaptador no retiene la cola de updates (Telegram) o no reabre el SSE (Signal), se pierden.
  • SDK pesada cargada por error: si el nombre de un deferred se registra mal o se invoca _resolve_all en una ruta que no debería, el CLI de chat acaba cargando discord.py. Que register tenga prioridad sobre el deferred es la red de seguridad.
  • Capability bits inconsistentes: si SUPPORTED_MESSAGE_EDITING=False pero el stream consumer sigue editando, el cliente se queda con un placeholder de «edición fallida». Cada capability bit nuevo debe llevar un default explícito en todos los adaptadores.
  • Listener duplicado en multi-profile: si el mismo número de Signal se registra en varios profiles, se arrancan dos SSE. Signal lo evita con un lock a nivel de proceso _acquire_platform_lock('signal-phone', self.account, ...).

Resumen

Adaptador = traductor entre «dialecto de plataforma ↔ evento unificado». Añadir una plataforma solo requiere heredar de BasePlatformAdapter, implementar unos pocos métodos abstractos y registrarse; ver gateway/platforms/ADDING_A_PLATFORM.md. Telegram/Discord/Slack van por la vía de plugins, tratados igual que los adaptadores integrados.

Sitio de aprendizaje comunitario no oficial. Basado en el código fuente de NousResearch/hermes-agent (licencia MIT).