Adaptadores (adapter) de plataforma
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
class BasePlatformAdapter(ABC):2317— clase base abstracta del adaptadormétodos abstractos send / send_draft:2983-3015— interfaz de envío que las subclases deben implementarMessageType / ProcessingOutcome / MessageEvent:1737-1759— modelo unificado de eventosCachedMedia:1638-1737— caché de medios (evita reuploads)SendResult / EphemeralReply:1898-2068— resultado de envío y respuesta efímerahelpers de envío comunes:3173-3326—send_slash_confirm/send_clarify/send_typing/send_multiple_imagesdirectorio gateway/platforms/— signal / whatsapp_cloud / weixin / bluebubbles / yuanbao / qqbot…directorio plugins/platforms/— Telegram / Discord / Slack (como plugins)ADDING_A_PLATFORM.md— guía para añadir plataformasPlatformRegistry:162— registro (registry) (register / register_deferred / unregister)
Flujo de datos
- En el arranque,
GatewayRunner(gateway/run.py:3029) levanta los adaptadores vía_start_one_profile_adapter:9389. - 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). - El adaptador escucha eventos de la plataforma, los traduce a
MessageEvent:1759y los entrega a_handle_message:9947. - 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. - 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:
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:
@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:
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:
connectdistingue cold start de recuperación conis_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_allen una ruta que no debería, el CLI de chat acaba cargando discord.py. Queregistertenga prioridad sobre el deferred es la red de seguridad. - Capability bits inconsistentes: si
SUPPORTED_MESSAGE_EDITING=Falsepero 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.