feat: implement channel runtime connectors

This commit is contained in:
2026-06-03 16:22:44 +08:00
parent ee972441f5
commit c3d84b904a
105 changed files with 15621 additions and 322 deletions

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,8 @@
**Architecture:** Add a connector layer under `beaver/interfaces/channels/connections/` while keeping `ChannelRuntime`, `MessageBus`, and existing adapters as the message path. A `ChannelConnectionStore` persists setup state, a small credential vault stores secrets by reference, and `ChannelConnectorRegistry` materializes enabled connections into `ChannelConfig` objects during app startup. The first concrete connector is Telegram because token validation and runtime materialization are simple and testable with fake clients.
**Weixin Follow-Up Constraint:** Weixin personal-account support is not implemented in this foundation slice. The follow-up Weixin plan must use a docker-compose predeclared sidecar service and Beaver must only call the existing connector HTTP API; Beaver must not dynamically create containers or require Docker socket access. Because the sidecar owns Weixin protocol, QR login, receive, and send behavior, Beaver should expose it to `ChannelRuntime` as an `ExternalConnectorChannel`, not as a protocol-level `WeixinAdapter`.
**Tech Stack:** Python dataclasses, FastAPI, Pydantic v2, local JSON stores, pytest, existing Beaver channel runtime.
---
@ -28,7 +30,7 @@ Excluded from this plan:
- Terminal authenticated pairing.
- Feishu/Lark official SDK integration.
- Weixin external connector process.
- Weixin docker-compose sidecar pairing and bridge implementation. The later Weixin plan must use a predeclared sidecar service plus Beaver HTTP bridge endpoints, not local host dependencies, dynamic container creation, or a Beaver-owned Weixin protocol adapter.
- QQBot connector.
- Frontend connection wizard.
- Hot starting/stopping adapters without backend restart.
@ -1739,9 +1741,19 @@ rg -n "token-1|token-2|bad-token|secret-token" tests/unit beaver || true
Expected: test fixture strings only appear in test files. They must not appear in implementation files or generated event log code.
- [ ] **Step 4: Update adapter spec wording if still contradictory**
- [ ] **Step 4: Update connector and adapter spec wording if still contradictory**
If `docs/superpowers/specs/2026-06-02-chat-platform-channel-adapters-design.md` still says pairing is out of scope and Node sidecars are disallowed, change only the Non-Goals and Access Control text:
If `docs/superpowers/specs/2026-06-02-channel-connectors-and-pairing-design.md` still says Weixin uses a local plugin installer, dynamically launched connector process, or `ChannelRuntime external adapter`, change only the Weixin/external-process wording to this architecture:
```markdown
Weixin personal-account support uses a docker-compose predeclared sidecar connector. Beaver calls the sidecar's existing HTTP API and must not dynamically create containers or require Docker socket access.
```
```markdown
For Weixin, the sidecar owns the platform protocol, QR login, inbound receive loop, outbound send, and login-state persistence. Beaver exposes it to the runtime through an `ExternalConnectorChannel`: inbound sidecar webhooks call Beaver's connector bridge endpoint, which submits normalized messages to `ChannelRuntime.accept_inbound()`, while outbound runtime messages call the sidecar `/send` API.
```
If `docs/superpowers/specs/2026-06-02-chat-platform-channel-adapters-design.md` still describes `WeixinAdapter` as the Beaver-owned protocol adapter, change only the Weixin adapter scope and access-control text:
```markdown
- Use internal adapters by default, but allow external connector processes where platform SDK or login state requires them.
@ -1751,17 +1763,40 @@ If `docs/superpowers/specs/2026-06-02-chat-platform-channel-adapters-design.md`
Pairing is owned by the connector layer. Platform adapters assume a materialized `ChannelConnection` and adapter-ready runtime config.
```
```markdown
For Weixin personal-account support, the runtime channel is an `ExternalConnectorChannel`, not a Beaver-owned `WeixinAdapter`. The docker-compose sidecar is the Weixin protocol adapter; Beaver only owns connector setup state, bridge API validation, message normalization boundaries, runtime dedupe, and outbound HTTP calls to the sidecar.
```
- [ ] **Step 5: Commit spec alignment if changed**
If Step 4 changed docs:
```bash
git add docs/superpowers/specs/2026-06-02-chat-platform-channel-adapters-design.md
git commit -m "docs: align channel adapter spec with connector layer"
git add \
docs/superpowers/specs/2026-06-02-channel-connectors-and-pairing-design.md \
docs/superpowers/specs/2026-06-02-chat-platform-channel-adapters-design.md
git commit -m "docs: align channel specs with external connector channels"
```
If Step 4 made no change, do not create an empty commit.
- [ ] **Step 6: Summarize remaining rollout**
Record in the final implementation response that this first plan does not implement Terminal pairing, Feishu/Lark connector, Weixin external connector, QQBot connector, frontend wizard, or hot adapter restart. Those are separate plans.
Record in the final implementation response that this first plan does not implement Terminal pairing, Feishu/Lark connector, Weixin docker-compose sidecar pairing/bridge, QQBot connector, frontend wizard, or hot adapter restart. Those are separate plans.
For Weixin specifically, record the agreed follow-up architecture:
```text
Weixin sidecar connector
-> Beaver connector bridge endpoint
-> ChannelRuntime.accept_inbound()
-> MessageBus
-> AgentService
AgentService
-> MessageBus outbound
-> ExternalConnectorChannel.send()
-> Weixin sidecar connector /send
```
Do not describe the follow-up path as `sidecar -> WeixinAdapter -> ChannelRuntime`. The sidecar is the Weixin protocol adapter; Beaver's runtime object should be named `ExternalConnectorChannel` or an equivalently generic connector-bridge channel.

File diff suppressed because it is too large Load Diff

View File

@ -13,19 +13,19 @@ ChannelConnector
-> install / auth / QR / OAuth / credential validation / login state
-> ChannelConnectionStore
-> ChannelRuntime
-> ChannelAdapter
-> ChannelAdapter or ExternalConnectorChannel
-> MessageBus
-> AgentService
```
The existing `ChannelRuntime`, `MessageBus`, `ChannelManager`, and `ChannelAdapter` contracts remain the message routing core. The new connector layer owns user-visible setup and connection lifecycle.
The existing `ChannelRuntime`, `MessageBus`, `ChannelManager`, and `ChannelAdapter` contracts remain the message routing core. The new connector layer owns user-visible setup and connection lifecycle. For platforms backed by predeclared sidecar services, Beaver should expose the sidecar to the runtime as an `ExternalConnectorChannel` rather than a Beaver-owned platform protocol adapter.
## Why This Is Required
The current channel design assumes a channel is already configured before the backend starts. That is enough for local development and simple webhook/token channels, but it does not match real platform onboarding:
- Feishu/Lark now has a Channel SDK pattern that packages bot channel setup, WebSocket or webhook transport, event handling, and replies around an installed app identity.
- Weixin/OpenClaw-style setup uses a local plugin installer plus QR login and persistent login state.
- Weixin personal-account setup uses a docker-compose predeclared sidecar connector plus QR login and persistent login state.
- Terminal devices need pairing or device registration; a raw `peer_id` connect frame is not enough for a real deployment.
- Even simple token platforms such as Telegram need a UI flow for token entry, validation, status, revoke, and restart.
@ -44,11 +44,13 @@ So Beaver needs a connection lifecycle layer. Adapters should not be responsible
`ChannelConnection` is the user-visible connection instance. Examples: "Weixin personal account", "Lark workspace bot", "Telegram main bot", "Desk terminal".
`ChannelConnector` is the setup and lifecycle controller for one platform family. It starts pairing sessions, validates credentials, launches connector processes, handles reconnects, and emits runtime channel config.
`ChannelConnector` is the setup and lifecycle controller for one platform family. It starts pairing sessions, validates credentials, checks preconfigured connector endpoints when needed, handles reconnects, and emits runtime channel config.
`ChannelAdapter` is the message transport adapter used by `ChannelRuntime`. It receives normalized inbound messages and sends outbound replies. It does not own onboarding.
`ExternalConnectorProcess` is an optional local process for platforms whose SDK or login behavior is better isolated outside the Python backend. It talks to Beaver through a narrow control and message protocol.
`ExternalConnectorChannel` is the runtime channel object used when a platform protocol lives outside the Python backend. It implements the same `start()`, `stop()`, and `send()` contract as an adapter, but its `send()` method calls an external connector HTTP API and inbound messages enter Beaver through a connector bridge endpoint.
`ExternalConnectorProcess` is an optional preconfigured service for platforms whose SDK or login behavior is better isolated outside the Python backend. For Weixin, this process is a docker-compose predeclared sidecar service. Beaver must not dynamically create containers or require Docker socket access.
## Data Model
@ -171,7 +173,7 @@ Some channels should run through an external process:
ExternalConnectorProcess
-> Beaver connector control API
-> local Unix/TCP/WebSocket bridge
-> ChannelRuntime external adapter
-> ChannelRuntime ExternalConnectorChannel
```
The external process must not receive permanent backend admin credentials through QR codes or copied commands. It should receive a short-lived pairing token with a narrow scope:
@ -183,7 +185,7 @@ expires_in: 10 minutes
one_time: true
```
After pairing, Beaver stores the resulting connection credentials and gives the connector a renewable connection token scoped to that connection only.
After pairing, Beaver stores the resulting connection credentials and gives the connector a renewable connection token scoped to that connection only. For docker-compose sidecars, that token is passed through the connector HTTP API or service configuration agreed for that sidecar; Beaver does not create or restart the sidecar container.
## Per-Channel Assessment
@ -211,23 +213,41 @@ The connector should expose both "manual app credential setup" and future "insta
### Weixin
Weixin should use an external connector process.
Weixin should use a docker-compose predeclared sidecar connector.
Recommended first implementation:
- connector kind: `weixin`
- setup mode: local plugin command plus QR login.
- external process: required.
- runtime adapter: external bridge adapter that receives normalized events from the connector.
- setup mode: Beaver calls the sidecar HTTP API to start QR login and poll pairing state.
- external process: required, predeclared in docker-compose, and never dynamically created by Beaver.
- runtime channel: `ExternalConnectorChannel`.
Required setup checks:
- local connector installed.
- sidecar base URL is configured.
- sidecar health endpoint responds.
- connector version is compatible with Beaver.
- QR session is pending, scanned, confirmed, expired, or failed.
- login state is stored behind `credentials_ref`.
- connector heartbeat is visible.
The sidecar owns Weixin protocol handling, QR login, inbound receive, outbound send, and login-state persistence. Beaver owns connector setup state, bridge API validation, message normalization boundaries, runtime dedupe, and outbound HTTP calls to the sidecar `/send` API.
The agreed runtime flow is:
```text
Weixin sidecar connector
-> Beaver connector bridge endpoint
-> ChannelRuntime.accept_inbound()
-> MessageBus
-> AgentService
AgentService
-> MessageBus outbound
-> ExternalConnectorChannel.send()
-> Weixin sidecar connector /send
```
Group delivery remains best-effort. The connector must surface group capability separately from direct message capability.
### Telegram

View File

@ -0,0 +1,307 @@
# 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
<channel_id>:<account_id>:<peer_id>[:<thread_id>]
```
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.