merge: channel runtime v1

This commit is contained in:
2026-06-03 16:23:07 +08:00
110 changed files with 22327 additions and 304 deletions

View File

@ -28,3 +28,19 @@ BEAVER_OUTLOOK_MCP_SERVER_ID=outlook_mcp
# Must be reachable from auth-portal and authz-service containers. # Must be reachable from auth-portal and authz-service containers.
BEAVER_DEPLOY_URL=http://beaver-deploy-control:8090 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=

View File

@ -1,177 +1,458 @@
# Hermes Gateway LLM Design # Beaver Terminal WebSocket Integration Guide
Date: 2026-06-01 Date: 2026-06-01
Audience: the small-terminal-side Codex agent that will modify terminal firmware or terminal app code.
## Goal ## Goal
Replace the OpenAI-compatible LLM call path in `custom/custom_agent.py` with a LiveKit LLM Connect the small terminal device to Beaver through a text-only WebSocket channel.
adapter that talks to NousResearch Hermes Agent through the OpenClaw gateway protocol.
The integration must keep the existing custom agent behavior: The first acceptance target is simple:
- Chinese room-locator and general assistant instructions 1. The terminal opens a WebSocket connection to Beaver.
- Emotion prefix parsing with `<emotion=...>` 2. The terminal sends a `connect` frame with a stable `peer_id`.
- Memory recall for room-locator queries 3. The terminal sends one text `message` frame.
- Optional vision-frame attachment 4. The terminal receives an `ack`.
- LiveKit ASR, TTS, VAD, turn handling, metrics, and interruption behavior 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 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.
session for the lifetime of that room.
## Non-Goals ## Non-Goals
- Do not replace LiveKit `AgentSession`, ASR, TTS, VAD, or room I/O. - Do not implement audio streaming.
- Do not move room-locator classification into Hermes Agent. - Do not implement camera, screen, image, or multimodal frames.
- Do not implement Hermes-side tools in the first pass. - Do not implement token streaming.
- Do not require an OpenAI-compatible proxy in front of the gateway. - 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`. ```text
The stream will own a single gateway request/response cycle while the LLM object owns the ws://<beaver-host>/api/channels/<channel_id>/ws
per-room gateway session state. ```
`custom/custom_agent.py` will continue to call `selected_llm.chat(...)` through For local development through the Beaver app instance nginx port:
`_run_selected_llm()`. That preserves the existing `llm_node()` pipeline and keeps Hermes
behind the same abstraction as OpenAI-compatible models.
## 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 Use `wss://` when Beaver is deployed behind TLS.
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()`.
### HermesGatewayLLMStream The expected first channel id is:
Responsibilities: ```text
terminal-dev
```
- Serialize LiveKit `ChatContext` into the gateway request payload. The terminal implementation should make the URL configurable, for example:
- 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.
### 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. The transport is JSON over WebSocket.
- `CUSTOM_LLM_PROVIDER=hermes_gateway` constructs `HermesGatewayLLM`.
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` The protocol is request/reply oriented in this phase. Beaver sends only final assistant messages, not token deltas.
- `CUSTOM_HERMES_API_KEY`
- `CUSTOM_HERMES_AGENT_ID`
- `CUSTOM_HERMES_SESSION_MODE=per_room`
- `CUSTOM_HERMES_REQUEST_TIMEOUT`
- `CUSTOM_HERMES_VERIFY_SSL`
When `CUSTOM_LLM_PROVIDER=hermes_gateway`, `base_llm`, `text_llm`, and `vision_llm` should all Required frame flow:
point at the same Hermes adapter. Separate Hermes text/vision agent IDs are out of scope for this
design.
## Data Flow ```text
terminal -> Beaver: connect
Beaver -> terminal: connected
terminal -> Beaver: message
Beaver -> terminal: ack
Beaver -> terminal: message
```
1. User speaks or sends text. Optional heartbeat:
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.
## 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. Terminal to Beaver:
- 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.
Function tool calls should not be sent in the first implementation. If tool messages appear, log ```json
that they were omitted. {
"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 - `type`: must be `"connect"`.
room name/SID is not available, fall back to one adapter-local session. - `peer_id`: stable terminal identity. Reuse this value across reconnects.
For each room key: Recommended fields:
1. Open or reuse a gateway connection. - `device_name`: human-readable terminal name.
2. Send the gateway `connect` handshake if needed. - `capabilities`: include `"text"`.
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.
This lets Hermes maintain its own conversational state while LiveKit still keeps the visible Optional fields:
conversation history.
## 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))` ```json
- Gateway final assistant message -> emit any remaining text not already streamed {
- Gateway usage metadata -> `llm.CompletionUsage` when token counts are available "type": "connected",
- Gateway tool/action events -> log at debug/info level in the first implementation "channel_id": "terminal-dev",
- Gateway error event -> raise a LiveKit `APIError` or `APIConnectionError` "session_id": "terminal-dev:local:device-001"
- Gateway completion event -> finish the async iterator }
```
The implementation should make the event parser tolerant of protocol field-name differences by The terminal should store `session_id` for logging and diagnostics. It does not need to send `session_id` back in message frames.
isolating event normalization in one helper function. Unknown event types should be logged and
ignored unless they indicate failure.
## Error Handling ## Message Frame
- Missing Hermes env vars should fail fast at startup when provider is `hermes_gateway`. Terminal to Beaver:
- 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.
## 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. - `type`: must be `"message"`.
- Creates one gateway session and reuses it across two turns for the same room. - `message_id`: unique id for this user message.
- Converts text deltas into `llm.ChatChunk` content. - `text`: non-empty user text.
- Handles final full-message events without duplicate text.
- Raises on gateway error events.
- Logs and skips unsupported image/tool content.
Add a small wiring test or import-level test for `CUSTOM_LLM_PROVIDER=hermes_gateway` if the Recommended `message_id` format:
custom module is testable without external services.
## Rollout ```text
<peer_id>-<monotonic-counter>
```
1. Implement the adapter behind `CUSTOM_LLM_PROVIDER=hermes_gateway`. Example:
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`. ```text
4. Test manually with a local gateway using `python custom/custom_agent.py console` or the device-001-000001
existing LiveKit development mode. device-001-000002
5. If vision payloads are unsupported by the deployed gateway, document that the first Hermes ```
rollout is text-only for vision turns.
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.

View File

@ -47,8 +47,12 @@ ARG NPM_REGISTRY="https://registry.npmmirror.com"
ARG NPM_FETCH_RETRIES="5" ARG NPM_FETCH_RETRIES="5"
ARG NPM_FETCH_RETRY_MIN_TIMEOUT="20000" ARG NPM_FETCH_RETRY_MIN_TIMEOUT="20000"
ARG NPM_FETCH_RETRY_MAX_TIMEOUT="120000" 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 && \ apt-get install -y --no-install-recommends curl ca-certificates gnupg git nginx dumb-init && \
mkdir -p /etc/apt/keyrings && \ mkdir -p /etc/apt/keyrings && \
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ 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/pyproject.toml backend/README.md ./
COPY backend/beaver/ ./beaver/ 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 WORKDIR /opt/app/frontend
COPY --from=frontend-builder /build/frontend/next.config.js ./ COPY --from=frontend-builder /build/frontend/next.config.js ./

View File

@ -76,7 +76,12 @@ class SessionContext:
model: str | None = None model: str | None = None
user_id: str | None = None user_id: str | None = None
channel: 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 chat_id: str | None = None
thread_id: str | None = None
parent_session_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}") rows.append(f"User ID: {session_context.user_id}")
if session_context.channel: if session_context.channel:
rows.append(f"Channel: {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: if session_context.chat_id:
rows.append(f"Chat ID: {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: if session_context.parent_session_id:
rows.append(f"Parent Session ID: {session_context.parent_session_id}") rows.append(f"Parent Session ID: {session_context.parent_session_id}")

View File

@ -13,6 +13,7 @@ from uuid import uuid4
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from beaver.engine.context import ContextBuildInput, RuntimeContext, SessionContext, SkillContext from beaver.engine.context import ContextBuildInput, RuntimeContext, SessionContext, SkillContext
from beaver.foundation.events import ChannelIdentity
from beaver.memory.runs import RunRecord, SkillEffectRecord from beaver.memory.runs import RunRecord, SkillEffectRecord
from beaver.skills.learning import RunReceiptContext from beaver.skills.learning import RunReceiptContext
from beaver.skills.catalog.utils import strip_frontmatter from beaver.skills.catalog.utils import strip_frontmatter
@ -248,6 +249,7 @@ class AgentLoop:
pinned_skill_contexts: list[SkillContext] | None = None, pinned_skill_contexts: list[SkillContext] | None = None,
allow_candidate_generation: bool = False, allow_candidate_generation: bool = False,
intent_agent_decision: dict[str, Any] | None = None, intent_agent_decision: dict[str, Any] | None = None,
channel_identity: ChannelIdentity | None = None,
) -> AgentRunResult: ) -> AgentRunResult:
"""跑通最小 direct run 主链。 """跑通最小 direct run 主链。
@ -297,6 +299,7 @@ class AgentLoop:
pinned_skill_contexts=pinned_skill_contexts, pinned_skill_contexts=pinned_skill_contexts,
allow_candidate_generation=allow_candidate_generation, allow_candidate_generation=allow_candidate_generation,
intent_agent_decision=intent_agent_decision, intent_agent_decision=intent_agent_decision,
channel_identity=channel_identity,
) )
async def _process_direct_impl( async def _process_direct_impl(
@ -334,6 +337,7 @@ class AgentLoop:
pinned_skill_contexts: list[SkillContext] | None = None, pinned_skill_contexts: list[SkillContext] | None = None,
allow_candidate_generation: bool = False, allow_candidate_generation: bool = False,
intent_agent_decision: dict[str, Any] | None = None, intent_agent_decision: dict[str, Any] | None = None,
channel_identity: ChannelIdentity | None = None,
) -> AgentRunResult: ) -> AgentRunResult:
"""真正执行一轮 direct run 的内部实现。 """真正执行一轮 direct run 的内部实现。
@ -576,6 +580,13 @@ class AgentLoop:
source=source, source=source,
model=resolved_model, model=resolved_model,
user_id=user_id, 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, parent_session_id=parent_session_id,
), ),
runtime_context=self._current_runtime_context(), runtime_context=self._current_runtime_context(),

View File

@ -13,6 +13,7 @@ from .schema import (
AuthzConfig, AuthzConfig,
BackendIdentityConfig, BackendIdentityConfig,
BeaverConfig, BeaverConfig,
ChannelConfig,
EmbeddingConfig, EmbeddingConfig,
MCPServerConfig, MCPServerConfig,
ProviderConfig, ProviderConfig,
@ -73,6 +74,7 @@ def load_config(
embedding=_parse_embedding(data), embedding=_parse_embedding(data),
tools=_parse_tools(data.get("tools")), tools=_parse_tools(data.get("tools")),
authz=_parse_authz(data.get("authz")), 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")), backend_identity=_parse_backend_identity(data.get("backend_identity") or data.get("backendIdentity")),
config_path=path, 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: def _parse_backend_identity(raw: Any) -> BackendIdentityConfig:
data = _as_dict(raw) data = _as_dict(raw)
return BackendIdentityConfig( return BackendIdentityConfig(

View File

@ -91,6 +91,19 @@ class AuthzConfig:
outlook_mcp_url: str = "" 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) @dataclass(slots=True)
class BackendIdentityConfig: class BackendIdentityConfig:
"""This backend's AuthZ client identity.""" """This backend's AuthZ client identity."""
@ -111,6 +124,7 @@ class BeaverConfig:
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig) embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
tools: ToolsConfig = field(default_factory=ToolsConfig) tools: ToolsConfig = field(default_factory=ToolsConfig)
authz: AuthzConfig = field(default_factory=AuthzConfig) authz: AuthzConfig = field(default_factory=AuthzConfig)
channels: dict[str, ChannelConfig] = field(default_factory=dict)
backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig) backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig)
config_path: Path | None = None config_path: Path | None = None

View File

@ -1,5 +1,5 @@
"""Event contracts and dispatch helpers.""" """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"]

View File

@ -9,12 +9,58 @@ from typing import Any
from uuid import uuid4 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) @dataclass(slots=True)
class InboundMessage: class InboundMessage:
"""A minimal inbound message accepted by the gateway bridge.""" """A minimal inbound message accepted by the gateway bridge."""
channel: str channel: str
content: str content: str
content_type: str = "text"
channel_identity: ChannelIdentity | None = None
session_id: str | None = None session_id: str | None = None
user_id: str | None = None user_id: str | None = None
title: str | None = None title: str | None = None
@ -35,6 +81,8 @@ class OutboundMessage:
content: str content: str
session_id: str | None session_id: str | None
finish_reason: str finish_reason: str
content_type: str = "text"
channel_identity: ChannelIdentity | None = None
message_id: str = field(default_factory=lambda: str(uuid4())) message_id: str = field(default_factory=lambda: str(uuid4()))
run_id: str | None = None run_id: str | None = None
provider_name: str | None = None provider_name: str | None = None

View File

@ -1,7 +1,17 @@
"""Channel interfaces.""" """Channel interfaces."""
from .base import ChannelAdapter from .base import ChannelAdapter
from .base import ChannelInboundSink
from .external_connector import ExternalConnectorChannel
from .manager import ChannelManager from .manager import ChannelManager
from .memory import MemoryChannelAdapter from .memory import MemoryChannelAdapter
from .terminal_websocket import TerminalWebSocketAdapter
__all__ = ["ChannelAdapter", "ChannelManager", "MemoryChannelAdapter"] __all__ = [
"ChannelAdapter",
"ChannelInboundSink",
"ExternalConnectorChannel",
"ChannelManager",
"MemoryChannelAdapter",
"TerminalWebSocketAdapter",
]

View File

@ -2,16 +2,17 @@
from __future__ import annotations 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): class ChannelAdapter(Protocol):
"""Minimal contract every gateway channel must implement.""" """Minimal contract every runtime channel adapter must implement."""
name: str channel_id: str
bus: MessageBus kind: str
mode: str
async def start(self) -> None: async def start(self) -> None:
"""Prepare the channel before messages are routed.""" """Prepare the channel before messages are routed."""
@ -22,3 +23,9 @@ class ChannelAdapter(Protocol):
async def send(self, message: OutboundMessage) -> None: async def send(self, message: OutboundMessage) -> None:
"""Deliver an outbound message to the concrete channel.""" """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."""

View File

@ -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",
]

View File

@ -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

View File

@ -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)

View File

@ -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"]

View File

@ -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

View File

@ -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()

View File

@ -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"

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Awaitable, Callable
from contextlib import suppress from contextlib import suppress
from beaver.foundation.events import MessageBus, OutboundMessage from beaver.foundation.events import MessageBus, OutboundMessage
@ -20,13 +21,17 @@ class ChannelManager:
self.started = False self.started = False
def register(self, channel: ChannelAdapter) -> None: def register(self, channel: ChannelAdapter) -> None:
if self.started: if channel.channel_id in self.channels:
raise RuntimeError("Cannot register channels after ChannelManager.start()") raise ValueError(f"Channel already registered: {channel.channel_id}")
if channel.name in self.channels: self.channels[channel.channel_id] = channel
raise ValueError(f"Channel already registered: {channel.name}")
if channel.bus is not self.bus: def unregister(self, channel_id: str) -> ChannelAdapter | None:
raise ValueError("Channel must share the same MessageBus as ChannelManager") return self.channels.pop(channel_id, None)
self.channels[channel.name] = channel
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: async def start(self) -> None:
started: list[ChannelAdapter] = [] started: list[ChannelAdapter] = []
@ -53,7 +58,13 @@ class ChannelManager:
if errors: if errors:
raise RuntimeError(f"Failed to stop {len(errors)} channel(s)") from errors[0] 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.""" """Route bus outbound messages until stopped and the queue is drained."""
while True: while True:
@ -68,9 +79,16 @@ class ChannelManager:
channel = self.channels.get(message.channel) channel = self.channels.get(message.channel)
if channel is None: if channel is None:
self.undeliverable.append(message) self.undeliverable.append(message)
if on_failed is not None:
await on_failed(message, None)
continue continue
try: try:
await channel.send(message) 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) 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)

View File

@ -4,15 +4,27 @@ from __future__ import annotations
from typing import Any 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: class MemoryChannelAdapter:
"""A local channel that stores outbound messages in memory.""" """A local channel that stores outbound messages in memory."""
def __init__(self, bus: MessageBus, *, name: str = "memory") -> None: def __init__(
self.name = name self,
self.bus = bus 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.started = False
self.sent_messages: list[OutboundMessage] = [] self.sent_messages: list[OutboundMessage] = []
@ -36,12 +48,24 @@ class MemoryChannelAdapter:
model: str | None = None, model: str | None = None,
provider_name: str | None = None, provider_name: str | None = None,
embedding_model: 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, metadata: dict[str, Any] | None = None,
) -> InboundMessage: ) -> InboundMessage:
"""Publish a text message from this channel into the shared bus.""" """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( message = InboundMessage(
channel=self.name, channel=self.channel_id,
content=content, content=content,
session_id=session_id, session_id=session_id,
user_id=user_id, user_id=user_id,
@ -50,9 +74,10 @@ class MemoryChannelAdapter:
model=model, model=model,
provider_name=provider_name, provider_name=provider_name,
embedding_model=embedding_model, embedding_model=embedding_model,
channel_identity=identity,
metadata=metadata or {}, metadata=metadata or {},
) )
await self.bus.publish_inbound(message) await self.inbound_sink.accept_inbound(message)
return message return message
async def publish_external_text( async def publish_external_text(
@ -73,9 +98,6 @@ class MemoryChannelAdapter:
the shared gateway bus. the shared gateway bus.
""" """
session_parts = [self.name, chat_id]
if thread_id:
session_parts.append(thread_id)
metadata = { metadata = {
"chat_id": chat_id, "chat_id": chat_id,
"message_id": message_id, "message_id": message_id,
@ -84,8 +106,10 @@ class MemoryChannelAdapter:
} }
return await self.publish_text( return await self.publish_text(
content, content,
session_id=":".join(str(part) for part in session_parts if str(part)),
user_id=user_id, user_id=user_id,
title=title, title=title,
peer_id=chat_id,
thread_id=thread_id,
message_id=message_id,
metadata=metadata, metadata=metadata,
) )

View File

@ -0,0 +1 @@
"""Platform channel adapters."""

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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))

View File

@ -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 '<empty>'}",
}
)
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,
)

View File

@ -19,6 +19,18 @@ from typing import Any
from beaver.engine.providers.registry import PROVIDERS, find_by_name from beaver.engine.providers.registry import PROVIDERS, find_by_name
from beaver.foundation.config import default_config_path, load_config 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.foundation.models import CronExecutionResult, CronRunRecord
from beaver.integrations.mcp import MCPConnectionManager from beaver.integrations.mcp import MCPConnectionManager
from beaver.services.agent_service import NOTIFICATION_SESSION_ID, AgentService from beaver.services.agent_service import NOTIFICATION_SESSION_ID, AgentService
@ -53,6 +65,16 @@ from .schemas import (
WebErrorResponse, WebErrorResponse,
WebAgentConfigRequest, WebAgentConfigRequest,
WebAgentConfigResponse, WebAgentConfigResponse,
WebChannelConfigRequest,
WebChannelConfigResponse,
WebChannelConnectionCreateRequest,
WebChannelConnectionResponse,
WebChannelConnectionUpdateRequest,
WebChannelValidationResponse,
WebConnectorBridgeEventRequest,
WebConnectorBridgeEventResponse,
WebConnectorSessionCreateRequest,
WebConnectorSessionResponse,
WebProviderConfigRequest, WebProviderConfigRequest,
WebProviderConfigResponse, WebProviderConfigResponse,
WebStatusResponse, WebStatusResponse,
@ -60,7 +82,7 @@ from .schemas import (
try: try:
from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect 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 except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments
def File(default: Any = None) -> Any: # type: ignore[override] def File(default: Any = None) -> Any: # type: ignore[override]
return default return default
@ -94,6 +116,11 @@ except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only env
self.media_type = media_type self.media_type = media_type
self.headers = headers or {} 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): class WebSocketDisconnect(Exception):
"""Fallback websocket disconnect 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 owns_service = manage_service_lifecycle if manage_service_lifecycle is not None else service is None
app.state.agent_service = attached_service app.state.agent_service = attached_service
app.state.cron_service = _build_cron_service(attached_service) if owns_service else None app.state.cron_service = _build_cron_service(attached_service) if owns_service else None
app.state.channel_runtime = None
started = False started = False
channel_runtime: ChannelRuntime | None = None
if owns_service: if owns_service:
try: try:
await attached_service.start() await attached_service.start()
@ -200,6 +229,29 @@ async def _app_lifespan(
else: else:
attached_service.close() attached_service.close()
raise 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: SkillLearningWorker | None = None
worker_task = None worker_task = None
worker_config = SkillLearningWorkerConfig.from_env() worker_config = SkillLearningWorkerConfig.from_env()
@ -216,6 +268,10 @@ async def _app_lifespan(
try: try:
yield yield
finally: 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) cron_service = getattr(app.state, "cron_service", None)
if isinstance(cron_service, CronService): if isinstance(cron_service, CronService):
cron_service.stop() cron_service.stop()
@ -283,6 +339,118 @@ def get_cron_service(request: Request) -> CronService:
return service 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( def create_app(
*, *,
workspace: str | Path | None = None, workspace: str | Path | None = None,
@ -380,10 +548,330 @@ def create_app(
"temperature": agent_service.profile.temperature, "temperature": agent_service.profile.temperature,
"max_tool_iterations": agent_service.profile.max_tool_iterations, "max_tool_iterations": agent_service.profile.max_tool_iterations,
"providers": providers_status, "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(), "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") @app.post("/api/auth/login")
async def auth_login(request: Request, payload: dict[str, Any]) -> dict[str, Any]: async def auth_login(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
username = _clean_text(payload.get("username")) username = _clean_text(payload.get("username"))
@ -3011,6 +3499,25 @@ def _mask_secret(value: str | None) -> str:
return f"{secret[:4]}••••{secret[-4:]}" 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]: def _read_config_json(path: Path) -> dict[str, Any]:
if not path.exists(): if not path.exists():
return {} return {}
@ -3082,7 +3589,14 @@ def _reload_agent_config(agent_service: AgentService, config_path: Path) -> None
old_manager = getattr(loaded, "mcp_manager", None) old_manager = getattr(loaded, "mcp_manager", None)
if old_manager is not None: if old_manager is not None:
async def _close_old_manager() -> 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: try:
running_loop = asyncio.get_running_loop() running_loop = asyncio.get_running_loop()

View File

@ -10,6 +10,16 @@ from .chat import (
WebErrorResponse, WebErrorResponse,
WebAgentConfigRequest, WebAgentConfigRequest,
WebAgentConfigResponse, WebAgentConfigResponse,
WebChannelConfigRequest,
WebChannelConfigResponse,
WebChannelConnectionCreateRequest,
WebChannelConnectionResponse,
WebChannelConnectionUpdateRequest,
WebChannelValidationResponse,
WebConnectorBridgeEventRequest,
WebConnectorBridgeEventResponse,
WebConnectorSessionCreateRequest,
WebConnectorSessionResponse,
WebProviderConfigRequest, WebProviderConfigRequest,
WebProviderConfigResponse, WebProviderConfigResponse,
WebProviderTarget, WebProviderTarget,
@ -26,6 +36,16 @@ __all__ = [
"WebErrorResponse", "WebErrorResponse",
"WebAgentConfigRequest", "WebAgentConfigRequest",
"WebAgentConfigResponse", "WebAgentConfigResponse",
"WebChannelConfigRequest",
"WebChannelConfigResponse",
"WebChannelConnectionCreateRequest",
"WebChannelConnectionResponse",
"WebChannelConnectionUpdateRequest",
"WebChannelValidationResponse",
"WebConnectorBridgeEventRequest",
"WebConnectorBridgeEventResponse",
"WebConnectorSessionCreateRequest",
"WebConnectorSessionResponse",
"WebProviderConfigRequest", "WebProviderConfigRequest",
"WebProviderConfigResponse", "WebProviderConfigResponse",
"WebProviderTarget", "WebProviderTarget",

View File

@ -139,6 +139,113 @@ class WebProviderConfigResponse(BaseModel):
enabled: bool 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): class WebAgentConfigRequest(BaseModel):
"""Agent runtime defaults update from the settings page.""" """Agent runtime defaults update from the settings page."""

View File

@ -1237,17 +1237,19 @@ class AgentService:
async def handle_inbound_message(self, inbound: InboundMessage) -> OutboundMessage: async def handle_inbound_message(self, inbound: InboundMessage) -> OutboundMessage:
"""把 bus inbound 映射成标准 runtime 调用,并返回结构化 outbound。""" """把 bus inbound 映射成标准 runtime 调用,并返回结构化 outbound。"""
channel_identity = inbound.channel_identity
try: try:
result = await self.submit_direct( result = await self.submit_direct(
inbound.content, inbound.content,
session_id=inbound.session_id, session_id=inbound.session_id,
source=f"gateway:{inbound.channel}", 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, title=inbound.title,
execution_context=inbound.execution_context, execution_context=inbound.execution_context,
model=inbound.model, model=inbound.model,
provider_name=inbound.provider_name, provider_name=inbound.provider_name,
embedding_model=inbound.embedding_model, embedding_model=inbound.embedding_model,
channel_identity=channel_identity,
) )
except Exception as exc: except Exception as exc:
return self.build_outbound_error( return self.build_outbound_error(
@ -1283,6 +1285,8 @@ class AgentService:
finish_reason=result.finish_reason, finish_reason=result.finish_reason,
provider_name=result.provider_name, provider_name=result.provider_name,
model=result.model, model=result.model,
content_type=inbound.content_type,
channel_identity=inbound.channel_identity,
usage=dict(result.usage), usage=dict(result.usage),
metadata={ metadata={
"inbound_metadata": dict(inbound.metadata), "inbound_metadata": dict(inbound.metadata),
@ -1308,6 +1312,8 @@ class AgentService:
session_id=inbound.session_id, session_id=inbound.session_id,
content=detail, content=detail,
finish_reason=finish_reason, finish_reason=finish_reason,
content_type=inbound.content_type,
channel_identity=inbound.channel_identity,
metadata={"error": detail, "inbound_metadata": dict(inbound.metadata)}, metadata={"error": detail, "inbound_metadata": dict(inbound.metadata)},
) )

View File

@ -22,6 +22,23 @@ dependencies = [
dev = [ dev = [
"pytest>=9.0.0,<10.0.0", "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] [project.scripts]
beaver = "beaver.interfaces.cli.main:main" beaver = "beaver.interfaces.cli.main:main"

View File

@ -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"}]

View File

@ -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

View File

@ -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())

View File

@ -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

View File

@ -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())

View File

@ -1,4 +1,5 @@
import json import json
import asyncio
from fastapi.testclient import TestClient 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"} 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: def test_provider_resolution_ignores_custom_and_disabled_overrides(tmp_path) -> None:
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
config_path.write_text( config_path.write_text(
@ -163,6 +202,58 @@ def test_reload_agent_config_updates_booted_loop_config(tmp_path) -> None:
service.close() 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: def test_agent_defaults_include_runtime_controls(tmp_path) -> None:
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
config_path.write_text( config_path.write_text(
@ -245,6 +336,67 @@ def test_agent_config_api_accepts_zero_temperature_and_iterations(tmp_path) -> N
service.close() 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: def test_openai_compatible_qwen_config_keeps_openai_provider() -> None:
bundle = make_provider_bundle( bundle = make_provider_bundle(
model="qwen-plus", model="qwen-plus",

View File

@ -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

View File

@ -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()

View File

@ -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())

View File

@ -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()

View File

@ -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())

View File

@ -2,9 +2,10 @@ import asyncio
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any 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.channels import ChannelManager, MemoryChannelAdapter
from beaver.interfaces.gateway.main import run_gateway from beaver.interfaces.gateway.main import run_gateway
from beaver.interfaces.channels.runtime import ChannelRuntime
from beaver.services.agent_service import AgentService from beaver.services.agent_service import AgentService
@ -52,22 +53,15 @@ class InvalidService:
is_running = True 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: async def run() -> None:
bus = MessageBus() bus = MessageBus()
channel = MemoryChannelAdapter(bus) runtime = ChannelRuntime(service=FakeService(), bus=bus, channels={}, workspace=tmp_path)
stop_event = asyncio.Event() channel = MemoryChannelAdapter(runtime)
task = asyncio.create_task( runtime.manager.register(channel)
run_gateway( await runtime.start()
service=FakeService(),
manage_service_lifecycle=False,
bus=bus,
channels=[channel],
stop_event=stop_event,
)
)
await channel.publish_text("hello", session_id="s1") await channel.publish_text("hello", peer_id="s1", message_id="m1")
for _ in range(40): for _ in range(40):
if channel.sent_messages: if channel.sent_messages:
break break
@ -76,38 +70,73 @@ def test_gateway_routes_memory_channel_roundtrip() -> None:
assert channel.sent_messages assert channel.sent_messages
message = channel.sent_messages[0] message = channel.sent_messages[0]
assert message.content == "echo:hello" 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.finish_reason == "stop"
assert message.metadata["task_id"] == "task-1" assert message.metadata["task_id"] == "task-1"
assert message.metadata["task_status"] == "awaiting_acceptance" assert message.metadata["task_status"] == "awaiting_acceptance"
assert message.metadata["evidence_status"] == "recorded" assert message.metadata["evidence_status"] == "recorded"
assert message.metadata["validation_result"] is None assert message.metadata["validation_result"] is None
stop_event.set() await runtime.stop()
await asyncio.wait_for(task, timeout=2)
asyncio.run(run()) 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: async def run() -> None:
bus = MessageBus() bus = MessageBus()
channel = MemoryChannelAdapter(bus) channel = CaptureChannel()
stop_event = asyncio.Event() manager = ChannelManager(bus)
task = asyncio.create_task( manager.register(channel)
run_gateway( await bus.publish_outbound(
service=SlowService(), OutboundMessage(
manage_service_lifecycle=False, channel="webhook-dev",
bus=bus, content="ok",
channels=[channel], session_id="webhook-dev:local:demo",
stop_event=stop_event, finish_reason="stop",
) )
) )
stop_event = asyncio.Event()
await channel.publish_text("slow", session_id="s1")
await asyncio.sleep(0.05)
stop_event.set() 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
assert channel.sent_messages[0].finish_reason == "cancelled" 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: def test_gateway_rejects_channel_manager_and_channels_together() -> None:
async def run() -> None: async def run() -> None:
bus = MessageBus() 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: try:
await run_gateway( await run_gateway(
service=FakeService(), service=FakeService(),
manage_service_lifecycle=False, manage_service_lifecycle=False,
bus=bus, bus=bus,
channel_manager=ChannelManager(bus), channel_manager=ChannelManager(bus),
channels=[MemoryChannelAdapter(bus)], channels=[CaptureChannel()],
stop_event=asyncio.Event(), stop_event=asyncio.Event(),
) )
except ValueError as exc: except ValueError as exc:
@ -212,10 +255,16 @@ def test_channel_manager_keeps_unknown_channel_outbound_undeliverable() -> None:
asyncio.run(run()) 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: async def run() -> None:
bus = MessageBus() 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( inbound = await channel.publish_external_text(
"hello", "hello",
chat_id="chat-1", 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() queued = await bus.consume_inbound()
assert queued is inbound assert queued is inbound
assert queued.channel == "telegram" assert queued.channel == "telegram-main"
assert queued.session_id == "telegram:chat-1" 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["chat_id"] == "chat-1"
assert queued.metadata["message_id"] == "message-1" assert queued.metadata["message_id"] == "message-1"
assert queued.metadata["raw_channel_payload"] == {"platform": "telegram", "text": "hello"} 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: def test_channel_manager_start_cancellation_rolls_back_started_channels() -> None:
class StartedChannel: class StartedChannel:
name = "started" channel_id = "started"
kind = "memory"
mode = "webhook"
def __init__(self, bus: MessageBus) -> None: def __init__(self, bus: MessageBus) -> None:
self.bus = bus self.bus = bus
@ -252,7 +305,9 @@ def test_channel_manager_start_cancellation_rolls_back_started_channels() -> Non
pass pass
class BlockingChannel: class BlockingChannel:
name = "blocking" channel_id = "blocking"
kind = "memory"
mode = "webhook"
def __init__(self, bus: MessageBus) -> None: def __init__(self, bus: MessageBus) -> None:
self.bus = bus self.bus = bus

View File

@ -6,6 +6,34 @@ from beaver.interfaces.web.app import create_app
from beaver.interfaces.web.schemas import WebChatRequest, WebChatResponse 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: def test_agent_loop_boots(tmp_path) -> None:
loop = AgentLoop(loader=EngineLoader(workspace=tmp_path)) loop = AgentLoop(loader=EngineLoader(workspace=tmp_path))
loaded = loop.boot() loaded = loop.boot()
@ -32,10 +60,14 @@ def test_message_bus_imports() -> None:
def test_channel_imports() -> None: def test_channel_imports() -> None:
bus = MessageBus() 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 = ChannelManager(bus)
manager.register(channel) manager.register(channel)
assert manager.channels["memory"] is channel assert manager.channels["memory-dev"] is channel
def test_web_schema_imports() -> None: def test_web_schema_imports() -> None:

View File

@ -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]"

View File

@ -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())

View File

@ -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())

View File

@ -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())

View File

@ -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()

View File

@ -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())

View File

@ -252,27 +252,51 @@ dependencies = [
] ]
[package.optional-dependencies] [package.optional-dependencies]
channels = [
{ name = "aiohttp" },
{ name = "lark-oapi" },
{ name = "python-telegram-bot" },
]
dev = [ dev = [
{ name = "pytest" }, { name = "pytest" },
] ]
feishu = [
{ name = "lark-oapi" },
]
qqbot = [
{ name = "aiohttp" },
]
telegram = [
{ name = "python-telegram-bot" },
]
weixin = [
{ name = "aiohttp" },
]
[package.metadata] [package.metadata]
requires-dist = [ 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 = "anthropic", specifier = ">=0.51.0,<1.0.0" },
{ name = "croniter", specifier = ">=6.0.0,<7.0.0" }, { name = "croniter", specifier = ">=6.0.0,<7.0.0" },
{ name = "fastapi", specifier = ">=0.115.0,<1.0.0" }, { name = "fastapi", specifier = ">=0.115.0,<1.0.0" },
{ name = "fastmcp", specifier = ">=3.0.0,<4.0.0" }, { name = "fastmcp", specifier = ">=3.0.0,<4.0.0" },
{ name = "httpx", specifier = ">=0.28.0,<1.0.0" }, { name = "httpx", specifier = ">=0.28.0,<1.0.0" },
{ name = "json-repair", specifier = ">=0.39.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 = "litellm", specifier = ">=1.79.0,<2.0.0" },
{ name = "openai", 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 = "pydantic", specifier = ">=2.12.0,<3.0.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.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-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 = "typer", specifier = ">=0.20.0,<1.0.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.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]] [[package]]
name = "cachetools" 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" }, { 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]] [[package]]
name = "litellm" name = "litellm"
version = "1.80.0" 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" }, { 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]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.13.3" 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" }, { 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]] [[package]]
name = "pywin32" name = "pywin32"
version = "311" 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" }, { 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]] [[package]]
name = "rich" name = "rich"
version = "15.0.0" version = "15.0.0"
@ -2687,61 +2781,44 @@ wheels = [
[[package]] [[package]]
name = "websockets" name = "websockets"
version = "16.0" version = "15.0.1"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
{ 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" },
] ]
[[package]] [[package]]

View File

@ -37,6 +37,8 @@ INSTANCES_ROOT="${INSTANCES_ROOT:-$INSTANCES_ROOT_DEFAULT}"
REGISTRY_PATH="${REGISTRY_PATH:-$REGISTRY_PATH_DEFAULT}" REGISTRY_PATH="${REGISTRY_PATH:-$REGISTRY_PATH_DEFAULT}"
NETWORK_NAME="${NETWORK_NAME:-}" NETWORK_NAME="${NETWORK_NAME:-}"
HOST_BIND_IP="${HOST_BIND_IP:-127.0.0.1}" 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 FORCE_BUILD=0
REPLACE=0 REPLACE=0
@ -78,6 +80,9 @@ Optional:
--registry <path> Registry JSON path. Default: ./runtime/registry/instances.json --registry <path> Registry JSON path. Default: ./runtime/registry/instances.json
--network <name> Optional docker network name. --network <name> Optional docker network name.
--host-bind-ip <ip> Host bind IP for published port. Default: 127.0.0.1 --host-bind-ip <ip> Host bind IP for published port. Default: 127.0.0.1
--initial-skills-dir <path> 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. --build Force rebuild image before running.
--replace Remove existing container with same name before running. --replace Remove existing container with same name before running.
--help Show this help. --help Show this help.
@ -225,6 +230,69 @@ data = {
"name": os.environ["BACKEND_NAME"].strip(), "name": os.environ["BACKEND_NAME"].strip(),
"publicBaseUrl": os.environ["PUBLIC_URL"].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") 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 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() { render_runtime_env_file() {
local target_path="$1" local target_path="$1"
@ -428,6 +556,14 @@ while [[ $# -gt 0 ]]; do
HOST_BIND_IP="${2:-}" HOST_BIND_IP="${2:-}"
shift 2 shift 2
;; ;;
--initial-skills-dir)
INITIAL_SKILLS_DIR="${2:-}"
shift 2
;;
--skip-initial-skills)
SEED_INITIAL_SKILLS=0
shift
;;
--build) --build)
FORCE_BUILD=1 FORCE_BUILD=1
shift shift
@ -531,6 +667,7 @@ mkdir -p "$BEAVER_HOME" "$WORKSPACE_PATH"
render_config_json "$CONFIG_PATH" render_config_json "$CONFIG_PATH"
render_auth_users_json "$AUTH_USERS_PATH" render_auth_users_json "$AUTH_USERS_PATH"
render_runtime_env_file "$RUNTIME_ENV_PATH" render_runtime_env_file "$RUNTIME_ENV_PATH"
seed_initial_skills "$WORKSPACE_PATH" "$INITIAL_SKILLS_DIR"
if [[ "$FORCE_BUILD" -eq 1 ]] || ! image_exists; then if [[ "$FORCE_BUILD" -eq 1 ]] || ! image_exists; then
log "building image ${IMAGE_NAME}" log "building image ${IMAGE_NAME}"
@ -564,6 +701,7 @@ RUN_ARGS=(
-e "APP_PUBLIC_PORT=8080" -e "APP_PUBLIC_PORT=8080"
-e "APP_FRONTEND_PORT=3000" -e "APP_FRONTEND_PORT=3000"
-e "APP_BACKEND_PORT=18080" -e "APP_BACKEND_PORT=18080"
-e "BEAVER_ENABLE_SELF_RESTART=1"
-e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}" -e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}"
--label "beaver.instance.id=${INSTANCE_ID}" --label "beaver.instance.id=${INSTANCE_ID}"
--label "beaver.instance.slug=${INSTANCE_SLUG}" --label "beaver.instance.slug=${INSTANCE_SLUG}"

View File

@ -10,6 +10,7 @@ import type { ChatLogEvent, ChatLogSession } from '@/types';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { containedJsonTextClass } from '@/lib/text-wrapping';
function eventLabel(event: ChatLogEvent): string { function eventLabel(event: ChatLogEvent): string {
return event.event_type || event.role || 'event'; return event.event_type || event.role || 'event';
@ -175,7 +176,7 @@ export default function LogsPage() {
return ( return (
<div <div
key={`${event.message_id ?? index}:${event.event_type}`} key={`${event.message_id ?? index}:${event.event_type}`}
className="rounded-lg border border-border bg-background" className="min-w-0 max-w-full overflow-hidden rounded-lg border border-border bg-background"
> >
<div className="flex flex-wrap items-center justify-between gap-2 border-b px-3 py-2"> <div className="flex flex-wrap items-center justify-between gap-2 border-b px-3 py-2">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
@ -188,7 +189,7 @@ export default function LogsPage() {
</div> </div>
<span className="text-xs text-muted-foreground">{timestampLabel(event.timestamp)}</span> <span className="text-xs text-muted-foreground">{timestampLabel(event.timestamp)}</span>
</div> </div>
<pre className="max-h-[520px] overflow-auto whitespace-pre-wrap break-words p-3 text-xs leading-5 text-foreground"> <pre className={`max-h-[520px] overflow-auto p-3 text-xs leading-5 text-foreground ${containedJsonTextClass}`}>
{body || formatPayload(event)} {body || formatPayload(event)}
</pre> </pre>
</div> </div>

View File

@ -19,7 +19,12 @@ import {
uploadFile, uploadFile,
wsManager, wsManager,
} from '@/lib/api'; } 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 { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider'; import { useAppI18n } from '@/lib/i18n/provider';
import { buildSessionProgressView } from '@/lib/session-progress'; import { buildSessionProgressView } from '@/lib/session-progress';
@ -47,6 +52,10 @@ function loadThinkingModePreference(): boolean {
return stored == null ? false : stored !== 'false'; return stored == null ? false : stored !== 'false';
} }
function isDocumentHidden(): boolean {
return typeof document !== 'undefined' && document.visibilityState === 'hidden';
}
export default function ChatPage() { export default function ChatPage() {
const { locale } = useAppI18n(); const { locale } = useAppI18n();
const { const {
@ -78,6 +87,7 @@ export default function ChatPage() {
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]); const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
const [activeTask, setActiveTask] = useState<ActiveTask | null>(null); const [activeTask, setActiveTask] = useState<ActiveTask | null>(null);
const [revisionTargetRunId, setRevisionTargetRunId] = useState<string | null>(null); const [revisionTargetRunId, setRevisionTargetRunId] = useState<string | null>(null);
const [documentHidden, setDocumentHidden] = useState(isDocumentHidden);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const messageViewportRef = useRef<HTMLDivElement>(null); const messageViewportRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -247,14 +257,26 @@ export default function ChatPage() {
}, [addMessage, loadActiveTask, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]); }, [addMessage, loadActiveTask, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]);
useEffect(() => { useEffect(() => {
if (!isLoading && !isThinking) { const intervalMs = getSessionRefreshIntervalMs({ isLoading, isThinking, documentHidden });
if (intervalMs == null) {
return; return;
} }
const timer = setInterval(() => { const timer = setInterval(() => {
loadSessionMessages(useChatStore.getState().sessionId); const currentSessionId = useChatStore.getState().sessionId;
}, 1500); void loadSessionMessages(currentSessionId);
void loadSessions();
}, intervalMs);
return () => clearInterval(timer); 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 scrollMessagesToLatest = useCallback((behavior: ScrollBehavior) => {
const viewport = messageViewportRef.current; const viewport = messageViewportRef.current;

View File

@ -73,6 +73,7 @@ import type {
} from '@/types'; } from '@/types';
import { pickAppText } from '@/lib/i18n/core'; import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider'; import { useAppI18n } from '@/lib/i18n/provider';
import { containedJsonTextClass, containedLongTextClass } from '@/lib/text-wrapping';
const TERMINAL_DRAFT_STATUSES = new Set(['rejected', 'published', 'disabled', 'archived']); const TERMINAL_DRAFT_STATUSES = new Set(['rejected', 'published', 'disabled', 'archived']);
const REJECTABLE_DRAFT_STATUSES = new Set(['draft', 'in_review', 'approved']); const REJECTABLE_DRAFT_STATUSES = new Set(['draft', 'in_review', 'approved']);
@ -1094,7 +1095,7 @@ function ReadableFact({
{icon} {icon}
{label} {label}
</div> </div>
<div className="break-words text-sm leading-5">{value || '-'}</div> <div className={`text-sm leading-5 ${containedLongTextClass}`}>{value || '-'}</div>
</div> </div>
); );
} }
@ -1119,12 +1120,12 @@ function MetricTile({
function RawDetails({ title, payload }: { title: string; payload: unknown }) { function RawDetails({ title, payload }: { title: string; payload: unknown }) {
return ( return (
<details className="mt-3 rounded-md border border-border bg-white"> <details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-white">
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-xs font-medium text-muted-foreground"> <summary className="flex cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-xs font-medium text-muted-foreground">
{title} {title}
<ChevronDown className="h-3.5 w-3.5" /> <ChevronDown className="h-3.5 w-3.5" />
</summary> </summary>
<pre className="max-h-72 overflow-auto border-t border-border p-3 text-xs leading-5"> <pre className={`max-h-72 overflow-auto border-t border-border p-3 text-xs leading-5 ${containedJsonTextClass}`}>
{JSON.stringify(payload, null, 2)} {JSON.stringify(payload, null, 2)}
</pre> </pre>
</details> </details>

File diff suppressed because it is too large Load Diff

View File

@ -88,6 +88,32 @@
} }
} }
@layer utilities {
.contained-long-text {
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
}
.contained-preserved-long-text {
min-width: 0;
max-width: 100%;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.contained-json-text {
min-width: 0;
max-width: 100%;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
}
/* Override Tailwind Typography table defaults for markdown rendering */ /* Override Tailwind Typography table defaults for markdown rendering */
.prose table { .prose table {
margin-top: 0; margin-top: 0;

View File

@ -3,9 +3,11 @@
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { containedLongTextClass } from '@/lib/text-wrapping';
export function MarkdownContent({ content }: { content: string }) { export function MarkdownContent({ content }: { content: string }) {
return ( return (
<div className="prose prose-sm max-w-none text-[#1D1715] prose-headings:text-[#0B0B0B] prose-p:text-[#1D1715] prose-p:leading-7 prose-strong:text-[#0B0B0B] prose-a:text-[#342E2B] prose-a:underline prose-a:decoration-[#B8AEA8] prose-a:underline-offset-4 prose-li:text-[#1D1715] prose-blockquote:border-l-[#D8D2CE] prose-blockquote:text-[#4F4642] prose-code:rounded-md prose-code:bg-[#ECE8E5] prose-code:px-1.5 prose-code:py-0.5 prose-code:text-[#342E2B] prose-pre:border prose-pre:border-[#D8D2CE] prose-pre:bg-[#ECE8E5] prose-pre:text-[#342E2B] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"> <div className={`prose prose-sm max-w-none text-[#1D1715] prose-headings:text-[#0B0B0B] prose-p:text-[#1D1715] prose-p:leading-7 prose-strong:text-[#0B0B0B] prose-a:text-[#342E2B] prose-a:underline prose-a:decoration-[#B8AEA8] prose-a:underline-offset-4 prose-li:text-[#1D1715] prose-blockquote:border-l-[#D8D2CE] prose-blockquote:text-[#4F4642] prose-code:rounded-md prose-code:bg-[#ECE8E5] prose-code:px-1.5 prose-code:py-0.5 prose-code:text-[#342E2B] prose-code:[overflow-wrap:anywhere] prose-pre:border prose-pre:border-[#D8D2CE] prose-pre:bg-[#ECE8E5] prose-pre:text-[#342E2B] prose-pre:whitespace-pre-wrap prose-pre:[overflow-wrap:anywhere] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${containedLongTextClass}`}>
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={{

View File

@ -12,6 +12,7 @@ import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { pickAppText } from '@/lib/i18n/core'; import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider'; import { useAppI18n } from '@/lib/i18n/provider';
import { containedPreservedLongTextClass } from '@/lib/text-wrapping';
function AuthImage({ src, alt, className }: { src: string; alt: string; className?: string }) { function AuthImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
const [blobUrl, setBlobUrl] = React.useState<string | null>(null); const [blobUrl, setBlobUrl] = React.useState<string | null>(null);
@ -66,7 +67,7 @@ function MessageBubble({
</div> </div>
)} )}
<div <div
className={`max-w-[88%] px-4 py-3 ${ className={`min-w-0 max-w-[88%] px-4 py-3 ${
isUser isUser
? 'rounded-[28px] bg-primary text-primary-foreground' ? 'rounded-[28px] bg-primary text-primary-foreground'
: 'rounded-none bg-transparent text-[#1D1715]' : 'rounded-none bg-transparent text-[#1D1715]'
@ -92,14 +93,14 @@ function MessageBubble({
key={att.file_id} key={att.file_id}
href={fileUrl} href={fileUrl}
download={att.name} download={att.name}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm ${ className={`flex min-w-0 items-center gap-2 px-3 py-2 rounded-md text-sm ${
isUser isUser
? 'bg-primary-foreground/10 hover:bg-primary-foreground/20' ? 'bg-primary-foreground/10 hover:bg-primary-foreground/20'
: 'bg-muted hover:bg-muted/80' : 'bg-muted hover:bg-muted/80'
}`} }`}
> >
<Paperclip className="w-3.5 h-3.5 flex-shrink-0" /> <Paperclip className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{att.name}</span> <span className="min-w-0 truncate">{att.name}</span>
{att.size && ( {att.size && (
<span className="text-xs opacity-70 flex-shrink-0"> <span className="text-xs opacity-70 flex-shrink-0">
{att.size > 1024 * 1024 {att.size > 1024 * 1024
@ -114,7 +115,7 @@ function MessageBubble({
)} )}
{isUser ? ( {isUser ? (
<p className="text-sm whitespace-pre-wrap">{textContent}</p> <p className={`text-sm ${containedPreservedLongTextClass}`}>{textContent}</p>
) : ( ) : (
<MarkdownContent content={textContent} /> <MarkdownContent content={textContent} />
)} )}

View File

@ -11,6 +11,7 @@ import { Textarea } from '@/components/ui/textarea';
import { pickAppText } from '@/lib/i18n/core'; import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider'; import { useAppI18n } from '@/lib/i18n/provider';
import type { TaskRuntimeStatus } from '@/lib/task-runtime'; import type { TaskRuntimeStatus } from '@/lib/task-runtime';
import { containedPreservedLongTextClass } from '@/lib/text-wrapping';
export type TaskFeedbackType = 'accept' | 'revise' | 'abandon'; export type TaskFeedbackType = 'accept' | 'revise' | 'abandon';
@ -177,7 +178,7 @@ export function TaskAcceptanceControls({
<CheckCircle2 className="h-4 w-4 text-[#657162]" /> <CheckCircle2 className="h-4 w-4 text-[#657162]" />
{pickAppText(locale, '已提交验收', 'Acceptance submitted')}: {humanFeedback(feedbackKind(recordedFeedback), locale)} {pickAppText(locale, '已提交验收', 'Acceptance submitted')}: {humanFeedback(feedbackKind(recordedFeedback), locale)}
</div> </div>
{recordedFeedback.comment ? <p className="mt-2 whitespace-pre-wrap text-muted-foreground">{String(recordedFeedback.comment)}</p> : null} {recordedFeedback.comment ? <p className={`mt-2 text-muted-foreground ${containedPreservedLongTextClass}`}>{String(recordedFeedback.comment)}</p> : null}
{recordedFeedback.created_at ? ( {recordedFeedback.created_at ? (
<p className="mt-2 text-xs text-muted-foreground">{formatTaskRuntimeTime(String(recordedFeedback.created_at), locale)}</p> <p className="mt-2 text-xs text-muted-foreground">{formatTaskRuntimeTime(String(recordedFeedback.created_at), locale)}</p>
) : null} ) : null}
@ -229,7 +230,7 @@ export function TaskAcceptanceControls({
disabled={Boolean(recordedFeedback) || isFinalized || !isReadyForAcceptance || Boolean(actionBusy)} disabled={Boolean(recordedFeedback) || isFinalized || !isReadyForAcceptance || Boolean(actionBusy)}
placeholder={pickAppText(locale, '需要修改时写下具体要求;接受或放弃可选填说明。', 'Describe requested changes; notes are optional for accept or abandon.')} placeholder={pickAppText(locale, '需要修改时写下具体要求;接受或放弃可选填说明。', 'Describe requested changes; notes are optional for accept or abandon.')}
/> />
<div className="text-xs text-muted-foreground"> <div className={`text-xs text-muted-foreground ${containedPreservedLongTextClass}`}>
{pickAppText(locale, '验收将记录到当前任务运行:', 'Acceptance will be recorded on run: ')} {pickAppText(locale, '验收将记录到当前任务运行:', 'Acceptance will be recorded on run: ')}
<span className="font-mono">{runId || '-'}</span> <span className="font-mono">{runId || '-'}</span>
<span className="mx-1">·</span> <span className="mx-1">·</span>

View File

@ -24,6 +24,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { pickAppText } from '@/lib/i18n/core'; import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider'; import { useAppI18n } from '@/lib/i18n/provider';
import type { TaskRuntimeStatus } from '@/lib/task-runtime'; import type { TaskRuntimeStatus } from '@/lib/task-runtime';
import { containedJsonTextClass, containedLongTextClass, containedPreservedLongTextClass } from '@/lib/text-wrapping';
import type { TaskTimelineCard as TaskTimelineCardView, TaskTimelineCardType } from '@/types'; import type { TaskTimelineCard as TaskTimelineCardView, TaskTimelineCardType } from '@/types';
import { TaskAcceptanceControls, type TaskFeedbackItem, type TaskFeedbackType } from './TaskAcceptanceCard'; import { TaskAcceptanceControls, type TaskFeedbackItem, type TaskFeedbackType } from './TaskAcceptanceCard';
@ -146,14 +147,14 @@ function TaskResultHistory({ card }: { card: TaskTimelineCardView }) {
const versions = historyVersions(card.details); const versions = historyVersions(card.details);
return ( return (
<details className="mt-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-sm"> <details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-muted/20 px-3 py-2 text-sm">
<summary className="flex cursor-pointer select-none items-center justify-between gap-3 font-medium"> <summary className="flex cursor-pointer select-none items-center justify-between gap-3 font-medium">
<span>{pickAppText(locale, '展开历史版本', 'Show previous versions')}</span> <span>{pickAppText(locale, '展开历史版本', 'Show previous versions')}</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" /> <ChevronDown className="h-4 w-4 text-muted-foreground" />
</summary> </summary>
<div className="mt-3 space-y-3"> <div className="mt-3 space-y-3">
{versions.map((version, index) => ( {versions.map((version, index) => (
<div key={String(version.runId || index)} className="rounded-md border border-border bg-background p-3"> <div key={String(version.runId || index)} className="min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-background p-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-medium"> <div className="text-sm font-medium">
{pickAppText(locale, `${index + 1} 轮结果`, `Version ${index + 1}`)} {pickAppText(locale, `${index + 1} 轮结果`, `Version ${index + 1}`)}
@ -162,9 +163,9 @@ function TaskResultHistory({ card }: { card: TaskTimelineCardView }) {
{renderHistoryStatus(version, locale)} {renderHistoryStatus(version, locale)}
</Badge> </Badge>
</div> </div>
{version.result ? <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{String(version.result)}</p> : null} {version.result ? <p className={`mt-2 text-sm leading-6 text-muted-foreground ${containedPreservedLongTextClass}`}>{String(version.result)}</p> : null}
{version.comment ? ( {version.comment ? (
<div className="mt-3 rounded-md bg-muted/35 p-2 text-xs text-muted-foreground"> <div className={`mt-3 rounded-md bg-muted/35 p-2 text-xs text-muted-foreground ${containedLongTextClass}`}>
{pickAppText(locale, '修改意见', 'Revision note')}: {String(version.comment)} {pickAppText(locale, '修改意见', 'Revision note')}: {String(version.comment)}
</div> </div>
) : null} ) : null}
@ -181,7 +182,7 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
const shouldRenderResultAcceptance = Boolean(card.type === 'result' && resultAcceptance && card.runId === resultAcceptance.runId); const shouldRenderResultAcceptance = Boolean(card.type === 'result' && resultAcceptance && card.runId === resultAcceptance.runId);
return ( return (
<Card id={shouldRenderResultAcceptance ? reviewTargetId : undefined} className="rounded-md scroll-mt-28"> <Card id={shouldRenderResultAcceptance ? reviewTargetId : undefined} className="scroll-mt-28 overflow-hidden rounded-md">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex gap-3"> <div className="flex gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted"> <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted">
@ -197,7 +198,7 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
</Badge> </Badge>
</div> </div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground"> <div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
{card.actorName ? <span>{card.actorName}</span> : null} {card.actorName ? <span className={containedLongTextClass}>{card.actorName}</span> : null}
<span>{formatTaskRuntimeTime(card.createdAt, locale)}</span> <span>{formatTaskRuntimeTime(card.createdAt, locale)}</span>
{card.runId ? <span className="font-mono">{card.runId.slice(0, 8)}</span> : null} {card.runId ? <span className="font-mono">{card.runId.slice(0, 8)}</span> : null}
</div> </div>
@ -213,7 +214,7 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
) : null} ) : null}
</div> </div>
{card.summary ? <p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{card.summary}</p> : null} {card.summary ? <p className={`mt-3 text-sm leading-6 text-muted-foreground ${containedPreservedLongTextClass}`}>{card.summary}</p> : null}
{shouldRenderResultAcceptance ? ( {shouldRenderResultAcceptance ? (
<div className="mt-4 border-t border-border pt-4"> <div className="mt-4 border-t border-border pt-4">
@ -222,11 +223,11 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
) : null} ) : null}
{card.type === 'result_history' ? <TaskResultHistory card={card} /> : card.details ? ( {card.type === 'result_history' ? <TaskResultHistory card={card} /> : card.details ? (
<details className="mt-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs"> <details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-muted/20 px-3 py-2 text-xs">
<summary className="cursor-pointer select-none font-medium text-muted-foreground"> <summary className="cursor-pointer select-none font-medium text-muted-foreground">
{pickAppText(locale, '详情 JSON', 'Details JSON')} {pickAppText(locale, '详情 JSON', 'Details JSON')}
</summary> </summary>
<pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-5 text-muted-foreground"> <pre className={`mt-2 max-h-72 overflow-auto text-[11px] leading-5 text-muted-foreground ${containedJsonTextClass}`}>
{detailsJson(card.details)} {detailsJson(card.details)}
</pre> </pre>
</details> </details>

View File

@ -8,6 +8,12 @@ import type {
ChatLogsResponse, ChatLogsResponse,
BackendTask, BackendTask,
ChatMessage, ChatMessage,
ChannelConfigDetail,
ChannelConfigPayload,
ChannelConnectorDescriptor,
ConnectorSessionResponse,
ConnectorSessionStartPayload,
ChannelEventRecord,
CronJob, CronJob,
FileAttachment, FileAttachment,
NotificationDetail, NotificationDetail,
@ -638,6 +644,53 @@ export async function updateProviderConfig(
}); });
} }
export async function getChannelConfig(channelId: string): Promise<ChannelConfigDetail> {
return fetchJSON(`/api/channels/${encodeURIComponent(channelId)}/config`);
}
export async function updateChannelConfig(
channelId: string,
payload: ChannelConfigPayload
): Promise<{ ok: boolean; channel_id: string; restart_required: boolean; channel: ChannelConfigDetail }> {
return fetchJSON(`/api/channels/${encodeURIComponent(channelId)}/config`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function listChannelEvents(channelId: string, limit: number = 100): Promise<ChannelEventRecord[]> {
return fetchJSON(`/api/channels/${encodeURIComponent(channelId)}/events?limit=${limit}`);
}
export async function listChannelConnectors(): Promise<ChannelConnectorDescriptor[]> {
return fetchJSON('/api/channel-connectors');
}
export async function startChannelConnectorSession(
payload: ConnectorSessionStartPayload
): Promise<ConnectorSessionResponse> {
return fetchJSON('/api/channel-connector-sessions', {
method: 'POST',
body: JSON.stringify({
kind: payload.kind,
displayName: payload.displayName,
ownerUserId: payload.ownerUserId,
options: payload.options || {},
}),
});
}
export async function getChannelConnectorSession(sessionId: string): Promise<ConnectorSessionResponse> {
return fetchJSON(`/api/channel-connector-sessions/${encodeURIComponent(sessionId)}`);
}
export async function restartRuntime(): Promise<{ ok: boolean; restarting: boolean }> {
return fetchJSON('/api/runtime/restart', {
method: 'POST',
timeoutMs: 5000,
});
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Cron (proxied) // Cron (proxied)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
getChannelConnectorSession,
listChannelConnectors,
startChannelConnectorSession,
} from '@/lib/api';
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
if (typeof localStorage !== 'undefined') {
localStorage.clear();
}
vi.restoreAllMocks();
});
function mockJsonResponse(body: unknown) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(body),
} as Response);
}
function firstFetchCall(fetchMock: any): [unknown, RequestInit] {
return fetchMock.mock.calls[0] as [unknown, RequestInit];
}
describe('channel connector api', () => {
it('lists available channel connectors', async () => {
const fetchMock = vi.fn(() => mockJsonResponse([{ kind: 'weixin', displayName: 'Weixin' }]));
globalThis.fetch = fetchMock as typeof fetch;
const connectors = await listChannelConnectors();
expect(connectors).toEqual([{ kind: 'weixin', displayName: 'Weixin' }]);
expect(String(firstFetchCall(fetchMock)[0])).toMatch(/\/api\/channel-connectors$/);
});
it('starts a connector session with options', async () => {
const fetchMock = vi.fn(() =>
mockJsonResponse({
session: { sessionId: 'cs_1', kind: 'weixin', status: 'qr_ready' },
connection: { connection_id: 'conn_1', kind: 'weixin', status: 'pairing' },
})
);
globalThis.fetch = fetchMock as typeof fetch;
const response = await startChannelConnectorSession({
kind: 'weixin',
displayName: 'Weixin Main',
options: { mode: 'qr' },
});
expect(response.session.sessionId).toBe('cs_1');
const [, request] = firstFetchCall(fetchMock);
expect(String(firstFetchCall(fetchMock)[0])).toMatch(/\/api\/channel-connector-sessions$/);
expect(request).toEqual(
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ kind: 'weixin', displayName: 'Weixin Main', options: { mode: 'qr' } }),
})
);
});
it('polls a connector session by id', async () => {
const fetchMock = vi.fn(() =>
mockJsonResponse({
session: { sessionId: 'cs_1', kind: 'weixin', status: 'connected' },
connection: { connection_id: 'conn_1', kind: 'weixin', status: 'connected' },
})
);
globalThis.fetch = fetchMock as typeof fetch;
const response = await getChannelConnectorSession('cs_1');
expect(response.connection?.status).toBe('connected');
expect(String(firstFetchCall(fetchMock)[0])).toMatch(/\/api\/channel-connector-sessions\/cs_1$/);
});
});

View File

@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { getTaskCardMessageIndexes, mergeServerWithPendingUsers, shouldDisplayChatMessage, shouldMergePendingUsers } from '@/lib/chat-messages'; import {
getSessionRefreshIntervalMs,
getTaskCardMessageIndexes,
mergeServerWithPendingUsers,
shouldDisplayChatMessage,
shouldMergePendingUsers,
} from '@/lib/chat-messages';
import type { ChatMessage } from '@/types'; import type { ChatMessage } from '@/types';
describe('chat message helpers', () => { describe('chat message helpers', () => {
@ -98,4 +104,11 @@ describe('chat message helpers', () => {
expect(shouldDisplayChatMessage({ role: 'assistant', content: 'Final answer.', task_id: 'task-1', run_id: 'run-1' })).toBe(true); expect(shouldDisplayChatMessage({ role: 'assistant', content: 'Final answer.', task_id: 'task-1', run_id: 'run-1' })).toBe(true);
expect(shouldDisplayChatMessage({ role: 'user', content: '' })).toBe(true); expect(shouldDisplayChatMessage({ role: 'user', content: '' })).toBe(true);
}); });
it('keeps polling idle visible chats so external channel messages appear', () => {
expect(getSessionRefreshIntervalMs({ isLoading: true, isThinking: false, documentHidden: false })).toBe(1500);
expect(getSessionRefreshIntervalMs({ isLoading: false, isThinking: true, documentHidden: false })).toBe(1500);
expect(getSessionRefreshIntervalMs({ isLoading: false, isThinking: false, documentHidden: false })).toBe(5000);
expect(getSessionRefreshIntervalMs({ isLoading: false, isThinking: false, documentHidden: true })).toBeNull();
});
}); });

View File

@ -1,6 +1,26 @@
import type { ChatMessage } from '@/types'; import type { ChatMessage } from '@/types';
const INVISIBLE_CONTENT_CHARS = /[\u200B-\u200D\uFEFF]/g; const INVISIBLE_CONTENT_CHARS = /[\u200B-\u200D\uFEFF]/g;
export const CHAT_WAITING_REFRESH_INTERVAL_MS = 1500;
export const CHAT_IDLE_REFRESH_INTERVAL_MS = 5000;
export function getSessionRefreshIntervalMs({
isLoading,
isThinking,
documentHidden,
}: {
isLoading: boolean;
isThinking: boolean;
documentHidden: boolean;
}): number | null {
if (documentHidden) {
return null;
}
if (isLoading || isThinking) {
return CHAT_WAITING_REFRESH_INTERVAL_MS;
}
return CHAT_IDLE_REFRESH_INTERVAL_MS;
}
export function normalizedMessageText(content: unknown): string { export function normalizedMessageText(content: unknown): string {
if (typeof content === 'string') { if (typeof content === 'string') {

View File

@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { containedJsonTextClass, containedLongTextClass } from './text-wrapping';
const globalsCss = readFileSync(join(process.cwd(), 'app/globals.css'), 'utf8');
describe('contained long text classes', () => {
it('keeps long plain text inside its container', () => {
expect(containedLongTextClass).toBe('contained-long-text');
expect(globalsCss).toContain('.contained-long-text');
expect(globalsCss).toContain('overflow-wrap: anywhere');
expect(globalsCss).toContain('word-break: break-word');
});
it('keeps long JSON and monospace output inside its container', () => {
expect(containedJsonTextClass).toBe('contained-json-text');
expect(globalsCss).toContain('.contained-json-text');
expect(globalsCss).toContain('white-space: pre-wrap');
});
});

View File

@ -0,0 +1,5 @@
export const containedLongTextClass = 'contained-long-text';
export const containedPreservedLongTextClass = 'contained-preserved-long-text';
export const containedJsonTextClass = 'contained-json-text';

View File

@ -6,6 +6,7 @@ const config: Config = {
'./pages/**/*.{js,ts,jsx,tsx,mdx}', './pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}', './components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}', './app/**/*.{js,ts,jsx,tsx,mdx}',
'./lib/**/*.{js,ts,jsx,tsx,mdx}',
], ],
theme: { theme: {
extend: { extend: {

View File

@ -148,9 +148,116 @@ export interface AgentConfigPayload {
max_tool_iterations: number; max_tool_iterations: number;
} }
export interface ChannelStatus { export interface ChannelConfigDetail {
name: string; channel_id: string;
enabled: boolean; enabled: boolean;
kind: string;
mode: string;
account_id: string;
display_name: string;
config: Record<string, unknown>;
secrets: Record<string, string>;
}
export interface ChannelConfigPayload {
enabled: boolean;
kind: string;
mode: string;
account_id?: string;
display_name?: string;
config: Record<string, unknown>;
secrets: Record<string, string>;
}
export interface ChannelStatus {
channel_id: string;
name?: string;
kind: string;
mode: string;
display_name: string;
enabled: boolean;
state: 'configured' | 'disabled' | 'starting' | 'running' | 'degraded' | 'error' | 'stopped';
account_id: string;
last_error?: string | null;
last_event_at?: string | null;
started_at?: string | null;
capabilities: string[];
webhook_url?: string | null;
websocket_url?: string | null;
connected_peers?: number;
}
export interface ChannelEventRecord {
event_id: string;
channel_id: string;
kind: string;
session_id?: string | null;
message_id?: string | null;
run_id?: string | null;
status: string;
error?: string | null;
text_preview?: string | null;
text_length?: number;
created_at: string;
metadata?: Record<string, unknown>;
}
export interface ChannelConnectorDescriptor {
kind: string;
displayName?: string;
display_name?: string;
authType?: string;
auth_type?: string;
providerId?: string;
provider_id?: string;
capabilities?: string[];
}
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<string, unknown>;
capabilities?: string[];
created_at?: string;
updated_at?: string;
last_seen_at?: string | null;
last_error?: string | null;
}
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<string, unknown>;
}
export interface ConnectorSessionResponse {
session: ConnectorSessionView;
connection?: ChannelConnectionView | null;
}
export interface ConnectorSessionStartPayload {
kind: string;
displayName?: string;
ownerUserId?: string;
options?: Record<string, unknown>;
}
export interface RuntimeControls {
self_restart: boolean;
} }
export interface SystemStatus { export interface SystemStatus {
@ -165,6 +272,7 @@ export interface SystemStatus {
max_tool_iterations: number; max_tool_iterations: number;
providers: ProviderStatus[]; providers: ProviderStatus[];
channels: ChannelStatus[]; channels: ChannelStatus[];
runtime_controls?: RuntimeControls;
cron: { cron: {
enabled: boolean; enabled: boolean;
jobs: number; jobs: number;

View File

@ -36,6 +36,15 @@ http {
proxy_pass http://127.0.0.1:18080; proxy_pass http://127.0.0.1:18080;
} }
location /api/channels/ {
proxy_pass http://127.0.0.1:18080;
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;
}
location /api/ { location /api/ {
proxy_pass http://127.0.0.1:18080; proxy_pass http://127.0.0.1:18080;
} }
@ -69,4 +78,3 @@ http {
} }
} }
} }

View File

@ -52,18 +52,18 @@ export default function LoginPage() {
className="login-logo" className="login-logo"
priority priority
/> />
<h1>Beaver Agentsandbox</h1> <h1>Beaver AgentSandbox</h1>
<form className="auth-form" onSubmit={handleSubmit}> <form className="auth-form" onSubmit={handleSubmit}>
<div className="field login-field"> <div className="field login-field">
<label className="visually-hidden" htmlFor="username">{pickPortalText(locale, '邮箱或用户名', 'Email or username')}</label> <label className="visually-hidden" htmlFor="username">{pickPortalText(locale, '用户名', 'Username')}</label>
<MailIcon /> <UserIcon />
<input <input
id="username" id="username"
value={username} value={username}
onChange={(event) => setUsername(event.target.value)} onChange={(event) => setUsername(event.target.value)}
autoComplete="username" autoComplete="username"
placeholder={pickPortalText(locale, '邮箱', 'Email')} placeholder={pickPortalText(locale, '用户名', 'Username')}
required required
/> />
</div> </div>
@ -114,11 +114,11 @@ export default function LoginPage() {
); );
} }
function MailIcon() { function UserIcon() {
return ( return (
<svg className="field-icon" viewBox="0 0 24 24" aria-hidden="true"> <svg className="field-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M4.75 6.75h14.5v10.5H4.75z" /> <path d="M12 12.25a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5Z" />
<path d="m5.25 7.25 6.75 5.5 6.75-5.5" /> <path d="M5.75 19.25a6.25 6.25 0 0 1 12.5 0" />
</svg> </svg>
); );
} }

View File

@ -182,7 +182,7 @@ export default function RegisterPage() {
</div> </div>
) : ( ) : (
<div className="auth-card login-card register-card"> <div className="auth-card login-card register-card">
<BrandHeader title="Beaver Agentsandbox" /> <BrandHeader title="Beaver AgentSandbox" />
<form className="auth-form" onSubmit={handleSubmit}> <form className="auth-form" onSubmit={handleSubmit}>
<div className="field login-field"> <div className="field login-field">

View File

@ -0,0 +1,32 @@
services:
external-connector:
build: ./external-connector
container_name: 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_PUBLIC_BASE_URL: ${CONNECTOR_PUBLIC_BASE_URL:-http://localhost:8787}
CONNECTOR_PROVIDER: ${CONNECTOR_PROVIDER:-vendor_cli}
CONNECTOR_COMMAND_TIMEOUT_SECONDS: ${CONNECTOR_COMMAND_TIMEOUT_SECONDS:-120}
WEIXIN_CONNECT_COMMAND: ${WEIXIN_CONNECT_COMMAND:-}
WEIXIN_STATUS_COMMAND: ${WEIXIN_STATUS_COMMAND:-}
WEIXIN_SEND_COMMAND: ${WEIXIN_SEND_COMMAND:-}
FEISHU_CONNECT_COMMAND: ${FEISHU_CONNECT_COMMAND:-}
FEISHU_STATUS_COMMAND: ${FEISHU_STATUS_COMMAND:-}
FEISHU_SEND_COMMAND: ${FEISHU_SEND_COMMAND:-}
volumes:
- external-connector-state:/var/lib/external-connector
ports:
- "${EXTERNAL_CONNECTOR_PORT:-8787}:8787"
networks:
- beaver-instance-edge
volumes:
external-connector-state:
networks:
beaver-instance-edge:
external: true

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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<string, unknown>;
capabilities: string[];
created_at: string;
updated_at: string;
last_seen_at?: string | null;
last_error?: string | null;
}
export interface ChannelConnectionResponse {
connection: ChannelConnectionView;
credentials?: Record<string, string>;
}
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<string, unknown>;
}
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<ChannelConnectorDescriptor[]> {
return fetchJSON('/api/channel-connectors');
}
export async function listChannelConnections(): Promise<ChannelConnectionView[]> {
return fetchJSON('/api/channel-connections');
}
export async function startConnectorSession(params: {
kind: string;
displayName?: string;
ownerUserId?: string;
options?: Record<string, unknown>;
}): Promise<ConnectorSessionResponse> {
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<ConnectorSessionResponse> {
return fetchJSON(`/api/channel-connector-sessions/${encodeURIComponent(sessionId)}`, {
timeoutMs: 45000,
});
}
export async function revokeChannelConnection(connectionId: string): Promise<ChannelConnectionResponse> {
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<ChannelConnectorDescriptor, 'kind' | 'displayName' | 'display_name'>): 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<string, string> = {
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> | void;
};
export function ChannelConnectorWizard({ connectors, connections, onChanged }: Props) {
const [activeKind, setActiveKind] = useState<string | null>(null);
const [session, setSession] = useState<ConnectorSessionView | null>(null);
const [connection, setConnection] = useState<ChannelConnectionView | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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 (
<section className="space-y-3">
<div className="grid gap-3 md:grid-cols-3">
{visibleConnectors.map((connector) => {
const existing = connections.find((item) => item.kind === connector.kind && item.status !== 'revoked');
return (
<Card key={connector.kind} className="rounded-md">
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between text-base">
<span>{connectorDisplayName(connector)}</span>
{existing ? <Badge variant="secondary">{existing.status}</Badge> : null}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{connector.kind === 'feishu' ? (
<div className="space-y-1">
<Label htmlFor="feishu-domain">Domain</Label>
<Input id="feishu-domain" value={feishuDomain} onChange={(event) => setFeishuDomain(event.target.value)} />
</div>
) : null}
{existing ? (
<div className="flex items-center justify-between gap-2 text-sm">
<span className="truncate">{existing.display_name || existing.account_id || existing.channel_id}</span>
<Button size="sm" variant="outline" onClick={() => revoke(existing)} disabled={busy}>
<Unplug className="mr-2 h-4 w-4" />
Logout
</Button>
</div>
) : (
<Button size="sm" onClick={() => start(connector.kind)} disabled={busy || connector.kind === 'telegram'}>
{busy && activeKind === connector.kind ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <QrCode className="mr-2 h-4 w-4" />}
{connector.kind === 'telegram' ? 'Use token setup' : 'Connect'}
</Button>
)}
</CardContent>
</Card>
);
})}
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Dialog open={Boolean(activeKind && session)} onOpenChange={(open) => !open && setActiveKind(null)}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>{activeKind ? connectorDisplayName({ kind: activeKind }) : 'Connector'}</DialogTitle>
</DialogHeader>
{session ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Badge variant={session.status === 'connected' ? 'default' : 'secondary'}>
{connectorStatusLabel(session.status)}
</Badge>
{session.status === 'connected' ? <CheckCircle2 className="h-5 w-5 text-emerald-600" /> : <RefreshCw className="h-5 w-5 text-muted-foreground" />}
</div>
{session.qrImage ? (
<img alt="Connector QR code" src={session.qrImage} className="mx-auto aspect-square w-64 rounded-md border object-contain" />
) : null}
{session.instructions && session.instructions.length > 0 ? (
<div className="space-y-2 rounded-md border p-3 text-sm">
{session.instructions.map((item) => <p key={item}>{item}</p>)}
</div>
) : null}
{connection ? <p className="text-sm text-muted-foreground">{connection.display_name || connection.account_id}</p> : null}
{session.error ? <p className="text-sm text-destructive">{session.error}</p> : null}
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => setActiveKind(null)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}
```
- [ ] **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<ChannelConnectorDescriptor[]>([]);
const [channelConnections, setChannelConnections] = useState<ChannelConnectionView[]>([]);
```
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
<section className="space-y-3">
<div>
<h2 className="text-lg font-semibold">{pickAppText(locale, '连接器', 'Connectors')}</h2>
<p className="text-sm text-muted-foreground">
{pickAppText(locale, '连接微信或飞书后会立即进入运行时。', 'Connected Weixin or Feishu channels activate immediately.')}
</p>
</div>
<ChannelConnectorWizard
connectors={channelConnectors}
connections={channelConnections}
onChanged={async () => {
await loadChannelConnectors();
await loadStatus();
}}
/>
</section>
```
- [ ] **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.

File diff suppressed because it is too large Load Diff

View File

@ -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
<channel_id>:<account_id>:<peer_id>[:<thread_id>]
```
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.

View File

@ -0,0 +1,404 @@
# 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 or ExternalConnectorChannel
-> MessageBus
-> AgentService
```
The existing `ChannelRuntime`, `MessageBus`, `ChannelManager`, and `ChannelAdapter` contracts remain the message routing core. The new connector layer owns user-visible setup and connection lifecycle. For platforms backed by predeclared sidecar services, Beaver should expose the sidecar to the runtime as an `ExternalConnectorChannel` rather than a Beaver-owned platform protocol adapter.
## Why This Is Required
The current channel design assumes a channel is already configured before the backend starts. That is enough for local development and simple webhook/token channels, but it does not match real platform onboarding:
- Feishu/Lark now has a Channel SDK pattern that packages bot channel setup, WebSocket or webhook transport, event handling, and replies around an installed app identity.
- Weixin personal-account setup uses a docker-compose predeclared sidecar connector plus QR login and persistent login state.
- Terminal devices need pairing or device registration; a raw `peer_id` connect frame is not enough for a real deployment.
- Even simple token platforms such as Telegram need a UI flow for token entry, validation, status, revoke, and restart.
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, checks preconfigured connector endpoints when needed, handles reconnects, and emits runtime channel config.
`ChannelAdapter` is the message transport adapter used by `ChannelRuntime`. It receives normalized inbound messages and sends outbound replies. It does not own onboarding.
`ExternalConnectorChannel` is the runtime channel object used when a platform protocol lives outside the Python backend. It implements the same `start()`, `stop()`, and `send()` contract as an adapter, but its `send()` method calls an external connector HTTP API and inbound messages enter Beaver through a connector bridge endpoint.
`ExternalConnectorProcess` is an optional preconfigured service for platforms whose SDK or login behavior is better isolated outside the Python backend. For Weixin, this process is a docker-compose predeclared sidecar service. Beaver must not dynamically create containers or require Docker socket access.
## Data Model
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 ExternalConnectorChannel
```
The external process must not receive permanent backend admin credentials through QR codes or copied commands. It should receive a short-lived pairing token with a narrow scope:
```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. For docker-compose sidecars, that token is passed through the connector HTTP API or service configuration agreed for that sidecar; Beaver does not create or restart the sidecar container.
## Per-Channel Assessment
### 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 a docker-compose predeclared sidecar connector.
Recommended first implementation:
- connector kind: `weixin`
- setup mode: Beaver calls the sidecar HTTP API to start QR login and poll pairing state.
- external process: required, predeclared in docker-compose, and never dynamically created by Beaver.
- runtime channel: `ExternalConnectorChannel`.
Required setup checks:
- sidecar base URL is configured.
- sidecar health endpoint responds.
- connector version is compatible with Beaver.
- QR session is pending, scanned, confirmed, expired, or failed.
- login state is stored behind `credentials_ref`.
- connector heartbeat is visible.
The sidecar owns Weixin protocol handling, QR login, inbound receive, outbound send, and login-state persistence. Beaver owns connector setup state, bridge API validation, message normalization boundaries, runtime dedupe, and outbound HTTP calls to the sidecar `/send` API.
The agreed runtime flow is:
```text
Weixin sidecar connector
-> Beaver connector bridge endpoint
-> ChannelRuntime.accept_inbound()
-> MessageBus
-> AgentService
AgentService
-> MessageBus outbound
-> ExternalConnectorChannel.send()
-> Weixin sidecar connector /send
```
Group delivery remains best-effort. The connector must surface group capability separately from direct message capability.
### Telegram
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.

View File

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

View File

@ -0,0 +1,592 @@
# 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.
- 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
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
- service-level connector 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<ProviderHealth>;
startSession(input: StartConnectorSessionInput): Promise<ConnectorSessionView>;
getSession(sessionId: string): Promise<ConnectorSessionView>;
cancelSession(sessionId: string): Promise<void>;
logout(connectionId: string): Promise<void>;
send(input: SendMessageInput): Promise<SendMessageResult>;
}
```
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`
- `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={},
)
```
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.
Add runtime methods:
```python
async def add_channel(self, channel_id: str, config: ChannelConfig) -> None:
...
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
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_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
```
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=<service-level shared secret>`.
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",
"options": {}
}
```
The sidecar authenticates the connector-session request with `Authorization: Bearer <EXTERNAL_CONNECTOR_TOKEN>`. 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:
```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
{
"requestId": "out_...",
"connectionId": "conn_...",
"channelId": "weixin-main",
"kind": "weixin",
"target": {
"peerId": "wx_user_or_chat_id",
"peerType": "dm",
"threadId": null
},
"content": "reply text",
"metadata": {
"contextToken": "optional"
}
}
```
`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
Add a backend bridge endpoint for sidecar inbound messages:
```text
POST /api/channel-connector-bridge/events
```
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:
```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
<workspace>/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`
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.
## 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 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
- 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 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 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
- Beaver never logs raw tokens, app secrets, bridge tokens, or sidecar connection tokens.
- 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.
- 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
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
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`
- send `processing` TTL returns `409 Conflict` before stale retry
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.

View File

@ -0,0 +1,23 @@
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 package.json ./
RUN pip install --no-cache-dir \
"fastapi>=0.115.0,<1.0" \
"httpx>=0.27.0,<1.0" \
"pydantic>=2.7.0,<3.0" \
"qrcode>=8.0,<9.0" \
"uvicorn[standard]>=0.30.0,<1.0" \
&& npm install --omit=dev --package-lock=false
COPY external_connector ./external_connector
ENV CONNECTOR_HOME=/var/lib/external-connector
EXPOSE 8787
CMD ["python", "-m", "external_connector.main"]

View File

@ -0,0 +1 @@
"""Generic external connector sidecar."""

View File

@ -0,0 +1,74 @@
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
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", response_model=None)
def send(payload: SendRequest, authorization: str | None = Header(default=None)) -> JSONResponse | dict[str, Any]:
require_auth(authorization)
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
if hasattr(provider, "handle_event"):
@app.post("/feishu/events", response_model=None)
def feishu_events(payload: dict[str, Any]) -> JSONResponse | dict[str, Any]:
result = dict(provider.handle_event(payload)) # type: ignore[attr-defined]
status_code = int(result.pop("httpStatus", 200))
if status_code != 200:
return JSONResponse(status_code=status_code, content=result)
return result
return app

View File

@ -0,0 +1,62 @@
from __future__ import annotations
import os
from pathlib import Path
import uvicorn
from external_connector.app import create_app
from external_connector.providers.composite import CompositeProvider
from external_connector.providers.fake import FakeProvider
from external_connector.providers.feishu_bot import FeishuBotProvider
from external_connector.providers.vendor_cli import VendorCliProvider
from external_connector.providers.weixin_ilink import WeixinIlinkProvider
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 == "official":
provider = CompositeProvider([
_weixin_provider(store),
_feishu_provider(store),
])
elif provider_name == "weixin_ilink":
provider = _weixin_provider(store)
elif provider_name == "feishu_bot":
provider = _feishu_provider(store)
elif 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", ""))
def _weixin_provider(store: SidecarStateStore) -> WeixinIlinkProvider:
return WeixinIlinkProvider(
store=store,
bridge_base_url=os.getenv("BEAVER_BRIDGE_BASE_URL", ""),
bridge_token=os.getenv("BEAVER_BRIDGE_TOKEN", ""),
)
def _feishu_provider(store: SidecarStateStore) -> FeishuBotProvider:
return FeishuBotProvider(
store=store,
bridge_base_url=os.getenv("BEAVER_BRIDGE_BASE_URL", ""),
public_base_url=os.getenv("CONNECTOR_PUBLIC_BASE_URL", ""),
bridge_token=os.getenv("BEAVER_BRIDGE_TOKEN", ""),
)
app = build_app()
def main() -> None:
uvicorn.run(app, host="0.0.0.0", port=8787)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,24 @@
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)

View File

@ -0,0 +1,149 @@
const Lark = require("@larksuiteoapi/node-sdk");
const appId = requireEnv("FEISHU_APP_ID");
const appSecret = requireEnv("FEISHU_APP_SECRET");
const connectionId = requireEnv("FEISHU_CONNECTION_ID");
const channelId = requireEnv("FEISHU_CHANNEL_ID");
const accountId = process.env.FEISHU_ACCOUNT_ID || `feishu:${appId}`;
const bridgeBaseUrl = requireEnv("BEAVER_BRIDGE_BASE_URL").replace(/\/+$/, "");
const bridgeToken = requireEnv("BEAVER_BRIDGE_TOKEN");
const domain = (process.env.FEISHU_DOMAIN || "feishu").toLowerCase() === "lark"
? Lark.Domain.Lark
: Lark.Domain.Feishu;
const wsClient = new Lark.WSClient({
appId,
appSecret,
domain,
loggerLevel: Lark.LoggerLevel.info,
onReady: () => log("feishu_ws_ready", {}),
onError: (error) => log("feishu_ws_error", { error: redact(String(error && error.message ? error.message : error)) }),
onReconnecting: () => log("feishu_ws_reconnecting", {}),
onReconnected: () => log("feishu_ws_reconnected", {}),
handshakeTimeoutMs: 15000,
wsConfig: { pingTimeout: 10 },
});
const dispatcher = new Lark.EventDispatcher({}).register({
"im.message.receive_v1": async (data) => {
const event = bridgeEventFromFeishu(data);
log("feishu_inbound_message", {
connectionId,
eventId: event.eventId,
messageId: event.messageId,
peerId: event.peerId,
textLength: event.content.length,
});
await postJson(`${bridgeBaseUrl}/api/channel-connector-bridge/events`, event);
},
});
process.on("SIGTERM", () => {
wsClient.close({ force: true });
process.exit(0);
});
process.on("SIGINT", () => {
wsClient.close({ force: true });
process.exit(0);
});
wsClient.start({ eventDispatcher: dispatcher }).catch((error) => {
log("feishu_ws_start_failed", { error: redact(String(error && error.message ? error.message : error)) });
process.exit(1);
});
setInterval(() => {
if (typeof wsClient.getConnectionStatus === "function") {
log("feishu_ws_status", wsClient.getConnectionStatus());
}
}, 60000).unref();
function bridgeEventFromFeishu(data) {
const message = objectValue(data.message);
const sender = objectValue(data.sender);
const senderId = objectValue(sender.sender_id);
const peerId = stringValue(senderId.open_id || senderId.user_id || "");
const messageId = stringValue(message.message_id || randomId());
const eventId = stringValue(data.event_id || data.eventId || `${channelId}:${messageId}`);
return {
eventId,
timestamp: new Date().toISOString(),
deliveryAttempt: 1,
connectionId,
channelId,
kind: "feishu",
accountId,
peerId,
peerType: message.chat_type === "group" ? "group" : "dm",
userId: peerId,
threadId: stringValue(message.chat_id || "") || null,
messageId,
messageType: stringValue(message.message_type || "text"),
content: extractText(message),
metadata: {
chatId: message.chat_id || null,
rawMessageType: message.message_type || null,
},
};
}
function extractText(message) {
const content = message.content;
if (typeof content !== "string") {
return "";
}
try {
const parsed = JSON.parse(content);
if (parsed && parsed.text != null) {
return String(parsed.text);
}
} catch (_error) {
return content;
}
return content;
}
async function postJson(url, payload) {
const response = await fetch(url, {
method: "POST",
headers: {
"Authorization": `Bearer ${bridgeToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`bridge post failed ${response.status}: ${text.slice(0, 300)}`);
}
}
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}
function objectValue(value) {
return value && typeof value === "object" ? value : {};
}
function stringValue(value) {
return value == null ? "" : String(value);
}
function randomId() {
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function log(event, fields) {
console.log(JSON.stringify({ event, ...fields }));
}
function redact(text) {
return text
.replace(appSecret, "***")
.replace(bridgeToken, "***");
}

View File

@ -0,0 +1,28 @@
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]:
...

View File

@ -0,0 +1,70 @@
from __future__ import annotations
from typing import Any
from external_connector.providers.base import ConnectorProvider
class CompositeProvider:
provider_id = "composite"
def __init__(self, providers: list[ConnectorProvider]) -> None:
self.providers = providers
self._by_kind: dict[str, ConnectorProvider] = {}
for provider in providers:
for connector in provider.connectors():
self._by_kind[str(connector["kind"])] = provider
def connectors(self) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
for provider in self.providers:
items.extend(provider.connectors())
return items
def health(self) -> dict[str, Any]:
return {"ok": True, "providerId": self.provider_id, "providers": [provider.health() for provider in self.providers]}
def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
return self._provider(str(payload["kind"])).start_session(payload)
def get_session(self, session_id: str) -> dict[str, Any]:
for provider in self.providers:
try:
return provider.get_session(session_id)
except KeyError:
continue
raise KeyError(session_id)
def cancel_session(self, session_id: str) -> None:
for provider in self.providers:
try:
provider.cancel_session(session_id)
return None
except KeyError:
continue
raise KeyError(session_id)
def logout(self, connection_id: str) -> None:
for provider in self.providers:
provider.logout(connection_id)
return None
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
return self._provider(str(payload["kind"])).send(payload)
def handle_event(self, payload: dict[str, Any]) -> dict[str, Any]:
for provider in self.providers:
handler = getattr(provider, "handle_event", None)
if handler is None:
continue
try:
return dict(handler(payload))
except KeyError:
continue
raise KeyError("No event provider matched")
def _provider(self, kind: str) -> ConnectorProvider:
provider = self._by_kind.get(kind)
if provider is None:
raise KeyError(f"Unsupported connector kind: {kind}")
return provider

View File

@ -0,0 +1,119 @@
from __future__ import annotations
import base64
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),
}
def _fake_qr_image() -> str:
svg = (
'<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">'
'<rect width="256" height="256" fill="#fff"/>'
'<rect x="16" y="16" width="58" height="58" fill="#111"/>'
'<rect x="28" y="28" width="34" height="34" fill="#fff"/>'
'<rect x="184" y="16" width="58" height="58" fill="#111"/>'
'<rect x="196" y="28" width="34" height="34" fill="#fff"/>'
'<rect x="16" y="184" width="58" height="58" fill="#111"/>'
'<rect x="28" y="196" width="34" height="34" fill="#fff"/>'
'<rect x="96" y="96" width="18" height="18" fill="#111"/>'
'<rect x="132" y="96" width="18" height="18" fill="#111"/>'
'<rect x="168" y="96" width="18" height="18" fill="#111"/>'
'<rect x="96" y="132" width="18" height="18" fill="#111"/>'
'<rect x="150" y="132" width="18" height="18" fill="#111"/>'
'<rect x="204" y="132" width="18" height="18" fill="#111"/>'
'<rect x="114" y="168" width="18" height="18" fill="#111"/>'
'<rect x="168" y="168" width="18" height="18" fill="#111"/>'
'<rect x="204" y="168" width="18" height="18" fill="#111"/>'
'<rect x="96" y="204" width="18" height="18" fill="#111"/>'
'<rect x="132" y="204" width="18" height="18" fill="#111"/>'
'<rect x="186" y="204" width="18" height="18" fill="#111"/>'
'<text x="128" y="248" text-anchor="middle" font-family="monospace" font-size="14" fill="#444">FAKE QR</text>'
"</svg>"
)
encoded = base64.b64encode(svg.encode("utf-8")).decode("ascii")
return f"data:image/svg+xml;base64,{encoded}"
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=_fake_qr_image() 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:
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)
return {"ok": True, "providerMessageId": provider_message_id}

View File

@ -0,0 +1,542 @@
from __future__ import annotations
import base64
import json
import os
import subprocess
import threading
import time
from collections.abc import Callable
from io import BytesIO
from pathlib import Path
from typing import Any
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from uuid import uuid4
import httpx
import qrcode
import qrcode.image.svg
from external_connector.providers.fake import _session_view
from external_connector.state import ConnectorSessionState, SidecarStateStore
BridgePoster = Callable[[str, dict[str, Any], dict[str, str]], None]
ReceiverStart = Callable[[ConnectorSessionState], object]
class FeishuBotProvider:
provider_id = "feishu_bot"
def __init__(
self,
*,
store: SidecarStateStore,
http_client: Any | None = None,
bridge_base_url: str,
public_base_url: str | None = None,
bridge_token: str,
bridge_post: BridgePoster | None = None,
api_base_url: str = "",
start_receivers: bool = True,
receiver_start: ReceiverStart | None = None,
) -> None:
self.store = store
self.http = http_client or httpx.Client(timeout=30)
self.bridge_base_url = bridge_base_url.rstrip("/")
self.public_base_url = (public_base_url or bridge_base_url).rstrip("/")
self.bridge_token = bridge_token
self.bridge_post = bridge_post or self._default_bridge_post
self.api_base_url = api_base_url.rstrip("/")
self.start_receivers = start_receivers
self.receiver_start = receiver_start or self._start_receiver_process
self._receiver_processes: dict[str, object] = {}
self._receiver_lock = threading.Lock()
self._start_existing_connected_receivers()
def connectors(self) -> list[dict[str, Any]]:
return [
{
"kind": "feishu",
"displayName": "Feishu/Lark",
"authType": "qr_or_bot_app",
"providerId": self.provider_id,
"capabilities": ["receive_text", "send_text", "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"])
if kind != "feishu":
raise KeyError(f"Unsupported connector kind: {kind}")
options = dict(payload.get("options") or {})
session = self.store.create_session(
kind=kind,
connection_id=str(payload["connectionId"]),
channel_id=str(payload["channelId"]),
display_name=str(payload["displayName"]),
options=options,
)
metadata = {
"eventCallbackPath": "/feishu/events",
"eventCallbackUrl": f"{self.public_base_url}/feishu/events",
}
app_id = str(options.get("appId") or options.get("app_id") or "").strip()
app_secret = str(options.get("appSecret") or options.get("app_secret") or "").strip()
verification_token = str(options.get("verificationToken") or options.get("verification_token") or "").strip()
mode = str(options.get("mode") or "create").strip().lower()
domain = _domain(options)
if not app_id or not app_secret:
if mode != "link":
return self._start_registration_session(session, metadata=metadata, domain=domain)
session = self.store.update_session(
session.session_id,
status="waiting_for_user",
instructions=_link_instructions(metadata["eventCallbackUrl"]),
metadata=metadata,
)
return _session_view(session)
token_data = self._tenant_token(app_id, app_secret, domain=domain)
metadata.update(
{
"appId": app_id,
"appSecret": app_secret,
"verificationToken": verification_token,
"tenantAccessToken": token_data["token"],
"tenantTokenExpiresAt": token_data["expires_at"],
"domain": domain,
}
)
session = self.store.update_session(
session.session_id,
status="connected",
account_id=f"feishu:{app_id}",
metadata=metadata,
instructions=_connected_instructions(),
)
self._ensure_receiver(session)
return _session_view(session)
def get_session(self, session_id: str) -> dict[str, Any]:
session = self.store.get_session(session_id)
if session.kind != "feishu":
raise KeyError(session_id)
if session.kind == "feishu" and session.status in {"qr_ready", "scanned", "confirmed", "waiting_for_user"} and session.metadata.get("deviceCode"):
session = self._poll_registration_session(session)
if session.status == "connected":
self._ensure_receiver(session)
return _session_view(session)
def cancel_session(self, session_id: str) -> None:
session = self.store.get_session(session_id)
if session.kind != "feishu":
raise KeyError(session_id)
self.store.update_session(session_id, status="cancelled")
self._stop_receiver(session.connection_id)
def logout(self, connection_id: str) -> None:
try:
session = self.store.find_session_by_connection_id(connection_id)
except KeyError:
return None
self._stop_receiver(connection_id)
self.store.update_session(session.session_id, status="cancelled")
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:
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}
session = self.store.find_session_by_connection_id(str(payload["connectionId"]))
token = self._tenant_token_for_session(session)
target = dict(payload.get("target") or {})
metadata = dict(session.metadata)
api_base = _open_api_base_url(str(metadata.get("domain") or "feishu"), self.api_base_url)
try:
response = self.http.post(
f"{api_base}/open-apis/im/v1/messages?receive_id_type=open_id",
json={
"receive_id": str(target.get("peerId") or ""),
"msg_type": "text",
"content": json.dumps({"text": str(payload.get("content") or "")}, ensure_ascii=False),
},
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
timeout=20,
)
response.raise_for_status()
except Exception as exc:
error = str(exc)
self.store.fail_send(begin.dedupe_key, error=error)
return {"ok": False, "error": error, "httpStatus": 502}
data = dict(response.json())
if int(data.get("code") or 0) != 0:
error = str(data.get("msg") or data)
self.store.fail_send(begin.dedupe_key, error=error)
return {"ok": False, "error": error, "httpStatus": 502}
provider_message_id = str((data.get("data") or {}).get("message_id") or f"feishu_{payload['requestId']}")
self.store.complete_send(begin.dedupe_key, provider_message_id=provider_message_id)
return {"ok": True, "providerMessageId": provider_message_id}
def handle_event(self, payload: dict[str, Any]) -> dict[str, Any]:
challenge = payload.get("challenge")
if challenge:
return {"challenge": challenge}
header = dict(payload.get("header") or {})
event = dict(payload.get("event") or {})
app_id = str(header.get("app_id") or "")
session = self._session_for_app_id(app_id)
expected_token = str(session.metadata.get("verificationToken") or "")
received_token = str(header.get("token") or payload.get("token") or "")
if expected_token and received_token != expected_token:
return {"ok": False, "error": "invalid verification token", "httpStatus": 401}
bridge_event = _bridge_event_from_feishu(session, header, event)
self.bridge_post(
f"{self.bridge_base_url}/api/channel-connector-bridge/events",
bridge_event,
{"Authorization": f"Bearer {self.bridge_token}"},
)
return {"ok": True}
def _tenant_token_for_session(self, session: ConnectorSessionState) -> str:
metadata = dict(session.metadata)
expires_at = float(metadata.get("tenantTokenExpiresAt") or 0)
token = str(metadata.get("tenantAccessToken") or "")
if token and expires_at - time.time() > 60:
return token
app_id = str(metadata.get("appId") or "")
app_secret = str(metadata.get("appSecret") or "")
token_data = self._tenant_token(app_id, app_secret, domain=str(metadata.get("domain") or "feishu"))
metadata.update({"tenantAccessToken": token_data["token"], "tenantTokenExpiresAt": token_data["expires_at"]})
self.store.update_session(session.session_id, metadata=metadata)
return str(token_data["token"])
def _tenant_token(self, app_id: str, app_secret: str, *, domain: str = "feishu") -> dict[str, Any]:
response = self.http.post(
f"{_open_api_base_url(domain, self.api_base_url)}/open-apis/auth/v3/tenant_access_token/internal",
json={"app_id": app_id, "app_secret": app_secret},
timeout=20,
)
response.raise_for_status()
data = dict(response.json())
if int(data.get("code") or 0) != 0:
raise RuntimeError(str(data.get("msg") or data))
return {"token": str(data["tenant_access_token"]), "expires_at": time.time() + int(data.get("expire") or 7200)}
def _session_for_app_id(self, app_id: str) -> ConnectorSessionState:
sessions = self.store.list_sessions()
for session in sorted(sessions, key=lambda item: item.updated_at, reverse=True):
if session.kind == "feishu" and session.status == "connected" and session.metadata.get("appId") == app_id:
return session
raise KeyError(app_id)
def _default_bridge_post(self, url: str, payload: dict[str, Any], headers: dict[str, str]) -> None:
response = self.http.post(url, json=payload, headers=headers, timeout=20)
response.raise_for_status()
def _start_registration_session(
self,
session: ConnectorSessionState,
*,
metadata: dict[str, Any],
domain: str,
) -> dict[str, Any]:
try:
init_data = self._registration_post(domain, {"action": "init"})
supported = init_data.get("supported_auth_methods") or []
if "client_secret" not in supported:
session = self.store.update_session(
session.session_id,
status="error",
error="Current Feishu/Lark environment does not support client_secret bot registration",
metadata=metadata,
)
return _session_view(session)
begin_data = self._registration_post(
domain,
{
"action": "begin",
"archetype": "PersonalAgent",
"auth_method": "client_secret",
"request_user_info": "open_id",
},
)
except Exception as exc:
session = self.store.update_session(
session.session_id,
status="error",
error=str(exc),
metadata=metadata,
)
return _session_view(session)
qr_code = _with_onboard_from(str(begin_data.get("verification_uri_complete") or ""))
if not qr_code:
session = self.store.update_session(
session.session_id,
status="error",
error="Feishu/Lark registration did not return a QR URL",
metadata=metadata,
)
return _session_view(session)
metadata.update(
{
"domain": domain,
"registrationBaseUrl": _registration_base_url(domain),
"deviceCode": str(begin_data.get("device_code") or ""),
"pollIntervalSeconds": int(begin_data.get("interval") or 5),
"expiresAt": time.time() + int(begin_data.get("expire_in") or 600),
}
)
session = self.store.update_session(
session.session_id,
status="qr_ready",
qr_code=qr_code,
qr_image=_qr_svg_data_uri(qr_code),
instructions=_create_instructions(metadata["eventCallbackUrl"]),
metadata=metadata,
)
return _session_view(session)
def _poll_registration_session(self, session: ConnectorSessionState) -> ConnectorSessionState:
metadata = dict(session.metadata)
expires_at = float(metadata.get("expiresAt") or 0)
if expires_at and time.time() > expires_at:
return self.store.update_session(session.session_id, status="expired", metadata=metadata)
domain = str(metadata.get("domain") or "feishu")
device_code = str(metadata.get("deviceCode") or "")
if not device_code:
return session
try:
poll_data = self._registration_post(domain, {"action": "poll", "device_code": device_code})
except Exception as exc:
return self.store.update_session(session.session_id, status="error", error=str(exc), metadata=metadata)
user_info = dict(poll_data.get("user_info") or {})
if user_info.get("tenant_brand") == "lark":
domain = "lark"
metadata["domain"] = domain
app_id = str(poll_data.get("client_id") or "").strip()
app_secret = str(poll_data.get("client_secret") or "").strip()
if app_id and app_secret:
token_data = self._tenant_token(app_id, app_secret, domain=domain)
metadata.update(
{
"appId": app_id,
"appSecret": app_secret,
"tenantAccessToken": token_data["token"],
"tenantTokenExpiresAt": token_data["expires_at"],
"registrationOpenId": str(user_info.get("open_id") or ""),
}
)
return self.store.update_session(
session.session_id,
status="connected",
account_id=f"feishu:{app_id}",
metadata=metadata,
instructions=_connected_instructions(),
)
error = str(poll_data.get("error") or "")
if error == "authorization_pending":
return session
if error == "slow_down":
metadata["pollIntervalSeconds"] = int(metadata.get("pollIntervalSeconds") or 5) + 5
return self.store.update_session(session.session_id, metadata=metadata)
if error == "expired_token":
return self.store.update_session(session.session_id, status="expired", metadata=metadata)
if error == "access_denied":
return self.store.update_session(session.session_id, status="error", error="Feishu/Lark authorization was denied", metadata=metadata)
if error:
description = str(poll_data.get("error_description") or error)
return self.store.update_session(session.session_id, status="error", error=description, metadata=metadata)
return session
def _registration_post(self, domain: str, values: dict[str, str]) -> dict[str, Any]:
response = self.http.post(
f"{_registration_base_url(domain)}/oauth/v1/app/registration",
data=urlencode(values),
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=20,
)
response.raise_for_status()
return dict(response.json())
def _start_existing_connected_receivers(self) -> None:
if not self.start_receivers:
return None
for session in self.store.list_sessions():
if session.kind == "feishu" and session.status == "connected":
self._ensure_receiver(session)
return None
def _ensure_receiver(self, session: ConnectorSessionState) -> None:
if not self.start_receivers or not _has_receiver_material(session):
return None
with self._receiver_lock:
existing = self._receiver_processes.get(session.connection_id)
if existing is not None and _receiver_is_alive(existing):
return None
receiver = self.receiver_start(session)
self._receiver_processes[session.connection_id] = receiver
return None
def _stop_receiver(self, connection_id: str) -> None:
with self._receiver_lock:
receiver = self._receiver_processes.pop(connection_id, None)
if receiver is None:
return None
terminate = getattr(receiver, "terminate", None)
if callable(terminate):
terminate()
wait = getattr(receiver, "wait", None)
if callable(wait):
try:
wait(timeout=5)
except subprocess.TimeoutExpired:
kill = getattr(receiver, "kill", None)
if callable(kill):
kill()
return None
def _start_receiver_process(self, session: ConnectorSessionState) -> subprocess.Popen[bytes]:
metadata = dict(session.metadata)
script = Path(__file__).resolve().parents[1] / "node" / "feishu_ws_receiver.js"
env = os.environ.copy()
env.update(
{
"FEISHU_APP_ID": str(metadata.get("appId") or ""),
"FEISHU_APP_SECRET": str(metadata.get("appSecret") or ""),
"FEISHU_DOMAIN": str(metadata.get("domain") or "feishu"),
"FEISHU_CONNECTION_ID": session.connection_id,
"FEISHU_CHANNEL_ID": session.channel_id,
"FEISHU_ACCOUNT_ID": str(session.account_id or ""),
"BEAVER_BRIDGE_BASE_URL": self.bridge_base_url,
"BEAVER_BRIDGE_TOKEN": self.bridge_token,
}
)
return subprocess.Popen(["node", str(script)], env=env, cwd=str(script.parent))
def _bridge_event_from_feishu(session: ConnectorSessionState, header: dict[str, Any], event: dict[str, Any]) -> dict[str, Any]:
message = dict(event.get("message") or {})
sender = dict(event.get("sender") or {})
sender_id = dict(sender.get("sender_id") or {})
peer_id = str(sender_id.get("open_id") or sender_id.get("user_id") or "")
message_id = str(message.get("message_id") or uuid4().hex)
event_id = str(header.get("event_id") or f"{session.channel_id}:{message_id}")
return {
"eventId": event_id,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"deliveryAttempt": 1,
"connectionId": session.connection_id,
"channelId": session.channel_id,
"kind": "feishu",
"accountId": session.account_id,
"peerId": peer_id,
"peerType": "group" if message.get("chat_type") == "group" else "dm",
"userId": peer_id,
"threadId": str(message.get("chat_id") or "") or None,
"messageId": message_id,
"messageType": str(message.get("message_type") or "text"),
"content": _extract_text(message),
"metadata": {"chatId": message.get("chat_id"), "rawMessageType": message.get("message_type")},
}
def _has_receiver_material(session: ConnectorSessionState) -> bool:
metadata = dict(session.metadata)
return bool(
session.status == "connected"
and str(metadata.get("appId") or "").strip()
and str(metadata.get("appSecret") or "").strip()
and session.connection_id
and session.channel_id
)
def _receiver_is_alive(receiver: object) -> bool:
poll = getattr(receiver, "poll", None)
if callable(poll):
return poll() is None
return True
def _extract_text(message: dict[str, Any]) -> str:
content = message.get("content")
if isinstance(content, str):
try:
parsed = json.loads(content)
except json.JSONDecodeError:
return content
text = parsed.get("text")
if text is not None:
return str(text)
return content
return ""
def _domain(options: dict[str, Any]) -> str:
domain = str(options.get("domain") or "feishu").strip().lower()
return "lark" if domain == "lark" else "feishu"
def _registration_base_url(domain: str) -> str:
return "https://accounts.larksuite.com" if domain == "lark" else "https://accounts.feishu.cn"
def _open_api_base_url(domain: str, configured_base_url: str) -> str:
if configured_base_url:
return configured_base_url.rstrip("/")
return "https://open.larksuite.com" if domain == "lark" else "https://open.feishu.cn"
def _with_onboard_from(value: str) -> str:
if not value:
return ""
parts = urlsplit(value)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query.setdefault("from", "onboard")
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
def _qr_svg_data_uri(value: str) -> str:
image = qrcode.make(value, image_factory=qrcode.image.svg.SvgPathImage)
buffer = BytesIO()
image.save(buffer)
return "data:image/svg+xml;base64," + base64.b64encode(buffer.getvalue()).decode("ascii")
def _create_instructions(event_callback_url: str) -> list[str]:
return [
"使用飞书客户端扫描二维码,选择一键创建飞书机器人。",
"创建完成后,点击打开机器人,在飞书中向机器人发送任意消息即可开始对话。",
"在飞书开放平台启用接收消息事件 im.message.receive_v1并选择长连接事件通道。",
f"如果使用 HTTP 回调模式,事件请求 URL 可设为 {event_callback_url}",
"如需用户身份授权,在飞书对话中发送 /feishu auth。",
"建议发送:学习一下我安装的新飞书插件,列出有哪些能力。",
"验证安装:在飞书对话中发送 /feishu start返回版本号代表安装成功。",
"如果 Windows 设备扫码异常,通常是终端二维码分辨率问题,可换用 Cmder 或使用本窗口二维码。",
]
def _link_instructions(event_callback_url: str) -> list[str]:
return [
"选择关联已有机器人时,请输入正确的 App ID 和 App Secret。",
"如果提示无效的 App ID 或 App Secret请回到飞书开放平台复制最新应用凭证。",
"在飞书开放平台启用接收消息事件 im.message.receive_v1并选择长连接事件通道。",
f"如果使用 HTTP 回调模式,事件请求 URL 可设为 {event_callback_url}",
]
def _connected_instructions() -> list[str]:
return [
"飞书机器人凭据已连接。",
"sidecar 会通过飞书长连接保持应用在线。",
"请确认飞书开放平台已启用接收消息事件 im.message.receive_v1。",
"在飞书对话中发送 /feishu start 验证插件返回版本号;发送任意消息验证 Beaver 是否收到。",
]

View File

@ -0,0 +1,281 @@
from __future__ import annotations
import json
import os
import re
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, float], tuple[int, str, str]]
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
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
self.command_timeout_seconds = float(self.env.get("CONNECTOR_COMMAND_TIMEOUT_SECONDS") or 120)
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 {}),
)
try:
command_template = self._command_template(kind)
except RuntimeError as exc:
session = self.store.update_session(session.session_id, status="error", error=str(exc))
return _session_view(session)
connector_home = Path(self.store.path).parent
state_dir = connector_home / kind / session.connection_id
state_dir.mkdir(parents=True, exist_ok=True)
command = shlex.split(command_template.format(state_dir=str(state_dir), connection_id=session.connection_id))
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)
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": str(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]:
session = self.store.get_session(session_id)
if session.status in {"cancelled", "error"}:
return _session_view(session)
command_template = self._optional_command_template(session.kind, "STATUS")
if not command_template:
return _session_view(session)
connector_home = Path(self.store.path).parent
state_dir = str(session.metadata.get("stateRef") or connector_home / session.kind / session.connection_id)
command = shlex.split(
command_template.format(
state_dir=state_dir,
connection_id=session.connection_id,
session_id=session.session_id,
)
)
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 status 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)
updates = _provider_output_updates(stdout)
if updates:
metadata = dict(session.metadata)
metadata.update(dict(updates.pop("metadata", {}) or {}))
updates["metadata"] = metadata
session = self.store.update_session(session.session_id, **updates)
return _session_view(session)
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:
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}
command_template = self._optional_command_template(str(payload["kind"]), "SEND")
if not command_template:
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}
connector_home = Path(self.store.path).parent
state_dir = connector_home / str(payload["kind"]) / str(payload["connectionId"])
payload_path = _write_send_payload(state_dir, str(payload["requestId"]), payload)
command = shlex.split(
command_template.format(
state_dir=str(state_dir),
payload_path=str(payload_path),
connection_id=str(payload["connectionId"]),
channel_id=str(payload["channelId"]),
request_id=str(payload["requestId"]),
kind=str(payload["kind"]),
)
)
try:
code, stdout, stderr = self.runner(command, str(connector_home), self.command_timeout_seconds)
except subprocess.TimeoutExpired:
return {"ok": False, "error": "Provider send command timed out"}
except Exception as exc:
return {"ok": False, "error": _redact(str(exc))}
if code != 0:
return {"ok": False, "error": _redact(stderr or stdout)}
provider_message_id = _extract_provider_message_id(stdout) or 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 _optional_command_template(self, kind: str, action: str) -> str | None:
prefix = "WEIXIN" if kind == "weixin" else "FEISHU"
command = str(self.env.get(f"{prefix}_{action}_COMMAND") or "").strip()
return command or None
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 _provider_output_updates(output: str) -> dict[str, Any]:
text = output.strip()
if not text:
return {}
parsed: dict[str, Any] = {}
try:
raw = json.loads(text)
except json.JSONDecodeError:
raw = _parse_key_value_output(text)
if isinstance(raw, dict):
parsed = raw
updates: dict[str, Any] = {}
key_map = {
"status": "status",
"qrImage": "qr_image",
"qr_image": "qr_image",
"qrCode": "qr_code",
"qr_code": "qr_code",
"accountId": "account_id",
"account_id": "account_id",
"account": "account_id",
"displayName": "display_name",
"display_name": "display_name",
"error": "error",
}
for source_key, target_key in key_map.items():
value = parsed.get(source_key)
if value is not None:
updates[target_key] = str(value)
instructions = parsed.get("instructions")
if isinstance(instructions, list):
updates["instructions"] = [str(item) for item in instructions]
elif isinstance(instructions, str) and instructions.strip():
updates["instructions"] = [instructions.strip()]
metadata = parsed.get("metadata")
if isinstance(metadata, dict):
updates["metadata"] = metadata
return updates
def _parse_key_value_output(text: str) -> dict[str, str]:
values: dict[str, str] = {}
for part in text.split():
if "=" not in part:
continue
key, value = part.split("=", 1)
if key:
values[key] = value
return values
def _write_send_payload(state_dir: Path, request_id: str, payload: dict[str, Any]) -> Path:
sends_dir = state_dir / "sends"
sends_dir.mkdir(parents=True, exist_ok=True)
safe_request_id = re.sub(r"[^A-Za-z0-9_.-]+", "_", request_id) or "request"
payload_path = sends_dir / f"{safe_request_id}.json"
payload_path.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
return payload_path
def _extract_provider_message_id(output: str) -> str | None:
text = output.strip()
if not text:
return None
try:
raw = json.loads(text)
except json.JSONDecodeError:
raw = _parse_key_value_output(text)
if not isinstance(raw, dict):
return None
value = raw.get("providerMessageId") or raw.get("provider_message_id") or raw.get("messageId") or raw.get("message_id")
return str(value) if value is not None else None
def _redact(text: str) -> str:
redacted = re.sub(r"\bsecret-[A-Za-z0-9._:-]+\b", "***", text)
return re.sub(r"\b(appSecret|token|secret)=\S+", r"\1=***", redacted)

Some files were not shown because too many files have changed in this diff Show More