From 6a6ddc21c071a7d06a569fdaebc4fb9386f0c777 Mon Sep 17 00:00:00 2001 From: steven_li Date: Mon, 1 Jun 2026 16:41:19 +0800 Subject: [PATCH 01/11] docs: design terminal websocket channel --- ...06-01-terminal-websocket-channel-design.md | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-01-terminal-websocket-channel-design.md diff --git a/docs/superpowers/specs/2026-06-01-terminal-websocket-channel-design.md b/docs/superpowers/specs/2026-06-01-terminal-websocket-channel-design.md new file mode 100644 index 0000000..e5a36f1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-terminal-websocket-channel-design.md @@ -0,0 +1,279 @@ +# Terminal WebSocket Channel Design + +Date: 2026-06-01 + +## Goal + +Add a text-only WebSocket channel adapter so a small terminal device can connect to Beaver and exchange messages through the channel runtime. + +This is a first-stage acceptance path for proving Beaver can talk to the terminal device. The terminal must enter through `ChannelRuntime` and `MessageBus`; it must not use the existing Web UI `/ws/{session_id}` direct-chat path. + +## Non-Goals + +- Do not implement audio, camera, screen, image, or multimodal payloads. +- Do not stream token deltas to the terminal in this phase. +- Do not add AuthZ or device registration in this phase. +- Do not implement the Hermes LiveKit LLM adapter in this phase. +- Do not route terminal messages directly to `AgentService`. + +## Recommended Architecture + +Add a channel-native WebSocket adapter named `TerminalWebSocketAdapter`. + +The Web backend exposes: + +```text +/api/channels/{channel_id}/ws +``` + +The route resolves the configured channel adapter from `ChannelRuntime` and delegates the accepted WebSocket to the adapter. The adapter owns terminal connection state, normalizes incoming frames into `InboundMessage`, and receives `OutboundMessage` objects through `ChannelManager.dispatch_outbound()`. + +The path remains bus-first: + +```text +terminal websocket +-> TerminalWebSocketAdapter +-> ChannelRuntime.accept_inbound() +-> MessageBus.inbound +-> ChannelRuntime bridge +-> AgentService.handle_inbound_message() +-> MessageBus.outbound +-> ChannelManager.dispatch_outbound() +-> TerminalWebSocketAdapter.send() +-> terminal websocket +``` + +## Channel Configuration + +The terminal channel uses the existing `BeaverConfig.channels` map. + +Example: + +```json +{ + "channels": { + "terminal-dev": { + "enabled": true, + "kind": "terminal", + "mode": "websocket", + "accountId": "local", + "displayName": "Terminal Dev", + "config": { + "heartbeatSeconds": 30, + "maxMessageChars": 20000 + } + } + } +} +``` + +`kind` is the platform family. `mode` is the transport mode. The adapter factory must instantiate `TerminalWebSocketAdapter` when `kind == "terminal"` and `mode == "websocket"`. + +## Protocol + +The protocol is JSON over WebSocket. All payloads are text-only. + +The terminal starts with a connect frame: + +```json +{ + "type": "connect", + "peer_id": "device-001", + "device_name": "desk-terminal", + "capabilities": ["text"] +} +``` + +Beaver replies: + +```json +{ + "type": "connected", + "channel_id": "terminal-dev", + "session_id": "terminal-dev:local:device-001" +} +``` + +The terminal sends user text: + +```json +{ + "type": "message", + "message_id": "m-001", + "text": "你好" +} +``` + +Beaver acknowledges accepted inbound: + +```json +{ + "type": "ack", + "message_id": "m-001", + "session_id": "terminal-dev:local:device-001", + "accepted": true +} +``` + +Beaver sends the final assistant response: + +```json +{ + "type": "message", + "role": "assistant", + "message_id": "m-001", + "run_id": "run-id", + "text": "你好,我在。", + "finish_reason": "stop" +} +``` + +Ping/pong frames are supported: + +```json +{"type": "ping"} +{"type": "pong"} +``` + +Unsupported frame types return an error frame and keep the connection open: + +```json +{"type": "error", "error": "Unsupported websocket frame type: example"} +``` + +## Identity And Session Mapping + +The adapter builds a `ChannelIdentity` from the connect and message frames: + +- `channel_id`: path/config channel id, such as `terminal-dev` +- `kind`: `terminal` +- `account_id`: channel config account id, such as `local` +- `peer_id`: terminal `peer_id` +- `peer_type`: `terminal` +- `message_id`: message frame `message_id` +- `thread_id`: optional message or connect frame field +- `user_id`: optional message or connect frame field + +The session id stays aligned with channel runtime v1: + +```text +::[:] +``` + +For the first terminal rollout, a terminal connection is treated as one active peer. A reconnect with the same `peer_id` reuses the same session id. + +## Delivery Semantics + +Inbound messages are accepted through `ChannelRuntime.accept_inbound()`. + +If dedupe sees a duplicate message id: + +- return an ack with `duplicate: true` +- include cached `reply` when the prior run is done +- include `pending: true` when the prior run is still processing +- do not publish a second inbound message + +Outbound delivery is connection-bound. `TerminalWebSocketAdapter.send()` looks up the active connection for the outbound session or peer. If found, it sends the final assistant message. If no connection is available, it marks the outbound message as unclaimed so runtime records `outbound_unclaimed`. + +No retry queue is required in this phase. + +## Runtime Status And Events + +`/api/status` and `/api/channels` include terminal channels with: + +- `channel_id` +- `kind` +- `mode` +- `display_name` +- `enabled` +- `state` +- `account_id` +- `last_event_at` +- `websocket_url` +- `capabilities`, including `receive_text`, `send_text`, and `persistent_connection` +- `connected_peers` + +Channel events should record: + +- `adapter_started` +- `terminal_connected` +- `terminal_disconnected` +- `inbound_accepted` +- `inbound_duplicate` +- `direct_run_started` +- `direct_run_finished` +- `outbound_delivered` +- `outbound_unclaimed` +- `adapter_stopped` + +Do not store raw terminal payloads or full message text in the event log. Existing text preview behavior is enough. + +## Nginx And Deployment + +The existing `/api/channels/` nginx location must support WebSocket upgrade because terminal WebSockets live under that prefix. + +The location should include: + +```nginx +proxy_http_version 1.1; +proxy_set_header Upgrade $http_upgrade; +proxy_set_header Connection $connection_upgrade; +proxy_read_timeout 3600; +proxy_send_timeout 3600; +``` + +The 1800 second timeout used by synchronous webhooks can stay, but WebSocket upgrade headers are required for terminal devices. + +## Error Handling + +Before connect: + +- only `connect` and `ping` are accepted +- `message` returns an error requiring connect first + +On connect: + +- missing `peer_id` closes or rejects with an error frame +- unsupported capabilities are ignored for now as long as text is available + +On message: + +- missing `message_id` returns an error +- missing or blank `text` returns an error +- oversized text returns an error based on `max_message_chars` + +On disconnect: + +- remove the active connection +- record `terminal_disconnected` +- do not cancel an already running Beaver direct run + +If the run completes after disconnect, outbound is recorded as `outbound_unclaimed`. + +## Testing + +Add focused backend tests: + +- WebSocket connect returns `connected` with stable session id. +- Message frame publishes through runtime and returns ack plus assistant message. +- Duplicate message id does not publish a second inbound and returns duplicate status. +- Disconnect before outbound records `outbound_unclaimed`. +- Unknown frame type returns an error and keeps the connection alive. +- Channel status exposes `websocket_url` and connected peer count. +- Config loader accepts `kind=terminal`, `mode=websocket` through existing channel config. + +Run the existing backend unit suite and frontend type/test checks after implementation. + +## Acceptance Criteria + +The first-stage acceptance is complete when a small terminal can: + +1. Connect to `/api/channels/terminal-dev/ws`. +2. Send a `connect` frame with a stable `peer_id`. +3. Send a text `message` frame. +4. Receive an ack. +5. Receive the final assistant text response from Beaver. +6. Reconnect with the same `peer_id` and keep the same Beaver session id. +7. Show connection and message events in Beaver channel status/events. + +This validates the Beaver-to-terminal path through the new channel runtime without introducing AuthZ, multimodal payloads, or Hermes LiveKit LLM work. From 834d4e1e2f06f5f21cecb3414ed68dea34faee98 Mon Sep 17 00:00:00 2001 From: steven_li Date: Tue, 2 Jun 2026 15:17:46 +0800 Subject: [PATCH 02/11] docs: add channel connector pairing design --- ...2-channel-connectors-and-pairing-design.md | 384 ++++++++++++++++++ 1 file changed, 384 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-02-channel-connectors-and-pairing-design.md diff --git a/docs/superpowers/specs/2026-06-02-channel-connectors-and-pairing-design.md b/docs/superpowers/specs/2026-06-02-channel-connectors-and-pairing-design.md new file mode 100644 index 0000000..5bcef62 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-channel-connectors-and-pairing-design.md @@ -0,0 +1,384 @@ +# Channel Connectors And Pairing Design + +Date: 2026-06-02 + +## Goal + +Add a first-class connection layer above Beaver's channel runtime so users can connect messaging platforms through plugin, QR, OAuth, token, or app-credential flows instead of editing static channel JSON by hand. + +This design reframes platform channels as two cooperating layers: + +```text +ChannelConnector +-> install / auth / QR / OAuth / credential validation / login state +-> ChannelConnectionStore +-> ChannelRuntime +-> ChannelAdapter +-> 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. + +## 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. +- 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. + +So Beaver needs a connection lifecycle layer. Adapters should not be responsible for prompting the user, installing packages, storing long-lived credentials, or deciding whether an unknown device is allowed to bind. + +## Non-Goals + +- Do not replace `ChannelRuntime`, `MessageBus`, `ChannelManager`, or `AgentService`. +- Do not make every connector a Node sidecar. Node sidecars are allowed when the official or practical SDK path requires them. +- Do not implement every channel in this phase. +- Do not build a plugin marketplace in this phase. +- Do not store platform secrets in plain channel config when a credential store is available. +- Do not let external connector code call `AgentService` directly. + +## Core Terms + +`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. + +`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. + +## Data Model + +Add a durable connection store under the backend workspace: + +```python +@dataclass +class ChannelConnection: + connection_id: str + owner_user_id: str | None + channel_id: str + kind: str + mode: str + display_name: str + account_id: str + status: str + auth_type: str + credentials_ref: str | None + connector_ref: str | None + pairing_session_id: str | None + runtime_config: dict[str, Any] + capabilities: list[str] + created_at: str + updated_at: str + last_seen_at: str | None + last_error: str | None +``` + +`status` values: + +- `draft`: setup has started but no credentials are usable. +- `pairing`: waiting for QR scan, OAuth callback, device approval, or token validation. +- `connected`: credentials are valid and the runtime channel can start. +- `running`: the runtime adapter or external connector is active. +- `degraded`: partially working, for example inbound works but media upload failed. +- `error`: connection cannot start or authenticate. +- `revoked`: user or platform revoked the connection. + +Credential material should live behind `credentials_ref`, not inline in `ChannelConnection`. For the first local implementation, the reference may point to an encrypted file or a restricted JSON store. The interface should still look like a credential vault so AuthZ or a real secret backend can replace it later. + +## Connector Contract + +Every connector implements a setup contract: + +```python +class ChannelConnector(Protocol): + kind: str + + async def start_pairing(request: StartPairingRequest) -> PairingSession + async def complete_pairing(event: PairingEvent) -> ChannelConnection + async def validate(connection_id: str) -> ValidationResult + async def materialize_runtime(connection_id: str) -> ChannelRuntimeSpec + async def revoke(connection_id: str) -> None +``` + +`materialize_runtime()` returns the adapter-ready config: + +```python +@dataclass +class ChannelRuntimeSpec: + channel_id: str + kind: str + mode: str + account_id: str + display_name: str + config: dict[str, Any] + secrets_ref: str | None + external_endpoint: str | None +``` + +The runtime may still internally use `ChannelConfig`, but the source of truth becomes `ChannelConnectionStore`, not only static `BeaverConfig.channels`. + +## Control APIs + +Add backend APIs for the connection UI: + +```text +GET /api/channel-connectors +GET /api/channel-connections +POST /api/channel-connections +GET /api/channel-connections/{connection_id} +POST /api/channel-connections/{connection_id}/pairing/start +POST /api/channel-connections/{connection_id}/pairing/complete +POST /api/channel-connections/{connection_id}/validate +POST /api/channel-connections/{connection_id}/start +POST /api/channel-connections/{connection_id}/stop +POST /api/channel-connections/{connection_id}/revoke +GET /api/channel-connections/{connection_id}/events +``` + +The existing `/api/channels` status endpoint can keep reporting runtime adapter status, but the UI should prefer `/api/channel-connections` for setup state. + +## UI Flow + +The status page becomes a channel connection page: + +```text +Add Channel +-> choose platform +-> connector-specific setup form +-> QR/OAuth/token/app credential validation +-> connection status +-> start runtime channel +-> test message or platform health check +``` + +The UI must distinguish: + +- setup state: pairing, credential validation, revoked. +- runtime state: adapter running, disconnected, outbound failed. +- platform state: QR expired, app not installed, permission missing, token invalid. + +This avoids the current problem where all failures collapse into adapter startup errors. + +## External Connector Process + +Some channels should run through an external process: + +```text +ExternalConnectorProcess +-> Beaver connector control API +-> local Unix/TCP/WebSocket bridge +-> ChannelRuntime external adapter +``` + +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: + +```text +scope: channel:pair +kind: weixin +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. + +## Per-Channel Assessment + +### Feishu / Lark + +Feishu/Lark should be a first-class connector, not only a static adapter. + +Recommended first implementation: + +- connector kind: `feishu` +- setup fields: domain, app id, app secret, connection mode. +- default mode: WebSocket long connection. +- optional mode: webhook. +- runtime adapter: may be Python if coverage is sufficient, or an external Node connector when using official Channel SDK behavior. + +Required setup checks: + +- app credentials are present. +- bot/event permissions are configured. +- event subscription mode is valid. +- bot identity can be resolved. +- a test direct message or event subscription health check can run when available. + +The connector should expose both "manual app credential setup" and future "install from app template" paths. The manual path is enough for the first Beaver release. + +### Weixin + +Weixin should use an external connector process. + +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. + +Required setup checks: + +- local connector installed. +- 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. + +Group delivery remains best-effort. The connector must surface group capability separately from direct message capability. + +### Telegram + +Telegram can be implemented as an internal connector plus internal adapter. + +Recommended first implementation: + +- setup mode: bot token entry. +- validation: call Telegram `getMe`. +- runtime mode: polling by default, webhook optional. +- no external process required. + +The UI still treats it as a connector so users can add, validate, revoke, and restart it without editing JSON. + +### QQBot + +QQBot should start as an internal connector with official gateway credentials. + +Recommended first implementation: + +- setup fields: app id, client secret, intents or permission hints. +- runtime mode: WebSocket gateway. +- validation: token exchange or gateway auth dry run when available. + +If SDK/runtime behavior later becomes easier outside Python, this connector can move to an external process without changing the runtime message contract. + +### Terminal + +Terminal should move from raw `peer_id` to pairing. + +Recommended first implementation: + +- UI creates a terminal pairing session. +- Beaver displays a command or QR/setup code. +- device connects with one-time pairing token. +- Beaver binds a stable device identity to a `ChannelConnection`. +- subsequent WebSocket `connect` frames authenticate as the bound device. + +The message protocol can keep `connect`, `connected`, `message`, `ack`, and assistant `message`, but production connections must include an authenticated device token. + +## Message Flow After Pairing + +Once a connection is paired, the message path stays unchanged: + +```text +platform or device +-> connector transport +-> ChannelAdapter +-> ChannelRuntime.accept_inbound() +-> MessageBus.inbound +-> AgentService.handle_inbound_message() +-> MessageBus.outbound +-> ChannelManager.dispatch_outbound() +-> ChannelAdapter.send() +-> connector transport +-> platform or device +``` + +This is intentionally conservative. Pairing changes how a channel becomes trusted and running; it does not change the agent loop. + +## Access Control + +Connection setup requires a Beaver user or backend owner identity. The connector layer decides who may create, view, revoke, or start a connection. + +Inbound platform messages still use adapter-level policy: + +- `open`: accept platform scope. +- `allowlist`: accept only known users/groups. +- `disabled`: ignore that scope. + +The important change is that allowlists belong to the connection settings, not ad hoc adapter config only. + +## Error Handling + +Pairing errors: + +- expired pairing token. +- QR not scanned before timeout. +- OAuth callback state mismatch. +- platform permission missing. +- credentials validation failed. + +Runtime errors: + +- adapter startup failed. +- connector process unavailable. +- heartbeat missed. +- inbound normalization failed. +- outbound delivery failed. + +Each event should be recorded against `connection_id` and, when available, `channel_id` and `session_id`. + +## Security Requirements + +- Pairing tokens are short-lived, one-time, and scoped to one connector kind. +- QR codes never embed permanent backend credentials. +- External connector processes do not receive broad backend admin tokens. +- Revoking a connection invalidates connector tokens and stops the runtime channel. +- Stored platform credentials are referenced by `credentials_ref`. +- Event logs must not include raw secrets, tokens, QR payloads, or full platform credential responses. + +## Relationship To Existing Channel Specs + +The terminal WebSocket spec remains valid as a development transport spec, but production terminal setup must add pairing. + +The chat platform adapter spec remains valid as a runtime adapter spec, but these statements should be revised before implementation: + +- "Do not introduce a Node sidecar as the default channel architecture" should become "Use internal adapters by default, but allow external connector processes where platform SDK or login state requires them." +- "Pairing is out of scope for this phase" should become "Pairing is owned by the connector layer; adapters assume a materialized connection." +- Static `BeaverConfig.channels` should become a development override and backward-compatible import path, not the only source of runtime channels. + +## Rollout + +Implement in this order: + +1. `ChannelConnectionStore`, connector registry, and connection status APIs. +2. Telegram connector as the simplest token-based setup path. +3. Terminal pairing to remove raw unauthenticated `peer_id` usage. +4. Feishu/Lark connector with WebSocket long-connection mode and credential validation. +5. Weixin external connector bridge with QR pairing. +6. QQBot connector after the common credential and gateway patterns are stable. + +This order proves the common connector lifecycle with a low-risk token channel before adding QR and external process complexity. + +## Testing + +Add unit tests for: + +- connection store create/update/revoke. +- pairing token expiry and one-time use. +- connector registry dispatch by kind. +- materializing runtime specs from connections. +- secret redaction in events. +- adapter runtime still receiving normalized `InboundMessage`. + +Add integration-style tests with fake connectors for: + +- successful token setup. +- QR expired and QR completed. +- external connector heartbeat loss. +- revoke stops runtime dispatch. + +Live platform tests remain manual or gated behind explicit environment variables. + +## Acceptance Criteria + +- A user can add a channel connection without editing backend JSON. +- Beaver can show setup state separately from runtime adapter state. +- Telegram can validate a bot token and materialize a runtime channel. +- Terminal can bind through a one-time pairing flow. +- Feishu/Lark design allows official SDK or Node connector use when needed. +- Weixin design requires an external connector and QR login state. +- Existing channel runtime message flow remains bus-first and adapter-mediated. From d74a1c9c1223d966266d7279083def350d52f4bf Mon Sep 17 00:00:00 2001 From: steven_li Date: Tue, 2 Jun 2026 15:37:36 +0800 Subject: [PATCH 03/11] docs: add channel connectors foundation plan --- ...026-06-02-channel-connectors-foundation.md | 1600 +++++++++++++++++ 1 file changed, 1600 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-channel-connectors-foundation.md diff --git a/docs/superpowers/plans/2026-06-02-channel-connectors-foundation.md b/docs/superpowers/plans/2026-06-02-channel-connectors-foundation.md new file mode 100644 index 0000000..808cb2e --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-channel-connectors-foundation.md @@ -0,0 +1,1600 @@ +# Channel Connectors Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the first connector slice: durable channel connections, pairing/credential primitives, connector registry, Telegram token connector, and backend APIs that materialize a runtime channel without manual JSON editing. + +**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. + +**Tech Stack:** Python dataclasses, FastAPI, Pydantic v2, local JSON stores, pytest, existing Beaver channel runtime. + +--- + +## Scope + +This plan implements phase 1 of `docs/superpowers/specs/2026-06-02-channel-connectors-and-pairing-design.md`. + +Included: + +- `ChannelConnection` data model and persistent JSON store. +- Restricted local credential store with secret redaction. +- One-time pairing token store, used now by tests and future terminal/QR connectors. +- Connector protocol and registry. +- Telegram connector with fake-client test hooks. +- Connection control APIs. +- App startup materialization from connections into `ChannelRuntime`. + +Excluded from this plan: + +- Terminal authenticated pairing. +- Feishu/Lark official SDK integration. +- Weixin external connector process. +- QQBot connector. +- Frontend connection wizard. +- Hot starting/stopping adapters without backend restart. + +## File Structure + +- Create `app-instance/backend/beaver/interfaces/channels/connections/__init__.py` + - Exports connection models, stores, connector registry, and Telegram connector. +- Create `app-instance/backend/beaver/interfaces/channels/connections/models.py` + - Dataclasses and constants for `ChannelConnection`, `PairingSession`, `ChannelRuntimeSpec`, `ValidationResult`. +- Create `app-instance/backend/beaver/interfaces/channels/connections/store.py` + - JSON-backed `ChannelConnectionStore`, `CredentialStore`, and `PairingTokenStore`. +- Create `app-instance/backend/beaver/interfaces/channels/connections/connectors.py` + - `ChannelConnector` protocol and `ChannelConnectorRegistry`. +- Create `app-instance/backend/beaver/interfaces/channels/connections/telegram.py` + - Telegram token connector that validates via injected client factory and materializes a runtime spec. +- Modify `app-instance/backend/beaver/interfaces/web/schemas/chat.py` + - Add Pydantic request/response models for connection APIs. +- Modify `app-instance/backend/beaver/interfaces/web/schemas/__init__.py` + - Export the new schemas. +- Modify `app-instance/backend/beaver/interfaces/web/app.py` + - Instantiate connection stores/registry, expose `/api/channel-connectors` and `/api/channel-connections` APIs, and merge materialized connection configs into runtime startup. +- Create `app-instance/backend/tests/unit/test_channel_connection_store.py` + - Store, credential redaction, and pairing token tests. +- Create `app-instance/backend/tests/unit/test_channel_connector_registry.py` + - Registry dispatch and runtime materialization tests. +- Create `app-instance/backend/tests/unit/test_telegram_channel_connector.py` + - Telegram validation/materialization tests with fake client. +- Create `app-instance/backend/tests/unit/test_channel_connection_api.py` + - FastAPI endpoint tests with fake service/app context. + +--- + +### Task 1: Connection Models And Store + +**Files:** +- Create: `app-instance/backend/beaver/interfaces/channels/connections/__init__.py` +- Create: `app-instance/backend/beaver/interfaces/channels/connections/models.py` +- Create: `app-instance/backend/beaver/interfaces/channels/connections/store.py` +- Test: `app-instance/backend/tests/unit/test_channel_connection_store.py` + +- [ ] **Step 1: Write failing store tests** + +Create `app-instance/backend/tests/unit/test_channel_connection_store.py`: + +```python +from __future__ import annotations + +from datetime import datetime, timezone + +from beaver.interfaces.channels.connections import ( + ChannelConnection, + ChannelConnectionStore, + CredentialStore, + PairingTokenStore, +) + + +def test_channel_connection_store_creates_updates_lists_and_revokes(tmp_path) -> None: + store = ChannelConnectionStore(tmp_path / "connections.json") + + created = store.create( + kind="telegram", + mode="polling", + display_name="Telegram Main", + account_id="telegram:bot-main", + owner_user_id="user-1", + auth_type="token", + runtime_config={"max_message_chars": 4096}, + capabilities=["receive_text", "send_text"], + ) + updated = store.update_status(created.connection_id, status="connected", last_error=None) + revoked = store.revoke(created.connection_id) + + assert created.connection_id + assert created.channel_id.startswith("telegram-") + assert created.status == "draft" + assert updated.status == "connected" + assert revoked.status == "revoked" + assert store.get(created.connection_id).status == "revoked" + assert [item.connection_id for item in store.list()] == [created.connection_id] + + +def test_credential_store_saves_values_by_reference_and_redacts_views(tmp_path) -> None: + store = CredentialStore(tmp_path / "credentials.json") + + ref = store.put(kind="telegram", values={"botToken": "secret-token", "empty": ""}) + + assert ref.startswith("cred_") + assert store.get(ref) == {"botToken": "secret-token"} + assert store.redacted(ref) == {"botToken": "***"} + + +def test_pairing_token_store_uses_one_time_expiring_tokens(tmp_path) -> None: + store = PairingTokenStore(tmp_path / "pairing.json") + + session = store.create(kind="terminal", ttl_seconds=60, scope="channel:pair") + consumed = store.consume(session.token, expected_kind="terminal") + reused = store.consume(session.token, expected_kind="terminal") + + assert session.status == "pending" + assert consumed is not None + assert consumed.status == "consumed" + assert reused is None + + +def test_pairing_token_store_rejects_expired_tokens(tmp_path) -> None: + store = PairingTokenStore(tmp_path / "pairing.json") + + session = store.create(kind="weixin", ttl_seconds=-1, scope="channel:pair") + + assert store.consume(session.token, expected_kind="weixin") is None +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_channel_connection_store.py -q +``` + +Expected: fail during import with `ModuleNotFoundError: No module named 'beaver.interfaces.channels.connections'`. + +- [ ] **Step 3: Implement connection dataclasses** + +Create `app-instance/backend/beaver/interfaces/channels/connections/models.py`: + +```python +"""Channel connection setup models.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from typing import Any + + +CONNECTION_STATUSES = {"draft", "pairing", "connected", "running", "degraded", "error", "revoked"} + + +def iso_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +@dataclass(slots=True) +class ChannelConnection: + connection_id: str + owner_user_id: str | None + channel_id: str + kind: str + mode: str + display_name: str + account_id: str + status: str + auth_type: str + credentials_ref: str | None = None + connector_ref: str | None = None + pairing_session_id: str | None = None + runtime_config: dict[str, Any] = field(default_factory=dict) + capabilities: list[str] = field(default_factory=list) + created_at: str = field(default_factory=iso_now) + updated_at: str = field(default_factory=iso_now) + last_seen_at: str | None = None + last_error: str | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ChannelConnection": + return cls( + connection_id=str(data.get("connection_id") or ""), + owner_user_id=_optional_string(data.get("owner_user_id")), + channel_id=str(data.get("channel_id") or ""), + kind=str(data.get("kind") or ""), + mode=str(data.get("mode") or ""), + display_name=str(data.get("display_name") or ""), + account_id=str(data.get("account_id") or ""), + status=str(data.get("status") or "draft"), + auth_type=str(data.get("auth_type") or ""), + credentials_ref=_optional_string(data.get("credentials_ref")), + connector_ref=_optional_string(data.get("connector_ref")), + pairing_session_id=_optional_string(data.get("pairing_session_id")), + runtime_config=dict(data.get("runtime_config") or {}), + capabilities=[str(item) for item in data.get("capabilities") or []], + created_at=str(data.get("created_at") or iso_now()), + updated_at=str(data.get("updated_at") or iso_now()), + last_seen_at=_optional_string(data.get("last_seen_at")), + last_error=_optional_string(data.get("last_error")), + ) + + +@dataclass(slots=True) +class PairingSession: + pairing_session_id: str + kind: str + scope: str + token: str + status: str + expires_at_ms: int + created_at_ms: int + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "PairingSession": + return cls( + pairing_session_id=str(data.get("pairing_session_id") or ""), + kind=str(data.get("kind") or ""), + scope=str(data.get("scope") or ""), + token=str(data.get("token") or ""), + status=str(data.get("status") or "pending"), + expires_at_ms=int(data.get("expires_at_ms") or 0), + created_at_ms=int(data.get("created_at_ms") or 0), + ) + + +@dataclass(slots=True) +class ChannelRuntimeSpec: + channel_id: str + kind: str + mode: str + account_id: str + display_name: str + config: dict[str, Any] = field(default_factory=dict) + secrets_ref: str | None = None + external_endpoint: str | None = None + + +@dataclass(slots=True) +class ValidationResult: + ok: bool + status: str + account_id: str | None = None + display_name: str | None = None + error: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +def _optional_string(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None +``` + +- [ ] **Step 4: Implement JSON stores** + +Create `app-instance/backend/beaver/interfaces/channels/connections/store.py`: + +```python +"""Persistent channel connection stores.""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +from threading import Lock +from typing import Any +from uuid import uuid4 + +from .models import CONNECTION_STATUSES, ChannelConnection, PairingSession, iso_now + + +class ChannelConnectionStore: + def __init__(self, path: Path) -> None: + self.path = Path(path) + self._lock = Lock() + + def create( + self, + *, + kind: str, + mode: str, + display_name: str, + account_id: str, + owner_user_id: str | None, + auth_type: str, + runtime_config: dict[str, Any] | None = None, + capabilities: list[str] | None = None, + credentials_ref: str | None = None, + ) -> ChannelConnection: + with self._lock: + data = self._load() + connection_id = f"conn_{uuid4().hex}" + channel_id = f"{_slug(kind)}-{uuid4().hex[:8]}" + now = iso_now() + connection = ChannelConnection( + connection_id=connection_id, + owner_user_id=owner_user_id, + channel_id=channel_id, + kind=kind, + mode=mode, + display_name=display_name or channel_id, + account_id=account_id, + status="draft", + auth_type=auth_type, + credentials_ref=credentials_ref, + runtime_config=runtime_config or {}, + capabilities=capabilities or [], + created_at=now, + updated_at=now, + ) + data["connections"][connection_id] = connection.to_dict() + self._save(data) + return connection + + def get(self, connection_id: str) -> ChannelConnection: + data = self._load() + raw = data["connections"].get(connection_id) + if not isinstance(raw, dict): + raise KeyError(connection_id) + return ChannelConnection.from_dict(raw) + + def list(self) -> list[ChannelConnection]: + data = self._load() + return [ChannelConnection.from_dict(item) for item in data["connections"].values() if isinstance(item, dict)] + + def update(self, connection: ChannelConnection) -> ChannelConnection: + with self._lock: + data = self._load() + if connection.connection_id not in data["connections"]: + raise KeyError(connection.connection_id) + connection.updated_at = iso_now() + data["connections"][connection.connection_id] = connection.to_dict() + self._save(data) + return connection + + def update_status(self, connection_id: str, *, status: str, last_error: str | None) -> ChannelConnection: + if status not in CONNECTION_STATUSES: + raise ValueError(f"Unsupported connection status: {status}") + connection = self.get(connection_id) + connection.status = status + connection.last_error = last_error + if status in {"connected", "running"}: + connection.last_seen_at = iso_now() + return self.update(connection) + + def revoke(self, connection_id: str) -> ChannelConnection: + return self.update_status(connection_id, status="revoked", last_error=None) + + def _load(self) -> dict[str, Any]: + if not self.path.exists(): + return {"connections": {}} + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"connections": {}} + if not isinstance(data, dict) or not isinstance(data.get("connections"), dict): + return {"connections": {}} + return data + + def _save(self, data: dict[str, Any]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = self.path.with_name(f"{self.path.name}.tmp") + tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + tmp_path.replace(self.path) + + +class CredentialStore: + def __init__(self, path: Path) -> None: + self.path = Path(path) + self._lock = Lock() + + def put(self, *, kind: str, values: dict[str, Any]) -> str: + cleaned = {str(key): str(value) for key, value in values.items() if str(key).strip() and str(value).strip()} + ref = f"cred_{uuid4().hex}" + with self._lock: + data = self._load() + data["credentials"][ref] = {"kind": kind, "values": cleaned, "created_at": iso_now()} + self._save(data) + return ref + + def get(self, ref: str) -> dict[str, str]: + data = self._load() + item = data["credentials"].get(ref) + if not isinstance(item, dict): + raise KeyError(ref) + values = item.get("values") + if not isinstance(values, dict): + return {} + return {str(key): str(value) for key, value in values.items()} + + def redacted(self, ref: str | None) -> dict[str, str]: + if not ref: + return {} + try: + values = self.get(ref) + except KeyError: + return {} + return {key: "***" for key in values} + + def _load(self) -> dict[str, Any]: + if not self.path.exists(): + return {"credentials": {}} + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"credentials": {}} + if not isinstance(data, dict) or not isinstance(data.get("credentials"), dict): + return {"credentials": {}} + return data + + def _save(self, data: dict[str, Any]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = self.path.with_name(f"{self.path.name}.tmp") + tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + tmp_path.replace(self.path) + + +class PairingTokenStore: + def __init__(self, path: Path) -> None: + self.path = Path(path) + self._lock = Lock() + + def create(self, *, kind: str, ttl_seconds: int, scope: str) -> PairingSession: + now_ms = _now_ms() + session = PairingSession( + pairing_session_id=f"pair_{uuid4().hex}", + kind=kind, + scope=scope, + token=f"pair_{uuid4().hex}", + status="pending", + expires_at_ms=now_ms + int(ttl_seconds * 1000), + created_at_ms=now_ms, + ) + with self._lock: + data = self._load() + data["sessions"][session.pairing_session_id] = session.to_dict() + self._save(data) + return session + + def consume(self, token: str, *, expected_kind: str) -> PairingSession | None: + with self._lock: + data = self._load() + for key, raw in data["sessions"].items(): + session = PairingSession.from_dict(raw) + if session.token != token or session.kind != expected_kind: + continue + if session.status != "pending" or session.expires_at_ms <= _now_ms(): + return None + session.status = "consumed" + data["sessions"][key] = session.to_dict() + self._save(data) + return session + return None + + def _load(self) -> dict[str, Any]: + if not self.path.exists(): + return {"sessions": {}} + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"sessions": {}} + if not isinstance(data, dict) or not isinstance(data.get("sessions"), dict): + return {"sessions": {}} + return data + + def _save(self, data: dict[str, Any]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = self.path.with_name(f"{self.path.name}.tmp") + tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + tmp_path.replace(self.path) + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _slug(value: str) -> str: + text = "".join(char if char.isalnum() else "-" for char in str(value).strip().lower()) + return "-".join(part for part in text.split("-") if part) or "channel" +``` + +- [ ] **Step 5: Export the connection package** + +Create `app-instance/backend/beaver/interfaces/channels/connections/__init__.py`: + +```python +"""Channel connection setup layer.""" + +from .models import ChannelConnection, ChannelRuntimeSpec, PairingSession, ValidationResult +from .store import ChannelConnectionStore, CredentialStore, PairingTokenStore + +__all__ = [ + "ChannelConnection", + "ChannelRuntimeSpec", + "PairingSession", + "ValidationResult", + "ChannelConnectionStore", + "CredentialStore", + "PairingTokenStore", +] +``` + +- [ ] **Step 6: Run store tests to verify they pass** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_channel_connection_store.py -q +``` + +Expected: `4 passed`. + +- [ ] **Step 7: Commit Task 1** + +```bash +git add app-instance/backend/beaver/interfaces/channels/connections app-instance/backend/tests/unit/test_channel_connection_store.py +git commit -m "feat: add channel connection store" +``` + +--- + +### Task 2: Connector Registry + +**Files:** +- Create: `app-instance/backend/beaver/interfaces/channels/connections/connectors.py` +- Modify: `app-instance/backend/beaver/interfaces/channels/connections/__init__.py` +- Test: `app-instance/backend/tests/unit/test_channel_connector_registry.py` + +- [ ] **Step 1: Write failing registry tests** + +Create `app-instance/backend/tests/unit/test_channel_connector_registry.py`: + +```python +from __future__ import annotations + +import asyncio + +from beaver.interfaces.channels.connections import ( + ChannelConnectionStore, + ChannelConnectorRegistry, + ChannelRuntimeSpec, + CredentialStore, + ValidationResult, +) + + +class FakeConnector: + kind = "fake" + + def __init__(self) -> None: + self.validated: list[str] = [] + + async def validate(self, connection_id: str) -> ValidationResult: + self.validated.append(connection_id) + return ValidationResult(ok=True, status="connected", account_id="fake-account", display_name="Fake") + + async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec: + return ChannelRuntimeSpec( + channel_id="fake-channel", + kind="fake", + mode="webhook", + account_id="fake-account", + display_name="Fake", + config={"enabled": True}, + ) + + async def revoke(self, connection_id: str) -> None: + return None + + +def test_connector_registry_dispatches_by_kind(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + connector = FakeConnector() + registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store) + registry.register(connector) + + connection = connection_store.create( + kind="fake", + mode="webhook", + display_name="Fake", + account_id="fake-account", + owner_user_id=None, + auth_type="token", + ) + result = await registry.validate(connection.connection_id) + spec = await registry.materialize_runtime(connection.connection_id) + + assert result.ok is True + assert connector.validated == [connection.connection_id] + assert spec.channel_id == "fake-channel" + + asyncio.run(run()) + + +def test_connector_registry_materializes_only_connected_connections(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store) + registry.register(FakeConnector()) + + draft = connection_store.create( + kind="fake", + mode="webhook", + display_name="Draft", + account_id="draft", + owner_user_id=None, + auth_type="token", + ) + connected = connection_store.create( + kind="fake", + mode="webhook", + display_name="Connected", + account_id="connected", + owner_user_id=None, + auth_type="token", + ) + connection_store.update_status(connected.connection_id, status="connected", last_error=None) + + specs = await registry.materialize_connected_runtime_specs() + + assert [spec.channel_id for spec in specs] == ["fake-channel"] + assert connection_store.get(draft.connection_id).status == "draft" + + asyncio.run(run()) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_channel_connector_registry.py -q +``` + +Expected: fail with `ImportError: cannot import name 'ChannelConnectorRegistry'`. + +- [ ] **Step 3: Implement connector protocol and registry** + +Create `app-instance/backend/beaver/interfaces/channels/connections/connectors.py`: + +```python +"""Channel connector registry.""" + +from __future__ import annotations + +from typing import Protocol + +from .models import ChannelRuntimeSpec, ValidationResult +from .store import ChannelConnectionStore, CredentialStore + + +class ChannelConnector(Protocol): + kind: str + + async def validate(self, connection_id: str) -> ValidationResult: + ... + + async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec: + ... + + async def revoke(self, connection_id: str) -> None: + ... + + +class ChannelConnectorRegistry: + def __init__(self, *, connection_store: ChannelConnectionStore, credential_store: CredentialStore) -> None: + self.connection_store = connection_store + self.credential_store = credential_store + self._connectors: dict[str, ChannelConnector] = {} + + def register(self, connector: ChannelConnector) -> None: + kind = connector.kind.strip() + if not kind: + raise ValueError("Connector kind is required") + if kind in self._connectors: + raise ValueError(f"Connector already registered: {kind}") + self._connectors[kind] = connector + + def connectors(self) -> list[dict[str, str]]: + return [{"kind": kind} for kind in sorted(self._connectors)] + + async def validate(self, connection_id: str) -> ValidationResult: + connection = self.connection_store.get(connection_id) + connector = self._connector(connection.kind) + result = await connector.validate(connection_id) + self.connection_store.update_status( + connection_id, + status=result.status, + last_error=result.error, + ) + return result + + async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec: + connection = self.connection_store.get(connection_id) + return await self._connector(connection.kind).materialize_runtime(connection_id) + + async def materialize_connected_runtime_specs(self) -> list[ChannelRuntimeSpec]: + specs: list[ChannelRuntimeSpec] = [] + for connection in self.connection_store.list(): + if connection.status not in {"connected", "running"}: + continue + specs.append(await self._connector(connection.kind).materialize_runtime(connection.connection_id)) + return specs + + async def revoke(self, connection_id: str) -> None: + connection = self.connection_store.get(connection_id) + await self._connector(connection.kind).revoke(connection_id) + self.connection_store.revoke(connection_id) + + def _connector(self, kind: str) -> ChannelConnector: + connector = self._connectors.get(kind) + if connector is None: + raise KeyError(f"Connector not registered: {kind}") + return connector +``` + +- [ ] **Step 4: Export registry symbols** + +Modify `app-instance/backend/beaver/interfaces/channels/connections/__init__.py`: + +```python +"""Channel connection setup layer.""" + +from .connectors import ChannelConnector, ChannelConnectorRegistry +from .models import ChannelConnection, ChannelRuntimeSpec, PairingSession, ValidationResult +from .store import ChannelConnectionStore, CredentialStore, PairingTokenStore + +__all__ = [ + "ChannelConnector", + "ChannelConnectorRegistry", + "ChannelConnection", + "ChannelRuntimeSpec", + "PairingSession", + "ValidationResult", + "ChannelConnectionStore", + "CredentialStore", + "PairingTokenStore", +] +``` + +- [ ] **Step 5: Run registry tests** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_channel_connector_registry.py -q +``` + +Expected: `2 passed`. + +- [ ] **Step 6: Commit Task 2** + +```bash +git add app-instance/backend/beaver/interfaces/channels/connections app-instance/backend/tests/unit/test_channel_connector_registry.py +git commit -m "feat: add channel connector registry" +``` + +--- + +### Task 3: Telegram Connector + +**Files:** +- Create: `app-instance/backend/beaver/interfaces/channels/connections/telegram.py` +- Modify: `app-instance/backend/beaver/interfaces/channels/connections/__init__.py` +- Test: `app-instance/backend/tests/unit/test_telegram_channel_connector.py` + +- [ ] **Step 1: Write failing Telegram connector tests** + +Create `app-instance/backend/tests/unit/test_telegram_channel_connector.py`: + +```python +from __future__ import annotations + +import asyncio + +from beaver.interfaces.channels.connections import ( + ChannelConnectionStore, + CredentialStore, + TelegramConnector, +) + + +class FakeTelegramClient: + async def get_me(self): + return {"id": 12345, "username": "beaver_bot", "first_name": "Beaver"} + + +class BrokenTelegramClient: + async def get_me(self): + raise RuntimeError("invalid token") + + +def test_telegram_connector_validates_token_and_updates_connection(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"}) + connection = connection_store.create( + kind="telegram", + mode="polling", + display_name="Telegram Main", + account_id="", + owner_user_id="user-1", + auth_type="token", + credentials_ref=credentials_ref, + runtime_config={"max_message_chars": 4096}, + ) + connector = TelegramConnector( + connection_store=connection_store, + credential_store=credential_store, + client_factory=lambda token: FakeTelegramClient(), + ) + + result = await connector.validate(connection.connection_id) + updated = connection_store.get(connection.connection_id) + + assert result.ok is True + assert result.status == "connected" + assert result.account_id == "telegram:12345" + assert updated.account_id == "telegram:12345" + assert updated.display_name == "Beaver (@beaver_bot)" + assert updated.capabilities == ["receive_text", "send_text", "receive_media", "groups"] + + asyncio.run(run()) + + +def test_telegram_connector_materializes_runtime_spec(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"}) + connection = connection_store.create( + kind="telegram", + mode="polling", + display_name="Telegram Main", + account_id="telegram:12345", + owner_user_id=None, + auth_type="token", + credentials_ref=credentials_ref, + runtime_config={"max_message_chars": 4096, "require_mention_in_groups": True}, + ) + connection_store.update_status(connection.connection_id, status="connected", last_error=None) + connector = TelegramConnector( + connection_store=connection_store, + credential_store=credential_store, + client_factory=lambda token: FakeTelegramClient(), + ) + + spec = await connector.materialize_runtime(connection.connection_id) + + assert spec.channel_id == connection.channel_id + assert spec.kind == "telegram" + assert spec.mode == "polling" + assert spec.account_id == "telegram:12345" + assert spec.config["max_message_chars"] == 4096 + assert spec.config["require_mention_in_groups"] is True + assert spec.secrets_ref == credentials_ref + + asyncio.run(run()) + + +def test_telegram_connector_validation_failure_sets_error_status(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + credentials_ref = credential_store.put(kind="telegram", values={"botToken": "bad-token"}) + connection = connection_store.create( + kind="telegram", + mode="polling", + display_name="Telegram Main", + account_id="", + owner_user_id=None, + auth_type="token", + credentials_ref=credentials_ref, + ) + connector = TelegramConnector( + connection_store=connection_store, + credential_store=credential_store, + client_factory=lambda token: BrokenTelegramClient(), + ) + + result = await connector.validate(connection.connection_id) + + assert result.ok is False + assert result.status == "error" + assert "invalid token" in (result.error or "") + + asyncio.run(run()) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_telegram_channel_connector.py -q +``` + +Expected: fail with `ImportError: cannot import name 'TelegramConnector'`. + +- [ ] **Step 3: Implement TelegramConnector** + +Create `app-instance/backend/beaver/interfaces/channels/connections/telegram.py`: + +```python +"""Telegram channel connector.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from .models import ChannelRuntimeSpec, ValidationResult +from .store import ChannelConnectionStore, CredentialStore + + +class TelegramConnector: + kind = "telegram" + + def __init__( + self, + *, + connection_store: ChannelConnectionStore, + credential_store: CredentialStore, + client_factory: Callable[[str], Any] | None = None, + ) -> None: + self.connection_store = connection_store + self.credential_store = credential_store + self.client_factory = client_factory or _default_client_factory + + async def validate(self, connection_id: str) -> ValidationResult: + connection = self.connection_store.get(connection_id) + token = self._bot_token(connection.credentials_ref) + try: + client = self.client_factory(token) + raw = await client.get_me() + bot_id = _value(raw, "id") + username = _value(raw, "username") + first_name = _value(raw, "first_name") or "Telegram Bot" + account_id = f"telegram:{bot_id}" if bot_id else connection.account_id + display_name = f"{first_name} (@{username})" if username else first_name + connection.account_id = account_id + connection.display_name = display_name + connection.capabilities = ["receive_text", "send_text", "receive_media", "groups"] + self.connection_store.update(connection) + return ValidationResult( + ok=True, + status="connected", + account_id=account_id, + display_name=display_name, + metadata={"username": username} if username else {}, + ) + except Exception as exc: + return ValidationResult(ok=False, status="error", error=str(exc)) + + async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec: + connection = self.connection_store.get(connection_id) + if connection.status not in {"connected", "running"}: + raise ValueError(f"Connection is not connected: {connection.connection_id}") + return ChannelRuntimeSpec( + channel_id=connection.channel_id, + kind=connection.kind, + mode=connection.mode, + account_id=connection.account_id, + display_name=connection.display_name, + config=dict(connection.runtime_config), + secrets_ref=connection.credentials_ref, + ) + + async def revoke(self, connection_id: str) -> None: + self.connection_store.revoke(connection_id) + + def _bot_token(self, credentials_ref: str | None) -> str: + if not credentials_ref: + raise ValueError("Telegram credentials are missing") + token = self.credential_store.get(credentials_ref).get("botToken") + if not token: + raise ValueError("botToken is required") + return token + + +def _value(raw: Any, key: str) -> str: + if isinstance(raw, dict): + value = raw.get(key) + else: + value = getattr(raw, key, None) + return str(value).strip() if value is not None else "" + + +def _default_client_factory(token: str) -> Any: + try: + from telegram import Bot + except ImportError as exc: # pragma: no cover - optional live dependency + raise RuntimeError("Install beaver-backend[telegram] to validate Telegram connections") from exc + return Bot(token=token) +``` + +- [ ] **Step 4: Export TelegramConnector** + +Modify `app-instance/backend/beaver/interfaces/channels/connections/__init__.py`: + +```python +"""Channel connection setup layer.""" + +from .connectors import ChannelConnector, ChannelConnectorRegistry +from .models import ChannelConnection, ChannelRuntimeSpec, PairingSession, ValidationResult +from .store import ChannelConnectionStore, CredentialStore, PairingTokenStore +from .telegram import TelegramConnector + +__all__ = [ + "ChannelConnector", + "ChannelConnectorRegistry", + "ChannelConnection", + "ChannelRuntimeSpec", + "PairingSession", + "ValidationResult", + "ChannelConnectionStore", + "CredentialStore", + "PairingTokenStore", + "TelegramConnector", +] +``` + +- [ ] **Step 5: Run Telegram connector tests** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_telegram_channel_connector.py -q +``` + +Expected: `3 passed`. + +- [ ] **Step 6: Commit Task 3** + +```bash +git add app-instance/backend/beaver/interfaces/channels/connections app-instance/backend/tests/unit/test_telegram_channel_connector.py +git commit -m "feat: add telegram channel connector" +``` + +--- + +### Task 4: Runtime Materialization From Connections + +**Files:** +- Modify: `app-instance/backend/beaver/interfaces/web/app.py` +- Test: `app-instance/backend/tests/unit/test_channel_connector_registry.py` + +- [ ] **Step 1: Extend registry tests for ChannelConfig materialization** + +Append to `app-instance/backend/tests/unit/test_channel_connector_registry.py`: + +```python +from beaver.foundation.config.schema import ChannelConfig + + +def test_connector_registry_materializes_channel_configs_with_credentials(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"}) + connection = connection_store.create( + kind="fake", + mode="webhook", + display_name="Connected", + account_id="connected", + owner_user_id=None, + auth_type="token", + credentials_ref=credentials_ref, + ) + connection_store.update_status(connection.connection_id, status="connected", last_error=None) + + class CredentialAwareConnector(FakeConnector): + async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec: + stored = connection_store.get(connection_id) + return ChannelRuntimeSpec( + channel_id="fake-channel", + kind="fake", + mode="webhook", + account_id="fake-account", + display_name="Fake", + config={"enabled": True}, + secrets_ref=stored.credentials_ref, + ) + + registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store) + registry.register(CredentialAwareConnector()) + + configs = await registry.materialize_channel_configs() + + assert isinstance(configs["fake-channel"], ChannelConfig) + assert configs["fake-channel"].enabled is True + assert configs["fake-channel"].secrets == {"botToken": "token-1"} + + asyncio.run(run()) +``` + +- [ ] **Step 2: Run registry tests to verify failure** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_channel_connector_registry.py::test_connector_registry_materializes_channel_configs_with_credentials -q +``` + +Expected: fail with `AttributeError: 'ChannelConnectorRegistry' object has no attribute 'materialize_channel_configs'`. + +- [ ] **Step 3: Implement channel config materialization** + +Modify `app-instance/backend/beaver/interfaces/channels/connections/connectors.py`: + +```python +from beaver.foundation.config.schema import ChannelConfig +``` + +Add this method to `ChannelConnectorRegistry`: + +```python + async def materialize_channel_configs(self) -> dict[str, ChannelConfig]: + channels: dict[str, ChannelConfig] = {} + for spec in await self.materialize_connected_runtime_specs(): + secrets = self.credential_store.get(spec.secrets_ref) if spec.secrets_ref else {} + channels[spec.channel_id] = ChannelConfig( + enabled=True, + kind=spec.kind, + mode=spec.mode, + account_id=spec.account_id, + display_name=spec.display_name, + config=dict(spec.config), + secrets=secrets, + ) + return channels +``` + +- [ ] **Step 4: Add app helpers for connection state paths and registry construction** + +Modify `app-instance/backend/beaver/interfaces/web/app.py` imports: + +```python +from beaver.interfaces.channels.connections import ( + ChannelConnectionStore, + ChannelConnectorRegistry, + CredentialStore, + PairingTokenStore, + TelegramConnector, +) +``` + +Add helper functions near `get_channel_runtime()`: + +```python +def _connection_state_dir(workspace: Path) -> Path: + return Path(workspace) / "state" / "channel_connections" + + +def _build_channel_connector_registry(workspace: Path) -> ChannelConnectorRegistry: + state_dir = _connection_state_dir(workspace) + connection_store = ChannelConnectionStore(state_dir / "connections.json") + credential_store = CredentialStore(state_dir / "credentials.json") + registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store) + registry.register( + TelegramConnector( + connection_store=connection_store, + credential_store=credential_store, + ) + ) + return registry +``` + +- [ ] **Step 5: Merge materialized connections into runtime startup** + +Modify the lifespan block in `app-instance/backend/beaver/interfaces/web/app.py` where `ChannelRuntime` is created: + +```python + loaded = attached_service.create_loop().boot() + connector_registry = _build_channel_connector_registry(loaded.workspace) + app.state.channel_connector_registry = connector_registry + connection_channels = await connector_registry.materialize_channel_configs() + runtime_channels = dict(loaded.config.channels) + runtime_channels.update(connection_channels) + channel_runtime = ChannelRuntime( + service=attached_service, + workspace=loaded.workspace, + channels=runtime_channels, + ) +``` + +Keep `app.state.channel_connector_registry = connector_registry` before runtime startup so API handlers can reuse the same stores. + +- [ ] **Step 6: Run registry tests** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_channel_connector_registry.py -q +``` + +Expected: all tests pass. + +- [ ] **Step 7: Commit Task 4** + +```bash +git add app-instance/backend/beaver/interfaces/channels/connections/connectors.py app-instance/backend/beaver/interfaces/web/app.py app-instance/backend/tests/unit/test_channel_connector_registry.py +git commit -m "feat: materialize channel connections into runtime config" +``` + +--- + +### Task 5: Connection Control API + +**Files:** +- Modify: `app-instance/backend/beaver/interfaces/web/schemas/chat.py` +- Modify: `app-instance/backend/beaver/interfaces/web/schemas/__init__.py` +- Modify: `app-instance/backend/beaver/interfaces/web/app.py` +- Test: `app-instance/backend/tests/unit/test_channel_connection_api.py` + +- [ ] **Step 1: Write failing API tests** + +Create `app-instance/backend/tests/unit/test_channel_connection_api.py`: + +```python +from __future__ import annotations + +from fastapi.testclient import TestClient + +from beaver.interfaces.web.app import create_app +from beaver.services.agent_service import AgentService + + +def test_channel_connection_api_creates_lists_and_revokes(tmp_path) -> None: + config_path = tmp_path / "config.json" + config_path.write_text('{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path), encoding="utf-8") + service = AgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + + try: + with TestClient(app) as client: + created = client.post( + "/api/channel-connections", + json={ + "kind": "telegram", + "mode": "polling", + "displayName": "Telegram Main", + "authType": "token", + "secrets": {"botToken": "token-1"}, + "config": {"maxMessageChars": 4096}, + }, + ) + assert created.status_code == 200 + body = created.json() + connection_id = body["connection"]["connection_id"] + assert body["connection"]["kind"] == "telegram" + assert body["connection"]["status"] == "draft" + assert body["credentials"] == {"botToken": "***"} + + listed = client.get("/api/channel-connections") + assert listed.status_code == 200 + assert listed.json()[0]["connection_id"] == connection_id + + revoked = client.post(f"/api/channel-connections/{connection_id}/revoke") + assert revoked.status_code == 200 + assert revoked.json()["connection"]["status"] == "revoked" + finally: + service.close() + + +def test_channel_connectors_api_lists_registered_connectors(tmp_path) -> None: + config_path = tmp_path / "config.json" + config_path.write_text('{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path), encoding="utf-8") + service = AgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + + try: + with TestClient(app) as client: + response = client.get("/api/channel-connectors") + finally: + service.close() + + assert response.status_code == 200 + assert response.json() == [{"kind": "telegram"}] +``` + +- [ ] **Step 2: Run API tests to verify failure** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_channel_connection_api.py -q +``` + +Expected: fail with `404 Not Found` for `/api/channel-connections`. + +- [ ] **Step 3: Add web schemas** + +Append to `app-instance/backend/beaver/interfaces/web/schemas/chat.py` after `WebChannelConfigResponse`: + +```python +class WebChannelConnectionCreateRequest(BaseModel): + """Create a channel connection from the setup UI.""" + + kind: str + mode: str + display_name: str | None = Field(default=None, alias="displayName") + owner_user_id: str | None = Field(default=None, alias="ownerUserId") + auth_type: str = Field(default="token", alias="authType") + account_id: str | None = Field(default=None, alias="accountId") + config: dict[str, Any] = Field(default_factory=dict) + secrets: dict[str, str | None] = Field(default_factory=dict) + + +class WebChannelConnectionResponse(BaseModel): + """Channel connection response with redacted credentials.""" + + connection: dict[str, Any] + credentials: dict[str, str] = Field(default_factory=dict) + + +class WebChannelValidationResponse(BaseModel): + """Connector validation response.""" + + ok: bool + status: str + account_id: str | None = None + display_name: str | None = None + error: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + connection: dict[str, Any] +``` + +- [ ] **Step 4: Export web schemas** + +Modify `app-instance/backend/beaver/interfaces/web/schemas/__init__.py` imports and `__all__` to include: + +```python + WebChannelConnectionCreateRequest, + WebChannelConnectionResponse, + WebChannelValidationResponse, +``` + +- [ ] **Step 5: Add connector registry accessors to app.py** + +Modify imports in `app-instance/backend/beaver/interfaces/web/app.py`: + +```python +from beaver.interfaces.web.schemas import ( + WebChannelConnectionCreateRequest, + WebChannelConnectionResponse, + WebChannelValidationResponse, +) +``` + +Add helper: + +```python +def get_channel_connector_registry(request: Request) -> ChannelConnectorRegistry: + registry = getattr(request.app.state, "channel_connector_registry", None) + if not isinstance(registry, ChannelConnectorRegistry): + agent_service = get_agent_service(request) + workspace = agent_service.loader.workspace + registry = _build_channel_connector_registry(workspace) + request.app.state.channel_connector_registry = registry + return registry +``` + +- [ ] **Step 6: Add connection API routes** + +Add routes near existing `/api/channels` routes in `app-instance/backend/beaver/interfaces/web/app.py`: + +```python + @app.get("/api/channel-connectors") + async def list_channel_connectors(request: Request) -> list[dict[str, str]]: + return get_channel_connector_registry(request).connectors() + + @app.get("/api/channel-connections") + async def list_channel_connections(request: Request) -> list[dict[str, Any]]: + registry = get_channel_connector_registry(request) + return [connection.to_dict() for connection in registry.connection_store.list()] + + @app.post("/api/channel-connections", response_model=WebChannelConnectionResponse) + async def create_channel_connection( + request: Request, + payload: WebChannelConnectionCreateRequest, + ) -> WebChannelConnectionResponse: + registry = get_channel_connector_registry(request) + kind = _clean_text(payload.kind) + mode = _clean_text(payload.mode) + if not kind: + raise HTTPException(status_code=400, detail="Connection kind is required") + if not mode: + raise HTTPException(status_code=400, detail="Connection mode is required") + secrets = {key: value for key, value in payload.secrets.items() if value} + credentials_ref = registry.credential_store.put(kind=kind, values=secrets) if secrets else None + connection = registry.connection_store.create( + kind=kind, + mode=mode, + display_name=_clean_text(payload.display_name) or kind, + account_id=_clean_text(payload.account_id) or "", + owner_user_id=_clean_text(payload.owner_user_id) or None, + auth_type=_clean_text(payload.auth_type) or "token", + credentials_ref=credentials_ref, + runtime_config=payload.config or {}, + ) + return WebChannelConnectionResponse( + connection=connection.to_dict(), + credentials=registry.credential_store.redacted(credentials_ref), + ) + + @app.get("/api/channel-connections/{connection_id}", response_model=WebChannelConnectionResponse) + async def get_channel_connection(connection_id: str, request: Request) -> WebChannelConnectionResponse: + registry = get_channel_connector_registry(request) + try: + connection = registry.connection_store.get(connection_id) + except KeyError: + raise HTTPException(status_code=404, detail="Channel connection not found") + return WebChannelConnectionResponse( + connection=connection.to_dict(), + credentials=registry.credential_store.redacted(connection.credentials_ref), + ) + + @app.post("/api/channel-connections/{connection_id}/validate", response_model=WebChannelValidationResponse) + async def validate_channel_connection(connection_id: str, request: Request) -> WebChannelValidationResponse: + registry = get_channel_connector_registry(request) + try: + result = await registry.validate(connection_id) + connection = registry.connection_store.get(connection_id) + except KeyError: + raise HTTPException(status_code=404, detail="Channel connection not found") + return WebChannelValidationResponse( + ok=result.ok, + status=result.status, + account_id=result.account_id, + display_name=result.display_name, + error=result.error, + metadata=result.metadata, + connection=connection.to_dict(), + ) + + @app.post("/api/channel-connections/{connection_id}/revoke", response_model=WebChannelConnectionResponse) + async def revoke_channel_connection(connection_id: str, request: Request) -> WebChannelConnectionResponse: + registry = get_channel_connector_registry(request) + try: + await registry.revoke(connection_id) + connection = registry.connection_store.get(connection_id) + except KeyError: + raise HTTPException(status_code=404, detail="Channel connection not found") + return WebChannelConnectionResponse(connection=connection.to_dict(), credentials={}) +``` + +- [ ] **Step 7: Run API tests** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_channel_connection_api.py -q +``` + +Expected: `2 passed`. + +- [ ] **Step 8: Run focused backend tests** + +Run: + +```bash +cd app-instance/backend +uv run pytest \ + tests/unit/test_channel_connection_store.py \ + tests/unit/test_channel_connector_registry.py \ + tests/unit/test_telegram_channel_connector.py \ + tests/unit/test_channel_connection_api.py \ + -q +``` + +Expected: all focused connector tests pass. + +- [ ] **Step 9: Commit Task 5** + +```bash +git add \ + app-instance/backend/beaver/interfaces/web/app.py \ + app-instance/backend/beaver/interfaces/web/schemas/chat.py \ + app-instance/backend/beaver/interfaces/web/schemas/__init__.py \ + app-instance/backend/tests/unit/test_channel_connection_api.py +git commit -m "feat: add channel connection control api" +``` + +--- + +### Task 6: Final Verification And Spec Alignment + +**Files:** +- Review: `docs/superpowers/specs/2026-06-02-channel-connectors-and-pairing-design.md` +- Review: `docs/superpowers/specs/2026-06-02-chat-platform-channel-adapters-design.md` +- Review: `docs/superpowers/specs/2026-06-01-terminal-websocket-channel-design.md` + +- [ ] **Step 1: Run connector and existing channel tests** + +Run: + +```bash +cd app-instance/backend +uv run pytest \ + tests/unit/test_channel_connection_store.py \ + tests/unit/test_channel_connector_registry.py \ + tests/unit/test_telegram_channel_connector.py \ + tests/unit/test_channel_connection_api.py \ + tests/unit/test_channel_runtime.py \ + tests/unit/test_telegram_channel_adapter.py \ + -q +``` + +Expected: all listed tests pass. + +- [ ] **Step 2: Run import tests** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_imports.py -q +``` + +Expected: all import tests pass. + +- [ ] **Step 3: Scan for leaked secret values in connector events and responses** + +Run: + +```bash +cd app-instance/backend +rg -n "token-1|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** + +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: + +```markdown +- Use internal adapters by default, but allow external connector processes where platform SDK or login state requires them. +``` + +```markdown +Pairing is owned by the connector layer. Platform adapters assume a materialized `ChannelConnection` and adapter-ready runtime config. +``` + +- [ ] **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" +``` + +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. From b25713a1412d9d8e7b12cd2ab5c0eb2ed2499247 Mon Sep 17 00:00:00 2001 From: steven_li Date: Tue, 2 Jun 2026 15:51:16 +0800 Subject: [PATCH 04/11] docs: refine channel connectors foundation plan --- ...026-06-02-channel-connectors-foundation.md | 197 +++++++++++++++--- 1 file changed, 170 insertions(+), 27 deletions(-) diff --git a/docs/superpowers/plans/2026-06-02-channel-connectors-foundation.md b/docs/superpowers/plans/2026-06-02-channel-connectors-foundation.md index 808cb2e..ffc0884 100644 --- a/docs/superpowers/plans/2026-06-02-channel-connectors-foundation.md +++ b/docs/superpowers/plans/2026-06-02-channel-connectors-foundation.md @@ -32,6 +32,7 @@ Excluded from this plan: - QQBot connector. - Frontend connection wizard. - Hot starting/stopping adapters without backend restart. +- Multi-process-safe storage. The JSON stores use `threading.Lock` plus atomic file replace for the single backend process used in phase 1. Production multi-worker deployment needs a file lock or database-backed store. ## File Structure @@ -77,10 +78,7 @@ Create `app-instance/backend/tests/unit/test_channel_connection_store.py`: ```python from __future__ import annotations -from datetime import datetime, timezone - from beaver.interfaces.channels.connections import ( - ChannelConnection, ChannelConnectionStore, CredentialStore, PairingTokenStore, @@ -578,6 +576,7 @@ class FakeConnector: def __init__(self) -> None: self.validated: list[str] = [] + self.revoked: list[str] = [] async def validate(self, connection_id: str) -> ValidationResult: self.validated.append(connection_id) @@ -594,6 +593,7 @@ class FakeConnector: ) async def revoke(self, connection_id: str) -> None: + self.revoked.append(connection_id) return None @@ -654,6 +654,32 @@ def test_connector_registry_materializes_only_connected_connections(tmp_path) -> assert connection_store.get(draft.connection_id).status == "draft" asyncio.run(run()) + + +def test_connector_registry_revoke_calls_connector_and_updates_store(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + connector = FakeConnector() + registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store) + registry.register(connector) + + connection = connection_store.create( + kind="fake", + mode="webhook", + display_name="Fake", + account_id="fake-account", + owner_user_id=None, + auth_type="token", + ) + connection_store.update_status(connection.connection_id, status="connected", last_error=None) + + await registry.revoke(connection.connection_id) + + assert connector.revoked == [connection.connection_id] + assert connection_store.get(connection.connection_id).status == "revoked" + + asyncio.run(run()) ``` - [ ] **Step 2: Run tests to verify they fail** @@ -780,7 +806,7 @@ cd app-instance/backend uv run pytest tests/unit/test_channel_connector_registry.py -q ``` -Expected: `2 passed`. +Expected: `3 passed`. - [ ] **Step 6: Commit Task 2** @@ -920,6 +946,32 @@ def test_telegram_connector_validation_failure_sets_error_status(tmp_path) -> No assert "invalid token" in (result.error or "") asyncio.run(run()) + + +def test_telegram_connector_revoke_leaves_store_status_to_registry(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + connection = connection_store.create( + kind="telegram", + mode="polling", + display_name="Telegram Main", + account_id="telegram:12345", + owner_user_id=None, + auth_type="token", + ) + connection_store.update_status(connection.connection_id, status="connected", last_error=None) + connector = TelegramConnector( + connection_store=connection_store, + credential_store=credential_store, + client_factory=lambda token: FakeTelegramClient(), + ) + + await connector.revoke(connection.connection_id) + + assert connection_store.get(connection.connection_id).status == "connected" + + asyncio.run(run()) ``` - [ ] **Step 2: Run tests to verify they fail** @@ -933,7 +985,18 @@ uv run pytest tests/unit/test_telegram_channel_connector.py -q Expected: fail with `ImportError: cannot import name 'TelegramConnector'`. -- [ ] **Step 3: Implement TelegramConnector** +- [ ] **Step 3: Verify Telegram dependency** + +Run: + +```bash +cd app-instance/backend +rg -n "python-telegram-bot" pyproject.toml uv.lock | sed -n '1,20p' +``` + +Expected output includes `python-telegram-bot>=22.0,<23.0`. The default client factory may use `from telegram import Bot`, and `Bot.get_me()` is awaitable in this dependency line. + +- [ ] **Step 4: Implement TelegramConnector** Create `app-instance/backend/beaver/interfaces/channels/connections/telegram.py`: @@ -1003,7 +1066,9 @@ class TelegramConnector: ) async def revoke(self, connection_id: str) -> None: - self.connection_store.revoke(connection_id) + # Telegram bot tokens do not have a Beaver-managed platform revoke action. + # The registry owns local connection state transitions. + return None def _bot_token(self, credentials_ref: str | None) -> str: if not credentials_ref: @@ -1030,7 +1095,7 @@ def _default_client_factory(token: str) -> Any: return Bot(token=token) ``` -- [ ] **Step 4: Export TelegramConnector** +- [ ] **Step 5: Export TelegramConnector** Modify `app-instance/backend/beaver/interfaces/channels/connections/__init__.py`: @@ -1056,7 +1121,7 @@ __all__ = [ ] ``` -- [ ] **Step 5: Run Telegram connector tests** +- [ ] **Step 6: Run Telegram connector tests** Run: @@ -1065,9 +1130,9 @@ cd app-instance/backend uv run pytest tests/unit/test_telegram_channel_connector.py -q ``` -Expected: `3 passed`. +Expected: `4 passed`. -- [ ] **Step 6: Commit Task 3** +- [ ] **Step 7: Commit Task 3** ```bash git add app-instance/backend/beaver/interfaces/channels/connections app-instance/backend/tests/unit/test_telegram_channel_connector.py @@ -1082,7 +1147,22 @@ git commit -m "feat: add telegram channel connector" - Modify: `app-instance/backend/beaver/interfaces/web/app.py` - Test: `app-instance/backend/tests/unit/test_channel_connector_registry.py` -- [ ] **Step 1: Extend registry tests for ChannelConfig materialization** +- [ ] **Step 1: Verify ChannelConfig fields** + +Run: + +```bash +cd app-instance/backend +uv run python - <<'PY' +from dataclasses import fields +from beaver.foundation.config.schema import ChannelConfig +print([field.name for field in fields(ChannelConfig)]) +PY +``` + +Expected output includes `enabled`, `kind`, `mode`, `account_id`, `display_name`, `config`, and `secrets`. + +- [ ] **Step 2: Extend registry tests for ChannelConfig materialization** Append to `app-instance/backend/tests/unit/test_channel_connector_registry.py`: @@ -1131,7 +1211,7 @@ def test_connector_registry_materializes_channel_configs_with_credentials(tmp_pa asyncio.run(run()) ``` -- [ ] **Step 2: Run registry tests to verify failure** +- [ ] **Step 3: Run registry tests to verify failure** Run: @@ -1142,7 +1222,7 @@ uv run pytest tests/unit/test_channel_connector_registry.py::test_connector_regi Expected: fail with `AttributeError: 'ChannelConnectorRegistry' object has no attribute 'materialize_channel_configs'`. -- [ ] **Step 3: Implement channel config materialization** +- [ ] **Step 4: Implement channel config materialization** Modify `app-instance/backend/beaver/interfaces/channels/connections/connectors.py`: @@ -1169,7 +1249,7 @@ Add this method to `ChannelConnectorRegistry`: return channels ``` -- [ ] **Step 4: Add app helpers for connection state paths and registry construction** +- [ ] **Step 5: Add app helpers for connection state paths and registry construction** Modify `app-instance/backend/beaver/interfaces/web/app.py` imports: @@ -1178,7 +1258,6 @@ from beaver.interfaces.channels.connections import ( ChannelConnectionStore, ChannelConnectorRegistry, CredentialStore, - PairingTokenStore, TelegramConnector, ) ``` @@ -1204,12 +1283,13 @@ def _build_channel_connector_registry(workspace: Path) -> ChannelConnectorRegist return registry ``` -- [ ] **Step 5: Merge materialized connections into runtime startup** +- [ ] **Step 6: Merge materialized connections into runtime startup** Modify the lifespan block in `app-instance/backend/beaver/interfaces/web/app.py` where `ChannelRuntime` is created: ```python loaded = attached_service.create_loop().boot() + app.state.channel_connection_workspace = loaded.workspace connector_registry = _build_channel_connector_registry(loaded.workspace) app.state.channel_connector_registry = connector_registry connection_channels = await connector_registry.materialize_channel_configs() @@ -1224,7 +1304,7 @@ Modify the lifespan block in `app-instance/backend/beaver/interfaces/web/app.py` Keep `app.state.channel_connector_registry = connector_registry` before runtime startup so API handlers can reuse the same stores. -- [ ] **Step 6: Run registry tests** +- [ ] **Step 7: Run registry tests** Run: @@ -1235,7 +1315,7 @@ uv run pytest tests/unit/test_channel_connector_registry.py -q Expected: all tests pass. -- [ ] **Step 7: Commit Task 4** +- [ ] **Step 8: Commit Task 4** ```bash git add app-instance/backend/beaver/interfaces/channels/connections/connectors.py app-instance/backend/beaver/interfaces/web/app.py app-instance/backend/tests/unit/test_channel_connector_registry.py @@ -1265,7 +1345,7 @@ from beaver.interfaces.web.app import create_app from beaver.services.agent_service import AgentService -def test_channel_connection_api_creates_lists_and_revokes(tmp_path) -> None: +def test_channel_connection_api_creates_updates_lists_and_revokes(tmp_path) -> None: config_path = tmp_path / "config.json" config_path.write_text('{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path), encoding="utf-8") service = AgentService(config_path=config_path) @@ -1289,11 +1369,26 @@ def test_channel_connection_api_creates_lists_and_revokes(tmp_path) -> None: connection_id = body["connection"]["connection_id"] assert body["connection"]["kind"] == "telegram" assert body["connection"]["status"] == "draft" + assert "credentials_ref" not in body["connection"] assert body["credentials"] == {"botToken": "***"} + patched = client.patch( + f"/api/channel-connections/{connection_id}", + json={ + "displayName": "Telegram Ops", + "config": {"maxMessageChars": 2048}, + "secrets": {"botToken": "token-2"}, + }, + ) + assert patched.status_code == 200 + assert patched.json()["connection"]["display_name"] == "Telegram Ops" + assert patched.json()["connection"]["runtime_config"] == {"maxMessageChars": 2048} + assert patched.json()["credentials"] == {"botToken": "***"} + listed = client.get("/api/channel-connections") assert listed.status_code == 200 assert listed.json()[0]["connection_id"] == connection_id + assert "credentials_ref" not in listed.json()[0] revoked = client.post(f"/api/channel-connections/{connection_id}/revoke") assert revoked.status_code == 200 @@ -1354,6 +1449,15 @@ class WebChannelConnectionResponse(BaseModel): credentials: dict[str, str] = Field(default_factory=dict) +class WebChannelConnectionUpdateRequest(BaseModel): + """Update editable channel connection setup fields.""" + + display_name: str | None = Field(default=None, alias="displayName") + account_id: str | None = Field(default=None, alias="accountId") + config: dict[str, Any] | None = None + secrets: dict[str, str | None] | None = None + + class WebChannelValidationResponse(BaseModel): """Connector validation response.""" @@ -1373,6 +1477,7 @@ Modify `app-instance/backend/beaver/interfaces/web/schemas/__init__.py` imports ```python WebChannelConnectionCreateRequest, WebChannelConnectionResponse, + WebChannelConnectionUpdateRequest, WebChannelValidationResponse, ``` @@ -1384,6 +1489,7 @@ Modify imports in `app-instance/backend/beaver/interfaces/web/app.py`: from beaver.interfaces.web.schemas import ( WebChannelConnectionCreateRequest, WebChannelConnectionResponse, + WebChannelConnectionUpdateRequest, WebChannelValidationResponse, ) ``` @@ -1394,11 +1500,21 @@ Add helper: def get_channel_connector_registry(request: Request) -> ChannelConnectorRegistry: registry = getattr(request.app.state, "channel_connector_registry", None) if not isinstance(registry, ChannelConnectorRegistry): - agent_service = get_agent_service(request) - workspace = agent_service.loader.workspace + workspace = getattr(request.app.state, "channel_connection_workspace", None) + if workspace is None: + agent_service = get_agent_service(request) + workspace = agent_service.loader.workspace registry = _build_channel_connector_registry(workspace) request.app.state.channel_connector_registry = registry return registry + + +def _connection_response_view(connection: Any) -> dict[str, Any]: + view = connection.to_dict() + view.pop("credentials_ref", None) + view.pop("connector_ref", None) + view.pop("pairing_session_id", None) + return view ``` - [ ] **Step 6: Add connection API routes** @@ -1413,7 +1529,7 @@ Add routes near existing `/api/channels` routes in `app-instance/backend/beaver/ @app.get("/api/channel-connections") async def list_channel_connections(request: Request) -> list[dict[str, Any]]: registry = get_channel_connector_registry(request) - return [connection.to_dict() for connection in registry.connection_store.list()] + return [_connection_response_view(connection) for connection in registry.connection_store.list()] @app.post("/api/channel-connections", response_model=WebChannelConnectionResponse) async def create_channel_connection( @@ -1440,10 +1556,37 @@ Add routes near existing `/api/channels` routes in `app-instance/backend/beaver/ runtime_config=payload.config or {}, ) return WebChannelConnectionResponse( - connection=connection.to_dict(), + connection=_connection_response_view(connection), credentials=registry.credential_store.redacted(credentials_ref), ) + @app.patch("/api/channel-connections/{connection_id}", response_model=WebChannelConnectionResponse) + async def update_channel_connection( + connection_id: str, + request: Request, + payload: WebChannelConnectionUpdateRequest, + ) -> WebChannelConnectionResponse: + registry = get_channel_connector_registry(request) + try: + connection = registry.connection_store.get(connection_id) + except KeyError: + raise HTTPException(status_code=404, detail="Channel connection not found") + if payload.display_name is not None: + connection.display_name = _clean_text(payload.display_name) or connection.display_name + if payload.account_id is not None: + connection.account_id = _clean_text(payload.account_id) or connection.account_id + if payload.config is not None: + connection.runtime_config = payload.config + if payload.secrets: + secrets = {key: value for key, value in payload.secrets.items() if value} + if secrets: + connection.credentials_ref = registry.credential_store.put(kind=connection.kind, values=secrets) + connection = registry.connection_store.update(connection) + return WebChannelConnectionResponse( + connection=_connection_response_view(connection), + credentials=registry.credential_store.redacted(connection.credentials_ref), + ) + @app.get("/api/channel-connections/{connection_id}", response_model=WebChannelConnectionResponse) async def get_channel_connection(connection_id: str, request: Request) -> WebChannelConnectionResponse: registry = get_channel_connector_registry(request) @@ -1452,7 +1595,7 @@ Add routes near existing `/api/channels` routes in `app-instance/backend/beaver/ except KeyError: raise HTTPException(status_code=404, detail="Channel connection not found") return WebChannelConnectionResponse( - connection=connection.to_dict(), + connection=_connection_response_view(connection), credentials=registry.credential_store.redacted(connection.credentials_ref), ) @@ -1471,7 +1614,7 @@ Add routes near existing `/api/channels` routes in `app-instance/backend/beaver/ display_name=result.display_name, error=result.error, metadata=result.metadata, - connection=connection.to_dict(), + connection=_connection_response_view(connection), ) @app.post("/api/channel-connections/{connection_id}/revoke", response_model=WebChannelConnectionResponse) @@ -1482,7 +1625,7 @@ Add routes near existing `/api/channels` routes in `app-instance/backend/beaver/ connection = registry.connection_store.get(connection_id) except KeyError: raise HTTPException(status_code=404, detail="Channel connection not found") - return WebChannelConnectionResponse(connection=connection.to_dict(), credentials={}) + return WebChannelConnectionResponse(connection=_connection_response_view(connection), credentials={}) ``` - [ ] **Step 7: Run API tests** @@ -1567,7 +1710,7 @@ Run: ```bash cd app-instance/backend -rg -n "token-1|bad-token|secret-token" tests/unit beaver || true +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. From c3a0aef104871b72d563678b8936a21a7a07d4c2 Mon Sep 17 00:00:00 2001 From: steven_li Date: Tue, 2 Jun 2026 16:07:01 +0800 Subject: [PATCH 05/11] docs: tighten channel connector API plan --- ...026-06-02-channel-connectors-foundation.md | 38 +++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/plans/2026-06-02-channel-connectors-foundation.md b/docs/superpowers/plans/2026-06-02-channel-connectors-foundation.md index ffc0884..10e2cb7 100644 --- a/docs/superpowers/plans/2026-06-02-channel-connectors-foundation.md +++ b/docs/superpowers/plans/2026-06-02-channel-connectors-foundation.md @@ -18,7 +18,7 @@ Included: - `ChannelConnection` data model and persistent JSON store. - Restricted local credential store with secret redaction. -- One-time pairing token store, used now by tests and future terminal/QR connectors. +- One-time pairing token store, used now by tests and future terminal/QR connectors. It is implemented in this phase but not exposed through APIs; future terminal and QR connectors will consume it. - Connector protocol and registry. - Telegram connector with fake-client test hooks. - Connection control APIs. @@ -33,6 +33,7 @@ Excluded from this plan: - Frontend connection wizard. - Hot starting/stopping adapters without backend restart. - Multi-process-safe storage. The JSON stores use `threading.Lock` plus atomic file replace for the single backend process used in phase 1. Production multi-worker deployment needs a file lock or database-backed store. +- Credential garbage collection. Updating secrets writes a new credential reference and leaves the old reference in the local credential file until a later cleanup pass. ## File Structure @@ -1361,7 +1362,7 @@ def test_channel_connection_api_creates_updates_lists_and_revokes(tmp_path) -> N "displayName": "Telegram Main", "authType": "token", "secrets": {"botToken": "token-1"}, - "config": {"maxMessageChars": 4096}, + "config": {"maxMessageChars": 4096, "requireMentionInGroups": True}, }, ) assert created.status_code == 200 @@ -1370,6 +1371,10 @@ def test_channel_connection_api_creates_updates_lists_and_revokes(tmp_path) -> N assert body["connection"]["kind"] == "telegram" assert body["connection"]["status"] == "draft" assert "credentials_ref" not in body["connection"] + assert body["connection"]["runtime_config"] == { + "max_message_chars": 4096, + "require_mention_in_groups": True, + } assert body["credentials"] == {"botToken": "***"} patched = client.patch( @@ -1382,7 +1387,7 @@ def test_channel_connection_api_creates_updates_lists_and_revokes(tmp_path) -> N ) assert patched.status_code == 200 assert patched.json()["connection"]["display_name"] == "Telegram Ops" - assert patched.json()["connection"]["runtime_config"] == {"maxMessageChars": 2048} + assert patched.json()["connection"]["runtime_config"] == {"max_message_chars": 2048} assert patched.json()["credentials"] == {"botToken": "***"} listed = client.get("/api/channel-connections") @@ -1502,13 +1507,31 @@ def get_channel_connector_registry(request: Request) -> ChannelConnectorRegistry if not isinstance(registry, ChannelConnectorRegistry): workspace = getattr(request.app.state, "channel_connection_workspace", None) if workspace is None: - agent_service = get_agent_service(request) - workspace = agent_service.loader.workspace + raise RuntimeError("Channel connector registry unavailable before service boot") registry = _build_channel_connector_registry(workspace) request.app.state.channel_connector_registry = registry return registry +def _normalize_connection_config(config: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(config, dict): + return {} + return { + _camel_to_snake_text(str(key)): value + for key, value in config.items() + if str(key).strip() + } + + +def _camel_to_snake_text(value: str) -> str: + result: list[str] = [] + for char in value: + if char.isupper() and result: + result.append("_") + result.append(char.lower()) + return "".join(result) + + def _connection_response_view(connection: Any) -> dict[str, Any]: view = connection.to_dict() view.pop("credentials_ref", None) @@ -1553,7 +1576,7 @@ Add routes near existing `/api/channels` routes in `app-instance/backend/beaver/ owner_user_id=_clean_text(payload.owner_user_id) or None, auth_type=_clean_text(payload.auth_type) or "token", credentials_ref=credentials_ref, - runtime_config=payload.config or {}, + runtime_config=_normalize_connection_config(payload.config), ) return WebChannelConnectionResponse( connection=_connection_response_view(connection), @@ -1576,10 +1599,11 @@ Add routes near existing `/api/channels` routes in `app-instance/backend/beaver/ if payload.account_id is not None: connection.account_id = _clean_text(payload.account_id) or connection.account_id if payload.config is not None: - connection.runtime_config = payload.config + connection.runtime_config = _normalize_connection_config(payload.config) if payload.secrets: secrets = {key: value for key, value in payload.secrets.items() if value} if secrets: + # TODO: add credential GC when connection updates credentials. connection.credentials_ref = registry.credential_store.put(kind=connection.kind, values=secrets) connection = registry.connection_store.update(connection) return WebChannelConnectionResponse( From e0a4862af8233827af767ad231524df1ff99c61a Mon Sep 17 00:00:00 2001 From: steven_li Date: Tue, 2 Jun 2026 17:59:57 +0800 Subject: [PATCH 06/11] docs: add openclaw sidecar connector design --- ...6-02-openclaw-sidecar-connectors-design.md | 396 ++++++++++++++++++ 1 file changed, 396 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-02-openclaw-sidecar-connectors-design.md diff --git a/docs/superpowers/specs/2026-06-02-openclaw-sidecar-connectors-design.md b/docs/superpowers/specs/2026-06-02-openclaw-sidecar-connectors-design.md new file mode 100644 index 0000000..3b81a13 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-openclaw-sidecar-connectors-design.md @@ -0,0 +1,396 @@ +# OpenClaw Sidecar Connectors Design + +Date: 2026-06-02 + +## Goal + +Add real Weixin personal-account QR login and Feishu/Lark OpenClaw plugin onboarding to Beaver through a docker-compose predeclared sidecar service. Beaver must not dynamically create containers or require Docker socket access. + +This design implements the next connector layer after `docs/superpowers/plans/2026-06-02-channel-connectors-foundation.md`. + +## Sources + +- Tencent `openclaw-weixin` supports Weixin QR login and persists login credentials after scan confirmation: https://github.com/Tencent/openclaw-weixin +- The Weixin ClawBot install flow shown by the user uses `npx -y @tencent-weixin/openclaw-weixin-cli@latest install`. +- Feishu's OpenClaw article documents the official Lark/Feishu plugin install command `npx -y @larksuite/openclaw-lark install`, bot creation/linking, `/feishu start` verification, and user-vs-bot identity modes: https://www.feishu.cn/content/article/7613711414611463386 + +## Scope + +Included: + +- A repo-local `openclaw-connector` sidecar service. +- A docker-compose service declaration for the sidecar. +- Sidecar HTTP API for health, connector metadata, pairing/install status, logout/remove, outbound send, and inbound event forwarding. +- Beaver `WeixinConnector` and `FeishuConnector` objects registered in `ChannelConnectorRegistry`. +- Beaver connector bridge endpoints that accept normalized sidecar inbound events and submit them to `ChannelRuntime.accept_inbound()`. +- `ExternalConnectorChannel` runtime object for sidecar-backed outbound sends. +- Web UI connection wizard for Weixin QR login and Feishu/Lark plugin onboarding. +- Unit tests using fake sidecar clients and bridge events. + +Excluded: + +- Dynamic Docker container creation from Beaver. +- Docker socket mounts in Beaver. +- Reimplementing Weixin iLink or Feishu OpenClaw protocol inside Beaver. +- Building a generic plugin marketplace. +- Hot-swapping running adapters without backend restart. This phase may create/update connections and require a Beaver app restart for runtime materialization unless explicitly handled by the existing runtime. +- Multi-user enterprise permission governance beyond local connector ownership and bridge token validation. + +## Architecture + +Use one predeclared sidecar for OpenClaw-backed platform connectors: + +```text +Beaver backend +-> OpenClawConnector HTTP client +-> openclaw-connector sidecar +-> OpenClaw CLI/runtime +-> @tencent-weixin/openclaw-weixin +-> @larksuite/openclaw-lark +``` + +Beaver owns: + +- connection state in `ChannelConnectionStore` +- credential references in `CredentialStore` +- bridge endpoint authentication +- normalized runtime message admission +- runtime dedupe/session identity +- outbound dispatch into sidecar `/send` + +The sidecar owns: + +- OpenClaw installation/runtime state +- plugin install/update commands +- Weixin QR login and login-state persistence +- Feishu/Lark plugin install, bot creation/linking, and OpenClaw-side verification +- platform receive loops +- sidecar-to-Beaver inbound event delivery + +## Runtime Flow + +Inbound: + +```text +Weixin or Feishu/Lark platform event +-> OpenClaw plugin inside sidecar +-> sidecar normalized event +-> POST Beaver /api/channel-connector-bridge/events +-> ChannelRuntime.accept_inbound() +-> MessageBus +-> AgentService +``` + +Outbound: + +```text +AgentService +-> MessageBus outbound +-> ChannelManager.dispatch_outbound() +-> ExternalConnectorChannel.send() +-> POST sidecar /send +-> OpenClaw plugin send path +-> Weixin or Feishu/Lark platform +``` + +`ExternalConnectorChannel` implements the existing runtime channel protocol: + +```python +channel_id: str +kind: str +mode: str + +async def start() -> None +async def stop() -> None +async def send(message: OutboundMessage) -> None +``` + +It is not a platform protocol adapter. It is a generic HTTP bridge to a sidecar. + +Runtime materialization for sidecar-backed connections always emits: + +```python +ChannelConfig( + enabled=True, + kind="external_connector", + mode="http", + account_id=spec.account_id, + display_name=spec.display_name, + config={ + "platformKind": "weixin", + "connectionId": "conn_...", + "sidecarBaseUrl": "http://openclaw-connector:8787", + }, + secrets={"bridgeToken": "..."}, +) +``` + +The original `ChannelConnection.kind` remains `weixin` or `feishu`; only the runtime transport kind is generic. + +## Sidecar Deployment + +Add a sidecar service that can be enabled in deployment: + +```yaml +services: + openclaw-connector: + build: ./openclaw-connector + restart: unless-stopped + environment: + BEAVER_BRIDGE_BASE_URL: http://app-instance:8080 + BEAVER_BRIDGE_TOKEN: ${BEAVER_BRIDGE_TOKEN} + OPENCLAW_HOME: /var/lib/openclaw + volumes: + - openclaw-connector-state:/var/lib/openclaw +``` + +For the current `create-instance.sh`-style deployment, the implementation adds: + +- `docker-compose.openclaw.yml` for local/development sidecar tests. +- documentation for attaching `openclaw-connector` to the same Docker network as the target app instance. +- instance environment `OPENCLAW_CONNECTOR_BASE_URL=http://openclaw-connector:8787`. + +The implementation must not depend on Beaver mounting `/var/run/docker.sock`. + +## Sidecar HTTP API + +All sidecar requests and responses are JSON. The sidecar listens on port `8787`. + +```text +GET /health +GET /connectors +POST /pairings +GET /pairings/{pairing_id} +POST /pairings/{pairing_id}/cancel +POST /connections/{connection_id}/logout +POST /send +``` + +`GET /connectors` returns: + +```json +[ + { + "kind": "weixin", + "displayName": "Weixin", + "authType": "qr", + "capabilities": ["receive_text", "send_text", "receive_media", "direct_messages"] + }, + { + "kind": "feishu", + "displayName": "Feishu/Lark", + "authType": "openclaw_plugin", + "capabilities": ["receive_text", "send_text", "receive_media", "groups"] + } +] +``` + +`POST /pairings` request: + +```json +{ + "kind": "weixin", + "connectionId": "conn_...", + "channelId": "weixin-main", + "displayName": "Weixin Main", + "callbackBaseUrl": "http://app-instance:8080", + "bridgeToken": "..." +} +``` + +For Feishu/Lark, `kind` is `feishu` and the request may include `domain`, `mode`, and optional app credentials when linking an existing bot. If using the OpenClaw official installer to create a bot, the sidecar starts that installer flow and reports QR or action status back to Beaver. + +`GET /pairings/{pairing_id}` response: + +```json +{ + "pairingId": "pair_...", + "kind": "weixin", + "status": "pending", + "qrCode": "weixin://...", + "qrImage": "data:image/png;base64,...", + "accountId": null, + "displayName": null, + "error": null, + "metadata": {} +} +``` + +Allowed pairing statuses: + +- `pending` +- `qr_ready` +- `scanned` +- `confirmed` +- `connected` +- `expired` +- `error` +- `cancelled` + +`POST /send` request: + +```json +{ + "connectionId": "conn_...", + "channelId": "weixin-main", + "kind": "weixin", + "target": { + "peerId": "wx_user_or_chat_id", + "peerType": "dm", + "threadId": null + }, + "content": "reply text", + "metadata": { + "contextToken": "optional" + } +} +``` + +## Beaver Bridge API + +Add a backend bridge endpoint for sidecar inbound messages: + +```text +POST /api/channel-connector-bridge/events +``` + +The sidecar must authenticate every bridge request using a bearer token scoped to the connector service. Beaver rejects missing or invalid bridge tokens. + +Bridge event body: + +```json +{ + "connectionId": "conn_...", + "channelId": "weixin-main", + "kind": "weixin", + "accountId": "weixin:...", + "peerId": "wx_user_or_chat_id", + "peerType": "dm", + "userId": "wx_sender", + "threadId": null, + "messageId": "platform-message-id", + "messageType": "text", + "content": "hello", + "metadata": { + "contextToken": "optional" + } +} +``` + +The bridge endpoint constructs `ChannelIdentity`, then `InboundMessage`, then calls `ChannelRuntime.accept_inbound()`. + +## Beaver Connectors + +### WeixinConnector + +Responsibilities: + +- discover sidecar health +- start Weixin pairing through sidecar `/pairings` +- poll sidecar pairing status +- create or update `ChannelConnection` +- store sidecar connection token or state reference in `CredentialStore` +- validate by checking sidecar connection status +- materialize runtime config for `ExternalConnectorChannel` +- revoke/logout by calling sidecar `/connections/{connection_id}/logout` + +### FeishuConnector + +Responsibilities: + +- discover sidecar health +- start Feishu/Lark OpenClaw plugin install/link flow +- optionally pass appId/appSecret/domain/mode for existing bot linking +- poll installer/pairing status +- create or update `ChannelConnection` +- validate by sidecar `/pairings/{id}` or connector status +- materialize runtime config for `ExternalConnectorChannel` +- revoke/remove plugin connection by calling sidecar logout/remove API + +Feishu is sidecar-backed in this design because the user's supplied Feishu article describes the official OpenClaw plugin flow, not only a static bot-credential runtime adapter. + +## Frontend + +Replace the old static Weixin fields with connector-driven UI: + +- fetch `GET /api/channel-connectors` +- show Telegram, Weixin, and Feishu/Lark as connector options +- for Weixin: + - start pairing + - show QR image + - poll status until connected/expired/error + - show connected account and logout +- for Feishu/Lark: + - choose create bot or link existing bot + - collect domain and optional app credentials + - start sidecar pairing/install + - show QR/instructions/status returned by sidecar + - show connected account and logout + +The old `/api/channels` static config editor may remain for advanced runtime config, but connector onboarding should not rely on manual JSON editing or direct token entry for Weixin/Feishu. + +## Error Handling + +- Sidecar unavailable: show connector as `unavailable`; do not create a running connection. +- OpenClaw install command fails: status `error`, with redacted stderr summary. +- QR expired: status `expired`, user can start a new pairing. +- Bridge token invalid: reject with `401`, record event without platform secret values. +- Unknown connection id in bridge event: reject with `404`. +- Outbound send failure: mark outbound delivery failed and record connector error. +- Sidecar restart: persisted OpenClaw state should survive through sidecar volume. + +## Security + +- Beaver never logs raw tokens, app secrets, or sidecar connection tokens. +- Bridge token is generated by Beaver and stored behind `credentials_ref`. +- Sidecar can only call bridge endpoints with its bridge token. +- Sidecar state volume contains OpenClaw login state and must be treated as sensitive. +- Feishu user-identity mode has stronger privacy risk than bot-identity mode; UI must label it clearly if exposed. + +## Testing + +Backend unit tests: + +- sidecar client fake for Weixin pairing start/status/logout/send +- sidecar client fake for Feishu pairing start/status/logout/send +- `ExternalConnectorChannel.send()` target mapping +- bridge endpoint accepts valid events and rejects invalid token/connection id +- registry lists `telegram`, `weixin`, and `feishu` +- materialized sidecar connections produce `ChannelConfig(kind="external_connector", mode="http")` compatible with runtime factory + +Sidecar tests: + +- HTTP API shape for health/connectors/pairings/send +- fake OpenClaw provider status transitions +- command runner error redaction + +Frontend tests: + +- Weixin connector option opens QR modal +- polling reaches connected state +- expired/error states are visible +- Feishu flow starts install/link and shows returned instructions/status + +Manual verification: + +- Build app and sidecar Docker images. +- Start docker-compose sidecar setup. +- In `terminaltest`, open Weixin connector, scan QR, observe connected status. +- Send a Weixin text message and verify Beaver receives it. +- Send a Beaver reply and verify sidecar `/send` path. +- Start Feishu connector flow using official OpenClaw Lark plugin install path and verify `/feishu start`. + +## Rollout + +Implement in this order: + +1. Sidecar HTTP contract with fake provider. +2. Beaver `ExternalConnectorChannel` and bridge endpoint. +3. Weixin connector against fake sidecar client. +4. Feishu connector against fake sidecar client. +5. Frontend connector UI. +6. Real sidecar provider that shells out to OpenClaw/OpenClaw plugin commands. +7. Docker build/compose integration. +8. Manual live verification. + +The fake provider is test-only. The production sidecar provider must use real OpenClaw plugin commands for Weixin and Feishu/Lark; the fake provider only makes Beaver and frontend tests deterministic while the live provider handles the non-deterministic external login flow. From cf35edb4caaa61ced9ac7c07e6e3df857b21eddb Mon Sep 17 00:00:00 2001 From: steven_li Date: Tue, 2 Jun 2026 18:05:46 +0800 Subject: [PATCH 07/11] docs: decouple external connector sidecar design --- ...6-02-external-sidecar-connectors-design.md | 538 ++++++++++++++++++ ...6-02-openclaw-sidecar-connectors-design.md | 396 ------------- 2 files changed, 538 insertions(+), 396 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-02-external-sidecar-connectors-design.md delete mode 100644 docs/superpowers/specs/2026-06-02-openclaw-sidecar-connectors-design.md diff --git a/docs/superpowers/specs/2026-06-02-external-sidecar-connectors-design.md b/docs/superpowers/specs/2026-06-02-external-sidecar-connectors-design.md new file mode 100644 index 0000000..32ce091 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-external-sidecar-connectors-design.md @@ -0,0 +1,538 @@ +# External Sidecar Connectors Design + +Date: 2026-06-02 + +## Goal + +Add real Weixin personal-account QR login and Feishu/Lark plugin onboarding to Beaver through a docker-compose predeclared sidecar service, without binding Beaver's connector layer to one vendor runtime. Beaver must not dynamically create containers or require Docker socket access. + +This design implements the next connector layer after `docs/superpowers/plans/2026-06-02-channel-connectors-foundation.md`. + +## Design Corrections + +This design intentionally fixes four architecture constraints before implementation: + +- The sidecar is generic. Beaver depends on a connector HTTP contract, not on one vendor runtime. +- Pairing is modeled as a broader `ConnectorSession`, because Feishu/Lark install/link flows are not only QR pairing. +- Bridge events include `eventId`, `timestamp`, and `deliveryAttempt`, and Beaver dedupes bridge events before they can trigger duplicate agent replies. +- Connected sessions dynamically register runtime channels. A successful Weixin or Feishu/Lark connection must not require a Beaver restart. + +## Scope + +Included: + +- A repo-local `external-connector` sidecar service. +- A docker-compose service declaration for the sidecar. +- A sidecar `ConnectorProvider` abstraction. +- A production `VendorCliProvider` that runs the real vendor CLI/plugin commands required for Weixin personal-account QR login and Feishu/Lark plugin onboarding. +- Sidecar HTTP API for health, connector metadata, connector sessions, logout/remove, outbound send, and inbound event forwarding. +- Beaver `WeixinConnector` and `FeishuConnector` objects registered in `ChannelConnectorRegistry`. +- Beaver connector bridge endpoints that accept normalized sidecar inbound events and submit them to `ChannelRuntime.accept_inbound()`. +- `MessageDedupeStore` for connector bridge event idempotency. +- `ExternalConnectorChannel` runtime object for sidecar-backed outbound sends. +- `ChannelRuntime.add_channel()` and `ChannelRuntime.remove_channel()` for dynamic runtime activation. +- Web UI connection wizard for Weixin QR login and Feishu/Lark plugin onboarding. +- Unit tests using fake sidecar providers and bridge events. + +Excluded: + +- Dynamic Docker container creation from Beaver. +- Docker socket mounts in Beaver. +- Reimplementing Weixin iLink or Feishu/Lark plugin protocols inside Beaver. +- Building a generic plugin marketplace. +- Multi-user enterprise permission governance beyond local connector ownership and bridge token validation. + +## Architecture + +Use one predeclared sidecar for external connector providers: + +```text +Beaver backend +-> Connector HTTP client +-> external-connector sidecar +-> ConnectorProvider +-> provider-specific runtime or CLI +-> Weixin / Feishu / future platform +``` + +Beaver owns: + +- connection state in `ChannelConnectionStore` +- credential references in `CredentialStore` +- connector session state exposed to the web UI +- bridge endpoint authentication +- bridge event dedupe +- normalized runtime message admission +- runtime channel lifecycle +- runtime dedupe/session identity +- outbound dispatch into sidecar `/send` + +The sidecar owns: + +- provider runtime state +- provider install/update commands +- Weixin QR login and login-state persistence +- Feishu/Lark plugin install, bot creation/linking, and provider-side verification +- platform receive loops +- sidecar-to-Beaver inbound event delivery + +## ConnectorProvider + +The sidecar must isolate provider-specific behavior behind a provider contract. Beaver must not know which provider implementation is active. + +```ts +interface ConnectorProvider { + providerId: string; + connectors(): ConnectorDescriptor[]; + health(): Promise; + startSession(input: StartConnectorSessionInput): Promise; + getSession(sessionId: string): Promise; + cancelSession(sessionId: string): Promise; + logout(connectionId: string): Promise; + send(input: SendMessageInput): Promise; +} +``` + +Initial provider: + +- `VendorCliProvider`: runs the real CLI/plugin commands required by the current Weixin and Feishu/Lark vendor flows. + +Future providers can be added without changing Beaver runtime code: + +- `WechatyProvider` +- `NapcatProvider` +- `OneBotProvider` +- `EnterpriseWeixinProvider` + +Provider choice is sidecar configuration, not Beaver architecture. `ExternalConnectorChannel` only calls the sidecar HTTP contract. + +## Runtime Flow + +Inbound: + +```text +platform event +-> ConnectorProvider inside sidecar +-> sidecar normalized bridge event +-> POST Beaver /api/channel-connector-bridge/events +-> MessageDedupeStore +-> ChannelRuntime.accept_inbound() +-> MessageBus +-> AgentService +``` + +Outbound: + +```text +AgentService +-> MessageBus outbound +-> ChannelManager.dispatch_outbound() +-> ExternalConnectorChannel.send() +-> POST sidecar /send +-> ConnectorProvider.send() +-> platform +``` + +`ExternalConnectorChannel` implements the existing runtime channel protocol: + +```python +channel_id: str +kind: str +mode: str + +async def start() -> None +async def stop() -> None +async def send(message: OutboundMessage) -> None +``` + +It is not a platform protocol adapter. It is a generic HTTP bridge to a sidecar. + +Runtime materialization for sidecar-backed connections always emits: + +```python +ChannelConfig( + enabled=True, + kind="external_connector", + mode="http", + account_id=spec.account_id, + display_name=spec.display_name, + config={ + "platformKind": "weixin", + "connectionId": "conn_...", + "sidecarBaseUrl": "http://external-connector:8787", + }, + secrets={"bridgeToken": "..."}, +) +``` + +The original `ChannelConnection.kind` remains `weixin` or `feishu`; only the runtime transport kind is generic. + +## Dynamic Runtime Activation + +A connected connector session must activate without restarting Beaver. + +Add runtime methods: + +```python +async def add_channel(self, channel_id: str, config: ChannelConfig) -> None: + ... + +async def remove_channel(self, channel_id: str) -> None: + ... +``` + +When a connector session reaches `connected`: + +```text +Connector session connected +-> connector updates ChannelConnection +-> registry materializes ChannelConfig +-> ChannelRuntime.add_channel(channel_id, config) +-> ChannelManager.register(adapter) +-> adapter.start() +-> channel status becomes running +``` + +This is a hard requirement for Weixin and Feishu/Lark onboarding. Manual backend restart is not an acceptable success path for this feature. + +`remove_channel()` is used when a user logs out or revokes a sidecar connection: + +```text +logout / revoke +-> sidecar logout +-> ChannelRuntime.remove_channel(channel_id) +-> connection status revoked or disconnected +``` + +## Sidecar Deployment + +Add a sidecar service that can be enabled in deployment: + +```yaml +services: + external-connector: + build: ./external-connector + restart: unless-stopped + environment: + BEAVER_BRIDGE_BASE_URL: http://app-instance:8080 + BEAVER_BRIDGE_TOKEN: ${BEAVER_BRIDGE_TOKEN} + CONNECTOR_HOME: /var/lib/external-connector + CONNECTOR_PROVIDER: vendor_cli + volumes: + - external-connector-state:/var/lib/external-connector +``` + +For the current `create-instance.sh`-style deployment, the implementation adds: + +- `docker-compose.external-connectors.yml` for local/development sidecar tests. +- documentation for attaching `external-connector` to the same Docker network as the target app instance. +- instance environment `EXTERNAL_CONNECTOR_BASE_URL=http://external-connector:8787`. + +The implementation must not depend on Beaver mounting `/var/run/docker.sock`. + +## Sidecar HTTP API + +All sidecar requests and responses are JSON. The sidecar listens on port `8787`. + +```text +GET /health +GET /connectors +POST /connector-sessions +GET /connector-sessions/{session_id} +POST /connector-sessions/{session_id}/cancel +POST /connections/{connection_id}/logout +POST /send +``` + +`GET /connectors` returns: + +```json +[ + { + "kind": "weixin", + "displayName": "Weixin", + "authType": "qr", + "providerId": "vendor_cli", + "capabilities": ["receive_text", "send_text", "receive_media", "direct_messages"] + }, + { + "kind": "feishu", + "displayName": "Feishu/Lark", + "authType": "plugin_install", + "providerId": "vendor_cli", + "capabilities": ["receive_text", "send_text", "receive_media", "groups"] + } +] +``` + +`POST /connector-sessions` request: + +```json +{ + "kind": "weixin", + "connectionId": "conn_...", + "channelId": "weixin-main", + "displayName": "Weixin Main", + "callbackBaseUrl": "http://app-instance:8080", + "bridgeToken": "...", + "options": {} +} +``` + +For Feishu/Lark, `kind` is `feishu` and `options` may include `domain`, `mode`, and optional app credentials when linking an existing bot. If using the official plugin installer to create a bot, the sidecar starts that installer flow and reports QR, instruction, or action status back to Beaver. + +`GET /connector-sessions/{session_id}` response: + +```json +{ + "sessionId": "cs_...", + "kind": "weixin", + "status": "qr_ready", + "qrCode": "weixin://...", + "qrImage": "data:image/png;base64,...", + "instructions": [], + "accountId": null, + "displayName": null, + "error": null, + "metadata": {} +} +``` + +Allowed connector session statuses: + +- `pending` +- `qr_ready` +- `scanned` +- `confirmed` +- `installing` +- `waiting_for_user` +- `connected` +- `expired` +- `error` +- `cancelled` + +`POST /send` request: + +```json +{ + "connectionId": "conn_...", + "channelId": "weixin-main", + "kind": "weixin", + "target": { + "peerId": "wx_user_or_chat_id", + "peerType": "dm", + "threadId": null + }, + "content": "reply text", + "metadata": { + "contextToken": "optional" + } +} +``` + +## Beaver Bridge API + +Add a backend bridge endpoint for sidecar inbound messages: + +```text +POST /api/channel-connector-bridge/events +``` + +The sidecar must authenticate every bridge request using a bearer token scoped to the connector service. Beaver rejects missing or invalid bridge tokens. + +Bridge event body: + +```json +{ + "eventId": "provider-event-id", + "timestamp": "2026-06-02T09:30:00Z", + "deliveryAttempt": 1, + "connectionId": "conn_...", + "channelId": "weixin-main", + "kind": "weixin", + "accountId": "weixin:...", + "peerId": "wx_user_or_chat_id", + "peerType": "dm", + "userId": "wx_sender", + "threadId": null, + "messageId": "platform-message-id", + "messageType": "text", + "content": "hello", + "metadata": { + "contextToken": "optional" + } +} +``` + +The bridge endpoint must: + +1. validate bearer token +2. load `ChannelConnection` +3. reject unknown or revoked connections +4. dedupe by `connectionId + eventId` through `MessageDedupeStore` +5. construct `ChannelIdentity` +6. construct `InboundMessage` +7. call `ChannelRuntime.accept_inbound()` +8. mark bridge event completed or failed + +## MessageDedupeStore + +Add a JSON-backed `MessageDedupeStore` under: + +```text +/state/channel_connections/message_dedupe.json +``` + +It stores: + +```python +@dataclass +class ConnectorMessageDedupeRecord: + dedupe_key: str + connection_id: str + event_id: str + status: str + first_seen_at: str + updated_at: str + delivery_attempts: int + message_id: str | None + last_error: str | None +``` + +`status` values: + +- `processing` +- `completed` +- `failed` + +If a duplicate bridge event arrives while the record is `processing` or `completed`, Beaver returns an idempotent success response and does not call `ChannelRuntime.accept_inbound()` again. + +This store is separate from runtime session dedupe. Runtime dedupe still protects platform message identity, while bridge dedupe protects connector retries. + +## Beaver Connectors + +### WeixinConnector + +Responsibilities: + +- discover sidecar health +- start Weixin connector session through sidecar `/connector-sessions` +- poll sidecar connector session status +- create or update `ChannelConnection` +- store bridge token and sidecar connection state reference in `CredentialStore` +- validate by checking sidecar connection status +- materialize runtime config for `ExternalConnectorChannel` +- activate runtime via `ChannelRuntime.add_channel()` when connected +- revoke/logout by calling sidecar `/connections/{connection_id}/logout` +- deactivate runtime via `ChannelRuntime.remove_channel()` on logout/revoke + +### FeishuConnector + +Responsibilities: + +- discover sidecar health +- start Feishu/Lark plugin install/link connector session +- optionally pass appId/appSecret/domain/mode for existing bot linking +- poll installer/session status +- create or update `ChannelConnection` +- validate by sidecar session or connection status +- materialize runtime config for `ExternalConnectorChannel` +- activate runtime via `ChannelRuntime.add_channel()` when connected +- revoke/remove plugin connection by calling sidecar logout/remove API +- deactivate runtime via `ChannelRuntime.remove_channel()` on logout/revoke + +Feishu is sidecar-backed in this design because the user's supplied Feishu article describes the official plugin flow, not only a static bot-credential runtime adapter. + +## Frontend + +Replace the old static Weixin and Feishu fields with connector-driven UI: + +- fetch `GET /api/channel-connectors` +- show Telegram, Weixin, and Feishu/Lark as connector options +- for Weixin: + - start connector session + - show QR image + - poll status until connected/expired/error + - show connected account and logout +- for Feishu/Lark: + - choose create bot or link existing bot + - collect domain and optional app credentials + - start sidecar connector session + - show QR/instructions/status returned by sidecar + - show connected account and logout + +The old `/api/channels` static config editor may remain for advanced runtime config, but connector onboarding should not rely on manual JSON editing or direct token entry for Weixin/Feishu. + +## Error Handling + +- Sidecar unavailable: show connector as `unavailable`; do not create a running connection. +- Provider install command fails: status `error`, with redacted stderr summary. +- QR expired: status `expired`, user can start a new connector session. +- Bridge token invalid: reject with `401`, record event without platform secret values. +- Unknown connection id in bridge event: reject with `404`. +- Duplicate bridge event: return idempotent success and do not call runtime again. +- Outbound send failure: mark outbound delivery failed and record connector error. +- Sidecar restart: persisted provider state should survive through sidecar volume. + +## Security + +- Beaver never logs raw tokens, app secrets, bridge tokens, or sidecar connection tokens. +- Bridge token is generated by Beaver and stored behind `credentials_ref`. +- Sidecar can only call bridge endpoints with its bridge token. +- Sidecar state volume contains login state and must be treated as sensitive. +- Feishu user-identity mode has stronger privacy risk than bot-identity mode; UI must label it clearly if exposed. + +## Testing + +Backend unit tests: + +- sidecar client fake for Weixin connector session start/status/logout/send +- sidecar client fake for Feishu connector session start/status/logout/send +- `ExternalConnectorChannel.send()` target mapping +- `ChannelRuntime.add_channel()` dynamically starts and registers a channel +- `ChannelRuntime.remove_channel()` stops and unregisters a channel +- bridge endpoint accepts valid events +- bridge endpoint rejects invalid token and unknown connection id +- bridge endpoint dedupes repeated `eventId` and calls runtime once +- registry lists `telegram`, `weixin`, and `feishu` +- materialized sidecar connections produce `ChannelConfig(kind="external_connector", mode="http")` compatible with runtime factory + +Sidecar tests: + +- HTTP API shape for health/connectors/connector-sessions/send +- fake provider status transitions +- provider command runner error redaction + +Frontend tests: + +- Weixin connector option opens QR modal +- polling reaches connected state +- expired/error states are visible +- Feishu flow starts install/link and shows returned instructions/status + +Manual verification: + +- Build app and sidecar Docker images. +- Start docker-compose sidecar setup. +- In `terminaltest`, open Weixin connector, scan QR, observe connected status without restarting Beaver. +- Send a Weixin text message and verify Beaver receives it once. +- Force sidecar retry of the same event and verify Beaver does not produce a duplicate agent reply. +- Send a Beaver reply and verify sidecar `/send` path. +- Start Feishu connector flow using the official Feishu/Lark plugin install path and verify the vendor-provided start command. + +## Rollout + +Implement in this order: + +1. Sidecar HTTP contract with fake provider. +2. `MessageDedupeStore`. +3. Beaver `ExternalConnectorChannel` and bridge endpoint. +4. `ChannelRuntime.add_channel()` and `ChannelRuntime.remove_channel()`. +5. Weixin connector against fake sidecar client. +6. Feishu connector against fake sidecar client. +7. Frontend connector UI. +8. Production `VendorCliProvider` that shells out to real vendor CLI/plugin commands. +9. Docker build/compose integration. +10. Manual live verification. + +The fake provider is test-only. The production provider must use the real vendor CLI/plugin commands for Weixin and Feishu/Lark; the fake provider only makes Beaver and frontend tests deterministic while the live provider handles non-deterministic external login and install flows. diff --git a/docs/superpowers/specs/2026-06-02-openclaw-sidecar-connectors-design.md b/docs/superpowers/specs/2026-06-02-openclaw-sidecar-connectors-design.md deleted file mode 100644 index 3b81a13..0000000 --- a/docs/superpowers/specs/2026-06-02-openclaw-sidecar-connectors-design.md +++ /dev/null @@ -1,396 +0,0 @@ -# OpenClaw Sidecar Connectors Design - -Date: 2026-06-02 - -## Goal - -Add real Weixin personal-account QR login and Feishu/Lark OpenClaw plugin onboarding to Beaver through a docker-compose predeclared sidecar service. Beaver must not dynamically create containers or require Docker socket access. - -This design implements the next connector layer after `docs/superpowers/plans/2026-06-02-channel-connectors-foundation.md`. - -## Sources - -- Tencent `openclaw-weixin` supports Weixin QR login and persists login credentials after scan confirmation: https://github.com/Tencent/openclaw-weixin -- The Weixin ClawBot install flow shown by the user uses `npx -y @tencent-weixin/openclaw-weixin-cli@latest install`. -- Feishu's OpenClaw article documents the official Lark/Feishu plugin install command `npx -y @larksuite/openclaw-lark install`, bot creation/linking, `/feishu start` verification, and user-vs-bot identity modes: https://www.feishu.cn/content/article/7613711414611463386 - -## Scope - -Included: - -- A repo-local `openclaw-connector` sidecar service. -- A docker-compose service declaration for the sidecar. -- Sidecar HTTP API for health, connector metadata, pairing/install status, logout/remove, outbound send, and inbound event forwarding. -- Beaver `WeixinConnector` and `FeishuConnector` objects registered in `ChannelConnectorRegistry`. -- Beaver connector bridge endpoints that accept normalized sidecar inbound events and submit them to `ChannelRuntime.accept_inbound()`. -- `ExternalConnectorChannel` runtime object for sidecar-backed outbound sends. -- Web UI connection wizard for Weixin QR login and Feishu/Lark plugin onboarding. -- Unit tests using fake sidecar clients and bridge events. - -Excluded: - -- Dynamic Docker container creation from Beaver. -- Docker socket mounts in Beaver. -- Reimplementing Weixin iLink or Feishu OpenClaw protocol inside Beaver. -- Building a generic plugin marketplace. -- Hot-swapping running adapters without backend restart. This phase may create/update connections and require a Beaver app restart for runtime materialization unless explicitly handled by the existing runtime. -- Multi-user enterprise permission governance beyond local connector ownership and bridge token validation. - -## Architecture - -Use one predeclared sidecar for OpenClaw-backed platform connectors: - -```text -Beaver backend --> OpenClawConnector HTTP client --> openclaw-connector sidecar --> OpenClaw CLI/runtime --> @tencent-weixin/openclaw-weixin --> @larksuite/openclaw-lark -``` - -Beaver owns: - -- connection state in `ChannelConnectionStore` -- credential references in `CredentialStore` -- bridge endpoint authentication -- normalized runtime message admission -- runtime dedupe/session identity -- outbound dispatch into sidecar `/send` - -The sidecar owns: - -- OpenClaw installation/runtime state -- plugin install/update commands -- Weixin QR login and login-state persistence -- Feishu/Lark plugin install, bot creation/linking, and OpenClaw-side verification -- platform receive loops -- sidecar-to-Beaver inbound event delivery - -## Runtime Flow - -Inbound: - -```text -Weixin or Feishu/Lark platform event --> OpenClaw plugin inside sidecar --> sidecar normalized event --> POST Beaver /api/channel-connector-bridge/events --> ChannelRuntime.accept_inbound() --> MessageBus --> AgentService -``` - -Outbound: - -```text -AgentService --> MessageBus outbound --> ChannelManager.dispatch_outbound() --> ExternalConnectorChannel.send() --> POST sidecar /send --> OpenClaw plugin send path --> Weixin or Feishu/Lark platform -``` - -`ExternalConnectorChannel` implements the existing runtime channel protocol: - -```python -channel_id: str -kind: str -mode: str - -async def start() -> None -async def stop() -> None -async def send(message: OutboundMessage) -> None -``` - -It is not a platform protocol adapter. It is a generic HTTP bridge to a sidecar. - -Runtime materialization for sidecar-backed connections always emits: - -```python -ChannelConfig( - enabled=True, - kind="external_connector", - mode="http", - account_id=spec.account_id, - display_name=spec.display_name, - config={ - "platformKind": "weixin", - "connectionId": "conn_...", - "sidecarBaseUrl": "http://openclaw-connector:8787", - }, - secrets={"bridgeToken": "..."}, -) -``` - -The original `ChannelConnection.kind` remains `weixin` or `feishu`; only the runtime transport kind is generic. - -## Sidecar Deployment - -Add a sidecar service that can be enabled in deployment: - -```yaml -services: - openclaw-connector: - build: ./openclaw-connector - restart: unless-stopped - environment: - BEAVER_BRIDGE_BASE_URL: http://app-instance:8080 - BEAVER_BRIDGE_TOKEN: ${BEAVER_BRIDGE_TOKEN} - OPENCLAW_HOME: /var/lib/openclaw - volumes: - - openclaw-connector-state:/var/lib/openclaw -``` - -For the current `create-instance.sh`-style deployment, the implementation adds: - -- `docker-compose.openclaw.yml` for local/development sidecar tests. -- documentation for attaching `openclaw-connector` to the same Docker network as the target app instance. -- instance environment `OPENCLAW_CONNECTOR_BASE_URL=http://openclaw-connector:8787`. - -The implementation must not depend on Beaver mounting `/var/run/docker.sock`. - -## Sidecar HTTP API - -All sidecar requests and responses are JSON. The sidecar listens on port `8787`. - -```text -GET /health -GET /connectors -POST /pairings -GET /pairings/{pairing_id} -POST /pairings/{pairing_id}/cancel -POST /connections/{connection_id}/logout -POST /send -``` - -`GET /connectors` returns: - -```json -[ - { - "kind": "weixin", - "displayName": "Weixin", - "authType": "qr", - "capabilities": ["receive_text", "send_text", "receive_media", "direct_messages"] - }, - { - "kind": "feishu", - "displayName": "Feishu/Lark", - "authType": "openclaw_plugin", - "capabilities": ["receive_text", "send_text", "receive_media", "groups"] - } -] -``` - -`POST /pairings` request: - -```json -{ - "kind": "weixin", - "connectionId": "conn_...", - "channelId": "weixin-main", - "displayName": "Weixin Main", - "callbackBaseUrl": "http://app-instance:8080", - "bridgeToken": "..." -} -``` - -For Feishu/Lark, `kind` is `feishu` and the request may include `domain`, `mode`, and optional app credentials when linking an existing bot. If using the OpenClaw official installer to create a bot, the sidecar starts that installer flow and reports QR or action status back to Beaver. - -`GET /pairings/{pairing_id}` response: - -```json -{ - "pairingId": "pair_...", - "kind": "weixin", - "status": "pending", - "qrCode": "weixin://...", - "qrImage": "data:image/png;base64,...", - "accountId": null, - "displayName": null, - "error": null, - "metadata": {} -} -``` - -Allowed pairing statuses: - -- `pending` -- `qr_ready` -- `scanned` -- `confirmed` -- `connected` -- `expired` -- `error` -- `cancelled` - -`POST /send` request: - -```json -{ - "connectionId": "conn_...", - "channelId": "weixin-main", - "kind": "weixin", - "target": { - "peerId": "wx_user_or_chat_id", - "peerType": "dm", - "threadId": null - }, - "content": "reply text", - "metadata": { - "contextToken": "optional" - } -} -``` - -## Beaver Bridge API - -Add a backend bridge endpoint for sidecar inbound messages: - -```text -POST /api/channel-connector-bridge/events -``` - -The sidecar must authenticate every bridge request using a bearer token scoped to the connector service. Beaver rejects missing or invalid bridge tokens. - -Bridge event body: - -```json -{ - "connectionId": "conn_...", - "channelId": "weixin-main", - "kind": "weixin", - "accountId": "weixin:...", - "peerId": "wx_user_or_chat_id", - "peerType": "dm", - "userId": "wx_sender", - "threadId": null, - "messageId": "platform-message-id", - "messageType": "text", - "content": "hello", - "metadata": { - "contextToken": "optional" - } -} -``` - -The bridge endpoint constructs `ChannelIdentity`, then `InboundMessage`, then calls `ChannelRuntime.accept_inbound()`. - -## Beaver Connectors - -### WeixinConnector - -Responsibilities: - -- discover sidecar health -- start Weixin pairing through sidecar `/pairings` -- poll sidecar pairing status -- create or update `ChannelConnection` -- store sidecar connection token or state reference in `CredentialStore` -- validate by checking sidecar connection status -- materialize runtime config for `ExternalConnectorChannel` -- revoke/logout by calling sidecar `/connections/{connection_id}/logout` - -### FeishuConnector - -Responsibilities: - -- discover sidecar health -- start Feishu/Lark OpenClaw plugin install/link flow -- optionally pass appId/appSecret/domain/mode for existing bot linking -- poll installer/pairing status -- create or update `ChannelConnection` -- validate by sidecar `/pairings/{id}` or connector status -- materialize runtime config for `ExternalConnectorChannel` -- revoke/remove plugin connection by calling sidecar logout/remove API - -Feishu is sidecar-backed in this design because the user's supplied Feishu article describes the official OpenClaw plugin flow, not only a static bot-credential runtime adapter. - -## Frontend - -Replace the old static Weixin fields with connector-driven UI: - -- fetch `GET /api/channel-connectors` -- show Telegram, Weixin, and Feishu/Lark as connector options -- for Weixin: - - start pairing - - show QR image - - poll status until connected/expired/error - - show connected account and logout -- for Feishu/Lark: - - choose create bot or link existing bot - - collect domain and optional app credentials - - start sidecar pairing/install - - show QR/instructions/status returned by sidecar - - show connected account and logout - -The old `/api/channels` static config editor may remain for advanced runtime config, but connector onboarding should not rely on manual JSON editing or direct token entry for Weixin/Feishu. - -## Error Handling - -- Sidecar unavailable: show connector as `unavailable`; do not create a running connection. -- OpenClaw install command fails: status `error`, with redacted stderr summary. -- QR expired: status `expired`, user can start a new pairing. -- Bridge token invalid: reject with `401`, record event without platform secret values. -- Unknown connection id in bridge event: reject with `404`. -- Outbound send failure: mark outbound delivery failed and record connector error. -- Sidecar restart: persisted OpenClaw state should survive through sidecar volume. - -## Security - -- Beaver never logs raw tokens, app secrets, or sidecar connection tokens. -- Bridge token is generated by Beaver and stored behind `credentials_ref`. -- Sidecar can only call bridge endpoints with its bridge token. -- Sidecar state volume contains OpenClaw login state and must be treated as sensitive. -- Feishu user-identity mode has stronger privacy risk than bot-identity mode; UI must label it clearly if exposed. - -## Testing - -Backend unit tests: - -- sidecar client fake for Weixin pairing start/status/logout/send -- sidecar client fake for Feishu pairing start/status/logout/send -- `ExternalConnectorChannel.send()` target mapping -- bridge endpoint accepts valid events and rejects invalid token/connection id -- registry lists `telegram`, `weixin`, and `feishu` -- materialized sidecar connections produce `ChannelConfig(kind="external_connector", mode="http")` compatible with runtime factory - -Sidecar tests: - -- HTTP API shape for health/connectors/pairings/send -- fake OpenClaw provider status transitions -- command runner error redaction - -Frontend tests: - -- Weixin connector option opens QR modal -- polling reaches connected state -- expired/error states are visible -- Feishu flow starts install/link and shows returned instructions/status - -Manual verification: - -- Build app and sidecar Docker images. -- Start docker-compose sidecar setup. -- In `terminaltest`, open Weixin connector, scan QR, observe connected status. -- Send a Weixin text message and verify Beaver receives it. -- Send a Beaver reply and verify sidecar `/send` path. -- Start Feishu connector flow using official OpenClaw Lark plugin install path and verify `/feishu start`. - -## Rollout - -Implement in this order: - -1. Sidecar HTTP contract with fake provider. -2. Beaver `ExternalConnectorChannel` and bridge endpoint. -3. Weixin connector against fake sidecar client. -4. Feishu connector against fake sidecar client. -5. Frontend connector UI. -6. Real sidecar provider that shells out to OpenClaw/OpenClaw plugin commands. -7. Docker build/compose integration. -8. Manual live verification. - -The fake provider is test-only. The production sidecar provider must use real OpenClaw plugin commands for Weixin and Feishu/Lark; the fake provider only makes Beaver and frontend tests deterministic while the live provider handles the non-deterministic external login flow. From feeaccc0e3f78dc6e3bbf86d6edd1940f2a8b26d Mon Sep 17 00:00:00 2001 From: steven_li Date: Wed, 3 Jun 2026 09:12:30 +0800 Subject: [PATCH 08/11] docs: tighten external connector contract --- ...6-02-external-sidecar-connectors-design.md | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-06-02-external-sidecar-connectors-design.md b/docs/superpowers/specs/2026-06-02-external-sidecar-connectors-design.md index 32ce091..b6c851e 100644 --- a/docs/superpowers/specs/2026-06-02-external-sidecar-connectors-design.md +++ b/docs/superpowers/specs/2026-06-02-external-sidecar-connectors-design.md @@ -15,6 +15,8 @@ This design intentionally fixes four architecture constraints before implementat - The sidecar is generic. Beaver depends on a connector HTTP contract, not on one vendor runtime. - Pairing is modeled as a broader `ConnectorSession`, because Feishu/Lark install/link flows are not only QR pairing. - Bridge events include `eventId`, `timestamp`, and `deliveryAttempt`, and Beaver dedupes bridge events before they can trigger duplicate agent replies. +- Bridge authentication is service-level in the first version. The shared connector token lives in environment variables, not per-connection credentials. +- Outbound sidecar sends include a required `requestId` so sidecar retries are idempotent. - Connected sessions dynamically register runtime channels. A successful Weixin or Feishu/Lark connection must not require a Beaver restart. ## Scope @@ -60,7 +62,7 @@ Beaver owns: - connection state in `ChannelConnectionStore` - credential references in `CredentialStore` - connector session state exposed to the web UI -- bridge endpoint authentication +- service-level connector authentication - bridge event dedupe - normalized runtime message admission - runtime channel lifecycle @@ -161,12 +163,14 @@ ChannelConfig( "connectionId": "conn_...", "sidecarBaseUrl": "http://external-connector:8787", }, - secrets={"bridgeToken": "..."}, + secrets={}, ) ``` The original `ChannelConnection.kind` remains `weixin` or `feishu`; only the runtime transport kind is generic. +`ExternalConnectorChannel` authenticates outbound calls with the service-level connector token configured in Beaver's process environment, not with a per-channel secret. The same first-version deployment may use one shared token value for both directions, exposed as `EXTERNAL_CONNECTOR_TOKEN` to Beaver and `BEAVER_BRIDGE_TOKEN` to the sidecar. + ## Dynamic Runtime Activation A connected connector session must activate without restarting Beaver. @@ -181,6 +185,15 @@ async def remove_channel(self, channel_id: str) -> None: ... ``` +`add_channel()` must run under a runtime lifecycle lock and has deterministic duplicate semantics: + +- Same `channel_id` and same effective `ChannelConfig`: no-op. +- Same `channel_id` and changed effective `ChannelConfig`: build and start the replacement adapter before swapping it into the manager; after the swap succeeds, stop the old adapter. +- Replacement start failure: keep the old adapter registered and running, and return the failure to the caller. +- First registration after runtime start: build the adapter, register it, then start only that adapter. + +`remove_channel()` must also run under the lifecycle lock. Missing channel ids are no-op; existing channels are stopped and unregistered. + When a connector session reaches `connected`: ```text @@ -216,6 +229,7 @@ services: environment: BEAVER_BRIDGE_BASE_URL: http://app-instance:8080 BEAVER_BRIDGE_TOKEN: ${BEAVER_BRIDGE_TOKEN} + CONNECTOR_API_TOKEN: ${EXTERNAL_CONNECTOR_TOKEN} CONNECTOR_HOME: /var/lib/external-connector CONNECTOR_PROVIDER: vendor_cli volumes: @@ -227,6 +241,7 @@ For the current `create-instance.sh`-style deployment, the implementation adds: - `docker-compose.external-connectors.yml` for local/development sidecar tests. - documentation for attaching `external-connector` to the same Docker network as the target app instance. - instance environment `EXTERNAL_CONNECTOR_BASE_URL=http://external-connector:8787`. +- instance environment `EXTERNAL_CONNECTOR_TOKEN=`. The implementation must not depend on Beaver mounting `/var/run/docker.sock`. @@ -274,11 +289,12 @@ POST /send "channelId": "weixin-main", "displayName": "Weixin Main", "callbackBaseUrl": "http://app-instance:8080", - "bridgeToken": "...", "options": {} } ``` +The sidecar authenticates the connector-session request with `Authorization: Bearer `. It already has `BEAVER_BRIDGE_TOKEN` from its environment, so Beaver does not send bridge tokens in connector-session bodies. + For Feishu/Lark, `kind` is `feishu` and `options` may include `domain`, `mode`, and optional app credentials when linking an existing bot. If using the official plugin installer to create a bot, the sidecar starts that installer flow and reports QR, instruction, or action status back to Beaver. `GET /connector-sessions/{session_id}` response: @@ -315,6 +331,7 @@ Allowed connector session statuses: ```json { + "requestId": "out_...", "connectionId": "conn_...", "channelId": "weixin-main", "kind": "weixin", @@ -330,6 +347,8 @@ Allowed connector session statuses: } ``` +`requestId` is required. Beaver must generate a stable request id for each outbound delivery attempt from the outbound message identity, and must reuse the same `requestId` if the same outbound delivery is retried. The sidecar dedupes `connectionId + requestId`; duplicate requests return the original send result and must not send a second platform message. + ## Beaver Bridge API Add a backend bridge endpoint for sidecar inbound messages: @@ -338,7 +357,7 @@ Add a backend bridge endpoint for sidecar inbound messages: POST /api/channel-connector-bridge/events ``` -The sidecar must authenticate every bridge request using a bearer token scoped to the connector service. Beaver rejects missing or invalid bridge tokens. +The sidecar must authenticate every bridge request using the service-level bearer token from `BEAVER_BRIDGE_TOKEN`. Beaver rejects missing or invalid bridge tokens. Bridge tokens are deployment secrets, not connection records. Bridge event body: @@ -405,7 +424,12 @@ class ConnectorMessageDedupeRecord: - `completed` - `failed` -If a duplicate bridge event arrives while the record is `processing` or `completed`, Beaver returns an idempotent success response and does not call `ChannelRuntime.accept_inbound()` again. +Duplicate handling: + +- `completed`: return idempotent success and do not call `ChannelRuntime.accept_inbound()` again. +- `processing` updated less than 60 seconds ago: return `409 Conflict` with `{"retryAfterSeconds": 5}` so the sidecar retries later. +- `processing` updated 60 seconds or more ago: treat the record as stale, increment `delivery_attempts`, update `updated_at`, and reprocess the event. +- `failed`: allow reprocessing on the next delivery attempt, increment `delivery_attempts`, and clear `last_error` before calling runtime. This store is separate from runtime session dedupe. Runtime dedupe still protects platform message identity, while bridge dedupe protects connector retries. @@ -419,7 +443,7 @@ Responsibilities: - start Weixin connector session through sidecar `/connector-sessions` - poll sidecar connector session status - create or update `ChannelConnection` -- store bridge token and sidecar connection state reference in `CredentialStore` +- store sidecar connection state reference in `CredentialStore` when the provider returns one - validate by checking sidecar connection status - materialize runtime config for `ExternalConnectorChannel` - activate runtime via `ChannelRuntime.add_channel()` when connected @@ -470,15 +494,18 @@ The old `/api/channels` static config editor may remain for advanced runtime con - QR expired: status `expired`, user can start a new connector session. - Bridge token invalid: reject with `401`, record event without platform secret values. - Unknown connection id in bridge event: reject with `404`. -- Duplicate bridge event: return idempotent success and do not call runtime again. +- Duplicate completed bridge event: return idempotent success and do not call runtime again. +- Duplicate in-flight bridge event: return `409 Conflict` until the 60-second processing TTL expires, then allow one reprocess. - Outbound send failure: mark outbound delivery failed and record connector error. +- Duplicate outbound send `requestId`: sidecar returns the original send result and does not send a second platform message. - Sidecar restart: persisted provider state should survive through sidecar volume. ## Security - Beaver never logs raw tokens, app secrets, bridge tokens, or sidecar connection tokens. -- Bridge token is generated by Beaver and stored behind `credentials_ref`. -- Sidecar can only call bridge endpoints with its bridge token. +- Bridge authentication uses a service-level token from environment variables. It is not stored per connection and is never returned by APIs. +- Sidecar can only call bridge endpoints with the service-level bridge token. +- Beaver can only call sidecar control and send endpoints with the service-level connector token. - Sidecar state volume contains login state and must be treated as sensitive. - Feishu user-identity mode has stronger privacy risk than bot-identity mode; UI must label it clearly if exposed. @@ -489,11 +516,14 @@ Backend unit tests: - sidecar client fake for Weixin connector session start/status/logout/send - sidecar client fake for Feishu connector session start/status/logout/send - `ExternalConnectorChannel.send()` target mapping +- `ExternalConnectorChannel.send()` includes stable `requestId` and connector bearer auth - `ChannelRuntime.add_channel()` dynamically starts and registers a channel +- `ChannelRuntime.add_channel()` no-ops for identical config, replaces changed config, and keeps the old channel if replacement start fails - `ChannelRuntime.remove_channel()` stops and unregisters a channel - bridge endpoint accepts valid events - bridge endpoint rejects invalid token and unknown connection id - bridge endpoint dedupes repeated `eventId` and calls runtime once +- bridge endpoint returns `409 Conflict` for non-stale `processing` duplicates and reprocesses stale records - registry lists `telegram`, `weixin`, and `feishu` - materialized sidecar connections produce `ChannelConfig(kind="external_connector", mode="http")` compatible with runtime factory @@ -502,6 +532,7 @@ Sidecar tests: - HTTP API shape for health/connectors/connector-sessions/send - fake provider status transitions - provider command runner error redaction +- send idempotency for duplicate `connectionId + requestId` Frontend tests: From d335199a64c2fcf75663716d444c0b265599408d Mon Sep 17 00:00:00 2001 From: steven_li Date: Wed, 3 Jun 2026 09:24:06 +0800 Subject: [PATCH 09/11] docs: add external connector implementation plans --- ...6-03-external-connector-backend-runtime.md | 1599 +++++++++++++++++ ...6-03-external-connector-frontend-deploy.md | 790 ++++++++ .../2026-06-03-external-connector-sidecar.md | 1167 ++++++++++++ 3 files changed, 3556 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-03-external-connector-backend-runtime.md create mode 100644 docs/superpowers/plans/2026-06-03-external-connector-frontend-deploy.md create mode 100644 docs/superpowers/plans/2026-06-03-external-connector-sidecar.md diff --git a/docs/superpowers/plans/2026-06-03-external-connector-backend-runtime.md b/docs/superpowers/plans/2026-06-03-external-connector-backend-runtime.md new file mode 100644 index 0000000..1e818f0 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-external-connector-backend-runtime.md @@ -0,0 +1,1599 @@ +# External Connector Backend Runtime Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Beaver backend support for sidecar-backed Weixin and Feishu connector sessions, bridge-event dedupe, outbound sidecar delivery, and dynamic runtime channel activation. + +**Architecture:** Beaver depends only on a generic connector HTTP contract. Sidecar-backed connections materialize as `ChannelConfig(kind="external_connector", mode="http")`, are registered dynamically through `ChannelRuntime.add_channel()`, and accept inbound events through an authenticated bridge endpoint with `MessageDedupeStore` idempotency. + +**Tech Stack:** Python dataclasses, FastAPI, Pydantic v2, local JSON stores, pytest, existing Beaver channel runtime. + +--- + +## Scope + +Included: + +- JSON-backed bridge event dedupe with 60-second stale processing TTL. +- `ExternalConnectorChannel` adapter for outbound `/send` calls with stable `requestId`. +- Runtime and manager dynamic add/remove channel support. +- Beaver sidecar HTTP client. +- Weixin and Feishu connector registry entries backed by fake sidecar clients in tests. +- Backend APIs for connector sessions and bridge inbound events. + +Excluded: + +- Sidecar process implementation. +- Frontend connector wizard. +- Docker compose integration. +- Live vendor CLI verification. + +## File Structure + +- Create `app-instance/backend/beaver/interfaces/channels/connections/dedupe.py` + - `ConnectorMessageDedupeRecord` and `MessageDedupeStore`. +- Create `app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py` + - Async HTTP client for generic sidecar contract. +- Create `app-instance/backend/beaver/interfaces/channels/connections/external.py` + - `ExternalConnectorBase`, `WeixinConnector`, and `FeishuConnector`. +- Create `app-instance/backend/beaver/interfaces/channels/external_connector.py` + - Runtime adapter that sends outbound messages to the sidecar. +- Modify `app-instance/backend/beaver/interfaces/channels/manager.py` + - Allow dynamic register/unregister while manager is started. +- Modify `app-instance/backend/beaver/interfaces/channels/runtime.py` + - Add lifecycle lock, `add_channel()`, `remove_channel()`, and adapter factory support for `external_connector/http`. +- Modify `app-instance/backend/beaver/interfaces/channels/connections/connectors.py` + - Materialize sidecar-backed connections and optionally activate runtime. +- Modify `app-instance/backend/beaver/interfaces/channels/connections/__init__.py` + - Export new dedupe, client, and connector symbols. +- Modify `app-instance/backend/beaver/interfaces/web/schemas/chat.py` + - Add connector session and bridge schemas. +- Modify `app-instance/backend/beaver/interfaces/web/schemas/__init__.py` + - Export new schemas. +- Modify `app-instance/backend/beaver/interfaces/web/app.py` + - Register Weixin/Feishu connectors, sidecar settings, connector session routes, and bridge endpoint. +- Test `app-instance/backend/tests/unit/test_connector_message_dedupe_store.py` +- Test `app-instance/backend/tests/unit/test_external_connector_channel.py` +- Test `app-instance/backend/tests/unit/test_channel_runtime_dynamic_channels.py` +- Test `app-instance/backend/tests/unit/test_external_connector_bridge_api.py` +- Test `app-instance/backend/tests/unit/test_external_sidecar_connectors.py` + +--- + +### Task 1: Message Dedupe Store + +**Files:** +- Create: `app-instance/backend/beaver/interfaces/channels/connections/dedupe.py` +- Modify: `app-instance/backend/beaver/interfaces/channels/connections/__init__.py` +- Test: `app-instance/backend/tests/unit/test_connector_message_dedupe_store.py` + +- [ ] **Step 1: Write failing dedupe tests** + +Create `app-instance/backend/tests/unit/test_connector_message_dedupe_store.py`: + +```python +from __future__ import annotations + +from beaver.interfaces.channels.connections import MessageDedupeStore + + +def test_message_dedupe_store_completes_and_dedupes_completed(tmp_path) -> None: + store = MessageDedupeStore(tmp_path / "message_dedupe.json") + + first = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1) + store.complete(first.dedupe_key, message_id="msg_1") + duplicate = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2) + + assert first.should_process is True + assert duplicate.should_process is False + assert duplicate.status == "completed" + assert duplicate.http_status == 200 + + +def test_message_dedupe_store_returns_conflict_for_active_processing(tmp_path) -> None: + store = MessageDedupeStore(tmp_path / "message_dedupe.json", processing_ttl_seconds=60) + + store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1) + duplicate = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2) + + assert duplicate.should_process is False + assert duplicate.status == "processing" + assert duplicate.http_status == 409 + assert duplicate.retry_after_seconds == 5 + + +def test_message_dedupe_store_reprocesses_stale_processing(tmp_path) -> None: + store = MessageDedupeStore(tmp_path / "message_dedupe.json", processing_ttl_seconds=0) + + store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1) + stale = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2) + + assert stale.should_process is True + assert stale.status == "processing" + assert stale.record.delivery_attempts == 2 + + +def test_message_dedupe_store_reprocesses_failed_records(tmp_path) -> None: + store = MessageDedupeStore(tmp_path / "message_dedupe.json") + + first = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1) + store.fail(first.dedupe_key, error="runtime rejected") + retry = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2) + + assert retry.should_process is True + assert retry.record.delivery_attempts == 2 + assert retry.record.last_error is None +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_connector_message_dedupe_store.py -q +``` + +Expected: fail with `ImportError: cannot import name 'MessageDedupeStore'`. + +- [ ] **Step 3: Implement dedupe models and store** + +Create `app-instance/backend/beaver/interfaces/channels/connections/dedupe.py` with: + +```python +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from threading import Lock +from typing import Any + + +def _iso_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _parse_iso(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +@dataclass(slots=True) +class ConnectorMessageDedupeRecord: + dedupe_key: str + connection_id: str + event_id: str + status: str + first_seen_at: str + updated_at: str + delivery_attempts: int + message_id: str | None = None + last_error: str | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ConnectorMessageDedupeRecord": + return cls( + dedupe_key=str(data.get("dedupe_key") or ""), + connection_id=str(data.get("connection_id") or ""), + event_id=str(data.get("event_id") or ""), + status=str(data.get("status") or "processing"), + first_seen_at=str(data.get("first_seen_at") or _iso_now()), + updated_at=str(data.get("updated_at") or _iso_now()), + delivery_attempts=int(data.get("delivery_attempts") or 0), + message_id=str(data["message_id"]) if data.get("message_id") is not None else None, + last_error=str(data["last_error"]) if data.get("last_error") is not None else None, + ) + + +@dataclass(slots=True) +class DedupeBeginResult: + should_process: bool + dedupe_key: str + status: str + http_status: int + retry_after_seconds: int | None + record: ConnectorMessageDedupeRecord + + +class MessageDedupeStore: + def __init__(self, path: Path, *, processing_ttl_seconds: int = 60) -> None: + self.path = Path(path) + self.processing_ttl_seconds = int(processing_ttl_seconds) + self._lock = Lock() + + def begin(self, *, connection_id: str, event_id: str, delivery_attempt: int) -> DedupeBeginResult: + dedupe_key = f"{connection_id}:{event_id}" + now = _iso_now() + with self._lock: + data = self._load() + raw = data["records"].get(dedupe_key) + if isinstance(raw, dict): + record = ConnectorMessageDedupeRecord.from_dict(raw) + if record.status == "completed": + return DedupeBeginResult(False, dedupe_key, record.status, 200, None, record) + if record.status == "processing" and not self._is_stale(record, now): + return DedupeBeginResult(False, dedupe_key, record.status, 409, 5, record) + record.status = "processing" + record.updated_at = now + record.delivery_attempts = max(record.delivery_attempts + 1, int(delivery_attempt)) + record.last_error = None + else: + record = ConnectorMessageDedupeRecord( + dedupe_key=dedupe_key, + connection_id=connection_id, + event_id=event_id, + status="processing", + first_seen_at=now, + updated_at=now, + delivery_attempts=max(1, int(delivery_attempt)), + ) + data["records"][dedupe_key] = record.to_dict() + self._save(data) + return DedupeBeginResult(True, dedupe_key, record.status, 200, None, record) + + def complete(self, dedupe_key: str, *, message_id: str | None) -> ConnectorMessageDedupeRecord: + return self._mark(dedupe_key, status="completed", message_id=message_id, error=None) + + def fail(self, dedupe_key: str, *, error: str) -> ConnectorMessageDedupeRecord: + return self._mark(dedupe_key, status="failed", message_id=None, error=error) + + def _mark( + self, + dedupe_key: str, + *, + status: str, + message_id: str | None, + error: str | None, + ) -> ConnectorMessageDedupeRecord: + with self._lock: + data = self._load() + raw = data["records"].get(dedupe_key) + if not isinstance(raw, dict): + raise KeyError(dedupe_key) + record = ConnectorMessageDedupeRecord.from_dict(raw) + record.status = status + record.updated_at = _iso_now() + record.message_id = message_id or record.message_id + record.last_error = error + data["records"][dedupe_key] = record.to_dict() + self._save(data) + return record + + def _is_stale(self, record: ConnectorMessageDedupeRecord, now: str) -> bool: + age = (_parse_iso(now) - _parse_iso(record.updated_at)).total_seconds() + return age >= self.processing_ttl_seconds + + def _load(self) -> dict[str, Any]: + if not self.path.exists(): + return {"records": {}} + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"records": {}} + if not isinstance(data, dict) or not isinstance(data.get("records"), dict): + return {"records": {}} + return data + + def _save(self, data: dict[str, Any]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = self.path.with_name(f"{self.path.name}.tmp") + tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + tmp_path.replace(self.path) +``` + +- [ ] **Step 4: Export dedupe symbols** + +Modify `app-instance/backend/beaver/interfaces/channels/connections/__init__.py`: + +```python +from .dedupe import ConnectorMessageDedupeRecord, DedupeBeginResult, MessageDedupeStore +``` + +Add these names to `__all__`. + +- [ ] **Step 5: Run dedupe tests** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_connector_message_dedupe_store.py -q +``` + +Expected: `4 passed`. + +- [ ] **Step 6: Commit Task 1** + +```bash +git add app-instance/backend/beaver/interfaces/channels/connections/dedupe.py app-instance/backend/beaver/interfaces/channels/connections/__init__.py app-instance/backend/tests/unit/test_connector_message_dedupe_store.py +git commit -m "feat: add connector bridge dedupe store" +``` + +--- + +### Task 2: External Connector Channel + +**Files:** +- Create: `app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py` +- Create: `app-instance/backend/beaver/interfaces/channels/external_connector.py` +- Modify: `app-instance/backend/beaver/interfaces/channels/__init__.py` +- Test: `app-instance/backend/tests/unit/test_external_connector_channel.py` + +- [ ] **Step 1: Write failing channel tests** + +Create `app-instance/backend/tests/unit/test_external_connector_channel.py`: + +```python +from __future__ import annotations + +import asyncio + +from beaver.foundation.events import ChannelIdentity, OutboundMessage +from beaver.interfaces.channels.external_connector import ExternalConnectorChannel + + +class FakeSidecarClient: + def __init__(self) -> None: + self.sent: list[dict] = [] + + async def send(self, payload: dict) -> dict: + self.sent.append(payload) + return {"ok": True, "providerMessageId": "provider-1"} + + +def test_external_connector_channel_sends_with_target_and_request_id() -> None: + async def run() -> None: + client = FakeSidecarClient() + channel = ExternalConnectorChannel( + channel_id="weixin-main", + platform_kind="weixin", + connection_id="conn_1", + account_id="weixin:me", + display_name="Weixin Main", + sidecar_client=client, + ) + message = OutboundMessage( + channel="weixin-main", + content="reply", + session_id="s1", + finish_reason="stop", + message_id="out-msg-1", + channel_identity=ChannelIdentity( + channel_id="weixin-main", + kind="weixin", + account_id="weixin:me", + peer_id="peer-1", + peer_type="dm", + thread_id=None, + user_id="sender-1", + message_id="in-msg-1", + ), + ) + + await channel.send(message) + + assert client.sent == [ + { + "requestId": "out_out-msg-1", + "connectionId": "conn_1", + "channelId": "weixin-main", + "kind": "weixin", + "target": {"peerId": "peer-1", "peerType": "dm", "threadId": None}, + "content": "reply", + "metadata": {"inboundMessageId": "in-msg-1", "sessionId": "s1"}, + } + ] + + asyncio.run(run()) + + +def test_external_connector_channel_requires_identity() -> None: + async def run() -> None: + channel = ExternalConnectorChannel( + channel_id="weixin-main", + platform_kind="weixin", + connection_id="conn_1", + account_id="weixin:me", + display_name="Weixin Main", + sidecar_client=FakeSidecarClient(), + ) + message = OutboundMessage(channel="weixin-main", content="reply", session_id="s1", finish_reason="stop") + + try: + await channel.send(message) + except ValueError as exc: + assert "channel_identity is required" in str(exc) + else: + raise AssertionError("Expected ValueError") + + asyncio.run(run()) +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_external_connector_channel.py -q +``` + +Expected: fail with `ModuleNotFoundError: No module named 'beaver.interfaces.channels.external_connector'`. + +- [ ] **Step 3: Implement sidecar client** + +Create `app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py`: + +```python +from __future__ import annotations + +from typing import Any + +import httpx + + +class ConnectorSidecarClient: + def __init__(self, *, base_url: str, token: str, timeout_seconds: float = 20.0) -> None: + self.base_url = base_url.rstrip("/") + self.token = token + self.timeout_seconds = float(timeout_seconds) + + async def get_connectors(self) -> list[dict[str, Any]]: + return await self._request("GET", "/connectors") + + async def start_session(self, payload: dict[str, Any]) -> dict[str, Any]: + return await self._request("POST", "/connector-sessions", json=payload) + + async def get_session(self, session_id: str) -> dict[str, Any]: + return await self._request("GET", f"/connector-sessions/{session_id}") + + async def cancel_session(self, session_id: str) -> dict[str, Any]: + return await self._request("POST", f"/connector-sessions/{session_id}/cancel", json={}) + + async def logout(self, connection_id: str) -> dict[str, Any]: + return await self._request("POST", f"/connections/{connection_id}/logout", json={}) + + async def send(self, payload: dict[str, Any]) -> dict[str, Any]: + return await self._request("POST", "/send", json=payload) + + async def _request(self, method: str, path: str, *, json: dict[str, Any] | None = None) -> Any: + headers = {"Authorization": f"Bearer {self.token}"} if self.token else {} + async with httpx.AsyncClient(timeout=self.timeout_seconds) as client: + response = await client.request(method, f"{self.base_url}{path}", json=json, headers=headers) + response.raise_for_status() + return response.json() +``` + +- [ ] **Step 4: Implement external channel** + +Create `app-instance/backend/beaver/interfaces/channels/external_connector.py`: + +```python +from __future__ import annotations + +from typing import Any + +from beaver.foundation.events import OutboundMessage +from beaver.interfaces.channels.connections.sidecar_client import ConnectorSidecarClient + + +class ExternalConnectorChannel: + def __init__( + self, + *, + channel_id: str, + platform_kind: str, + connection_id: str, + account_id: str, + display_name: str, + sidecar_client: ConnectorSidecarClient | Any, + ) -> None: + self.channel_id = channel_id + self.kind = "external_connector" + self.mode = "http" + self.platform_kind = platform_kind + self.connection_id = connection_id + self.account_id = account_id + self.display_name = display_name or channel_id + self.sidecar_client = sidecar_client + self.started = False + + async def start(self) -> None: + self.started = True + + async def stop(self) -> None: + self.started = False + + async def send(self, message: OutboundMessage) -> None: + identity = message.channel_identity + if identity is None: + raise ValueError("channel_identity is required for external connector sends") + payload = { + "requestId": _request_id(message), + "connectionId": self.connection_id, + "channelId": self.channel_id, + "kind": self.platform_kind, + "target": { + "peerId": identity.peer_id, + "peerType": identity.peer_type, + "threadId": identity.thread_id, + }, + "content": message.content, + "metadata": { + "inboundMessageId": identity.message_id, + "sessionId": message.session_id, + }, + } + await self.sidecar_client.send(payload) + + +def _request_id(message: OutboundMessage) -> str: + return f"out_{message.message_id}" +``` + +- [ ] **Step 5: Export channel symbol** + +Modify `app-instance/backend/beaver/interfaces/channels/__init__.py`: + +```python +from .external_connector import ExternalConnectorChannel +``` + +Add `ExternalConnectorChannel` to `__all__`. + +- [ ] **Step 6: Run channel tests** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_external_connector_channel.py -q +``` + +Expected: `2 passed`. + +- [ ] **Step 7: Commit Task 2** + +```bash +git add app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py app-instance/backend/beaver/interfaces/channels/external_connector.py app-instance/backend/beaver/interfaces/channels/__init__.py app-instance/backend/tests/unit/test_external_connector_channel.py +git commit -m "feat: add external connector channel" +``` + +--- + +### Task 3: Dynamic Runtime Channels + +**Files:** +- Modify: `app-instance/backend/beaver/interfaces/channels/manager.py` +- Modify: `app-instance/backend/beaver/interfaces/channels/runtime.py` +- Test: `app-instance/backend/tests/unit/test_channel_runtime_dynamic_channels.py` + +- [ ] **Step 1: Write failing dynamic runtime tests** + +Create `app-instance/backend/tests/unit/test_channel_runtime_dynamic_channels.py`: + +```python +from __future__ import annotations + +import asyncio + +from beaver.foundation.config.schema import ChannelConfig +from beaver.foundation.events import MessageBus, OutboundMessage +from beaver.interfaces.channels.runtime import ChannelRuntime + + +class FakeService: + async def handle_inbound_message(self, inbound): + return OutboundMessage(channel=inbound.channel, content="ok", session_id=inbound.session_id, finish_reason="stop") + + +def test_runtime_add_channel_starts_new_channel_after_runtime_start(tmp_path) -> None: + async def run() -> None: + runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus()) + await runtime.start() + try: + await runtime.add_channel( + "webhook-dev", + ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct"), + ) + assert "webhook-dev" in runtime.adapters + assert runtime.states["webhook-dev"]["state"] == "running" + finally: + await runtime.stop() + + asyncio.run(run()) + + +def test_runtime_add_channel_noops_for_same_config(tmp_path) -> None: + async def run() -> None: + cfg = ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct") + runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus()) + await runtime.start() + try: + await runtime.add_channel("webhook-dev", cfg) + first = runtime.adapters["webhook-dev"] + await runtime.add_channel("webhook-dev", cfg) + assert runtime.adapters["webhook-dev"] is first + finally: + await runtime.stop() + + asyncio.run(run()) + + +def test_runtime_replacement_failure_keeps_old_channel(tmp_path) -> None: + async def run() -> None: + good = ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct") + bad = ChannelConfig(enabled=True, kind="missing", mode="http", account_id="acct") + runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus()) + await runtime.start() + try: + await runtime.add_channel("webhook-dev", good) + old = runtime.adapters["webhook-dev"] + try: + await runtime.add_channel("webhook-dev", bad) + except ValueError: + pass + else: + raise AssertionError("Expected ValueError") + assert runtime.adapters["webhook-dev"] is old + assert runtime.channel_configs["webhook-dev"] == good + assert runtime.states["webhook-dev"]["state"] == "running" + finally: + await runtime.stop() + + asyncio.run(run()) + + +def test_runtime_remove_channel_stops_and_unregisters(tmp_path) -> None: + async def run() -> None: + runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus()) + await runtime.start() + try: + await runtime.add_channel( + "webhook-dev", + ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct"), + ) + await runtime.remove_channel("webhook-dev") + assert "webhook-dev" not in runtime.adapters + assert "webhook-dev" not in runtime.manager.channels + assert runtime.states["webhook-dev"]["state"] == "removed" + finally: + await runtime.stop() + + asyncio.run(run()) +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_channel_runtime_dynamic_channels.py -q +``` + +Expected: fail with `AttributeError: 'ChannelRuntime' object has no attribute 'add_channel'`. + +- [ ] **Step 3: Add dynamic manager methods** + +Modify `app-instance/backend/beaver/interfaces/channels/manager.py`: + +```python + def register(self, channel: ChannelAdapter) -> None: + if channel.channel_id in self.channels: + raise ValueError(f"Channel already registered: {channel.channel_id}") + self.channels[channel.channel_id] = channel + + def unregister(self, channel_id: str) -> ChannelAdapter | None: + return self.channels.pop(channel_id, None) + + def replace_registered(self, channel: ChannelAdapter) -> ChannelAdapter | None: + old = self.channels.get(channel.channel_id) + self.channels[channel.channel_id] = channel + return old +``` + +Keep `start()`, `stop()`, and `dispatch_outbound()` unchanged. + +- [ ] **Step 4: Add runtime lifecycle lock and methods** + +Modify `app-instance/backend/beaver/interfaces/channels/runtime.py`: + +```python + self._lifecycle_lock = asyncio.Lock() +``` + +Add methods to `ChannelRuntime`: + +```python + async def add_channel(self, channel_id: str, config: ChannelConfig) -> None: + async with self._lifecycle_lock: + current = self.channel_configs.get(channel_id) + if current == config and channel_id in self.adapters: + return + if not config.enabled: + await self.remove_channel(channel_id) + self.channel_configs[channel_id] = config + self.states[channel_id] = {"state": "disabled", "last_error": None} + return + adapter = self._build_adapter(channel_id, config) + await adapter.start() + old = self.manager.replace_registered(adapter) + old_adapter = self.adapters.get(channel_id) + self.adapters[channel_id] = adapter + self.channel_configs[channel_id] = config + self.states[channel_id] = {"state": "running", "last_error": None, "started_at": _iso_now()} + self.events.record(channel_id=channel_id, kind="adapter_started") + if old_adapter is not None and old_adapter is not adapter: + await old_adapter.stop() + + async def remove_channel(self, channel_id: str) -> None: + async with self._lifecycle_lock: + adapter = self.adapters.pop(channel_id, None) + self.manager.unregister(channel_id) + self.channel_configs.pop(channel_id, None) + if adapter is not None: + await adapter.stop() + self.events.record(channel_id=channel_id, kind="adapter_stopped") + self.states[channel_id] = {"state": "removed", "last_error": None} +``` + +If this direct implementation deadlocks because `add_channel()` calls `remove_channel()` under the same lock, split the locked removal body into a private `_remove_channel_locked()` helper and call that from both public methods. + +- [ ] **Step 5: Run dynamic runtime tests** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_channel_runtime_dynamic_channels.py tests/unit/test_channel_runtime.py tests/unit/test_gateway_channels.py -q +``` + +Expected: all listed tests pass. + +- [ ] **Step 6: Commit Task 3** + +```bash +git add app-instance/backend/beaver/interfaces/channels/manager.py app-instance/backend/beaver/interfaces/channels/runtime.py app-instance/backend/tests/unit/test_channel_runtime_dynamic_channels.py +git commit -m "feat: support dynamic runtime channels" +``` + +--- + +### Task 4: Runtime Factory For External Connector Channel + +**Files:** +- Modify: `app-instance/backend/beaver/interfaces/channels/runtime.py` +- Test: `app-instance/backend/tests/unit/test_channel_runtime_dynamic_channels.py` + +- [ ] **Step 1: Extend dynamic runtime tests** + +Append to `app-instance/backend/tests/unit/test_channel_runtime_dynamic_channels.py`: + +```python +def test_runtime_builds_external_connector_channel(tmp_path, monkeypatch) -> None: + async def run() -> None: + monkeypatch.setenv("EXTERNAL_CONNECTOR_TOKEN", "connector-token") + runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus()) + await runtime.start() + try: + await runtime.add_channel( + "weixin-main", + ChannelConfig( + enabled=True, + kind="external_connector", + mode="http", + account_id="weixin:me", + display_name="Weixin Main", + config={ + "platformKind": "weixin", + "connectionId": "conn_1", + "sidecarBaseUrl": "http://external-connector:8787", + }, + ), + ) + adapter = runtime.adapters["weixin-main"] + assert adapter.kind == "external_connector" + assert adapter.mode == "http" + assert getattr(adapter, "platform_kind") == "weixin" + finally: + await runtime.stop() + + asyncio.run(run()) +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_channel_runtime_dynamic_channels.py::test_runtime_builds_external_connector_channel -q +``` + +Expected: fail with `ValueError: Unsupported channel kind/mode: external_connector/http`. + +- [ ] **Step 3: Add runtime factory branch** + +Modify `_build_adapter()` in `app-instance/backend/beaver/interfaces/channels/runtime.py` before the final `raise`: + +```python + if cfg.kind == "external_connector" and cfg.mode == "http": + import os + + from beaver.interfaces.channels.connections.sidecar_client import ConnectorSidecarClient + from beaver.interfaces.channels.external_connector import ExternalConnectorChannel + + base_url = str(cfg.config.get("sidecarBaseUrl") or os.getenv("EXTERNAL_CONNECTOR_BASE_URL") or "").strip() + token = os.getenv("EXTERNAL_CONNECTOR_TOKEN", "") + platform_kind = str(cfg.config.get("platformKind") or "").strip() + connection_id = str(cfg.config.get("connectionId") or "").strip() + if not base_url: + raise ValueError("external connector sidecarBaseUrl is required") + if not platform_kind: + raise ValueError("external connector platformKind is required") + if not connection_id: + raise ValueError("external connector connectionId is required") + return ExternalConnectorChannel( + channel_id=channel_id, + platform_kind=platform_kind, + connection_id=connection_id, + account_id=cfg.account_id, + display_name=cfg.display_name, + sidecar_client=ConnectorSidecarClient(base_url=base_url, token=token), + ) +``` + +- [ ] **Step 4: Run tests** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_channel_runtime_dynamic_channels.py tests/unit/test_external_connector_channel.py -q +``` + +Expected: all listed tests pass. + +- [ ] **Step 5: Commit Task 4** + +```bash +git add app-instance/backend/beaver/interfaces/channels/runtime.py app-instance/backend/tests/unit/test_channel_runtime_dynamic_channels.py +git commit -m "feat: materialize external connector channels" +``` + +--- + +### Task 5: Bridge API + +**Files:** +- Modify: `app-instance/backend/beaver/interfaces/web/schemas/chat.py` +- Modify: `app-instance/backend/beaver/interfaces/web/schemas/__init__.py` +- Modify: `app-instance/backend/beaver/interfaces/web/app.py` +- Test: `app-instance/backend/tests/unit/test_external_connector_bridge_api.py` + +- [ ] **Step 1: Write failing bridge API tests** + +Create `app-instance/backend/tests/unit/test_external_connector_bridge_api.py`: + +```python +from __future__ import annotations + +from fastapi.testclient import TestClient + +from beaver.interfaces.channels.connections import ChannelConnectionStore, CredentialStore +from beaver.interfaces.web.app import create_app +from beaver.services.agent_service import AgentService + + +def _app(tmp_path, monkeypatch): + monkeypatch.setenv("BEAVER_BRIDGE_TOKEN", "bridge-token") + config_path = tmp_path / "config.json" + config_path.write_text( + '{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path), + encoding="utf-8", + ) + service = AgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + return app, service + + +def test_bridge_endpoint_accepts_valid_event(tmp_path, monkeypatch) -> None: + app, service = _app(tmp_path, monkeypatch) + state_dir = tmp_path / "state" / "channel_connections" + store = ChannelConnectionStore(state_dir / "connections.json") + connection = store.create( + kind="weixin", + mode="sidecar", + display_name="Weixin Main", + account_id="weixin:me", + owner_user_id=None, + auth_type="connector_session", + ) + store.update_status(connection.connection_id, status="connected", last_error=None) + try: + with TestClient(app) as client: + response = client.post( + "/api/channel-connector-bridge/events", + headers={"Authorization": "Bearer bridge-token"}, + json={ + "eventId": "evt-1", + "timestamp": "2026-06-02T09:30:00Z", + "deliveryAttempt": 1, + "connectionId": connection.connection_id, + "channelId": connection.channel_id, + "kind": "weixin", + "accountId": "weixin:me", + "peerId": "peer-1", + "peerType": "dm", + "userId": "sender-1", + "threadId": None, + "messageId": "msg-1", + "messageType": "text", + "content": "hello", + "metadata": {}, + }, + ) + assert response.status_code == 200 + assert response.json()["accepted"] is True + finally: + service.close() + + +def test_bridge_endpoint_rejects_invalid_token(tmp_path, monkeypatch) -> None: + app, service = _app(tmp_path, monkeypatch) + try: + with TestClient(app) as client: + response = client.post("/api/channel-connector-bridge/events", headers={"Authorization": "Bearer wrong"}, json={}) + assert response.status_code == 401 + finally: + service.close() + + +def test_bridge_endpoint_returns_conflict_for_processing_duplicate(tmp_path, monkeypatch) -> None: + app, service = _app(tmp_path, monkeypatch) + state_dir = tmp_path / "state" / "channel_connections" + store = ChannelConnectionStore(state_dir / "connections.json") + connection = store.create( + kind="weixin", + mode="sidecar", + display_name="Weixin Main", + account_id="weixin:me", + owner_user_id=None, + auth_type="connector_session", + ) + store.update_status(connection.connection_id, status="connected", last_error=None) + payload = { + "eventId": "evt-1", + "timestamp": "2026-06-02T09:30:00Z", + "deliveryAttempt": 1, + "connectionId": connection.connection_id, + "channelId": connection.channel_id, + "kind": "weixin", + "accountId": "weixin:me", + "peerId": "peer-1", + "peerType": "dm", + "userId": "sender-1", + "threadId": None, + "messageId": "msg-1", + "messageType": "text", + "content": "hello", + "metadata": {}, + } + try: + with TestClient(app) as client: + first = client.post("/api/channel-connector-bridge/events", headers={"Authorization": "Bearer bridge-token"}, json=payload) + second = client.post("/api/channel-connector-bridge/events", headers={"Authorization": "Bearer bridge-token"}, json={**payload, "deliveryAttempt": 2}) + assert first.status_code == 200 + assert second.status_code in {200, 409} + finally: + service.close() +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_external_connector_bridge_api.py -q +``` + +Expected: fail with `404 Not Found` for `/api/channel-connector-bridge/events`. + +- [ ] **Step 3: Add bridge schemas** + +Append to `app-instance/backend/beaver/interfaces/web/schemas/chat.py`: + +```python +class WebConnectorBridgeEventRequest(BaseModel): + event_id: str = Field(alias="eventId") + timestamp: str + delivery_attempt: int = Field(default=1, alias="deliveryAttempt") + connection_id: str = Field(alias="connectionId") + channel_id: str = Field(alias="channelId") + kind: str + account_id: str = Field(alias="accountId") + peer_id: str = Field(alias="peerId") + peer_type: str = Field(default="unknown", alias="peerType") + user_id: str | None = Field(default=None, alias="userId") + thread_id: str | None = Field(default=None, alias="threadId") + message_id: str = Field(alias="messageId") + message_type: str = Field(default="text", alias="messageType") + content: str + metadata: dict[str, Any] = Field(default_factory=dict) + + +class WebConnectorBridgeEventResponse(BaseModel): + accepted: bool + duplicate: bool = False + pending: bool = False + retry_after_seconds: int | None = Field(default=None, alias="retryAfterSeconds") +``` + +Export both in `app-instance/backend/beaver/interfaces/web/schemas/__init__.py`. + +- [ ] **Step 4: Add bridge endpoint** + +Modify `app-instance/backend/beaver/interfaces/web/app.py` imports to include: + +```python +from beaver.foundation.events import ChannelIdentity, InboundMessage +from beaver.interfaces.channels.connections import MessageDedupeStore +``` + +Add helpers: + +```python +def _bridge_token() -> str: + return os.getenv("BEAVER_BRIDGE_TOKEN", "") + + +def _message_dedupe_store(workspace: Path) -> MessageDedupeStore: + return MessageDedupeStore(_connection_state_dir(workspace) / "message_dedupe.json") +``` + +Add the route inside `create_app()`: + +```python + @app.post("/api/channel-connector-bridge/events", response_model=WebConnectorBridgeEventResponse) + async def accept_connector_bridge_event( + request: Request, + payload: WebConnectorBridgeEventRequest, + authorization: str | None = Header(default=None), + ) -> JSONResponse | WebConnectorBridgeEventResponse: + expected = _bridge_token() + if not expected or authorization != f"Bearer {expected}": + raise HTTPException(status_code=401, detail="Invalid connector bridge token") + registry = get_channel_connector_registry(request) + try: + connection = registry.connection_store.get(payload.connection_id) + except KeyError: + raise HTTPException(status_code=404, detail="Channel connection not found") + if connection.status == "revoked": + raise HTTPException(status_code=404, detail="Channel connection not found") + store = _message_dedupe_store(_channel_connection_workspace(request)) + begin = store.begin( + connection_id=payload.connection_id, + event_id=payload.event_id, + delivery_attempt=payload.delivery_attempt, + ) + if not begin.should_process: + body = WebConnectorBridgeEventResponse( + accepted=begin.http_status == 200, + duplicate=True, + pending=begin.http_status == 409, + retryAfterSeconds=begin.retry_after_seconds, + ).model_dump(by_alias=True) + return JSONResponse(status_code=begin.http_status, content=body) + runtime = get_channel_runtime(request) + identity = ChannelIdentity( + channel_id=payload.channel_id, + kind=payload.kind, + account_id=payload.account_id, + peer_id=payload.peer_id, + thread_id=payload.thread_id, + peer_type=payload.peer_type, + user_id=payload.user_id, + message_id=payload.message_id, + ) + inbound = InboundMessage( + channel=payload.channel_id, + content=payload.content, + content_type=payload.message_type, + channel_identity=identity, + user_id=payload.user_id, + message_id=payload.message_id, + metadata=dict(payload.metadata), + ) + result = await runtime.accept_inbound(inbound) + if result.accepted: + store.complete(begin.dedupe_key, message_id=payload.message_id) + else: + store.fail(begin.dedupe_key, error=result.error or "runtime rejected bridge event") + return WebConnectorBridgeEventResponse(accepted=result.accepted, duplicate=result.duplicate, pending=result.pending) +``` + +If `_channel_connection_workspace(request)` does not exist yet, add it: + +```python +def _channel_connection_workspace(request: Request) -> Path: + workspace = getattr(request.app.state, "channel_connection_workspace", None) + if workspace is not None: + return Path(workspace) + return Path(get_agent_service(request).loader.workspace) +``` + +- [ ] **Step 5: Run bridge tests** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_external_connector_bridge_api.py -q +``` + +Expected: all listed tests pass. If the duplicate test returns `200` because runtime completes before the second request, add a focused `MessageDedupeStore` API test for `409`; do not weaken the store behavior. + +- [ ] **Step 6: Commit Task 5** + +```bash +git add app-instance/backend/beaver/interfaces/web/app.py app-instance/backend/beaver/interfaces/web/schemas/chat.py app-instance/backend/beaver/interfaces/web/schemas/__init__.py app-instance/backend/tests/unit/test_external_connector_bridge_api.py +git commit -m "feat: add external connector bridge api" +``` + +--- + +### Task 6: Weixin And Feishu Connectors + +**Files:** +- Create: `app-instance/backend/beaver/interfaces/channels/connections/external.py` +- Modify: `app-instance/backend/beaver/interfaces/channels/connections/__init__.py` +- Modify: `app-instance/backend/beaver/interfaces/channels/connections/connectors.py` +- Modify: `app-instance/backend/beaver/interfaces/web/schemas/chat.py` +- Modify: `app-instance/backend/beaver/interfaces/web/schemas/__init__.py` +- Modify: `app-instance/backend/beaver/interfaces/web/app.py` +- Test: `app-instance/backend/tests/unit/test_external_sidecar_connectors.py` + +- [ ] **Step 1: Write failing connector tests** + +Create `app-instance/backend/tests/unit/test_external_sidecar_connectors.py`: + +```python +from __future__ import annotations + +import asyncio + +from beaver.interfaces.channels.connections import ( + ChannelConnectionStore, + CredentialStore, + FeishuConnector, + WeixinConnector, +) + + +class FakeSidecarClient: + def __init__(self) -> None: + self.sessions: dict[str, dict] = {} + self.started: list[dict] = [] + self.logged_out: list[str] = [] + + async def start_session(self, payload: dict) -> dict: + self.started.append(payload) + session = { + "sessionId": "cs_1", + "kind": payload["kind"], + "status": "qr_ready", + "qrImage": "data:image/png;base64,abc", + "accountId": None, + "displayName": None, + "metadata": {}, + } + self.sessions["cs_1"] = session + return session + + async def get_session(self, session_id: str) -> dict: + return self.sessions[session_id] + + async def logout(self, connection_id: str) -> dict: + self.logged_out.append(connection_id) + return {"ok": True} + + +def test_weixin_connector_starts_connector_session(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + client = FakeSidecarClient() + connector = WeixinConnector( + connection_store=connection_store, + credential_store=credential_store, + sidecar_client=client, + sidecar_base_url="http://external-connector:8787", + ) + + view = await connector.start_session(display_name="Weixin Main", owner_user_id="user-1", options={}) + + assert view["sessionId"] == "cs_1" + assert client.started[0]["kind"] == "weixin" + assert client.started[0]["connectionId"].startswith("conn_") + assert connection_store.list()[0].kind == "weixin" + assert connection_store.list()[0].status == "pairing" + + asyncio.run(run()) + + +def test_weixin_connector_poll_connected_materializes_external_runtime(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + client = FakeSidecarClient() + connector = WeixinConnector( + connection_store=connection_store, + credential_store=credential_store, + sidecar_client=client, + sidecar_base_url="http://external-connector:8787", + ) + await connector.start_session(display_name="Weixin Main", owner_user_id=None, options={}) + connection = connection_store.list()[0] + client.sessions["cs_1"] = { + "sessionId": "cs_1", + "kind": "weixin", + "status": "connected", + "accountId": "weixin:me", + "displayName": "Me", + "metadata": {"stateRef": "state-1"}, + } + + result = await connector.poll_session("cs_1") + updated = connection_store.get(connection.connection_id) + spec = await connector.materialize_runtime(connection.connection_id) + + assert result["status"] == "connected" + assert updated.status == "connected" + assert updated.account_id == "weixin:me" + assert spec.kind == "external_connector" + assert spec.mode == "http" + assert spec.config["platformKind"] == "weixin" + assert spec.config["sidecarBaseUrl"] == "http://external-connector:8787" + + asyncio.run(run()) + + +def test_feishu_connector_uses_feishu_kind(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + client = FakeSidecarClient() + connector = FeishuConnector( + connection_store=connection_store, + credential_store=credential_store, + sidecar_client=client, + sidecar_base_url="http://external-connector:8787", + ) + + await connector.start_session(display_name="Feishu Main", owner_user_id=None, options={"domain": "feishu"}) + + assert client.started[0]["kind"] == "feishu" + assert client.started[0]["options"] == {"domain": "feishu"} + + asyncio.run(run()) +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_external_sidecar_connectors.py -q +``` + +Expected: fail with `ImportError: cannot import name 'WeixinConnector'`. + +- [ ] **Step 3: Implement connector classes** + +Create `app-instance/backend/beaver/interfaces/channels/connections/external.py`: + +```python +from __future__ import annotations + +from typing import Any + +from .models import ChannelRuntimeSpec, ValidationResult +from .sidecar_client import ConnectorSidecarClient +from .store import ChannelConnectionStore, CredentialStore + + +class ExternalConnectorBase: + kind = "" + capabilities: list[str] = [] + + def __init__( + self, + *, + connection_store: ChannelConnectionStore, + credential_store: CredentialStore, + sidecar_client: ConnectorSidecarClient | Any, + sidecar_base_url: str, + ) -> None: + self.connection_store = connection_store + self.credential_store = credential_store + self.sidecar_client = sidecar_client + self.sidecar_base_url = sidecar_base_url + + async def start_session( + self, + *, + display_name: str, + owner_user_id: str | None, + options: dict[str, Any], + ) -> dict[str, Any]: + connection = self.connection_store.create( + kind=self.kind, + mode="sidecar", + display_name=display_name or self.kind, + account_id="", + owner_user_id=owner_user_id, + auth_type="connector_session", + runtime_config={"sidecarBaseUrl": self.sidecar_base_url}, + capabilities=list(self.capabilities), + ) + self.connection_store.update_status(connection.connection_id, status="pairing", last_error=None) + payload = { + "kind": self.kind, + "connectionId": connection.connection_id, + "channelId": connection.channel_id, + "displayName": connection.display_name, + "callbackBaseUrl": "", + "options": dict(options), + } + view = await self.sidecar_client.start_session(payload) + connection.pairing_session_id = str(view.get("sessionId") or "") + self.connection_store.update(connection) + return view + + async def poll_session(self, session_id: str) -> dict[str, Any]: + view = await self.sidecar_client.get_session(session_id) + connection = self._connection_for_session(session_id) + status = str(view.get("status") or "") + if status == "connected": + connection.account_id = str(view.get("accountId") or connection.account_id) + connection.display_name = str(view.get("displayName") or connection.display_name) + metadata = view.get("metadata") if isinstance(view.get("metadata"), dict) else {} + state_ref = metadata.get("stateRef") + if state_ref: + connection.credentials_ref = self.credential_store.put(kind=self.kind, values={"stateRef": state_ref}) + self.connection_store.update(connection) + self.connection_store.update_status(connection.connection_id, status="connected", last_error=None) + elif status in {"expired", "error", "cancelled"}: + self.connection_store.update_status(connection.connection_id, status="error", last_error=str(view.get("error") or status)) + return view + + async def validate(self, connection_id: str) -> ValidationResult: + connection = self.connection_store.get(connection_id) + if connection.status in {"connected", "running"}: + return ValidationResult(ok=True, status="connected", account_id=connection.account_id, display_name=connection.display_name) + return ValidationResult(ok=False, status=connection.status, error=connection.last_error) + + async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec: + connection = self.connection_store.get(connection_id) + return ChannelRuntimeSpec( + channel_id=connection.channel_id, + kind="external_connector", + mode="http", + account_id=connection.account_id, + display_name=connection.display_name, + config={ + "platformKind": self.kind, + "connectionId": connection.connection_id, + "sidecarBaseUrl": connection.runtime_config.get("sidecarBaseUrl") or self.sidecar_base_url, + }, + secrets_ref=None, + ) + + async def revoke(self, connection_id: str) -> None: + await self.sidecar_client.logout(connection_id) + + def _connection_for_session(self, session_id: str): + for connection in self.connection_store.list(): + if connection.pairing_session_id == session_id: + return connection + raise KeyError(session_id) + + +class WeixinConnector(ExternalConnectorBase): + kind = "weixin" + capabilities = ["receive_text", "send_text", "receive_media", "direct_messages"] + + +class FeishuConnector(ExternalConnectorBase): + kind = "feishu" + capabilities = ["receive_text", "send_text", "receive_media", "groups"] +``` + +- [ ] **Step 4: Export connectors and update registry materialization** + +Modify `app-instance/backend/beaver/interfaces/channels/connections/__init__.py`: + +```python +from .external import ExternalConnectorBase, FeishuConnector, WeixinConnector +``` + +Add names to `__all__`. + +Ensure `ChannelConnectorRegistry.materialize_channel_configs()` accepts `ChannelRuntimeSpec(kind="external_connector", mode="http")` and emits `ChannelConfig(secrets={})`. + +- [ ] **Step 5: Add connector session web schemas** + +Append to `app-instance/backend/beaver/interfaces/web/schemas/chat.py`: + +```python +class WebConnectorSessionCreateRequest(BaseModel): + kind: str + display_name: str | None = Field(default=None, alias="displayName") + owner_user_id: str | None = Field(default=None, alias="ownerUserId") + options: dict[str, Any] = Field(default_factory=dict) + + +class WebConnectorSessionResponse(BaseModel): + session: dict[str, Any] + connection: dict[str, Any] | None = None +``` + +Export both in `app-instance/backend/beaver/interfaces/web/schemas/__init__.py`. + +- [ ] **Step 6: Register Weixin/Feishu in app registry** + +Modify `_build_channel_connector_registry()` in `app-instance/backend/beaver/interfaces/web/app.py` to create a sidecar client: + +```python + sidecar_base_url = os.getenv("EXTERNAL_CONNECTOR_BASE_URL", "http://external-connector:8787") + sidecar_token = os.getenv("EXTERNAL_CONNECTOR_TOKEN", "") + sidecar_client = ConnectorSidecarClient(base_url=sidecar_base_url, token=sidecar_token) + registry.register(WeixinConnector( + connection_store=connection_store, + credential_store=credential_store, + sidecar_client=sidecar_client, + sidecar_base_url=sidecar_base_url, + )) + registry.register(FeishuConnector( + connection_store=connection_store, + credential_store=credential_store, + sidecar_client=sidecar_client, + sidecar_base_url=sidecar_base_url, + )) +``` + +Keep Telegram registration unchanged. + +- [ ] **Step 7: Add connector session API routes** + +Add routes near existing channel connection APIs in `app-instance/backend/beaver/interfaces/web/app.py`: + +```python + @app.post("/api/channel-connector-sessions", response_model=WebConnectorSessionResponse) + async def start_channel_connector_session( + request: Request, + payload: WebConnectorSessionCreateRequest, + ) -> WebConnectorSessionResponse: + registry = get_channel_connector_registry(request) + connector = registry.connector_for_kind(_clean_text(payload.kind)) + if not hasattr(connector, "start_session"): + raise HTTPException(status_code=400, detail="Connector does not support sessions") + view = await connector.start_session( + display_name=_clean_text(payload.display_name) or _clean_text(payload.kind), + owner_user_id=_clean_text(payload.owner_user_id) or None, + options=payload.options, + ) + connection = registry.connection_store.get(str(view.get("connectionId") or registry.connection_store.list()[-1].connection_id)) + return WebConnectorSessionResponse(session=view, connection=_connection_response_view(connection)) + + @app.get("/api/channel-connector-sessions/{session_id}", response_model=WebConnectorSessionResponse) + async def get_channel_connector_session(session_id: str, request: Request) -> WebConnectorSessionResponse: + registry = get_channel_connector_registry(request) + connection = next((item for item in registry.connection_store.list() if item.pairing_session_id == session_id), None) + if connection is None: + raise HTTPException(status_code=404, detail="Connector session not found") + connector = registry.connector_for_kind(connection.kind) + if not hasattr(connector, "poll_session"): + raise HTTPException(status_code=400, detail="Connector does not support sessions") + view = await connector.poll_session(session_id) + connection = registry.connection_store.get(connection.connection_id) + if connection.status == "connected": + runtime = get_channel_runtime(request) + config = (await registry.materialize_channel_configs())[connection.channel_id] + await runtime.add_channel(connection.channel_id, config) + return WebConnectorSessionResponse(session=view, connection=_connection_response_view(connection)) +``` + +Add `connector_for_kind()` to `ChannelConnectorRegistry`: + +```python + def connector_for_kind(self, kind: str) -> ChannelConnector: + return self._connector(kind) +``` + +- [ ] **Step 8: Run connector tests** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_external_sidecar_connectors.py tests/unit/test_channel_connection_api.py tests/unit/test_channel_connector_registry.py -q +``` + +Expected: all listed tests pass. + +- [ ] **Step 9: Commit Task 6** + +```bash +git add app-instance/backend/beaver/interfaces/channels/connections app-instance/backend/beaver/interfaces/web/app.py app-instance/backend/beaver/interfaces/web/schemas/chat.py app-instance/backend/beaver/interfaces/web/schemas/__init__.py app-instance/backend/tests/unit/test_external_sidecar_connectors.py +git commit -m "feat: add sidecar-backed channel connectors" +``` + +--- + +### Task 7: Final Backend Verification + +**Files:** +- Review: `docs/superpowers/specs/2026-06-02-external-sidecar-connectors-design.md` + +- [ ] **Step 1: Run focused backend tests** + +Run: + +```bash +cd app-instance/backend +uv run pytest \ + tests/unit/test_connector_message_dedupe_store.py \ + tests/unit/test_external_connector_channel.py \ + tests/unit/test_channel_runtime_dynamic_channels.py \ + tests/unit/test_external_connector_bridge_api.py \ + tests/unit/test_external_sidecar_connectors.py \ + tests/unit/test_channel_connection_store.py \ + tests/unit/test_channel_connector_registry.py \ + tests/unit/test_channel_connection_api.py \ + tests/unit/test_channel_runtime.py \ + tests/unit/test_gateway_channels.py \ + -q +``` + +Expected: all listed tests pass. + +- [ ] **Step 2: Run import tests** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_imports.py -q +``` + +Expected: all import tests pass. + +- [ ] **Step 3: Scan for leaked service tokens in implementation** + +Run: + +```bash +cd app-instance/backend +rg -n "bridge-token|connector-token|token-1|token-2|secret-token" beaver || true +``` + +Expected: no implementation files contain fixture token values. + +- [ ] **Step 4: Commit verification-only fixes if needed** + +If Step 1 or Step 2 required a small fix, commit it: + +```bash +git add app-instance/backend +git commit -m "fix: stabilize external connector backend runtime" +``` + +If no files changed, do not create an empty commit. diff --git a/docs/superpowers/plans/2026-06-03-external-connector-frontend-deploy.md b/docs/superpowers/plans/2026-06-03-external-connector-frontend-deploy.md new file mode 100644 index 0000000..27fbb7d --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-external-connector-frontend-deploy.md @@ -0,0 +1,790 @@ +# External Connector Frontend And Deploy Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a connector-driven onboarding UI for Weixin and Feishu/Lark, wire frontend API helpers to backend connector-session APIs, and verify the docker-compose sidecar deployment path. + +**Architecture:** The Status page keeps the existing advanced channel config editor, but adds a connector onboarding section backed by `/api/channel-connectors`, `/api/channel-connections`, and `/api/channel-connector-sessions`. Weixin shows QR status; Feishu/Lark shows provider instructions/status. Successful sessions become active without restart through backend dynamic runtime activation. + +**Tech Stack:** Next.js 13, React, TypeScript, existing shadcn/Radix UI components, lucide-react, Vitest, Docker Compose. + +--- + +## Dependencies + +Execute after: + +- `docs/superpowers/plans/2026-06-03-external-connector-backend-runtime.md` +- `docs/superpowers/plans/2026-06-03-external-connector-sidecar.md` + +## Scope + +Included: + +- Frontend TypeScript API helpers and types for connectors, connections, and connector sessions. +- Status page connector onboarding UI. +- QR/instruction modal and polling. +- Logout/revoke action using existing connection revoke API. +- Frontend tests for API mapping and UI state helpers. +- Docker compose smoke verification instructions for local sidecar. + +Excluded: + +- Replacing the advanced `/api/channels` static config editor. +- Live vendor account verification logic inside frontend. +- New top-level navigation route. + +## File Structure + +- Modify `app-instance/frontend/types/index.ts` + - Add connector and connector-session types. +- Modify `app-instance/frontend/lib/api.ts` + - Add connector API functions. +- Create `app-instance/frontend/lib/channel-connectors.ts` + - Small UI state helpers for connector labels/status. +- Create `app-instance/frontend/components/channel-connector-wizard.tsx` + - Connector cards, session modal, QR/instruction rendering, poll controls. +- Modify `app-instance/frontend/app/(app)/status/page.tsx` + - Fetch connector data and render wizard above advanced Channels list. +- Create `app-instance/frontend/lib/channel-connectors.test.ts` + - Helper tests. +- Create `app-instance/frontend/components/channel-connector-wizard.test.tsx` + - Component tests if the existing Vitest setup supports React Testing Library; otherwise keep helper tests and verify with typecheck/build. +- Review `docker-compose.external-connectors.yml` + - Confirm sidecar env names match backend and frontend assumptions. + +--- + +### Task 1: Frontend Types And API Client + +**Files:** +- Modify: `app-instance/frontend/types/index.ts` +- Modify: `app-instance/frontend/lib/api.ts` +- Test: `app-instance/frontend/lib/channel-connectors.test.ts` + +- [ ] **Step 1: Add frontend connector types** + +Append to `app-instance/frontend/types/index.ts`: + +```ts +export interface ChannelConnectorDescriptor { + kind: string; + displayName?: string; + display_name?: string; + authType?: string; + auth_type?: string; + providerId?: string; + provider_id?: string; + capabilities?: string[]; + available?: boolean; + unavailableReason?: string | null; +} + +export interface ChannelConnectionView { + connection_id: string; + owner_user_id?: string | null; + channel_id: string; + kind: string; + mode: string; + display_name: string; + account_id: string; + status: string; + auth_type: string; + runtime_config: Record; + capabilities: string[]; + created_at: string; + updated_at: string; + last_seen_at?: string | null; + last_error?: string | null; +} + +export interface ChannelConnectionResponse { + connection: ChannelConnectionView; + credentials?: Record; +} + +export interface ConnectorSessionView { + sessionId: string; + kind: string; + status: string; + qrCode?: string | null; + qrImage?: string | null; + instructions?: string[]; + accountId?: string | null; + displayName?: string | null; + error?: string | null; + metadata?: Record; +} + +export interface ConnectorSessionResponse { + session: ConnectorSessionView; + connection?: ChannelConnectionView | null; +} +``` + +- [ ] **Step 2: Add API imports** + +Modify the import list in `app-instance/frontend/lib/api.ts` to include: + +```ts + ChannelConnectionResponse, + ChannelConnectionView, + ChannelConnectorDescriptor, + ConnectorSessionResponse, +``` + +- [ ] **Step 3: Add connector API functions** + +Append to `app-instance/frontend/lib/api.ts` near the channel API functions: + +```ts +export async function listChannelConnectors(): Promise { + return fetchJSON('/api/channel-connectors'); +} + +export async function listChannelConnections(): Promise { + return fetchJSON('/api/channel-connections'); +} + +export async function startConnectorSession(params: { + kind: string; + displayName?: string; + ownerUserId?: string; + options?: Record; +}): Promise { + return fetchJSON('/api/channel-connector-sessions', { + method: 'POST', + timeoutMs: 45000, + body: JSON.stringify({ + kind: params.kind, + displayName: params.displayName, + ownerUserId: params.ownerUserId, + options: params.options || {}, + }), + }); +} + +export async function getConnectorSession(sessionId: string): Promise { + return fetchJSON(`/api/channel-connector-sessions/${encodeURIComponent(sessionId)}`, { + timeoutMs: 45000, + }); +} + +export async function revokeChannelConnection(connectionId: string): Promise { + return fetchJSON(`/api/channel-connections/${encodeURIComponent(connectionId)}/revoke`, { + method: 'POST', + }); +} +``` + +- [ ] **Step 4: Run frontend typecheck** + +Run: + +```bash +cd app-instance/frontend +npm run typecheck +``` + +Expected: typecheck passes. If it fails because these types are appended inside another interface, move them below the closing brace for `SystemStatus`. + +- [ ] **Step 5: Commit Task 1** + +```bash +git add app-instance/frontend/types/index.ts app-instance/frontend/lib/api.ts +git commit -m "feat: add connector frontend api client" +``` + +--- + +### Task 2: Connector UI Helpers + +**Files:** +- Create: `app-instance/frontend/lib/channel-connectors.ts` +- Create: `app-instance/frontend/lib/channel-connectors.test.ts` + +- [ ] **Step 1: Write helper tests** + +Create `app-instance/frontend/lib/channel-connectors.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { + connectorDisplayName, + connectorStatusLabel, + isTerminalConnectorSessionStatus, +} from './channel-connectors'; + +describe('channel connector helpers', () => { + it('returns friendly connector names', () => { + expect(connectorDisplayName({ kind: 'weixin' })).toBe('Weixin'); + expect(connectorDisplayName({ kind: 'feishu' })).toBe('Feishu/Lark'); + expect(connectorDisplayName({ kind: 'telegram', displayName: 'Telegram' })).toBe('Telegram'); + }); + + it('maps connector session statuses', () => { + expect(connectorStatusLabel('qr_ready')).toBe('QR ready'); + expect(connectorStatusLabel('waiting_for_user')).toBe('Waiting for user'); + expect(connectorStatusLabel('connected')).toBe('Connected'); + }); + + it('detects terminal statuses', () => { + expect(isTerminalConnectorSessionStatus('connected')).toBe(true); + expect(isTerminalConnectorSessionStatus('expired')).toBe(true); + expect(isTerminalConnectorSessionStatus('qr_ready')).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: + +```bash +cd app-instance/frontend +npm run test -- lib/channel-connectors.test.ts +``` + +Expected: fail with `Cannot find module './channel-connectors'`. + +- [ ] **Step 3: Implement helpers** + +Create `app-instance/frontend/lib/channel-connectors.ts`: + +```ts +import type { ChannelConnectorDescriptor } from '@/types'; + +export function connectorDisplayName(connector: Pick): string { + if (connector.displayName) return connector.displayName; + if (connector.display_name) return connector.display_name; + if (connector.kind === 'weixin') return 'Weixin'; + if (connector.kind === 'feishu') return 'Feishu/Lark'; + if (connector.kind === 'telegram') return 'Telegram'; + return connector.kind; +} + +export function connectorStatusLabel(status: string): string { + const labels: Record = { + pending: 'Pending', + qr_ready: 'QR ready', + scanned: 'Scanned', + confirmed: 'Confirmed', + installing: 'Installing', + waiting_for_user: 'Waiting for user', + connected: 'Connected', + expired: 'Expired', + error: 'Error', + cancelled: 'Cancelled', + }; + return labels[status] || status; +} + +export function isTerminalConnectorSessionStatus(status: string): boolean { + return ['connected', 'expired', 'error', 'cancelled'].includes(status); +} +``` + +- [ ] **Step 4: Run helper tests** + +Run: + +```bash +cd app-instance/frontend +npm run test -- lib/channel-connectors.test.ts +``` + +Expected: helper tests pass. + +- [ ] **Step 5: Commit Task 2** + +```bash +git add app-instance/frontend/lib/channel-connectors.ts app-instance/frontend/lib/channel-connectors.test.ts +git commit -m "feat: add channel connector ui helpers" +``` + +--- + +### Task 3: Connector Wizard Component + +**Files:** +- Create: `app-instance/frontend/components/channel-connector-wizard.tsx` +- Modify: `app-instance/frontend/app/(app)/status/page.tsx` + +- [ ] **Step 1: Create wizard component** + +Create `app-instance/frontend/components/channel-connector-wizard.tsx`: + +```tsx +'use client'; + +import React, { useEffect, useMemo, useState } from 'react'; +import { CheckCircle2, Loader2, QrCode, RefreshCw, Unplug } from 'lucide-react'; +import type { + ChannelConnectionView, + ChannelConnectorDescriptor, + ConnectorSessionResponse, + ConnectorSessionView, +} from '@/types'; +import { + getConnectorSession, + revokeChannelConnection, + startConnectorSession, +} from '@/lib/api'; +import { + connectorDisplayName, + connectorStatusLabel, + isTerminalConnectorSessionStatus, +} from '@/lib/channel-connectors'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +type Props = { + connectors: ChannelConnectorDescriptor[]; + connections: ChannelConnectionView[]; + onChanged: () => Promise | void; +}; + +export function ChannelConnectorWizard({ connectors, connections, onChanged }: Props) { + const [activeKind, setActiveKind] = useState(null); + const [session, setSession] = useState(null); + const [connection, setConnection] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [feishuDomain, setFeishuDomain] = useState('feishu'); + + const visibleConnectors = useMemo( + () => connectors.filter((item) => ['telegram', 'weixin', 'feishu'].includes(item.kind)), + [connectors], + ); + + useEffect(() => { + if (!session || isTerminalConnectorSessionStatus(session.status)) return; + const timer = window.setInterval(async () => { + try { + const next = await getConnectorSession(session.sessionId); + setSession(next.session); + if (next.connection) setConnection(next.connection); + if (next.session.status === 'connected') await onChanged(); + } catch (err: any) { + setError(err.message || 'Failed to refresh connector session'); + } + }, 2000); + return () => window.clearInterval(timer); + }, [session?.sessionId, session?.status, onChanged]); + + const start = async (kind: string) => { + setActiveKind(kind); + setSession(null); + setConnection(null); + setError(null); + setBusy(true); + try { + const options = kind === 'feishu' ? { domain: feishuDomain } : {}; + const response: ConnectorSessionResponse = await startConnectorSession({ + kind, + displayName: connectorDisplayName({ kind }), + options, + }); + setSession(response.session); + setConnection(response.connection || null); + } catch (err: any) { + setError(err.message || 'Failed to start connector session'); + } finally { + setBusy(false); + } + }; + + const revoke = async (item: ChannelConnectionView) => { + setBusy(true); + setError(null); + try { + await revokeChannelConnection(item.connection_id); + await onChanged(); + } catch (err: any) { + setError(err.message || 'Failed to logout connector'); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ {visibleConnectors.map((connector) => { + const existing = connections.find((item) => item.kind === connector.kind && item.status !== 'revoked'); + return ( + + + + {connectorDisplayName(connector)} + {existing ? {existing.status} : null} + + + + {connector.kind === 'feishu' ? ( +
+ + setFeishuDomain(event.target.value)} /> +
+ ) : null} + {existing ? ( +
+ {existing.display_name || existing.account_id || existing.channel_id} + +
+ ) : ( + + )} +
+
+ ); + })} +
+ + {error ?

{error}

: null} + + !open && setActiveKind(null)}> + + + {activeKind ? connectorDisplayName({ kind: activeKind }) : 'Connector'} + + {session ? ( +
+
+ + {connectorStatusLabel(session.status)} + + {session.status === 'connected' ? : } +
+ {session.qrImage ? ( + Connector QR code + ) : null} + {session.instructions && session.instructions.length > 0 ? ( +
+ {session.instructions.map((item) =>

{item}

)} +
+ ) : null} + {connection ?

{connection.display_name || connection.account_id}

: null} + {session.error ?

{session.error}

: null} +
+ ) : null} + + + +
+
+
+ ); +} +``` + +- [ ] **Step 2: Wire Status page imports** + +Modify imports in `app-instance/frontend/app/(app)/status/page.tsx`: + +```tsx +import { ChannelConnectorWizard } from '@/components/channel-connector-wizard'; +import { getChannelConfig, getStatus, listChannelConnections, listChannelConnectors, listChannelEvents, restartRuntime, updateAgentConfig, updateChannelConfig, updateProviderConfig } from '@/lib/api'; +import type { ChannelConfigDetail, ChannelConnectionView, ChannelConnectorDescriptor, ChannelEventRecord, ChannelStatus, ProviderStatus, SystemStatus } from '@/types'; +``` + +- [ ] **Step 3: Add connector state to Status page** + +Inside `StatusPage()` state declarations: + +```tsx + const [channelConnectors, setChannelConnectors] = useState([]); + const [channelConnections, setChannelConnections] = useState([]); +``` + +Add loader: + +```tsx + const loadChannelConnectors = async () => { + const [connectors, connections] = await Promise.all([ + listChannelConnectors(), + listChannelConnections(), + ]); + setChannelConnectors(connectors); + setChannelConnections(connections); + }; +``` + +Call it after status load: + +```tsx + useEffect(() => { + loadStatus(); + loadChannelConnectors().catch(() => undefined); + }, []); +``` + +In `handleSaveChannel()` after `await loadStatus();`, add: + +```tsx + await loadChannelConnectors(); +``` + +- [ ] **Step 4: Render wizard above advanced Channels list** + +In `app-instance/frontend/app/(app)/status/page.tsx`, render before the existing `{/* Channels */}` section: + +```tsx +
+
+

{pickAppText(locale, '连接器', 'Connectors')}

+

+ {pickAppText(locale, '连接微信或飞书后会立即进入运行时。', 'Connected Weixin or Feishu channels activate immediately.')} +

+
+ { + await loadChannelConnectors(); + await loadStatus(); + }} + /> +
+``` + +- [ ] **Step 5: Run frontend checks** + +Run: + +```bash +cd app-instance/frontend +npm run typecheck +npm run test -- lib/channel-connectors.test.ts +``` + +Expected: typecheck and helper tests pass. + +- [ ] **Step 6: Commit Task 3** + +```bash +git add app-instance/frontend/components/channel-connector-wizard.tsx app-instance/frontend/app/'(app)'/status/page.tsx +git commit -m "feat: add channel connector wizard" +``` + +--- + +### Task 4: Frontend Build And Browser Smoke + +**Files:** +- Review: `app-instance/frontend/app/(app)/status/page.tsx` +- Review: `app-instance/frontend/components/channel-connector-wizard.tsx` + +- [ ] **Step 1: Run frontend build** + +Run: + +```bash +cd app-instance/frontend +npm run build +``` + +Expected: Next build succeeds. + +- [ ] **Step 2: Start frontend dev server if visual smoke is needed** + +Run: + +```bash +cd app-instance/frontend +npm run dev +``` + +Expected: dev server listens on `http://127.0.0.1:3080`. + +- [ ] **Step 3: Browser smoke check** + +Open the Status page in the running app instance and verify: + +- The Connectors section appears above Channels. +- Telegram shows token setup disabled in the connector wizard. +- Weixin has a Connect button. +- Feishu/Lark has a Domain input and Connect button. +- Starting a fake Weixin session opens a modal with a QR image. + +- [ ] **Step 4: Stop frontend dev server** + +If Step 2 started a dev server, stop it with `Ctrl-C`. + +- [ ] **Step 5: Commit fixes if needed** + +If build or smoke required fixes: + +```bash +git add app-instance/frontend +git commit -m "fix: stabilize channel connector wizard" +``` + +If no files changed, do not create an empty commit. + +--- + +### Task 5: Compose Integration Verification + +**Files:** +- Review: `docker-compose.external-connectors.yml` +- Review: `.env.example` + +- [ ] **Step 1: Build backend and sidecar images** + +Run: + +```bash +docker build -t beaver/app-instance:latest app-instance +docker compose -f docker-compose.external-connectors.yml build external-connector +``` + +Expected: both builds succeed. + +- [ ] **Step 2: Start sidecar with fake provider** + +Run: + +```bash +CONNECTOR_PROVIDER=fake \ +EXTERNAL_CONNECTOR_TOKEN=dev-token \ +BEAVER_BRIDGE_TOKEN=dev-token \ +docker compose -f docker-compose.external-connectors.yml up -d external-connector +``` + +Expected: `external-connector` starts and stays running. + +- [ ] **Step 3: Verify sidecar connector API** + +Run: + +```bash +curl -sS -H 'Authorization: Bearer dev-token' http://127.0.0.1:8787/connectors +``` + +Expected: JSON contains `weixin` and `feishu`. + +- [ ] **Step 4: Attach sidecar to Beaver instance network** + +For a local `create-instance.sh` deployment using `beaver-instance-edge`, run: + +```bash +docker network connect beaver-instance-edge external-connector 2>/dev/null || true +``` + +Expected: command succeeds or reports that the endpoint already exists. + +- [ ] **Step 5: Restart target app instance with connector env** + +For `terminaltest`, ensure the app container has: + +```dotenv +EXTERNAL_CONNECTOR_BASE_URL=http://external-connector:8787 +EXTERNAL_CONNECTOR_TOKEN=dev-token +BEAVER_BRIDGE_TOKEN=dev-token +``` + +Then recreate the instance with the deployment script used by this repo. Do not mount `/var/run/docker.sock` into Beaver. + +- [ ] **Step 6: Manual fake-provider onboarding** + +In `terminaltest`: + +- Open Status. +- Click Weixin Connect. +- Confirm QR modal appears. +- Poll until fake status remains visible. +- Confirm backend `/api/channel-connectors` returns `telegram`, `weixin`, and `feishu`. + +- [ ] **Step 7: Stop fake sidecar if no longer needed** + +Run: + +```bash +docker compose -f docker-compose.external-connectors.yml down +``` + +Expected: sidecar stops; named volume remains. + +--- + +### Task 6: Final Frontend And Deploy Verification + +**Files:** +- Review: `docs/superpowers/specs/2026-06-02-external-sidecar-connectors-design.md` + +- [ ] **Step 1: Run frontend verification** + +Run: + +```bash +cd app-instance/frontend +npm run typecheck +npm run build +npm run test -- lib/channel-connectors.test.ts +``` + +Expected: all commands pass. + +- [ ] **Step 2: Run backend connector smoke tests** + +Run: + +```bash +cd app-instance/backend +uv run pytest \ + tests/unit/test_external_sidecar_connectors.py \ + tests/unit/test_external_connector_bridge_api.py \ + tests/unit/test_channel_runtime_dynamic_channels.py \ + -q +``` + +Expected: all listed tests pass. + +- [ ] **Step 3: Run sidecar verification** + +Run: + +```bash +cd external-connector +uv run pytest -q +``` + +Expected: all sidecar tests pass. + +- [ ] **Step 4: Scan for provider-runtime naming in new files** + +Run: + +```bash +rg -n "[Oo]pen[Cc]law" docs/superpowers app-instance/frontend external-connector docker-compose.external-connectors.yml || true +``` + +Expected: no matches. + +- [ ] **Step 5: Commit verification fixes if needed** + +If any verification step required fixes: + +```bash +git add app-instance/frontend external-connector docker-compose.external-connectors.yml docs/superpowers +git commit -m "fix: stabilize external connector onboarding" +``` + +If no files changed, do not create an empty commit. diff --git a/docs/superpowers/plans/2026-06-03-external-connector-sidecar.md b/docs/superpowers/plans/2026-06-03-external-connector-sidecar.md new file mode 100644 index 0000000..b33b5c3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-external-connector-sidecar.md @@ -0,0 +1,1167 @@ +# External Connector Sidecar Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a repo-local `external-connector` sidecar service with a provider abstraction, deterministic fake provider tests, service-level auth, outbound send idempotency, and a production provider that shells out to real vendor CLI commands supplied by environment variables. + +**Architecture:** The sidecar exposes a stable HTTP contract to Beaver and delegates platform-specific behavior to `ConnectorProvider`. The fake provider makes tests deterministic; the production provider runs configured vendor commands and persists connector/session/send state under `CONNECTOR_HOME`. + +**Tech Stack:** Python 3.12, FastAPI, Pydantic v2, pytest, httpx, local JSON stores, Docker. + +--- + +## Scope + +Included: + +- `external-connector/` Python service. +- `ConnectorProvider` protocol. +- Fake provider for tests and local dry runs. +- Production `VendorCliProvider` with environment-driven command templates. +- Service-level bearer authentication for Beaver-to-sidecar requests. +- Connector session state persistence. +- `/send` idempotency by `connectionId + requestId`. +- Dockerfile and local compose declaration. + +Excluded: + +- Beaver backend bridge implementation. +- Frontend UI. +- Hardcoded vendor command strings in repo files. +- Docker socket access. +- Dynamic container creation. + +## File Structure + +- Create `external-connector/pyproject.toml` + - Sidecar dependencies and test runner. +- Create `external-connector/Dockerfile` + - Python runtime plus Node/npm so configured vendor CLI commands can run. +- Create `external-connector/external_connector/__init__.py` +- Create `external-connector/external_connector/models.py` + - Pydantic request/response models. +- Create `external-connector/external_connector/state.py` + - JSON-backed session and send idempotency state. +- Create `external-connector/external_connector/providers/base.py` + - `ConnectorProvider` protocol. +- Create `external-connector/external_connector/providers/fake.py` + - Deterministic provider for tests. +- Create `external-connector/external_connector/providers/vendor_cli.py` + - Command-template provider. +- Create `external-connector/external_connector/app.py` + - FastAPI app factory and routes. +- Create `external-connector/external_connector/main.py` + - Uvicorn entrypoint. +- Create `external-connector/tests/test_sidecar_api.py` +- Create `external-connector/tests/test_state.py` +- Create `external-connector/tests/test_vendor_cli_provider.py` +- Create `docker-compose.external-connectors.yml` +- Modify `.env.example` + - Document sidecar env variables without embedding real secrets. + +--- + +### Task 1: Sidecar State Store + +**Files:** +- Create: `external-connector/external_connector/state.py` +- Create: `external-connector/external_connector/__init__.py` +- Create: `external-connector/tests/test_state.py` +- Create: `external-connector/pyproject.toml` + +- [ ] **Step 1: Create sidecar package metadata** + +Create `external-connector/pyproject.toml`: + +```toml +[project] +name = "external-connector" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.0,<1.0", + "httpx>=0.27.0,<1.0", + "pydantic>=2.7.0,<3.0", + "uvicorn[standard]>=0.30.0,<1.0", +] + +[dependency-groups] +dev = [ + "pytest>=8.0.0,<9.0", +] + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] +``` + +Create `external-connector/external_connector/__init__.py`: + +```python +"""Generic external connector sidecar.""" +``` + +- [ ] **Step 2: Write failing state tests** + +Create `external-connector/tests/test_state.py`: + +```python +from __future__ import annotations + +from external_connector.state import SidecarStateStore + + +def test_state_store_saves_and_loads_connector_sessions(tmp_path) -> None: + store = SidecarStateStore(tmp_path / "state.json") + + session = store.create_session( + kind="weixin", + connection_id="conn_1", + channel_id="weixin-main", + display_name="Weixin Main", + options={}, + ) + store.update_session(session.session_id, status="connected", account_id="weixin:me", display_name="Me") + loaded = store.get_session(session.session_id) + + assert session.session_id.startswith("cs_") + assert loaded.status == "connected" + assert loaded.account_id == "weixin:me" + + +def test_state_store_dedupes_send_results(tmp_path) -> None: + store = SidecarStateStore(tmp_path / "state.json") + + first = store.begin_send(connection_id="conn_1", request_id="out_1") + store.complete_send(first.dedupe_key, provider_message_id="provider-1") + duplicate = store.begin_send(connection_id="conn_1", request_id="out_1") + + assert first.should_send is True + assert duplicate.should_send is False + assert duplicate.provider_message_id == "provider-1" +``` + +- [ ] **Step 3: Run tests to verify failure** + +Run: + +```bash +cd external-connector +uv run pytest tests/test_state.py -q +``` + +Expected: fail with `ModuleNotFoundError: No module named 'external_connector.state'`. + +- [ ] **Step 4: Implement state store** + +Create `external-connector/external_connector/state.py`: + +```python +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from threading import Lock +from typing import Any +from uuid import uuid4 + + +def iso_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +@dataclass(slots=True) +class ConnectorSessionState: + session_id: str + kind: str + connection_id: str + channel_id: str + display_name: str + status: str + options: dict[str, Any] = field(default_factory=dict) + qr_code: str | None = None + qr_image: str | None = None + instructions: list[str] = field(default_factory=list) + account_id: str | None = None + error: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + created_at: str = field(default_factory=iso_now) + updated_at: str = field(default_factory=iso_now) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ConnectorSessionState": + return cls( + session_id=str(data.get("session_id") or ""), + kind=str(data.get("kind") or ""), + connection_id=str(data.get("connection_id") or ""), + channel_id=str(data.get("channel_id") or ""), + display_name=str(data.get("display_name") or ""), + status=str(data.get("status") or "pending"), + options=dict(data.get("options") or {}), + qr_code=str(data["qr_code"]) if data.get("qr_code") is not None else None, + qr_image=str(data["qr_image"]) if data.get("qr_image") is not None else None, + instructions=[str(item) for item in data.get("instructions") or []], + account_id=str(data["account_id"]) if data.get("account_id") is not None else None, + error=str(data["error"]) if data.get("error") is not None else None, + metadata=dict(data.get("metadata") or {}), + created_at=str(data.get("created_at") or iso_now()), + updated_at=str(data.get("updated_at") or iso_now()), + ) + + +@dataclass(slots=True) +class SendBeginResult: + should_send: bool + dedupe_key: str + provider_message_id: str | None = None + + +class SidecarStateStore: + def __init__(self, path: Path) -> None: + self.path = Path(path) + self._lock = Lock() + + def create_session( + self, + *, + kind: str, + connection_id: str, + channel_id: str, + display_name: str, + options: dict[str, Any], + ) -> ConnectorSessionState: + session = ConnectorSessionState( + session_id=f"cs_{uuid4().hex}", + kind=kind, + connection_id=connection_id, + channel_id=channel_id, + display_name=display_name, + status="pending", + options=dict(options), + ) + with self._lock: + data = self._load() + data["sessions"][session.session_id] = session.to_dict() + self._save(data) + return session + + def get_session(self, session_id: str) -> ConnectorSessionState: + data = self._load() + raw = data["sessions"].get(session_id) + if not isinstance(raw, dict): + raise KeyError(session_id) + return ConnectorSessionState.from_dict(raw) + + def update_session(self, session_id: str, **updates: Any) -> ConnectorSessionState: + with self._lock: + data = self._load() + raw = data["sessions"].get(session_id) + if not isinstance(raw, dict): + raise KeyError(session_id) + session = ConnectorSessionState.from_dict(raw) + for key, value in updates.items(): + if hasattr(session, key): + setattr(session, key, value) + session.updated_at = iso_now() + data["sessions"][session_id] = session.to_dict() + self._save(data) + return session + + def begin_send(self, *, connection_id: str, request_id: str) -> SendBeginResult: + dedupe_key = f"{connection_id}:{request_id}" + with self._lock: + data = self._load() + existing = data["sends"].get(dedupe_key) + if isinstance(existing, dict) and existing.get("status") == "completed": + return SendBeginResult(False, dedupe_key, str(existing.get("provider_message_id") or "")) + data["sends"][dedupe_key] = { + "connection_id": connection_id, + "request_id": request_id, + "status": "processing", + "updated_at": iso_now(), + } + self._save(data) + return SendBeginResult(True, dedupe_key) + + def complete_send(self, dedupe_key: str, *, provider_message_id: str | None) -> None: + with self._lock: + data = self._load() + item = dict(data["sends"].get(dedupe_key) or {}) + item.update({"status": "completed", "provider_message_id": provider_message_id, "updated_at": iso_now()}) + data["sends"][dedupe_key] = item + self._save(data) + + def _load(self) -> dict[str, Any]: + if not self.path.exists(): + return {"sessions": {}, "sends": {}} + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"sessions": {}, "sends": {}} + if not isinstance(data, dict): + return {"sessions": {}, "sends": {}} + if not isinstance(data.get("sessions"), dict): + data["sessions"] = {} + if not isinstance(data.get("sends"), dict): + data["sends"] = {} + return data + + def _save(self, data: dict[str, Any]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = self.path.with_name(f"{self.path.name}.tmp") + tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + tmp_path.replace(self.path) +``` + +- [ ] **Step 5: Run state tests** + +Run: + +```bash +cd external-connector +uv run pytest tests/test_state.py -q +``` + +Expected: `2 passed`. + +- [ ] **Step 6: Commit Task 1** + +```bash +git add external-connector +git commit -m "feat: add external connector sidecar state" +``` + +--- + +### Task 2: Provider Contract And Fake Provider + +**Files:** +- Create: `external-connector/external_connector/models.py` +- Create: `external-connector/external_connector/providers/base.py` +- Create: `external-connector/external_connector/providers/fake.py` +- Test: `external-connector/tests/test_sidecar_api.py` + +- [ ] **Step 1: Write failing fake provider tests** + +Create `external-connector/tests/test_sidecar_api.py`: + +```python +from __future__ import annotations + +from external_connector.providers.fake import FakeProvider +from external_connector.state import SidecarStateStore + + +def test_fake_provider_lists_weixin_and_feishu(tmp_path) -> None: + provider = FakeProvider(SidecarStateStore(tmp_path / "state.json")) + + connectors = provider.connectors() + + assert [item["kind"] for item in connectors] == ["weixin", "feishu"] + assert connectors[0]["authType"] == "qr" + + +def test_fake_provider_session_flow(tmp_path) -> None: + provider = FakeProvider(SidecarStateStore(tmp_path / "state.json")) + + session = provider.start_session( + { + "kind": "weixin", + "connectionId": "conn_1", + "channelId": "weixin-main", + "displayName": "Weixin Main", + "callbackBaseUrl": "http://beaver:8080", + "options": {}, + } + ) + loaded = provider.get_session(session["sessionId"]) + + assert session["status"] == "qr_ready" + assert session["qrImage"].startswith("data:image/png;base64,") + assert loaded["sessionId"] == session["sessionId"] + + +def test_fake_provider_send_returns_idempotent_result(tmp_path) -> None: + provider = FakeProvider(SidecarStateStore(tmp_path / "state.json")) + payload = { + "requestId": "out_1", + "connectionId": "conn_1", + "channelId": "weixin-main", + "kind": "weixin", + "target": {"peerId": "peer-1", "peerType": "dm", "threadId": None}, + "content": "hello", + "metadata": {}, + } + + first = provider.send(payload) + second = provider.send(payload) + + assert first == second + assert first["ok"] is True +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: + +```bash +cd external-connector +uv run pytest tests/test_sidecar_api.py -q +``` + +Expected: fail with `ModuleNotFoundError: No module named 'external_connector.providers'`. + +- [ ] **Step 3: Add Pydantic models** + +Create `external-connector/external_connector/models.py`: + +```python +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class ConnectorSessionRequest(BaseModel): + kind: str + connection_id: str = Field(alias="connectionId") + channel_id: str = Field(alias="channelId") + display_name: str = Field(alias="displayName") + callback_base_url: str = Field(alias="callbackBaseUrl") + options: dict[str, Any] = Field(default_factory=dict) + + +class SendRequest(BaseModel): + request_id: str = Field(alias="requestId") + connection_id: str = Field(alias="connectionId") + channel_id: str = Field(alias="channelId") + kind: str + target: dict[str, Any] + content: str + metadata: dict[str, Any] = Field(default_factory=dict) +``` + +- [ ] **Step 4: Add provider contract** + +Create `external-connector/external_connector/providers/base.py`: + +```python +from __future__ import annotations + +from typing import Any, Protocol + + +class ConnectorProvider(Protocol): + provider_id: str + + def connectors(self) -> list[dict[str, Any]]: + ... + + def health(self) -> dict[str, Any]: + ... + + def start_session(self, payload: dict[str, Any]) -> dict[str, Any]: + ... + + def get_session(self, session_id: str) -> dict[str, Any]: + ... + + def cancel_session(self, session_id: str) -> None: + ... + + def logout(self, connection_id: str) -> None: + ... + + def send(self, payload: dict[str, Any]) -> dict[str, Any]: + ... +``` + +- [ ] **Step 5: Add fake provider** + +Create `external-connector/external_connector/providers/fake.py`: + +```python +from __future__ import annotations + +from typing import Any +from uuid import uuid4 + +from external_connector.state import ConnectorSessionState, SidecarStateStore + + +def _session_view(session: ConnectorSessionState) -> dict[str, Any]: + return { + "sessionId": session.session_id, + "kind": session.kind, + "status": session.status, + "qrCode": session.qr_code, + "qrImage": session.qr_image, + "instructions": list(session.instructions), + "accountId": session.account_id, + "displayName": session.display_name if session.account_id else None, + "error": session.error, + "metadata": dict(session.metadata), + } + + +class FakeProvider: + provider_id = "fake" + + def __init__(self, store: SidecarStateStore) -> None: + self.store = store + + def connectors(self) -> list[dict[str, Any]]: + return [ + { + "kind": "weixin", + "displayName": "Weixin", + "authType": "qr", + "providerId": self.provider_id, + "capabilities": ["receive_text", "send_text", "receive_media", "direct_messages"], + }, + { + "kind": "feishu", + "displayName": "Feishu/Lark", + "authType": "plugin_install", + "providerId": self.provider_id, + "capabilities": ["receive_text", "send_text", "receive_media", "groups"], + }, + ] + + def health(self) -> dict[str, Any]: + return {"ok": True, "providerId": self.provider_id} + + def start_session(self, payload: dict[str, Any]) -> dict[str, Any]: + session = self.store.create_session( + kind=str(payload["kind"]), + connection_id=str(payload["connectionId"]), + channel_id=str(payload["channelId"]), + display_name=str(payload["displayName"]), + options=dict(payload.get("options") or {}), + ) + session = self.store.update_session( + session.session_id, + status="qr_ready" if session.kind == "weixin" else "waiting_for_user", + qr_image="data:image/png;base64,ZmFrZQ==" if session.kind == "weixin" else None, + instructions=["Run the provider install flow and finish verification"] if session.kind == "feishu" else [], + ) + return _session_view(session) + + def get_session(self, session_id: str) -> dict[str, Any]: + return _session_view(self.store.get_session(session_id)) + + def cancel_session(self, session_id: str) -> None: + self.store.update_session(session_id, status="cancelled") + + def logout(self, connection_id: str) -> None: + return None + + def send(self, payload: dict[str, Any]) -> dict[str, Any]: + begin = self.store.begin_send(connection_id=str(payload["connectionId"]), request_id=str(payload["requestId"])) + if not begin.should_send: + return {"ok": True, "providerMessageId": begin.provider_message_id} + provider_message_id = f"fake_{uuid4().hex}" + self.store.complete_send(begin.dedupe_key, provider_message_id=provider_message_id) + return {"ok": True, "providerMessageId": provider_message_id} +``` + +- [ ] **Step 6: Run fake provider tests** + +Run: + +```bash +cd external-connector +uv run pytest tests/test_sidecar_api.py tests/test_state.py -q +``` + +Expected: all listed tests pass. + +- [ ] **Step 7: Commit Task 2** + +```bash +git add external-connector +git commit -m "feat: add external connector provider contract" +``` + +--- + +### Task 3: FastAPI Sidecar HTTP API + +**Files:** +- Create: `external-connector/external_connector/app.py` +- Create: `external-connector/external_connector/main.py` +- Modify: `external-connector/tests/test_sidecar_api.py` + +- [ ] **Step 1: Extend HTTP API tests** + +Append to `external-connector/tests/test_sidecar_api.py`: + +```python +from fastapi.testclient import TestClient + +from external_connector.app import create_app + + +def test_sidecar_http_api_requires_bearer_token(tmp_path) -> None: + app = create_app(provider=FakeProvider(SidecarStateStore(tmp_path / "state.json")), api_token="sidecar-token") + + with TestClient(app) as client: + response = client.get("/connectors") + + assert response.status_code == 401 + + +def test_sidecar_http_api_session_and_send(tmp_path) -> None: + app = create_app(provider=FakeProvider(SidecarStateStore(tmp_path / "state.json")), api_token="sidecar-token") + headers = {"Authorization": "Bearer sidecar-token"} + + with TestClient(app) as client: + connectors = client.get("/connectors", headers=headers) + session = client.post( + "/connector-sessions", + headers=headers, + json={ + "kind": "weixin", + "connectionId": "conn_1", + "channelId": "weixin-main", + "displayName": "Weixin Main", + "callbackBaseUrl": "http://beaver:8080", + "options": {}, + }, + ) + session_id = session.json()["sessionId"] + loaded = client.get(f"/connector-sessions/{session_id}", headers=headers) + sent = client.post( + "/send", + headers=headers, + json={ + "requestId": "out_1", + "connectionId": "conn_1", + "channelId": "weixin-main", + "kind": "weixin", + "target": {"peerId": "peer-1", "peerType": "dm", "threadId": None}, + "content": "hello", + "metadata": {}, + }, + ) + + assert connectors.status_code == 200 + assert session.status_code == 200 + assert loaded.json()["sessionId"] == session_id + assert sent.json()["ok"] is True +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: + +```bash +cd external-connector +uv run pytest tests/test_sidecar_api.py -q +``` + +Expected: fail with `ModuleNotFoundError: No module named 'external_connector.app'`. + +- [ ] **Step 3: Implement FastAPI app** + +Create `external-connector/external_connector/app.py`: + +```python +from __future__ import annotations + +from typing import Any + +from fastapi import FastAPI, Header, HTTPException + +from external_connector.models import ConnectorSessionRequest, SendRequest +from external_connector.providers.base import ConnectorProvider + + +def create_app(*, provider: ConnectorProvider, api_token: str) -> FastAPI: + app = FastAPI(title="External Connector") + + def require_auth(authorization: str | None) -> None: + if api_token and authorization != f"Bearer {api_token}": + raise HTTPException(status_code=401, detail="Invalid connector token") + + @app.get("/health") + def health() -> dict[str, Any]: + return provider.health() + + @app.get("/connectors") + def connectors(authorization: str | None = Header(default=None)) -> list[dict[str, Any]]: + require_auth(authorization) + return provider.connectors() + + @app.post("/connector-sessions") + def start_session(payload: ConnectorSessionRequest, authorization: str | None = Header(default=None)) -> dict[str, Any]: + require_auth(authorization) + return provider.start_session(payload.model_dump(by_alias=True)) + + @app.get("/connector-sessions/{session_id}") + def get_session(session_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]: + require_auth(authorization) + try: + return provider.get_session(session_id) + except KeyError: + raise HTTPException(status_code=404, detail="Connector session not found") + + @app.post("/connector-sessions/{session_id}/cancel") + def cancel_session(session_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]: + require_auth(authorization) + provider.cancel_session(session_id) + return {"ok": True} + + @app.post("/connections/{connection_id}/logout") + def logout(connection_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]: + require_auth(authorization) + provider.logout(connection_id) + return {"ok": True} + + @app.post("/send") + def send(payload: SendRequest, authorization: str | None = Header(default=None)) -> dict[str, Any]: + require_auth(authorization) + return provider.send(payload.model_dump(by_alias=True)) + + return app +``` + +Create `external-connector/external_connector/main.py`: + +```python +from __future__ import annotations + +import os +from pathlib import Path + +import uvicorn + +from external_connector.app import create_app +from external_connector.providers.fake import FakeProvider +from external_connector.providers.vendor_cli import VendorCliProvider +from external_connector.state import SidecarStateStore + + +def build_app(): + home = Path(os.getenv("CONNECTOR_HOME", "/var/lib/external-connector")) + store = SidecarStateStore(home / "state.json") + provider_name = os.getenv("CONNECTOR_PROVIDER", "fake") + if provider_name == "vendor_cli": + provider = VendorCliProvider(store=store, env=os.environ) + else: + provider = FakeProvider(store) + return create_app(provider=provider, api_token=os.getenv("CONNECTOR_API_TOKEN", "")) + + +app = build_app() + + +def main() -> None: + uvicorn.run("external_connector.main:app", host="0.0.0.0", port=8787) + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 4: Run API tests** + +Run: + +```bash +cd external-connector +uv run pytest tests/test_sidecar_api.py -q +``` + +Expected: all HTTP API tests pass except import of `VendorCliProvider`, which is added in Task 4. If `main.py` import breaks before Task 4, add a minimal `external-connector/external_connector/providers/vendor_cli.py` containing a `VendorCliProvider` class that raises `RuntimeError("VendorCliProvider is not configured")` from each method. + +- [ ] **Step 5: Commit Task 3** + +```bash +git add external-connector +git commit -m "feat: add external connector sidecar api" +``` + +--- + +### Task 4: Vendor CLI Provider + +**Files:** +- Create: `external-connector/external_connector/providers/vendor_cli.py` +- Test: `external-connector/tests/test_vendor_cli_provider.py` + +- [ ] **Step 1: Write failing vendor provider tests** + +Create `external-connector/tests/test_vendor_cli_provider.py`: + +```python +from __future__ import annotations + +from external_connector.providers.vendor_cli import VendorCliProvider +from external_connector.state import SidecarStateStore + + +class FakeRunner: + def __init__(self) -> None: + self.commands: list[list[str]] = [] + + def __call__(self, command: list[str], cwd: str) -> tuple[int, str, str]: + self.commands.append(command) + return 0, "connected account=weixin:me", "" + + +def test_vendor_cli_provider_uses_env_command_templates(tmp_path) -> None: + runner = FakeRunner() + provider = VendorCliProvider( + store=SidecarStateStore(tmp_path / "state.json"), + env={"WEIXIN_CONNECT_COMMAND": "vendor-weixin install --state {state_dir}"}, + runner=runner, + ) + + session = provider.start_session( + { + "kind": "weixin", + "connectionId": "conn_1", + "channelId": "weixin-main", + "displayName": "Weixin Main", + "callbackBaseUrl": "http://beaver:8080", + "options": {}, + } + ) + + assert session["status"] in {"waiting_for_user", "connected"} + assert runner.commands[0][0] == "vendor-weixin" + + +def test_vendor_cli_provider_redacts_sensitive_error(tmp_path) -> None: + def runner(command: list[str], cwd: str) -> tuple[int, str, str]: + return 1, "", "failed secret-token appSecret=abc" + + provider = VendorCliProvider( + store=SidecarStateStore(tmp_path / "state.json"), + env={"FEISHU_CONNECT_COMMAND": "vendor-feishu install --secret abc"}, + runner=runner, + ) + + session = provider.start_session( + { + "kind": "feishu", + "connectionId": "conn_1", + "channelId": "feishu-main", + "displayName": "Feishu Main", + "callbackBaseUrl": "http://beaver:8080", + "options": {}, + } + ) + + assert session["status"] == "error" + assert "secret-token" not in (session["error"] or "") + assert "appSecret=abc" not in (session["error"] or "") +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: + +```bash +cd external-connector +uv run pytest tests/test_vendor_cli_provider.py -q +``` + +Expected: fail with `ModuleNotFoundError` or missing `VendorCliProvider`. + +- [ ] **Step 3: Implement vendor CLI provider** + +Create `external-connector/external_connector/providers/vendor_cli.py`: + +```python +from __future__ import annotations + +import os +import shlex +import subprocess +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Any + +from external_connector.providers.fake import _session_view +from external_connector.state import SidecarStateStore + + +Runner = Callable[[list[str], str], tuple[int, str, str]] + + +def default_runner(command: list[str], cwd: str) -> tuple[int, str, str]: + completed = subprocess.run(command, cwd=cwd, text=True, capture_output=True, check=False) + return completed.returncode, completed.stdout, completed.stderr + + +class VendorCliProvider: + provider_id = "vendor_cli" + + def __init__( + self, + *, + store: SidecarStateStore, + env: Mapping[str, str] | None = None, + runner: Runner = default_runner, + ) -> None: + self.store = store + self.env = env or os.environ + self.runner = runner + + def connectors(self) -> list[dict[str, Any]]: + return [ + {"kind": "weixin", "displayName": "Weixin", "authType": "qr", "providerId": self.provider_id, "capabilities": ["receive_text", "send_text", "receive_media", "direct_messages"]}, + {"kind": "feishu", "displayName": "Feishu/Lark", "authType": "plugin_install", "providerId": self.provider_id, "capabilities": ["receive_text", "send_text", "receive_media", "groups"]}, + ] + + def health(self) -> dict[str, Any]: + return {"ok": True, "providerId": self.provider_id} + + def start_session(self, payload: dict[str, Any]) -> dict[str, Any]: + kind = str(payload["kind"]) + session = self.store.create_session( + kind=kind, + connection_id=str(payload["connectionId"]), + channel_id=str(payload["channelId"]), + display_name=str(payload["displayName"]), + options=dict(payload.get("options") or {}), + ) + command_template = self._command_template(kind) + state_dir = str(Path(self.store.path).parent / kind / session.connection_id) + command = shlex.split(command_template.format(state_dir=state_dir, connection_id=session.connection_id)) + code, stdout, stderr = self.runner(command, state_dir) + if code != 0: + session = self.store.update_session(session.session_id, status="error", error=_redact(stderr or stdout)) + return _session_view(session) + status = "connected" if "connected" in stdout.lower() else "waiting_for_user" + account_id = _extract_account_id(stdout) + session = self.store.update_session( + session.session_id, + status=status, + account_id=account_id, + metadata={"stateRef": state_dir}, + instructions=["Complete the vendor install or verification flow"] if status != "connected" else [], + ) + return _session_view(session) + + def get_session(self, session_id: str) -> dict[str, Any]: + return _session_view(self.store.get_session(session_id)) + + def cancel_session(self, session_id: str) -> None: + self.store.update_session(session_id, status="cancelled") + + def logout(self, connection_id: str) -> None: + return None + + def send(self, payload: dict[str, Any]) -> dict[str, Any]: + begin = self.store.begin_send(connection_id=str(payload["connectionId"]), request_id=str(payload["requestId"])) + if not begin.should_send: + return {"ok": True, "providerMessageId": begin.provider_message_id} + provider_message_id = f"vendor_{payload['requestId']}" + self.store.complete_send(begin.dedupe_key, provider_message_id=provider_message_id) + return {"ok": True, "providerMessageId": provider_message_id} + + def _command_template(self, kind: str) -> str: + key = "WEIXIN_CONNECT_COMMAND" if kind == "weixin" else "FEISHU_CONNECT_COMMAND" + command = str(self.env.get(key) or "").strip() + if not command: + raise RuntimeError(f"{key} is required") + return command + + +def _extract_account_id(output: str) -> str | None: + for part in output.split(): + if part.startswith("account="): + return part.split("=", 1)[1] + return None + + +def _redact(text: str) -> str: + redacted = text.replace("secret-token", "***") + for marker in ("appSecret=", "token=", "secret="): + while marker in redacted: + start = redacted.index(marker) + len(marker) + end = start + while end < len(redacted) and not redacted[end].isspace(): + end += 1 + redacted = redacted[:start] + "***" + redacted[end:] + return redacted +``` + +- [ ] **Step 4: Run provider tests** + +Run: + +```bash +cd external-connector +uv run pytest tests/test_vendor_cli_provider.py tests/test_sidecar_api.py tests/test_state.py -q +``` + +Expected: all sidecar tests pass. + +- [ ] **Step 5: Commit Task 4** + +```bash +git add external-connector +git commit -m "feat: add vendor cli connector provider" +``` + +--- + +### Task 5: Docker And Compose + +**Files:** +- Create: `external-connector/Dockerfile` +- Create: `docker-compose.external-connectors.yml` +- Modify: `.env.example` + +- [ ] **Step 1: Create Dockerfile** + +Create `external-connector/Dockerfile`: + +```dockerfile +FROM python:3.12-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends nodejs npm ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY pyproject.toml ./ +COPY external_connector ./external_connector +RUN pip install --no-cache-dir uv \ + && uv pip install --system . + +ENV CONNECTOR_HOME=/var/lib/external-connector +EXPOSE 8787 + +CMD ["python", "-m", "external_connector.main"] +``` + +- [ ] **Step 2: Create compose file** + +Create `docker-compose.external-connectors.yml`: + +```yaml +services: + external-connector: + build: ./external-connector + restart: unless-stopped + environment: + BEAVER_BRIDGE_BASE_URL: ${BEAVER_BRIDGE_BASE_URL:-http://app-instance:8080} + BEAVER_BRIDGE_TOKEN: ${BEAVER_BRIDGE_TOKEN} + CONNECTOR_API_TOKEN: ${EXTERNAL_CONNECTOR_TOKEN} + CONNECTOR_HOME: /var/lib/external-connector + CONNECTOR_PROVIDER: ${CONNECTOR_PROVIDER:-vendor_cli} + WEIXIN_CONNECT_COMMAND: ${WEIXIN_CONNECT_COMMAND:-} + FEISHU_CONNECT_COMMAND: ${FEISHU_CONNECT_COMMAND:-} + volumes: + - external-connector-state:/var/lib/external-connector + ports: + - "${EXTERNAL_CONNECTOR_PORT:-8787}:8787" + +volumes: + external-connector-state: +``` + +- [ ] **Step 3: Update env example** + +Append to `.env.example`: + +```dotenv +# External connector sidecar +EXTERNAL_CONNECTOR_TOKEN= +BEAVER_BRIDGE_TOKEN= +BEAVER_BRIDGE_BASE_URL=http://app-instance:8080 +EXTERNAL_CONNECTOR_PORT=8787 +CONNECTOR_PROVIDER=vendor_cli +WEIXIN_CONNECT_COMMAND= +FEISHU_CONNECT_COMMAND= +``` + +- [ ] **Step 4: Build sidecar image** + +Run: + +```bash +docker compose -f docker-compose.external-connectors.yml build external-connector +``` + +Expected: build succeeds. + +- [ ] **Step 5: Run sidecar API smoke test with fake provider** + +Run: + +```bash +CONNECTOR_PROVIDER=fake EXTERNAL_CONNECTOR_TOKEN=dev-token BEAVER_BRIDGE_TOKEN=dev-token \ +docker compose -f docker-compose.external-connectors.yml up -d external-connector +curl -sS -H 'Authorization: Bearer dev-token' http://localhost:8787/connectors +docker compose -f docker-compose.external-connectors.yml down +``` + +Expected: curl output contains both `"kind":"weixin"` and `"kind":"feishu"`. + +- [ ] **Step 6: Commit Task 5** + +```bash +git add external-connector/Dockerfile docker-compose.external-connectors.yml .env.example +git commit -m "feat: add external connector sidecar docker setup" +``` + +--- + +### Task 6: Final Sidecar Verification + +**Files:** +- Review: `docs/superpowers/specs/2026-06-02-external-sidecar-connectors-design.md` + +- [ ] **Step 1: Run all sidecar tests** + +Run: + +```bash +cd external-connector +uv run pytest -q +``` + +Expected: all sidecar tests pass. + +- [ ] **Step 2: Scan repo files for forbidden provider-runtime naming** + +Run: + +```bash +rg -n "[Oo]pen[Cc]law" external-connector docker-compose.external-connectors.yml .env.example docs/superpowers || true +``` + +Expected: no matches. + +- [ ] **Step 3: Verify Docker build** + +Run: + +```bash +docker compose -f docker-compose.external-connectors.yml build external-connector +``` + +Expected: build succeeds. + +- [ ] **Step 4: Commit verification-only fixes if needed** + +If verification required small fixes: + +```bash +git add external-connector docker-compose.external-connectors.yml .env.example +git commit -m "fix: stabilize external connector sidecar" +``` + +If no files changed, do not create an empty commit. From ee972441f57eec50deb8a6e0412f8a132105aee5 Mon Sep 17 00:00:00 2001 From: steven_li Date: Wed, 3 Jun 2026 10:32:50 +0800 Subject: [PATCH 10/11] docs: harden external connector implementation plans --- ...6-03-external-connector-backend-runtime.md | 570 ++++++++++-------- .../2026-06-03-external-connector-sidecar.md | 130 +++- ...6-02-external-sidecar-connectors-design.md | 27 +- 3 files changed, 448 insertions(+), 279 deletions(-) diff --git a/docs/superpowers/plans/2026-06-03-external-connector-backend-runtime.md b/docs/superpowers/plans/2026-06-03-external-connector-backend-runtime.md index 1e818f0..32aa02b 100644 --- a/docs/superpowers/plans/2026-06-03-external-connector-backend-runtime.md +++ b/docs/superpowers/plans/2026-06-03-external-connector-backend-runtime.md @@ -315,257 +315,7 @@ git commit -m "feat: add connector bridge dedupe store" --- -### Task 2: External Connector Channel - -**Files:** -- Create: `app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py` -- Create: `app-instance/backend/beaver/interfaces/channels/external_connector.py` -- Modify: `app-instance/backend/beaver/interfaces/channels/__init__.py` -- Test: `app-instance/backend/tests/unit/test_external_connector_channel.py` - -- [ ] **Step 1: Write failing channel tests** - -Create `app-instance/backend/tests/unit/test_external_connector_channel.py`: - -```python -from __future__ import annotations - -import asyncio - -from beaver.foundation.events import ChannelIdentity, OutboundMessage -from beaver.interfaces.channels.external_connector import ExternalConnectorChannel - - -class FakeSidecarClient: - def __init__(self) -> None: - self.sent: list[dict] = [] - - async def send(self, payload: dict) -> dict: - self.sent.append(payload) - return {"ok": True, "providerMessageId": "provider-1"} - - -def test_external_connector_channel_sends_with_target_and_request_id() -> None: - async def run() -> None: - client = FakeSidecarClient() - channel = ExternalConnectorChannel( - channel_id="weixin-main", - platform_kind="weixin", - connection_id="conn_1", - account_id="weixin:me", - display_name="Weixin Main", - sidecar_client=client, - ) - message = OutboundMessage( - channel="weixin-main", - content="reply", - session_id="s1", - finish_reason="stop", - message_id="out-msg-1", - channel_identity=ChannelIdentity( - channel_id="weixin-main", - kind="weixin", - account_id="weixin:me", - peer_id="peer-1", - peer_type="dm", - thread_id=None, - user_id="sender-1", - message_id="in-msg-1", - ), - ) - - await channel.send(message) - - assert client.sent == [ - { - "requestId": "out_out-msg-1", - "connectionId": "conn_1", - "channelId": "weixin-main", - "kind": "weixin", - "target": {"peerId": "peer-1", "peerType": "dm", "threadId": None}, - "content": "reply", - "metadata": {"inboundMessageId": "in-msg-1", "sessionId": "s1"}, - } - ] - - asyncio.run(run()) - - -def test_external_connector_channel_requires_identity() -> None: - async def run() -> None: - channel = ExternalConnectorChannel( - channel_id="weixin-main", - platform_kind="weixin", - connection_id="conn_1", - account_id="weixin:me", - display_name="Weixin Main", - sidecar_client=FakeSidecarClient(), - ) - message = OutboundMessage(channel="weixin-main", content="reply", session_id="s1", finish_reason="stop") - - try: - await channel.send(message) - except ValueError as exc: - assert "channel_identity is required" in str(exc) - else: - raise AssertionError("Expected ValueError") - - asyncio.run(run()) -``` - -- [ ] **Step 2: Run tests to verify failure** - -Run: - -```bash -cd app-instance/backend -uv run pytest tests/unit/test_external_connector_channel.py -q -``` - -Expected: fail with `ModuleNotFoundError: No module named 'beaver.interfaces.channels.external_connector'`. - -- [ ] **Step 3: Implement sidecar client** - -Create `app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py`: - -```python -from __future__ import annotations - -from typing import Any - -import httpx - - -class ConnectorSidecarClient: - def __init__(self, *, base_url: str, token: str, timeout_seconds: float = 20.0) -> None: - self.base_url = base_url.rstrip("/") - self.token = token - self.timeout_seconds = float(timeout_seconds) - - async def get_connectors(self) -> list[dict[str, Any]]: - return await self._request("GET", "/connectors") - - async def start_session(self, payload: dict[str, Any]) -> dict[str, Any]: - return await self._request("POST", "/connector-sessions", json=payload) - - async def get_session(self, session_id: str) -> dict[str, Any]: - return await self._request("GET", f"/connector-sessions/{session_id}") - - async def cancel_session(self, session_id: str) -> dict[str, Any]: - return await self._request("POST", f"/connector-sessions/{session_id}/cancel", json={}) - - async def logout(self, connection_id: str) -> dict[str, Any]: - return await self._request("POST", f"/connections/{connection_id}/logout", json={}) - - async def send(self, payload: dict[str, Any]) -> dict[str, Any]: - return await self._request("POST", "/send", json=payload) - - async def _request(self, method: str, path: str, *, json: dict[str, Any] | None = None) -> Any: - headers = {"Authorization": f"Bearer {self.token}"} if self.token else {} - async with httpx.AsyncClient(timeout=self.timeout_seconds) as client: - response = await client.request(method, f"{self.base_url}{path}", json=json, headers=headers) - response.raise_for_status() - return response.json() -``` - -- [ ] **Step 4: Implement external channel** - -Create `app-instance/backend/beaver/interfaces/channels/external_connector.py`: - -```python -from __future__ import annotations - -from typing import Any - -from beaver.foundation.events import OutboundMessage -from beaver.interfaces.channels.connections.sidecar_client import ConnectorSidecarClient - - -class ExternalConnectorChannel: - def __init__( - self, - *, - channel_id: str, - platform_kind: str, - connection_id: str, - account_id: str, - display_name: str, - sidecar_client: ConnectorSidecarClient | Any, - ) -> None: - self.channel_id = channel_id - self.kind = "external_connector" - self.mode = "http" - self.platform_kind = platform_kind - self.connection_id = connection_id - self.account_id = account_id - self.display_name = display_name or channel_id - self.sidecar_client = sidecar_client - self.started = False - - async def start(self) -> None: - self.started = True - - async def stop(self) -> None: - self.started = False - - async def send(self, message: OutboundMessage) -> None: - identity = message.channel_identity - if identity is None: - raise ValueError("channel_identity is required for external connector sends") - payload = { - "requestId": _request_id(message), - "connectionId": self.connection_id, - "channelId": self.channel_id, - "kind": self.platform_kind, - "target": { - "peerId": identity.peer_id, - "peerType": identity.peer_type, - "threadId": identity.thread_id, - }, - "content": message.content, - "metadata": { - "inboundMessageId": identity.message_id, - "sessionId": message.session_id, - }, - } - await self.sidecar_client.send(payload) - - -def _request_id(message: OutboundMessage) -> str: - return f"out_{message.message_id}" -``` - -- [ ] **Step 5: Export channel symbol** - -Modify `app-instance/backend/beaver/interfaces/channels/__init__.py`: - -```python -from .external_connector import ExternalConnectorChannel -``` - -Add `ExternalConnectorChannel` to `__all__`. - -- [ ] **Step 6: Run channel tests** - -Run: - -```bash -cd app-instance/backend -uv run pytest tests/unit/test_external_connector_channel.py -q -``` - -Expected: `2 passed`. - -- [ ] **Step 7: Commit Task 2** - -```bash -git add app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py app-instance/backend/beaver/interfaces/channels/external_connector.py app-instance/backend/beaver/interfaces/channels/__init__.py app-instance/backend/tests/unit/test_external_connector_channel.py -git commit -m "feat: add external connector channel" -``` - ---- - -### Task 3: Dynamic Runtime Channels +### Task 2: Dynamic Runtime Channels **Files:** - Modify: `app-instance/backend/beaver/interfaces/channels/manager.py` @@ -716,7 +466,7 @@ Add methods to `ChannelRuntime`: if current == config and channel_id in self.adapters: return if not config.enabled: - await self.remove_channel(channel_id) + await self._remove_channel_locked(channel_id) self.channel_configs[channel_id] = config self.states[channel_id] = {"state": "disabled", "last_error": None} return @@ -733,16 +483,17 @@ Add methods to `ChannelRuntime`: async def remove_channel(self, channel_id: str) -> None: async with self._lifecycle_lock: - adapter = self.adapters.pop(channel_id, None) - self.manager.unregister(channel_id) - self.channel_configs.pop(channel_id, None) - if adapter is not None: - await adapter.stop() - self.events.record(channel_id=channel_id, kind="adapter_stopped") - self.states[channel_id] = {"state": "removed", "last_error": None} -``` + await self._remove_channel_locked(channel_id) -If this direct implementation deadlocks because `add_channel()` calls `remove_channel()` under the same lock, split the locked removal body into a private `_remove_channel_locked()` helper and call that from both public methods. + async def _remove_channel_locked(self, channel_id: str) -> None: + adapter = self.adapters.pop(channel_id, None) + self.manager.unregister(channel_id) + self.channel_configs.pop(channel_id, None) + if adapter is not None: + await adapter.stop() + self.events.record(channel_id=channel_id, kind="adapter_stopped") + self.states[channel_id] = {"state": "removed", "last_error": None} +``` - [ ] **Step 5: Run dynamic runtime tests** @@ -755,7 +506,7 @@ uv run pytest tests/unit/test_channel_runtime_dynamic_channels.py tests/unit/tes Expected: all listed tests pass. -- [ ] **Step 6: Commit Task 3** +- [ ] **Step 6: Commit Task 2** ```bash git add app-instance/backend/beaver/interfaces/channels/manager.py app-instance/backend/beaver/interfaces/channels/runtime.py app-instance/backend/tests/unit/test_channel_runtime_dynamic_channels.py @@ -764,6 +515,301 @@ git commit -m "feat: support dynamic runtime channels" --- +### Task 3: External Connector Channel + +**Files:** +- Create: `app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py` +- Create: `app-instance/backend/beaver/interfaces/channels/external_connector.py` +- Modify: `app-instance/backend/beaver/interfaces/channels/__init__.py` +- Test: `app-instance/backend/tests/unit/test_external_connector_channel.py` + +- [ ] **Step 1: Write failing channel tests** + +Create `app-instance/backend/tests/unit/test_external_connector_channel.py`: + +```python +from __future__ import annotations + +import asyncio + +from beaver.foundation.events import ChannelIdentity, OutboundMessage +from beaver.interfaces.channels.external_connector import ExternalConnectorChannel, _request_id + + +class FakeSidecarClient: + def __init__(self) -> None: + self.sent: list[dict] = [] + + async def send(self, payload: dict) -> dict: + self.sent.append(payload) + return {"ok": True, "providerMessageId": "provider-1"} + + +def test_external_connector_channel_sends_with_target_and_request_id() -> None: + async def run() -> None: + client = FakeSidecarClient() + channel = ExternalConnectorChannel( + channel_id="weixin-main", + platform_kind="weixin", + connection_id="conn_1", + account_id="weixin:me", + display_name="Weixin Main", + sidecar_client=client, + ) + message = OutboundMessage( + channel="weixin-main", + content="reply", + session_id="s1", + finish_reason="stop", + message_id="out-msg-1", + channel_identity=ChannelIdentity( + channel_id="weixin-main", + kind="weixin", + account_id="weixin:me", + peer_id="peer-1", + peer_type="dm", + thread_id=None, + user_id="sender-1", + message_id="in-msg-1", + ), + ) + + await channel.send(message) + + assert client.sent == [ + { + "requestId": "out_weixin-main:s1:out-msg-1", + "connectionId": "conn_1", + "channelId": "weixin-main", + "kind": "weixin", + "target": {"peerId": "peer-1", "peerType": "dm", "threadId": None}, + "content": "reply", + "metadata": {"inboundMessageId": "in-msg-1", "sessionId": "s1"}, + } + ] + + asyncio.run(run()) + + +def test_external_connector_request_id_falls_back_when_message_id_is_none_or_blank() -> None: + identity = ChannelIdentity( + channel_id="weixin-main", + kind="weixin", + account_id="weixin:me", + peer_id="peer-1", + peer_type="dm", + message_id="in-msg-1", + ) + first = OutboundMessage( + channel="weixin-main", + content="same reply", + session_id="s1", + finish_reason="stop", + message_id=None, # type: ignore[arg-type] + channel_identity=identity, + ) + second = OutboundMessage( + channel="weixin-main", + content="same reply", + session_id="s1", + finish_reason="stop", + message_id="", + channel_identity=identity, + ) + + assert _request_id(first) == _request_id(second) + assert _request_id(first).startswith("out_weixin-main:s1:") + + +def test_external_connector_channel_requires_identity() -> None: + async def run() -> None: + channel = ExternalConnectorChannel( + channel_id="weixin-main", + platform_kind="weixin", + connection_id="conn_1", + account_id="weixin:me", + display_name="Weixin Main", + sidecar_client=FakeSidecarClient(), + ) + message = OutboundMessage(channel="weixin-main", content="reply", session_id="s1", finish_reason="stop") + + try: + await channel.send(message) + except ValueError as exc: + assert "channel_identity is required" in str(exc) + else: + raise AssertionError("Expected ValueError") + + asyncio.run(run()) +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_external_connector_channel.py -q +``` + +Expected: fail with `ModuleNotFoundError: No module named 'beaver.interfaces.channels.external_connector'`. + +- [ ] **Step 3: Implement sidecar client** + +Create `app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py`: + +```python +from __future__ import annotations + +import hashlib +from typing import Any + +import httpx + + +class ConnectorSidecarClient: + def __init__(self, *, base_url: str, token: str, timeout_seconds: float = 20.0) -> None: + self.base_url = base_url.rstrip("/") + self.token = token + self.timeout_seconds = float(timeout_seconds) + + async def get_connectors(self) -> list[dict[str, Any]]: + return await self._request("GET", "/connectors") + + async def start_session(self, payload: dict[str, Any]) -> dict[str, Any]: + return await self._request("POST", "/connector-sessions", json=payload) + + async def get_session(self, session_id: str) -> dict[str, Any]: + return await self._request("GET", f"/connector-sessions/{session_id}") + + async def cancel_session(self, session_id: str) -> dict[str, Any]: + return await self._request("POST", f"/connector-sessions/{session_id}/cancel", json={}) + + async def logout(self, connection_id: str) -> dict[str, Any]: + return await self._request("POST", f"/connections/{connection_id}/logout", json={}) + + async def send(self, payload: dict[str, Any]) -> dict[str, Any]: + return await self._request("POST", "/send", json=payload) + + async def _request(self, method: str, path: str, *, json: dict[str, Any] | None = None) -> Any: + headers = {"Authorization": f"Bearer {self.token}"} if self.token else {} + async with httpx.AsyncClient(timeout=self.timeout_seconds) as client: + response = await client.request(method, f"{self.base_url}{path}", json=json, headers=headers) + response.raise_for_status() + return response.json() +``` + +- [ ] **Step 4: Implement external channel** + +Create `app-instance/backend/beaver/interfaces/channels/external_connector.py`: + +```python +from __future__ import annotations + +from typing import Any + +from beaver.foundation.events import OutboundMessage +from beaver.interfaces.channels.connections.sidecar_client import ConnectorSidecarClient + + +class ExternalConnectorChannel: + def __init__( + self, + *, + channel_id: str, + platform_kind: str, + connection_id: str, + account_id: str, + display_name: str, + sidecar_client: ConnectorSidecarClient | Any, + ) -> None: + self.channel_id = channel_id + self.kind = "external_connector" + self.mode = "http" + self.platform_kind = platform_kind + self.connection_id = connection_id + self.account_id = account_id + self.display_name = display_name or channel_id + self.sidecar_client = sidecar_client + self.started = False + + async def start(self) -> None: + self.started = True + + async def stop(self) -> None: + self.started = False + + async def send(self, message: OutboundMessage) -> None: + identity = message.channel_identity + if identity is None: + raise ValueError("channel_identity is required for external connector sends") + payload = { + "requestId": _request_id(message), + "connectionId": self.connection_id, + "channelId": self.channel_id, + "kind": self.platform_kind, + "target": { + "peerId": identity.peer_id, + "peerType": identity.peer_type, + "threadId": identity.thread_id, + }, + "content": message.content, + "metadata": { + "inboundMessageId": identity.message_id, + "sessionId": message.session_id, + }, + } + await self.sidecar_client.send(payload) + + +def _request_id(message: OutboundMessage) -> str: + identity = message.channel_identity + channel = message.channel or (identity.channel_id if identity else "unknown") + session_id = message.session_id or (identity.session_id() if identity else "unknown") + message_id = str(message.message_id or "").strip() + if not message_id: + basis = "|".join( + [ + message.content, + identity.message_id if identity and identity.message_id else "", + identity.peer_id if identity else "", + message.finish_reason, + ] + ) + message_id = hashlib.sha256(basis.encode("utf-8")).hexdigest()[:24] + return f"out_{channel}:{session_id}:{message_id}" +``` + +- [ ] **Step 5: Export channel symbol** + +Modify `app-instance/backend/beaver/interfaces/channels/__init__.py`: + +```python +from .external_connector import ExternalConnectorChannel +``` + +Add `ExternalConnectorChannel` to `__all__`. + +- [ ] **Step 6: Run channel tests** + +Run: + +```bash +cd app-instance/backend +uv run pytest tests/unit/test_external_connector_channel.py -q +``` + +Expected: `2 passed`. + +- [ ] **Step 7: Commit Task 3** + +```bash +git add app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py app-instance/backend/beaver/interfaces/channels/external_connector.py app-instance/backend/beaver/interfaces/channels/__init__.py app-instance/backend/tests/unit/test_external_connector_channel.py +git commit -m "feat: add external connector channel" +``` + +--- + ### Task 4: Runtime Factory For External Connector Channel **Files:** diff --git a/docs/superpowers/plans/2026-06-03-external-connector-sidecar.md b/docs/superpowers/plans/2026-06-03-external-connector-sidecar.md index b33b5c3..354468f 100644 --- a/docs/superpowers/plans/2026-06-03-external-connector-sidecar.md +++ b/docs/superpowers/plans/2026-06-03-external-connector-sidecar.md @@ -20,7 +20,7 @@ Included: - Production `VendorCliProvider` with environment-driven command templates. - Service-level bearer authentication for Beaver-to-sidecar requests. - Connector session state persistence. -- `/send` idempotency by `connectionId + requestId`. +- `/send` idempotency by `connectionId + requestId`, including processing TTL retry semantics. - Dockerfile and local compose declaration. Excluded: @@ -28,9 +28,20 @@ Excluded: - Beaver backend bridge implementation. - Frontend UI. - Hardcoded vendor command strings in repo files. +- Accepting command strings from frontend or sidecar HTTP request bodies. - Docker socket access. - Dynamic container creation. +## Vendor Command Safety Contract + +`VendorCliProvider` may execute vendor install/send commands because the sidecar is a controlled deployment container, but command execution has fixed boundaries: + +- Command templates only come from sidecar startup environment variables. +- No frontend or HTTP API payload can supply or override command strings. +- `cwd` is fixed to `CONNECTOR_HOME`; per-connection state paths are passed as formatted arguments only. +- Every command uses a hard timeout from `CONNECTOR_COMMAND_TIMEOUT_SECONDS`, defaulting to 120 seconds. +- stdout and stderr are redacted before being stored or returned. + ## File Structure - Create `external-connector/pyproject.toml` @@ -138,7 +149,31 @@ def test_state_store_dedupes_send_results(tmp_path) -> None: assert first.should_send is True assert duplicate.should_send is False + assert duplicate.status == "completed" + assert duplicate.http_status == 200 assert duplicate.provider_message_id == "provider-1" + + +def test_state_store_returns_conflict_for_active_send_processing(tmp_path) -> None: + store = SidecarStateStore(tmp_path / "state.json", send_processing_ttl_seconds=60) + + store.begin_send(connection_id="conn_1", request_id="out_1") + duplicate = store.begin_send(connection_id="conn_1", request_id="out_1") + + assert duplicate.should_send is False + assert duplicate.status == "processing" + assert duplicate.http_status == 409 + assert duplicate.retry_after_seconds == 5 + + +def test_state_store_retries_stale_send_processing(tmp_path) -> None: + store = SidecarStateStore(tmp_path / "state.json", send_processing_ttl_seconds=0) + + store.begin_send(connection_id="conn_1", request_id="out_1") + retry = store.begin_send(connection_id="conn_1", request_id="out_1") + + assert retry.should_send is True + assert retry.status == "processing" ``` - [ ] **Step 3: Run tests to verify failure** @@ -218,12 +253,16 @@ class ConnectorSessionState: class SendBeginResult: should_send: bool dedupe_key: str + status: str + http_status: int + retry_after_seconds: int | None = None provider_message_id: str | None = None class SidecarStateStore: - def __init__(self, path: Path) -> None: + def __init__(self, path: Path, *, send_processing_ttl_seconds: int = 60) -> None: self.path = Path(path) + self.send_processing_ttl_seconds = int(send_processing_ttl_seconds) self._lock = Lock() def create_session( @@ -277,8 +316,12 @@ class SidecarStateStore: with self._lock: data = self._load() existing = data["sends"].get(dedupe_key) - if isinstance(existing, dict) and existing.get("status") == "completed": - return SendBeginResult(False, dedupe_key, str(existing.get("provider_message_id") or "")) + if isinstance(existing, dict): + status = str(existing.get("status") or "processing") + if status == "completed": + return SendBeginResult(False, dedupe_key, "completed", 200, None, str(existing.get("provider_message_id") or "")) + if status == "processing" and not self._send_is_stale(existing): + return SendBeginResult(False, dedupe_key, "processing", 409, 5) data["sends"][dedupe_key] = { "connection_id": connection_id, "request_id": request_id, @@ -286,7 +329,7 @@ class SidecarStateStore: "updated_at": iso_now(), } self._save(data) - return SendBeginResult(True, dedupe_key) + return SendBeginResult(True, dedupe_key, "processing", 200) def complete_send(self, dedupe_key: str, *, provider_message_id: str | None) -> None: with self._lock: @@ -296,6 +339,11 @@ class SidecarStateStore: data["sends"][dedupe_key] = item self._save(data) + def _send_is_stale(self, item: dict[str, Any]) -> bool: + updated_at = str(item.get("updated_at") or iso_now()) + updated = datetime.fromisoformat(updated_at.replace("Z", "+00:00")) + return (datetime.now(timezone.utc) - updated).total_seconds() >= self.send_processing_ttl_seconds + def _load(self) -> dict[str, Any]: if not self.path.exists(): return {"sessions": {}, "sends": {}} @@ -565,6 +613,8 @@ class FakeProvider: def send(self, payload: dict[str, Any]) -> dict[str, Any]: begin = self.store.begin_send(connection_id=str(payload["connectionId"]), request_id=str(payload["requestId"])) if not begin.should_send: + if begin.http_status == 409: + return {"ok": False, "status": begin.status, "retryAfterSeconds": begin.retry_after_seconds, "httpStatus": 409} return {"ok": True, "providerMessageId": begin.provider_message_id} provider_message_id = f"fake_{uuid4().hex}" self.store.complete_send(begin.dedupe_key, provider_message_id=provider_message_id) @@ -655,6 +705,31 @@ def test_sidecar_http_api_session_and_send(tmp_path) -> None: assert session.status_code == 200 assert loaded.json()["sessionId"] == session_id assert sent.json()["ok"] is True + + +def test_sidecar_http_api_returns_conflict_for_processing_send(tmp_path) -> None: + store = SidecarStateStore(tmp_path / "state.json", send_processing_ttl_seconds=60) + store.begin_send(connection_id="conn_1", request_id="out_1") + app = create_app(provider=FakeProvider(store), api_token="sidecar-token") + headers = {"Authorization": "Bearer sidecar-token"} + + with TestClient(app) as client: + response = client.post( + "/send", + headers=headers, + json={ + "requestId": "out_1", + "connectionId": "conn_1", + "channelId": "weixin-main", + "kind": "weixin", + "target": {"peerId": "peer-1", "peerType": "dm", "threadId": None}, + "content": "hello", + "metadata": {}, + }, + ) + + assert response.status_code == 409 + assert response.json()["retryAfterSeconds"] == 5 ``` - [ ] **Step 2: Run tests to verify failure** @@ -678,6 +753,7 @@ from __future__ import annotations from typing import Any from fastapi import FastAPI, Header, HTTPException +from fastapi.responses import JSONResponse from external_connector.models import ConnectorSessionRequest, SendRequest from external_connector.providers.base import ConnectorProvider @@ -725,9 +801,13 @@ def create_app(*, provider: ConnectorProvider, api_token: str) -> FastAPI: return {"ok": True} @app.post("/send") - def send(payload: SendRequest, authorization: str | None = Header(default=None)) -> dict[str, Any]: + def send(payload: SendRequest, authorization: str | None = Header(default=None)) -> JSONResponse | dict[str, Any]: require_auth(authorization) - return provider.send(payload.model_dump(by_alias=True)) + result = dict(provider.send(payload.model_dump(by_alias=True))) + status_code = int(result.pop("httpStatus", 200)) + if status_code != 200: + return JSONResponse(status_code=status_code, content=result) + return result return app ``` @@ -810,9 +890,13 @@ from external_connector.state import SidecarStateStore class FakeRunner: def __init__(self) -> None: self.commands: list[list[str]] = [] + self.cwd: str | None = None + self.timeout: float | None = None - def __call__(self, command: list[str], cwd: str) -> tuple[int, str, str]: + def __call__(self, command: list[str], cwd: str, timeout: float) -> tuple[int, str, str]: self.commands.append(command) + self.cwd = cwd + self.timeout = timeout return 0, "connected account=weixin:me", "" @@ -820,7 +904,7 @@ def test_vendor_cli_provider_uses_env_command_templates(tmp_path) -> None: runner = FakeRunner() provider = VendorCliProvider( store=SidecarStateStore(tmp_path / "state.json"), - env={"WEIXIN_CONNECT_COMMAND": "vendor-weixin install --state {state_dir}"}, + env={"WEIXIN_CONNECT_COMMAND": "vendor-weixin install --state {state_dir}", "CONNECTOR_COMMAND_TIMEOUT_SECONDS": "30"}, runner=runner, ) @@ -837,10 +921,12 @@ def test_vendor_cli_provider_uses_env_command_templates(tmp_path) -> None: assert session["status"] in {"waiting_for_user", "connected"} assert runner.commands[0][0] == "vendor-weixin" + assert runner.cwd == str(tmp_path) + assert runner.timeout == 30.0 def test_vendor_cli_provider_redacts_sensitive_error(tmp_path) -> None: - def runner(command: list[str], cwd: str) -> tuple[int, str, str]: + def runner(command: list[str], cwd: str, timeout: float) -> tuple[int, str, str]: return 1, "", "failed secret-token appSecret=abc" provider = VendorCliProvider( @@ -894,11 +980,11 @@ from external_connector.providers.fake import _session_view from external_connector.state import SidecarStateStore -Runner = Callable[[list[str], str], tuple[int, str, str]] +Runner = Callable[[list[str], str, float], tuple[int, str, str]] -def default_runner(command: list[str], cwd: str) -> tuple[int, str, str]: - completed = subprocess.run(command, cwd=cwd, text=True, capture_output=True, check=False) +def default_runner(command: list[str], cwd: str, timeout: float) -> tuple[int, str, str]: + completed = subprocess.run(command, cwd=cwd, text=True, capture_output=True, check=False, timeout=timeout) return completed.returncode, completed.stdout, completed.stderr @@ -915,6 +1001,7 @@ class VendorCliProvider: self.store = store self.env = env or os.environ self.runner = runner + self.command_timeout_seconds = float(self.env.get("CONNECTOR_COMMAND_TIMEOUT_SECONDS") or 120) def connectors(self) -> list[dict[str, Any]]: return [ @@ -935,9 +1022,18 @@ class VendorCliProvider: options=dict(payload.get("options") or {}), ) command_template = self._command_template(kind) - state_dir = str(Path(self.store.path).parent / kind / session.connection_id) + connector_home = Path(self.store.path).parent + state_dir = str(connector_home / kind / session.connection_id) + Path(state_dir).mkdir(parents=True, exist_ok=True) command = shlex.split(command_template.format(state_dir=state_dir, connection_id=session.connection_id)) - code, stdout, stderr = self.runner(command, state_dir) + try: + code, stdout, stderr = self.runner(command, str(connector_home), self.command_timeout_seconds) + except subprocess.TimeoutExpired: + session = self.store.update_session(session.session_id, status="error", error="Provider command timed out") + return _session_view(session) + except Exception as exc: + session = self.store.update_session(session.session_id, status="error", error=_redact(str(exc))) + return _session_view(session) if code != 0: session = self.store.update_session(session.session_id, status="error", error=_redact(stderr or stdout)) return _session_view(session) @@ -964,6 +1060,8 @@ class VendorCliProvider: def send(self, payload: dict[str, Any]) -> dict[str, Any]: begin = self.store.begin_send(connection_id=str(payload["connectionId"]), request_id=str(payload["requestId"])) if not begin.should_send: + if begin.http_status == 409: + return {"ok": False, "status": begin.status, "retryAfterSeconds": begin.retry_after_seconds, "httpStatus": 409} return {"ok": True, "providerMessageId": begin.provider_message_id} provider_message_id = f"vendor_{payload['requestId']}" self.store.complete_send(begin.dedupe_key, provider_message_id=provider_message_id) @@ -1061,6 +1159,7 @@ services: CONNECTOR_API_TOKEN: ${EXTERNAL_CONNECTOR_TOKEN} CONNECTOR_HOME: /var/lib/external-connector CONNECTOR_PROVIDER: ${CONNECTOR_PROVIDER:-vendor_cli} + CONNECTOR_COMMAND_TIMEOUT_SECONDS: ${CONNECTOR_COMMAND_TIMEOUT_SECONDS:-120} WEIXIN_CONNECT_COMMAND: ${WEIXIN_CONNECT_COMMAND:-} FEISHU_CONNECT_COMMAND: ${FEISHU_CONNECT_COMMAND:-} volumes: @@ -1083,6 +1182,7 @@ BEAVER_BRIDGE_TOKEN= BEAVER_BRIDGE_BASE_URL=http://app-instance:8080 EXTERNAL_CONNECTOR_PORT=8787 CONNECTOR_PROVIDER=vendor_cli +CONNECTOR_COMMAND_TIMEOUT_SECONDS=120 WEIXIN_CONNECT_COMMAND= FEISHU_CONNECT_COMMAND= ``` diff --git a/docs/superpowers/specs/2026-06-02-external-sidecar-connectors-design.md b/docs/superpowers/specs/2026-06-02-external-sidecar-connectors-design.md index b6c851e..317d9fb 100644 --- a/docs/superpowers/specs/2026-06-02-external-sidecar-connectors-design.md +++ b/docs/superpowers/specs/2026-06-02-external-sidecar-connectors-design.md @@ -99,6 +99,15 @@ Initial provider: - `VendorCliProvider`: runs the real CLI/plugin commands required by the current Weixin and Feishu/Lark vendor flows. +`VendorCliProvider` command execution is intentionally constrained: + +- Command templates are read only from sidecar startup environment variables. +- Frontend requests and sidecar HTTP request bodies cannot provide command strings. +- Command working directory is fixed to `CONNECTOR_HOME`. +- Per-connection state paths may be passed to commands as formatted arguments. +- Every command has a hard timeout. +- stdout and stderr are redacted before storage or API responses. + Future providers can be added without changing Beaver runtime code: - `WechatyProvider` @@ -232,6 +241,7 @@ services: CONNECTOR_API_TOKEN: ${EXTERNAL_CONNECTOR_TOKEN} CONNECTOR_HOME: /var/lib/external-connector CONNECTOR_PROVIDER: vendor_cli + CONNECTOR_COMMAND_TIMEOUT_SECONDS: 120 volumes: - external-connector-state:/var/lib/external-connector ``` @@ -347,7 +357,17 @@ Allowed connector session statuses: } ``` -`requestId` is required. Beaver must generate a stable request id for each outbound delivery attempt from the outbound message identity, and must reuse the same `requestId` if the same outbound delivery is retried. The sidecar dedupes `connectionId + requestId`; duplicate requests return the original send result and must not send a second platform message. +`requestId` is required. Beaver must generate a stable request id for each outbound delivery attempt and must reuse the same `requestId` if the same outbound delivery is retried. The first-version rule is: + +```text +out_{channel}:{session_id}:{message_id or sha256(content + inbound_message_id + peer_id + finish_reason)} +``` + +The sidecar dedupes `connectionId + requestId`: + +- `completed`: return the original send result and do not send a second platform message. +- `processing` updated less than 60 seconds ago: return `409 Conflict` with `{"retryAfterSeconds": 5}` so Beaver retries later. +- `processing` updated 60 seconds or more ago: treat as stale and retry the provider send. ## Beaver Bridge API @@ -497,7 +517,8 @@ The old `/api/channels` static config editor may remain for advanced runtime con - Duplicate completed bridge event: return idempotent success and do not call runtime again. - Duplicate in-flight bridge event: return `409 Conflict` until the 60-second processing TTL expires, then allow one reprocess. - Outbound send failure: mark outbound delivery failed and record connector error. -- Duplicate outbound send `requestId`: sidecar returns the original send result and does not send a second platform message. +- Duplicate completed outbound send `requestId`: sidecar returns the original send result and does not send a second platform message. +- Duplicate in-flight outbound send `requestId`: sidecar returns `409 Conflict` until the 60-second processing TTL expires, then allows one retry. - Sidecar restart: persisted provider state should survive through sidecar volume. ## Security @@ -507,6 +528,7 @@ The old `/api/channels` static config editor may remain for advanced runtime con - Sidecar can only call bridge endpoints with the service-level bridge token. - Beaver can only call sidecar control and send endpoints with the service-level connector token. - Sidecar state volume contains login state and must be treated as sensitive. +- Vendor command strings are deployment configuration, not user input. - Feishu user-identity mode has stronger privacy risk than bot-identity mode; UI must label it clearly if exposed. ## Testing @@ -533,6 +555,7 @@ Sidecar tests: - fake provider status transitions - provider command runner error redaction - send idempotency for duplicate `connectionId + requestId` +- send `processing` TTL returns `409 Conflict` before stale retry Frontend tests: From c3d84b904a18121d995c74384277a3f77522a7aa Mon Sep 17 00:00:00 2001 From: steven_li Date: Wed, 3 Jun 2026 16:22:44 +0800 Subject: [PATCH 11/11] feat: implement channel runtime connectors --- .env.example | 16 + 2026-06-01-hermes-gateway-llm-design.md | 531 ++++-- app-instance/Dockerfile | 8 +- .../backend/beaver/engine/context/builder.py | 15 + app-instance/backend/beaver/engine/loop.py | 11 + .../beaver/foundation/config/loader.py | 44 + .../beaver/foundation/config/schema.py | 14 + .../beaver/foundation/events/__init__.py | 4 +- .../beaver/foundation/events/message_bus.py | 48 + .../beaver/interfaces/channels/__init__.py | 12 +- .../beaver/interfaces/channels/base.py | 17 +- .../channels/connections/__init__.py | 29 + .../channels/connections/connectors.py | 93 + .../interfaces/channels/connections/dedupe.py | 144 ++ .../channels/connections/external.py | 131 ++ .../interfaces/channels/connections/models.py | 117 ++ .../channels/connections/sidecar_client.py | 39 + .../interfaces/channels/connections/store.py | 222 +++ .../channels/connections/telegram.py | 92 + .../interfaces/channels/external_connector.py | 97 ++ .../interfaces/channels/generic_webhook.py | 116 ++ .../beaver/interfaces/channels/manager.py | 36 +- .../beaver/interfaces/channels/memory.py | 44 +- .../interfaces/channels/platforms/__init__.py | 1 + .../interfaces/channels/platforms/base.py | 138 ++ .../interfaces/channels/platforms/feishu.py | 207 +++ .../interfaces/channels/platforms/qqbot.py | 206 +++ .../interfaces/channels/platforms/telegram.py | 244 +++ .../interfaces/channels/platforms/weixin.py | 180 ++ .../beaver/interfaces/channels/runtime.py | 526 ++++++ .../beaver/interfaces/channels/state.py | 198 +++ .../interfaces/channels/terminal_websocket.py | 301 ++++ .../backend/beaver/interfaces/web/app.py | 520 +++++- .../beaver/interfaces/web/schemas/__init__.py | 20 + .../beaver/interfaces/web/schemas/chat.py | 107 ++ .../backend/beaver/services/agent_service.py | 8 +- app-instance/backend/pyproject.toml | 17 + .../tests/unit/test_channel_connection_api.py | 84 + .../unit/test_channel_connection_store.py | 63 + .../unit/test_channel_connector_registry.py | 164 ++ .../tests/unit/test_channel_runtime.py | 414 +++++ .../test_channel_runtime_dynamic_channels.py | 119 ++ .../backend/tests/unit/test_config_loader.py | 152 ++ .../test_connector_message_dedupe_store.py | 51 + .../test_external_connector_bridge_api.py | 107 ++ .../unit/test_external_connector_channel.py | 114 ++ .../unit/test_external_sidecar_connectors.py | 176 ++ .../tests/unit/test_feishu_channel_adapter.py | 154 ++ .../tests/unit/test_gateway_channels.py | 131 +- .../backend/tests/unit/test_imports.py | 36 +- .../unit/test_platform_channel_helpers.py | 66 + .../tests/unit/test_qqbot_channel_adapter.py | 143 ++ .../unit/test_telegram_channel_adapter.py | 141 ++ .../unit/test_telegram_channel_connector.py | 143 ++ .../unit/test_terminal_websocket_channel.py | 243 +++ .../tests/unit/test_weixin_channel_adapter.py | 129 ++ app-instance/backend/uv.lock | 185 +- app-instance/create-instance.sh | 138 ++ app-instance/frontend/app/(app)/logs/page.tsx | 5 +- app-instance/frontend/app/(app)/page.tsx | 32 +- .../frontend/app/(app)/skills/page.tsx | 7 +- .../frontend/app/(app)/status/page.tsx | 1144 ++++++++++++- app-instance/frontend/app/globals.css | 26 + .../chat-workbench/MarkdownContent.tsx | 4 +- .../components/chat-workbench/MessageList.tsx | 9 +- .../task-detail/TaskAcceptanceCard.tsx | 5 +- .../task-detail/TaskTimelineCard.tsx | 19 +- app-instance/frontend/lib/api.ts | 53 + .../frontend/lib/channel-connectors.test.ts | 81 + .../frontend/lib/chat-messages.test.ts | 15 +- app-instance/frontend/lib/chat-messages.ts | 20 + .../frontend/lib/text-wrapping.test.ts | 22 + app-instance/frontend/lib/text-wrapping.ts | 5 + app-instance/frontend/tailwind.config.ts | 1 + app-instance/frontend/types/index.ts | 112 +- app-instance/nginx.conf | 10 +- auth-portal/src/app/login/page.tsx | 14 +- auth-portal/src/app/register/page.tsx | 2 +- docker-compose.external-connectors.yml | 32 + .../2026-06-01-terminal-websocket-channel.md | 1105 ++++++++++++ ...026-06-02-channel-connectors-foundation.md | 47 +- ...26-06-02-chat-platform-channel-adapters.md | 1515 +++++++++++++++++ ...2-channel-connectors-and-pairing-design.md | 44 +- ...2-chat-platform-channel-adapters-design.md | 307 ++++ external-connector/Dockerfile | 23 + .../external_connector/__init__.py | 1 + external-connector/external_connector/app.py | 74 + external-connector/external_connector/main.py | 62 + .../external_connector/models.py | 24 + .../node/feishu_ws_receiver.js | 149 ++ .../external_connector/providers/base.py | 28 + .../external_connector/providers/composite.py | 70 + .../external_connector/providers/fake.py | 119 ++ .../providers/feishu_bot.py | 542 ++++++ .../providers/vendor_cli.py | 281 +++ .../providers/weixin_ilink.py | 463 +++++ .../external_connector/state.py | 203 +++ external-connector/package.json | 6 + external-connector/pyproject.toml | 20 + .../tests/test_feishu_bot_provider.py | 322 ++++ external-connector/tests/test_sidecar_api.py | 135 ++ external-connector/tests/test_state.py | 68 + .../tests/test_vendor_cli_provider.py | 182 ++ .../tests/test_weixin_ilink_provider.py | 408 +++++ external-connector/uv.lock | 621 +++++++ 105 files changed, 15621 insertions(+), 322 deletions(-) create mode 100644 app-instance/backend/beaver/interfaces/channels/connections/__init__.py create mode 100644 app-instance/backend/beaver/interfaces/channels/connections/connectors.py create mode 100644 app-instance/backend/beaver/interfaces/channels/connections/dedupe.py create mode 100644 app-instance/backend/beaver/interfaces/channels/connections/external.py create mode 100644 app-instance/backend/beaver/interfaces/channels/connections/models.py create mode 100644 app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py create mode 100644 app-instance/backend/beaver/interfaces/channels/connections/store.py create mode 100644 app-instance/backend/beaver/interfaces/channels/connections/telegram.py create mode 100644 app-instance/backend/beaver/interfaces/channels/external_connector.py create mode 100644 app-instance/backend/beaver/interfaces/channels/generic_webhook.py create mode 100644 app-instance/backend/beaver/interfaces/channels/platforms/__init__.py create mode 100644 app-instance/backend/beaver/interfaces/channels/platforms/base.py create mode 100644 app-instance/backend/beaver/interfaces/channels/platforms/feishu.py create mode 100644 app-instance/backend/beaver/interfaces/channels/platforms/qqbot.py create mode 100644 app-instance/backend/beaver/interfaces/channels/platforms/telegram.py create mode 100644 app-instance/backend/beaver/interfaces/channels/platforms/weixin.py create mode 100644 app-instance/backend/beaver/interfaces/channels/runtime.py create mode 100644 app-instance/backend/beaver/interfaces/channels/state.py create mode 100644 app-instance/backend/beaver/interfaces/channels/terminal_websocket.py create mode 100644 app-instance/backend/tests/unit/test_channel_connection_api.py create mode 100644 app-instance/backend/tests/unit/test_channel_connection_store.py create mode 100644 app-instance/backend/tests/unit/test_channel_connector_registry.py create mode 100644 app-instance/backend/tests/unit/test_channel_runtime.py create mode 100644 app-instance/backend/tests/unit/test_channel_runtime_dynamic_channels.py create mode 100644 app-instance/backend/tests/unit/test_connector_message_dedupe_store.py create mode 100644 app-instance/backend/tests/unit/test_external_connector_bridge_api.py create mode 100644 app-instance/backend/tests/unit/test_external_connector_channel.py create mode 100644 app-instance/backend/tests/unit/test_external_sidecar_connectors.py create mode 100644 app-instance/backend/tests/unit/test_feishu_channel_adapter.py create mode 100644 app-instance/backend/tests/unit/test_platform_channel_helpers.py create mode 100644 app-instance/backend/tests/unit/test_qqbot_channel_adapter.py create mode 100644 app-instance/backend/tests/unit/test_telegram_channel_adapter.py create mode 100644 app-instance/backend/tests/unit/test_telegram_channel_connector.py create mode 100644 app-instance/backend/tests/unit/test_terminal_websocket_channel.py create mode 100644 app-instance/backend/tests/unit/test_weixin_channel_adapter.py create mode 100644 app-instance/frontend/lib/channel-connectors.test.ts create mode 100644 app-instance/frontend/lib/text-wrapping.test.ts create mode 100644 app-instance/frontend/lib/text-wrapping.ts create mode 100644 docker-compose.external-connectors.yml create mode 100644 docs/superpowers/plans/2026-06-01-terminal-websocket-channel.md create mode 100644 docs/superpowers/plans/2026-06-02-chat-platform-channel-adapters.md create mode 100644 docs/superpowers/specs/2026-06-02-chat-platform-channel-adapters-design.md create mode 100644 external-connector/Dockerfile create mode 100644 external-connector/external_connector/__init__.py create mode 100644 external-connector/external_connector/app.py create mode 100644 external-connector/external_connector/main.py create mode 100644 external-connector/external_connector/models.py create mode 100644 external-connector/external_connector/node/feishu_ws_receiver.js create mode 100644 external-connector/external_connector/providers/base.py create mode 100644 external-connector/external_connector/providers/composite.py create mode 100644 external-connector/external_connector/providers/fake.py create mode 100644 external-connector/external_connector/providers/feishu_bot.py create mode 100644 external-connector/external_connector/providers/vendor_cli.py create mode 100644 external-connector/external_connector/providers/weixin_ilink.py create mode 100644 external-connector/external_connector/state.py create mode 100644 external-connector/package.json create mode 100644 external-connector/pyproject.toml create mode 100644 external-connector/tests/test_feishu_bot_provider.py create mode 100644 external-connector/tests/test_sidecar_api.py create mode 100644 external-connector/tests/test_state.py create mode 100644 external-connector/tests/test_vendor_cli_provider.py create mode 100644 external-connector/tests/test_weixin_ilink_provider.py create mode 100644 external-connector/uv.lock diff --git a/.env.example b/.env.example index d8bbfea..ba2c492 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,19 @@ BEAVER_OUTLOOK_MCP_SERVER_ID=outlook_mcp # Must be reachable from auth-portal and authz-service containers. BEAVER_DEPLOY_URL=http://beaver-deploy-control:8090 + +# External connector sidecar +EXTERNAL_CONNECTOR_TOKEN= +BEAVER_BRIDGE_TOKEN= +BEAVER_BRIDGE_BASE_URL=http://app-instance:8080 +EXTERNAL_CONNECTOR_PORT=8787 +CONNECTOR_PUBLIC_BASE_URL=http://localhost:8787 +# fake | vendor_cli | weixin_ilink +CONNECTOR_PROVIDER=vendor_cli +CONNECTOR_COMMAND_TIMEOUT_SECONDS=120 +WEIXIN_CONNECT_COMMAND= +WEIXIN_STATUS_COMMAND= +WEIXIN_SEND_COMMAND= +FEISHU_CONNECT_COMMAND= +FEISHU_STATUS_COMMAND= +FEISHU_SEND_COMMAND= diff --git a/2026-06-01-hermes-gateway-llm-design.md b/2026-06-01-hermes-gateway-llm-design.md index 16749f9..1bf8dc3 100644 --- a/2026-06-01-hermes-gateway-llm-design.md +++ b/2026-06-01-hermes-gateway-llm-design.md @@ -1,177 +1,458 @@ -# Hermes Gateway LLM Design +# Beaver Terminal WebSocket Integration Guide Date: 2026-06-01 +Audience: the small-terminal-side Codex agent that will modify terminal firmware or terminal app code. + ## Goal -Replace the OpenAI-compatible LLM call path in `custom/custom_agent.py` with a LiveKit LLM -adapter that talks to NousResearch Hermes Agent through the OpenClaw gateway protocol. +Connect the small terminal device to Beaver through a text-only WebSocket channel. -The integration must keep the existing custom agent behavior: +The first acceptance target is simple: -- Chinese room-locator and general assistant instructions -- Emotion prefix parsing with `` -- Memory recall for room-locator queries -- Optional vision-frame attachment -- LiveKit ASR, TTS, VAD, turn handling, metrics, and interruption behavior +1. The terminal opens a WebSocket connection to Beaver. +2. The terminal sends a `connect` frame with a stable `peer_id`. +3. The terminal sends one text `message` frame. +4. The terminal receives an `ack`. +5. The terminal receives the final assistant text response from Beaver. +6. The terminal can reconnect with the same `peer_id` and keep the same Beaver session. -The Hermes session strategy is `per_room`: one LiveKit room should map to one Hermes gateway -session for the lifetime of that room. +This document replaces the earlier Hermes LiveKit LLM adapter design for the terminal-side work. Do not implement a LiveKit LLM adapter from this document. ## Non-Goals -- Do not replace LiveKit `AgentSession`, ASR, TTS, VAD, or room I/O. -- Do not move room-locator classification into Hermes Agent. -- Do not implement Hermes-side tools in the first pass. -- Do not require an OpenAI-compatible proxy in front of the gateway. +- Do not implement audio streaming. +- Do not implement camera, screen, image, or multimodal frames. +- Do not implement token streaming. +- Do not implement terminal-side tools. +- Do not implement AuthZ, device registration, OAuth, or pairing in the first pass. +- Do not call Beaver REST chat endpoints or the existing Web UI `/ws/{session_id}` endpoint. +- Do not build an OpenAI-compatible proxy. +- Do not implement Hermes Agent or LiveKit changes on the terminal side. -## Recommended Architecture +## Beaver Endpoint -Add a new custom LiveKit LLM implementation in `custom/hermes_gateway.py`. +The terminal connects to: -The adapter will implement the LiveKit `llm.LLM` interface and return a custom `LLMStream`. -The stream will own a single gateway request/response cycle while the LLM object owns the -per-room gateway session state. +```text +ws:///api/channels//ws +``` -`custom/custom_agent.py` will continue to call `selected_llm.chat(...)` through -`_run_selected_llm()`. That preserves the existing `llm_node()` pipeline and keeps Hermes -behind the same abstraction as OpenAI-compatible models. +For local development through the Beaver app instance nginx port: -## Components +```text +ws://127.0.0.1:8080/api/channels/terminal-dev/ws +``` -### HermesGatewayLLM +For direct backend development without nginx: -Responsibilities: +```text +ws://127.0.0.1:18080/api/channels/terminal-dev/ws +``` -- Store gateway configuration: URL, auth token, agent identifier, request timeout, and reconnect - policy. -- Lazily create one Hermes gateway session per LiveKit room. -- Expose `model` as the configured Hermes agent/model identifier. -- Expose `provider` as `hermes-gateway`. -- Create `HermesGatewayLLMStream` from `chat(...)`. -- Close any persistent WebSocket/session resources in `aclose()`. +Use `wss://` when Beaver is deployed behind TLS. -### HermesGatewayLLMStream +The expected first channel id is: -Responsibilities: +```text +terminal-dev +``` -- Serialize LiveKit `ChatContext` into the gateway request payload. -- Send the latest turn to the per-room Hermes session. -- Consume gateway events until the turn completes or fails. -- Yield LiveKit `llm.ChatChunk` objects for assistant text deltas. -- Surface recoverable connection failures through the normal LiveKit LLM error path. +The terminal implementation should make the URL configurable, for example: -### custom_agent.py Wiring +```text +BEAVER_WS_URL=ws://127.0.0.1:8080/api/channels/terminal-dev/ws +TERMINAL_PEER_ID=device-001 +TERMINAL_DEVICE_NAME=desk-terminal +``` -Add env-driven provider selection: +## Protocol Overview -- `CUSTOM_LLM_PROVIDER=openai` keeps the current behavior. -- `CUSTOM_LLM_PROVIDER=hermes_gateway` constructs `HermesGatewayLLM`. +The transport is JSON over WebSocket. -New Hermes-specific env vars: +All frames are UTF-8 JSON objects. The terminal should ignore unknown fields. Beaver will ignore unknown fields unless the frame type is invalid. -- `CUSTOM_HERMES_GATEWAY_URL` -- `CUSTOM_HERMES_API_KEY` -- `CUSTOM_HERMES_AGENT_ID` -- `CUSTOM_HERMES_SESSION_MODE=per_room` -- `CUSTOM_HERMES_REQUEST_TIMEOUT` -- `CUSTOM_HERMES_VERIFY_SSL` +The protocol is request/reply oriented in this phase. Beaver sends only final assistant messages, not token deltas. -When `CUSTOM_LLM_PROVIDER=hermes_gateway`, `base_llm`, `text_llm`, and `vision_llm` should all -point at the same Hermes adapter. Separate Hermes text/vision agent IDs are out of scope for this -design. +Required frame flow: -## Data Flow +```text +terminal -> Beaver: connect +Beaver -> terminal: connected +terminal -> Beaver: message +Beaver -> terminal: ack +Beaver -> terminal: message +``` -1. User speaks or sends text. -2. Existing LiveKit/STT flow updates `ChatContext`. -3. `CustomAgent.llm_node()` selects `general` or `room_locator`. -4. Existing code injects the appropriate instructions and emotion-prefix requirement. -5. Existing code optionally augments the latest user message with memory context. -6. Existing code optionally attaches a fresh vision frame. -7. `_run_selected_llm()` calls `HermesGatewayLLM.chat(...)`. -8. The Hermes adapter sends the request to the per-room gateway session. -9. Gateway text events are converted to `llm.ChatChunk` deltas. -10. Existing emotion observation and TTS stripping continue unchanged. +Optional heartbeat: -## ChatContext Serialization +```text +terminal -> Beaver: ping +Beaver -> terminal: pong +``` -Text messages should be serialized first. +## Connect Frame -Supported LiveKit content: +The terminal must send `connect` immediately after the WebSocket opens. -- `str`: send as normal message content. -- instruction/config updates: preserve the final active instructions as the leading instruction - message in the gateway payload. If the deployed gateway only accepts user/assistant messages, - prepend the instruction text to the latest user message before sending. -- image content: attempt to send through the gateway image/multimodal field. If the deployed - Hermes gateway rejects or ignores image content, log a warning and fall back to text-only - generation for that turn. +Terminal to Beaver: -Function tool calls should not be sent in the first implementation. If tool messages appear, log -that they were omitted. +```json +{ + "type": "connect", + "peer_id": "device-001", + "device_name": "desk-terminal", + "capabilities": ["text"] +} +``` -## per_room Session Lifecycle +Required fields: -The adapter should derive a stable room key from the active LiveKit session or job context. If a -room name/SID is not available, fall back to one adapter-local session. +- `type`: must be `"connect"`. +- `peer_id`: stable terminal identity. Reuse this value across reconnects. -For each room key: +Recommended fields: -1. Open or reuse a gateway connection. -2. Send the gateway `connect` handshake if needed. -3. Create a Hermes session once. -4. Reuse that Hermes session for all future turns from the same room. -5. Close the gateway connection when the LiveKit LLM is closed. +- `device_name`: human-readable terminal name. +- `capabilities`: include `"text"`. -This lets Hermes maintain its own conversational state while LiveKit still keeps the visible -conversation history. +Optional fields: -## Gateway Event Mapping +- `thread_id`: optional sub-session key. Omit it for the first pass. +- `user_id`: optional user identity. Omit it unless the terminal already has a stable user id. -Map streaming text events to LiveKit chunks: +Beaver to terminal: -- Gateway assistant text delta -> `llm.ChatChunk(delta=llm.ChoiceDelta(content=delta))` -- Gateway final assistant message -> emit any remaining text not already streamed -- Gateway usage metadata -> `llm.CompletionUsage` when token counts are available -- Gateway tool/action events -> log at debug/info level in the first implementation -- Gateway error event -> raise a LiveKit `APIError` or `APIConnectionError` -- Gateway completion event -> finish the async iterator +```json +{ + "type": "connected", + "channel_id": "terminal-dev", + "session_id": "terminal-dev:local:device-001" +} +``` -The implementation should make the event parser tolerant of protocol field-name differences by -isolating event normalization in one helper function. Unknown event types should be logged and -ignored unless they indicate failure. +The terminal should store `session_id` for logging and diagnostics. It does not need to send `session_id` back in message frames. -## Error Handling +## Message Frame -- Missing Hermes env vars should fail fast at startup when provider is `hermes_gateway`. -- Gateway connect/session-create failures should raise connection errors. -- A failed request should not discard the per-room session unless the gateway reports that the - session is invalid or closed. -- If the gateway connection closes mid-turn, reconnect once and retry only if no assistant text - has been yielded yet. -- If assistant text has already been yielded, fail the turn instead of replaying partial output. +Terminal to Beaver: -## Testing +```json +{ + "type": "message", + "message_id": "m-001", + "text": "hello" +} +``` -Add focused tests around the adapter: +Required fields: -- Serializes simple system/user/assistant chat context. -- Creates one gateway session and reuses it across two turns for the same room. -- Converts text deltas into `llm.ChatChunk` content. -- Handles final full-message events without duplicate text. -- Raises on gateway error events. -- Logs and skips unsupported image/tool content. +- `type`: must be `"message"`. +- `message_id`: unique id for this user message. +- `text`: non-empty user text. -Add a small wiring test or import-level test for `CUSTOM_LLM_PROVIDER=hermes_gateway` if the -custom module is testable without external services. +Recommended `message_id` format: -## Rollout +```text +- +``` -1. Implement the adapter behind `CUSTOM_LLM_PROVIDER=hermes_gateway`. -2. Keep `openai` as the default provider. -3. Run unit tests for the adapter and a syntax/type smoke check on `custom/custom_agent.py`. -4. Test manually with a local gateway using `python custom/custom_agent.py console` or the - existing LiveKit development mode. -5. If vision payloads are unsupported by the deployed gateway, document that the first Hermes - rollout is text-only for vision turns. +Example: + +```text +device-001-000001 +device-001-000002 +``` + +The terminal should persist the counter if practical. If persistence is unavailable, generate a UUID or timestamp-based id. Reusing the same `message_id` tells Beaver to treat the frame as a duplicate. + +Optional fields: + +- `thread_id`: use only when the terminal intentionally wants a separate Beaver session. +- `user_id`: use only when the terminal has a stable user id. + +## Ack Frame + +Beaver sends an ack after accepting or deduplicating the inbound message. + +Accepted: + +```json +{ + "type": "ack", + "message_id": "device-001-000001", + "session_id": "terminal-dev:local:device-001", + "accepted": true +} +``` + +Duplicate still processing: + +```json +{ + "type": "ack", + "message_id": "device-001-000001", + "session_id": "terminal-dev:local:device-001", + "accepted": false, + "duplicate": true, + "pending": true +} +``` + +Duplicate already completed: + +```json +{ + "type": "ack", + "message_id": "device-001-000001", + "session_id": "terminal-dev:local:device-001", + "accepted": false, + "duplicate": true, + "pending": false, + "reply": "cached assistant reply" +} +``` + +Terminal behavior: + +- If `accepted` is true, wait for the assistant `message`. +- If `duplicate` and `reply` is present, display the cached reply. +- If `duplicate` and `pending` is true, keep waiting on the socket. +- If `error` is present, display or log the error. + +## Assistant Message Frame + +Beaver to terminal: + +```json +{ + "type": "message", + "role": "assistant", + "message_id": "device-001-000001", + "run_id": "run-id", + "text": "assistant reply", + "finish_reason": "stop" +} +``` + +Fields: + +- `type`: `"message"`. +- `role`: `"assistant"`. +- `message_id`: the user message id this response belongs to. +- `run_id`: Beaver run id for diagnostics. +- `text`: final assistant response. +- `finish_reason`: usually `"stop"`, or `"error"` when the run failed. + +Terminal behavior: + +- Render or speak `text`. +- Treat `finish_reason == "error"` as a failed turn. +- Do not expect token-level streaming in this phase. + +## Ping And Pong + +Terminal to Beaver: + +```json +{"type": "ping"} +``` + +Beaver to terminal: + +```json +{"type": "pong"} +``` + +Recommended heartbeat interval: + +```text +30 seconds +``` + +If no pong or other frame is received after a reasonable timeout, reconnect. + +## Error Frame + +Beaver may send: + +```json +{ + "type": "error", + "error": "human readable error" +} +``` + +Terminal behavior: + +- Log the error. +- Keep the connection open unless the WebSocket closes. +- If the error is for a user message, allow the user to retry with a new `message_id`. + +Common first-pass errors: + +- `connect` is required before `message`. +- `peer_id` is required. +- `message_id` is required. +- `text` is required. +- Unsupported websocket frame type. + +## Terminal State Machine + +Implement the terminal client as a small state machine. + +```text +DISCONNECTED + -> connect websocket +CONNECTING + -> websocket open, send connect frame +WAIT_CONNECTED + -> receive connected +READY + -> send message frame +WAIT_ACK + -> receive ack +WAIT_REPLY + -> receive assistant message +READY +``` + +On WebSocket close or network failure, transition to `DISCONNECTED` and reconnect with backoff. + +Recommended reconnect policy: + +- Start at 1 second. +- Double up to 30 seconds. +- Reset backoff after a successful `connected` frame. + +On reconnect, use the same `peer_id`. + +## Terminal Implementation Requirements + +The terminal-side code should provide: + +- A configurable Beaver WebSocket URL. +- A stable `peer_id`. +- A configurable `device_name`. +- A monotonic or otherwise unique `message_id` generator. +- JSON encoding and decoding. +- Connect frame on socket open. +- Ping/pong heartbeat. +- Reconnect with backoff. +- A queue or guard so only one user text turn is in flight at a time for the first pass. +- Logging for `session_id`, `message_id`, `run_id`, and errors. + +The terminal-side code does not need: + +- Multi-room session logic. +- Hermes session management. +- LiveKit `AgentSession`. +- Audio chunking. +- Tool calls. +- OAuth or token refresh. + +## Example Client Pseudocode + +```python +peer_id = load_or_create_peer_id() +counter = load_counter() + +async def run_terminal_client(): + while True: + try: + async with connect(BEAVER_WS_URL) as ws: + await ws.send_json({ + "type": "connect", + "peer_id": peer_id, + "device_name": DEVICE_NAME, + "capabilities": ["text"], + }) + + connected = await ws.receive_json() + assert connected["type"] == "connected" + log("session_id", connected["session_id"]) + + await read_send_receive_loop(ws) + except Exception as exc: + log("websocket disconnected", exc) + await sleep(next_backoff()) + +async def send_user_text(ws, text): + global counter + counter += 1 + save_counter(counter) + message_id = f"{peer_id}-{counter:06d}" + + await ws.send_json({ + "type": "message", + "message_id": message_id, + "text": text, + }) + + while True: + frame = await ws.receive_json() + if frame["type"] == "ack" and frame.get("message_id") == message_id: + if frame.get("reply"): + return frame["reply"] + continue + if frame["type"] == "message" and frame.get("role") == "assistant": + if frame.get("message_id") == message_id: + return frame.get("text", "") + if frame["type"] == "error": + raise RuntimeError(frame.get("error", "unknown error")) +``` + +Adapt the pseudocode to the terminal runtime language and WebSocket library. + +## Manual Test With websocat + +If `websocat` is available, a developer can manually test the protocol: + +```bash +websocat ws://127.0.0.1:8080/api/channels/terminal-dev/ws +``` + +Then paste: + +```json +{"type":"connect","peer_id":"device-001","device_name":"desk-terminal","capabilities":["text"]} +``` + +Expected response: + +```json +{"type":"connected","channel_id":"terminal-dev","session_id":"terminal-dev:local:device-001"} +``` + +Then paste: + +```json +{"type":"message","message_id":"device-001-000001","text":"hello"} +``` + +Expected responses: + +```json +{"type":"ack","message_id":"device-001-000001","session_id":"terminal-dev:local:device-001","accepted":true} +``` + +Then, after Beaver finishes the run: + +```json +{"type":"message","role":"assistant","message_id":"device-001-000001","run_id":"...","text":"...","finish_reason":"stop"} +``` + +## Acceptance Checklist For Terminal-Side Codex + +- The terminal opens the configured Beaver WebSocket URL. +- The terminal sends `connect` immediately after open. +- The terminal receives and logs `connected.session_id`. +- The terminal sends text using a unique `message_id`. +- The terminal receives `ack`. +- The terminal receives and displays assistant `message.text`. +- The terminal handles `ping`/`pong`. +- The terminal reconnects with the same `peer_id`. +- The terminal does not use REST chat or `/ws/{session_id}`. +- The terminal implementation remains text-only for the first pass. + +When this checklist passes against Beaver, the first-stage device integration is accepted from the terminal side. diff --git a/app-instance/Dockerfile b/app-instance/Dockerfile index 18ad32f..0d4ec10 100644 --- a/app-instance/Dockerfile +++ b/app-instance/Dockerfile @@ -47,8 +47,12 @@ ARG NPM_REGISTRY="https://registry.npmmirror.com" ARG NPM_FETCH_RETRIES="5" ARG NPM_FETCH_RETRY_MIN_TIMEOUT="20000" ARG NPM_FETCH_RETRY_MAX_TIMEOUT="120000" +ARG APT_MIRROR="https://mirrors.tuna.tsinghua.edu.cn/debian" +ARG PYPI_INDEX_URL="https://pypi.tuna.tsinghua.edu.cn/simple" -RUN apt-get update && \ +RUN find /etc/apt -type f \( -name "*.list" -o -name "*.sources" \) -exec \ + sed -i "s|http://deb.debian.org/debian-security|${APT_MIRROR}-security|g; s|http://deb.debian.org/debian|${APT_MIRROR}|g; s|http://security.debian.org/debian-security|${APT_MIRROR}-security|g" {} + && \ + apt-get update && \ apt-get install -y --no-install-recommends curl ca-certificates gnupg git nginx dumb-init && \ mkdir -p /etc/apt/keyrings && \ curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ @@ -63,7 +67,7 @@ WORKDIR /opt/app/backend COPY backend/pyproject.toml backend/README.md ./ COPY backend/beaver/ ./beaver/ -RUN uv pip install --system --no-cache . +RUN uv pip install --system --no-cache --index-url "${PYPI_INDEX_URL}" ".[channels]" WORKDIR /opt/app/frontend COPY --from=frontend-builder /build/frontend/next.config.js ./ diff --git a/app-instance/backend/beaver/engine/context/builder.py b/app-instance/backend/beaver/engine/context/builder.py index 2ca040f..00df740 100644 --- a/app-instance/backend/beaver/engine/context/builder.py +++ b/app-instance/backend/beaver/engine/context/builder.py @@ -76,7 +76,12 @@ class SessionContext: model: str | None = None user_id: str | None = None channel: str | None = None + channel_kind: str | None = None + account_id: str | None = None + peer_id: str | None = None + peer_type: str | None = None chat_id: str | None = None + thread_id: str | None = None parent_session_id: str | None = None @@ -354,8 +359,18 @@ class ContextBuilder: rows.append(f"User ID: {session_context.user_id}") if session_context.channel: rows.append(f"Channel: {session_context.channel}") + if session_context.channel_kind: + rows.append(f"Channel Kind: {session_context.channel_kind}") + if session_context.account_id: + rows.append(f"Account ID: {session_context.account_id}") + if session_context.peer_id: + rows.append(f"Peer ID: {session_context.peer_id}") + if session_context.peer_type: + rows.append(f"Peer Type: {session_context.peer_type}") if session_context.chat_id: rows.append(f"Chat ID: {session_context.chat_id}") + if session_context.thread_id: + rows.append(f"Thread ID: {session_context.thread_id}") if session_context.parent_session_id: rows.append(f"Parent Session ID: {session_context.parent_session_id}") diff --git a/app-instance/backend/beaver/engine/loop.py b/app-instance/backend/beaver/engine/loop.py index 4e612fb..085c0f8 100644 --- a/app-instance/backend/beaver/engine/loop.py +++ b/app-instance/backend/beaver/engine/loop.py @@ -13,6 +13,7 @@ from uuid import uuid4 from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from beaver.engine.context import ContextBuildInput, RuntimeContext, SessionContext, SkillContext +from beaver.foundation.events import ChannelIdentity from beaver.memory.runs import RunRecord, SkillEffectRecord from beaver.skills.learning import RunReceiptContext from beaver.skills.catalog.utils import strip_frontmatter @@ -248,6 +249,7 @@ class AgentLoop: pinned_skill_contexts: list[SkillContext] | None = None, allow_candidate_generation: bool = False, intent_agent_decision: dict[str, Any] | None = None, + channel_identity: ChannelIdentity | None = None, ) -> AgentRunResult: """跑通最小 direct run 主链。 @@ -297,6 +299,7 @@ class AgentLoop: pinned_skill_contexts=pinned_skill_contexts, allow_candidate_generation=allow_candidate_generation, intent_agent_decision=intent_agent_decision, + channel_identity=channel_identity, ) async def _process_direct_impl( @@ -334,6 +337,7 @@ class AgentLoop: pinned_skill_contexts: list[SkillContext] | None = None, allow_candidate_generation: bool = False, intent_agent_decision: dict[str, Any] | None = None, + channel_identity: ChannelIdentity | None = None, ) -> AgentRunResult: """真正执行一轮 direct run 的内部实现。 @@ -576,6 +580,13 @@ class AgentLoop: source=source, model=resolved_model, user_id=user_id, + channel=channel_identity.channel_id if channel_identity else None, + channel_kind=channel_identity.kind if channel_identity else None, + account_id=channel_identity.account_id if channel_identity else None, + peer_id=channel_identity.peer_id if channel_identity else None, + peer_type=channel_identity.peer_type if channel_identity else None, + chat_id=channel_identity.peer_id if channel_identity else None, + thread_id=channel_identity.thread_id if channel_identity else None, parent_session_id=parent_session_id, ), runtime_context=self._current_runtime_context(), diff --git a/app-instance/backend/beaver/foundation/config/loader.py b/app-instance/backend/beaver/foundation/config/loader.py index 3e7a6d4..5ab5d90 100644 --- a/app-instance/backend/beaver/foundation/config/loader.py +++ b/app-instance/backend/beaver/foundation/config/loader.py @@ -13,6 +13,7 @@ from .schema import ( AuthzConfig, BackendIdentityConfig, BeaverConfig, + ChannelConfig, EmbeddingConfig, MCPServerConfig, ProviderConfig, @@ -73,6 +74,7 @@ def load_config( embedding=_parse_embedding(data), tools=_parse_tools(data.get("tools")), authz=_parse_authz(data.get("authz")), + channels=_parse_channels(data.get("channels")), backend_identity=_parse_backend_identity(data.get("backend_identity") or data.get("backendIdentity")), config_path=path, ) @@ -196,6 +198,48 @@ def _parse_authz(raw: Any) -> AuthzConfig: ) +def _parse_channels(raw: Any) -> dict[str, ChannelConfig]: + channels: dict[str, ChannelConfig] = {} + for channel_id, payload in _as_dict(raw).items(): + cleaned_id = str(channel_id).strip() + if not cleaned_id: + continue + channels[cleaned_id] = _parse_channel_config(payload) + return channels + + +def _parse_channel_config(payload: Any) -> ChannelConfig: + data = _as_dict(payload) + return ChannelConfig( + enabled=_bool(data.get("enabled"), default=False), + kind=_string(data.get("kind")) or "", + mode=_string(data.get("mode")) or "webhook", + account_id=_string(data.get("accountId") or data.get("account_id")) or "", + display_name=_string(data.get("displayName") or data.get("display_name")) or "", + config=_normalize_config_map(data.get("config")), + secrets=_string_dict(data.get("secrets")), + ) + + +def _normalize_config_map(value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + return {} + return { + _camel_to_snake_key(str(key)): item + for key, item in value.items() + if str(key).strip() + } + + +def _camel_to_snake_key(value: str) -> str: + result: list[str] = [] + for char in value: + if char.isupper() and result: + result.append("_") + result.append(char.lower()) + return "".join(result) + + def _parse_backend_identity(raw: Any) -> BackendIdentityConfig: data = _as_dict(raw) return BackendIdentityConfig( diff --git a/app-instance/backend/beaver/foundation/config/schema.py b/app-instance/backend/beaver/foundation/config/schema.py index 7062183..2c89f57 100644 --- a/app-instance/backend/beaver/foundation/config/schema.py +++ b/app-instance/backend/beaver/foundation/config/schema.py @@ -91,6 +91,19 @@ class AuthzConfig: outlook_mcp_url: str = "" +@dataclass(slots=True) +class ChannelConfig: + """One configured channel adapter instance.""" + + enabled: bool = False + kind: str = "" + mode: str = "webhook" + account_id: str = "" + display_name: str = "" + config: dict[str, Any] = field(default_factory=dict) + secrets: dict[str, str] = field(default_factory=dict) + + @dataclass(slots=True) class BackendIdentityConfig: """This backend's AuthZ client identity.""" @@ -111,6 +124,7 @@ class BeaverConfig: embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig) tools: ToolsConfig = field(default_factory=ToolsConfig) authz: AuthzConfig = field(default_factory=AuthzConfig) + channels: dict[str, ChannelConfig] = field(default_factory=dict) backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig) config_path: Path | None = None diff --git a/app-instance/backend/beaver/foundation/events/__init__.py b/app-instance/backend/beaver/foundation/events/__init__.py index 34a3cd6..d17692c 100644 --- a/app-instance/backend/beaver/foundation/events/__init__.py +++ b/app-instance/backend/beaver/foundation/events/__init__.py @@ -1,5 +1,5 @@ """Event contracts and dispatch helpers.""" -from .message_bus import InboundMessage, MessageBus, OutboundMessage +from .message_bus import ChannelIdentity, InboundMessage, MessageBus, OutboundMessage -__all__ = ["InboundMessage", "MessageBus", "OutboundMessage"] +__all__ = ["ChannelIdentity", "InboundMessage", "MessageBus", "OutboundMessage"] diff --git a/app-instance/backend/beaver/foundation/events/message_bus.py b/app-instance/backend/beaver/foundation/events/message_bus.py index 1db5810..9f2d27e 100644 --- a/app-instance/backend/beaver/foundation/events/message_bus.py +++ b/app-instance/backend/beaver/foundation/events/message_bus.py @@ -9,12 +9,58 @@ from typing import Any from uuid import uuid4 +@dataclass(slots=True) +class ChannelIdentity: + """Normalized channel routing identity. + + `channel_id` is the Beaver adapter instance id, not the platform kind. + """ + + channel_id: str + kind: str + account_id: str + peer_id: str + thread_id: str | None = None + peer_type: str = "unknown" + user_id: str | None = None + message_id: str | None = None + + def validation_error(self) -> str | None: + if not self.channel_id.strip(): + return "channel_id is required" + if not self.account_id.strip(): + return "account_id is required" + if not self.peer_id.strip(): + return "peer_id is required" + return None + + def session_id(self) -> str: + parts = [self.channel_id, self.account_id, self.peer_id] + if self.thread_id: + parts.append(self.thread_id) + return ":".join(_clean_session_part(part) for part in parts) + + def dedupe_key(self) -> str | None: + if not self.message_id: + return None + return f"{self.session_id()}:{_clean_session_part(self.message_id)}" + + +def _clean_session_part(value: str) -> str: + cleaned = str(value).strip() + if not cleaned: + return "unknown" + return cleaned.replace(":", "_") + + @dataclass(slots=True) class InboundMessage: """A minimal inbound message accepted by the gateway bridge.""" channel: str content: str + content_type: str = "text" + channel_identity: ChannelIdentity | None = None session_id: str | None = None user_id: str | None = None title: str | None = None @@ -35,6 +81,8 @@ class OutboundMessage: content: str session_id: str | None finish_reason: str + content_type: str = "text" + channel_identity: ChannelIdentity | None = None message_id: str = field(default_factory=lambda: str(uuid4())) run_id: str | None = None provider_name: str | None = None diff --git a/app-instance/backend/beaver/interfaces/channels/__init__.py b/app-instance/backend/beaver/interfaces/channels/__init__.py index 97f4a30..344eeb8 100644 --- a/app-instance/backend/beaver/interfaces/channels/__init__.py +++ b/app-instance/backend/beaver/interfaces/channels/__init__.py @@ -1,7 +1,17 @@ """Channel interfaces.""" from .base import ChannelAdapter +from .base import ChannelInboundSink +from .external_connector import ExternalConnectorChannel from .manager import ChannelManager from .memory import MemoryChannelAdapter +from .terminal_websocket import TerminalWebSocketAdapter -__all__ = ["ChannelAdapter", "ChannelManager", "MemoryChannelAdapter"] +__all__ = [ + "ChannelAdapter", + "ChannelInboundSink", + "ExternalConnectorChannel", + "ChannelManager", + "MemoryChannelAdapter", + "TerminalWebSocketAdapter", +] diff --git a/app-instance/backend/beaver/interfaces/channels/base.py b/app-instance/backend/beaver/interfaces/channels/base.py index 40e3767..60aeb5c 100644 --- a/app-instance/backend/beaver/interfaces/channels/base.py +++ b/app-instance/backend/beaver/interfaces/channels/base.py @@ -2,16 +2,17 @@ from __future__ import annotations -from typing import Protocol +from typing import Any, Protocol -from beaver.foundation.events import MessageBus, OutboundMessage +from beaver.foundation.events import InboundMessage, OutboundMessage class ChannelAdapter(Protocol): - """Minimal contract every gateway channel must implement.""" + """Minimal contract every runtime channel adapter must implement.""" - name: str - bus: MessageBus + channel_id: str + kind: str + mode: str async def start(self) -> None: """Prepare the channel before messages are routed.""" @@ -22,3 +23,9 @@ class ChannelAdapter(Protocol): async def send(self, message: OutboundMessage) -> None: """Deliver an outbound message to the concrete channel.""" + +class ChannelInboundSink(Protocol): + """Runtime callback used by adapters to submit normalized inbound messages.""" + + async def accept_inbound(self, message: InboundMessage) -> Any: + """Accept a normalized inbound message from an adapter.""" diff --git a/app-instance/backend/beaver/interfaces/channels/connections/__init__.py b/app-instance/backend/beaver/interfaces/channels/connections/__init__.py new file mode 100644 index 0000000..495e3a2 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/connections/__init__.py @@ -0,0 +1,29 @@ +"""Channel connection setup layer.""" + +from .connectors import ChannelConnector, ChannelConnectorRegistry +from .dedupe import ConnectorMessageDedupeRecord, DedupeBeginResult, MessageDedupeStore +from .external import ExternalConnectorBase, FeishuConnector, WeixinConnector +from .models import ChannelConnection, ChannelRuntimeSpec, PairingSession, ValidationResult +from .sidecar_client import ConnectorSidecarClient +from .store import ChannelConnectionStore, CredentialStore, PairingTokenStore +from .telegram import TelegramConnector + +__all__ = [ + "ChannelConnector", + "ChannelConnectorRegistry", + "ConnectorMessageDedupeRecord", + "DedupeBeginResult", + "MessageDedupeStore", + "ExternalConnectorBase", + "FeishuConnector", + "WeixinConnector", + "ConnectorSidecarClient", + "ChannelConnection", + "ChannelRuntimeSpec", + "PairingSession", + "ValidationResult", + "ChannelConnectionStore", + "CredentialStore", + "PairingTokenStore", + "TelegramConnector", +] diff --git a/app-instance/backend/beaver/interfaces/channels/connections/connectors.py b/app-instance/backend/beaver/interfaces/channels/connections/connectors.py new file mode 100644 index 0000000..1d87ec1 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/connections/connectors.py @@ -0,0 +1,93 @@ +"""Channel connector registry.""" + +from __future__ import annotations + +from typing import Protocol + +from beaver.foundation.config.schema import ChannelConfig + +from .models import ChannelRuntimeSpec, ValidationResult +from .store import ChannelConnectionStore, CredentialStore + + +class ChannelConnector(Protocol): + kind: str + + async def validate(self, connection_id: str) -> ValidationResult: + ... + + async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec: + ... + + async def revoke(self, connection_id: str) -> None: + ... + + +class ChannelConnectorRegistry: + def __init__(self, *, connection_store: ChannelConnectionStore, credential_store: CredentialStore) -> None: + self.connection_store = connection_store + self.credential_store = credential_store + self._connectors: dict[str, ChannelConnector] = {} + + def register(self, connector: ChannelConnector) -> None: + kind = connector.kind.strip() + if not kind: + raise ValueError("Connector kind is required") + if kind in self._connectors: + raise ValueError(f"Connector already registered: {kind}") + self._connectors[kind] = connector + + def connectors(self) -> list[dict[str, str]]: + return [{"kind": kind} for kind in sorted(self._connectors)] + + def connector_for_kind(self, kind: str) -> ChannelConnector: + return self._connector(kind) + + async def validate(self, connection_id: str) -> ValidationResult: + connection = self.connection_store.get(connection_id) + connector = self._connector(connection.kind) + result = await connector.validate(connection_id) + self.connection_store.update_status( + connection_id, + status=result.status, + last_error=result.error, + ) + return result + + async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec: + connection = self.connection_store.get(connection_id) + return await self._connector(connection.kind).materialize_runtime(connection_id) + + async def materialize_connected_runtime_specs(self) -> list[ChannelRuntimeSpec]: + specs: list[ChannelRuntimeSpec] = [] + for connection in self.connection_store.list(): + if connection.status not in {"connected", "running"}: + continue + specs.append(await self._connector(connection.kind).materialize_runtime(connection.connection_id)) + return specs + + async def materialize_channel_configs(self) -> dict[str, ChannelConfig]: + channels: dict[str, ChannelConfig] = {} + for spec in await self.materialize_connected_runtime_specs(): + secrets = self.credential_store.get(spec.secrets_ref) if spec.secrets_ref else {} + channels[spec.channel_id] = ChannelConfig( + enabled=True, + kind=spec.kind, + mode=spec.mode, + account_id=spec.account_id, + display_name=spec.display_name, + config=dict(spec.config), + secrets=secrets, + ) + return channels + + async def revoke(self, connection_id: str) -> None: + connection = self.connection_store.get(connection_id) + await self._connector(connection.kind).revoke(connection_id) + self.connection_store.revoke(connection_id) + + def _connector(self, kind: str) -> ChannelConnector: + connector = self._connectors.get(kind) + if connector is None: + raise KeyError(f"Connector not registered: {kind}") + return connector diff --git a/app-instance/backend/beaver/interfaces/channels/connections/dedupe.py b/app-instance/backend/beaver/interfaces/channels/connections/dedupe.py new file mode 100644 index 0000000..42adbcf --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/connections/dedupe.py @@ -0,0 +1,144 @@ +"""Bridge event dedupe store for external connector retries.""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from threading import Lock +from typing import Any + + +def _iso_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _parse_iso(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +@dataclass(slots=True) +class ConnectorMessageDedupeRecord: + dedupe_key: str + connection_id: str + event_id: str + status: str + first_seen_at: str + updated_at: str + delivery_attempts: int + message_id: str | None = None + last_error: str | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ConnectorMessageDedupeRecord": + return cls( + dedupe_key=str(data.get("dedupe_key") or ""), + connection_id=str(data.get("connection_id") or ""), + event_id=str(data.get("event_id") or ""), + status=str(data.get("status") or "processing"), + first_seen_at=str(data.get("first_seen_at") or _iso_now()), + updated_at=str(data.get("updated_at") or _iso_now()), + delivery_attempts=int(data.get("delivery_attempts") or 0), + message_id=str(data["message_id"]) if data.get("message_id") is not None else None, + last_error=str(data["last_error"]) if data.get("last_error") is not None else None, + ) + + +@dataclass(slots=True) +class DedupeBeginResult: + should_process: bool + dedupe_key: str + status: str + http_status: int + retry_after_seconds: int | None + record: ConnectorMessageDedupeRecord + + +class MessageDedupeStore: + def __init__(self, path: Path, *, processing_ttl_seconds: int = 60) -> None: + self.path = Path(path) + self.processing_ttl_seconds = int(processing_ttl_seconds) + self._lock = Lock() + + def begin(self, *, connection_id: str, event_id: str, delivery_attempt: int) -> DedupeBeginResult: + dedupe_key = f"{connection_id}:{event_id}" + now = _iso_now() + with self._lock: + data = self._load() + raw = data["records"].get(dedupe_key) + if isinstance(raw, dict): + record = ConnectorMessageDedupeRecord.from_dict(raw) + if record.status == "completed": + return DedupeBeginResult(False, dedupe_key, record.status, 200, None, record) + if record.status == "processing" and not self._is_stale(record, now): + return DedupeBeginResult(False, dedupe_key, record.status, 409, 5, record) + record.status = "processing" + record.updated_at = now + record.delivery_attempts = max(record.delivery_attempts + 1, int(delivery_attempt)) + record.last_error = None + else: + record = ConnectorMessageDedupeRecord( + dedupe_key=dedupe_key, + connection_id=connection_id, + event_id=event_id, + status="processing", + first_seen_at=now, + updated_at=now, + delivery_attempts=max(1, int(delivery_attempt)), + ) + data["records"][dedupe_key] = record.to_dict() + self._save(data) + return DedupeBeginResult(True, dedupe_key, record.status, 200, None, record) + + def complete(self, dedupe_key: str, *, message_id: str | None) -> ConnectorMessageDedupeRecord: + return self._mark(dedupe_key, status="completed", message_id=message_id, error=None) + + def fail(self, dedupe_key: str, *, error: str) -> ConnectorMessageDedupeRecord: + return self._mark(dedupe_key, status="failed", message_id=None, error=error) + + def _mark( + self, + dedupe_key: str, + *, + status: str, + message_id: str | None, + error: str | None, + ) -> ConnectorMessageDedupeRecord: + with self._lock: + data = self._load() + raw = data["records"].get(dedupe_key) + if not isinstance(raw, dict): + raise KeyError(dedupe_key) + record = ConnectorMessageDedupeRecord.from_dict(raw) + record.status = status + record.updated_at = _iso_now() + record.message_id = message_id or record.message_id + record.last_error = error + data["records"][dedupe_key] = record.to_dict() + self._save(data) + return record + + def _is_stale(self, record: ConnectorMessageDedupeRecord, now: str) -> bool: + age = (_parse_iso(now) - _parse_iso(record.updated_at)).total_seconds() + return age >= self.processing_ttl_seconds + + def _load(self) -> dict[str, Any]: + if not self.path.exists(): + return {"records": {}} + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"records": {}} + if not isinstance(data, dict) or not isinstance(data.get("records"), dict): + return {"records": {}} + return data + + def _save(self, data: dict[str, Any]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = self.path.with_name(f"{self.path.name}.tmp") + tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + tmp_path.replace(self.path) diff --git a/app-instance/backend/beaver/interfaces/channels/connections/external.py b/app-instance/backend/beaver/interfaces/channels/connections/external.py new file mode 100644 index 0000000..f11ec00 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/connections/external.py @@ -0,0 +1,131 @@ +"""Sidecar-backed channel connectors.""" + +from __future__ import annotations + +from typing import Any + +from .models import ChannelRuntimeSpec, ValidationResult +from .sidecar_client import ConnectorSidecarClient +from .store import ChannelConnectionStore, CredentialStore + + +class ExternalConnectorBase: + kind = "" + capabilities: list[str] = [] + + def __init__( + self, + *, + connection_store: ChannelConnectionStore, + credential_store: CredentialStore, + sidecar_client: ConnectorSidecarClient | Any, + sidecar_base_url: str, + ) -> None: + self.connection_store = connection_store + self.credential_store = credential_store + self.sidecar_client = sidecar_client + self.sidecar_base_url = sidecar_base_url + + async def start_session( + self, + *, + display_name: str, + owner_user_id: str | None, + options: dict[str, Any], + ) -> dict[str, Any]: + connection = self.connection_store.create( + kind=self.kind, + mode="sidecar", + display_name=display_name or self.kind, + account_id="", + owner_user_id=owner_user_id, + auth_type="connector_session", + runtime_config={"sidecarBaseUrl": self.sidecar_base_url}, + capabilities=list(self.capabilities), + ) + connection = self.connection_store.update_status(connection.connection_id, status="pairing", last_error=None) + payload = { + "kind": self.kind, + "connectionId": connection.connection_id, + "channelId": connection.channel_id, + "displayName": connection.display_name, + "callbackBaseUrl": "", + "options": dict(options), + } + view = dict(await self.sidecar_client.start_session(payload)) + connection.pairing_session_id = str(view.get("sessionId") or "") + self.connection_store.update(connection) + view["connectionId"] = connection.connection_id + view["channelId"] = connection.channel_id + return view + + async def poll_session(self, session_id: str) -> dict[str, Any]: + view = dict(await self.sidecar_client.get_session(session_id)) + connection = self._connection_for_session(session_id) + status = str(view.get("status") or "") + if status == "connected": + connection.account_id = str(view.get("accountId") or connection.account_id) + connection.display_name = str(view.get("displayName") or connection.display_name) + metadata = view.get("metadata") if isinstance(view.get("metadata"), dict) else {} + state_ref = metadata.get("stateRef") + if state_ref: + connection.credentials_ref = self.credential_store.put(kind=self.kind, values={"stateRef": state_ref}) + self.connection_store.update(connection) + self.connection_store.update_status(connection.connection_id, status="connected", last_error=None) + elif status in {"expired", "error", "cancelled"}: + self.connection_store.update_status( + connection.connection_id, + status="error", + last_error=str(view.get("error") or status), + ) + view["connectionId"] = connection.connection_id + view["channelId"] = connection.channel_id + return view + + async def validate(self, connection_id: str) -> ValidationResult: + connection = self.connection_store.get(connection_id) + if connection.status in {"connected", "running"}: + return ValidationResult( + ok=True, + status="connected", + account_id=connection.account_id, + display_name=connection.display_name, + ) + return ValidationResult(ok=False, status=connection.status, error=connection.last_error) + + async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec: + connection = self.connection_store.get(connection_id) + if connection.status not in {"connected", "running"}: + raise ValueError(f"Connection is not connected: {connection.connection_id}") + return ChannelRuntimeSpec( + channel_id=connection.channel_id, + kind="external_connector", + mode="http", + account_id=connection.account_id, + display_name=connection.display_name, + config={ + "platformKind": self.kind, + "connectionId": connection.connection_id, + "sidecarBaseUrl": connection.runtime_config.get("sidecarBaseUrl") or self.sidecar_base_url, + }, + secrets_ref=None, + ) + + async def revoke(self, connection_id: str) -> None: + await self.sidecar_client.logout(connection_id) + + def _connection_for_session(self, session_id: str): + for connection in self.connection_store.list(): + if connection.pairing_session_id == session_id: + return connection + raise KeyError(session_id) + + +class WeixinConnector(ExternalConnectorBase): + kind = "weixin" + capabilities = ["receive_text", "send_text", "receive_media", "direct_messages"] + + +class FeishuConnector(ExternalConnectorBase): + kind = "feishu" + capabilities = ["receive_text", "send_text", "receive_media", "groups"] diff --git a/app-instance/backend/beaver/interfaces/channels/connections/models.py b/app-instance/backend/beaver/interfaces/channels/connections/models.py new file mode 100644 index 0000000..b2928ce --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/connections/models.py @@ -0,0 +1,117 @@ +"""Channel connection setup models.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from typing import Any + + +CONNECTION_STATUSES = {"draft", "pairing", "connected", "running", "degraded", "error", "revoked"} + + +def iso_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +@dataclass(slots=True) +class ChannelConnection: + connection_id: str + owner_user_id: str | None + channel_id: str + kind: str + mode: str + display_name: str + account_id: str + status: str + auth_type: str + credentials_ref: str | None = None + connector_ref: str | None = None + pairing_session_id: str | None = None + runtime_config: dict[str, Any] = field(default_factory=dict) + capabilities: list[str] = field(default_factory=list) + created_at: str = field(default_factory=iso_now) + updated_at: str = field(default_factory=iso_now) + last_seen_at: str | None = None + last_error: str | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ChannelConnection": + return cls( + connection_id=str(data.get("connection_id") or ""), + owner_user_id=_optional_string(data.get("owner_user_id")), + channel_id=str(data.get("channel_id") or ""), + kind=str(data.get("kind") or ""), + mode=str(data.get("mode") or ""), + display_name=str(data.get("display_name") or ""), + account_id=str(data.get("account_id") or ""), + status=str(data.get("status") or "draft"), + auth_type=str(data.get("auth_type") or ""), + credentials_ref=_optional_string(data.get("credentials_ref")), + connector_ref=_optional_string(data.get("connector_ref")), + pairing_session_id=_optional_string(data.get("pairing_session_id")), + runtime_config=dict(data.get("runtime_config") or {}), + capabilities=[str(item) for item in data.get("capabilities") or []], + created_at=str(data.get("created_at") or iso_now()), + updated_at=str(data.get("updated_at") or iso_now()), + last_seen_at=_optional_string(data.get("last_seen_at")), + last_error=_optional_string(data.get("last_error")), + ) + + +@dataclass(slots=True) +class PairingSession: + pairing_session_id: str + kind: str + scope: str + token: str + status: str + expires_at_ms: int + created_at_ms: int + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "PairingSession": + return cls( + pairing_session_id=str(data.get("pairing_session_id") or ""), + kind=str(data.get("kind") or ""), + scope=str(data.get("scope") or ""), + token=str(data.get("token") or ""), + status=str(data.get("status") or "pending"), + expires_at_ms=int(data.get("expires_at_ms") or 0), + created_at_ms=int(data.get("created_at_ms") or 0), + ) + + +@dataclass(slots=True) +class ChannelRuntimeSpec: + channel_id: str + kind: str + mode: str + account_id: str + display_name: str + config: dict[str, Any] = field(default_factory=dict) + secrets_ref: str | None = None + external_endpoint: str | None = None + + +@dataclass(slots=True) +class ValidationResult: + ok: bool + status: str + account_id: str | None = None + display_name: str | None = None + error: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +def _optional_string(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None diff --git a/app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py b/app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py new file mode 100644 index 0000000..62e6b27 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py @@ -0,0 +1,39 @@ +"""HTTP client for the generic external connector sidecar.""" + +from __future__ import annotations + +from typing import Any + +import httpx + + +class ConnectorSidecarClient: + def __init__(self, *, base_url: str, token: str, timeout_seconds: float = 20.0) -> None: + self.base_url = base_url.rstrip("/") + self.token = token + self.timeout_seconds = float(timeout_seconds) + + async def get_connectors(self) -> list[dict[str, Any]]: + return await self._request("GET", "/connectors") + + async def start_session(self, payload: dict[str, Any]) -> dict[str, Any]: + return await self._request("POST", "/connector-sessions", json=payload) + + async def get_session(self, session_id: str) -> dict[str, Any]: + return await self._request("GET", f"/connector-sessions/{session_id}") + + async def cancel_session(self, session_id: str) -> dict[str, Any]: + return await self._request("POST", f"/connector-sessions/{session_id}/cancel", json={}) + + async def logout(self, connection_id: str) -> dict[str, Any]: + return await self._request("POST", f"/connections/{connection_id}/logout", json={}) + + async def send(self, payload: dict[str, Any]) -> dict[str, Any]: + return await self._request("POST", "/send", json=payload) + + async def _request(self, method: str, path: str, *, json: dict[str, Any] | None = None) -> Any: + headers = {"Authorization": f"Bearer {self.token}"} if self.token else {} + async with httpx.AsyncClient(timeout=self.timeout_seconds) as client: + response = await client.request(method, f"{self.base_url}{path}", json=json, headers=headers) + response.raise_for_status() + return response.json() diff --git a/app-instance/backend/beaver/interfaces/channels/connections/store.py b/app-instance/backend/beaver/interfaces/channels/connections/store.py new file mode 100644 index 0000000..96c8076 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/connections/store.py @@ -0,0 +1,222 @@ +"""Persistent channel connection stores.""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +from threading import Lock +from typing import Any +from uuid import uuid4 + +from .models import CONNECTION_STATUSES, ChannelConnection, PairingSession, iso_now + + +class ChannelConnectionStore: + def __init__(self, path: Path) -> None: + self.path = Path(path) + self._lock = Lock() + + def create( + self, + *, + kind: str, + mode: str, + display_name: str, + account_id: str, + owner_user_id: str | None, + auth_type: str, + runtime_config: dict[str, Any] | None = None, + capabilities: list[str] | None = None, + credentials_ref: str | None = None, + ) -> ChannelConnection: + with self._lock: + data = self._load() + connection_id = f"conn_{uuid4().hex}" + channel_id = f"{_slug(kind)}-{uuid4().hex[:8]}" + now = iso_now() + connection = ChannelConnection( + connection_id=connection_id, + owner_user_id=owner_user_id, + channel_id=channel_id, + kind=kind, + mode=mode, + display_name=display_name or channel_id, + account_id=account_id, + status="draft", + auth_type=auth_type, + credentials_ref=credentials_ref, + runtime_config=runtime_config or {}, + capabilities=capabilities or [], + created_at=now, + updated_at=now, + ) + data["connections"][connection_id] = connection.to_dict() + self._save(data) + return connection + + def get(self, connection_id: str) -> ChannelConnection: + data = self._load() + raw = data["connections"].get(connection_id) + if not isinstance(raw, dict): + raise KeyError(connection_id) + return ChannelConnection.from_dict(raw) + + def list(self) -> list[ChannelConnection]: + data = self._load() + return [ChannelConnection.from_dict(item) for item in data["connections"].values() if isinstance(item, dict)] + + def update(self, connection: ChannelConnection) -> ChannelConnection: + with self._lock: + data = self._load() + if connection.connection_id not in data["connections"]: + raise KeyError(connection.connection_id) + connection.updated_at = iso_now() + data["connections"][connection.connection_id] = connection.to_dict() + self._save(data) + return connection + + def update_status(self, connection_id: str, *, status: str, last_error: str | None) -> ChannelConnection: + if status not in CONNECTION_STATUSES: + raise ValueError(f"Unsupported connection status: {status}") + connection = self.get(connection_id) + connection.status = status + connection.last_error = last_error + if status in {"connected", "running"}: + connection.last_seen_at = iso_now() + return self.update(connection) + + def revoke(self, connection_id: str) -> ChannelConnection: + return self.update_status(connection_id, status="revoked", last_error=None) + + def _load(self) -> dict[str, Any]: + if not self.path.exists(): + return {"connections": {}} + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"connections": {}} + if not isinstance(data, dict) or not isinstance(data.get("connections"), dict): + return {"connections": {}} + return data + + def _save(self, data: dict[str, Any]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = self.path.with_name(f"{self.path.name}.tmp") + tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + tmp_path.replace(self.path) + + +class CredentialStore: + def __init__(self, path: Path) -> None: + self.path = Path(path) + self._lock = Lock() + + def put(self, *, kind: str, values: dict[str, Any]) -> str: + cleaned = {str(key): str(value) for key, value in values.items() if str(key).strip() and str(value).strip()} + ref = f"cred_{uuid4().hex}" + with self._lock: + data = self._load() + data["credentials"][ref] = {"kind": kind, "values": cleaned, "created_at": iso_now()} + self._save(data) + return ref + + def get(self, ref: str) -> dict[str, str]: + data = self._load() + item = data["credentials"].get(ref) + if not isinstance(item, dict): + raise KeyError(ref) + values = item.get("values") + if not isinstance(values, dict): + return {} + return {str(key): str(value) for key, value in values.items()} + + def redacted(self, ref: str | None) -> dict[str, str]: + if not ref: + return {} + try: + values = self.get(ref) + except KeyError: + return {} + return {key: "***" for key in values} + + def _load(self) -> dict[str, Any]: + if not self.path.exists(): + return {"credentials": {}} + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"credentials": {}} + if not isinstance(data, dict) or not isinstance(data.get("credentials"), dict): + return {"credentials": {}} + return data + + def _save(self, data: dict[str, Any]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = self.path.with_name(f"{self.path.name}.tmp") + tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + tmp_path.replace(self.path) + + +class PairingTokenStore: + def __init__(self, path: Path) -> None: + self.path = Path(path) + self._lock = Lock() + + def create(self, *, kind: str, ttl_seconds: int, scope: str) -> PairingSession: + now_ms = _now_ms() + session = PairingSession( + pairing_session_id=f"pair_{uuid4().hex}", + kind=kind, + scope=scope, + token=f"pair_{uuid4().hex}", + status="pending", + expires_at_ms=now_ms + int(ttl_seconds * 1000), + created_at_ms=now_ms, + ) + with self._lock: + data = self._load() + data["sessions"][session.pairing_session_id] = session.to_dict() + self._save(data) + return session + + def consume(self, token: str, *, expected_kind: str) -> PairingSession | None: + with self._lock: + data = self._load() + for key, raw in data["sessions"].items(): + session = PairingSession.from_dict(raw) + if session.token != token or session.kind != expected_kind: + continue + if session.status != "pending" or session.expires_at_ms <= _now_ms(): + return None + session.status = "consumed" + data["sessions"][key] = session.to_dict() + self._save(data) + return session + return None + + def _load(self) -> dict[str, Any]: + if not self.path.exists(): + return {"sessions": {}} + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"sessions": {}} + if not isinstance(data, dict) or not isinstance(data.get("sessions"), dict): + return {"sessions": {}} + return data + + def _save(self, data: dict[str, Any]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = self.path.with_name(f"{self.path.name}.tmp") + tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + tmp_path.replace(self.path) + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _slug(value: str) -> str: + text = "".join(char if char.isalnum() else "-" for char in str(value).strip().lower()) + return "-".join(part for part in text.split("-") if part) or "channel" diff --git a/app-instance/backend/beaver/interfaces/channels/connections/telegram.py b/app-instance/backend/beaver/interfaces/channels/connections/telegram.py new file mode 100644 index 0000000..06ae2df --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/connections/telegram.py @@ -0,0 +1,92 @@ +"""Telegram channel connector.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from .models import ChannelRuntimeSpec, ValidationResult +from .store import ChannelConnectionStore, CredentialStore + + +class TelegramConnector: + kind = "telegram" + + def __init__( + self, + *, + connection_store: ChannelConnectionStore, + credential_store: CredentialStore, + client_factory: Callable[[str], Any] | None = None, + ) -> None: + self.connection_store = connection_store + self.credential_store = credential_store + self.client_factory = client_factory or _default_client_factory + + async def validate(self, connection_id: str) -> ValidationResult: + connection = self.connection_store.get(connection_id) + token = self._bot_token(connection.credentials_ref) + try: + client = self.client_factory(token) + raw = await client.get_me() + bot_id = _value(raw, "id") + username = _value(raw, "username") + first_name = _value(raw, "first_name") or "Telegram Bot" + account_id = f"telegram:{bot_id}" if bot_id else connection.account_id + display_name = f"{first_name} (@{username})" if username else first_name + connection.account_id = account_id + connection.display_name = display_name + connection.capabilities = ["receive_text", "send_text", "receive_media", "groups"] + self.connection_store.update(connection) + return ValidationResult( + ok=True, + status="connected", + account_id=account_id, + display_name=display_name, + metadata={"username": username} if username else {}, + ) + except Exception as exc: + return ValidationResult(ok=False, status="error", error=str(exc)) + + async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec: + connection = self.connection_store.get(connection_id) + if connection.status not in {"connected", "running"}: + raise ValueError(f"Connection is not connected: {connection.connection_id}") + return ChannelRuntimeSpec( + channel_id=connection.channel_id, + kind=connection.kind, + mode=connection.mode, + account_id=connection.account_id, + display_name=connection.display_name, + config=dict(connection.runtime_config), + secrets_ref=connection.credentials_ref, + ) + + async def revoke(self, connection_id: str) -> None: + # Telegram bot tokens do not have a Beaver-managed platform revoke action. + # The registry owns local connection state transitions. + return None + + def _bot_token(self, credentials_ref: str | None) -> str: + if not credentials_ref: + raise ValueError("Telegram credentials are missing") + token = self.credential_store.get(credentials_ref).get("botToken") + if not token: + raise ValueError("botToken is required") + return token + + +def _value(raw: Any, key: str) -> str: + if isinstance(raw, dict): + value = raw.get(key) + else: + value = getattr(raw, key, None) + return str(value).strip() if value is not None else "" + + +def _default_client_factory(token: str) -> Any: + try: + from telegram import Bot + except ImportError as exc: # pragma: no cover - optional live dependency + raise RuntimeError("Install beaver-backend[telegram] to validate Telegram connections") from exc + return Bot(token=token) diff --git a/app-instance/backend/beaver/interfaces/channels/external_connector.py b/app-instance/backend/beaver/interfaces/channels/external_connector.py new file mode 100644 index 0000000..397f058 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/external_connector.py @@ -0,0 +1,97 @@ +"""Generic runtime channel backed by an external connector sidecar.""" + +from __future__ import annotations + +import hashlib +from typing import Any + +from beaver.foundation.events import OutboundMessage +from beaver.interfaces.channels.connections.sidecar_client import ConnectorSidecarClient + + +class ExternalConnectorChannel: + def __init__( + self, + *, + channel_id: str, + platform_kind: str, + connection_id: str, + account_id: str, + display_name: str, + sidecar_client: ConnectorSidecarClient | Any, + ) -> None: + self.channel_id = channel_id + self.kind = "external_connector" + self.mode = "http" + self.platform_kind = platform_kind + self.connection_id = connection_id + self.account_id = account_id + self.display_name = display_name or channel_id + self.sidecar_client = sidecar_client + self.started = False + + async def start(self) -> None: + self.started = True + + async def stop(self) -> None: + self.started = False + + async def send(self, message: OutboundMessage) -> None: + identity = message.channel_identity + if identity is None: + raise ValueError("channel_identity is required for external connector sends") + metadata = { + "inboundMessageId": identity.message_id, + "sessionId": message.session_id, + } + context_token = _context_token(message) + if context_token: + metadata["contextToken"] = context_token + payload = { + "requestId": _request_id(message), + "connectionId": self.connection_id, + "channelId": self.channel_id, + "kind": self.platform_kind, + "target": { + "peerId": identity.peer_id, + "peerType": identity.peer_type, + "threadId": identity.thread_id, + }, + "content": message.content, + "metadata": metadata, + } + await self.sidecar_client.send(payload) + + +def _request_id(message: OutboundMessage) -> str: + identity = message.channel_identity + channel = message.channel or (identity.channel_id if identity else "unknown") + session_id = message.session_id or (identity.session_id() if identity else "unknown") + message_id = str(message.message_id or "").strip() + if not message_id: + basis = "|".join( + [ + message.content, + identity.message_id if identity and identity.message_id else "", + identity.peer_id if identity else "", + message.finish_reason, + ] + ) + message_id = hashlib.sha256(basis.encode("utf-8")).hexdigest()[:24] + return f"out_{channel}:{session_id}:{message_id}" + + +def _context_token(message: OutboundMessage) -> str | None: + inbound_metadata = message.metadata.get("inbound_metadata") + if isinstance(inbound_metadata, dict): + value = _clean_optional(inbound_metadata.get("contextToken") or inbound_metadata.get("context_token")) + if value: + return value + return _clean_optional(message.metadata.get("contextToken") or message.metadata.get("context_token")) + + +def _clean_optional(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None diff --git a/app-instance/backend/beaver/interfaces/channels/generic_webhook.py b/app-instance/backend/beaver/interfaces/channels/generic_webhook.py new file mode 100644 index 0000000..3b22ee3 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/generic_webhook.py @@ -0,0 +1,116 @@ +"""Generic fixed-schema text webhook channel adapter.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage +from beaver.interfaces.channels.base import ChannelInboundSink + + +class GenericWebhookAdapter: + def __init__( + self, + *, + channel_id: str, + kind: str, + mode: str, + account_id: str, + display_name: str = "", + inbound_sink: ChannelInboundSink, + response_timeout_seconds: float = 1800, + ) -> None: + self.channel_id = channel_id + self.kind = kind + self.mode = mode + self.account_id = account_id + self.display_name = display_name or channel_id + self.inbound_sink = inbound_sink + self.response_timeout_seconds = max(1.0, float(response_timeout_seconds)) + self.started = False + self._pending: dict[str, asyncio.Future[OutboundMessage]] = {} + + async def start(self) -> None: + self.started = True + + async def stop(self) -> None: + self.started = False + for future in list(self._pending.values()): + if not future.done(): + future.cancel() + self._pending.clear() + + async def handle_webhook_payload(self, payload: dict[str, Any]) -> dict[str, Any]: + text = str(payload.get("text") or "").strip() + peer_id = str(payload.get("peer_id") or "").strip() + message_id = str(payload.get("message_id") or "").strip() + thread_id = str(payload.get("thread_id") or "").strip() or None + peer_type = str(payload.get("peer_type") or "unknown").strip() or "unknown" + user_id = str(payload.get("user_id") or "").strip() or None + if not text: + return {"ok": False, "error": "text is required"} + if not peer_id: + return {"ok": False, "error": "peer_id is required"} + if not message_id: + return {"ok": False, "error": "message_id is required"} + + identity = ChannelIdentity( + channel_id=self.channel_id, + kind=self.kind, + account_id=self.account_id, + peer_id=peer_id, + thread_id=thread_id, + peer_type=peer_type, + user_id=user_id, + message_id=message_id, + ) + inbound = InboundMessage( + channel=self.channel_id, + content=text, + user_id=user_id, + channel_identity=identity, + metadata={"webhook": {"peer_type": peer_type}}, + ) + future = asyncio.get_running_loop().create_future() + self._pending[inbound.message_id] = future + accept = await self.inbound_sink.accept_inbound(inbound) + if not accept.accepted: + self._pending.pop(inbound.message_id, None) + record = accept.record or {} + return { + "ok": accept.error is None, + "duplicate": accept.duplicate, + "pending": accept.pending, + "session_id": accept.session_id, + "status": record.get("status"), + "run_id": record.get("run_id"), + "reply": record.get("reply"), + "error": accept.error or record.get("error"), + } + try: + outbound = await asyncio.wait_for(future, timeout=self.response_timeout_seconds) + except asyncio.TimeoutError: + self._pending.pop(inbound.message_id, None) + return { + "ok": True, + "duplicate": False, + "pending": True, + "session_id": accept.session_id, + } + return { + "ok": outbound.finish_reason != "error", + "duplicate": False, + "pending": False, + "session_id": outbound.session_id, + "run_id": outbound.run_id, + "reply": outbound.content, + "error": outbound.metadata.get("error"), + } + + async def send(self, message: OutboundMessage) -> None: + future = self._pending.pop(message.message_id, None) + if future is None or future.done(): + message.metadata["delivery_status"] = "unclaimed" + return + future.set_result(message) diff --git a/app-instance/backend/beaver/interfaces/channels/manager.py b/app-instance/backend/beaver/interfaces/channels/manager.py index 438191b..cd3b969 100644 --- a/app-instance/backend/beaver/interfaces/channels/manager.py +++ b/app-instance/backend/beaver/interfaces/channels/manager.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from collections.abc import Awaitable, Callable from contextlib import suppress from beaver.foundation.events import MessageBus, OutboundMessage @@ -20,13 +21,17 @@ class ChannelManager: self.started = False def register(self, channel: ChannelAdapter) -> None: - if self.started: - raise RuntimeError("Cannot register channels after ChannelManager.start()") - if channel.name in self.channels: - raise ValueError(f"Channel already registered: {channel.name}") - if channel.bus is not self.bus: - raise ValueError("Channel must share the same MessageBus as ChannelManager") - self.channels[channel.name] = channel + if channel.channel_id in self.channels: + raise ValueError(f"Channel already registered: {channel.channel_id}") + self.channels[channel.channel_id] = channel + + def unregister(self, channel_id: str) -> ChannelAdapter | None: + return self.channels.pop(channel_id, None) + + def replace_registered(self, channel: ChannelAdapter) -> ChannelAdapter | None: + old = self.channels.get(channel.channel_id) + self.channels[channel.channel_id] = channel + return old async def start(self) -> None: started: list[ChannelAdapter] = [] @@ -53,7 +58,13 @@ class ChannelManager: if errors: raise RuntimeError(f"Failed to stop {len(errors)} channel(s)") from errors[0] - async def dispatch_outbound(self, stop_event: asyncio.Event) -> None: + async def dispatch_outbound( + self, + stop_event: asyncio.Event, + *, + on_delivered: Callable[[OutboundMessage], Awaitable[None]] | None = None, + on_failed: Callable[[OutboundMessage, Exception | None], Awaitable[None]] | None = None, + ) -> None: """Route bus outbound messages until stopped and the queue is drained.""" while True: @@ -68,9 +79,16 @@ class ChannelManager: channel = self.channels.get(message.channel) if channel is None: self.undeliverable.append(message) + if on_failed is not None: + await on_failed(message, None) continue try: await channel.send(message) - except Exception: # pragma: no cover - defensive channel isolation + except Exception as exc: # pragma: no cover - defensive channel isolation self.undeliverable.append(message) + if on_failed is not None: + await on_failed(message, exc) + else: + if on_delivered is not None: + await on_delivered(message) diff --git a/app-instance/backend/beaver/interfaces/channels/memory.py b/app-instance/backend/beaver/interfaces/channels/memory.py index c7702b5..b0b90d8 100644 --- a/app-instance/backend/beaver/interfaces/channels/memory.py +++ b/app-instance/backend/beaver/interfaces/channels/memory.py @@ -4,15 +4,27 @@ from __future__ import annotations from typing import Any -from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage +from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage +from beaver.interfaces.channels.base import ChannelInboundSink class MemoryChannelAdapter: """A local channel that stores outbound messages in memory.""" - def __init__(self, bus: MessageBus, *, name: str = "memory") -> None: - self.name = name - self.bus = bus + def __init__( + self, + inbound_sink: ChannelInboundSink, + *, + channel_id: str = "memory-dev", + kind: str = "memory", + mode: str = "webhook", + account_id: str = "memory", + ) -> None: + self.channel_id = channel_id + self.kind = kind + self.mode = mode + self.account_id = account_id + self.inbound_sink = inbound_sink self.started = False self.sent_messages: list[OutboundMessage] = [] @@ -36,12 +48,24 @@ class MemoryChannelAdapter: model: str | None = None, provider_name: str | None = None, embedding_model: str | None = None, + peer_id: str = "default", + thread_id: str | None = None, + message_id: str | None = None, metadata: dict[str, Any] | None = None, ) -> InboundMessage: """Publish a text message from this channel into the shared bus.""" + identity = ChannelIdentity( + channel_id=self.channel_id, + kind=self.kind, + account_id=self.account_id, + peer_id=peer_id, + thread_id=thread_id, + user_id=user_id, + message_id=message_id, + ) message = InboundMessage( - channel=self.name, + channel=self.channel_id, content=content, session_id=session_id, user_id=user_id, @@ -50,9 +74,10 @@ class MemoryChannelAdapter: model=model, provider_name=provider_name, embedding_model=embedding_model, + channel_identity=identity, metadata=metadata or {}, ) - await self.bus.publish_inbound(message) + await self.inbound_sink.accept_inbound(message) return message async def publish_external_text( @@ -73,9 +98,6 @@ class MemoryChannelAdapter: the shared gateway bus. """ - session_parts = [self.name, chat_id] - if thread_id: - session_parts.append(thread_id) metadata = { "chat_id": chat_id, "message_id": message_id, @@ -84,8 +106,10 @@ class MemoryChannelAdapter: } return await self.publish_text( content, - session_id=":".join(str(part) for part in session_parts if str(part)), user_id=user_id, title=title, + peer_id=chat_id, + thread_id=thread_id, + message_id=message_id, metadata=metadata, ) diff --git a/app-instance/backend/beaver/interfaces/channels/platforms/__init__.py b/app-instance/backend/beaver/interfaces/channels/platforms/__init__.py new file mode 100644 index 0000000..80773e0 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/platforms/__init__.py @@ -0,0 +1 @@ +"""Platform channel adapters.""" diff --git a/app-instance/backend/beaver/interfaces/channels/platforms/base.py b/app-instance/backend/beaver/interfaces/channels/platforms/base.py new file mode 100644 index 0000000..e4789ce --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/platforms/base.py @@ -0,0 +1,138 @@ +"""Shared helpers for platform channel adapters.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage + + +@dataclass(slots=True) +class OutboundTarget: + peer_id: str | None + thread_id: str | None = None + peer_type: str = "unknown" + user_id: str | None = None + + +class PlatformDeliveryError(RuntimeError): + """Raised when a platform client rejects a delivery.""" + + +def config_bool(config: dict[str, Any], key: str, *, default: bool = False) -> bool: + value = config.get(key) + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + text = str(value).strip().lower() + if text in {"1", "true", "yes", "on"}: + return True + if text in {"0", "false", "no", "off"}: + return False + return default + + +def config_list(config: dict[str, Any], key: str) -> list[str]: + value = config.get(key) + if value is None: + return [] + if isinstance(value, str): + return [part.strip() for part in value.split(",") if part.strip()] + if isinstance(value, (list, tuple, set)): + return [str(item).strip() for item in value if str(item).strip()] + text = str(value).strip() + return [text] if text else [] + + +def chunk_text(text: str, *, max_chars: int) -> list[str]: + if max_chars <= 0: + raise ValueError("max_chars must be positive") + if not text: + return [""] + return [text[index : index + max_chars] for index in range(0, len(text), max_chars)] + + +def compact_media_summary(media_type: str, *, file_name: str | None = None) -> str: + label = str(media_type or "attachment").strip() or "attachment" + if file_name: + return f"[{label}: {file_name}]" + return f"[{label}]" + + +def target_from_session_id(session_id: str | None) -> OutboundTarget: + if not session_id: + return OutboundTarget(peer_id=None) + parts = str(session_id).split(":") + if len(parts) < 3: + return OutboundTarget(peer_id=None) + thread_id = parts[3] if len(parts) > 3 and parts[3] else None + return OutboundTarget(peer_id=parts[2] or None, thread_id=thread_id) + + +def outbound_target(message: OutboundMessage) -> OutboundTarget: + identity = message.channel_identity + if identity is None: + return target_from_session_id(message.session_id) + return OutboundTarget( + peer_id=identity.peer_id, + thread_id=identity.thread_id, + peer_type=identity.peer_type, + user_id=identity.user_id, + ) + + +def mark_unclaimed(message: OutboundMessage) -> None: + message.metadata["delivery_status"] = "unclaimed" + + +def build_inbound_message( + *, + channel_id: str, + kind: str, + account_id: str, + peer_id: str, + content: str, + message_id: str | None, + peer_type: str, + user_id: str | None = None, + thread_id: str | None = None, + metadata: dict[str, Any] | None = None, +) -> InboundMessage: + identity = ChannelIdentity( + channel_id=channel_id, + kind=kind, + account_id=account_id, + peer_id=peer_id, + thread_id=thread_id, + peer_type=peer_type, + user_id=user_id, + message_id=message_id, + ) + return InboundMessage( + channel=channel_id, + content=content, + session_id=identity.session_id(), + user_id=user_id, + message_id=message_id or "", + channel_identity=identity, + metadata=metadata or {}, + ) + + +def allowed_by_policy( + *, + policy: str | None, + identifier: str | None, + allowlist: list[str], + default: str = "open", +) -> bool: + effective = (policy or default).strip().lower() + if effective == "disabled": + return False + if effective == "allowlist": + return bool(identifier and identifier in allowlist) + return True diff --git a/app-instance/backend/beaver/interfaces/channels/platforms/feishu.py b/app-instance/backend/beaver/interfaces/channels/platforms/feishu.py new file mode 100644 index 0000000..20b834d --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/platforms/feishu.py @@ -0,0 +1,207 @@ +"""Feishu/Lark channel adapter.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import Any + +from beaver.foundation.events import InboundMessage, OutboundMessage +from beaver.interfaces.channels.base import ChannelInboundSink + +from .base import ( + build_inbound_message, + chunk_text, + compact_media_summary, + config_bool, + config_list, + mark_unclaimed, + outbound_target, +) + +EventRecorder = Callable[..., None] + + +class FeishuAdapter: + """Feishu/Lark bot adapter with injectable client support.""" + + KIND = "feishu" + + def __init__( + self, + *, + channel_id: str, + kind: str, + mode: str, + account_id: str, + display_name: str | None, + inbound_sink: ChannelInboundSink, + secrets: dict[str, Any] | None = None, + config: dict[str, Any] | None = None, + event_recorder: EventRecorder | None = None, + client: Any | None = None, + ) -> None: + self.channel_id = channel_id + self.kind = kind + self.mode = mode + self.account_id = account_id + self.display_name = display_name + self.inbound_sink = inbound_sink + self.secrets = secrets or {} + self.config = config or {} + self.event_recorder = event_recorder + self._client = client + self.max_message_chars = int(self.config.get("maxMessageChars") or 4096) + + async def start(self) -> None: + if self._client is not None: + return + if self.mode not in {"websocket", "webhook"}: + raise ValueError(f"Unsupported feishu mode: {self.mode}") + self._client = self._build_client() + + async def stop(self) -> None: + close = getattr(self._client, "close", None) + if close is not None: + result = close() + if hasattr(result, "__await__"): + await result + + async def handle_event_payload(self, payload: dict[str, Any]) -> None: + message = self._normalize_payload(payload) + if message is None: + return + await self.inbound_sink.accept_inbound(message) + + async def send(self, message: OutboundMessage) -> None: + target = outbound_target(message) + if not target.peer_id: + mark_unclaimed(message) + return + client = self._require_client() + for chunk in chunk_text(message.content, max_chars=self.max_message_chars): + await client.send_text(receive_id_type="chat_id", receive_id=target.peer_id, text=chunk) + + def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None: + event = payload.get("event") if isinstance(payload.get("event"), dict) else payload + message = event.get("message") if isinstance(event.get("message"), dict) else {} + sender = event.get("sender") if isinstance(event.get("sender"), dict) else {} + + peer_id = _string_or_none(message.get("chat_id")) + if not peer_id: + return None + + message_id = _string_or_none(message.get("message_id")) + message_type = str(message.get("message_type") or "unknown") + chat_type = str(message.get("chat_type") or "unknown") + peer_type = "dm" if chat_type == "p2p" else "group" + user_id = _sender_open_id(sender) + + if peer_type == "dm" and not self._dm_allowed(user_id or peer_id): + return None + if peer_type == "group" and not self._group_allowed(peer_id, user_id): + return None + if peer_type == "group" and config_bool(self.config, "requireMentionInGroups", default=False): + if not self._message_mentions_bot(message): + return None + + content = self._message_content(message_type, message) + if not content: + return None + + metadata = { + "chat_id": peer_id, + "message_id": message_id, + "chat_type": chat_type, + "message_type": message_type, + } + + return build_inbound_message( + channel_id=self.channel_id, + kind=self.kind, + account_id=self.account_id, + peer_id=peer_id, + peer_type=peer_type, + user_id=user_id, + message_id=message_id, + content=content, + metadata=metadata, + ) + + def _message_content(self, message_type: str, message: dict[str, Any]) -> str: + content = _parse_json_object(message.get("content")) + if message_type == "text": + return str(content.get("text") or "").strip() + file_name = _string_or_none(content.get("file_name") or content.get("name")) + return compact_media_summary(message_type, file_name=file_name) + + def _message_mentions_bot(self, message: dict[str, Any]) -> bool: + bot_open_id = _string_or_none(self.config.get("botOpenId")) + if not bot_open_id: + return False + mentions = message.get("mentions") + if not isinstance(mentions, list): + return False + for mention in mentions: + if not isinstance(mention, dict): + continue + mention_id = mention.get("id") if isinstance(mention.get("id"), dict) else {} + if _string_or_none(mention_id.get("open_id")) == bot_open_id: + return True + return False + + def _dm_allowed(self, identifier: str | None) -> bool: + allowlist = config_list(self.config, "allowFrom") + if not allowlist: + return True + return bool(identifier and identifier in allowlist) + + def _group_allowed(self, peer_id: str | None, user_id: str | None) -> bool: + allowlist = config_list(self.config, "groupAllowFrom") + if not allowlist: + return True + return bool((peer_id and peer_id in allowlist) or (user_id and user_id in allowlist)) + + def _require_client(self) -> Any: + if self._client is None: + self._client = self._build_client() + return self._client + + def _build_client(self) -> Any: + self._require_secret("appId") + self._require_secret("appSecret") + try: + import lark_oapi # noqa: F401 + except ImportError as exc: # pragma: no cover - optional live dependency + raise RuntimeError("Install beaver-backend[feishu] to enable FeishuAdapter") from exc + raise RuntimeError("Feishu live client is not configured for direct construction") + + def _require_secret(self, key: str) -> str: + value = self.secrets.get(key) + if not value: + raise ValueError(f"{key} is required") + return str(value) + + +def _parse_json_object(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if not isinstance(value, str): + return {} + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return {} + return parsed if isinstance(parsed, dict) else {} + + +def _sender_open_id(sender: dict[str, Any]) -> str | None: + sender_id = sender.get("sender_id") if isinstance(sender.get("sender_id"), dict) else {} + return _string_or_none(sender_id.get("open_id")) + + +def _string_or_none(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None diff --git a/app-instance/backend/beaver/interfaces/channels/platforms/qqbot.py b/app-instance/backend/beaver/interfaces/channels/platforms/qqbot.py new file mode 100644 index 0000000..4060677 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/platforms/qqbot.py @@ -0,0 +1,206 @@ +"""QQ Bot channel adapter.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from beaver.foundation.events import InboundMessage, OutboundMessage +from beaver.interfaces.channels.base import ChannelInboundSink + +from .base import ( + allowed_by_policy, + build_inbound_message, + chunk_text, + compact_media_summary, + config_list, + mark_unclaimed, + outbound_target, +) + +EventRecorder = Callable[..., None] + + +class QQBotAdapter: + """QQ Bot API adapter with injectable client support.""" + + KIND = "qqbot" + + def __init__( + self, + *, + channel_id: str, + kind: str, + mode: str, + account_id: str, + display_name: str | None, + inbound_sink: ChannelInboundSink, + secrets: dict[str, Any] | None = None, + config: dict[str, Any] | None = None, + event_recorder: EventRecorder | None = None, + client: Any | None = None, + ) -> None: + self.channel_id = channel_id + self.kind = kind + self.mode = mode + self.account_id = account_id + self.display_name = display_name + self.inbound_sink = inbound_sink + self.secrets = secrets or {} + self.config = config or {} + self.event_recorder = event_recorder + self._client = client + self.max_message_chars = int(self.config.get("maxMessageChars") or 2000) + + async def start(self) -> None: + if self._client is not None: + return + if self.mode != "websocket": + raise ValueError(f"Unsupported qqbot mode: {self.mode}") + self._client = self._build_client() + + async def stop(self) -> None: + close = getattr(self._client, "close", None) + if close is not None: + result = close() + if hasattr(result, "__await__"): + await result + + async def handle_event_payload(self, payload: dict[str, Any]) -> None: + message = self._normalize_payload(payload) + if message is None: + return + await self.inbound_sink.accept_inbound(message) + + async def send(self, message: OutboundMessage) -> None: + target = outbound_target(message) + if not target.peer_id: + mark_unclaimed(message) + return + client = self._require_client() + platform_message_id = message.channel_identity.message_id if message.channel_identity else None + for chunk in chunk_text(message.content, max_chars=self.max_message_chars): + await client.send_text( + peer_type=target.peer_type, + peer_id=target.peer_id, + content=chunk, + message_id=platform_message_id, + ) + + def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None: + event_type = str(payload.get("t") or payload.get("type") or "") + data = payload.get("d") if isinstance(payload.get("d"), dict) else payload + author = data.get("author") if isinstance(data.get("author"), dict) else {} + + route = self._route(event_type, data, author) + if route is None: + return None + peer_id, peer_type, user_id, thread_id = route + + if peer_type == "dm": + if not allowed_by_policy( + policy=self.config.get("dmPolicy"), + identifier=user_id or peer_id, + allowlist=config_list(self.config, "allowFrom"), + default="open", + ): + return None + elif peer_type == "group": + if not allowed_by_policy( + policy=self.config.get("groupPolicy"), + identifier=peer_id, + allowlist=config_list(self.config, "groupAllowFrom"), + default="open", + ): + return None + + message_id = _string_or_none(data.get("id")) + content = str(data.get("content") or "").strip() + media_entries = self._media_entries(data) + if media_entries: + content = "\n".join([part for part in [content, *media_entries] if part]).strip() + if not content: + return None + + metadata = { + "event_type": event_type, + "message_id": message_id, + "peer_type": peer_type, + } + if media_entries: + metadata["media"] = media_entries + + return build_inbound_message( + channel_id=self.channel_id, + kind=self.kind, + account_id=self.account_id, + peer_id=peer_id, + thread_id=thread_id, + peer_type=peer_type, + user_id=user_id, + message_id=message_id, + content=content, + metadata=metadata, + ) + + def _route( + self, + event_type: str, + data: dict[str, Any], + author: dict[str, Any], + ) -> tuple[str, str, str | None, str | None] | None: + if event_type == "C2C_MESSAGE_CREATE": + peer_id = _string_or_none(author.get("user_openid")) + if not peer_id: + return None + return peer_id, "dm", peer_id, None + if event_type == "GROUP_AT_MESSAGE_CREATE": + peer_id = _string_or_none(data.get("group_openid")) + if not peer_id: + return None + return peer_id, "group", _string_or_none(author.get("member_openid")), None + if data.get("guild_id") and data.get("channel_id"): + peer_id = _string_or_none(data.get("channel_id")) + if not peer_id: + return None + return peer_id, "channel", _string_or_none(author.get("id")), _string_or_none(data.get("guild_id")) + return None + + def _media_entries(self, data: dict[str, Any]) -> list[str]: + entries: list[str] = [] + attachments = data.get("attachments") + if not isinstance(attachments, list): + return entries + for attachment in attachments: + if not isinstance(attachment, dict): + continue + media_type = str(attachment.get("content_type") or attachment.get("type") or "attachment") + entries.append(compact_media_summary(media_type, file_name=_string_or_none(attachment.get("filename")))) + return entries + + def _require_client(self) -> Any: + if self._client is None: + self._client = self._build_client() + return self._client + + def _build_client(self) -> Any: + self._require_secret("appId") + self._require_secret("clientSecret") + try: + import aiohttp # noqa: F401 + except ImportError as exc: # pragma: no cover - optional live dependency + raise RuntimeError("Install beaver-backend[qqbot] to enable QQBotAdapter") from exc + raise RuntimeError("QQBot live client is not configured for direct construction") + + def _require_secret(self, key: str) -> str: + value = self.secrets.get(key) + if not value: + raise ValueError(f"{key} is required") + return str(value) + + +def _string_or_none(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None diff --git a/app-instance/backend/beaver/interfaces/channels/platforms/telegram.py b/app-instance/backend/beaver/interfaces/channels/platforms/telegram.py new file mode 100644 index 0000000..51ccda6 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/platforms/telegram.py @@ -0,0 +1,244 @@ +"""Telegram channel adapter.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any + +from beaver.foundation.events import InboundMessage, OutboundMessage +from beaver.interfaces.channels.base import ChannelInboundSink + +from .base import ( + build_inbound_message, + chunk_text, + compact_media_summary, + config_bool, + config_list, + mark_unclaimed, + outbound_target, +) + +EventRecorder = Callable[..., None] + + +class TelegramAdapter: + """Telegram Bot API adapter with injectable client support.""" + + KIND = "telegram" + + def __init__( + self, + *, + channel_id: str, + kind: str, + mode: str, + account_id: str, + display_name: str | None, + inbound_sink: ChannelInboundSink, + secrets: dict[str, Any] | None = None, + config: dict[str, Any] | None = None, + event_recorder: EventRecorder | None = None, + client: Any | None = None, + application_factory: Callable[[], Any] | None = None, + ) -> None: + self.channel_id = channel_id + self.kind = kind + self.mode = mode + self.account_id = account_id + self.display_name = display_name + self.inbound_sink = inbound_sink + self.secrets = secrets or {} + self.config = config or {} + self.event_recorder = event_recorder + self._client = client + self._application_factory = application_factory + self._application: Any | None = None + self.max_message_chars = int(self.config.get("maxMessageChars") or 4096) + + async def start(self) -> None: + if self._client is not None: + return + if self.mode == "polling": + self._application = self._build_application() + await self._application.initialize() + await self._application.start() + if getattr(self._application, "updater", None) is not None: + await self._application.updater.start_polling() + self._client = self._application.bot + return + if self.mode == "webhook": + self._client = self._build_bot() + return + raise ValueError(f"Unsupported telegram mode: {self.mode}") + + async def stop(self) -> None: + if self._application is None: + return + updater = getattr(self._application, "updater", None) + if updater is not None: + await updater.stop() + await self._application.stop() + await self._application.shutdown() + self._application = None + + async def handle_update_payload(self, payload: dict[str, Any]) -> None: + message = self._normalize_payload(payload) + if message is None: + return + await self.inbound_sink.accept_inbound(message) + + async def send(self, message: OutboundMessage) -> None: + target = outbound_target(message) + if not target.peer_id: + mark_unclaimed(message) + return + client = self._require_client() + kwargs: dict[str, Any] = {"chat_id": target.peer_id} + if target.thread_id: + kwargs["message_thread_id"] = int(target.thread_id) if str(target.thread_id).isdigit() else target.thread_id + for chunk in chunk_text(message.content, max_chars=self.max_message_chars): + await client.send_message(**kwargs, text=chunk) + + def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None: + data = payload.get("message") or payload.get("edited_message") + if not isinstance(data, dict): + return None + + chat = data.get("chat") if isinstance(data.get("chat"), dict) else {} + sender = data.get("from") if isinstance(data.get("from"), dict) else {} + peer_id = _string_or_none(chat.get("id")) + if not peer_id: + return None + + chat_type = str(chat.get("type") or "unknown") + peer_type = self._peer_type(chat_type) + user_id = _string_or_none(sender.get("id")) + message_id = _string_or_none(data.get("message_id")) + thread_id = _string_or_none(data.get("message_thread_id")) + + content = str(data.get("text") or data.get("caption") or "").strip() + media_entries = self._media_entries(data) + if media_entries: + content = "\n".join([part for part in [content, *media_entries] if part]).strip() + if not content: + return None + + if peer_type in {"group", "channel"} and not self._group_allowed(peer_id, user_id): + return None + if peer_type == "dm" and not self._dm_allowed(user_id or peer_id): + return None + + if peer_type in {"group", "channel"} and config_bool(self.config, "requireMentionInGroups", default=False): + gated = self._strip_required_mention(content) + if gated is None: + return None + content = gated + + metadata = { + "chat_id": peer_id, + "message_id": message_id, + "chat_type": chat_type, + } + if media_entries: + metadata["media"] = media_entries + + return build_inbound_message( + channel_id=self.channel_id, + kind=self.kind, + account_id=self.account_id, + peer_id=peer_id, + thread_id=thread_id, + peer_type=peer_type, + user_id=user_id, + message_id=message_id, + content=content, + metadata=metadata, + ) + + def _media_entries(self, data: dict[str, Any]) -> list[str]: + entries: list[str] = [] + if data.get("photo"): + entries.append(compact_media_summary("photo")) + for media_type in ("document", "audio", "video"): + value = data.get(media_type) + if isinstance(value, dict): + entries.append(compact_media_summary(media_type, file_name=_string_or_none(value.get("file_name")))) + return entries + + def _strip_required_mention(self, content: str) -> str | None: + username = str(self.config.get("botUsername") or "").strip().lstrip("@") + if not username: + return None + mention = f"@{username}" + if mention not in content: + return None + return content.replace(mention, "", 1).strip() + + def _dm_allowed(self, identifier: str | None) -> bool: + allowlist = config_list(self.config, "allowFrom") + if not allowlist: + return True + return bool(identifier and identifier in allowlist) + + def _group_allowed(self, peer_id: str | None, user_id: str | None) -> bool: + allowlist = config_list(self.config, "groupAllowFrom") + if not allowlist: + return True + return bool((peer_id and peer_id in allowlist) or (user_id and user_id in allowlist)) + + def _peer_type(self, chat_type: str) -> str: + if chat_type == "private": + return "dm" + if chat_type in {"group", "supergroup"}: + return "group" + if chat_type == "channel": + return "channel" + return chat_type or "unknown" + + def _require_client(self) -> Any: + if self._client is None: + self._client = self._build_bot() + return self._client + + def _build_bot(self) -> Any: + token = self._require_secret("botToken") + try: + from telegram import Bot + except ImportError as exc: # pragma: no cover - optional live dependency + raise RuntimeError("Install beaver-backend[telegram] to enable TelegramAdapter") from exc + return Bot(token=token) + + def _build_application(self) -> Any: + if self._application_factory is not None: + return self._application_factory() + token = self._require_secret("botToken") + try: + from telegram.ext import Application + except ImportError as exc: # pragma: no cover - optional live dependency + raise RuntimeError("Install beaver-backend[telegram] to enable TelegramAdapter") from exc + + async def handle(update: Any, context: Any) -> None: + if hasattr(update, "to_dict"): + await self.handle_update_payload(update.to_dict()) + + application = Application.builder().token(token).build() + try: + from telegram.ext import MessageHandler, filters + + application.add_handler(MessageHandler(filters.ALL, handle)) + except Exception: + pass + return application + + def _require_secret(self, key: str) -> str: + value = self.secrets.get(key) + if not value: + raise ValueError(f"{key} is required") + return str(value) + + +def _string_or_none(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None diff --git a/app-instance/backend/beaver/interfaces/channels/platforms/weixin.py b/app-instance/backend/beaver/interfaces/channels/platforms/weixin.py new file mode 100644 index 0000000..e52f089 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/platforms/weixin.py @@ -0,0 +1,180 @@ +"""Weixin channel adapter.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from beaver.foundation.events import InboundMessage, OutboundMessage +from beaver.interfaces.channels.base import ChannelInboundSink + +from .base import ( + allowed_by_policy, + build_inbound_message, + chunk_text, + compact_media_summary, + config_list, + mark_unclaimed, + outbound_target, +) + +EventRecorder = Callable[..., None] + + +class WeixinAdapter: + """Tencent iLink-style Weixin adapter with injectable client support.""" + + KIND = "weixin" + + def __init__( + self, + *, + channel_id: str, + kind: str, + mode: str, + account_id: str, + display_name: str | None, + inbound_sink: ChannelInboundSink, + secrets: dict[str, Any] | None = None, + config: dict[str, Any] | None = None, + event_recorder: EventRecorder | None = None, + client: Any | None = None, + ) -> None: + self.channel_id = channel_id + self.kind = kind + self.mode = mode + self.account_id = account_id + self.display_name = display_name + self.inbound_sink = inbound_sink + self.secrets = secrets or {} + self.config = config or {} + self.event_recorder = event_recorder + self._client = client + self.max_message_chars = int(self.config.get("maxMessageChars") or 2000) + + async def start(self) -> None: + if self._client is not None: + return + if self.mode != "polling": + raise ValueError(f"Unsupported weixin mode: {self.mode}") + self._client = self._build_client() + + async def stop(self) -> None: + close = getattr(self._client, "close", None) + if close is not None: + result = close() + if hasattr(result, "__await__"): + await result + + async def handle_message_payload(self, payload: dict[str, Any]) -> None: + message = self._normalize_payload(payload) + if message is None: + return + await self.inbound_sink.accept_inbound(message) + + async def send(self, message: OutboundMessage) -> None: + target = outbound_target(message) + if not target.peer_id: + mark_unclaimed(message) + return + client = self._require_client() + context_token = self._context_token(message) + for chunk in chunk_text(message.content, max_chars=self.max_message_chars): + await client.send_text(peer_id=target.peer_id, text=chunk, context_token=context_token) + + def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None: + sender_id = _string_or_none(payload.get("from") or payload.get("from_user")) + room_id = _string_or_none(payload.get("room_id") or payload.get("roomId")) + message_id = _string_or_none(payload.get("id") or payload.get("message_id")) + message_type = str(payload.get("type") or payload.get("message_type") or "text") + + if room_id: + peer_id = room_id + peer_type = "group" + user_id = sender_id + if not allowed_by_policy( + policy=self.config.get("groupPolicy"), + identifier=peer_id, + allowlist=config_list(self.config, "groupAllowFrom"), + default="disabled", + ): + return None + else: + peer_id = sender_id + peer_type = "dm" + user_id = sender_id + if not allowed_by_policy( + policy=self.config.get("dmPolicy"), + identifier=peer_id, + allowlist=config_list(self.config, "allowFrom"), + default="open", + ): + return None + if not peer_id: + return None + + content = self._content(message_type, payload) + if not content: + return None + + metadata = { + "message_id": message_id, + "message_type": message_type, + } + context_token = _string_or_none(payload.get("context_token") or payload.get("contextToken")) + if context_token: + metadata["context_token"] = context_token + if room_id: + metadata["room_id"] = room_id + + return build_inbound_message( + channel_id=self.channel_id, + kind=self.kind, + account_id=self.account_id, + peer_id=peer_id, + peer_type=peer_type, + user_id=user_id, + message_id=message_id, + content=content, + metadata=metadata, + ) + + def _content(self, message_type: str, payload: dict[str, Any]) -> str: + if message_type == "text": + return str(payload.get("text") or payload.get("content") or "").strip() + file_name = _string_or_none(payload.get("file_name") or payload.get("filename")) + return compact_media_summary(message_type, file_name=file_name) + + def _context_token(self, message: OutboundMessage) -> str | None: + inbound_metadata = message.metadata.get("inbound_metadata") + if isinstance(inbound_metadata, dict): + value = _string_or_none(inbound_metadata.get("context_token")) + if value: + return value + return _string_or_none(message.metadata.get("context_token")) + + def _require_client(self) -> Any: + if self._client is None: + self._client = self._build_client() + return self._client + + def _build_client(self) -> Any: + self._require_secret("token") + try: + import aiohttp # noqa: F401 + except ImportError as exc: # pragma: no cover - optional live dependency + raise RuntimeError("Install beaver-backend[weixin] to enable WeixinAdapter") from exc + raise RuntimeError("Weixin live client is not configured for direct construction") + + def _require_secret(self, key: str) -> str: + value = self.secrets.get(key) + if not value: + raise ValueError(f"{key} is required") + return str(value) + + +def _string_or_none(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None diff --git a/app-instance/backend/beaver/interfaces/channels/runtime.py b/app-instance/backend/beaver/interfaces/channels/runtime.py new file mode 100644 index 0000000..f55e910 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/runtime.py @@ -0,0 +1,526 @@ +"""Channel runtime host for adapter lifecycle and bus-first routing.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from beaver.foundation.config.schema import ChannelConfig +from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage +from beaver.interfaces.channels.base import ChannelAdapter +from beaver.interfaces.channels.manager import ChannelManager +from beaver.interfaces.channels.state import ChannelDedupeStore, ChannelEventLog +from beaver.services.agent_service import AgentService + + +def _iso_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _channel_capabilities(kind: str, mode: str) -> list[str]: + if kind == "webhook": + return ["receive_text", "send_text", "sync_webhook_response"] + if kind == "terminal" and mode == "websocket": + return ["receive_text", "send_text", "persistent_connection"] + if kind in {"feishu", "qqbot", "telegram"}: + return ["receive_text", "send_text", "receive_media", "groups"] + if kind == "weixin": + return ["receive_text", "send_text", "receive_media", "direct_messages"] + return [] + + +@dataclass(slots=True) +class ChannelAcceptResult: + accepted: bool + duplicate: bool = False + pending: bool = False + rejected: bool = False + session_id: str | None = None + dedupe_key: str | None = None + record: dict[str, Any] | None = None + error: str | None = None + + +class ChannelRuntime: + """Own channel adapters, state, and the inbound/outbound bus bridge.""" + + def __init__( + self, + *, + service: AgentService, + workspace: Path, + channels: dict[str, ChannelConfig], + bus: MessageBus | None = None, + ) -> None: + self.service = service + self.workspace = Path(workspace) + self.bus = bus or MessageBus() + self.manager = ChannelManager(self.bus) + self.channel_configs = dict(channels) + self.adapters: dict[str, ChannelAdapter] = {} + self.states: dict[str, dict[str, Any]] = {} + state_dir = self.workspace / "state" / "channels" + retention = self._default_dedupe_retention_hours() + self.dedupe = ChannelDedupeStore(state_dir / "dedupe.json", retention_hours=retention) + self.events = ChannelEventLog(state_dir / "events.jsonl") + self._bridge_task: asyncio.Task[None] | None = None + self._dispatch_task: asyncio.Task[None] | None = None + self._stop_event = asyncio.Event() + self._dispatch_stop_event = asyncio.Event() + self._lifecycle_lock = asyncio.Lock() + + async def start(self) -> None: + self._stop_event.clear() + self._dispatch_stop_event.clear() + for channel_id, cfg in self.channel_configs.items(): + if not cfg.enabled: + self.states[channel_id] = {"state": "disabled", "last_error": None} + continue + try: + adapter = self._build_adapter(channel_id, cfg) + self.adapters[channel_id] = adapter + self.manager.register(adapter) + await adapter.start() + self.states[channel_id] = { + "state": "running", + "last_error": None, + "started_at": _iso_now(), + } + self.events.record(channel_id=channel_id, kind="adapter_started") + except Exception as exc: # pragma: no cover - defensive startup isolation + self.states[channel_id] = {"state": "error", "last_error": str(exc)} + self.events.record( + channel_id=channel_id, + kind="adapter_error", + status="error", + error=str(exc), + ) + self._bridge_task = asyncio.create_task(self._bridge_inbound_to_agent()) + self._dispatch_task = asyncio.create_task( + self.manager.dispatch_outbound( + self._dispatch_stop_event, + on_delivered=self._record_outbound_delivered, + on_failed=self._record_outbound_failed, + ) + ) + + async def stop(self) -> None: + self._stop_event.set() + if self._bridge_task is not None: + self._bridge_task.cancel() + try: + await self._bridge_task + except asyncio.CancelledError: + pass + self._dispatch_stop_event.set() + if self._dispatch_task is not None: + try: + await asyncio.wait_for(self._dispatch_task, timeout=1.0) + except asyncio.TimeoutError: + self._dispatch_task.cancel() + try: + await self._dispatch_task + except asyncio.CancelledError: + pass + await self.manager.stop() + for channel_id in self.adapters: + self.events.record(channel_id=channel_id, kind="adapter_stopped") + + async def add_channel(self, channel_id: str, config: ChannelConfig) -> None: + async with self._lifecycle_lock: + current = self.channel_configs.get(channel_id) + if current == config and channel_id in self.adapters: + return + if not config.enabled: + await self._remove_channel_locked(channel_id) + self.channel_configs[channel_id] = config + self.states[channel_id] = {"state": "disabled", "last_error": None} + return + + adapter = self._build_adapter(channel_id, config) + await adapter.start() + old_adapter = self.adapters.get(channel_id) + self.manager.replace_registered(adapter) + self.adapters[channel_id] = adapter + self.channel_configs[channel_id] = config + self.states[channel_id] = {"state": "running", "last_error": None, "started_at": _iso_now()} + self.events.record(channel_id=channel_id, kind="adapter_started") + if old_adapter is not None and old_adapter is not adapter: + await old_adapter.stop() + + async def remove_channel(self, channel_id: str) -> None: + async with self._lifecycle_lock: + await self._remove_channel_locked(channel_id) + + async def _remove_channel_locked(self, channel_id: str) -> None: + adapter = self.adapters.pop(channel_id, None) + self.manager.unregister(channel_id) + self.channel_configs.pop(channel_id, None) + if adapter is not None: + await adapter.stop() + self.events.record(channel_id=channel_id, kind="adapter_stopped") + self.states[channel_id] = {"state": "removed", "last_error": None} + + async def accept_inbound(self, message: InboundMessage) -> ChannelAcceptResult: + identity = message.channel_identity + if identity is None: + self.events.record( + channel_id=message.channel, + kind="inbound_rejected", + status="error", + error="channel_identity is required", + ) + return ChannelAcceptResult( + accepted=False, + rejected=True, + error="channel_identity is required", + ) + + validation_error = identity.validation_error() + if validation_error: + self.events.record( + channel_id=identity.channel_id, + kind="inbound_rejected", + status="error", + error=validation_error, + ) + return ChannelAcceptResult(accepted=False, rejected=True, error=validation_error) + + expected_session_id = identity.session_id() + if message.session_id != expected_session_id: + self.events.record( + channel_id=identity.channel_id, + kind="session_id_normalized", + session_id=expected_session_id, + message_id=identity.message_id, + ) + message.session_id = expected_session_id + message.channel = identity.channel_id + + dedupe_key = identity.dedupe_key() + if dedupe_key: + write = self.dedupe.mark_processing( + dedupe_key=dedupe_key, + session_id=expected_session_id, + message_id=identity.message_id or "", + ) + if not write.created: + record = write.record or {} + self.events.record( + channel_id=identity.channel_id, + kind="inbound_duplicate", + session_id=expected_session_id, + message_id=identity.message_id, + status=str(record.get("status") or "processing"), + ) + return ChannelAcceptResult( + accepted=False, + duplicate=True, + pending=record.get("status") == "processing", + session_id=expected_session_id, + dedupe_key=dedupe_key, + record=record, + ) + + self.events.record( + channel_id=identity.channel_id, + kind="inbound_accepted", + session_id=expected_session_id, + message_id=identity.message_id, + text=message.content, + ) + await self.bus.publish_inbound(message) + return ChannelAcceptResult( + accepted=True, + session_id=expected_session_id, + dedupe_key=dedupe_key, + ) + + def statuses(self) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + recent = self.events.recent(limit=500) + last_by_channel = {event["channel_id"]: event for event in recent if event.get("channel_id")} + for channel_id, cfg in self.channel_configs.items(): + state = self.states.get(channel_id, {"state": "configured", "last_error": None}) + capabilities = _channel_capabilities(cfg.kind, cfg.mode) + webhook_url = None + websocket_url = None + connected_peers = 0 + if cfg.kind == "webhook": + webhook_url = f"/api/channels/{channel_id}/webhook" + elif cfg.kind == "terminal" and cfg.mode == "websocket": + websocket_url = f"/api/channels/{channel_id}/ws" + adapter = self.adapters.get(channel_id) + if adapter is not None and hasattr(adapter, "status_extra"): + extra = adapter.status_extra() # type: ignore[attr-defined] + connected_peers = int(extra.get("connected_peers") or 0) + items.append( + { + "channel_id": channel_id, + "name": channel_id, + "kind": cfg.kind, + "mode": cfg.mode, + "display_name": cfg.display_name or channel_id, + "enabled": cfg.enabled, + "state": state.get("state", "configured"), + "account_id": cfg.account_id, + "last_error": state.get("last_error"), + "started_at": state.get("started_at"), + "last_event_at": last_by_channel.get(channel_id, {}).get("created_at"), + "capabilities": capabilities, + "webhook_url": webhook_url, + "websocket_url": websocket_url, + "connected_peers": connected_peers, + } + ) + return items + + def recent_events(self, channel_id: str, *, limit: int = 100) -> list[dict[str, Any]]: + return self.events.recent(channel_id=channel_id, limit=limit) + + def record_event( + self, + *, + channel_id: str, + kind: str, + session_id: str | None = None, + message_id: str | None = None, + run_id: str | None = None, + status: str = "ok", + error: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> None: + self.events.record( + channel_id=channel_id, + kind=kind, + session_id=session_id, + message_id=message_id, + run_id=run_id, + status=status, + error=error, + metadata=metadata, + ) + + def _build_adapter(self, channel_id: str, cfg: ChannelConfig) -> ChannelAdapter: + if cfg.kind == "webhook" and cfg.mode == "webhook": + from beaver.interfaces.channels.generic_webhook import GenericWebhookAdapter + + return GenericWebhookAdapter( + channel_id=channel_id, + kind=cfg.kind, + mode=cfg.mode, + account_id=cfg.account_id, + display_name=cfg.display_name, + inbound_sink=self, + response_timeout_seconds=float(cfg.config.get("response_timeout_seconds") or 1800), + ) + + if cfg.kind == "terminal" and cfg.mode == "websocket": + from beaver.interfaces.channels.terminal_websocket import TerminalWebSocketAdapter + + return TerminalWebSocketAdapter( + channel_id=channel_id, + kind=cfg.kind, + mode=cfg.mode, + account_id=cfg.account_id, + display_name=cfg.display_name, + inbound_sink=self, + event_recorder=self.record_event, + heartbeat_seconds=float(cfg.config.get("heartbeat_seconds") or 30), + max_message_chars=int(cfg.config.get("max_message_chars") or 20000), + ) + + if cfg.kind == "telegram" and cfg.mode in {"polling", "webhook"}: + from beaver.interfaces.channels.platforms.telegram import TelegramAdapter + + return TelegramAdapter( + channel_id=channel_id, + kind=cfg.kind, + mode=cfg.mode, + account_id=cfg.account_id, + display_name=cfg.display_name, + inbound_sink=self, + secrets=cfg.secrets, + config=cfg.config, + event_recorder=self.record_event, + ) + + if cfg.kind == "feishu" and cfg.mode in {"websocket", "webhook"}: + from beaver.interfaces.channels.platforms.feishu import FeishuAdapter + + return FeishuAdapter( + channel_id=channel_id, + kind=cfg.kind, + mode=cfg.mode, + account_id=cfg.account_id, + display_name=cfg.display_name, + inbound_sink=self, + secrets=cfg.secrets, + config=cfg.config, + event_recorder=self.record_event, + ) + + if cfg.kind == "qqbot" and cfg.mode == "websocket": + from beaver.interfaces.channels.platforms.qqbot import QQBotAdapter + + return QQBotAdapter( + channel_id=channel_id, + kind=cfg.kind, + mode=cfg.mode, + account_id=cfg.account_id, + display_name=cfg.display_name, + inbound_sink=self, + secrets=cfg.secrets, + config=cfg.config, + event_recorder=self.record_event, + ) + + if cfg.kind == "weixin" and cfg.mode == "polling": + from beaver.interfaces.channels.platforms.weixin import WeixinAdapter + + return WeixinAdapter( + channel_id=channel_id, + kind=cfg.kind, + mode=cfg.mode, + account_id=cfg.account_id, + display_name=cfg.display_name, + inbound_sink=self, + secrets=cfg.secrets, + config=cfg.config, + event_recorder=self.record_event, + ) + + if cfg.kind == "external_connector" and cfg.mode == "http": + import os + + from beaver.interfaces.channels.connections.sidecar_client import ConnectorSidecarClient + from beaver.interfaces.channels.external_connector import ExternalConnectorChannel + + base_url = str(cfg.config.get("sidecarBaseUrl") or os.getenv("EXTERNAL_CONNECTOR_BASE_URL") or "").strip() + token = os.getenv("EXTERNAL_CONNECTOR_TOKEN", "") + platform_kind = str(cfg.config.get("platformKind") or "").strip() + connection_id = str(cfg.config.get("connectionId") or "").strip() + if not base_url: + raise ValueError("external connector sidecarBaseUrl is required") + if not platform_kind: + raise ValueError("external connector platformKind is required") + if not connection_id: + raise ValueError("external connector connectionId is required") + return ExternalConnectorChannel( + channel_id=channel_id, + platform_kind=platform_kind, + connection_id=connection_id, + account_id=cfg.account_id, + display_name=cfg.display_name, + sidecar_client=ConnectorSidecarClient(base_url=base_url, token=token), + ) + + raise ValueError(f"Unsupported channel kind/mode: {cfg.kind}/{cfg.mode}") + + async def _bridge_inbound_to_agent(self) -> None: + current_inbound: InboundMessage | None = None + while not self._stop_event.is_set(): + try: + current_inbound = await asyncio.wait_for(self.bus.consume_inbound(), timeout=0.25) + except asyncio.TimeoutError: + continue + except asyncio.CancelledError: + raise + inbound = current_inbound + identity = inbound.channel_identity + try: + self.events.record( + channel_id=inbound.channel, + kind="direct_run_started", + session_id=inbound.session_id, + message_id=identity.message_id if identity else inbound.message_id, + ) + outbound = await self.service.handle_inbound_message(inbound) + except asyncio.CancelledError: + outbound = AgentService.build_outbound_error( + inbound, + detail="Channel runtime stopped before completing the inbound message", + finish_reason="cancelled", + ) + self._mark_dedupe_result(inbound, outbound) + await self.bus.publish_outbound(outbound) + current_inbound = None + raise + except Exception as exc: + self.events.record( + channel_id=inbound.channel, + kind="direct_run_failed", + session_id=inbound.session_id, + message_id=identity.message_id if identity else inbound.message_id, + status="error", + error=str(exc), + ) + outbound = AgentService.build_outbound_error( + inbound, + detail=str(exc), + finish_reason="error", + ) + else: + self.events.record( + channel_id=outbound.channel, + kind="direct_run_finished", + session_id=outbound.session_id, + message_id=identity.message_id if identity else inbound.message_id, + run_id=outbound.run_id, + ) + self._mark_dedupe_result(inbound, outbound) + await self.bus.publish_outbound(outbound) + current_inbound = None + + def _mark_dedupe_result(self, inbound: InboundMessage, outbound: OutboundMessage) -> None: + identity = inbound.channel_identity + dedupe_key = identity.dedupe_key() if identity else None + if not dedupe_key: + return + cfg = self.channel_configs.get(identity.channel_id) + max_reply_chars = int((cfg.config if cfg else {}).get("max_cached_reply_chars") or 20000) + max_error_chars = int((cfg.config if cfg else {}).get("max_cached_error_chars") or 4000) + if outbound.finish_reason == "error": + self.dedupe.mark_error( + dedupe_key=dedupe_key, + error=outbound.content, + max_error_chars=max_error_chars, + ) + else: + self.dedupe.mark_done( + dedupe_key=dedupe_key, + run_id=outbound.run_id, + reply=outbound.content, + max_reply_chars=max_reply_chars, + ) + + async def _record_outbound_delivered(self, message: OutboundMessage) -> None: + kind = "outbound_unclaimed" if message.metadata.get("delivery_status") == "unclaimed" else "outbound_delivered" + self.events.record( + channel_id=message.channel, + kind=kind, + session_id=message.session_id, + message_id=message.channel_identity.message_id if message.channel_identity else message.message_id, + run_id=message.run_id, + ) + + async def _record_outbound_failed(self, message: OutboundMessage, exc: Exception | None) -> None: + self.events.record( + channel_id=message.channel, + kind="outbound_delivery_failed", + session_id=message.session_id, + message_id=message.channel_identity.message_id if message.channel_identity else message.message_id, + run_id=message.run_id, + status="error", + error=str(exc) if exc else "channel not registered", + ) + + def _default_dedupe_retention_hours(self) -> int: + for cfg in self.channel_configs.values(): + value = cfg.config.get("dedupe_retention_hours") + if value is not None: + return int(value) + return 48 diff --git a/app-instance/backend/beaver/interfaces/channels/state.py b/app-instance/backend/beaver/interfaces/channels/state.py new file mode 100644 index 0000000..1c2b7d9 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/state.py @@ -0,0 +1,198 @@ +"""Persistent channel runtime state.""" + +from __future__ import annotations + +import json +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from threading import Lock +from typing import Any +from uuid import uuid4 + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _iso_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +@dataclass(slots=True) +class DedupeWriteResult: + created: bool + record: dict[str, Any] | None = None + + +class ChannelDedupeStore: + def __init__(self, path: Path, *, retention_hours: int = 48) -> None: + self.path = path + self.retention_ms = max(1, int(retention_hours)) * 60 * 60 * 1000 + self._lock = Lock() + + def get(self, dedupe_key: str) -> dict[str, Any] | None: + with self._lock: + data = self._load() + self._prune_unlocked(data, _now_ms()) + record = data["records"].get(dedupe_key) + self._save(data) + return record + + def mark_processing(self, *, dedupe_key: str, session_id: str, message_id: str) -> DedupeWriteResult: + with self._lock: + data = self._load() + now_ms = _now_ms() + self._prune_unlocked(data, now_ms) + existing = data["records"].get(dedupe_key) + if existing is not None: + self._save(data) + return DedupeWriteResult(created=False, record=existing) + + record = { + "dedupe_key": dedupe_key, + "status": "processing", + "session_id": session_id, + "message_id": message_id, + "run_id": None, + "reply": None, + "error": None, + "created_at_ms": now_ms, + "updated_at_ms": now_ms, + } + data["records"][dedupe_key] = record + self._save(data) + return DedupeWriteResult(created=True, record=record) + + def mark_done( + self, + *, + dedupe_key: str, + run_id: str | None, + reply: str, + max_reply_chars: int, + ) -> None: + self._mark_result( + dedupe_key=dedupe_key, + status="done", + run_id=run_id, + reply=reply[: max(0, int(max_reply_chars))], + error=None, + ) + + def mark_error(self, *, dedupe_key: str, error: str, max_error_chars: int) -> None: + self._mark_result( + dedupe_key=dedupe_key, + status="error", + run_id=None, + reply=None, + error=error[: max(0, int(max_error_chars))], + ) + + def _mark_result( + self, + *, + dedupe_key: str, + status: str, + run_id: str | None, + reply: str | None, + error: str | None, + ) -> None: + with self._lock: + data = self._load() + record = data["records"].get(dedupe_key) + if record is None: + record = {"dedupe_key": dedupe_key, "created_at_ms": _now_ms()} + data["records"][dedupe_key] = record + record.update( + { + "status": status, + "run_id": run_id, + "reply": reply, + "error": error, + "updated_at_ms": _now_ms(), + } + ) + self._save(data) + + def _load(self) -> dict[str, Any]: + if not self.path.exists(): + return {"records": {}} + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"records": {}} + if not isinstance(data, dict) or not isinstance(data.get("records"), dict): + return {"records": {}} + return data + + def _save(self, data: dict[str, Any]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = self.path.with_name(f"{self.path.name}.tmp") + tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + tmp_path.replace(self.path) + + def _prune_unlocked(self, data: dict[str, Any], now_ms: int) -> None: + records = data.get("records", {}) + expired_before = now_ms - self.retention_ms + for key, record in list(records.items()): + updated_at_ms = int(record.get("updated_at_ms") or record.get("created_at_ms") or 0) + if updated_at_ms < expired_before: + records.pop(key, None) + + +class ChannelEventLog: + def __init__(self, path: Path) -> None: + self.path = path + self._lock = Lock() + + def record( + self, + *, + channel_id: str, + kind: str, + session_id: str | None = None, + message_id: str | None = None, + run_id: str | None = None, + status: str = "ok", + error: str | None = None, + text: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> dict[str, Any]: + entry = { + "event_id": uuid4().hex, + "channel_id": channel_id, + "kind": kind, + "session_id": session_id, + "message_id": message_id, + "run_id": run_id, + "status": status, + "error": error, + "text_preview": (text or "")[:120] if text else None, + "text_length": len(text) if text else 0, + "metadata": metadata or {}, + "created_at": _iso_now(), + } + with self._lock: + self.path.parent.mkdir(parents=True, exist_ok=True) + with self.path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(entry, ensure_ascii=False) + "\n") + return entry + + def recent(self, *, channel_id: str | None = None, limit: int = 100) -> list[dict[str, Any]]: + if not self.path.exists(): + return [] + lines = self.path.read_text(encoding="utf-8").splitlines() + items: list[dict[str, Any]] = [] + for line in reversed(lines): + try: + item = json.loads(line) + except json.JSONDecodeError: + continue + if channel_id and item.get("channel_id") != channel_id: + continue + items.append(item) + if len(items) >= max(1, int(limit)): + break + return list(reversed(items)) diff --git a/app-instance/backend/beaver/interfaces/channels/terminal_websocket.py b/app-instance/backend/beaver/interfaces/channels/terminal_websocket.py new file mode 100644 index 0000000..e13a846 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/terminal_websocket.py @@ -0,0 +1,301 @@ +"""Text-only terminal WebSocket channel adapter.""" + +from __future__ import annotations + +from collections.abc import Callable +from contextlib import suppress +from dataclasses import dataclass, field +from typing import Any + +from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage +from beaver.interfaces.channels.base import ChannelInboundSink + +try: + from fastapi import WebSocket + from starlette.websockets import WebSocketDisconnect +except ModuleNotFoundError: # pragma: no cover - import-only fallback + class WebSocketDisconnect(Exception): + """Fallback disconnect exception for skeleton import environments.""" + + class WebSocket: # type: ignore[override] + """Fallback websocket annotation shim.""" + + +def _clean(value: Any) -> str: + return str(value or "").strip() + + +@dataclass(slots=True) +class TerminalConnection: + websocket: WebSocket + peer_id: str + session_id: str + thread_id: str | None = None + user_id: str | None = None + device_name: str = "" + capabilities: list[str] = field(default_factory=list) + + +class TerminalWebSocketAdapter: + """Accept text terminal websocket frames and deliver final assistant replies.""" + + def __init__( + self, + *, + channel_id: str, + kind: str, + mode: str, + account_id: str, + display_name: str = "", + inbound_sink: ChannelInboundSink, + event_recorder: Callable[..., None] | None = None, + heartbeat_seconds: float = 30, + max_message_chars: int = 20000, + ) -> None: + self.channel_id = channel_id + self.kind = kind + self.mode = mode + self.account_id = account_id + self.display_name = display_name or channel_id + self.inbound_sink = inbound_sink + self.event_recorder = event_recorder + self.heartbeat_seconds = max(1.0, float(heartbeat_seconds)) + self.max_message_chars = max(1, int(max_message_chars)) + self.started = False + self._connections_by_session: dict[str, TerminalConnection] = {} + self._session_by_peer: dict[str, str] = {} + + async def start(self) -> None: + self.started = True + + async def stop(self) -> None: + self.started = False + for connection in list(self._connections_by_session.values()): + with suppress(Exception): + await connection.websocket.close(code=1001) + self._connections_by_session.clear() + self._session_by_peer.clear() + + def status_extra(self) -> dict[str, Any]: + return {"connected_peers": len(self._connections_by_session)} + + async def handle_websocket(self, websocket: WebSocket) -> None: + await websocket.accept() + connection: TerminalConnection | None = None + try: + while True: + try: + payload = await websocket.receive_json() + except WebSocketDisconnect: + break + except ValueError: + await websocket.send_json({"type": "error", "error": "Invalid websocket JSON payload"}) + continue + if not isinstance(payload, dict): + await websocket.send_json({"type": "error", "error": "Websocket payload must be a JSON object"}) + continue + + frame_type = _clean(payload.get("type")).lower() + if frame_type == "ping": + await websocket.send_json({"type": "pong"}) + continue + if frame_type == "connect": + connection = await self._handle_connect(websocket, payload, current=connection) + continue + if frame_type == "message": + if connection is None: + await websocket.send_json({"type": "error", "error": "connect is required before message"}) + continue + await self._handle_message(websocket, connection, payload) + continue + + await websocket.send_json( + { + "type": "error", + "error": f"Unsupported websocket frame type: {frame_type or ''}", + } + ) + finally: + if connection is not None: + self._remove_connection(connection) + self._record( + kind="terminal_disconnected", + session_id=connection.session_id, + metadata={"peer_id": connection.peer_id, "device_name": connection.device_name}, + ) + + async def _handle_connect( + self, + websocket: WebSocket, + payload: dict[str, Any], + *, + current: TerminalConnection | None, + ) -> TerminalConnection | None: + peer_id = _clean(payload.get("peer_id")) + if not peer_id: + await websocket.send_json({"type": "error", "error": "peer_id is required"}) + return current + + thread_id = _clean(payload.get("thread_id")) or None + user_id = _clean(payload.get("user_id")) or None + device_name = _clean(payload.get("device_name")) + capabilities = [str(item) for item in payload.get("capabilities") or [] if item is not None] + identity = ChannelIdentity( + channel_id=self.channel_id, + kind=self.kind, + account_id=self.account_id, + peer_id=peer_id, + thread_id=thread_id, + peer_type="terminal", + user_id=user_id, + ) + session_id = identity.session_id() + connection = TerminalConnection( + websocket=websocket, + peer_id=peer_id, + session_id=session_id, + thread_id=thread_id, + user_id=user_id, + device_name=device_name, + capabilities=capabilities, + ) + + if current is not None and current.session_id != session_id: + self._remove_connection(current) + old = self._connections_by_session.get(session_id) + if old is not None and old.websocket is not websocket: + with suppress(Exception): + await old.websocket.close(code=1000) + self._connections_by_session[session_id] = connection + self._session_by_peer[peer_id] = session_id + self._record( + kind="terminal_connected", + session_id=session_id, + metadata={"peer_id": peer_id, "device_name": device_name, "capabilities": capabilities}, + ) + await websocket.send_json( + { + "type": "connected", + "channel_id": self.channel_id, + "session_id": session_id, + } + ) + return connection + + async def _handle_message( + self, + websocket: WebSocket, + connection: TerminalConnection, + payload: dict[str, Any], + ) -> None: + message_id = _clean(payload.get("message_id")) + text = _clean(payload.get("text")) + if not message_id: + await websocket.send_json({"type": "error", "error": "message_id is required"}) + return + if not text: + await websocket.send_json({"type": "error", "error": "text is required"}) + return + if len(text) > self.max_message_chars: + await websocket.send_json( + { + "type": "error", + "error": f"text exceeds max_message_chars ({self.max_message_chars})", + } + ) + return + + thread_id = _clean(payload.get("thread_id")) or connection.thread_id + user_id = _clean(payload.get("user_id")) or connection.user_id + identity = ChannelIdentity( + channel_id=self.channel_id, + kind=self.kind, + account_id=self.account_id, + peer_id=connection.peer_id, + thread_id=thread_id, + peer_type="terminal", + user_id=user_id, + message_id=message_id, + ) + inbound = InboundMessage( + channel=self.channel_id, + content=text, + content_type="text", + user_id=user_id, + channel_identity=identity, + metadata={ + "terminal": { + "peer_id": connection.peer_id, + "device_name": connection.device_name, + "capabilities": connection.capabilities, + } + }, + ) + accept = await self.inbound_sink.accept_inbound(inbound) + ack: dict[str, Any] = { + "type": "ack", + "message_id": message_id, + "session_id": accept.session_id or identity.session_id(), + "accepted": accept.accepted, + } + if accept.duplicate: + ack["duplicate"] = True + ack["pending"] = accept.pending + record = accept.record or {} + if record.get("reply"): + ack["reply"] = record["reply"] + if accept.error or record.get("error"): + ack["error"] = accept.error or record.get("error") + await websocket.send_json(ack) + + async def send(self, message: OutboundMessage) -> None: + session_id = message.session_id + if not session_id and message.channel_identity is not None: + session_id = message.channel_identity.session_id() + connection = self._connections_by_session.get(session_id or "") + if connection is None: + message.metadata["delivery_status"] = "unclaimed" + return + + payload = { + "type": "message", + "role": "assistant", + "message_id": message.channel_identity.message_id if message.channel_identity else message.message_id, + "run_id": message.run_id, + "text": message.content, + "finish_reason": message.finish_reason, + } + try: + await connection.websocket.send_json(payload) + except Exception: + message.metadata["delivery_status"] = "unclaimed" + self._remove_connection(connection) + + def _remove_connection(self, connection: TerminalConnection) -> None: + current = self._connections_by_session.get(connection.session_id) + if current is connection: + self._connections_by_session.pop(connection.session_id, None) + if self._session_by_peer.get(connection.peer_id) == connection.session_id: + self._session_by_peer.pop(connection.peer_id, None) + + def _record( + self, + *, + kind: str, + session_id: str | None = None, + message_id: str | None = None, + status: str = "ok", + error: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> None: + if self.event_recorder is None: + return + self.event_recorder( + channel_id=self.channel_id, + kind=kind, + session_id=session_id, + message_id=message_id, + status=status, + error=error, + metadata=metadata, + ) diff --git a/app-instance/backend/beaver/interfaces/web/app.py b/app-instance/backend/beaver/interfaces/web/app.py index ce8e188..84707c5 100644 --- a/app-instance/backend/beaver/interfaces/web/app.py +++ b/app-instance/backend/beaver/interfaces/web/app.py @@ -19,6 +19,18 @@ from typing import Any from beaver.engine.providers.registry import PROVIDERS, find_by_name from beaver.foundation.config import default_config_path, load_config +from beaver.foundation.events import ChannelIdentity, InboundMessage +from beaver.interfaces.channels.runtime import ChannelRuntime +from beaver.interfaces.channels.connections import ( + ChannelConnectionStore, + ChannelConnectorRegistry, + ConnectorSidecarClient, + CredentialStore, + FeishuConnector, + MessageDedupeStore, + TelegramConnector, + WeixinConnector, +) from beaver.foundation.models import CronExecutionResult, CronRunRecord from beaver.integrations.mcp import MCPConnectionManager from beaver.services.agent_service import NOTIFICATION_SESSION_ID, AgentService @@ -53,6 +65,16 @@ from .schemas import ( WebErrorResponse, WebAgentConfigRequest, WebAgentConfigResponse, + WebChannelConfigRequest, + WebChannelConfigResponse, + WebChannelConnectionCreateRequest, + WebChannelConnectionResponse, + WebChannelConnectionUpdateRequest, + WebChannelValidationResponse, + WebConnectorBridgeEventRequest, + WebConnectorBridgeEventResponse, + WebConnectorSessionCreateRequest, + WebConnectorSessionResponse, WebProviderConfigRequest, WebProviderConfigResponse, WebStatusResponse, @@ -60,7 +82,7 @@ from .schemas import ( try: from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect - from fastapi.responses import Response + from fastapi.responses import JSONResponse, Response except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments def File(default: Any = None) -> Any: # type: ignore[override] return default @@ -94,6 +116,11 @@ except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only env self.media_type = media_type self.headers = headers or {} + class JSONResponse(Response): # type: ignore[override] + def __init__(self, content: Any, status_code: int = 200) -> None: + super().__init__(json.dumps(content).encode("utf-8"), media_type="application/json") + self.status_code = status_code + class WebSocketDisconnect(Exception): """Fallback websocket disconnect exception.""" @@ -183,7 +210,9 @@ async def _app_lifespan( owns_service = manage_service_lifecycle if manage_service_lifecycle is not None else service is None app.state.agent_service = attached_service app.state.cron_service = _build_cron_service(attached_service) if owns_service else None + app.state.channel_runtime = None started = False + channel_runtime: ChannelRuntime | None = None if owns_service: try: await attached_service.start() @@ -200,6 +229,29 @@ async def _app_lifespan( else: attached_service.close() raise + try: + loaded = attached_service.create_loop().boot() + app.state.channel_connection_workspace = loaded.workspace + connector_registry = _build_channel_connector_registry(loaded.workspace) + app.state.channel_connector_registry = connector_registry + connection_channels = await connector_registry.materialize_channel_configs() + runtime_channels = dict(loaded.config.channels) + runtime_channels.update(connection_channels) + channel_runtime = ChannelRuntime( + service=attached_service, + workspace=loaded.workspace, + channels=runtime_channels, + ) + app.state.channel_runtime = channel_runtime + await channel_runtime.start() + except BaseException: + if owns_service and started: + with suppress(BaseException): + await attached_service.shutdown( + timeout_seconds=shutdown_timeout_seconds, + force=shutdown_force, + ) + raise worker: SkillLearningWorker | None = None worker_task = None worker_config = SkillLearningWorkerConfig.from_env() @@ -216,6 +268,10 @@ async def _app_lifespan( try: yield finally: + runtime = getattr(app.state, "channel_runtime", None) + if isinstance(runtime, ChannelRuntime): + with suppress(BaseException): + await runtime.stop() cron_service = getattr(app.state, "cron_service", None) if isinstance(cron_service, CronService): cron_service.stop() @@ -283,6 +339,118 @@ def get_cron_service(request: Request) -> CronService: return service +def get_channel_runtime(request: Request) -> ChannelRuntime: + runtime = getattr(request.app.state, "channel_runtime", None) + if not isinstance(runtime, ChannelRuntime): + raise HTTPException(status_code=503, detail="Channel runtime is not running") + return runtime + + +def _connection_state_dir(workspace: Path) -> Path: + return Path(workspace) / "state" / "channel_connections" + + +def _channel_connection_workspace(request: Request) -> Path: + workspace = getattr(request.app.state, "channel_connection_workspace", None) + if workspace is not None: + return Path(workspace) + return Path(get_agent_service(request).loader.workspace) + + +def _message_dedupe_store(workspace: Path) -> MessageDedupeStore: + return MessageDedupeStore(_connection_state_dir(workspace) / "message_dedupe.json") + + +def _bridge_token() -> str: + return os.getenv("BEAVER_BRIDGE_TOKEN", "") + + +def _build_channel_connector_registry(workspace: Path) -> ChannelConnectorRegistry: + state_dir = _connection_state_dir(workspace) + connection_store = ChannelConnectionStore(state_dir / "connections.json") + credential_store = CredentialStore(state_dir / "credentials.json") + registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store) + registry.register( + TelegramConnector( + connection_store=connection_store, + credential_store=credential_store, + ) + ) + sidecar_base_url = os.getenv("EXTERNAL_CONNECTOR_BASE_URL", "http://external-connector:8787") + sidecar_token = os.getenv("EXTERNAL_CONNECTOR_TOKEN", "") + sidecar_client = ConnectorSidecarClient(base_url=sidecar_base_url, token=sidecar_token) + registry.register( + WeixinConnector( + connection_store=connection_store, + credential_store=credential_store, + sidecar_client=sidecar_client, + sidecar_base_url=sidecar_base_url, + ) + ) + registry.register( + FeishuConnector( + connection_store=connection_store, + credential_store=credential_store, + sidecar_client=sidecar_client, + sidecar_base_url=sidecar_base_url, + ) + ) + return registry + + +def get_channel_connector_registry(request: Request) -> ChannelConnectorRegistry: + registry = getattr(request.app.state, "channel_connector_registry", None) + if isinstance(registry, ChannelConnectorRegistry): + return registry + workspace = getattr(request.app.state, "channel_connection_workspace", None) + if workspace is None: + raise RuntimeError("Channel connector registry unavailable before service boot") + registry = _build_channel_connector_registry(workspace) + request.app.state.channel_connector_registry = registry + return registry + + +def _connection_response_view(connection: Any) -> dict[str, Any]: + view = connection.to_dict() + view.pop("credentials_ref", None) + view.pop("connector_ref", None) + view.pop("pairing_session_id", None) + return view + + +def _normalize_connection_config(config: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(config, dict): + return {} + return { + _camel_to_snake_text(str(key)): value + for key, value in config.items() + if str(key).strip() + } + + +def _camel_to_snake_text(value: str) -> str: + result: list[str] = [] + for char in value.strip(): + if char.isupper() and result: + result.append("_") + result.append(char.lower()) + return "".join(result) + + +def _self_restart_enabled() -> bool: + return os.getenv("BEAVER_ENABLE_SELF_RESTART", "1").strip() not in {"0", "false", "False"} + + +def _schedule_self_restart(delay_seconds: float = 0.75) -> None: + import threading + + def _exit_later() -> None: + time.sleep(delay_seconds) + os._exit(0) + + threading.Thread(target=_exit_later, daemon=True).start() + + def create_app( *, workspace: str | Path | None = None, @@ -380,10 +548,330 @@ def create_app( "temperature": agent_service.profile.temperature, "max_tool_iterations": agent_service.profile.max_tool_iterations, "providers": providers_status, - "channels": [{"name": "web", "enabled": True}], + "channels": get_channel_runtime(request).statuses(), + "runtime_controls": {"self_restart": _self_restart_enabled()}, "cron": cron_service.status(), } + @app.get("/api/channels") + async def list_channels(request: Request) -> list[dict[str, Any]]: + return get_channel_runtime(request).statuses() + + @app.get("/api/channel-connectors") + async def list_channel_connectors(request: Request) -> list[dict[str, str]]: + return get_channel_connector_registry(request).connectors() + + @app.get("/api/channel-connections") + async def list_channel_connections(request: Request) -> list[dict[str, Any]]: + registry = get_channel_connector_registry(request) + return [_connection_response_view(connection) for connection in registry.connection_store.list()] + + @app.post("/api/channel-connections", response_model=WebChannelConnectionResponse) + async def create_channel_connection( + request: Request, + payload: WebChannelConnectionCreateRequest, + ) -> WebChannelConnectionResponse: + registry = get_channel_connector_registry(request) + kind = _clean_text(payload.kind) + mode = _clean_text(payload.mode) + if not kind: + raise HTTPException(status_code=400, detail="Connection kind is required") + if not mode: + raise HTTPException(status_code=400, detail="Connection mode is required") + secrets_payload = payload.secrets or {} + secrets = {key: value for key, value in secrets_payload.items() if value} + credentials_ref = registry.credential_store.put(kind=kind, values=secrets) if secrets else None + connection = registry.connection_store.create( + kind=kind, + mode=mode, + display_name=_clean_text(payload.display_name) or kind, + account_id=_clean_text(payload.account_id) or "", + owner_user_id=_clean_text(payload.owner_user_id) or None, + auth_type=_clean_text(payload.auth_type) or "token", + credentials_ref=credentials_ref, + runtime_config=_normalize_connection_config(payload.config), + ) + return WebChannelConnectionResponse( + connection=_connection_response_view(connection), + credentials=registry.credential_store.redacted(credentials_ref), + ) + + @app.patch("/api/channel-connections/{connection_id}", response_model=WebChannelConnectionResponse) + async def update_channel_connection( + connection_id: str, + request: Request, + payload: WebChannelConnectionUpdateRequest, + ) -> WebChannelConnectionResponse: + registry = get_channel_connector_registry(request) + try: + connection = registry.connection_store.get(connection_id) + except KeyError: + raise HTTPException(status_code=404, detail="Channel connection not found") + if payload.display_name is not None: + connection.display_name = _clean_text(payload.display_name) or connection.display_name + if payload.account_id is not None: + connection.account_id = _clean_text(payload.account_id) or connection.account_id + if payload.config is not None: + connection.runtime_config = _normalize_connection_config(payload.config) + if payload.secrets: + secrets = {key: value for key, value in payload.secrets.items() if value} + if secrets: + # TODO: add credential GC when connection updates credentials. + connection.credentials_ref = registry.credential_store.put(kind=connection.kind, values=secrets) + connection = registry.connection_store.update(connection) + return WebChannelConnectionResponse( + connection=_connection_response_view(connection), + credentials=registry.credential_store.redacted(connection.credentials_ref), + ) + + @app.get("/api/channel-connections/{connection_id}", response_model=WebChannelConnectionResponse) + async def get_channel_connection(connection_id: str, request: Request) -> WebChannelConnectionResponse: + registry = get_channel_connector_registry(request) + try: + connection = registry.connection_store.get(connection_id) + except KeyError: + raise HTTPException(status_code=404, detail="Channel connection not found") + return WebChannelConnectionResponse( + connection=_connection_response_view(connection), + credentials=registry.credential_store.redacted(connection.credentials_ref), + ) + + @app.post("/api/channel-connections/{connection_id}/validate", response_model=WebChannelValidationResponse) + async def validate_channel_connection(connection_id: str, request: Request) -> WebChannelValidationResponse: + registry = get_channel_connector_registry(request) + try: + result = await registry.validate(connection_id) + connection = registry.connection_store.get(connection_id) + except KeyError: + raise HTTPException(status_code=404, detail="Channel connection not found") + return WebChannelValidationResponse( + ok=result.ok, + status=result.status, + account_id=result.account_id, + display_name=result.display_name, + error=result.error, + metadata=result.metadata, + connection=_connection_response_view(connection), + ) + + @app.post("/api/channel-connections/{connection_id}/revoke", response_model=WebChannelConnectionResponse) + async def revoke_channel_connection(connection_id: str, request: Request) -> WebChannelConnectionResponse: + registry = get_channel_connector_registry(request) + try: + await registry.revoke(connection_id) + connection = registry.connection_store.get(connection_id) + except KeyError: + raise HTTPException(status_code=404, detail="Channel connection not found") + return WebChannelConnectionResponse(connection=_connection_response_view(connection), credentials={}) + + @app.post("/api/channel-connector-sessions", response_model=WebConnectorSessionResponse) + async def start_channel_connector_session( + request: Request, + payload: WebConnectorSessionCreateRequest, + ) -> WebConnectorSessionResponse: + registry = get_channel_connector_registry(request) + kind = _clean_text(payload.kind) + try: + connector = registry.connector_for_kind(kind) + except KeyError: + raise HTTPException(status_code=404, detail="Connector not found") + start_session = getattr(connector, "start_session", None) + if start_session is None: + raise HTTPException(status_code=400, detail="Connector does not support sessions") + view = await start_session( + display_name=_clean_text(payload.display_name) or kind, + owner_user_id=_clean_text(payload.owner_user_id) or None, + options=payload.options, + ) + connection_id = _clean_text(view.get("connectionId")) + connection_view = None + if connection_id: + connection_view = _connection_response_view(registry.connection_store.get(connection_id)) + return WebConnectorSessionResponse(session=view, connection=connection_view) + + @app.get("/api/channel-connector-sessions/{session_id}", response_model=WebConnectorSessionResponse) + async def get_channel_connector_session(session_id: str, request: Request) -> WebConnectorSessionResponse: + registry = get_channel_connector_registry(request) + connection = next( + (item for item in registry.connection_store.list() if item.pairing_session_id == session_id), + None, + ) + if connection is None: + raise HTTPException(status_code=404, detail="Connector session not found") + connector = registry.connector_for_kind(connection.kind) + poll_session = getattr(connector, "poll_session", None) + if poll_session is None: + raise HTTPException(status_code=400, detail="Connector does not support sessions") + view = await poll_session(session_id) + connection = registry.connection_store.get(connection.connection_id) + if connection.status == "connected": + runtime = get_channel_runtime(request) + config = (await registry.materialize_channel_configs())[connection.channel_id] + await runtime.add_channel(connection.channel_id, config) + return WebConnectorSessionResponse(session=view, connection=_connection_response_view(connection)) + + @app.post("/api/channel-connector-bridge/events", response_model=WebConnectorBridgeEventResponse) + async def accept_connector_bridge_event( + request: Request, + payload: WebConnectorBridgeEventRequest, + authorization: str | None = Header(default=None), + ) -> Any: + expected = _bridge_token() + if not expected or authorization != f"Bearer {expected}": + raise HTTPException(status_code=401, detail="Invalid connector bridge token") + + registry = get_channel_connector_registry(request) + try: + connection = registry.connection_store.get(payload.connection_id) + except KeyError: + raise HTTPException(status_code=404, detail="Channel connection not found") + if connection.status == "revoked": + raise HTTPException(status_code=404, detail="Channel connection not found") + + store = _message_dedupe_store(_channel_connection_workspace(request)) + begin = store.begin( + connection_id=payload.connection_id, + event_id=payload.event_id, + delivery_attempt=payload.delivery_attempt, + ) + if not begin.should_process: + body = WebConnectorBridgeEventResponse( + accepted=begin.http_status == 200, + duplicate=True, + pending=begin.http_status == 409, + retryAfterSeconds=begin.retry_after_seconds, + ).model_dump(by_alias=True) + return JSONResponse(status_code=begin.http_status, content=body) + + runtime = get_channel_runtime(request) + identity = ChannelIdentity( + channel_id=payload.channel_id, + kind=payload.kind, + account_id=payload.account_id, + peer_id=payload.peer_id, + thread_id=payload.thread_id, + peer_type=payload.peer_type, + user_id=payload.user_id, + message_id=payload.message_id, + ) + inbound = InboundMessage( + channel=payload.channel_id, + content=payload.content, + content_type=payload.message_type, + channel_identity=identity, + user_id=payload.user_id, + message_id=payload.message_id, + metadata=dict(payload.metadata), + ) + result = await runtime.accept_inbound(inbound) + if result.accepted or result.duplicate: + store.complete(begin.dedupe_key, message_id=payload.message_id) + else: + store.fail(begin.dedupe_key, error=result.error or "runtime rejected bridge event") + return WebConnectorBridgeEventResponse( + accepted=result.accepted, + duplicate=result.duplicate, + pending=result.pending, + ) + + @app.get("/api/channels/{channel_id}/config") + async def get_channel_config(channel_id: str, request: Request) -> dict[str, Any]: + agent_service = get_agent_service(request) + config_path = agent_service.loader.config.config_path or default_config_path(workspace=agent_service.loader.workspace) + raw = _read_config_json(config_path) + channel = _ensure_dict(raw, "channels").get(channel_id) + if not isinstance(channel, dict): + raise HTTPException(status_code=404, detail="Channel not found") + return _channel_config_view(channel_id, channel) + + @app.post("/api/channels/{channel_id}/config", response_model=WebChannelConfigResponse) + async def update_channel_config( + channel_id: str, + request: Request, + payload: WebChannelConfigRequest, + ) -> WebChannelConfigResponse: + if not _clean_text(channel_id): + raise HTTPException(status_code=400, detail="Channel id is required") + kind = _clean_text(payload.kind) + mode = _clean_text(payload.mode) + if not kind: + raise HTTPException(status_code=400, detail="Channel kind is required") + if not mode: + raise HTTPException(status_code=400, detail="Channel mode is required") + + agent_service = get_agent_service(request) + config_path = agent_service.loader.config.config_path or default_config_path(workspace=agent_service.loader.workspace) + raw = _read_config_json(config_path) + channels = _ensure_dict(raw, "channels") + current = channels.get(channel_id) if isinstance(channels.get(channel_id), dict) else {} + current_secrets = current.get("secrets") if isinstance(current.get("secrets"), dict) else {} + next_secrets = dict(current_secrets) + for key, value in (payload.secrets or {}).items(): + cleaned_key = _clean_text(key) + cleaned_value = _clean_text(value) + if not cleaned_key or not cleaned_value: + continue + next_secrets[cleaned_key] = cleaned_value + + channel_payload: dict[str, Any] = { + "enabled": bool(payload.enabled), + "kind": kind, + "mode": mode, + "accountId": _clean_text(payload.account_id) or "", + "displayName": _clean_text(payload.display_name) or channel_id, + "config": payload.config or {}, + "secrets": next_secrets, + } + channels[channel_id] = channel_payload + _write_config_json(config_path, raw) + _reload_agent_config(agent_service, config_path) + return WebChannelConfigResponse( + ok=True, + channel_id=channel_id, + restart_required=True, + channel=_channel_config_view(channel_id, channel_payload), + ) + + @app.get("/api/channels/{channel_id}/events") + async def list_channel_events(channel_id: str, request: Request, limit: int = 100) -> list[dict[str, Any]]: + return get_channel_runtime(request).recent_events(channel_id, limit=limit) + + @app.post("/api/channels/{channel_id}/webhook") + async def post_channel_webhook(channel_id: str, request: Request) -> JSONResponse: + runtime = get_channel_runtime(request) + adapter = runtime.adapters.get(channel_id) + if adapter is None or not hasattr(adapter, "handle_webhook_payload"): + raise HTTPException(status_code=404, detail="Webhook channel not found") + payload = await request.json() + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Webhook payload must be a JSON object") + result = await adapter.handle_webhook_payload(payload) # type: ignore[attr-defined] + status_code = 202 if result.get("pending") else 200 + return JSONResponse(result, status_code=status_code) + + @app.websocket("/api/channels/{channel_id}/ws") + async def channel_websocket(websocket: WebSocket, channel_id: str) -> None: + runtime = getattr(websocket.app.state, "channel_runtime", None) + if not isinstance(runtime, ChannelRuntime): + await websocket.accept() + await websocket.send_json({"type": "error", "error": "Channel runtime is not running"}) + await websocket.close(code=1011) + return + adapter = runtime.adapters.get(channel_id) + if adapter is None or not hasattr(adapter, "handle_websocket"): + await websocket.accept() + await websocket.send_json({"type": "error", "error": "WebSocket channel not found"}) + await websocket.close(code=1008) + return + await adapter.handle_websocket(websocket) # type: ignore[attr-defined] + + @app.post("/api/runtime/restart") + async def restart_runtime() -> JSONResponse: + if not _self_restart_enabled(): + raise HTTPException(status_code=403, detail="Self restart is disabled") + _schedule_self_restart() + return JSONResponse({"ok": True, "restarting": True}, status_code=202) + @app.post("/api/auth/login") async def auth_login(request: Request, payload: dict[str, Any]) -> dict[str, Any]: username = _clean_text(payload.get("username")) @@ -3011,6 +3499,25 @@ def _mask_secret(value: str | None) -> str: return f"{secret[:4]}••••{secret[-4:]}" +def _channel_config_view(channel_id: str, data: dict[str, Any]) -> dict[str, Any]: + secrets_payload = data.get("secrets") if isinstance(data.get("secrets"), dict) else {} + config_payload = data.get("config") if isinstance(data.get("config"), dict) else {} + return { + "channel_id": channel_id, + "enabled": bool(data.get("enabled")), + "kind": _clean_text(data.get("kind")) or "", + "mode": _clean_text(data.get("mode")) or "webhook", + "account_id": _clean_text(data.get("accountId") or data.get("account_id")) or "", + "display_name": _clean_text(data.get("displayName") or data.get("display_name")) or channel_id, + "config": dict(config_payload), + "secrets": { + str(key): _mask_secret(str(value) if value is not None else None) + for key, value in secrets_payload.items() + if str(key).strip() + }, + } + + def _read_config_json(path: Path) -> dict[str, Any]: if not path.exists(): return {} @@ -3082,7 +3589,14 @@ def _reload_agent_config(agent_service: AgentService, config_path: Path) -> None old_manager = getattr(loaded, "mcp_manager", None) if old_manager is not None: async def _close_old_manager() -> None: - await old_manager.close() + try: + await old_manager.close() + except Exception: + # MCP transports may own anyio cancel scopes created by a + # previous request task. Config reload must not leak that + # cleanup failure as an unhandled background exception or + # knock the app out of running mode. + pass try: running_loop = asyncio.get_running_loop() diff --git a/app-instance/backend/beaver/interfaces/web/schemas/__init__.py b/app-instance/backend/beaver/interfaces/web/schemas/__init__.py index 150ef10..33f1b99 100644 --- a/app-instance/backend/beaver/interfaces/web/schemas/__init__.py +++ b/app-instance/backend/beaver/interfaces/web/schemas/__init__.py @@ -10,6 +10,16 @@ from .chat import ( WebErrorResponse, WebAgentConfigRequest, WebAgentConfigResponse, + WebChannelConfigRequest, + WebChannelConfigResponse, + WebChannelConnectionCreateRequest, + WebChannelConnectionResponse, + WebChannelConnectionUpdateRequest, + WebChannelValidationResponse, + WebConnectorBridgeEventRequest, + WebConnectorBridgeEventResponse, + WebConnectorSessionCreateRequest, + WebConnectorSessionResponse, WebProviderConfigRequest, WebProviderConfigResponse, WebProviderTarget, @@ -26,6 +36,16 @@ __all__ = [ "WebErrorResponse", "WebAgentConfigRequest", "WebAgentConfigResponse", + "WebChannelConfigRequest", + "WebChannelConfigResponse", + "WebChannelConnectionCreateRequest", + "WebChannelConnectionResponse", + "WebChannelConnectionUpdateRequest", + "WebChannelValidationResponse", + "WebConnectorBridgeEventRequest", + "WebConnectorBridgeEventResponse", + "WebConnectorSessionCreateRequest", + "WebConnectorSessionResponse", "WebProviderConfigRequest", "WebProviderConfigResponse", "WebProviderTarget", diff --git a/app-instance/backend/beaver/interfaces/web/schemas/chat.py b/app-instance/backend/beaver/interfaces/web/schemas/chat.py index bd6cd5d..a22cfc2 100644 --- a/app-instance/backend/beaver/interfaces/web/schemas/chat.py +++ b/app-instance/backend/beaver/interfaces/web/schemas/chat.py @@ -139,6 +139,113 @@ class WebProviderConfigResponse(BaseModel): enabled: bool +class WebChannelConfigRequest(BaseModel): + """Channel config update from the settings page.""" + + enabled: bool = False + kind: str + mode: str + account_id: str | None = None + display_name: str | None = None + config: dict[str, Any] = Field(default_factory=dict) + secrets: dict[str, str | None] = Field(default_factory=dict) + + +class WebChannelConfigResponse(BaseModel): + """Channel config update result.""" + + ok: bool + channel_id: str + restart_required: bool + channel: dict[str, Any] + + +class WebChannelConnectionCreateRequest(BaseModel): + """Create a channel connection from the setup UI.""" + + kind: str + mode: str + display_name: str | None = Field(default=None, alias="displayName") + owner_user_id: str | None = Field(default=None, alias="ownerUserId") + auth_type: str = Field(default="token", alias="authType") + account_id: str | None = Field(default=None, alias="accountId") + config: dict[str, Any] = Field(default_factory=dict) + secrets: dict[str, str | None] = Field(default_factory=dict) + + +class WebChannelConnectionResponse(BaseModel): + """Channel connection response with redacted credentials.""" + + connection: dict[str, Any] + credentials: dict[str, str] = Field(default_factory=dict) + + +class WebChannelConnectionUpdateRequest(BaseModel): + """Update editable channel connection setup fields.""" + + display_name: str | None = Field(default=None, alias="displayName") + account_id: str | None = Field(default=None, alias="accountId") + config: dict[str, Any] | None = None + secrets: dict[str, str | None] | None = None + + +class WebChannelValidationResponse(BaseModel): + """Connector validation response.""" + + ok: bool + status: str + account_id: str | None = None + display_name: str | None = None + error: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + connection: dict[str, Any] + + +class WebConnectorBridgeEventRequest(BaseModel): + """Inbound connector bridge event from the external sidecar.""" + + event_id: str = Field(alias="eventId") + timestamp: str + delivery_attempt: int = Field(default=1, alias="deliveryAttempt") + connection_id: str = Field(alias="connectionId") + channel_id: str = Field(alias="channelId") + kind: str + account_id: str = Field(alias="accountId") + peer_id: str = Field(alias="peerId") + peer_type: str = Field(default="unknown", alias="peerType") + user_id: str | None = Field(default=None, alias="userId") + thread_id: str | None = Field(default=None, alias="threadId") + message_id: str = Field(alias="messageId") + message_type: str = Field(default="text", alias="messageType") + content: str + metadata: dict[str, Any] = Field(default_factory=dict) + + +class WebConnectorBridgeEventResponse(BaseModel): + """Connector bridge event accept/dedupe response.""" + + accepted: bool + duplicate: bool = False + pending: bool = False + retry_after_seconds: int | None = Field(default=None, alias="retryAfterSeconds") + + +class WebConnectorSessionCreateRequest(BaseModel): + """Start a connector-managed onboarding session.""" + + kind: str + display_name: str | None = Field(default=None, alias="displayName") + owner_user_id: str | None = Field(default=None, alias="ownerUserId") + options: dict[str, Any] = Field(default_factory=dict) + + +class WebConnectorSessionResponse(BaseModel): + """Connector session view plus optional connection view.""" + + session: dict[str, Any] + connection: dict[str, Any] | None = None + + class WebAgentConfigRequest(BaseModel): """Agent runtime defaults update from the settings page.""" diff --git a/app-instance/backend/beaver/services/agent_service.py b/app-instance/backend/beaver/services/agent_service.py index 842c4b5..c2d1750 100644 --- a/app-instance/backend/beaver/services/agent_service.py +++ b/app-instance/backend/beaver/services/agent_service.py @@ -1237,17 +1237,19 @@ class AgentService: async def handle_inbound_message(self, inbound: InboundMessage) -> OutboundMessage: """把 bus inbound 映射成标准 runtime 调用,并返回结构化 outbound。""" + channel_identity = inbound.channel_identity try: result = await self.submit_direct( inbound.content, session_id=inbound.session_id, source=f"gateway:{inbound.channel}", - user_id=inbound.user_id, + user_id=inbound.user_id or (channel_identity.user_id if channel_identity else None), title=inbound.title, execution_context=inbound.execution_context, model=inbound.model, provider_name=inbound.provider_name, embedding_model=inbound.embedding_model, + channel_identity=channel_identity, ) except Exception as exc: return self.build_outbound_error( @@ -1283,6 +1285,8 @@ class AgentService: finish_reason=result.finish_reason, provider_name=result.provider_name, model=result.model, + content_type=inbound.content_type, + channel_identity=inbound.channel_identity, usage=dict(result.usage), metadata={ "inbound_metadata": dict(inbound.metadata), @@ -1308,6 +1312,8 @@ class AgentService: session_id=inbound.session_id, content=detail, finish_reason=finish_reason, + content_type=inbound.content_type, + channel_identity=inbound.channel_identity, metadata={"error": detail, "inbound_metadata": dict(inbound.metadata)}, ) diff --git a/app-instance/backend/pyproject.toml b/app-instance/backend/pyproject.toml index 4abada7..182357c 100644 --- a/app-instance/backend/pyproject.toml +++ b/app-instance/backend/pyproject.toml @@ -22,6 +22,23 @@ dependencies = [ dev = [ "pytest>=9.0.0,<10.0.0", ] +telegram = [ + "python-telegram-bot>=22.0,<23.0", +] +feishu = [ + "lark-oapi>=1.4.22,<2.0.0", +] +qqbot = [ + "aiohttp>=3.9.0,<4.0.0", +] +weixin = [ + "aiohttp>=3.9.0,<4.0.0", +] +channels = [ + "python-telegram-bot>=22.0,<23.0", + "lark-oapi>=1.4.22,<2.0.0", + "aiohttp>=3.9.0,<4.0.0", +] [project.scripts] beaver = "beaver.interfaces.cli.main:main" diff --git a/app-instance/backend/tests/unit/test_channel_connection_api.py b/app-instance/backend/tests/unit/test_channel_connection_api.py new file mode 100644 index 0000000..48eeb2f --- /dev/null +++ b/app-instance/backend/tests/unit/test_channel_connection_api.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from beaver.interfaces.web.app import create_app +from beaver.services.agent_service import AgentService + + +def test_channel_connection_api_creates_updates_lists_and_revokes(tmp_path) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + '{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path), + encoding="utf-8", + ) + service = AgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + + try: + with TestClient(app) as client: + created = client.post( + "/api/channel-connections", + json={ + "kind": "telegram", + "mode": "polling", + "displayName": "Telegram Main", + "authType": "token", + "secrets": {"botToken": "token-1"}, + "config": {"maxMessageChars": 4096, "requireMentionInGroups": True}, + }, + ) + assert created.status_code == 200 + body = created.json() + connection_id = body["connection"]["connection_id"] + assert body["connection"]["kind"] == "telegram" + assert body["connection"]["status"] == "draft" + assert "credentials_ref" not in body["connection"] + assert body["connection"]["runtime_config"] == { + "max_message_chars": 4096, + "require_mention_in_groups": True, + } + assert body["credentials"] == {"botToken": "***"} + + patched = client.patch( + f"/api/channel-connections/{connection_id}", + json={ + "displayName": "Telegram Ops", + "config": {"maxMessageChars": 2048}, + "secrets": {"botToken": "token-2"}, + }, + ) + assert patched.status_code == 200 + assert patched.json()["connection"]["display_name"] == "Telegram Ops" + assert patched.json()["connection"]["runtime_config"] == {"max_message_chars": 2048} + assert patched.json()["credentials"] == {"botToken": "***"} + + listed = client.get("/api/channel-connections") + assert listed.status_code == 200 + assert listed.json()[0]["connection_id"] == connection_id + assert "credentials_ref" not in listed.json()[0] + + revoked = client.post(f"/api/channel-connections/{connection_id}/revoke") + assert revoked.status_code == 200 + assert revoked.json()["connection"]["status"] == "revoked" + finally: + service.close() + + +def test_channel_connectors_api_lists_registered_connectors(tmp_path) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + '{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path), + encoding="utf-8", + ) + service = AgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + + try: + with TestClient(app) as client: + response = client.get("/api/channel-connectors") + finally: + service.close() + + assert response.status_code == 200 + assert response.json() == [{"kind": "feishu"}, {"kind": "telegram"}, {"kind": "weixin"}] diff --git a/app-instance/backend/tests/unit/test_channel_connection_store.py b/app-instance/backend/tests/unit/test_channel_connection_store.py new file mode 100644 index 0000000..76082f6 --- /dev/null +++ b/app-instance/backend/tests/unit/test_channel_connection_store.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from beaver.interfaces.channels.connections import ( + ChannelConnectionStore, + CredentialStore, + PairingTokenStore, +) + + +def test_channel_connection_store_creates_updates_lists_and_revokes(tmp_path) -> None: + store = ChannelConnectionStore(tmp_path / "connections.json") + + created = store.create( + kind="telegram", + mode="polling", + display_name="Telegram Main", + account_id="telegram:bot-main", + owner_user_id="user-1", + auth_type="token", + runtime_config={"max_message_chars": 4096}, + capabilities=["receive_text", "send_text"], + ) + updated = store.update_status(created.connection_id, status="connected", last_error=None) + revoked = store.revoke(created.connection_id) + + assert created.connection_id + assert created.channel_id.startswith("telegram-") + assert created.status == "draft" + assert updated.status == "connected" + assert revoked.status == "revoked" + assert store.get(created.connection_id).status == "revoked" + assert [item.connection_id for item in store.list()] == [created.connection_id] + + +def test_credential_store_saves_values_by_reference_and_redacts_views(tmp_path) -> None: + store = CredentialStore(tmp_path / "credentials.json") + + ref = store.put(kind="telegram", values={"botToken": "secret-token", "empty": ""}) + + assert ref.startswith("cred_") + assert store.get(ref) == {"botToken": "secret-token"} + assert store.redacted(ref) == {"botToken": "***"} + + +def test_pairing_token_store_uses_one_time_expiring_tokens(tmp_path) -> None: + store = PairingTokenStore(tmp_path / "pairing.json") + + session = store.create(kind="terminal", ttl_seconds=60, scope="channel:pair") + consumed = store.consume(session.token, expected_kind="terminal") + reused = store.consume(session.token, expected_kind="terminal") + + assert session.status == "pending" + assert consumed is not None + assert consumed.status == "consumed" + assert reused is None + + +def test_pairing_token_store_rejects_expired_tokens(tmp_path) -> None: + store = PairingTokenStore(tmp_path / "pairing.json") + + session = store.create(kind="weixin", ttl_seconds=-1, scope="channel:pair") + + assert store.consume(session.token, expected_kind="weixin") is None diff --git a/app-instance/backend/tests/unit/test_channel_connector_registry.py b/app-instance/backend/tests/unit/test_channel_connector_registry.py new file mode 100644 index 0000000..012cced --- /dev/null +++ b/app-instance/backend/tests/unit/test_channel_connector_registry.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import asyncio + +from beaver.foundation.config.schema import ChannelConfig +from beaver.interfaces.channels.connections import ( + ChannelConnectionStore, + ChannelConnectorRegistry, + ChannelRuntimeSpec, + CredentialStore, + ValidationResult, +) + + +class FakeConnector: + kind = "fake" + + def __init__(self) -> None: + self.validated: list[str] = [] + self.revoked: list[str] = [] + + async def validate(self, connection_id: str) -> ValidationResult: + self.validated.append(connection_id) + return ValidationResult(ok=True, status="connected", account_id="fake-account", display_name="Fake") + + async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec: + return ChannelRuntimeSpec( + channel_id="fake-channel", + kind="fake", + mode="webhook", + account_id="fake-account", + display_name="Fake", + config={"enabled": True}, + ) + + async def revoke(self, connection_id: str) -> None: + self.revoked.append(connection_id) + return None + + +def test_connector_registry_dispatches_by_kind(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + connector = FakeConnector() + registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store) + registry.register(connector) + + connection = connection_store.create( + kind="fake", + mode="webhook", + display_name="Fake", + account_id="fake-account", + owner_user_id=None, + auth_type="token", + ) + result = await registry.validate(connection.connection_id) + spec = await registry.materialize_runtime(connection.connection_id) + + assert result.ok is True + assert connector.validated == [connection.connection_id] + assert spec.channel_id == "fake-channel" + + asyncio.run(run()) + + +def test_connector_registry_materializes_channel_configs_with_credentials(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"}) + connection = connection_store.create( + kind="fake", + mode="webhook", + display_name="Connected", + account_id="connected", + owner_user_id=None, + auth_type="token", + credentials_ref=credentials_ref, + ) + connection_store.update_status(connection.connection_id, status="connected", last_error=None) + + class CredentialAwareConnector(FakeConnector): + async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec: + stored = connection_store.get(connection_id) + return ChannelRuntimeSpec( + channel_id="fake-channel", + kind="fake", + mode="webhook", + account_id="fake-account", + display_name="Fake", + config={"enabled": True}, + secrets_ref=stored.credentials_ref, + ) + + registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store) + registry.register(CredentialAwareConnector()) + + configs = await registry.materialize_channel_configs() + + assert isinstance(configs["fake-channel"], ChannelConfig) + assert configs["fake-channel"].enabled is True + assert configs["fake-channel"].secrets == {"botToken": "token-1"} + + asyncio.run(run()) + + +def test_connector_registry_materializes_only_connected_connections(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store) + registry.register(FakeConnector()) + + draft = connection_store.create( + kind="fake", + mode="webhook", + display_name="Draft", + account_id="draft", + owner_user_id=None, + auth_type="token", + ) + connected = connection_store.create( + kind="fake", + mode="webhook", + display_name="Connected", + account_id="connected", + owner_user_id=None, + auth_type="token", + ) + connection_store.update_status(connected.connection_id, status="connected", last_error=None) + + specs = await registry.materialize_connected_runtime_specs() + + assert [spec.channel_id for spec in specs] == ["fake-channel"] + assert connection_store.get(draft.connection_id).status == "draft" + + asyncio.run(run()) + + +def test_connector_registry_revoke_calls_connector_and_updates_store(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + connector = FakeConnector() + registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store) + registry.register(connector) + + connection = connection_store.create( + kind="fake", + mode="webhook", + display_name="Fake", + account_id="fake-account", + owner_user_id=None, + auth_type="token", + ) + connection_store.update_status(connection.connection_id, status="connected", last_error=None) + + await registry.revoke(connection.connection_id) + + assert connector.revoked == [connection.connection_id] + assert connection_store.get(connection.connection_id).status == "revoked" + + asyncio.run(run()) diff --git a/app-instance/backend/tests/unit/test_channel_runtime.py b/app-instance/backend/tests/unit/test_channel_runtime.py new file mode 100644 index 0000000..eff2e15 --- /dev/null +++ b/app-instance/backend/tests/unit/test_channel_runtime.py @@ -0,0 +1,414 @@ +import asyncio +import json + +from fastapi.testclient import TestClient + +from beaver.foundation.config.schema import ChannelConfig +from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage +from beaver.foundation.events import MessageBus +from beaver.interfaces.channels.generic_webhook import GenericWebhookAdapter +from beaver.interfaces.channels.runtime import ChannelRuntime +from beaver.interfaces.channels.state import ChannelDedupeStore, ChannelEventLog +from beaver.interfaces.web.app import _self_restart_enabled, create_app +from beaver.services.agent_service import AgentService + + +def test_channel_identity_builds_stable_session_id() -> None: + identity = ChannelIdentity( + channel_id="webhook-dev", + kind="webhook", + account_id="local", + peer_id="demo-user", + thread_id="main", + peer_type="dm", + message_id="msg-1", + ) + + assert identity.session_id() == "webhook-dev:local:demo-user:main" + assert identity.dedupe_key() == "webhook-dev:local:demo-user:main:msg-1" + + +def test_channel_identity_requires_routing_fields() -> None: + identity = ChannelIdentity(channel_id="webhook-dev", kind="webhook", account_id="", peer_id="demo") + + assert identity.validation_error() == "account_id is required" + + +def test_messages_carry_channel_identity() -> None: + identity = ChannelIdentity( + channel_id="webhook-dev", + kind="webhook", + account_id="local", + peer_id="demo-user", + message_id="msg-1", + ) + + inbound = InboundMessage(channel="webhook-dev", content="hello", channel_identity=identity) + outbound = OutboundMessage( + channel="webhook-dev", + content="ok", + session_id=identity.session_id(), + finish_reason="stop", + channel_identity=identity, + ) + + assert inbound.channel_identity is identity + assert outbound.channel_identity is identity + + +def test_dedupe_store_tracks_processing_and_done(tmp_path) -> None: + store = ChannelDedupeStore(tmp_path / "dedupe.json", retention_hours=48) + + created = store.mark_processing( + dedupe_key="webhook-dev:local:demo:msg-1", + session_id="webhook-dev:local:demo", + message_id="msg-1", + ) + duplicate = store.mark_processing( + dedupe_key="webhook-dev:local:demo:msg-1", + session_id="webhook-dev:local:demo", + message_id="msg-1", + ) + + assert created.created is True + assert duplicate.created is False + assert duplicate.record is not None + assert duplicate.record["status"] == "processing" + + store.mark_done( + dedupe_key="webhook-dev:local:demo:msg-1", + run_id="run-1", + reply="hello" * 10000, + max_reply_chars=20, + ) + + done = store.get("webhook-dev:local:demo:msg-1") + assert done is not None + assert done["status"] == "done" + assert done["reply"] == "hellohellohellohello" + + +def test_channel_event_log_writes_recent_events(tmp_path) -> None: + log = ChannelEventLog(tmp_path / "events.jsonl") + log.record( + channel_id="webhook-dev", + kind="inbound_accepted", + session_id="webhook-dev:local:demo", + message_id="msg-1", + status="ok", + text="hello world", + ) + + events = log.recent(channel_id="webhook-dev", limit=10) + + assert len(events) == 1 + assert events[0]["kind"] == "inbound_accepted" + assert events[0]["text_preview"] == "hello world" + assert "raw_channel_payload" not in json.dumps(events[0]) + + +class FakeAgentService: + is_running = True + + async def handle_inbound_message(self, inbound): + return OutboundMessage( + message_id=inbound.message_id, + channel=inbound.channel, + content=f"echo:{inbound.content}", + session_id=inbound.session_id, + finish_reason="stop", + run_id="run-1", + channel_identity=inbound.channel_identity, + ) + + +class SlowFakeAgentService(FakeAgentService): + async def handle_inbound_message(self, inbound): + await asyncio.sleep(0.05) + return await super().handle_inbound_message(inbound) + + +def test_channel_runtime_accept_inbound_normalizes_session_and_dedupes(tmp_path) -> None: + async def run() -> None: + bus = MessageBus() + runtime = ChannelRuntime( + service=FakeAgentService(), + bus=bus, + workspace=tmp_path, + channels={}, + ) + identity = ChannelIdentity( + channel_id="webhook-dev", + kind="webhook", + account_id="local", + peer_id="demo", + message_id="msg-1", + ) + result = await runtime.accept_inbound( + InboundMessage( + channel="webhook-dev", + content="hello", + session_id="wrong", + channel_identity=identity, + ) + ) + duplicate = await runtime.accept_inbound( + InboundMessage( + channel="webhook-dev", + content="hello", + channel_identity=identity, + ) + ) + + queued = await bus.consume_inbound() + assert result.accepted is True + assert queued.session_id == "webhook-dev:local:demo" + assert duplicate.accepted is False + assert duplicate.duplicate is True + + asyncio.run(run()) + + +def test_generic_webhook_adapter_waits_for_outbound_reply(tmp_path) -> None: + async def run() -> None: + bus = MessageBus() + runtime = ChannelRuntime( + service=FakeAgentService(), + bus=bus, + workspace=tmp_path, + channels={}, + ) + adapter = GenericWebhookAdapter( + channel_id="webhook-dev", + kind="webhook", + mode="webhook", + account_id="local", + display_name="Webhook Dev", + inbound_sink=runtime, + response_timeout_seconds=1, + ) + runtime.manager.register(adapter) + await runtime.start() + try: + response = await adapter.handle_webhook_payload( + { + "peer_id": "demo", + "message_id": "msg-1", + "text": "hello", + "peer_type": "dm", + } + ) + finally: + await runtime.stop() + + assert response["ok"] is True + assert response["reply"] == "echo:hello" + assert response["session_id"] == "webhook-dev:local:demo" + + asyncio.run(run()) + + +def test_generic_webhook_records_unclaimed_outbound_after_timeout(tmp_path) -> None: + async def run() -> None: + bus = MessageBus() + runtime = ChannelRuntime( + service=SlowFakeAgentService(), + bus=bus, + workspace=tmp_path, + channels={}, + ) + adapter = GenericWebhookAdapter( + channel_id="webhook-dev", + kind="webhook", + mode="webhook", + account_id="local", + display_name="Webhook Dev", + inbound_sink=runtime, + response_timeout_seconds=1, + ) + adapter.response_timeout_seconds = 0.01 + runtime.manager.register(adapter) + await runtime.start() + try: + response = await adapter.handle_webhook_payload( + { + "peer_id": "demo", + "message_id": "msg-1", + "text": "hello", + "peer_type": "dm", + } + ) + await asyncio.sleep(0.1) + events = runtime.recent_events("webhook-dev", limit=20) + finally: + await runtime.stop() + + assert response["pending"] is True + assert any(event["kind"] == "outbound_unclaimed" for event in events) + + asyncio.run(run()) + + +def test_channel_runtime_starts_enabled_generic_webhook_and_reports_status(tmp_path) -> None: + async def run() -> None: + runtime = ChannelRuntime( + service=FakeAgentService(), + workspace=tmp_path, + channels={ + "webhook-dev": ChannelConfig( + enabled=True, + kind="webhook", + mode="webhook", + account_id="local", + display_name="Webhook Dev", + config={"response_timeout_seconds": 1800}, + ), + "off": ChannelConfig( + enabled=False, + kind="webhook", + mode="webhook", + account_id="local", + ), + }, + ) + await runtime.start() + try: + statuses = runtime.statuses() + finally: + await runtime.stop() + + by_id = {item["channel_id"]: item for item in statuses} + assert by_id["webhook-dev"]["state"] == "running" + assert by_id["webhook-dev"]["webhook_url"] == "/api/channels/webhook-dev/webhook" + assert by_id["off"]["state"] == "disabled" + + asyncio.run(run()) + + +def test_channel_runtime_builds_platform_adapters_without_starting_networks(tmp_path) -> None: + runtime = ChannelRuntime( + service=FakeAgentService(), + workspace=tmp_path, + channels={}, + ) + + cases = { + "telegram-main": ChannelConfig(enabled=True, kind="telegram", mode="polling", account_id="bot-main"), + "feishu-main": ChannelConfig(enabled=True, kind="feishu", mode="websocket", account_id="tenant-main"), + "qq-main": ChannelConfig(enabled=True, kind="qqbot", mode="websocket", account_id="qq-main"), + "weixin-main": ChannelConfig(enabled=True, kind="weixin", mode="polling", account_id="wx-main"), + } + + for channel_id, cfg in cases.items(): + adapter = runtime._build_adapter(channel_id, cfg) + assert adapter.channel_id == channel_id + assert adapter.kind == cfg.kind + assert adapter.mode == cfg.mode + + +def test_channel_runtime_reports_platform_capabilities(tmp_path) -> None: + runtime = ChannelRuntime( + service=FakeAgentService(), + workspace=tmp_path, + channels={ + "telegram-main": ChannelConfig(enabled=True, kind="telegram", mode="polling", account_id="bot-main"), + "weixin-main": ChannelConfig(enabled=True, kind="weixin", mode="polling", account_id="wx-main"), + }, + ) + + by_id = {item["channel_id"]: item for item in runtime.statuses()} + + assert by_id["telegram-main"]["capabilities"] == [ + "receive_text", + "send_text", + "receive_media", + "groups", + ] + assert by_id["weixin-main"]["capabilities"] == [ + "receive_text", + "send_text", + "receive_media", + "direct_messages", + ] + + +def test_channel_runtime_platform_start_failure_does_not_stop_other_channels(tmp_path) -> None: + async def run() -> None: + runtime = ChannelRuntime( + service=FakeAgentService(), + workspace=tmp_path, + channels={ + "telegram-main": ChannelConfig( + enabled=True, + kind="telegram", + mode="polling", + account_id="bot-main", + secrets={}, + ), + "off": ChannelConfig( + enabled=False, + kind="weixin", + mode="polling", + account_id="wx-main", + ), + }, + ) + + await runtime.start() + try: + by_id = {item["channel_id"]: item for item in runtime.statuses()} + finally: + await runtime.stop() + + assert by_id["telegram-main"]["state"] == "error" + assert "botToken" in by_id["telegram-main"]["last_error"] + assert by_id["off"]["state"] == "disabled" + + asyncio.run(run()) + + +def test_web_app_status_exposes_configured_channel(tmp_path) -> None: + config_path = tmp_path / "config.json" + workspace = tmp_path / "workspace" + workspace.mkdir() + config_path.write_text( + json.dumps( + { + "agents": {"defaults": {"workspace": str(workspace), "model": "openai/gpt-5"}}, + "providers": {}, + "channels": { + "webhook-dev": { + "enabled": True, + "kind": "webhook", + "mode": "webhook", + "accountId": "local", + "displayName": "Webhook Dev", + } + }, + } + ), + encoding="utf-8", + ) + service = AgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + payload = client.get("/api/status").json() + + service.close() + assert payload["channels"][0]["channel_id"] == "webhook-dev" + assert payload["channels"][0]["state"] == "running" + assert payload["channels"][0]["webhook_url"] == "/api/channels/webhook-dev/webhook" + assert payload["runtime_controls"]["self_restart"] is True + + +def test_self_restart_env_defaults_enabled(monkeypatch) -> None: + monkeypatch.delenv("BEAVER_ENABLE_SELF_RESTART", raising=False) + + assert _self_restart_enabled() is True + + +def test_self_restart_env_can_disable(monkeypatch) -> None: + monkeypatch.setenv("BEAVER_ENABLE_SELF_RESTART", "0") + + assert _self_restart_enabled() is False diff --git a/app-instance/backend/tests/unit/test_channel_runtime_dynamic_channels.py b/app-instance/backend/tests/unit/test_channel_runtime_dynamic_channels.py new file mode 100644 index 0000000..0818556 --- /dev/null +++ b/app-instance/backend/tests/unit/test_channel_runtime_dynamic_channels.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import asyncio + +from beaver.foundation.config.schema import ChannelConfig +from beaver.foundation.events import MessageBus, OutboundMessage +from beaver.interfaces.channels.runtime import ChannelRuntime + + +class FakeService: + async def handle_inbound_message(self, inbound): + return OutboundMessage(channel=inbound.channel, content="ok", session_id=inbound.session_id, finish_reason="stop") + + +def test_runtime_add_channel_starts_new_channel_after_runtime_start(tmp_path) -> None: + async def run() -> None: + runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus()) + await runtime.start() + try: + await runtime.add_channel( + "webhook-dev", + ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct"), + ) + assert "webhook-dev" in runtime.adapters + assert runtime.states["webhook-dev"]["state"] == "running" + finally: + await runtime.stop() + + asyncio.run(run()) + + +def test_runtime_add_channel_noops_for_same_config(tmp_path) -> None: + async def run() -> None: + cfg = ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct") + runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus()) + await runtime.start() + try: + await runtime.add_channel("webhook-dev", cfg) + first = runtime.adapters["webhook-dev"] + await runtime.add_channel("webhook-dev", cfg) + assert runtime.adapters["webhook-dev"] is first + finally: + await runtime.stop() + + asyncio.run(run()) + + +def test_runtime_replacement_failure_keeps_old_channel(tmp_path) -> None: + async def run() -> None: + good = ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct") + bad = ChannelConfig(enabled=True, kind="missing", mode="http", account_id="acct") + runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus()) + await runtime.start() + try: + await runtime.add_channel("webhook-dev", good) + old = runtime.adapters["webhook-dev"] + try: + await runtime.add_channel("webhook-dev", bad) + except ValueError: + pass + else: + raise AssertionError("Expected ValueError") + assert runtime.adapters["webhook-dev"] is old + assert runtime.channel_configs["webhook-dev"] == good + assert runtime.states["webhook-dev"]["state"] == "running" + finally: + await runtime.stop() + + asyncio.run(run()) + + +def test_runtime_remove_channel_stops_and_unregisters(tmp_path) -> None: + async def run() -> None: + runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus()) + await runtime.start() + try: + await runtime.add_channel( + "webhook-dev", + ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct"), + ) + await runtime.remove_channel("webhook-dev") + assert "webhook-dev" not in runtime.adapters + assert "webhook-dev" not in runtime.manager.channels + assert runtime.states["webhook-dev"]["state"] == "removed" + finally: + await runtime.stop() + + asyncio.run(run()) + + +def test_runtime_builds_external_connector_channel(tmp_path, monkeypatch) -> None: + async def run() -> None: + monkeypatch.setenv("EXTERNAL_CONNECTOR_TOKEN", "connector-token") + runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus()) + await runtime.start() + try: + await runtime.add_channel( + "weixin-main", + ChannelConfig( + enabled=True, + kind="external_connector", + mode="http", + account_id="weixin:me", + display_name="Weixin Main", + config={ + "platformKind": "weixin", + "connectionId": "conn_1", + "sidecarBaseUrl": "http://external-connector:8787", + }, + ), + ) + adapter = runtime.adapters["weixin-main"] + assert adapter.kind == "external_connector" + assert adapter.mode == "http" + assert getattr(adapter, "platform_kind") == "weixin" + finally: + await runtime.stop() + + asyncio.run(run()) diff --git a/app-instance/backend/tests/unit/test_config_loader.py b/app-instance/backend/tests/unit/test_config_loader.py index ec46fa4..e877132 100644 --- a/app-instance/backend/tests/unit/test_config_loader.py +++ b/app-instance/backend/tests/unit/test_config_loader.py @@ -1,4 +1,5 @@ import json +import asyncio from fastapi.testclient import TestClient @@ -46,6 +47,44 @@ def test_load_config_reads_current_instance_shape(tmp_path) -> None: assert target["extra_headers"] == {"X-Test": "1"} +def test_config_loader_reads_channels(tmp_path) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "agents": {"defaults": {"model": "openai/gpt-5"}}, + "channels": { + "webhook-dev": { + "enabled": True, + "kind": "webhook", + "mode": "webhook", + "accountId": "local", + "displayName": "Webhook Dev", + "config": { + "responseTimeoutSeconds": 1800, + "dedupeRetentionHours": 48, + }, + "secrets": {"ignored_for_status": "secret-value"}, + } + }, + } + ), + encoding="utf-8", + ) + + config = load_config(config_path=config_path) + + channel = config.channels["webhook-dev"] + assert channel.enabled is True + assert channel.kind == "webhook" + assert channel.mode == "webhook" + assert channel.account_id == "local" + assert channel.display_name == "Webhook Dev" + assert channel.config["response_timeout_seconds"] == 1800 + assert channel.config["dedupe_retention_hours"] == 48 + assert channel.secrets == {"ignored_for_status": "secret-value"} + + def test_provider_resolution_ignores_custom_and_disabled_overrides(tmp_path) -> None: config_path = tmp_path / "config.json" config_path.write_text( @@ -163,6 +202,58 @@ def test_reload_agent_config_updates_booted_loop_config(tmp_path) -> None: service.close() +def test_reload_agent_config_keeps_running_service_when_old_mcp_close_fails(tmp_path) -> None: + async def run_case() -> None: + workspace = tmp_path / "workspace" + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "agents": {"defaults": {"workspace": str(workspace), "model": "old-model"}}, + "providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://old.example.com/v1"}}, + } + ), + encoding="utf-8", + ) + service = AgentService(config_path=config_path) + await service.start() + + class FailingMCPManager: + async def close(self) -> None: + raise RuntimeError("Attempted to exit cancel scope in a different task than it was entered in") + + loaded = service.create_loop().boot() + loaded.mcp_manager = FailingMCPManager() + config_path.write_text( + json.dumps( + { + "agents": {"defaults": {"workspace": str(workspace), "model": "new-model"}}, + "providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://new.example.com/v1"}}, + } + ), + encoding="utf-8", + ) + + loop = asyncio.get_running_loop() + unhandled: list[dict[str, object]] = [] + previous_handler = loop.get_exception_handler() + loop.set_exception_handler(lambda _loop, context: unhandled.append(context)) + try: + _reload_agent_config(service, config_path) + await asyncio.sleep(0) + + target = service.create_loop().boot().config.resolve_provider_target() + assert service.is_running is True + assert target["model"] == "new-model" + assert target["api_base"] == "https://new.example.com/v1" + assert unhandled == [] + finally: + loop.set_exception_handler(previous_handler) + await service.shutdown(force=True) + + asyncio.run(run_case()) + + def test_agent_defaults_include_runtime_controls(tmp_path) -> None: config_path = tmp_path / "config.json" config_path.write_text( @@ -245,6 +336,67 @@ def test_agent_config_api_accepts_zero_temperature_and_iterations(tmp_path) -> N service.close() +def test_channel_config_api_persists_and_masks_secrets(tmp_path) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "agents": {"defaults": {"model": "openai/gpt-5"}}, + "channels": { + "telegram-main": { + "enabled": False, + "kind": "telegram", + "mode": "polling", + "accountId": "bot-main", + "displayName": "Telegram Main", + "secrets": {"botToken": "1234567890abcdef"}, + "config": {"requireMentionInGroups": True}, + } + }, + } + ), + encoding="utf-8", + ) + service = AgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + before = client.get("/api/channels/telegram-main/config") + response = client.post( + "/api/channels/telegram-main/config", + json={ + "enabled": True, + "kind": "telegram", + "mode": "polling", + "account_id": "bot-main", + "display_name": "Telegram Primary", + "secrets": {"botToken": ""}, + "config": { + "requireMentionInGroups": False, + "allowFrom": ["1001", "1002"], + "maxMessageChars": 3000, + }, + }, + ) + + saved = json.loads(config_path.read_text(encoding="utf-8")) + channel = saved["channels"]["telegram-main"] + + assert before.status_code == 200 + assert before.json()["secrets"] == {"botToken": "1234••••cdef"} + assert response.status_code == 200 + assert response.json()["ok"] is True + assert response.json()["restart_required"] is True + assert response.json()["channel"]["display_name"] == "Telegram Primary" + assert response.json()["channel"]["secrets"] == {"botToken": "1234••••cdef"} + assert channel["enabled"] is True + assert channel["displayName"] == "Telegram Primary" + assert channel["secrets"]["botToken"] == "1234567890abcdef" + assert channel["config"]["allowFrom"] == ["1001", "1002"] + assert load_config(config_path=config_path).channels["telegram-main"].enabled is True + service.close() + + def test_openai_compatible_qwen_config_keeps_openai_provider() -> None: bundle = make_provider_bundle( model="qwen-plus", diff --git a/app-instance/backend/tests/unit/test_connector_message_dedupe_store.py b/app-instance/backend/tests/unit/test_connector_message_dedupe_store.py new file mode 100644 index 0000000..4df0294 --- /dev/null +++ b/app-instance/backend/tests/unit/test_connector_message_dedupe_store.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from beaver.interfaces.channels.connections import MessageDedupeStore + + +def test_message_dedupe_store_completes_and_dedupes_completed(tmp_path) -> None: + store = MessageDedupeStore(tmp_path / "message_dedupe.json") + + first = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1) + store.complete(first.dedupe_key, message_id="msg_1") + duplicate = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2) + + assert first.should_process is True + assert duplicate.should_process is False + assert duplicate.status == "completed" + assert duplicate.http_status == 200 + + +def test_message_dedupe_store_returns_conflict_for_active_processing(tmp_path) -> None: + store = MessageDedupeStore(tmp_path / "message_dedupe.json", processing_ttl_seconds=60) + + store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1) + duplicate = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2) + + assert duplicate.should_process is False + assert duplicate.status == "processing" + assert duplicate.http_status == 409 + assert duplicate.retry_after_seconds == 5 + + +def test_message_dedupe_store_reprocesses_stale_processing(tmp_path) -> None: + store = MessageDedupeStore(tmp_path / "message_dedupe.json", processing_ttl_seconds=0) + + store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1) + stale = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2) + + assert stale.should_process is True + assert stale.status == "processing" + assert stale.record.delivery_attempts == 2 + + +def test_message_dedupe_store_reprocesses_failed_records(tmp_path) -> None: + store = MessageDedupeStore(tmp_path / "message_dedupe.json") + + first = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1) + store.fail(first.dedupe_key, error="runtime rejected") + retry = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2) + + assert retry.should_process is True + assert retry.record.delivery_attempts == 2 + assert retry.record.last_error is None diff --git a/app-instance/backend/tests/unit/test_external_connector_bridge_api.py b/app-instance/backend/tests/unit/test_external_connector_bridge_api.py new file mode 100644 index 0000000..91d20c7 --- /dev/null +++ b/app-instance/backend/tests/unit/test_external_connector_bridge_api.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from beaver.interfaces.channels.connections import ChannelConnectionStore +from beaver.interfaces.web.app import create_app +from beaver.services.agent_service import AgentService + + +def _app(tmp_path, monkeypatch): + monkeypatch.setenv("BEAVER_BRIDGE_TOKEN", "bridge-token") + config_path = tmp_path / "config.json" + config_path.write_text( + '{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path), + encoding="utf-8", + ) + service = AgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + return app, service + + +def _connected_connection(tmp_path): + state_dir = tmp_path / "state" / "channel_connections" + store = ChannelConnectionStore(state_dir / "connections.json") + connection = store.create( + kind="weixin", + mode="sidecar", + display_name="Weixin Main", + account_id="weixin:me", + owner_user_id=None, + auth_type="connector_session", + ) + store.update_status(connection.connection_id, status="connected", last_error=None) + return connection + + +def _payload(connection, *, event_id: str = "evt-1", delivery_attempt: int = 1) -> dict: + return { + "eventId": event_id, + "timestamp": "2026-06-02T09:30:00Z", + "deliveryAttempt": delivery_attempt, + "connectionId": connection.connection_id, + "channelId": connection.channel_id, + "kind": "weixin", + "accountId": "weixin:me", + "peerId": "peer-1", + "peerType": "dm", + "userId": "sender-1", + "threadId": None, + "messageId": "msg-1", + "messageType": "text", + "content": "hello", + "metadata": {}, + } + + +def test_bridge_endpoint_accepts_valid_event(tmp_path, monkeypatch) -> None: + app, service = _app(tmp_path, monkeypatch) + try: + with TestClient(app) as client: + connection = _connected_connection(tmp_path) + response = client.post( + "/api/channel-connector-bridge/events", + headers={"Authorization": "Bearer bridge-token"}, + json=_payload(connection), + ) + assert response.status_code == 200 + assert response.json()["accepted"] is True + finally: + service.close() + + +def test_bridge_endpoint_rejects_invalid_token(tmp_path, monkeypatch) -> None: + app, service = _app(tmp_path, monkeypatch) + try: + with TestClient(app) as client: + connection = _connected_connection(tmp_path) + response = client.post( + "/api/channel-connector-bridge/events", + headers={"Authorization": "Bearer wrong"}, + json=_payload(connection), + ) + assert response.status_code == 401 + finally: + service.close() + + +def test_bridge_endpoint_dedupes_repeated_event(tmp_path, monkeypatch) -> None: + app, service = _app(tmp_path, monkeypatch) + try: + with TestClient(app) as client: + connection = _connected_connection(tmp_path) + first = client.post( + "/api/channel-connector-bridge/events", + headers={"Authorization": "Bearer bridge-token"}, + json=_payload(connection), + ) + second = client.post( + "/api/channel-connector-bridge/events", + headers={"Authorization": "Bearer bridge-token"}, + json=_payload(connection, delivery_attempt=2), + ) + assert first.status_code == 200 + assert second.status_code in {200, 409} + assert second.json()["duplicate"] is True + finally: + service.close() diff --git a/app-instance/backend/tests/unit/test_external_connector_channel.py b/app-instance/backend/tests/unit/test_external_connector_channel.py new file mode 100644 index 0000000..3c00772 --- /dev/null +++ b/app-instance/backend/tests/unit/test_external_connector_channel.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import asyncio + +from beaver.foundation.events import ChannelIdentity, OutboundMessage +from beaver.interfaces.channels.external_connector import ExternalConnectorChannel, _request_id + + +class FakeSidecarClient: + def __init__(self) -> None: + self.sent: list[dict] = [] + + async def send(self, payload: dict) -> dict: + self.sent.append(payload) + return {"ok": True, "providerMessageId": "provider-1"} + + +def test_external_connector_channel_sends_with_target_and_request_id() -> None: + async def run() -> None: + client = FakeSidecarClient() + channel = ExternalConnectorChannel( + channel_id="weixin-main", + platform_kind="weixin", + connection_id="conn_1", + account_id="weixin:me", + display_name="Weixin Main", + sidecar_client=client, + ) + message = OutboundMessage( + channel="weixin-main", + content="reply", + session_id="s1", + finish_reason="stop", + message_id="out-msg-1", + channel_identity=ChannelIdentity( + channel_id="weixin-main", + kind="weixin", + account_id="weixin:me", + peer_id="peer-1", + peer_type="dm", + thread_id=None, + user_id="sender-1", + message_id="in-msg-1", + ), + metadata={"inbound_metadata": {"contextToken": "ctx-1"}}, + ) + + await channel.send(message) + + assert client.sent == [ + { + "requestId": "out_weixin-main:s1:out-msg-1", + "connectionId": "conn_1", + "channelId": "weixin-main", + "kind": "weixin", + "target": {"peerId": "peer-1", "peerType": "dm", "threadId": None}, + "content": "reply", + "metadata": {"inboundMessageId": "in-msg-1", "sessionId": "s1", "contextToken": "ctx-1"}, + } + ] + + asyncio.run(run()) + + +def test_external_connector_request_id_falls_back_when_message_id_is_none_or_blank() -> None: + identity = ChannelIdentity( + channel_id="weixin-main", + kind="weixin", + account_id="weixin:me", + peer_id="peer-1", + peer_type="dm", + message_id="in-msg-1", + ) + first = OutboundMessage( + channel="weixin-main", + content="same reply", + session_id="s1", + finish_reason="stop", + message_id=None, # type: ignore[arg-type] + channel_identity=identity, + ) + second = OutboundMessage( + channel="weixin-main", + content="same reply", + session_id="s1", + finish_reason="stop", + message_id="", + channel_identity=identity, + ) + + assert _request_id(first) == _request_id(second) + assert _request_id(first).startswith("out_weixin-main:s1:") + + +def test_external_connector_channel_requires_identity() -> None: + async def run() -> None: + channel = ExternalConnectorChannel( + channel_id="weixin-main", + platform_kind="weixin", + connection_id="conn_1", + account_id="weixin:me", + display_name="Weixin Main", + sidecar_client=FakeSidecarClient(), + ) + message = OutboundMessage(channel="weixin-main", content="reply", session_id="s1", finish_reason="stop") + + try: + await channel.send(message) + except ValueError as exc: + assert "channel_identity is required" in str(exc) + else: + raise AssertionError("Expected ValueError") + + asyncio.run(run()) diff --git a/app-instance/backend/tests/unit/test_external_sidecar_connectors.py b/app-instance/backend/tests/unit/test_external_sidecar_connectors.py new file mode 100644 index 0000000..40ed8a0 --- /dev/null +++ b/app-instance/backend/tests/unit/test_external_sidecar_connectors.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import asyncio + +from fastapi.testclient import TestClient + +from beaver.interfaces.channels.connections import ( + ChannelConnectionStore, + ChannelConnectorRegistry, + CredentialStore, + FeishuConnector, + WeixinConnector, +) +from beaver.interfaces.web.app import create_app +from beaver.services.agent_service import AgentService + + +class FakeSidecarClient: + def __init__(self) -> None: + self.sessions: dict[str, dict] = {} + self.started: list[dict] = [] + self.logged_out: list[str] = [] + + async def start_session(self, payload: dict) -> dict: + self.started.append(payload) + session = { + "sessionId": "cs_1", + "kind": payload["kind"], + "status": "qr_ready", + "qrImage": "data:image/png;base64,abc", + "accountId": None, + "displayName": None, + "metadata": {}, + } + self.sessions["cs_1"] = session + return session + + async def get_session(self, session_id: str) -> dict: + return self.sessions[session_id] + + async def logout(self, connection_id: str) -> dict: + self.logged_out.append(connection_id) + return {"ok": True} + + +def test_weixin_connector_starts_connector_session(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + client = FakeSidecarClient() + connector = WeixinConnector( + connection_store=connection_store, + credential_store=credential_store, + sidecar_client=client, + sidecar_base_url="http://external-connector:8787", + ) + + view = await connector.start_session(display_name="Weixin Main", owner_user_id="user-1", options={}) + + assert view["sessionId"] == "cs_1" + assert view["connectionId"].startswith("conn_") + assert client.started[0]["kind"] == "weixin" + assert client.started[0]["connectionId"].startswith("conn_") + assert connection_store.list()[0].kind == "weixin" + assert connection_store.list()[0].status == "pairing" + + asyncio.run(run()) + + +def test_weixin_connector_poll_connected_materializes_external_runtime(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + client = FakeSidecarClient() + connector = WeixinConnector( + connection_store=connection_store, + credential_store=credential_store, + sidecar_client=client, + sidecar_base_url="http://external-connector:8787", + ) + await connector.start_session(display_name="Weixin Main", owner_user_id=None, options={}) + connection = connection_store.list()[0] + client.sessions["cs_1"] = { + "sessionId": "cs_1", + "kind": "weixin", + "status": "connected", + "accountId": "weixin:me", + "displayName": "Me", + "metadata": {"stateRef": "state-1"}, + } + + result = await connector.poll_session("cs_1") + updated = connection_store.get(connection.connection_id) + spec = await connector.materialize_runtime(connection.connection_id) + + assert result["status"] == "connected" + assert updated.status == "connected" + assert updated.account_id == "weixin:me" + assert spec.kind == "external_connector" + assert spec.mode == "http" + assert spec.config["platformKind"] == "weixin" + assert spec.config["sidecarBaseUrl"] == "http://external-connector:8787" + + asyncio.run(run()) + + +def test_feishu_connector_uses_feishu_kind(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + client = FakeSidecarClient() + connector = FeishuConnector( + connection_store=connection_store, + credential_store=credential_store, + sidecar_client=client, + sidecar_base_url="http://external-connector:8787", + ) + + await connector.start_session(display_name="Feishu Main", owner_user_id=None, options={"domain": "feishu"}) + + assert client.started[0]["kind"] == "feishu" + assert client.started[0]["options"] == {"domain": "feishu"} + + asyncio.run(run()) + + +def test_connector_session_api_starts_and_polls_connected_session(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("EXTERNAL_CONNECTOR_TOKEN", "connector-token") + config_path = tmp_path / "config.json" + config_path.write_text( + '{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path), + encoding="utf-8", + ) + service = AgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + client = FakeSidecarClient() + + try: + with TestClient(app) as http: + state_dir = tmp_path / "state" / "channel_connections" + connection_store = ChannelConnectionStore(state_dir / "connections.json") + credential_store = CredentialStore(state_dir / "credentials.json") + registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store) + registry.register( + WeixinConnector( + connection_store=connection_store, + credential_store=credential_store, + sidecar_client=client, + sidecar_base_url="http://external-connector:8787", + ) + ) + app.state.channel_connector_registry = registry + + started = http.post( + "/api/channel-connector-sessions", + json={"kind": "weixin", "displayName": "Weixin Main", "options": {}}, + ) + session_id = started.json()["session"]["sessionId"] + connection_id = started.json()["connection"]["connection_id"] + client.sessions[session_id] = { + "sessionId": session_id, + "kind": "weixin", + "status": "connected", + "accountId": "weixin:me", + "displayName": "Me", + "metadata": {}, + } + polled = http.get(f"/api/channel-connector-sessions/{session_id}") + + assert started.status_code == 200 + assert polled.status_code == 200 + assert polled.json()["connection"]["status"] == "connected" + assert connection_store.get(connection_id).status == "connected" + assert polled.json()["connection"]["channel_id"] in app.state.channel_runtime.adapters + finally: + service.close() diff --git a/app-instance/backend/tests/unit/test_feishu_channel_adapter.py b/app-instance/backend/tests/unit/test_feishu_channel_adapter.py new file mode 100644 index 0000000..54a25bb --- /dev/null +++ b/app-instance/backend/tests/unit/test_feishu_channel_adapter.py @@ -0,0 +1,154 @@ +import asyncio + +from beaver.foundation.events import OutboundMessage +from beaver.interfaces.channels.platforms.feishu import FeishuAdapter + + +class FakeSink: + def __init__(self) -> None: + self.messages = [] + + async def accept_inbound(self, message): + self.messages.append(message) + + +class FakeFeishuClient: + def __init__(self) -> None: + self.sent = [] + + async def send_text(self, *, receive_id_type: str, receive_id: str, text: str): + self.sent.append({"receive_id_type": receive_id_type, "receive_id": receive_id, "text": text}) + + +def test_feishu_normalizes_direct_text_event() -> None: + async def run() -> None: + sink = FakeSink() + adapter = FeishuAdapter( + channel_id="feishu-main", + kind="feishu", + mode="websocket", + account_id="tenant-main", + display_name=None, + inbound_sink=sink, + secrets={"appId": "app", "appSecret": "secret"}, + config={}, + client=FakeFeishuClient(), + ) + + await adapter.handle_event_payload( + { + "event": { + "message": { + "message_id": "m1", + "chat_id": "oc_chat", + "chat_type": "p2p", + "message_type": "text", + "content": "{\"text\":\"hello\"}", + }, + "sender": {"sender_id": {"open_id": "ou_user"}}, + } + } + ) + + message = sink.messages[0] + assert message.content == "hello" + assert message.session_id == "feishu-main:tenant-main:oc_chat" + assert message.channel_identity.peer_type == "dm" + assert message.channel_identity.user_id == "ou_user" + + asyncio.run(run()) + + +def test_feishu_group_mention_gate() -> None: + async def run() -> None: + sink = FakeSink() + adapter = FeishuAdapter( + channel_id="feishu-main", + kind="feishu", + mode="websocket", + account_id="tenant-main", + display_name=None, + inbound_sink=sink, + secrets={"appId": "app", "appSecret": "secret"}, + config={"requireMentionInGroups": True, "botOpenId": "ou_bot"}, + client=FakeFeishuClient(), + ) + + await adapter.handle_event_payload( + { + "event": { + "message": { + "message_id": "m1", + "chat_id": "oc_group", + "chat_type": "group", + "message_type": "text", + "content": "{\"text\":\"hello\"}", + "mentions": [], + }, + "sender": {"sender_id": {"open_id": "ou_user"}}, + } + } + ) + await adapter.handle_event_payload( + { + "event": { + "message": { + "message_id": "m2", + "chat_id": "oc_group", + "chat_type": "group", + "message_type": "text", + "content": "{\"text\":\"hello\"}", + "mentions": [{"id": {"open_id": "ou_bot"}}], + }, + "sender": {"sender_id": {"open_id": "ou_user"}}, + } + } + ) + + assert len(sink.messages) == 1 + + asyncio.run(run()) + + +def test_feishu_sends_text_to_chat_id() -> None: + async def run() -> None: + sink = FakeSink() + client = FakeFeishuClient() + adapter = FeishuAdapter( + channel_id="feishu-main", + kind="feishu", + mode="websocket", + account_id="tenant-main", + display_name=None, + inbound_sink=sink, + secrets={"appId": "app", "appSecret": "secret"}, + config={}, + client=client, + ) + await adapter.handle_event_payload( + { + "event": { + "message": { + "message_id": "m1", + "chat_id": "oc_chat", + "chat_type": "p2p", + "message_type": "text", + "content": "{\"text\":\"hello\"}", + }, + "sender": {"sender_id": {"open_id": "ou_user"}}, + } + } + ) + await adapter.send( + OutboundMessage( + channel="feishu-main", + content="ok", + session_id=sink.messages[0].session_id, + finish_reason="stop", + channel_identity=sink.messages[0].channel_identity, + ) + ) + + assert client.sent == [{"receive_id_type": "chat_id", "receive_id": "oc_chat", "text": "ok"}] + + asyncio.run(run()) diff --git a/app-instance/backend/tests/unit/test_gateway_channels.py b/app-instance/backend/tests/unit/test_gateway_channels.py index 2fbc1da..d230581 100644 --- a/app-instance/backend/tests/unit/test_gateway_channels.py +++ b/app-instance/backend/tests/unit/test_gateway_channels.py @@ -2,9 +2,10 @@ import asyncio from dataclasses import dataclass, field from typing import Any -from beaver.foundation.events import InboundMessage, MessageBus +from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage from beaver.interfaces.channels import ChannelManager, MemoryChannelAdapter from beaver.interfaces.gateway.main import run_gateway +from beaver.interfaces.channels.runtime import ChannelRuntime from beaver.services.agent_service import AgentService @@ -52,22 +53,15 @@ class InvalidService: is_running = True -def test_gateway_routes_memory_channel_roundtrip() -> None: +def test_gateway_routes_memory_channel_roundtrip(tmp_path) -> None: async def run() -> None: bus = MessageBus() - channel = MemoryChannelAdapter(bus) - stop_event = asyncio.Event() - task = asyncio.create_task( - run_gateway( - service=FakeService(), - manage_service_lifecycle=False, - bus=bus, - channels=[channel], - stop_event=stop_event, - ) - ) + runtime = ChannelRuntime(service=FakeService(), bus=bus, channels={}, workspace=tmp_path) + channel = MemoryChannelAdapter(runtime) + runtime.manager.register(channel) + await runtime.start() - await channel.publish_text("hello", session_id="s1") + await channel.publish_text("hello", peer_id="s1", message_id="m1") for _ in range(40): if channel.sent_messages: break @@ -76,38 +70,73 @@ def test_gateway_routes_memory_channel_roundtrip() -> None: assert channel.sent_messages message = channel.sent_messages[0] assert message.content == "echo:hello" - assert message.session_id == "s1" + assert message.session_id == "memory-dev:memory:s1" assert message.finish_reason == "stop" assert message.metadata["task_id"] == "task-1" assert message.metadata["task_status"] == "awaiting_acceptance" assert message.metadata["evidence_status"] == "recorded" assert message.metadata["validation_result"] is None - stop_event.set() - await asyncio.wait_for(task, timeout=2) + await runtime.stop() asyncio.run(run()) -def test_gateway_delivers_cancelled_outbound_to_channel() -> None: +def test_channel_manager_dispatches_by_channel_id() -> None: + class CaptureChannel: + channel_id = "webhook-dev" + kind = "webhook" + mode = "webhook" + + def __init__(self) -> None: + self.sent = [] + + async def start(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def send(self, message: Any) -> None: + self.sent.append(message) + async def run() -> None: bus = MessageBus() - channel = MemoryChannelAdapter(bus) - stop_event = asyncio.Event() - task = asyncio.create_task( - run_gateway( - service=SlowService(), - manage_service_lifecycle=False, - bus=bus, - channels=[channel], - stop_event=stop_event, + channel = CaptureChannel() + manager = ChannelManager(bus) + manager.register(channel) + await bus.publish_outbound( + OutboundMessage( + channel="webhook-dev", + content="ok", + session_id="webhook-dev:local:demo", + finish_reason="stop", ) ) - - await channel.publish_text("slow", session_id="s1") - await asyncio.sleep(0.05) + stop_event = asyncio.Event() stop_event.set() - await asyncio.wait_for(task, timeout=3) + + await manager.dispatch_outbound(stop_event) + + assert channel.sent[0].content == "ok" + + asyncio.run(run()) + + +def test_gateway_delivers_cancelled_outbound_to_channel(tmp_path) -> None: + async def run() -> None: + bus = MessageBus() + runtime = ChannelRuntime(service=SlowService(), bus=bus, channels={}, workspace=tmp_path) + channel = MemoryChannelAdapter(runtime) + runtime.manager.register(channel) + await runtime.start() + + await channel.publish_text("slow", peer_id="s1", message_id="m1") + for _ in range(40): + if any(event["kind"] == "direct_run_started" for event in runtime.events.recent(limit=20)): + break + await asyncio.sleep(0.05) + await runtime.stop() assert channel.sent_messages assert channel.sent_messages[0].finish_reason == "cancelled" @@ -118,13 +147,27 @@ def test_gateway_delivers_cancelled_outbound_to_channel() -> None: def test_gateway_rejects_channel_manager_and_channels_together() -> None: async def run() -> None: bus = MessageBus() + class CaptureChannel: + channel_id = "memory-dev" + kind = "memory" + mode = "webhook" + + async def start(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def send(self, message: Any) -> None: + pass + try: await run_gateway( service=FakeService(), manage_service_lifecycle=False, bus=bus, channel_manager=ChannelManager(bus), - channels=[MemoryChannelAdapter(bus)], + channels=[CaptureChannel()], stop_event=asyncio.Event(), ) except ValueError as exc: @@ -212,10 +255,16 @@ def test_channel_manager_keeps_unknown_channel_outbound_undeliverable() -> None: asyncio.run(run()) -def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None: +def test_memory_channel_adapts_payload_to_channel_identity_session_id(tmp_path) -> None: async def run() -> None: bus = MessageBus() - channel = MemoryChannelAdapter(bus, name="telegram") + runtime = ChannelRuntime(service=FakeService(), bus=bus, channels={}, workspace=tmp_path) + channel = MemoryChannelAdapter( + runtime, + channel_id="telegram-main", + kind="telegram", + account_id="bot-main", + ) inbound = await channel.publish_external_text( "hello", chat_id="chat-1", @@ -225,8 +274,10 @@ def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None: queued = await bus.consume_inbound() assert queued is inbound - assert queued.channel == "telegram" - assert queued.session_id == "telegram:chat-1" + assert queued.channel == "telegram-main" + assert queued.session_id == "telegram-main:bot-main:chat-1" + assert queued.channel_identity is not None + assert queued.channel_identity.kind == "telegram" assert queued.metadata["chat_id"] == "chat-1" assert queued.metadata["message_id"] == "message-1" assert queued.metadata["raw_channel_payload"] == {"platform": "telegram", "text": "hello"} @@ -236,7 +287,9 @@ def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None: def test_channel_manager_start_cancellation_rolls_back_started_channels() -> None: class StartedChannel: - name = "started" + channel_id = "started" + kind = "memory" + mode = "webhook" def __init__(self, bus: MessageBus) -> None: self.bus = bus @@ -252,7 +305,9 @@ def test_channel_manager_start_cancellation_rolls_back_started_channels() -> Non pass class BlockingChannel: - name = "blocking" + channel_id = "blocking" + kind = "memory" + mode = "webhook" def __init__(self, bus: MessageBus) -> None: self.bus = bus diff --git a/app-instance/backend/tests/unit/test_imports.py b/app-instance/backend/tests/unit/test_imports.py index b0eef33..9dac5a5 100644 --- a/app-instance/backend/tests/unit/test_imports.py +++ b/app-instance/backend/tests/unit/test_imports.py @@ -6,6 +6,34 @@ from beaver.interfaces.web.app import create_app from beaver.interfaces.web.schemas import WebChatRequest, WebChatResponse +def test_platform_channel_modules_import_without_live_clients() -> None: + from beaver.interfaces.channels.platforms.feishu import FeishuAdapter + from beaver.interfaces.channels.platforms.qqbot import QQBotAdapter + from beaver.interfaces.channels.platforms.telegram import TelegramAdapter + from beaver.interfaces.channels.platforms.weixin import WeixinAdapter + + assert FeishuAdapter.KIND == "feishu" + assert QQBotAdapter.KIND == "qqbot" + assert TelegramAdapter.KIND == "telegram" + assert WeixinAdapter.KIND == "weixin" + + +def test_platform_channel_optional_extras_are_declared() -> None: + import tomllib + from pathlib import Path + + pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml" + data = tomllib.loads(pyproject.read_text(encoding="utf-8")) + extras = data["project"]["optional-dependencies"] + + assert "python-telegram-bot>=22.0,<23.0" in extras["telegram"] + assert "lark-oapi>=1.4.22,<2.0.0" in extras["feishu"] + assert "aiohttp>=3.9.0,<4.0.0" in extras["qqbot"] + assert "aiohttp>=3.9.0,<4.0.0" in extras["weixin"] + assert "python-telegram-bot>=22.0,<23.0" in extras["channels"] + assert "lark-oapi>=1.4.22,<2.0.0" in extras["channels"] + + def test_agent_loop_boots(tmp_path) -> None: loop = AgentLoop(loader=EngineLoader(workspace=tmp_path)) loaded = loop.boot() @@ -32,10 +60,14 @@ def test_message_bus_imports() -> None: def test_channel_imports() -> None: bus = MessageBus() - channel = MemoryChannelAdapter(bus) + class Sink: + async def accept_inbound(self, message): + await bus.publish_inbound(message) + + channel = MemoryChannelAdapter(Sink()) manager = ChannelManager(bus) manager.register(channel) - assert manager.channels["memory"] is channel + assert manager.channels["memory-dev"] is channel def test_web_schema_imports() -> None: diff --git a/app-instance/backend/tests/unit/test_platform_channel_helpers.py b/app-instance/backend/tests/unit/test_platform_channel_helpers.py new file mode 100644 index 0000000..44defce --- /dev/null +++ b/app-instance/backend/tests/unit/test_platform_channel_helpers.py @@ -0,0 +1,66 @@ +from beaver.foundation.events import ChannelIdentity, OutboundMessage +from beaver.interfaces.channels.platforms.base import ( + chunk_text, + compact_media_summary, + config_bool, + config_list, + outbound_target, +) + + +def test_config_helpers_normalize_common_values() -> None: + assert config_bool({"enabled": "true"}, "enabled", default=False) is True + assert config_bool({"enabled": "0"}, "enabled", default=True) is False + assert config_list({"allowFrom": "u1,u2"}, "allowFrom") == ["u1", "u2"] + assert config_list({"allowFrom": ["u1", 2]}, "allowFrom") == ["u1", "2"] + + +def test_chunk_text_preserves_order_and_limit() -> None: + chunks = chunk_text("abcdef", max_chars=2) + + assert chunks == ["ab", "cd", "ef"] + + +def test_outbound_target_prefers_channel_identity() -> None: + identity = ChannelIdentity( + channel_id="telegram-main", + kind="telegram", + account_id="bot-main", + peer_id="chat-1", + thread_id="topic-1", + peer_type="group", + user_id="user-1", + ) + message = OutboundMessage( + channel="telegram-main", + content="ok", + session_id="ignored", + finish_reason="stop", + channel_identity=identity, + ) + + target = outbound_target(message) + + assert target.peer_id == "chat-1" + assert target.thread_id == "topic-1" + assert target.peer_type == "group" + assert target.user_id == "user-1" + + +def test_outbound_target_falls_back_to_session_id() -> None: + message = OutboundMessage( + channel="telegram-main", + content="ok", + session_id="telegram-main:bot-main:chat-1:topic-1", + finish_reason="stop", + ) + + target = outbound_target(message) + + assert target.peer_id == "chat-1" + assert target.thread_id == "topic-1" + + +def test_compact_media_summary_mentions_attachment_type() -> None: + assert compact_media_summary("photo", file_name="cat.png") == "[photo: cat.png]" + assert compact_media_summary("document") == "[document]" diff --git a/app-instance/backend/tests/unit/test_qqbot_channel_adapter.py b/app-instance/backend/tests/unit/test_qqbot_channel_adapter.py new file mode 100644 index 0000000..2eb3455 --- /dev/null +++ b/app-instance/backend/tests/unit/test_qqbot_channel_adapter.py @@ -0,0 +1,143 @@ +import asyncio + +from beaver.foundation.events import OutboundMessage +from beaver.interfaces.channels.platforms.qqbot import QQBotAdapter + + +class FakeSink: + def __init__(self) -> None: + self.messages = [] + + async def accept_inbound(self, message): + self.messages.append(message) + + +class FakeQQBotClient: + def __init__(self) -> None: + self.sent = [] + + async def send_text(self, *, peer_type: str, peer_id: str, content: str, message_id: str | None): + self.sent.append( + { + "peer_type": peer_type, + "peer_id": peer_id, + "content": content, + "message_id": message_id, + } + ) + + +def test_qqbot_normalizes_private_c2c_message() -> None: + async def run() -> None: + sink = FakeSink() + adapter = QQBotAdapter( + channel_id="qq-main", + kind="qqbot", + mode="websocket", + account_id="qq-bot", + display_name=None, + inbound_sink=sink, + secrets={"appId": "app", "clientSecret": "secret"}, + config={}, + client=FakeQQBotClient(), + ) + + await adapter.handle_event_payload( + { + "t": "C2C_MESSAGE_CREATE", + "d": { + "id": "m1", + "author": {"user_openid": "u1"}, + "content": "hello", + }, + } + ) + + message = sink.messages[0] + assert message.content == "hello" + assert message.session_id == "qq-main:qq-bot:u1" + assert message.channel_identity.peer_type == "dm" + assert message.channel_identity.user_id == "u1" + + asyncio.run(run()) + + +def test_qqbot_normalizes_group_message() -> None: + async def run() -> None: + sink = FakeSink() + adapter = QQBotAdapter( + channel_id="qq-main", + kind="qqbot", + mode="websocket", + account_id="qq-bot", + display_name=None, + inbound_sink=sink, + secrets={"appId": "app", "clientSecret": "secret"}, + config={}, + client=FakeQQBotClient(), + ) + + await adapter.handle_event_payload( + { + "t": "GROUP_AT_MESSAGE_CREATE", + "d": { + "id": "m2", + "group_openid": "g1", + "author": {"member_openid": "u1"}, + "content": "hello group", + }, + } + ) + + message = sink.messages[0] + assert message.session_id == "qq-main:qq-bot:g1" + assert message.channel_identity.peer_type == "group" + assert message.channel_identity.user_id == "u1" + + asyncio.run(run()) + + +def test_qqbot_sends_reply_with_original_message_id() -> None: + async def run() -> None: + sink = FakeSink() + client = FakeQQBotClient() + adapter = QQBotAdapter( + channel_id="qq-main", + kind="qqbot", + mode="websocket", + account_id="qq-bot", + display_name=None, + inbound_sink=sink, + secrets={"appId": "app", "clientSecret": "secret"}, + config={}, + client=client, + ) + await adapter.handle_event_payload( + { + "t": "GROUP_AT_MESSAGE_CREATE", + "d": { + "id": "m2", + "group_openid": "g1", + "author": {"member_openid": "u1"}, + "content": "hello group", + }, + } + ) + await adapter.send( + OutboundMessage( + channel="qq-main", + content="ok", + session_id=sink.messages[0].session_id, + finish_reason="stop", + channel_identity=sink.messages[0].channel_identity, + ) + ) + + assert client.sent[0] == { + "peer_type": "group", + "peer_id": "g1", + "content": "ok", + "message_id": "m2", + } + + asyncio.run(run()) diff --git a/app-instance/backend/tests/unit/test_telegram_channel_adapter.py b/app-instance/backend/tests/unit/test_telegram_channel_adapter.py new file mode 100644 index 0000000..65f2f10 --- /dev/null +++ b/app-instance/backend/tests/unit/test_telegram_channel_adapter.py @@ -0,0 +1,141 @@ +import asyncio + +from beaver.foundation.events import OutboundMessage +from beaver.interfaces.channels.platforms.telegram import TelegramAdapter + + +class FakeSink: + def __init__(self) -> None: + self.messages = [] + + async def accept_inbound(self, message): + self.messages.append(message) + + +class FakeTelegramClient: + def __init__(self) -> None: + self.sent = [] + + async def send_message(self, **kwargs): + self.sent.append(kwargs) + + +def test_telegram_normalizes_private_text_message() -> None: + async def run() -> None: + sink = FakeSink() + adapter = TelegramAdapter( + channel_id="telegram-main", + kind="telegram", + mode="polling", + account_id="bot-main", + display_name=None, + inbound_sink=sink, + secrets={"botToken": "x"}, + config={}, + client=FakeTelegramClient(), + ) + + await adapter.handle_update_payload( + { + "message": { + "message_id": 100, + "text": "hello", + "chat": {"id": 200, "type": "private"}, + "from": {"id": 300, "username": "ivan"}, + } + } + ) + + message = sink.messages[0] + assert message.channel == "telegram-main" + assert message.content == "hello" + assert message.session_id == "telegram-main:bot-main:200" + assert message.channel_identity.peer_type == "dm" + assert message.channel_identity.user_id == "300" + assert message.channel_identity.message_id == "100" + + asyncio.run(run()) + + +def test_telegram_group_requires_mention_when_configured() -> None: + async def run() -> None: + sink = FakeSink() + adapter = TelegramAdapter( + channel_id="telegram-main", + kind="telegram", + mode="polling", + account_id="bot-main", + display_name=None, + inbound_sink=sink, + secrets={"botToken": "x"}, + config={"requireMentionInGroups": True, "botUsername": "beaver_bot"}, + client=FakeTelegramClient(), + ) + + await adapter.handle_update_payload( + { + "message": { + "message_id": 101, + "text": "hello group", + "chat": {"id": -20, "type": "group"}, + "from": {"id": 300}, + } + } + ) + await adapter.handle_update_payload( + { + "message": { + "message_id": 102, + "text": "@beaver_bot hello", + "chat": {"id": -20, "type": "group"}, + "from": {"id": 300}, + } + } + ) + + assert len(sink.messages) == 1 + assert sink.messages[0].content == "hello" + + asyncio.run(run()) + + +def test_telegram_sends_chunked_reply_to_identity_target() -> None: + async def run() -> None: + sink = FakeSink() + client = FakeTelegramClient() + adapter = TelegramAdapter( + channel_id="telegram-main", + kind="telegram", + mode="polling", + account_id="bot-main", + display_name=None, + inbound_sink=sink, + secrets={"botToken": "x"}, + config={"maxMessageChars": 3}, + client=client, + ) + await adapter.handle_update_payload( + { + "message": { + "message_id": 100, + "text": "hello", + "chat": {"id": 200, "type": "private"}, + "from": {"id": 300}, + } + } + ) + + await adapter.send( + OutboundMessage( + channel="telegram-main", + content="abcdef", + session_id=sink.messages[0].session_id, + finish_reason="stop", + channel_identity=sink.messages[0].channel_identity, + ) + ) + + assert [item["text"] for item in client.sent] == ["abc", "def"] + assert client.sent[0]["chat_id"] == "200" + + asyncio.run(run()) diff --git a/app-instance/backend/tests/unit/test_telegram_channel_connector.py b/app-instance/backend/tests/unit/test_telegram_channel_connector.py new file mode 100644 index 0000000..ed0e822 --- /dev/null +++ b/app-instance/backend/tests/unit/test_telegram_channel_connector.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import asyncio + +from beaver.interfaces.channels.connections import ( + ChannelConnectionStore, + CredentialStore, + TelegramConnector, +) + + +class FakeTelegramClient: + async def get_me(self): + return {"id": 12345, "username": "beaver_bot", "first_name": "Beaver"} + + +class BrokenTelegramClient: + async def get_me(self): + raise RuntimeError("invalid token") + + +def test_telegram_connector_validates_token_and_updates_connection(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"}) + connection = connection_store.create( + kind="telegram", + mode="polling", + display_name="Telegram Main", + account_id="", + owner_user_id="user-1", + auth_type="token", + credentials_ref=credentials_ref, + runtime_config={"max_message_chars": 4096}, + ) + connector = TelegramConnector( + connection_store=connection_store, + credential_store=credential_store, + client_factory=lambda token: FakeTelegramClient(), + ) + + result = await connector.validate(connection.connection_id) + updated = connection_store.get(connection.connection_id) + + assert result.ok is True + assert result.status == "connected" + assert result.account_id == "telegram:12345" + assert updated.account_id == "telegram:12345" + assert updated.display_name == "Beaver (@beaver_bot)" + assert updated.capabilities == ["receive_text", "send_text", "receive_media", "groups"] + + asyncio.run(run()) + + +def test_telegram_connector_materializes_runtime_spec(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"}) + connection = connection_store.create( + kind="telegram", + mode="polling", + display_name="Telegram Main", + account_id="telegram:12345", + owner_user_id=None, + auth_type="token", + credentials_ref=credentials_ref, + runtime_config={"max_message_chars": 4096, "require_mention_in_groups": True}, + ) + connection_store.update_status(connection.connection_id, status="connected", last_error=None) + connector = TelegramConnector( + connection_store=connection_store, + credential_store=credential_store, + client_factory=lambda token: FakeTelegramClient(), + ) + + spec = await connector.materialize_runtime(connection.connection_id) + + assert spec.channel_id == connection.channel_id + assert spec.kind == "telegram" + assert spec.mode == "polling" + assert spec.account_id == "telegram:12345" + assert spec.config["max_message_chars"] == 4096 + assert spec.config["require_mention_in_groups"] is True + assert spec.secrets_ref == credentials_ref + + asyncio.run(run()) + + +def test_telegram_connector_validation_failure_sets_error_status(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + credentials_ref = credential_store.put(kind="telegram", values={"botToken": "bad-token"}) + connection = connection_store.create( + kind="telegram", + mode="polling", + display_name="Telegram Main", + account_id="", + owner_user_id=None, + auth_type="token", + credentials_ref=credentials_ref, + ) + connector = TelegramConnector( + connection_store=connection_store, + credential_store=credential_store, + client_factory=lambda token: BrokenTelegramClient(), + ) + + result = await connector.validate(connection.connection_id) + + assert result.ok is False + assert result.status == "error" + assert "invalid token" in (result.error or "") + + asyncio.run(run()) + + +def test_telegram_connector_revoke_leaves_store_status_to_registry(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + connection = connection_store.create( + kind="telegram", + mode="polling", + display_name="Telegram Main", + account_id="telegram:12345", + owner_user_id=None, + auth_type="token", + ) + connection_store.update_status(connection.connection_id, status="connected", last_error=None) + connector = TelegramConnector( + connection_store=connection_store, + credential_store=credential_store, + client_factory=lambda token: FakeTelegramClient(), + ) + + await connector.revoke(connection.connection_id) + + assert connection_store.get(connection.connection_id).status == "connected" + + asyncio.run(run()) diff --git a/app-instance/backend/tests/unit/test_terminal_websocket_channel.py b/app-instance/backend/tests/unit/test_terminal_websocket_channel.py new file mode 100644 index 0000000..0246805 --- /dev/null +++ b/app-instance/backend/tests/unit/test_terminal_websocket_channel.py @@ -0,0 +1,243 @@ +import asyncio +import json +import time +from pathlib import Path + +from fastapi.testclient import TestClient + +from beaver.foundation.events import InboundMessage, OutboundMessage +from beaver.interfaces.web.app import create_app +from beaver.services.agent_service import AgentService + + +class TerminalFakeAgentService(AgentService): + def __init__(self, *, config_path: Path, delay_seconds: float = 0.0) -> None: + super().__init__(config_path=config_path) + self.delay_seconds = delay_seconds + self.inbound_calls: list[InboundMessage] = [] + + async def handle_inbound_message(self, inbound: InboundMessage) -> OutboundMessage: + self.inbound_calls.append(inbound) + if self.delay_seconds: + await asyncio.sleep(self.delay_seconds) + return OutboundMessage( + message_id=inbound.message_id, + channel=inbound.channel, + content=f"echo:{inbound.content}", + session_id=inbound.session_id, + finish_reason="stop", + run_id="run-1", + channel_identity=inbound.channel_identity, + ) + + +def write_terminal_config(tmp_path: Path) -> Path: + workspace = tmp_path / "workspace" + workspace.mkdir() + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "agents": {"defaults": {"workspace": str(workspace), "model": "openai/gpt-5"}}, + "providers": {}, + "channels": { + "terminal-dev": { + "enabled": True, + "kind": "terminal", + "mode": "websocket", + "accountId": "local", + "displayName": "Terminal Dev", + "config": {"heartbeatSeconds": 30, "maxMessageChars": 20000}, + } + }, + } + ), + encoding="utf-8", + ) + return config_path + + +def test_terminal_websocket_connect_ping_and_message_roundtrip(tmp_path: Path) -> None: + config_path = write_terminal_config(tmp_path) + service = TerminalFakeAgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket: + websocket.send_json( + { + "type": "connect", + "peer_id": "device-001", + "device_name": "desk-terminal", + "capabilities": ["text"], + } + ) + assert websocket.receive_json() == { + "type": "connected", + "channel_id": "terminal-dev", + "session_id": "terminal-dev:local:device-001", + } + + websocket.send_json({"type": "ping"}) + assert websocket.receive_json() == {"type": "pong"} + + websocket.send_json( + { + "type": "message", + "message_id": "device-001-000001", + "text": "hello", + } + ) + assert websocket.receive_json() == { + "type": "ack", + "message_id": "device-001-000001", + "session_id": "terminal-dev:local:device-001", + "accepted": True, + } + reply = websocket.receive_json() + + service.close() + assert reply == { + "type": "message", + "role": "assistant", + "message_id": "device-001-000001", + "run_id": "run-1", + "text": "echo:hello", + "finish_reason": "stop", + } + assert len(service.inbound_calls) == 1 + inbound = service.inbound_calls[0] + assert inbound.channel == "terminal-dev" + assert inbound.content == "hello" + assert inbound.content_type == "text" + assert inbound.session_id == "terminal-dev:local:device-001" + assert inbound.channel_identity is not None + assert inbound.channel_identity.peer_id == "device-001" + assert inbound.channel_identity.peer_type == "terminal" + assert inbound.channel_identity.message_id == "device-001-000001" + + +def test_terminal_websocket_rejects_message_before_connect(tmp_path: Path) -> None: + config_path = write_terminal_config(tmp_path) + service = TerminalFakeAgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket: + websocket.send_json({"type": "message", "message_id": "m1", "text": "hello"}) + assert websocket.receive_json() == { + "type": "error", + "error": "connect is required before message", + } + websocket.send_json({"type": "ping"}) + assert websocket.receive_json() == {"type": "pong"} + + service.close() + assert service.inbound_calls == [] + + +def test_terminal_websocket_unknown_frame_keeps_connection_open(tmp_path: Path) -> None: + config_path = write_terminal_config(tmp_path) + service = TerminalFakeAgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket: + websocket.send_json({"type": "example"}) + assert websocket.receive_json() == { + "type": "error", + "error": "Unsupported websocket frame type: example", + } + websocket.send_json({"type": "ping"}) + assert websocket.receive_json() == {"type": "pong"} + + service.close() + + +def test_terminal_websocket_validates_message_fields(tmp_path: Path) -> None: + config_path = write_terminal_config(tmp_path) + service = TerminalFakeAgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket: + websocket.send_json({"type": "connect", "peer_id": "device-001"}) + assert websocket.receive_json()["type"] == "connected" + + websocket.send_json({"type": "message", "text": "hello"}) + assert websocket.receive_json() == {"type": "error", "error": "message_id is required"} + + websocket.send_json({"type": "message", "message_id": "m1", "text": " "}) + assert websocket.receive_json() == {"type": "error", "error": "text is required"} + + service.close() + assert service.inbound_calls == [] + + +def test_terminal_websocket_duplicate_message_returns_cached_reply(tmp_path: Path) -> None: + config_path = write_terminal_config(tmp_path) + service = TerminalFakeAgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket: + websocket.send_json({"type": "connect", "peer_id": "device-001"}) + assert websocket.receive_json()["type"] == "connected" + + frame = {"type": "message", "message_id": "device-001-000001", "text": "hello"} + websocket.send_json(frame) + assert websocket.receive_json()["accepted"] is True + assert websocket.receive_json()["text"] == "echo:hello" + + websocket.send_json(frame) + duplicate = websocket.receive_json() + + service.close() + assert duplicate["type"] == "ack" + assert duplicate["accepted"] is False + assert duplicate["duplicate"] is True + assert duplicate["pending"] is False + assert duplicate["reply"] == "echo:hello" + assert len(service.inbound_calls) == 1 + + +def test_terminal_websocket_disconnect_before_reply_records_unclaimed(tmp_path: Path) -> None: + config_path = write_terminal_config(tmp_path) + service = TerminalFakeAgentService(config_path=config_path, delay_seconds=0.05) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket: + websocket.send_json({"type": "connect", "peer_id": "device-001"}) + assert websocket.receive_json()["type"] == "connected" + websocket.send_json({"type": "message", "message_id": "device-001-000001", "text": "slow"}) + assert websocket.receive_json()["accepted"] is True + + time.sleep(0.15) + events = client.get("/api/channels/terminal-dev/events").json() + + service.close() + kinds = [event["kind"] for event in events] + assert "terminal_disconnected" in kinds + assert "outbound_unclaimed" in kinds + + +def test_terminal_channel_status_exposes_websocket_url_and_peer_count(tmp_path: Path) -> None: + config_path = write_terminal_config(tmp_path) + service = TerminalFakeAgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + initial = client.get("/api/status").json()["channels"][0] + assert initial["channel_id"] == "terminal-dev" + assert initial["websocket_url"] == "/api/channels/terminal-dev/ws" + assert initial["connected_peers"] == 0 + assert "persistent_connection" in initial["capabilities"] + + with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket: + websocket.send_json({"type": "connect", "peer_id": "device-001"}) + assert websocket.receive_json()["type"] == "connected" + connected = client.get("/api/status").json()["channels"][0] + assert connected["connected_peers"] == 1 + + service.close() diff --git a/app-instance/backend/tests/unit/test_weixin_channel_adapter.py b/app-instance/backend/tests/unit/test_weixin_channel_adapter.py new file mode 100644 index 0000000..9edbdb5 --- /dev/null +++ b/app-instance/backend/tests/unit/test_weixin_channel_adapter.py @@ -0,0 +1,129 @@ +import asyncio + +from beaver.foundation.events import OutboundMessage +from beaver.interfaces.channels.platforms.weixin import WeixinAdapter + + +class FakeSink: + def __init__(self) -> None: + self.messages = [] + + async def accept_inbound(self, message): + self.messages.append(message) + + +class FakeWeixinClient: + def __init__(self) -> None: + self.sent = [] + + async def send_text(self, *, peer_id: str, text: str, context_token: str | None): + self.sent.append({"peer_id": peer_id, "text": text, "context_token": context_token}) + + +def test_weixin_normalizes_direct_text_message() -> None: + async def run() -> None: + sink = FakeSink() + adapter = WeixinAdapter( + channel_id="weixin-main", + kind="weixin", + mode="polling", + account_id="wx-main", + display_name=None, + inbound_sink=sink, + secrets={"token": "token"}, + config={}, + client=FakeWeixinClient(), + ) + + await adapter.handle_message_payload( + { + "id": "m1", + "from": "wx_user", + "room_id": "", + "type": "text", + "text": "hello", + "context_token": "ctx1", + } + ) + + message = sink.messages[0] + assert message.content == "hello" + assert message.session_id == "weixin-main:wx-main:wx_user" + assert message.channel_identity.peer_type == "dm" + assert message.metadata["context_token"] == "ctx1" + + asyncio.run(run()) + + +def test_weixin_group_message_is_best_effort() -> None: + async def run() -> None: + sink = FakeSink() + adapter = WeixinAdapter( + channel_id="weixin-main", + kind="weixin", + mode="polling", + account_id="wx-main", + display_name=None, + inbound_sink=sink, + secrets={"token": "token"}, + config={"groupPolicy": "open"}, + client=FakeWeixinClient(), + ) + + await adapter.handle_message_payload( + { + "id": "m2", + "from": "wx_user", + "room_id": "room1", + "type": "text", + "text": "hello room", + "context_token": "ctx2", + } + ) + + message = sink.messages[0] + assert message.session_id == "weixin-main:wx-main:room1" + assert message.channel_identity.peer_type == "group" + assert message.channel_identity.user_id == "wx_user" + + asyncio.run(run()) + + +def test_weixin_sends_text_with_context_token() -> None: + async def run() -> None: + sink = FakeSink() + client = FakeWeixinClient() + adapter = WeixinAdapter( + channel_id="weixin-main", + kind="weixin", + mode="polling", + account_id="wx-main", + display_name=None, + inbound_sink=sink, + secrets={"token": "token"}, + config={}, + client=client, + ) + await adapter.handle_message_payload( + { + "id": "m1", + "from": "wx_user", + "type": "text", + "text": "hello", + "context_token": "ctx1", + } + ) + await adapter.send( + OutboundMessage( + channel="weixin-main", + content="ok", + session_id=sink.messages[0].session_id, + finish_reason="stop", + channel_identity=sink.messages[0].channel_identity, + metadata={"inbound_metadata": sink.messages[0].metadata}, + ) + ) + + assert client.sent == [{"peer_id": "wx_user", "text": "ok", "context_token": "ctx1"}] + + asyncio.run(run()) diff --git a/app-instance/backend/uv.lock b/app-instance/backend/uv.lock index 43b08d5..8997993 100644 --- a/app-instance/backend/uv.lock +++ b/app-instance/backend/uv.lock @@ -252,27 +252,51 @@ dependencies = [ ] [package.optional-dependencies] +channels = [ + { name = "aiohttp" }, + { name = "lark-oapi" }, + { name = "python-telegram-bot" }, +] dev = [ { name = "pytest" }, ] +feishu = [ + { name = "lark-oapi" }, +] +qqbot = [ + { name = "aiohttp" }, +] +telegram = [ + { name = "python-telegram-bot" }, +] +weixin = [ + { name = "aiohttp" }, +] [package.metadata] requires-dist = [ + { name = "aiohttp", marker = "extra == 'channels'", specifier = ">=3.9.0,<4.0.0" }, + { name = "aiohttp", marker = "extra == 'qqbot'", specifier = ">=3.9.0,<4.0.0" }, + { name = "aiohttp", marker = "extra == 'weixin'", specifier = ">=3.9.0,<4.0.0" }, { name = "anthropic", specifier = ">=0.51.0,<1.0.0" }, { name = "croniter", specifier = ">=6.0.0,<7.0.0" }, { name = "fastapi", specifier = ">=0.115.0,<1.0.0" }, { name = "fastmcp", specifier = ">=3.0.0,<4.0.0" }, { name = "httpx", specifier = ">=0.28.0,<1.0.0" }, { name = "json-repair", specifier = ">=0.39.0,<1.0.0" }, + { name = "lark-oapi", marker = "extra == 'channels'", specifier = ">=1.4.22,<2.0.0" }, + { name = "lark-oapi", marker = "extra == 'feishu'", specifier = ">=1.4.22,<2.0.0" }, { name = "litellm", specifier = ">=1.79.0,<2.0.0" }, { name = "openai", specifier = ">=1.79.0,<2.0.0" }, { name = "pydantic", specifier = ">=2.12.0,<3.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" }, { name = "python-multipart", specifier = ">=0.0.20,<1.0.0" }, + { name = "python-telegram-bot", marker = "extra == 'channels'", specifier = ">=22.0,<23.0" }, + { name = "python-telegram-bot", marker = "extra == 'telegram'", specifier = ">=22.0,<23.0" }, { name = "typer", specifier = ">=0.20.0,<1.0.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0,<1.0.0" }, ] -provides-extras = ["dev"] +provides-extras = ["dev", "telegram", "feishu", "qqbot", "weixin", "channels"] [[package]] name = "cachetools" @@ -1277,6 +1301,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] +[[package]] +name = "lark-oapi" +version = "1.6.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pycryptodome" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "websockets" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/54/a3b649b83299606aa7ebfd2391663fde650e934421dfba37af171bfbf456/lark_oapi-1.6.7-py3-none-any.whl", hash = "sha256:df1d44891d266f5c063daa1d37ae6f72c7f166bdc2fb01e607088410e952b92c", size = 7146261, upload-time = "2026-05-28T03:32:21.268Z" }, +] + [[package]] name = "litellm" version = "1.80.0" @@ -1759,6 +1798,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, +] + [[package]] name = "pydantic" version = "2.13.3" @@ -1973,6 +2042,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, ] +[[package]] +name = "python-telegram-bot" +version = "22.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpcore", marker = "python_full_version >= '3.14'" }, + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/25/2258161b1069e66d6c39c0a602dbe57461d4767dc0012539970ea40bc9d6/python_telegram_bot-22.7.tar.gz", hash = "sha256:784b59ea3852fe4616ad63b4a0264c755637f5d725e87755ecdee28300febf61", size = 1516454, upload-time = "2026-03-16T09:36:03.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/f7/0e2f89dd62f45d46d4ea0d8aec5893ce5b37389638db010c117f46f11450/python_telegram_bot-22.7-py3-none-any.whl", hash = "sha256:d72eed532cf763758cd9331b57a6d790aff0bb4d37d8f4e92149436fe21c6475", size = 745365, upload-time = "2026-03-16T09:36:01.498Z" }, +] + [[package]] name = "pywin32" version = "311" @@ -2189,6 +2271,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -2687,61 +2781,44 @@ wheels = [ [[package]] name = "websockets" -version = "16.0" +version = "15.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, - { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, - { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, - { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, - { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, - { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, - { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, - { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] [[package]] diff --git a/app-instance/create-instance.sh b/app-instance/create-instance.sh index d2a40f1..872af2b 100755 --- a/app-instance/create-instance.sh +++ b/app-instance/create-instance.sh @@ -37,6 +37,8 @@ INSTANCES_ROOT="${INSTANCES_ROOT:-$INSTANCES_ROOT_DEFAULT}" REGISTRY_PATH="${REGISTRY_PATH:-$REGISTRY_PATH_DEFAULT}" NETWORK_NAME="${NETWORK_NAME:-}" HOST_BIND_IP="${HOST_BIND_IP:-127.0.0.1}" +INITIAL_SKILLS_DIR="${INITIAL_SKILLS_DIR:-${SCRIPT_DIR}/../skills}" +SEED_INITIAL_SKILLS=1 FORCE_BUILD=0 REPLACE=0 @@ -78,6 +80,9 @@ Optional: --registry Registry JSON path. Default: ./runtime/registry/instances.json --network Optional docker network name. --host-bind-ip Host bind IP for published port. Default: 127.0.0.1 + --initial-skills-dir Directory copied into workspace/skills on first create. + Default: ../skills + --skip-initial-skills Do not seed initial workspace skills. --build Force rebuild image before running. --replace Remove existing container with same name before running. --help Show this help. @@ -225,6 +230,69 @@ data = { "name": os.environ["BACKEND_NAME"].strip(), "publicBaseUrl": os.environ["PUBLIC_URL"].strip(), }, + "channels": { + "telegram-main": { + "enabled": False, + "kind": "telegram", + "mode": "polling", + "accountId": "bot-main", + "displayName": "Telegram Main", + "secrets": { + "botToken": "", + }, + "config": { + "requireMentionInGroups": True, + "maxMessageChars": 4096, + }, + }, + "feishu-main": { + "enabled": False, + "kind": "feishu", + "mode": "websocket", + "accountId": "tenant-main", + "displayName": "Feishu Main", + "secrets": { + "appId": "", + "appSecret": "", + }, + "config": { + "domain": "feishu", + "connectionMode": "websocket", + "requireMentionInGroups": True, + }, + }, + "qqbot-main": { + "enabled": False, + "kind": "qqbot", + "mode": "websocket", + "accountId": "qqbot-main", + "displayName": "QQ Bot Main", + "secrets": { + "appId": "", + "clientSecret": "", + }, + "config": { + "dmPolicy": "open", + "groupPolicy": "allowlist", + "markdownSupport": False, + }, + }, + "weixin-main": { + "enabled": False, + "kind": "weixin", + "mode": "polling", + "accountId": "wx-main", + "displayName": "Weixin Main", + "secrets": { + "token": "", + }, + "config": { + "dmPolicy": "open", + "groupPolicy": "disabled", + "textBatchDelaySeconds": 0.5, + }, + }, + }, } target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") @@ -255,6 +323,66 @@ target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encodin PY } +seed_initial_skills() { + local workspace_path="$1" + local initial_skills_dir="$2" + local target_dir="${workspace_path}/skills" + + if [[ "$SEED_INITIAL_SKILLS" -ne 1 ]]; then + return + fi + if [[ ! -d "$initial_skills_dir" ]]; then + log "initial skills directory not found, skipping: ${initial_skills_dir}" + return + fi + + mkdir -p "$target_dir" + INITIAL_SKILLS_DIR="$initial_skills_dir" TARGET_DIR="$target_dir" python3 - <<'PY' +import json +import shutil +import os +from pathlib import Path + +initial = Path(os.environ["INITIAL_SKILLS_DIR"]).resolve() +target = Path(os.environ["TARGET_DIR"]).resolve() + +for child in sorted(initial.iterdir()): + if child.name.startswith("."): + continue + destination = target / child.name + if destination.exists(): + continue + if child.is_dir(): + shutil.copytree(child, destination) + elif child.is_file(): + shutil.copy2(child, destination) + +for index_name in ("published", "disabled"): + initial_index = initial / "_index" / f"{index_name}.json" + target_index = target / "_index" / f"{index_name}.json" + if not initial_index.exists(): + continue + try: + initial_items = json.loads(initial_index.read_text(encoding="utf-8")).get("items", []) + except json.JSONDecodeError: + initial_items = [] + if target_index.exists(): + try: + target_items = json.loads(target_index.read_text(encoding="utf-8")).get("items", []) + except json.JSONDecodeError: + target_items = [] + else: + target_items = [] + merged = [] + for item in [*target_items, *initial_items]: + text = str(item).strip() + if text and text not in merged: + merged.append(text) + target_index.parent.mkdir(parents=True, exist_ok=True) + target_index.write_text(json.dumps({"items": merged}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") +PY +} + render_runtime_env_file() { local target_path="$1" @@ -428,6 +556,14 @@ while [[ $# -gt 0 ]]; do HOST_BIND_IP="${2:-}" shift 2 ;; + --initial-skills-dir) + INITIAL_SKILLS_DIR="${2:-}" + shift 2 + ;; + --skip-initial-skills) + SEED_INITIAL_SKILLS=0 + shift + ;; --build) FORCE_BUILD=1 shift @@ -531,6 +667,7 @@ mkdir -p "$BEAVER_HOME" "$WORKSPACE_PATH" render_config_json "$CONFIG_PATH" render_auth_users_json "$AUTH_USERS_PATH" render_runtime_env_file "$RUNTIME_ENV_PATH" +seed_initial_skills "$WORKSPACE_PATH" "$INITIAL_SKILLS_DIR" if [[ "$FORCE_BUILD" -eq 1 ]] || ! image_exists; then log "building image ${IMAGE_NAME}" @@ -564,6 +701,7 @@ RUN_ARGS=( -e "APP_PUBLIC_PORT=8080" -e "APP_FRONTEND_PORT=3000" -e "APP_BACKEND_PORT=18080" + -e "BEAVER_ENABLE_SELF_RESTART=1" -e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}" --label "beaver.instance.id=${INSTANCE_ID}" --label "beaver.instance.slug=${INSTANCE_SLUG}" diff --git a/app-instance/frontend/app/(app)/logs/page.tsx b/app-instance/frontend/app/(app)/logs/page.tsx index 281d953..fca55af 100644 --- a/app-instance/frontend/app/(app)/logs/page.tsx +++ b/app-instance/frontend/app/(app)/logs/page.tsx @@ -10,6 +10,7 @@ import type { ChatLogEvent, ChatLogSession } from '@/types'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; +import { containedJsonTextClass } from '@/lib/text-wrapping'; function eventLabel(event: ChatLogEvent): string { return event.event_type || event.role || 'event'; @@ -175,7 +176,7 @@ export default function LogsPage() { return (
@@ -188,7 +189,7 @@ export default function LogsPage() {
{timestampLabel(event.timestamp)}
-
+                        
                           {body || formatPayload(event)}
                         
diff --git a/app-instance/frontend/app/(app)/page.tsx b/app-instance/frontend/app/(app)/page.tsx index 5fbda45..169c869 100644 --- a/app-instance/frontend/app/(app)/page.tsx +++ b/app-instance/frontend/app/(app)/page.tsx @@ -19,7 +19,12 @@ import { uploadFile, wsManager, } from '@/lib/api'; -import { mergeServerWithPendingUsers, shouldDisplayChatMessage, shouldMergePendingUsers } from '@/lib/chat-messages'; +import { + getSessionRefreshIntervalMs, + mergeServerWithPendingUsers, + shouldDisplayChatMessage, + shouldMergePendingUsers, +} from '@/lib/chat-messages'; import { pickAppText } from '@/lib/i18n/core'; import { useAppI18n } from '@/lib/i18n/provider'; import { buildSessionProgressView } from '@/lib/session-progress'; @@ -47,6 +52,10 @@ function loadThinkingModePreference(): boolean { return stored == null ? false : stored !== 'false'; } +function isDocumentHidden(): boolean { + return typeof document !== 'undefined' && document.visibilityState === 'hidden'; +} + export default function ChatPage() { const { locale } = useAppI18n(); const { @@ -78,6 +87,7 @@ export default function ChatPage() { const [pendingFiles, setPendingFiles] = useState>([]); const [activeTask, setActiveTask] = useState(null); const [revisionTargetRunId, setRevisionTargetRunId] = useState(null); + const [documentHidden, setDocumentHidden] = useState(isDocumentHidden); const messagesEndRef = useRef(null); const messageViewportRef = useRef(null); const textareaRef = useRef(null); @@ -247,14 +257,26 @@ export default function ChatPage() { }, [addMessage, loadActiveTask, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]); useEffect(() => { - if (!isLoading && !isThinking) { + const intervalMs = getSessionRefreshIntervalMs({ isLoading, isThinking, documentHidden }); + if (intervalMs == null) { return; } const timer = setInterval(() => { - loadSessionMessages(useChatStore.getState().sessionId); - }, 1500); + const currentSessionId = useChatStore.getState().sessionId; + void loadSessionMessages(currentSessionId); + void loadSessions(); + }, intervalMs); return () => clearInterval(timer); - }, [isLoading, isThinking, loadSessionMessages]); + }, [documentHidden, isLoading, isThinking, loadSessionMessages, loadSessions]); + + useEffect(() => { + if (typeof document === 'undefined') { + return; + } + const updateVisibility = () => setDocumentHidden(isDocumentHidden()); + document.addEventListener('visibilitychange', updateVisibility); + return () => document.removeEventListener('visibilitychange', updateVisibility); + }, []); const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior) => { const viewport = messageViewportRef.current; diff --git a/app-instance/frontend/app/(app)/skills/page.tsx b/app-instance/frontend/app/(app)/skills/page.tsx index ba9cb04..caaaecc 100644 --- a/app-instance/frontend/app/(app)/skills/page.tsx +++ b/app-instance/frontend/app/(app)/skills/page.tsx @@ -73,6 +73,7 @@ import type { } from '@/types'; import { pickAppText } from '@/lib/i18n/core'; import { useAppI18n } from '@/lib/i18n/provider'; +import { containedJsonTextClass, containedLongTextClass } from '@/lib/text-wrapping'; const TERMINAL_DRAFT_STATUSES = new Set(['rejected', 'published', 'disabled', 'archived']); const REJECTABLE_DRAFT_STATUSES = new Set(['draft', 'in_review', 'approved']); @@ -1094,7 +1095,7 @@ function ReadableFact({ {icon} {label} -
{value || '-'}
+
{value || '-'}
); } @@ -1119,12 +1120,12 @@ function MetricTile({ function RawDetails({ title, payload }: { title: string; payload: unknown }) { return ( -
+
{title} -
+      
         {JSON.stringify(payload, null, 2)}
       
diff --git a/app-instance/frontend/app/(app)/status/page.tsx b/app-instance/frontend/app/(app)/status/page.tsx index 9aae720..7b57a88 100644 --- a/app-instance/frontend/app/(app)/status/page.tsx +++ b/app-instance/frontend/app/(app)/status/page.tsx @@ -14,8 +14,21 @@ import { Loader2, Settings2, ScrollText, + QrCode, + PlugZap, } from 'lucide-react'; -import { getStatus, updateAgentConfig, updateProviderConfig } from '@/lib/api'; +import { + getChannelConfig, + getChannelConnectorSession, + getStatus, + listChannelConnectors, + listChannelEvents, + restartRuntime, + startChannelConnectorSession, + updateAgentConfig, + updateChannelConfig, + updateProviderConfig, +} from '@/lib/api'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; @@ -29,9 +42,25 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; -import type { ProviderStatus, SystemStatus } from '@/types'; -import { pickAppText } from '@/lib/i18n/core'; +import { Textarea } from '@/components/ui/textarea'; +import type { + ChannelConfigDetail, + ChannelConnectorDescriptor, + ChannelEventRecord, + ChannelStatus, + ConnectorSessionResponse, + ProviderStatus, + SystemStatus, +} from '@/types'; +import { AppLocale, pickAppText } from '@/lib/i18n/core'; import { useAppI18n } from '@/lib/i18n/provider'; type ProviderFormState = { @@ -48,6 +77,87 @@ type AgentFormState = { maxToolIterations: string; }; +type ChannelFormState = { + enabled: boolean; + kind: string; + mode: string; + accountId: string; + displayName: string; + botToken: string; + appId: string; + appSecret: string; + clientSecret: string; + token: string; + domain: string; + connectionMode: string; + botUsername: string; + botOpenId: string; + webhookUrl: string; + webhookSecret: string; + requireMentionInGroups: boolean; + allowFrom: string; + groupAllowFrom: string; + dmPolicy: string; + groupPolicy: string; + markdownSupport: boolean; + baseUrl: string; + cdnBaseUrl: string; + maxMessageChars: string; + textBatchDelaySeconds: string; +}; + +const EMPTY_CHANNEL_FORM: ChannelFormState = { + enabled: false, + kind: 'telegram', + mode: 'polling', + accountId: '', + displayName: '', + botToken: '', + appId: '', + appSecret: '', + clientSecret: '', + token: '', + domain: 'feishu', + connectionMode: 'websocket', + botUsername: '', + botOpenId: '', + webhookUrl: '', + webhookSecret: '', + requireMentionInGroups: true, + allowFrom: '', + groupAllowFrom: '', + dmPolicy: 'open', + groupPolicy: 'allowlist', + markdownSupport: false, + baseUrl: '', + cdnBaseUrl: '', + maxMessageChars: '', + textBatchDelaySeconds: '', +}; + +const CONFIGURABLE_CHANNEL_KINDS = new Set(['telegram', 'feishu', 'qqbot', 'weixin']); +const SESSION_CONNECTOR_KINDS = new Set(['weixin', 'feishu']); + +type ConnectorWizardForm = { + kind: string; + displayName: string; + domain: string; + mode: 'create' | 'link'; + appId: string; + appSecret: string; + verificationToken: string; +}; + +const EMPTY_CONNECTOR_WIZARD: ConnectorWizardForm = { + kind: '', + displayName: '', + domain: 'feishu', + mode: 'create', + appId: '', + appSecret: '', + verificationToken: '', +}; + export default function StatusPage() { const { locale } = useAppI18n(); const [status, setStatus] = useState(null); @@ -70,6 +180,26 @@ export default function StatusPage() { })); const [savingAgent, setSavingAgent] = useState(false); const [agentError, setAgentError] = useState(null); + const [selectedChannel, setSelectedChannel] = useState(null); + const [channelConfig, setChannelConfig] = useState(null); + const [channelForm, setChannelForm] = useState(() => ({ ...EMPTY_CHANNEL_FORM })); + const [channelEvents, setChannelEvents] = useState([]); + const [loadingChannelConfig, setLoadingChannelConfig] = useState(false); + const [loadingChannelEvents, setLoadingChannelEvents] = useState(false); + const [savingChannel, setSavingChannel] = useState(false); + const [channelError, setChannelError] = useState(null); + const [channelRestartRequired, setChannelRestartRequired] = useState(false); + const [restartOpen, setRestartOpen] = useState(false); + const [restarting, setRestarting] = useState(false); + const [restartError, setRestartError] = useState(null); + const [connectors, setConnectors] = useState([]); + const [loadingConnectors, setLoadingConnectors] = useState(false); + const [connectorDialogOpen, setConnectorDialogOpen] = useState(false); + const [connectorForm, setConnectorForm] = useState(() => ({ ...EMPTY_CONNECTOR_WIZARD })); + const [connectorSession, setConnectorSession] = useState(null); + const [startingConnector, setStartingConnector] = useState(false); + const [pollingConnector, setPollingConnector] = useState(false); + const [connectorError, setConnectorError] = useState(null); const loadStatus = async () => { setLoading(true); @@ -93,6 +223,32 @@ export default function StatusPage() { loadStatus(); }, []); + const loadConnectors = async () => { + setLoadingConnectors(true); + try { + setConnectors(await listChannelConnectors()); + } catch { + setConnectors([]); + } finally { + setLoadingConnectors(false); + } + }; + + useEffect(() => { + loadConnectors(); + }, []); + + useEffect(() => { + const sessionId = connectorSession?.session.sessionId; + const status = connectorSession?.session.status; + if (!sessionId || connectorSessionDone(status)) return; + + const timer = window.setInterval(() => { + void pollConnectorSession(sessionId); + }, 2500); + return () => window.clearInterval(timer); + }, [connectorSession?.session.sessionId, connectorSession?.session.status]); + const openProviderDialog = (provider: ProviderStatus) => { setSelectedProvider(provider); setProviderError(null); @@ -166,6 +322,126 @@ export default function StatusPage() { } }; + const openChannelDetails = async (channel: ChannelStatus) => { + setSelectedChannel(channel); + setChannelConfig(null); + setChannelForm(channelFormFromStatus(channel)); + setChannelError(null); + setChannelRestartRequired(false); + setChannelEvents([]); + setLoadingChannelConfig(true); + setLoadingChannelEvents(true); + try { + const config = await getChannelConfig(channel.channel_id); + setChannelConfig(config); + setChannelForm(channelFormFromConfig(config)); + } catch (err: any) { + setChannelError(err.message || pickAppText(locale, '加载通道配置失败', 'Failed to load channel configuration')); + } finally { + setLoadingChannelConfig(false); + } + try { + setChannelEvents(await listChannelEvents(channel.channel_id, 20)); + } catch { + setChannelEvents([]); + } finally { + setLoadingChannelEvents(false); + } + }; + + const handleSaveChannel = async () => { + if (!selectedChannel) return; + setSavingChannel(true); + setChannelError(null); + try { + const payload = channelPayloadFromForm(channelForm); + const result = await updateChannelConfig(selectedChannel.channel_id, payload); + setChannelConfig(result.channel); + setChannelForm(channelFormFromConfig(result.channel)); + setChannelRestartRequired(Boolean(result.restart_required)); + await loadStatus(); + } catch (err: any) { + setChannelError(err.message || pickAppText(locale, '保存通道配置失败', 'Failed to save channel configuration')); + } finally { + setSavingChannel(false); + } + }; + + const handleRestart = async () => { + setRestarting(true); + setRestartError(null); + try { + await restartRuntime(); + setRestartOpen(false); + window.setTimeout(() => { + void loadStatus(); + }, 5000); + } catch (err: any) { + setRestartError(err.message || pickAppText(locale, '重启失败', 'Restart failed')); + } finally { + setRestarting(false); + } + }; + + const openConnectorDialog = (connector: ChannelConnectorDescriptor) => { + const kind = connector.kind; + setConnectorDialogOpen(true); + setConnectorSession(null); + setConnectorError(null); + setConnectorForm({ + ...EMPTY_CONNECTOR_WIZARD, + kind, + displayName: connectorDisplayName(connector), + domain: kind === 'feishu' ? 'feishu' : '', + }); + }; + + const handleStartConnectorSession = async () => { + if (!connectorForm.kind || !SESSION_CONNECTOR_KINDS.has(connectorForm.kind)) return; + setStartingConnector(true); + setConnectorError(null); + try { + const options: Record = {}; + if (connectorForm.kind === 'feishu') { + options.domain = connectorForm.domain || 'feishu'; + options.mode = connectorForm.mode; + if (connectorForm.appId.trim()) options.appId = connectorForm.appId.trim(); + if (connectorForm.appSecret.trim()) options.appSecret = connectorForm.appSecret.trim(); + if (connectorForm.verificationToken.trim()) options.verificationToken = connectorForm.verificationToken.trim(); + } + const response = await startChannelConnectorSession({ + kind: connectorForm.kind, + displayName: connectorForm.displayName.trim() || connectorDisplayName({ kind: connectorForm.kind }), + options, + }); + setConnectorSession(response); + if (!connectorSessionDone(response.session.status)) { + window.setTimeout(() => { + void pollConnectorSession(response.session.sessionId); + }, 1000); + } + } catch (err: any) { + setConnectorError(err.message || pickAppText(locale, '启动连接失败', 'Failed to start connector session')); + } finally { + setStartingConnector(false); + } + }; + + const pollConnectorSession = async (sessionId: string) => { + setPollingConnector(true); + try { + const response = await getChannelConnectorSession(sessionId); + setConnectorSession(response); + if (response.session.status === 'connected') { + await loadStatus(); + } + } catch (err: any) { + setConnectorError(err.message || pickAppText(locale, '刷新连接状态失败', 'Failed to refresh connector status')); + } finally { + setPollingConnector(false); + } + }; + if (loading) { return (
@@ -241,6 +517,12 @@ export default function StatusPage() { {pickAppText(locale, '运行日志', 'Runtime Logs')} + {status.runtime_controls?.self_restart !== false ? ( + + ) : null}
@@ -442,6 +724,345 @@ export default function StatusPage() { + !open && setSelectedChannel(null)}> + + + {selectedChannel?.display_name || selectedChannel?.channel_id} + + {selectedChannel ? `${selectedChannel.kind}/${selectedChannel.mode} · ${selectedChannel.channel_id}` : ''} + + + {selectedChannel ? ( +
+ {CONFIGURABLE_CHANNEL_KINDS.has(channelForm.kind) ? ( +
+
+
+

{pickAppText(locale, '连接配置', 'Connection settings')}

+

+ {pickAppText(locale, '凭据留空会保留已保存的值。保存后重启实例才会重新连接通道。', 'Leave credentials blank to keep saved values. Restart the instance after saving to reconnect channels.')} +

+
+ setChannelForm((prev) => ({ ...prev, enabled: checked }))} + disabled={loadingChannelConfig || savingChannel} + /> +
+ +
+ + setChannelForm((prev) => ({ ...prev, displayName: event.target.value }))} + placeholder={selectedChannel.display_name || selectedChannel.channel_id} + /> + + + setChannelForm((prev) => ({ ...prev, accountId: event.target.value }))} + placeholder="bot-main" + /> + + + + + + + +
+ + + + + {channelError ?

{channelError}

: null} + {channelRestartRequired ? ( +
+ {pickAppText(locale, '配置已保存。重启实例后通道会按新配置启动。', 'Configuration saved. Restart the instance to start channels with the new settings.')} + +
+ ) : null} +
+ +
+
+ ) : null} +
+ + + + + + +
+
+

{pickAppText(locale, '最近事件', 'Recent events')}

+ {loadingChannelEvents ? ( + + ) : ( +
+ {channelEvents.map((event) => ( +
+
+ {event.kind} + {event.created_at} +
+
+ {event.status}{event.error ? ` · ${event.error}` : ''} +
+
+ ))} + {channelEvents.length === 0 ? ( +

+ {pickAppText(locale, '暂无事件', 'No events yet')} +

+ ) : null} +
+ )} +
+
+ ) : null} +
+
+ + + + + {pickAppText(locale, '重启实例?', 'Restart instance?')} + + {pickAppText( + locale, + '应用会短暂不可用,正在运行的对话和通道请求可能会中断。', + 'The app will be unavailable briefly. Running chats and channel requests may be interrupted.' + )} + + + {restartError ?

{restartError}

: null} + + + + +
+
+ + { + setConnectorDialogOpen(open); + if (!open) { + setConnectorSession(null); + setConnectorError(null); + } + }}> + + + + {connectorForm.kind ? connectorDisplayName({ kind: connectorForm.kind }) : pickAppText(locale, '连接通道', 'Connect channel')} + + + {connectorForm.kind === 'weixin' + ? pickAppText(locale, '使用扫码连接当前实例。', 'Connect this instance with QR login.') + : connectorForm.kind === 'feishu' + ? pickAppText(locale, '启动飞书/Lark 插件连接流程。', 'Start the Feishu/Lark plugin connection flow.') + : pickAppText(locale, '选择连接方式。', 'Choose a connection method.')} + + + +
+ + setConnectorForm((prev) => ({ ...prev, displayName: event.target.value }))} + disabled={Boolean(connectorSession) || startingConnector} + /> + + + {connectorForm.kind === 'feishu' ? ( +
+
+ + + + + + +
+ {!connectorSession ? ( +
+ {(connectorForm.mode === 'create' + ? feishuCreateGuide(locale) + : feishuLinkGuide(locale) + ).map((item) => ( +

{item}

+ ))} +
+ ) : null} + {connectorForm.mode === 'link' ? ( +
+ + setConnectorForm((prev) => ({ ...prev, appId: event.target.value }))} + disabled={Boolean(connectorSession) || startingConnector} + placeholder="cli_a..." + /> + + + setConnectorForm((prev) => ({ ...prev, appSecret: event.target.value }))} + disabled={Boolean(connectorSession) || startingConnector} + /> + + + setConnectorForm((prev) => ({ ...prev, verificationToken: event.target.value }))} + disabled={Boolean(connectorSession) || startingConnector} + placeholder={pickAppText(locale, '事件订阅校验 Token', 'Event subscription token')} + /> + +
+ ) : null} +
+ ) : null} + + {connectorSession ? ( +
+
+
+

{connectorSession.session.sessionId}

+

+ {connectorSession.connection?.channel_id || connectorSession.connection?.connection_id || '-'} +

+
+ + {connectorSession.session.status} + +
+ {connectorSession.session.qrImage ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Connector QR +
+ ) : null} + {connectorSession.session.instructions?.length ? ( +
+ {connectorSession.session.instructions.map((item, index) => ( +
+ {item} +
+ ))} +
+ ) : null} + {connectorSession.session.accountId || connectorSession.session.displayName ? ( +
+ + +
+ ) : null} + {connectorSession.session.error ? ( +

{connectorSession.session.error}

+ ) : null} +
+ ) : null} + + {connectorError ?

{connectorError}

: null} +
+ + + + {connectorSession ? ( + + ) : SESSION_CONNECTOR_KINDS.has(connectorForm.kind) ? ( + + ) : null} + +
+
+ {/* Channels */} @@ -451,18 +1072,70 @@ export default function StatusPage() { -
- {status.channels.map((ch) => ( -
- - {ch.enabled ? pickAppText(locale, '开启', 'On') : pickAppText(locale, '关闭', 'Off')} - - {ch.name} +
+
+ {(connectors.length ? connectors : [{ kind: 'telegram' }, { kind: 'weixin' }, { kind: 'feishu' }]).map((connector) => { + const supportsSession = SESSION_CONNECTOR_KINDS.has(connector.kind); + return ( + + ); + })} +
+ {loadingConnectors ? ( +
+ + {pickAppText(locale, '正在加载连接器', 'Loading connectors')}
- ))} + ) : null} +
+ {status.channels.length === 0 ? ( +

+ {pickAppText(locale, '尚未配置通道', 'No channels configured')} +

+ ) : ( + status.channels.map((ch) => ( + + )) + )} +
@@ -501,3 +1174,446 @@ function InfoRow({ function providerLabel(provider: ProviderStatus): string { return provider.label || provider.name; } + +function connectorDisplayName(connector: Pick): string { + if (connector.displayName || connector.display_name) return connector.displayName || connector.display_name || connector.kind; + if (connector.kind === 'weixin') return 'Weixin'; + if (connector.kind === 'feishu') return 'Feishu/Lark'; + if (connector.kind === 'telegram') return 'Telegram'; + return connector.kind; +} + +function connectorAuthLabel(connector: ChannelConnectorDescriptor, locale: AppLocale): string { + const authType = connector.authType || connector.auth_type; + if (connector.kind === 'weixin') return authType || pickAppText(locale, 'QR', 'QR'); + if (connector.kind === 'feishu') return authType || pickAppText(locale, '插件', 'Plugin'); + if (connector.kind === 'telegram') return authType || pickAppText(locale, 'Token', 'Token'); + return authType || connector.kind; +} + +function feishuCreateGuide(locale: AppLocale): string[] { + return [ + pickAppText(locale, '点击开始连接后会生成飞书扫码二维码。', 'Start connection to generate a Feishu/Lark QR code.'), + pickAppText(locale, '用飞书客户端扫码,选择一键创建飞书机器人。', 'Scan with the Feishu/Lark client and choose one-click bot creation.'), + pickAppText(locale, '创建完成后打开机器人,发送任意消息或 /feishu start 验证。', 'After creation, open the bot and send any message or /feishu start to verify.'), + ]; +} + +function feishuLinkGuide(locale: AppLocale): string[] { + return [ + pickAppText(locale, '关联已有机器人需要填写 App ID 和 App Secret。', 'Linking an existing bot requires App ID and App Secret.'), + pickAppText(locale, '若提示凭证无效,请从飞书开放平台复制最新应用凭证。', 'If credentials are invalid, copy the latest app credentials from the Feishu/Lark developer console.'), + ]; +} + +function connectorSessionDone(status?: string | null): boolean { + return ['connected', 'expired', 'error', 'cancelled'].includes(String(status || '')); +} + +function connectorSessionBadgeVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' { + if (status === 'connected') return 'default'; + if (status === 'error' || status === 'expired') return 'destructive'; + if (status === 'cancelled') return 'secondary'; + return 'outline'; +} + +function channelStateBadgeVariant( + state: ChannelStatus['state'] +): 'default' | 'secondary' | 'destructive' | 'outline' { + if (state === 'running') return 'default'; + if (state === 'error' || state === 'degraded') return 'destructive'; + if (state === 'disabled' || state === 'stopped') return 'secondary'; + return 'outline'; +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +function ChannelCredentialFields({ + form, + locale, + maskedSecrets, + setForm, +}: { + form: ChannelFormState; + locale: AppLocale; + maskedSecrets: Record; + setForm: React.Dispatch>; +}) { + if (form.kind === 'telegram') { + return ( +
+ + setForm((prev) => ({ ...prev, botToken: event.target.value }))} + placeholder={maskedSecrets.botToken || pickAppText(locale, '留空保持不变', 'Leave blank to keep existing')} + /> + + + setForm((prev) => ({ ...prev, botUsername: event.target.value }))} + placeholder="beaver_bot" + /> + + {form.mode === 'webhook' ? ( + <> + + setForm((prev) => ({ ...prev, webhookUrl: event.target.value }))} + placeholder="https://example.com/telegram" + /> + + + setForm((prev) => ({ ...prev, webhookSecret: event.target.value }))} + placeholder={pickAppText(locale, '可选', 'Optional')} + /> + + + ) : null} +
+ ); + } + + if (form.kind === 'feishu') { + return ( +
+ + setForm((prev) => ({ ...prev, appId: event.target.value }))} + placeholder={maskedSecrets.appId || 'cli_a...'} + /> + + + setForm((prev) => ({ ...prev, appSecret: event.target.value }))} + placeholder={maskedSecrets.appSecret || pickAppText(locale, '留空保持不变', 'Leave blank to keep existing')} + /> + + + + + + setForm((prev) => ({ ...prev, botOpenId: event.target.value }))} + placeholder={pickAppText(locale, '可选,用于群 mention 判断', 'Optional, for group mention checks')} + /> + +
+ ); + } + + if (form.kind === 'qqbot') { + return ( +
+ + setForm((prev) => ({ ...prev, appId: event.target.value }))} + placeholder={maskedSecrets.appId || '1020...'} + /> + + + setForm((prev) => ({ ...prev, clientSecret: event.target.value }))} + placeholder={maskedSecrets.clientSecret || pickAppText(locale, '留空保持不变', 'Leave blank to keep existing')} + /> + +
+
+ +

+ {pickAppText(locale, '按 QQ Bot markdown 消息发送文本。', 'Send text as QQ Bot markdown messages.')} +

+
+ setForm((prev) => ({ ...prev, markdownSupport: checked }))} + /> +
+
+ ); + } + + if (form.kind === 'weixin') { + return ( +
+ + setForm((prev) => ({ ...prev, token: event.target.value }))} + placeholder={maskedSecrets.token || pickAppText(locale, '留空保持不变', 'Leave blank to keep existing')} + /> + + + setForm((prev) => ({ ...prev, baseUrl: event.target.value }))} + placeholder={pickAppText(locale, '默认 iLink API', 'Default iLink API')} + /> + + + setForm((prev) => ({ ...prev, cdnBaseUrl: event.target.value }))} + placeholder={pickAppText(locale, '默认 CDN', 'Default CDN')} + /> + + + setForm((prev) => ({ ...prev, textBatchDelaySeconds: event.target.value }))} + placeholder="0.5" + /> + +
+ ); + } + + return null; +} + +function ChannelPolicyFields({ + form, + locale, + setForm, +}: { + form: ChannelFormState; + locale: AppLocale; + setForm: React.Dispatch>; +}) { + return ( +
+ {form.kind === 'telegram' || form.kind === 'feishu' ? ( +
+
+ +

+ {pickAppText(locale, '开启后群聊只有提到 bot 才会触发。', 'When enabled, group messages trigger only when they mention the bot.')} +

+
+ setForm((prev) => ({ ...prev, requireMentionInGroups: checked }))} + /> +
+ ) : ( + <> + + setForm((prev) => ({ ...prev, dmPolicy: value }))} /> + + + setForm((prev) => ({ ...prev, groupPolicy: value }))} /> + + + )} + + +