# Chat Platform Channel Adapters Design Date: 2026-06-02 ## Goal Add first-class Beaver channel adapters for four messaging platforms: - `FeishuAdapter` - `QQBotAdapter` - `TelegramAdapter` - `ExternalConnectorChannel` for Weixin personal-account sidecars Each runtime channel must plug into the existing `ChannelRuntime`, normalize inbound platform messages into `InboundMessage` with `ChannelIdentity`, and deliver `OutboundMessage` replies back to the original platform conversation. Feishu, QQBot, and Telegram use Beaver-owned protocol adapters. Weixin personal-account support uses a docker-compose predeclared sidecar connector, so Beaver exposes it as an `ExternalConnectorChannel` rather than a Beaver-owned `WeixinAdapter`. ## Non-Goals - Use internal adapters by default, but allow external connector processes where platform SDK or login state requires them. - Do not implement WhatsApp in this phase. - Do not replace `ChannelRuntime`, `MessageBus`, or `ChannelManager`. - Do not move platform access policy into `AgentService`. - Do not implement streaming token deltas for these channels in this phase. - Do not promise stable Weixin group support; Weixin group delivery is best-effort only. ## Architecture Keep Beaver's channel runtime as the owner of lifecycle, dedupe, event logging, and agent dispatch. ```text platform SDK/API or sidecar connector -> {Channel}Adapter or ExternalConnectorChannel bridge endpoint -> ChannelRuntime.accept_inbound() -> MessageBus.inbound -> ChannelRuntime bridge -> AgentService.handle_inbound_message() -> MessageBus.outbound -> ChannelManager.dispatch_outbound() -> {Channel}Adapter.send() or ExternalConnectorChannel.send() -> platform SDK/API or sidecar connector API ``` Adapters own platform-specific transport and delivery details when Beaver directly integrates a platform API. For Weixin, the sidecar owns the platform protocol, QR login, receive loop, send behavior, and login-state persistence. The runtime owns Beaver session identity, dedupe, event logging, and run dispatch in both cases. ## Shared Adapter Contract Each runtime channel implements the existing `ChannelAdapter` protocol: ```python channel_id: str kind: str mode: str async def start() -> None async def stop() -> None async def send(message: OutboundMessage) -> None ``` Each Beaver-owned adapter receives a `ChannelInboundSink` and calls `accept_inbound()` for every normalized user message. `ExternalConnectorChannel` receives inbound Weixin messages through Beaver's connector bridge endpoint, then submits normalized messages to `ChannelRuntime.accept_inbound()`. For all four adapters: - `kind` is one of `feishu`, `qqbot`, `telegram`, `weixin`. - `account_id` comes from channel config. - inbound messages must include `ChannelIdentity`. - outbound replies route by `message.channel_identity` when present, falling back to `message.session_id`. - unsupported media is represented as text metadata in phase one rather than dropped silently. ## Channel Configuration All channels use the existing `BeaverConfig.channels` map. ```json { "channels": { "telegram-main": { "enabled": true, "kind": "telegram", "mode": "polling", "accountId": "bot-main", "displayName": "Telegram Main", "secrets": { "botToken": "..." }, "config": { "requireMentionInGroups": true, "maxMessageChars": 4096 } } } } ``` Config keys stay channel-specific inside `config` and `secrets`. The factory chooses the adapter by `kind` and `mode`. For sidecar-backed channels, config also includes the connector base URL and bridge settings. Beaver must call the already-running connector HTTP API and must not dynamically create containers or require Docker socket access. ## Identity Mapping All adapters map platform identity into `ChannelIdentity`: - `channel_id`: configured Beaver channel id, such as `telegram-main` - `kind`: platform kind - `account_id`: configured account id - `peer_id`: platform chat, group, openid, or user conversation id - `thread_id`: platform topic/thread id when applicable - `peer_type`: `dm`, `group`, `channel`, or platform-specific value - `user_id`: platform sender id when available - `message_id`: platform message id or event id The runtime continues to derive sessions as: ```text ::[:] ``` Group sessions can later become per-user or per-thread by adding adapter-level `thread_id` rules without changing `ChannelRuntime`. ## Adapter Scope ### FeishuAdapter Supports: - WebSocket long connection as the preferred mode. - Optional webhook mode if configured. - Direct messages. - Group messages gated by mention or config. - Text outbound replies. - Basic inbound media metadata and cached local file paths when available. Configuration: - `secrets.appId` - `secrets.appSecret` - `config.domain`: `feishu` or `lark` - `config.connectionMode`: `websocket` or `webhook` - `config.requireMentionInGroups` - `config.allowFrom` - `config.groupAllowFrom` ### QQBotAdapter Supports: - Official QQ Bot WebSocket gateway for inbound events. - Official REST API for outbound text replies. - Private C2C messages. - Group messages. - Guild/channel messages when the platform event provides them. - Basic rich media intake as cached local files or text metadata. Configuration: - `secrets.appId` - `secrets.clientSecret` - `config.markdownSupport` - `config.dmPolicy` - `config.allowFrom` - `config.groupPolicy` - `config.groupAllowFrom` ### TelegramAdapter Supports: - Bot API long polling as the default mode. - Optional webhook mode if configured. - Direct messages. - Group messages gated by mention or config. - Text replies with platform-safe formatting and chunking. - Photo/document/audio/video intake as cached local files or metadata. Configuration: - `secrets.botToken` - `config.mode`: `polling` or `webhook` - `config.webhookUrl` - `config.webhookSecret` - `config.requireMentionInGroups` - `config.allowFrom` - `config.groupAllowFrom` - `config.maxMessageChars` ### ExternalConnectorChannel For Weixin Supports: - Docker-compose predeclared sidecar connector. - QR-login sessions started and observed through the sidecar HTTP API. - Direct messages. - Text replies sent through the sidecar `/send` API. - Media send/receive when the sidecar provides normalized metadata. - Group delivery as best-effort only. Configuration: - `secrets.connectionToken` - `config.accountId` - `config.baseUrl` - `config.bridgeSecret` - `config.dmPolicy` - `config.allowFrom` - `config.groupPolicy` - `config.groupAllowFrom` - `config.maxMessageChars` Inbound flow: ```text Weixin sidecar connector -> Beaver connector bridge endpoint -> ChannelRuntime.accept_inbound() -> MessageBus -> AgentService ``` Outbound flow: ```text AgentService -> MessageBus outbound -> ExternalConnectorChannel.send() -> Weixin sidecar connector /send ``` The sidecar is the Weixin protocol adapter. Beaver's `ExternalConnectorChannel` only validates bridge calls, normalizes the sidecar event boundary, preserves runtime dedupe/session semantics, and forwards outbound sends to the sidecar HTTP API. ## Access Control Adapters may block inbound messages before calling `accept_inbound()` when the platform has channel-native allowlist settings. Runtime dedupe still applies after adapter admission. Initial policy values: - `open`: allow matching platform scope. - `allowlist`: require `allowFrom` or `groupAllowFrom`. - `disabled`: ignore inbound messages for that scope. Pairing is owned by the connector layer. Platform adapters assume a materialized `ChannelConnection` and adapter-ready runtime config. For Weixin personal-account support, the runtime channel is an `ExternalConnectorChannel`, not a Beaver-owned `WeixinAdapter`. ## Delivery Semantics Inbound: - validate required routing fields before submitting to runtime. - preserve raw platform payload in metadata only when useful for debugging. - keep metadata small enough for event logs. - include media paths in metadata and text summaries in `content` when the agent needs to know an attachment exists. Outbound: - send only final assistant replies in phase one. - chunk messages to platform limits. - mark `delivery_status = "unclaimed"` when a target cannot be resolved. - raise or return delivery failures so `ChannelManager` records `outbound_delivery_failed`. ## Runtime Status `ChannelRuntime.statuses()` should report platform channels with: - `channel_id` - `kind` - `mode` - `display_name` - `enabled` - `state` - `account_id` - `last_error` - `last_event_at` - `capabilities` Capabilities are conservative: - Feishu: `receive_text`, `send_text`, `receive_media`, `groups` - QQBot: `receive_text`, `send_text`, `receive_media`, `groups` - Telegram: `receive_text`, `send_text`, `receive_media`, `groups` - Weixin: `receive_text`, `send_text`, `receive_media`, `direct_messages` ## Error Handling - Adapter startup failure sets channel state to `error` and does not stop other channels. - Runtime shutdown calls every adapter `stop()`. - Platform transient errors should retry inside the adapter only when retrying cannot duplicate user-visible sends. - Fatal credential/config errors should surface in channel status. - Inbound duplicates are handled by existing `ChannelDedupeStore`. ## Testing Add tests in small layers: - factory tests for `kind` and `mode` adapter selection. - identity normalization tests for each platform. - inbound adapter tests using fake platform payloads. - outbound adapter tests with fake platform clients. - runtime status tests for configured enabled/disabled/error channels. Network live tests are out of scope for unit tests. Adapter constructors should accept injectable clients or lightweight transport functions so tests do not call real platform APIs. ## Rollout Implement one adapter at a time: 1. Telegram 2. Feishu 3. QQBot 4. Weixin Telegram is first because its bot-token flow and text path are the simplest proof of the shared adapter pattern. Weixin is last because QR/login state, context tokens, and media handling are more specialized.