Platform Adapters
Responsibility
Each platform (Telegram, Discord, Slack, Signal, WhatsApp, WeChat, QQ...) has an adapter that translates native platform events into the unified MessageEvent and translates unified send actions back into platform API calls. Adapters inherit BasePlatformAdapter and implement a few abstract methods. Adapters can come from the built-in gateway/platforms/ or from plugins in plugins/platforms/; both register via PlatformRegistry.
Design Motivation
Native platform APIs differ wildly: Telegram Bot API uses sendMessage plus MarkdownV2 escaping, Discord goes through webhooks plus snowflake IDs, Signal has to talk to a local signal-cli daemon, and WeChat doesn't even support rich text. Stuffing all these differences into the agent's main loop would mean the agent never ships. The adapter layer absorbs them — it emits a unified MessageEvent upward and accepts a unified send(chat_id, content) abstraction downward. Combined with PlatformRegistry's deferred loading, commands like hermes chat that never touch a platform aren't slowed down by the twenty-plus platform SDKs.
Key Files
class BasePlatformAdapter(ABC):2317— abstract base class for adaptersabstract methods send / send_draft:2983-3015— send interface subclasses must implementMessageType / ProcessingOutcome / MessageEvent:1737-1759— unified event modelCachedMedia:1638-1737— media cache (avoids re-upload)SendResult / EphemeralReply:1898-2068— send result and ephemeral replycommon send helpers:3173-3326—send_slash_confirm/send_clarify/send_typing/send_multiple_imagesgateway/platforms/ directory— signal / whatsapp_cloud / weixin / bluebubbles / yuanbao / qqbot...plugins/platforms/ directory— Telegram / Discord / Slack (as plugins)ADDING_A_PLATFORM.md— guide for adding a platformPlatformRegistry:162— registry (register / register_deferred / unregister)
Data Flow
- At startup
GatewayRunner(gateway/run.py:3029) brings up adapters via_start_one_profile_adapter:9389. - Adapters are looked up by name from
PlatformRegistry(gateway/platform_registry.py:162), supporting deferred loading (a cheap placeholder first; real import only when used). - Adapters listen for platform events, translate them into
MessageEvent:1759s, and hand them to_handle_message:9947. - When the agent produces streaming output, the adapter delivers it back to the platform via the unified interface (
send:3008/send_draft); media goes throughCachedMedia:1638. - Platform differences (message length, rich text, image limits) are absorbed inside the adapter; the gateway core is unaware.
PlatformRegistry keeps two ledgers — _entries for already-installed platforms and _deferred for cheap placeholders. The latter is only promoted to the former when looked up. Core fields:
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)The motivation for deferred loading is in the comment — platform adapter modules import heavy SDKs at the top level (lark_oapi, discord.py, slack_bolt...), and loading them all would slow hermes chat by seconds. The placeholder loader only does the real import on the first get(name).
BasePlatformAdapter uses ABC to force subclasses to implement three things — connect, disconnect, send — the minimum contract for an adapter:
@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 lets reconnect preserve the server-side update queue (like Telegram's). Without it, messages sent during the outage would be silently dropped.
A concrete adapter (Signal) looks like this. send translates the unified content into signal-cli RPC parameters:
class SignalAdapter(BasePlatformAdapter):
"""Signal messenger adapter using signal-cli HTTP daemon."""
platform = Platform.SIGNAL
SUPPORTS_MESSAGE_EDITING = False # Signal has no edit API for sent messages
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)A capability flag like SUPPORTED_MESSAGE_EDITING = False tells the stream consumer not to edit sent messages, avoiding an "edit failed" placeholder box in the Signal client. Platform feature differences are all expressed through adapter fields rather than if-else branches.
Boundaries and Failure
- Platform offline / dropped connection:
connectusesis_reconnectto tell cold boot from recovery. While offline, user messages may pile up on the server side. If the adapter doesn't preserve the update queue (Telegram) or doesn't SSE-reconnect (Signal), those messages are lost. - Heavy SDK loaded by mistake: a wrong name in deferred registration or calling
_resolve_allon a path that shouldn't will dragdiscord.pyinto a CLI chat session.registertaking precedence over deferred is the fallback. - Capability flag mismatch: if
SUPPORTED_MESSAGE_EDITING=Falsebut the stream consumer still takes the edit path, the client gets an "edit failed" placeholder. When new capability flags are added, every adapter must declare a default explicitly. - Multi-profile duplicate listeners: registering the same Signal number under multiple profiles would spin up two SSE streams. Signal uses
_acquire_platform_lock('signal-phone', self.account, ...)to take a process-level lock and prevent duplicates.
Summary
Adapter = a translator between "platform dialect" and "unified event". Adding a platform only requires subclassing BasePlatformAdapter, implementing a few abstract methods, and registering — see gateway/platforms/ADDING_A_PLATFORM.md. Telegram/Discord/Slack go through the plugin path and are treated the same as built-in adapters.