diff --git a/.env.example b/.env.example index d8bbfea..ba2c492 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,19 @@ BEAVER_OUTLOOK_MCP_SERVER_ID=outlook_mcp # Must be reachable from auth-portal and authz-service containers. BEAVER_DEPLOY_URL=http://beaver-deploy-control:8090 + +# External connector sidecar +EXTERNAL_CONNECTOR_TOKEN= +BEAVER_BRIDGE_TOKEN= +BEAVER_BRIDGE_BASE_URL=http://app-instance:8080 +EXTERNAL_CONNECTOR_PORT=8787 +CONNECTOR_PUBLIC_BASE_URL=http://localhost:8787 +# fake | vendor_cli | weixin_ilink +CONNECTOR_PROVIDER=vendor_cli +CONNECTOR_COMMAND_TIMEOUT_SECONDS=120 +WEIXIN_CONNECT_COMMAND= +WEIXIN_STATUS_COMMAND= +WEIXIN_SEND_COMMAND= +FEISHU_CONNECT_COMMAND= +FEISHU_STATUS_COMMAND= +FEISHU_SEND_COMMAND= diff --git a/2026-06-01-hermes-gateway-llm-design.md b/2026-06-01-hermes-gateway-llm-design.md index 16749f9..1bf8dc3 100644 --- a/2026-06-01-hermes-gateway-llm-design.md +++ b/2026-06-01-hermes-gateway-llm-design.md @@ -1,177 +1,458 @@ -# Hermes Gateway LLM Design +# Beaver Terminal WebSocket Integration Guide Date: 2026-06-01 +Audience: the small-terminal-side Codex agent that will modify terminal firmware or terminal app code. + ## Goal -Replace the OpenAI-compatible LLM call path in `custom/custom_agent.py` with a LiveKit LLM -adapter that talks to NousResearch Hermes Agent through the OpenClaw gateway protocol. +Connect the small terminal device to Beaver through a text-only WebSocket channel. -The integration must keep the existing custom agent behavior: +The first acceptance target is simple: -- Chinese room-locator and general assistant instructions -- Emotion prefix parsing with `` -- Memory recall for room-locator queries -- Optional vision-frame attachment -- LiveKit ASR, TTS, VAD, turn handling, metrics, and interruption behavior +1. The terminal opens a WebSocket connection to Beaver. +2. The terminal sends a `connect` frame with a stable `peer_id`. +3. The terminal sends one text `message` frame. +4. The terminal receives an `ack`. +5. The terminal receives the final assistant text response from Beaver. +6. The terminal can reconnect with the same `peer_id` and keep the same Beaver session. -The Hermes session strategy is `per_room`: one LiveKit room should map to one Hermes gateway -session for the lifetime of that room. +This document replaces the earlier Hermes LiveKit LLM adapter design for the terminal-side work. Do not implement a LiveKit LLM adapter from this document. ## Non-Goals -- Do not replace LiveKit `AgentSession`, ASR, TTS, VAD, or room I/O. -- Do not move room-locator classification into Hermes Agent. -- Do not implement Hermes-side tools in the first pass. -- Do not require an OpenAI-compatible proxy in front of the gateway. +- Do not implement audio streaming. +- Do not implement camera, screen, image, or multimodal frames. +- Do not implement token streaming. +- Do not implement terminal-side tools. +- Do not implement AuthZ, device registration, OAuth, or pairing in the first pass. +- Do not call Beaver REST chat endpoints or the existing Web UI `/ws/{session_id}` endpoint. +- Do not build an OpenAI-compatible proxy. +- Do not implement Hermes Agent or LiveKit changes on the terminal side. -## Recommended Architecture +## Beaver Endpoint -Add a new custom LiveKit LLM implementation in `custom/hermes_gateway.py`. +The terminal connects to: -The adapter will implement the LiveKit `llm.LLM` interface and return a custom `LLMStream`. -The stream will own a single gateway request/response cycle while the LLM object owns the -per-room gateway session state. +```text +ws:///api/channels//ws +``` -`custom/custom_agent.py` will continue to call `selected_llm.chat(...)` through -`_run_selected_llm()`. That preserves the existing `llm_node()` pipeline and keeps Hermes -behind the same abstraction as OpenAI-compatible models. +For local development through the Beaver app instance nginx port: -## Components +```text +ws://127.0.0.1:8080/api/channels/terminal-dev/ws +``` -### HermesGatewayLLM +For direct backend development without nginx: -Responsibilities: +```text +ws://127.0.0.1:18080/api/channels/terminal-dev/ws +``` -- Store gateway configuration: URL, auth token, agent identifier, request timeout, and reconnect - policy. -- Lazily create one Hermes gateway session per LiveKit room. -- Expose `model` as the configured Hermes agent/model identifier. -- Expose `provider` as `hermes-gateway`. -- Create `HermesGatewayLLMStream` from `chat(...)`. -- Close any persistent WebSocket/session resources in `aclose()`. +Use `wss://` when Beaver is deployed behind TLS. -### HermesGatewayLLMStream +The expected first channel id is: -Responsibilities: +```text +terminal-dev +``` -- Serialize LiveKit `ChatContext` into the gateway request payload. -- Send the latest turn to the per-room Hermes session. -- Consume gateway events until the turn completes or fails. -- Yield LiveKit `llm.ChatChunk` objects for assistant text deltas. -- Surface recoverable connection failures through the normal LiveKit LLM error path. +The terminal implementation should make the URL configurable, for example: -### custom_agent.py Wiring +```text +BEAVER_WS_URL=ws://127.0.0.1:8080/api/channels/terminal-dev/ws +TERMINAL_PEER_ID=device-001 +TERMINAL_DEVICE_NAME=desk-terminal +``` -Add env-driven provider selection: +## Protocol Overview -- `CUSTOM_LLM_PROVIDER=openai` keeps the current behavior. -- `CUSTOM_LLM_PROVIDER=hermes_gateway` constructs `HermesGatewayLLM`. +The transport is JSON over WebSocket. -New Hermes-specific env vars: +All frames are UTF-8 JSON objects. The terminal should ignore unknown fields. Beaver will ignore unknown fields unless the frame type is invalid. -- `CUSTOM_HERMES_GATEWAY_URL` -- `CUSTOM_HERMES_API_KEY` -- `CUSTOM_HERMES_AGENT_ID` -- `CUSTOM_HERMES_SESSION_MODE=per_room` -- `CUSTOM_HERMES_REQUEST_TIMEOUT` -- `CUSTOM_HERMES_VERIFY_SSL` +The protocol is request/reply oriented in this phase. Beaver sends only final assistant messages, not token deltas. -When `CUSTOM_LLM_PROVIDER=hermes_gateway`, `base_llm`, `text_llm`, and `vision_llm` should all -point at the same Hermes adapter. Separate Hermes text/vision agent IDs are out of scope for this -design. +Required frame flow: -## Data Flow +```text +terminal -> Beaver: connect +Beaver -> terminal: connected +terminal -> Beaver: message +Beaver -> terminal: ack +Beaver -> terminal: message +``` -1. User speaks or sends text. -2. Existing LiveKit/STT flow updates `ChatContext`. -3. `CustomAgent.llm_node()` selects `general` or `room_locator`. -4. Existing code injects the appropriate instructions and emotion-prefix requirement. -5. Existing code optionally augments the latest user message with memory context. -6. Existing code optionally attaches a fresh vision frame. -7. `_run_selected_llm()` calls `HermesGatewayLLM.chat(...)`. -8. The Hermes adapter sends the request to the per-room gateway session. -9. Gateway text events are converted to `llm.ChatChunk` deltas. -10. Existing emotion observation and TTS stripping continue unchanged. +Optional heartbeat: -## ChatContext Serialization +```text +terminal -> Beaver: ping +Beaver -> terminal: pong +``` -Text messages should be serialized first. +## Connect Frame -Supported LiveKit content: +The terminal must send `connect` immediately after the WebSocket opens. -- `str`: send as normal message content. -- instruction/config updates: preserve the final active instructions as the leading instruction - message in the gateway payload. If the deployed gateway only accepts user/assistant messages, - prepend the instruction text to the latest user message before sending. -- image content: attempt to send through the gateway image/multimodal field. If the deployed - Hermes gateway rejects or ignores image content, log a warning and fall back to text-only - generation for that turn. +Terminal to Beaver: -Function tool calls should not be sent in the first implementation. If tool messages appear, log -that they were omitted. +```json +{ + "type": "connect", + "peer_id": "device-001", + "device_name": "desk-terminal", + "capabilities": ["text"] +} +``` -## per_room Session Lifecycle +Required fields: -The adapter should derive a stable room key from the active LiveKit session or job context. If a -room name/SID is not available, fall back to one adapter-local session. +- `type`: must be `"connect"`. +- `peer_id`: stable terminal identity. Reuse this value across reconnects. -For each room key: +Recommended fields: -1. Open or reuse a gateway connection. -2. Send the gateway `connect` handshake if needed. -3. Create a Hermes session once. -4. Reuse that Hermes session for all future turns from the same room. -5. Close the gateway connection when the LiveKit LLM is closed. +- `device_name`: human-readable terminal name. +- `capabilities`: include `"text"`. -This lets Hermes maintain its own conversational state while LiveKit still keeps the visible -conversation history. +Optional fields: -## Gateway Event Mapping +- `thread_id`: optional sub-session key. Omit it for the first pass. +- `user_id`: optional user identity. Omit it unless the terminal already has a stable user id. -Map streaming text events to LiveKit chunks: +Beaver to terminal: -- Gateway assistant text delta -> `llm.ChatChunk(delta=llm.ChoiceDelta(content=delta))` -- Gateway final assistant message -> emit any remaining text not already streamed -- Gateway usage metadata -> `llm.CompletionUsage` when token counts are available -- Gateway tool/action events -> log at debug/info level in the first implementation -- Gateway error event -> raise a LiveKit `APIError` or `APIConnectionError` -- Gateway completion event -> finish the async iterator +```json +{ + "type": "connected", + "channel_id": "terminal-dev", + "session_id": "terminal-dev:local:device-001" +} +``` -The implementation should make the event parser tolerant of protocol field-name differences by -isolating event normalization in one helper function. Unknown event types should be logged and -ignored unless they indicate failure. +The terminal should store `session_id` for logging and diagnostics. It does not need to send `session_id` back in message frames. -## Error Handling +## Message Frame -- Missing Hermes env vars should fail fast at startup when provider is `hermes_gateway`. -- Gateway connect/session-create failures should raise connection errors. -- A failed request should not discard the per-room session unless the gateway reports that the - session is invalid or closed. -- If the gateway connection closes mid-turn, reconnect once and retry only if no assistant text - has been yielded yet. -- If assistant text has already been yielded, fail the turn instead of replaying partial output. +Terminal to Beaver: -## Testing +```json +{ + "type": "message", + "message_id": "m-001", + "text": "hello" +} +``` -Add focused tests around the adapter: +Required fields: -- Serializes simple system/user/assistant chat context. -- Creates one gateway session and reuses it across two turns for the same room. -- Converts text deltas into `llm.ChatChunk` content. -- Handles final full-message events without duplicate text. -- Raises on gateway error events. -- Logs and skips unsupported image/tool content. +- `type`: must be `"message"`. +- `message_id`: unique id for this user message. +- `text`: non-empty user text. -Add a small wiring test or import-level test for `CUSTOM_LLM_PROVIDER=hermes_gateway` if the -custom module is testable without external services. +Recommended `message_id` format: -## Rollout +```text +- +``` -1. Implement the adapter behind `CUSTOM_LLM_PROVIDER=hermes_gateway`. -2. Keep `openai` as the default provider. -3. Run unit tests for the adapter and a syntax/type smoke check on `custom/custom_agent.py`. -4. Test manually with a local gateway using `python custom/custom_agent.py console` or the - existing LiveKit development mode. -5. If vision payloads are unsupported by the deployed gateway, document that the first Hermes - rollout is text-only for vision turns. +Example: + +```text +device-001-000001 +device-001-000002 +``` + +The terminal should persist the counter if practical. If persistence is unavailable, generate a UUID or timestamp-based id. Reusing the same `message_id` tells Beaver to treat the frame as a duplicate. + +Optional fields: + +- `thread_id`: use only when the terminal intentionally wants a separate Beaver session. +- `user_id`: use only when the terminal has a stable user id. + +## Ack Frame + +Beaver sends an ack after accepting or deduplicating the inbound message. + +Accepted: + +```json +{ + "type": "ack", + "message_id": "device-001-000001", + "session_id": "terminal-dev:local:device-001", + "accepted": true +} +``` + +Duplicate still processing: + +```json +{ + "type": "ack", + "message_id": "device-001-000001", + "session_id": "terminal-dev:local:device-001", + "accepted": false, + "duplicate": true, + "pending": true +} +``` + +Duplicate already completed: + +```json +{ + "type": "ack", + "message_id": "device-001-000001", + "session_id": "terminal-dev:local:device-001", + "accepted": false, + "duplicate": true, + "pending": false, + "reply": "cached assistant reply" +} +``` + +Terminal behavior: + +- If `accepted` is true, wait for the assistant `message`. +- If `duplicate` and `reply` is present, display the cached reply. +- If `duplicate` and `pending` is true, keep waiting on the socket. +- If `error` is present, display or log the error. + +## Assistant Message Frame + +Beaver to terminal: + +```json +{ + "type": "message", + "role": "assistant", + "message_id": "device-001-000001", + "run_id": "run-id", + "text": "assistant reply", + "finish_reason": "stop" +} +``` + +Fields: + +- `type`: `"message"`. +- `role`: `"assistant"`. +- `message_id`: the user message id this response belongs to. +- `run_id`: Beaver run id for diagnostics. +- `text`: final assistant response. +- `finish_reason`: usually `"stop"`, or `"error"` when the run failed. + +Terminal behavior: + +- Render or speak `text`. +- Treat `finish_reason == "error"` as a failed turn. +- Do not expect token-level streaming in this phase. + +## Ping And Pong + +Terminal to Beaver: + +```json +{"type": "ping"} +``` + +Beaver to terminal: + +```json +{"type": "pong"} +``` + +Recommended heartbeat interval: + +```text +30 seconds +``` + +If no pong or other frame is received after a reasonable timeout, reconnect. + +## Error Frame + +Beaver may send: + +```json +{ + "type": "error", + "error": "human readable error" +} +``` + +Terminal behavior: + +- Log the error. +- Keep the connection open unless the WebSocket closes. +- If the error is for a user message, allow the user to retry with a new `message_id`. + +Common first-pass errors: + +- `connect` is required before `message`. +- `peer_id` is required. +- `message_id` is required. +- `text` is required. +- Unsupported websocket frame type. + +## Terminal State Machine + +Implement the terminal client as a small state machine. + +```text +DISCONNECTED + -> connect websocket +CONNECTING + -> websocket open, send connect frame +WAIT_CONNECTED + -> receive connected +READY + -> send message frame +WAIT_ACK + -> receive ack +WAIT_REPLY + -> receive assistant message +READY +``` + +On WebSocket close or network failure, transition to `DISCONNECTED` and reconnect with backoff. + +Recommended reconnect policy: + +- Start at 1 second. +- Double up to 30 seconds. +- Reset backoff after a successful `connected` frame. + +On reconnect, use the same `peer_id`. + +## Terminal Implementation Requirements + +The terminal-side code should provide: + +- A configurable Beaver WebSocket URL. +- A stable `peer_id`. +- A configurable `device_name`. +- A monotonic or otherwise unique `message_id` generator. +- JSON encoding and decoding. +- Connect frame on socket open. +- Ping/pong heartbeat. +- Reconnect with backoff. +- A queue or guard so only one user text turn is in flight at a time for the first pass. +- Logging for `session_id`, `message_id`, `run_id`, and errors. + +The terminal-side code does not need: + +- Multi-room session logic. +- Hermes session management. +- LiveKit `AgentSession`. +- Audio chunking. +- Tool calls. +- OAuth or token refresh. + +## Example Client Pseudocode + +```python +peer_id = load_or_create_peer_id() +counter = load_counter() + +async def run_terminal_client(): + while True: + try: + async with connect(BEAVER_WS_URL) as ws: + await ws.send_json({ + "type": "connect", + "peer_id": peer_id, + "device_name": DEVICE_NAME, + "capabilities": ["text"], + }) + + connected = await ws.receive_json() + assert connected["type"] == "connected" + log("session_id", connected["session_id"]) + + await read_send_receive_loop(ws) + except Exception as exc: + log("websocket disconnected", exc) + await sleep(next_backoff()) + +async def send_user_text(ws, text): + global counter + counter += 1 + save_counter(counter) + message_id = f"{peer_id}-{counter:06d}" + + await ws.send_json({ + "type": "message", + "message_id": message_id, + "text": text, + }) + + while True: + frame = await ws.receive_json() + if frame["type"] == "ack" and frame.get("message_id") == message_id: + if frame.get("reply"): + return frame["reply"] + continue + if frame["type"] == "message" and frame.get("role") == "assistant": + if frame.get("message_id") == message_id: + return frame.get("text", "") + if frame["type"] == "error": + raise RuntimeError(frame.get("error", "unknown error")) +``` + +Adapt the pseudocode to the terminal runtime language and WebSocket library. + +## Manual Test With websocat + +If `websocat` is available, a developer can manually test the protocol: + +```bash +websocat ws://127.0.0.1:8080/api/channels/terminal-dev/ws +``` + +Then paste: + +```json +{"type":"connect","peer_id":"device-001","device_name":"desk-terminal","capabilities":["text"]} +``` + +Expected response: + +```json +{"type":"connected","channel_id":"terminal-dev","session_id":"terminal-dev:local:device-001"} +``` + +Then paste: + +```json +{"type":"message","message_id":"device-001-000001","text":"hello"} +``` + +Expected responses: + +```json +{"type":"ack","message_id":"device-001-000001","session_id":"terminal-dev:local:device-001","accepted":true} +``` + +Then, after Beaver finishes the run: + +```json +{"type":"message","role":"assistant","message_id":"device-001-000001","run_id":"...","text":"...","finish_reason":"stop"} +``` + +## Acceptance Checklist For Terminal-Side Codex + +- The terminal opens the configured Beaver WebSocket URL. +- The terminal sends `connect` immediately after open. +- The terminal receives and logs `connected.session_id`. +- The terminal sends text using a unique `message_id`. +- The terminal receives `ack`. +- The terminal receives and displays assistant `message.text`. +- The terminal handles `ping`/`pong`. +- The terminal reconnects with the same `peer_id`. +- The terminal does not use REST chat or `/ws/{session_id}`. +- The terminal implementation remains text-only for the first pass. + +When this checklist passes against Beaver, the first-stage device integration is accepted from the terminal side. diff --git a/app-instance/Dockerfile b/app-instance/Dockerfile index 18ad32f..0d4ec10 100644 --- a/app-instance/Dockerfile +++ b/app-instance/Dockerfile @@ -47,8 +47,12 @@ ARG NPM_REGISTRY="https://registry.npmmirror.com" ARG NPM_FETCH_RETRIES="5" ARG NPM_FETCH_RETRY_MIN_TIMEOUT="20000" ARG NPM_FETCH_RETRY_MAX_TIMEOUT="120000" +ARG APT_MIRROR="https://mirrors.tuna.tsinghua.edu.cn/debian" +ARG PYPI_INDEX_URL="https://pypi.tuna.tsinghua.edu.cn/simple" -RUN apt-get update && \ +RUN find /etc/apt -type f \( -name "*.list" -o -name "*.sources" \) -exec \ + sed -i "s|http://deb.debian.org/debian-security|${APT_MIRROR}-security|g; s|http://deb.debian.org/debian|${APT_MIRROR}|g; s|http://security.debian.org/debian-security|${APT_MIRROR}-security|g" {} + && \ + apt-get update && \ apt-get install -y --no-install-recommends curl ca-certificates gnupg git nginx dumb-init && \ mkdir -p /etc/apt/keyrings && \ curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ @@ -63,7 +67,7 @@ WORKDIR /opt/app/backend COPY backend/pyproject.toml backend/README.md ./ COPY backend/beaver/ ./beaver/ -RUN uv pip install --system --no-cache . +RUN uv pip install --system --no-cache --index-url "${PYPI_INDEX_URL}" ".[channels]" WORKDIR /opt/app/frontend COPY --from=frontend-builder /build/frontend/next.config.js ./ diff --git a/app-instance/backend/beaver/engine/context/builder.py b/app-instance/backend/beaver/engine/context/builder.py index 2ca040f..00df740 100644 --- a/app-instance/backend/beaver/engine/context/builder.py +++ b/app-instance/backend/beaver/engine/context/builder.py @@ -76,7 +76,12 @@ class SessionContext: model: str | None = None user_id: str | None = None channel: str | None = None + channel_kind: str | None = None + account_id: str | None = None + peer_id: str | None = None + peer_type: str | None = None chat_id: str | None = None + thread_id: str | None = None parent_session_id: str | None = None @@ -354,8 +359,18 @@ class ContextBuilder: rows.append(f"User ID: {session_context.user_id}") if session_context.channel: rows.append(f"Channel: {session_context.channel}") + if session_context.channel_kind: + rows.append(f"Channel Kind: {session_context.channel_kind}") + if session_context.account_id: + rows.append(f"Account ID: {session_context.account_id}") + if session_context.peer_id: + rows.append(f"Peer ID: {session_context.peer_id}") + if session_context.peer_type: + rows.append(f"Peer Type: {session_context.peer_type}") if session_context.chat_id: rows.append(f"Chat ID: {session_context.chat_id}") + if session_context.thread_id: + rows.append(f"Thread ID: {session_context.thread_id}") if session_context.parent_session_id: rows.append(f"Parent Session ID: {session_context.parent_session_id}") diff --git a/app-instance/backend/beaver/engine/loop.py b/app-instance/backend/beaver/engine/loop.py index 4e612fb..085c0f8 100644 --- a/app-instance/backend/beaver/engine/loop.py +++ b/app-instance/backend/beaver/engine/loop.py @@ -13,6 +13,7 @@ from uuid import uuid4 from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from beaver.engine.context import ContextBuildInput, RuntimeContext, SessionContext, SkillContext +from beaver.foundation.events import ChannelIdentity from beaver.memory.runs import RunRecord, SkillEffectRecord from beaver.skills.learning import RunReceiptContext from beaver.skills.catalog.utils import strip_frontmatter @@ -248,6 +249,7 @@ class AgentLoop: pinned_skill_contexts: list[SkillContext] | None = None, allow_candidate_generation: bool = False, intent_agent_decision: dict[str, Any] | None = None, + channel_identity: ChannelIdentity | None = None, ) -> AgentRunResult: """跑通最小 direct run 主链。 @@ -297,6 +299,7 @@ class AgentLoop: pinned_skill_contexts=pinned_skill_contexts, allow_candidate_generation=allow_candidate_generation, intent_agent_decision=intent_agent_decision, + channel_identity=channel_identity, ) async def _process_direct_impl( @@ -334,6 +337,7 @@ class AgentLoop: pinned_skill_contexts: list[SkillContext] | None = None, allow_candidate_generation: bool = False, intent_agent_decision: dict[str, Any] | None = None, + channel_identity: ChannelIdentity | None = None, ) -> AgentRunResult: """真正执行一轮 direct run 的内部实现。 @@ -576,6 +580,13 @@ class AgentLoop: source=source, model=resolved_model, user_id=user_id, + channel=channel_identity.channel_id if channel_identity else None, + channel_kind=channel_identity.kind if channel_identity else None, + account_id=channel_identity.account_id if channel_identity else None, + peer_id=channel_identity.peer_id if channel_identity else None, + peer_type=channel_identity.peer_type if channel_identity else None, + chat_id=channel_identity.peer_id if channel_identity else None, + thread_id=channel_identity.thread_id if channel_identity else None, parent_session_id=parent_session_id, ), runtime_context=self._current_runtime_context(), diff --git a/app-instance/backend/beaver/foundation/config/loader.py b/app-instance/backend/beaver/foundation/config/loader.py index 3e7a6d4..5ab5d90 100644 --- a/app-instance/backend/beaver/foundation/config/loader.py +++ b/app-instance/backend/beaver/foundation/config/loader.py @@ -13,6 +13,7 @@ from .schema import ( AuthzConfig, BackendIdentityConfig, BeaverConfig, + ChannelConfig, EmbeddingConfig, MCPServerConfig, ProviderConfig, @@ -73,6 +74,7 @@ def load_config( embedding=_parse_embedding(data), tools=_parse_tools(data.get("tools")), authz=_parse_authz(data.get("authz")), + channels=_parse_channels(data.get("channels")), backend_identity=_parse_backend_identity(data.get("backend_identity") or data.get("backendIdentity")), config_path=path, ) @@ -196,6 +198,48 @@ def _parse_authz(raw: Any) -> AuthzConfig: ) +def _parse_channels(raw: Any) -> dict[str, ChannelConfig]: + channels: dict[str, ChannelConfig] = {} + for channel_id, payload in _as_dict(raw).items(): + cleaned_id = str(channel_id).strip() + if not cleaned_id: + continue + channels[cleaned_id] = _parse_channel_config(payload) + return channels + + +def _parse_channel_config(payload: Any) -> ChannelConfig: + data = _as_dict(payload) + return ChannelConfig( + enabled=_bool(data.get("enabled"), default=False), + kind=_string(data.get("kind")) or "", + mode=_string(data.get("mode")) or "webhook", + account_id=_string(data.get("accountId") or data.get("account_id")) or "", + display_name=_string(data.get("displayName") or data.get("display_name")) or "", + config=_normalize_config_map(data.get("config")), + secrets=_string_dict(data.get("secrets")), + ) + + +def _normalize_config_map(value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + return {} + return { + _camel_to_snake_key(str(key)): item + for key, item in value.items() + if str(key).strip() + } + + +def _camel_to_snake_key(value: str) -> str: + result: list[str] = [] + for char in value: + if char.isupper() and result: + result.append("_") + result.append(char.lower()) + return "".join(result) + + def _parse_backend_identity(raw: Any) -> BackendIdentityConfig: data = _as_dict(raw) return BackendIdentityConfig( diff --git a/app-instance/backend/beaver/foundation/config/schema.py b/app-instance/backend/beaver/foundation/config/schema.py index 7062183..2c89f57 100644 --- a/app-instance/backend/beaver/foundation/config/schema.py +++ b/app-instance/backend/beaver/foundation/config/schema.py @@ -91,6 +91,19 @@ class AuthzConfig: outlook_mcp_url: str = "" +@dataclass(slots=True) +class ChannelConfig: + """One configured channel adapter instance.""" + + enabled: bool = False + kind: str = "" + mode: str = "webhook" + account_id: str = "" + display_name: str = "" + config: dict[str, Any] = field(default_factory=dict) + secrets: dict[str, str] = field(default_factory=dict) + + @dataclass(slots=True) class BackendIdentityConfig: """This backend's AuthZ client identity.""" @@ -111,6 +124,7 @@ class BeaverConfig: embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig) tools: ToolsConfig = field(default_factory=ToolsConfig) authz: AuthzConfig = field(default_factory=AuthzConfig) + channels: dict[str, ChannelConfig] = field(default_factory=dict) backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig) config_path: Path | None = None diff --git a/app-instance/backend/beaver/foundation/events/__init__.py b/app-instance/backend/beaver/foundation/events/__init__.py index 34a3cd6..d17692c 100644 --- a/app-instance/backend/beaver/foundation/events/__init__.py +++ b/app-instance/backend/beaver/foundation/events/__init__.py @@ -1,5 +1,5 @@ """Event contracts and dispatch helpers.""" -from .message_bus import InboundMessage, MessageBus, OutboundMessage +from .message_bus import ChannelIdentity, InboundMessage, MessageBus, OutboundMessage -__all__ = ["InboundMessage", "MessageBus", "OutboundMessage"] +__all__ = ["ChannelIdentity", "InboundMessage", "MessageBus", "OutboundMessage"] diff --git a/app-instance/backend/beaver/foundation/events/message_bus.py b/app-instance/backend/beaver/foundation/events/message_bus.py index 1db5810..9f2d27e 100644 --- a/app-instance/backend/beaver/foundation/events/message_bus.py +++ b/app-instance/backend/beaver/foundation/events/message_bus.py @@ -9,12 +9,58 @@ from typing import Any from uuid import uuid4 +@dataclass(slots=True) +class ChannelIdentity: + """Normalized channel routing identity. + + `channel_id` is the Beaver adapter instance id, not the platform kind. + """ + + channel_id: str + kind: str + account_id: str + peer_id: str + thread_id: str | None = None + peer_type: str = "unknown" + user_id: str | None = None + message_id: str | None = None + + def validation_error(self) -> str | None: + if not self.channel_id.strip(): + return "channel_id is required" + if not self.account_id.strip(): + return "account_id is required" + if not self.peer_id.strip(): + return "peer_id is required" + return None + + def session_id(self) -> str: + parts = [self.channel_id, self.account_id, self.peer_id] + if self.thread_id: + parts.append(self.thread_id) + return ":".join(_clean_session_part(part) for part in parts) + + def dedupe_key(self) -> str | None: + if not self.message_id: + return None + return f"{self.session_id()}:{_clean_session_part(self.message_id)}" + + +def _clean_session_part(value: str) -> str: + cleaned = str(value).strip() + if not cleaned: + return "unknown" + return cleaned.replace(":", "_") + + @dataclass(slots=True) class InboundMessage: """A minimal inbound message accepted by the gateway bridge.""" channel: str content: str + content_type: str = "text" + channel_identity: ChannelIdentity | None = None session_id: str | None = None user_id: str | None = None title: str | None = None @@ -35,6 +81,8 @@ class OutboundMessage: content: str session_id: str | None finish_reason: str + content_type: str = "text" + channel_identity: ChannelIdentity | None = None message_id: str = field(default_factory=lambda: str(uuid4())) run_id: str | None = None provider_name: str | None = None diff --git a/app-instance/backend/beaver/interfaces/channels/__init__.py b/app-instance/backend/beaver/interfaces/channels/__init__.py index 97f4a30..344eeb8 100644 --- a/app-instance/backend/beaver/interfaces/channels/__init__.py +++ b/app-instance/backend/beaver/interfaces/channels/__init__.py @@ -1,7 +1,17 @@ """Channel interfaces.""" from .base import ChannelAdapter +from .base import ChannelInboundSink +from .external_connector import ExternalConnectorChannel from .manager import ChannelManager from .memory import MemoryChannelAdapter +from .terminal_websocket import TerminalWebSocketAdapter -__all__ = ["ChannelAdapter", "ChannelManager", "MemoryChannelAdapter"] +__all__ = [ + "ChannelAdapter", + "ChannelInboundSink", + "ExternalConnectorChannel", + "ChannelManager", + "MemoryChannelAdapter", + "TerminalWebSocketAdapter", +] diff --git a/app-instance/backend/beaver/interfaces/channels/base.py b/app-instance/backend/beaver/interfaces/channels/base.py index 40e3767..60aeb5c 100644 --- a/app-instance/backend/beaver/interfaces/channels/base.py +++ b/app-instance/backend/beaver/interfaces/channels/base.py @@ -2,16 +2,17 @@ from __future__ import annotations -from typing import Protocol +from typing import Any, Protocol -from beaver.foundation.events import MessageBus, OutboundMessage +from beaver.foundation.events import InboundMessage, OutboundMessage class ChannelAdapter(Protocol): - """Minimal contract every gateway channel must implement.""" + """Minimal contract every runtime channel adapter must implement.""" - name: str - bus: MessageBus + channel_id: str + kind: str + mode: str async def start(self) -> None: """Prepare the channel before messages are routed.""" @@ -22,3 +23,9 @@ class ChannelAdapter(Protocol): async def send(self, message: OutboundMessage) -> None: """Deliver an outbound message to the concrete channel.""" + +class ChannelInboundSink(Protocol): + """Runtime callback used by adapters to submit normalized inbound messages.""" + + async def accept_inbound(self, message: InboundMessage) -> Any: + """Accept a normalized inbound message from an adapter.""" diff --git a/app-instance/backend/beaver/interfaces/channels/connections/__init__.py b/app-instance/backend/beaver/interfaces/channels/connections/__init__.py new file mode 100644 index 0000000..495e3a2 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/connections/__init__.py @@ -0,0 +1,29 @@ +"""Channel connection setup layer.""" + +from .connectors import ChannelConnector, ChannelConnectorRegistry +from .dedupe import ConnectorMessageDedupeRecord, DedupeBeginResult, MessageDedupeStore +from .external import ExternalConnectorBase, FeishuConnector, WeixinConnector +from .models import ChannelConnection, ChannelRuntimeSpec, PairingSession, ValidationResult +from .sidecar_client import ConnectorSidecarClient +from .store import ChannelConnectionStore, CredentialStore, PairingTokenStore +from .telegram import TelegramConnector + +__all__ = [ + "ChannelConnector", + "ChannelConnectorRegistry", + "ConnectorMessageDedupeRecord", + "DedupeBeginResult", + "MessageDedupeStore", + "ExternalConnectorBase", + "FeishuConnector", + "WeixinConnector", + "ConnectorSidecarClient", + "ChannelConnection", + "ChannelRuntimeSpec", + "PairingSession", + "ValidationResult", + "ChannelConnectionStore", + "CredentialStore", + "PairingTokenStore", + "TelegramConnector", +] diff --git a/app-instance/backend/beaver/interfaces/channels/connections/connectors.py b/app-instance/backend/beaver/interfaces/channels/connections/connectors.py new file mode 100644 index 0000000..1d87ec1 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/connections/connectors.py @@ -0,0 +1,93 @@ +"""Channel connector registry.""" + +from __future__ import annotations + +from typing import Protocol + +from beaver.foundation.config.schema import ChannelConfig + +from .models import ChannelRuntimeSpec, ValidationResult +from .store import ChannelConnectionStore, CredentialStore + + +class ChannelConnector(Protocol): + kind: str + + async def validate(self, connection_id: str) -> ValidationResult: + ... + + async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec: + ... + + async def revoke(self, connection_id: str) -> None: + ... + + +class ChannelConnectorRegistry: + def __init__(self, *, connection_store: ChannelConnectionStore, credential_store: CredentialStore) -> None: + self.connection_store = connection_store + self.credential_store = credential_store + self._connectors: dict[str, ChannelConnector] = {} + + def register(self, connector: ChannelConnector) -> None: + kind = connector.kind.strip() + if not kind: + raise ValueError("Connector kind is required") + if kind in self._connectors: + raise ValueError(f"Connector already registered: {kind}") + self._connectors[kind] = connector + + def connectors(self) -> list[dict[str, str]]: + return [{"kind": kind} for kind in sorted(self._connectors)] + + def connector_for_kind(self, kind: str) -> ChannelConnector: + return self._connector(kind) + + async def validate(self, connection_id: str) -> ValidationResult: + connection = self.connection_store.get(connection_id) + connector = self._connector(connection.kind) + result = await connector.validate(connection_id) + self.connection_store.update_status( + connection_id, + status=result.status, + last_error=result.error, + ) + return result + + async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec: + connection = self.connection_store.get(connection_id) + return await self._connector(connection.kind).materialize_runtime(connection_id) + + async def materialize_connected_runtime_specs(self) -> list[ChannelRuntimeSpec]: + specs: list[ChannelRuntimeSpec] = [] + for connection in self.connection_store.list(): + if connection.status not in {"connected", "running"}: + continue + specs.append(await self._connector(connection.kind).materialize_runtime(connection.connection_id)) + return specs + + async def materialize_channel_configs(self) -> dict[str, ChannelConfig]: + channels: dict[str, ChannelConfig] = {} + for spec in await self.materialize_connected_runtime_specs(): + secrets = self.credential_store.get(spec.secrets_ref) if spec.secrets_ref else {} + channels[spec.channel_id] = ChannelConfig( + enabled=True, + kind=spec.kind, + mode=spec.mode, + account_id=spec.account_id, + display_name=spec.display_name, + config=dict(spec.config), + secrets=secrets, + ) + return channels + + async def revoke(self, connection_id: str) -> None: + connection = self.connection_store.get(connection_id) + await self._connector(connection.kind).revoke(connection_id) + self.connection_store.revoke(connection_id) + + def _connector(self, kind: str) -> ChannelConnector: + connector = self._connectors.get(kind) + if connector is None: + raise KeyError(f"Connector not registered: {kind}") + return connector diff --git a/app-instance/backend/beaver/interfaces/channels/connections/dedupe.py b/app-instance/backend/beaver/interfaces/channels/connections/dedupe.py new file mode 100644 index 0000000..42adbcf --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/connections/dedupe.py @@ -0,0 +1,144 @@ +"""Bridge event dedupe store for external connector retries.""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from threading import Lock +from typing import Any + + +def _iso_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _parse_iso(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +@dataclass(slots=True) +class ConnectorMessageDedupeRecord: + dedupe_key: str + connection_id: str + event_id: str + status: str + first_seen_at: str + updated_at: str + delivery_attempts: int + message_id: str | None = None + last_error: str | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ConnectorMessageDedupeRecord": + return cls( + dedupe_key=str(data.get("dedupe_key") or ""), + connection_id=str(data.get("connection_id") or ""), + event_id=str(data.get("event_id") or ""), + status=str(data.get("status") or "processing"), + first_seen_at=str(data.get("first_seen_at") or _iso_now()), + updated_at=str(data.get("updated_at") or _iso_now()), + delivery_attempts=int(data.get("delivery_attempts") or 0), + message_id=str(data["message_id"]) if data.get("message_id") is not None else None, + last_error=str(data["last_error"]) if data.get("last_error") is not None else None, + ) + + +@dataclass(slots=True) +class DedupeBeginResult: + should_process: bool + dedupe_key: str + status: str + http_status: int + retry_after_seconds: int | None + record: ConnectorMessageDedupeRecord + + +class MessageDedupeStore: + def __init__(self, path: Path, *, processing_ttl_seconds: int = 60) -> None: + self.path = Path(path) + self.processing_ttl_seconds = int(processing_ttl_seconds) + self._lock = Lock() + + def begin(self, *, connection_id: str, event_id: str, delivery_attempt: int) -> DedupeBeginResult: + dedupe_key = f"{connection_id}:{event_id}" + now = _iso_now() + with self._lock: + data = self._load() + raw = data["records"].get(dedupe_key) + if isinstance(raw, dict): + record = ConnectorMessageDedupeRecord.from_dict(raw) + if record.status == "completed": + return DedupeBeginResult(False, dedupe_key, record.status, 200, None, record) + if record.status == "processing" and not self._is_stale(record, now): + return DedupeBeginResult(False, dedupe_key, record.status, 409, 5, record) + record.status = "processing" + record.updated_at = now + record.delivery_attempts = max(record.delivery_attempts + 1, int(delivery_attempt)) + record.last_error = None + else: + record = ConnectorMessageDedupeRecord( + dedupe_key=dedupe_key, + connection_id=connection_id, + event_id=event_id, + status="processing", + first_seen_at=now, + updated_at=now, + delivery_attempts=max(1, int(delivery_attempt)), + ) + data["records"][dedupe_key] = record.to_dict() + self._save(data) + return DedupeBeginResult(True, dedupe_key, record.status, 200, None, record) + + def complete(self, dedupe_key: str, *, message_id: str | None) -> ConnectorMessageDedupeRecord: + return self._mark(dedupe_key, status="completed", message_id=message_id, error=None) + + def fail(self, dedupe_key: str, *, error: str) -> ConnectorMessageDedupeRecord: + return self._mark(dedupe_key, status="failed", message_id=None, error=error) + + def _mark( + self, + dedupe_key: str, + *, + status: str, + message_id: str | None, + error: str | None, + ) -> ConnectorMessageDedupeRecord: + with self._lock: + data = self._load() + raw = data["records"].get(dedupe_key) + if not isinstance(raw, dict): + raise KeyError(dedupe_key) + record = ConnectorMessageDedupeRecord.from_dict(raw) + record.status = status + record.updated_at = _iso_now() + record.message_id = message_id or record.message_id + record.last_error = error + data["records"][dedupe_key] = record.to_dict() + self._save(data) + return record + + def _is_stale(self, record: ConnectorMessageDedupeRecord, now: str) -> bool: + age = (_parse_iso(now) - _parse_iso(record.updated_at)).total_seconds() + return age >= self.processing_ttl_seconds + + def _load(self) -> dict[str, Any]: + if not self.path.exists(): + return {"records": {}} + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"records": {}} + if not isinstance(data, dict) or not isinstance(data.get("records"), dict): + return {"records": {}} + return data + + def _save(self, data: dict[str, Any]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = self.path.with_name(f"{self.path.name}.tmp") + tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + tmp_path.replace(self.path) diff --git a/app-instance/backend/beaver/interfaces/channels/connections/external.py b/app-instance/backend/beaver/interfaces/channels/connections/external.py new file mode 100644 index 0000000..f11ec00 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/connections/external.py @@ -0,0 +1,131 @@ +"""Sidecar-backed channel connectors.""" + +from __future__ import annotations + +from typing import Any + +from .models import ChannelRuntimeSpec, ValidationResult +from .sidecar_client import ConnectorSidecarClient +from .store import ChannelConnectionStore, CredentialStore + + +class ExternalConnectorBase: + kind = "" + capabilities: list[str] = [] + + def __init__( + self, + *, + connection_store: ChannelConnectionStore, + credential_store: CredentialStore, + sidecar_client: ConnectorSidecarClient | Any, + sidecar_base_url: str, + ) -> None: + self.connection_store = connection_store + self.credential_store = credential_store + self.sidecar_client = sidecar_client + self.sidecar_base_url = sidecar_base_url + + async def start_session( + self, + *, + display_name: str, + owner_user_id: str | None, + options: dict[str, Any], + ) -> dict[str, Any]: + connection = self.connection_store.create( + kind=self.kind, + mode="sidecar", + display_name=display_name or self.kind, + account_id="", + owner_user_id=owner_user_id, + auth_type="connector_session", + runtime_config={"sidecarBaseUrl": self.sidecar_base_url}, + capabilities=list(self.capabilities), + ) + connection = self.connection_store.update_status(connection.connection_id, status="pairing", last_error=None) + payload = { + "kind": self.kind, + "connectionId": connection.connection_id, + "channelId": connection.channel_id, + "displayName": connection.display_name, + "callbackBaseUrl": "", + "options": dict(options), + } + view = dict(await self.sidecar_client.start_session(payload)) + connection.pairing_session_id = str(view.get("sessionId") or "") + self.connection_store.update(connection) + view["connectionId"] = connection.connection_id + view["channelId"] = connection.channel_id + return view + + async def poll_session(self, session_id: str) -> dict[str, Any]: + view = dict(await self.sidecar_client.get_session(session_id)) + connection = self._connection_for_session(session_id) + status = str(view.get("status") or "") + if status == "connected": + connection.account_id = str(view.get("accountId") or connection.account_id) + connection.display_name = str(view.get("displayName") or connection.display_name) + metadata = view.get("metadata") if isinstance(view.get("metadata"), dict) else {} + state_ref = metadata.get("stateRef") + if state_ref: + connection.credentials_ref = self.credential_store.put(kind=self.kind, values={"stateRef": state_ref}) + self.connection_store.update(connection) + self.connection_store.update_status(connection.connection_id, status="connected", last_error=None) + elif status in {"expired", "error", "cancelled"}: + self.connection_store.update_status( + connection.connection_id, + status="error", + last_error=str(view.get("error") or status), + ) + view["connectionId"] = connection.connection_id + view["channelId"] = connection.channel_id + return view + + async def validate(self, connection_id: str) -> ValidationResult: + connection = self.connection_store.get(connection_id) + if connection.status in {"connected", "running"}: + return ValidationResult( + ok=True, + status="connected", + account_id=connection.account_id, + display_name=connection.display_name, + ) + return ValidationResult(ok=False, status=connection.status, error=connection.last_error) + + async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec: + connection = self.connection_store.get(connection_id) + if connection.status not in {"connected", "running"}: + raise ValueError(f"Connection is not connected: {connection.connection_id}") + return ChannelRuntimeSpec( + channel_id=connection.channel_id, + kind="external_connector", + mode="http", + account_id=connection.account_id, + display_name=connection.display_name, + config={ + "platformKind": self.kind, + "connectionId": connection.connection_id, + "sidecarBaseUrl": connection.runtime_config.get("sidecarBaseUrl") or self.sidecar_base_url, + }, + secrets_ref=None, + ) + + async def revoke(self, connection_id: str) -> None: + await self.sidecar_client.logout(connection_id) + + def _connection_for_session(self, session_id: str): + for connection in self.connection_store.list(): + if connection.pairing_session_id == session_id: + return connection + raise KeyError(session_id) + + +class WeixinConnector(ExternalConnectorBase): + kind = "weixin" + capabilities = ["receive_text", "send_text", "receive_media", "direct_messages"] + + +class FeishuConnector(ExternalConnectorBase): + kind = "feishu" + capabilities = ["receive_text", "send_text", "receive_media", "groups"] diff --git a/app-instance/backend/beaver/interfaces/channels/connections/models.py b/app-instance/backend/beaver/interfaces/channels/connections/models.py new file mode 100644 index 0000000..b2928ce --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/connections/models.py @@ -0,0 +1,117 @@ +"""Channel connection setup models.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from typing import Any + + +CONNECTION_STATUSES = {"draft", "pairing", "connected", "running", "degraded", "error", "revoked"} + + +def iso_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +@dataclass(slots=True) +class ChannelConnection: + connection_id: str + owner_user_id: str | None + channel_id: str + kind: str + mode: str + display_name: str + account_id: str + status: str + auth_type: str + credentials_ref: str | None = None + connector_ref: str | None = None + pairing_session_id: str | None = None + runtime_config: dict[str, Any] = field(default_factory=dict) + capabilities: list[str] = field(default_factory=list) + created_at: str = field(default_factory=iso_now) + updated_at: str = field(default_factory=iso_now) + last_seen_at: str | None = None + last_error: str | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ChannelConnection": + return cls( + connection_id=str(data.get("connection_id") or ""), + owner_user_id=_optional_string(data.get("owner_user_id")), + channel_id=str(data.get("channel_id") or ""), + kind=str(data.get("kind") or ""), + mode=str(data.get("mode") or ""), + display_name=str(data.get("display_name") or ""), + account_id=str(data.get("account_id") or ""), + status=str(data.get("status") or "draft"), + auth_type=str(data.get("auth_type") or ""), + credentials_ref=_optional_string(data.get("credentials_ref")), + connector_ref=_optional_string(data.get("connector_ref")), + pairing_session_id=_optional_string(data.get("pairing_session_id")), + runtime_config=dict(data.get("runtime_config") or {}), + capabilities=[str(item) for item in data.get("capabilities") or []], + created_at=str(data.get("created_at") or iso_now()), + updated_at=str(data.get("updated_at") or iso_now()), + last_seen_at=_optional_string(data.get("last_seen_at")), + last_error=_optional_string(data.get("last_error")), + ) + + +@dataclass(slots=True) +class PairingSession: + pairing_session_id: str + kind: str + scope: str + token: str + status: str + expires_at_ms: int + created_at_ms: int + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "PairingSession": + return cls( + pairing_session_id=str(data.get("pairing_session_id") or ""), + kind=str(data.get("kind") or ""), + scope=str(data.get("scope") or ""), + token=str(data.get("token") or ""), + status=str(data.get("status") or "pending"), + expires_at_ms=int(data.get("expires_at_ms") or 0), + created_at_ms=int(data.get("created_at_ms") or 0), + ) + + +@dataclass(slots=True) +class ChannelRuntimeSpec: + channel_id: str + kind: str + mode: str + account_id: str + display_name: str + config: dict[str, Any] = field(default_factory=dict) + secrets_ref: str | None = None + external_endpoint: str | None = None + + +@dataclass(slots=True) +class ValidationResult: + ok: bool + status: str + account_id: str | None = None + display_name: str | None = None + error: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +def _optional_string(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None diff --git a/app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py b/app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py new file mode 100644 index 0000000..62e6b27 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py @@ -0,0 +1,39 @@ +"""HTTP client for the generic external connector sidecar.""" + +from __future__ import annotations + +from typing import Any + +import httpx + + +class ConnectorSidecarClient: + def __init__(self, *, base_url: str, token: str, timeout_seconds: float = 20.0) -> None: + self.base_url = base_url.rstrip("/") + self.token = token + self.timeout_seconds = float(timeout_seconds) + + async def get_connectors(self) -> list[dict[str, Any]]: + return await self._request("GET", "/connectors") + + async def start_session(self, payload: dict[str, Any]) -> dict[str, Any]: + return await self._request("POST", "/connector-sessions", json=payload) + + async def get_session(self, session_id: str) -> dict[str, Any]: + return await self._request("GET", f"/connector-sessions/{session_id}") + + async def cancel_session(self, session_id: str) -> dict[str, Any]: + return await self._request("POST", f"/connector-sessions/{session_id}/cancel", json={}) + + async def logout(self, connection_id: str) -> dict[str, Any]: + return await self._request("POST", f"/connections/{connection_id}/logout", json={}) + + async def send(self, payload: dict[str, Any]) -> dict[str, Any]: + return await self._request("POST", "/send", json=payload) + + async def _request(self, method: str, path: str, *, json: dict[str, Any] | None = None) -> Any: + headers = {"Authorization": f"Bearer {self.token}"} if self.token else {} + async with httpx.AsyncClient(timeout=self.timeout_seconds) as client: + response = await client.request(method, f"{self.base_url}{path}", json=json, headers=headers) + response.raise_for_status() + return response.json() diff --git a/app-instance/backend/beaver/interfaces/channels/connections/store.py b/app-instance/backend/beaver/interfaces/channels/connections/store.py new file mode 100644 index 0000000..96c8076 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/connections/store.py @@ -0,0 +1,222 @@ +"""Persistent channel connection stores.""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +from threading import Lock +from typing import Any +from uuid import uuid4 + +from .models import CONNECTION_STATUSES, ChannelConnection, PairingSession, iso_now + + +class ChannelConnectionStore: + def __init__(self, path: Path) -> None: + self.path = Path(path) + self._lock = Lock() + + def create( + self, + *, + kind: str, + mode: str, + display_name: str, + account_id: str, + owner_user_id: str | None, + auth_type: str, + runtime_config: dict[str, Any] | None = None, + capabilities: list[str] | None = None, + credentials_ref: str | None = None, + ) -> ChannelConnection: + with self._lock: + data = self._load() + connection_id = f"conn_{uuid4().hex}" + channel_id = f"{_slug(kind)}-{uuid4().hex[:8]}" + now = iso_now() + connection = ChannelConnection( + connection_id=connection_id, + owner_user_id=owner_user_id, + channel_id=channel_id, + kind=kind, + mode=mode, + display_name=display_name or channel_id, + account_id=account_id, + status="draft", + auth_type=auth_type, + credentials_ref=credentials_ref, + runtime_config=runtime_config or {}, + capabilities=capabilities or [], + created_at=now, + updated_at=now, + ) + data["connections"][connection_id] = connection.to_dict() + self._save(data) + return connection + + def get(self, connection_id: str) -> ChannelConnection: + data = self._load() + raw = data["connections"].get(connection_id) + if not isinstance(raw, dict): + raise KeyError(connection_id) + return ChannelConnection.from_dict(raw) + + def list(self) -> list[ChannelConnection]: + data = self._load() + return [ChannelConnection.from_dict(item) for item in data["connections"].values() if isinstance(item, dict)] + + def update(self, connection: ChannelConnection) -> ChannelConnection: + with self._lock: + data = self._load() + if connection.connection_id not in data["connections"]: + raise KeyError(connection.connection_id) + connection.updated_at = iso_now() + data["connections"][connection.connection_id] = connection.to_dict() + self._save(data) + return connection + + def update_status(self, connection_id: str, *, status: str, last_error: str | None) -> ChannelConnection: + if status not in CONNECTION_STATUSES: + raise ValueError(f"Unsupported connection status: {status}") + connection = self.get(connection_id) + connection.status = status + connection.last_error = last_error + if status in {"connected", "running"}: + connection.last_seen_at = iso_now() + return self.update(connection) + + def revoke(self, connection_id: str) -> ChannelConnection: + return self.update_status(connection_id, status="revoked", last_error=None) + + def _load(self) -> dict[str, Any]: + if not self.path.exists(): + return {"connections": {}} + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"connections": {}} + if not isinstance(data, dict) or not isinstance(data.get("connections"), dict): + return {"connections": {}} + return data + + def _save(self, data: dict[str, Any]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = self.path.with_name(f"{self.path.name}.tmp") + tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + tmp_path.replace(self.path) + + +class CredentialStore: + def __init__(self, path: Path) -> None: + self.path = Path(path) + self._lock = Lock() + + def put(self, *, kind: str, values: dict[str, Any]) -> str: + cleaned = {str(key): str(value) for key, value in values.items() if str(key).strip() and str(value).strip()} + ref = f"cred_{uuid4().hex}" + with self._lock: + data = self._load() + data["credentials"][ref] = {"kind": kind, "values": cleaned, "created_at": iso_now()} + self._save(data) + return ref + + def get(self, ref: str) -> dict[str, str]: + data = self._load() + item = data["credentials"].get(ref) + if not isinstance(item, dict): + raise KeyError(ref) + values = item.get("values") + if not isinstance(values, dict): + return {} + return {str(key): str(value) for key, value in values.items()} + + def redacted(self, ref: str | None) -> dict[str, str]: + if not ref: + return {} + try: + values = self.get(ref) + except KeyError: + return {} + return {key: "***" for key in values} + + def _load(self) -> dict[str, Any]: + if not self.path.exists(): + return {"credentials": {}} + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"credentials": {}} + if not isinstance(data, dict) or not isinstance(data.get("credentials"), dict): + return {"credentials": {}} + return data + + def _save(self, data: dict[str, Any]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = self.path.with_name(f"{self.path.name}.tmp") + tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + tmp_path.replace(self.path) + + +class PairingTokenStore: + def __init__(self, path: Path) -> None: + self.path = Path(path) + self._lock = Lock() + + def create(self, *, kind: str, ttl_seconds: int, scope: str) -> PairingSession: + now_ms = _now_ms() + session = PairingSession( + pairing_session_id=f"pair_{uuid4().hex}", + kind=kind, + scope=scope, + token=f"pair_{uuid4().hex}", + status="pending", + expires_at_ms=now_ms + int(ttl_seconds * 1000), + created_at_ms=now_ms, + ) + with self._lock: + data = self._load() + data["sessions"][session.pairing_session_id] = session.to_dict() + self._save(data) + return session + + def consume(self, token: str, *, expected_kind: str) -> PairingSession | None: + with self._lock: + data = self._load() + for key, raw in data["sessions"].items(): + session = PairingSession.from_dict(raw) + if session.token != token or session.kind != expected_kind: + continue + if session.status != "pending" or session.expires_at_ms <= _now_ms(): + return None + session.status = "consumed" + data["sessions"][key] = session.to_dict() + self._save(data) + return session + return None + + def _load(self) -> dict[str, Any]: + if not self.path.exists(): + return {"sessions": {}} + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"sessions": {}} + if not isinstance(data, dict) or not isinstance(data.get("sessions"), dict): + return {"sessions": {}} + return data + + def _save(self, data: dict[str, Any]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = self.path.with_name(f"{self.path.name}.tmp") + tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + tmp_path.replace(self.path) + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _slug(value: str) -> str: + text = "".join(char if char.isalnum() else "-" for char in str(value).strip().lower()) + return "-".join(part for part in text.split("-") if part) or "channel" diff --git a/app-instance/backend/beaver/interfaces/channels/connections/telegram.py b/app-instance/backend/beaver/interfaces/channels/connections/telegram.py new file mode 100644 index 0000000..06ae2df --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/connections/telegram.py @@ -0,0 +1,92 @@ +"""Telegram channel connector.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from .models import ChannelRuntimeSpec, ValidationResult +from .store import ChannelConnectionStore, CredentialStore + + +class TelegramConnector: + kind = "telegram" + + def __init__( + self, + *, + connection_store: ChannelConnectionStore, + credential_store: CredentialStore, + client_factory: Callable[[str], Any] | None = None, + ) -> None: + self.connection_store = connection_store + self.credential_store = credential_store + self.client_factory = client_factory or _default_client_factory + + async def validate(self, connection_id: str) -> ValidationResult: + connection = self.connection_store.get(connection_id) + token = self._bot_token(connection.credentials_ref) + try: + client = self.client_factory(token) + raw = await client.get_me() + bot_id = _value(raw, "id") + username = _value(raw, "username") + first_name = _value(raw, "first_name") or "Telegram Bot" + account_id = f"telegram:{bot_id}" if bot_id else connection.account_id + display_name = f"{first_name} (@{username})" if username else first_name + connection.account_id = account_id + connection.display_name = display_name + connection.capabilities = ["receive_text", "send_text", "receive_media", "groups"] + self.connection_store.update(connection) + return ValidationResult( + ok=True, + status="connected", + account_id=account_id, + display_name=display_name, + metadata={"username": username} if username else {}, + ) + except Exception as exc: + return ValidationResult(ok=False, status="error", error=str(exc)) + + async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec: + connection = self.connection_store.get(connection_id) + if connection.status not in {"connected", "running"}: + raise ValueError(f"Connection is not connected: {connection.connection_id}") + return ChannelRuntimeSpec( + channel_id=connection.channel_id, + kind=connection.kind, + mode=connection.mode, + account_id=connection.account_id, + display_name=connection.display_name, + config=dict(connection.runtime_config), + secrets_ref=connection.credentials_ref, + ) + + async def revoke(self, connection_id: str) -> None: + # Telegram bot tokens do not have a Beaver-managed platform revoke action. + # The registry owns local connection state transitions. + return None + + def _bot_token(self, credentials_ref: str | None) -> str: + if not credentials_ref: + raise ValueError("Telegram credentials are missing") + token = self.credential_store.get(credentials_ref).get("botToken") + if not token: + raise ValueError("botToken is required") + return token + + +def _value(raw: Any, key: str) -> str: + if isinstance(raw, dict): + value = raw.get(key) + else: + value = getattr(raw, key, None) + return str(value).strip() if value is not None else "" + + +def _default_client_factory(token: str) -> Any: + try: + from telegram import Bot + except ImportError as exc: # pragma: no cover - optional live dependency + raise RuntimeError("Install beaver-backend[telegram] to validate Telegram connections") from exc + return Bot(token=token) diff --git a/app-instance/backend/beaver/interfaces/channels/external_connector.py b/app-instance/backend/beaver/interfaces/channels/external_connector.py new file mode 100644 index 0000000..397f058 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/external_connector.py @@ -0,0 +1,97 @@ +"""Generic runtime channel backed by an external connector sidecar.""" + +from __future__ import annotations + +import hashlib +from typing import Any + +from beaver.foundation.events import OutboundMessage +from beaver.interfaces.channels.connections.sidecar_client import ConnectorSidecarClient + + +class ExternalConnectorChannel: + def __init__( + self, + *, + channel_id: str, + platform_kind: str, + connection_id: str, + account_id: str, + display_name: str, + sidecar_client: ConnectorSidecarClient | Any, + ) -> None: + self.channel_id = channel_id + self.kind = "external_connector" + self.mode = "http" + self.platform_kind = platform_kind + self.connection_id = connection_id + self.account_id = account_id + self.display_name = display_name or channel_id + self.sidecar_client = sidecar_client + self.started = False + + async def start(self) -> None: + self.started = True + + async def stop(self) -> None: + self.started = False + + async def send(self, message: OutboundMessage) -> None: + identity = message.channel_identity + if identity is None: + raise ValueError("channel_identity is required for external connector sends") + metadata = { + "inboundMessageId": identity.message_id, + "sessionId": message.session_id, + } + context_token = _context_token(message) + if context_token: + metadata["contextToken"] = context_token + payload = { + "requestId": _request_id(message), + "connectionId": self.connection_id, + "channelId": self.channel_id, + "kind": self.platform_kind, + "target": { + "peerId": identity.peer_id, + "peerType": identity.peer_type, + "threadId": identity.thread_id, + }, + "content": message.content, + "metadata": metadata, + } + await self.sidecar_client.send(payload) + + +def _request_id(message: OutboundMessage) -> str: + identity = message.channel_identity + channel = message.channel or (identity.channel_id if identity else "unknown") + session_id = message.session_id or (identity.session_id() if identity else "unknown") + message_id = str(message.message_id or "").strip() + if not message_id: + basis = "|".join( + [ + message.content, + identity.message_id if identity and identity.message_id else "", + identity.peer_id if identity else "", + message.finish_reason, + ] + ) + message_id = hashlib.sha256(basis.encode("utf-8")).hexdigest()[:24] + return f"out_{channel}:{session_id}:{message_id}" + + +def _context_token(message: OutboundMessage) -> str | None: + inbound_metadata = message.metadata.get("inbound_metadata") + if isinstance(inbound_metadata, dict): + value = _clean_optional(inbound_metadata.get("contextToken") or inbound_metadata.get("context_token")) + if value: + return value + return _clean_optional(message.metadata.get("contextToken") or message.metadata.get("context_token")) + + +def _clean_optional(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None diff --git a/app-instance/backend/beaver/interfaces/channels/generic_webhook.py b/app-instance/backend/beaver/interfaces/channels/generic_webhook.py new file mode 100644 index 0000000..3b22ee3 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/generic_webhook.py @@ -0,0 +1,116 @@ +"""Generic fixed-schema text webhook channel adapter.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage +from beaver.interfaces.channels.base import ChannelInboundSink + + +class GenericWebhookAdapter: + def __init__( + self, + *, + channel_id: str, + kind: str, + mode: str, + account_id: str, + display_name: str = "", + inbound_sink: ChannelInboundSink, + response_timeout_seconds: float = 1800, + ) -> None: + self.channel_id = channel_id + self.kind = kind + self.mode = mode + self.account_id = account_id + self.display_name = display_name or channel_id + self.inbound_sink = inbound_sink + self.response_timeout_seconds = max(1.0, float(response_timeout_seconds)) + self.started = False + self._pending: dict[str, asyncio.Future[OutboundMessage]] = {} + + async def start(self) -> None: + self.started = True + + async def stop(self) -> None: + self.started = False + for future in list(self._pending.values()): + if not future.done(): + future.cancel() + self._pending.clear() + + async def handle_webhook_payload(self, payload: dict[str, Any]) -> dict[str, Any]: + text = str(payload.get("text") or "").strip() + peer_id = str(payload.get("peer_id") or "").strip() + message_id = str(payload.get("message_id") or "").strip() + thread_id = str(payload.get("thread_id") or "").strip() or None + peer_type = str(payload.get("peer_type") or "unknown").strip() or "unknown" + user_id = str(payload.get("user_id") or "").strip() or None + if not text: + return {"ok": False, "error": "text is required"} + if not peer_id: + return {"ok": False, "error": "peer_id is required"} + if not message_id: + return {"ok": False, "error": "message_id is required"} + + identity = ChannelIdentity( + channel_id=self.channel_id, + kind=self.kind, + account_id=self.account_id, + peer_id=peer_id, + thread_id=thread_id, + peer_type=peer_type, + user_id=user_id, + message_id=message_id, + ) + inbound = InboundMessage( + channel=self.channel_id, + content=text, + user_id=user_id, + channel_identity=identity, + metadata={"webhook": {"peer_type": peer_type}}, + ) + future = asyncio.get_running_loop().create_future() + self._pending[inbound.message_id] = future + accept = await self.inbound_sink.accept_inbound(inbound) + if not accept.accepted: + self._pending.pop(inbound.message_id, None) + record = accept.record or {} + return { + "ok": accept.error is None, + "duplicate": accept.duplicate, + "pending": accept.pending, + "session_id": accept.session_id, + "status": record.get("status"), + "run_id": record.get("run_id"), + "reply": record.get("reply"), + "error": accept.error or record.get("error"), + } + try: + outbound = await asyncio.wait_for(future, timeout=self.response_timeout_seconds) + except asyncio.TimeoutError: + self._pending.pop(inbound.message_id, None) + return { + "ok": True, + "duplicate": False, + "pending": True, + "session_id": accept.session_id, + } + return { + "ok": outbound.finish_reason != "error", + "duplicate": False, + "pending": False, + "session_id": outbound.session_id, + "run_id": outbound.run_id, + "reply": outbound.content, + "error": outbound.metadata.get("error"), + } + + async def send(self, message: OutboundMessage) -> None: + future = self._pending.pop(message.message_id, None) + if future is None or future.done(): + message.metadata["delivery_status"] = "unclaimed" + return + future.set_result(message) diff --git a/app-instance/backend/beaver/interfaces/channels/manager.py b/app-instance/backend/beaver/interfaces/channels/manager.py index 438191b..cd3b969 100644 --- a/app-instance/backend/beaver/interfaces/channels/manager.py +++ b/app-instance/backend/beaver/interfaces/channels/manager.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from collections.abc import Awaitable, Callable from contextlib import suppress from beaver.foundation.events import MessageBus, OutboundMessage @@ -20,13 +21,17 @@ class ChannelManager: self.started = False def register(self, channel: ChannelAdapter) -> None: - if self.started: - raise RuntimeError("Cannot register channels after ChannelManager.start()") - if channel.name in self.channels: - raise ValueError(f"Channel already registered: {channel.name}") - if channel.bus is not self.bus: - raise ValueError("Channel must share the same MessageBus as ChannelManager") - self.channels[channel.name] = channel + if channel.channel_id in self.channels: + raise ValueError(f"Channel already registered: {channel.channel_id}") + self.channels[channel.channel_id] = channel + + def unregister(self, channel_id: str) -> ChannelAdapter | None: + return self.channels.pop(channel_id, None) + + def replace_registered(self, channel: ChannelAdapter) -> ChannelAdapter | None: + old = self.channels.get(channel.channel_id) + self.channels[channel.channel_id] = channel + return old async def start(self) -> None: started: list[ChannelAdapter] = [] @@ -53,7 +58,13 @@ class ChannelManager: if errors: raise RuntimeError(f"Failed to stop {len(errors)} channel(s)") from errors[0] - async def dispatch_outbound(self, stop_event: asyncio.Event) -> None: + async def dispatch_outbound( + self, + stop_event: asyncio.Event, + *, + on_delivered: Callable[[OutboundMessage], Awaitable[None]] | None = None, + on_failed: Callable[[OutboundMessage, Exception | None], Awaitable[None]] | None = None, + ) -> None: """Route bus outbound messages until stopped and the queue is drained.""" while True: @@ -68,9 +79,16 @@ class ChannelManager: channel = self.channels.get(message.channel) if channel is None: self.undeliverable.append(message) + if on_failed is not None: + await on_failed(message, None) continue try: await channel.send(message) - except Exception: # pragma: no cover - defensive channel isolation + except Exception as exc: # pragma: no cover - defensive channel isolation self.undeliverable.append(message) + if on_failed is not None: + await on_failed(message, exc) + else: + if on_delivered is not None: + await on_delivered(message) diff --git a/app-instance/backend/beaver/interfaces/channels/memory.py b/app-instance/backend/beaver/interfaces/channels/memory.py index c7702b5..b0b90d8 100644 --- a/app-instance/backend/beaver/interfaces/channels/memory.py +++ b/app-instance/backend/beaver/interfaces/channels/memory.py @@ -4,15 +4,27 @@ from __future__ import annotations from typing import Any -from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage +from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage +from beaver.interfaces.channels.base import ChannelInboundSink class MemoryChannelAdapter: """A local channel that stores outbound messages in memory.""" - def __init__(self, bus: MessageBus, *, name: str = "memory") -> None: - self.name = name - self.bus = bus + def __init__( + self, + inbound_sink: ChannelInboundSink, + *, + channel_id: str = "memory-dev", + kind: str = "memory", + mode: str = "webhook", + account_id: str = "memory", + ) -> None: + self.channel_id = channel_id + self.kind = kind + self.mode = mode + self.account_id = account_id + self.inbound_sink = inbound_sink self.started = False self.sent_messages: list[OutboundMessage] = [] @@ -36,12 +48,24 @@ class MemoryChannelAdapter: model: str | None = None, provider_name: str | None = None, embedding_model: str | None = None, + peer_id: str = "default", + thread_id: str | None = None, + message_id: str | None = None, metadata: dict[str, Any] | None = None, ) -> InboundMessage: """Publish a text message from this channel into the shared bus.""" + identity = ChannelIdentity( + channel_id=self.channel_id, + kind=self.kind, + account_id=self.account_id, + peer_id=peer_id, + thread_id=thread_id, + user_id=user_id, + message_id=message_id, + ) message = InboundMessage( - channel=self.name, + channel=self.channel_id, content=content, session_id=session_id, user_id=user_id, @@ -50,9 +74,10 @@ class MemoryChannelAdapter: model=model, provider_name=provider_name, embedding_model=embedding_model, + channel_identity=identity, metadata=metadata or {}, ) - await self.bus.publish_inbound(message) + await self.inbound_sink.accept_inbound(message) return message async def publish_external_text( @@ -73,9 +98,6 @@ class MemoryChannelAdapter: the shared gateway bus. """ - session_parts = [self.name, chat_id] - if thread_id: - session_parts.append(thread_id) metadata = { "chat_id": chat_id, "message_id": message_id, @@ -84,8 +106,10 @@ class MemoryChannelAdapter: } return await self.publish_text( content, - session_id=":".join(str(part) for part in session_parts if str(part)), user_id=user_id, title=title, + peer_id=chat_id, + thread_id=thread_id, + message_id=message_id, metadata=metadata, ) diff --git a/app-instance/backend/beaver/interfaces/channels/platforms/__init__.py b/app-instance/backend/beaver/interfaces/channels/platforms/__init__.py new file mode 100644 index 0000000..80773e0 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/platforms/__init__.py @@ -0,0 +1 @@ +"""Platform channel adapters.""" diff --git a/app-instance/backend/beaver/interfaces/channels/platforms/base.py b/app-instance/backend/beaver/interfaces/channels/platforms/base.py new file mode 100644 index 0000000..e4789ce --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/platforms/base.py @@ -0,0 +1,138 @@ +"""Shared helpers for platform channel adapters.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage + + +@dataclass(slots=True) +class OutboundTarget: + peer_id: str | None + thread_id: str | None = None + peer_type: str = "unknown" + user_id: str | None = None + + +class PlatformDeliveryError(RuntimeError): + """Raised when a platform client rejects a delivery.""" + + +def config_bool(config: dict[str, Any], key: str, *, default: bool = False) -> bool: + value = config.get(key) + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + text = str(value).strip().lower() + if text in {"1", "true", "yes", "on"}: + return True + if text in {"0", "false", "no", "off"}: + return False + return default + + +def config_list(config: dict[str, Any], key: str) -> list[str]: + value = config.get(key) + if value is None: + return [] + if isinstance(value, str): + return [part.strip() for part in value.split(",") if part.strip()] + if isinstance(value, (list, tuple, set)): + return [str(item).strip() for item in value if str(item).strip()] + text = str(value).strip() + return [text] if text else [] + + +def chunk_text(text: str, *, max_chars: int) -> list[str]: + if max_chars <= 0: + raise ValueError("max_chars must be positive") + if not text: + return [""] + return [text[index : index + max_chars] for index in range(0, len(text), max_chars)] + + +def compact_media_summary(media_type: str, *, file_name: str | None = None) -> str: + label = str(media_type or "attachment").strip() or "attachment" + if file_name: + return f"[{label}: {file_name}]" + return f"[{label}]" + + +def target_from_session_id(session_id: str | None) -> OutboundTarget: + if not session_id: + return OutboundTarget(peer_id=None) + parts = str(session_id).split(":") + if len(parts) < 3: + return OutboundTarget(peer_id=None) + thread_id = parts[3] if len(parts) > 3 and parts[3] else None + return OutboundTarget(peer_id=parts[2] or None, thread_id=thread_id) + + +def outbound_target(message: OutboundMessage) -> OutboundTarget: + identity = message.channel_identity + if identity is None: + return target_from_session_id(message.session_id) + return OutboundTarget( + peer_id=identity.peer_id, + thread_id=identity.thread_id, + peer_type=identity.peer_type, + user_id=identity.user_id, + ) + + +def mark_unclaimed(message: OutboundMessage) -> None: + message.metadata["delivery_status"] = "unclaimed" + + +def build_inbound_message( + *, + channel_id: str, + kind: str, + account_id: str, + peer_id: str, + content: str, + message_id: str | None, + peer_type: str, + user_id: str | None = None, + thread_id: str | None = None, + metadata: dict[str, Any] | None = None, +) -> InboundMessage: + identity = ChannelIdentity( + channel_id=channel_id, + kind=kind, + account_id=account_id, + peer_id=peer_id, + thread_id=thread_id, + peer_type=peer_type, + user_id=user_id, + message_id=message_id, + ) + return InboundMessage( + channel=channel_id, + content=content, + session_id=identity.session_id(), + user_id=user_id, + message_id=message_id or "", + channel_identity=identity, + metadata=metadata or {}, + ) + + +def allowed_by_policy( + *, + policy: str | None, + identifier: str | None, + allowlist: list[str], + default: str = "open", +) -> bool: + effective = (policy or default).strip().lower() + if effective == "disabled": + return False + if effective == "allowlist": + return bool(identifier and identifier in allowlist) + return True diff --git a/app-instance/backend/beaver/interfaces/channels/platforms/feishu.py b/app-instance/backend/beaver/interfaces/channels/platforms/feishu.py new file mode 100644 index 0000000..20b834d --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/platforms/feishu.py @@ -0,0 +1,207 @@ +"""Feishu/Lark channel adapter.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import Any + +from beaver.foundation.events import InboundMessage, OutboundMessage +from beaver.interfaces.channels.base import ChannelInboundSink + +from .base import ( + build_inbound_message, + chunk_text, + compact_media_summary, + config_bool, + config_list, + mark_unclaimed, + outbound_target, +) + +EventRecorder = Callable[..., None] + + +class FeishuAdapter: + """Feishu/Lark bot adapter with injectable client support.""" + + KIND = "feishu" + + def __init__( + self, + *, + channel_id: str, + kind: str, + mode: str, + account_id: str, + display_name: str | None, + inbound_sink: ChannelInboundSink, + secrets: dict[str, Any] | None = None, + config: dict[str, Any] | None = None, + event_recorder: EventRecorder | None = None, + client: Any | None = None, + ) -> None: + self.channel_id = channel_id + self.kind = kind + self.mode = mode + self.account_id = account_id + self.display_name = display_name + self.inbound_sink = inbound_sink + self.secrets = secrets or {} + self.config = config or {} + self.event_recorder = event_recorder + self._client = client + self.max_message_chars = int(self.config.get("maxMessageChars") or 4096) + + async def start(self) -> None: + if self._client is not None: + return + if self.mode not in {"websocket", "webhook"}: + raise ValueError(f"Unsupported feishu mode: {self.mode}") + self._client = self._build_client() + + async def stop(self) -> None: + close = getattr(self._client, "close", None) + if close is not None: + result = close() + if hasattr(result, "__await__"): + await result + + async def handle_event_payload(self, payload: dict[str, Any]) -> None: + message = self._normalize_payload(payload) + if message is None: + return + await self.inbound_sink.accept_inbound(message) + + async def send(self, message: OutboundMessage) -> None: + target = outbound_target(message) + if not target.peer_id: + mark_unclaimed(message) + return + client = self._require_client() + for chunk in chunk_text(message.content, max_chars=self.max_message_chars): + await client.send_text(receive_id_type="chat_id", receive_id=target.peer_id, text=chunk) + + def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None: + event = payload.get("event") if isinstance(payload.get("event"), dict) else payload + message = event.get("message") if isinstance(event.get("message"), dict) else {} + sender = event.get("sender") if isinstance(event.get("sender"), dict) else {} + + peer_id = _string_or_none(message.get("chat_id")) + if not peer_id: + return None + + message_id = _string_or_none(message.get("message_id")) + message_type = str(message.get("message_type") or "unknown") + chat_type = str(message.get("chat_type") or "unknown") + peer_type = "dm" if chat_type == "p2p" else "group" + user_id = _sender_open_id(sender) + + if peer_type == "dm" and not self._dm_allowed(user_id or peer_id): + return None + if peer_type == "group" and not self._group_allowed(peer_id, user_id): + return None + if peer_type == "group" and config_bool(self.config, "requireMentionInGroups", default=False): + if not self._message_mentions_bot(message): + return None + + content = self._message_content(message_type, message) + if not content: + return None + + metadata = { + "chat_id": peer_id, + "message_id": message_id, + "chat_type": chat_type, + "message_type": message_type, + } + + return build_inbound_message( + channel_id=self.channel_id, + kind=self.kind, + account_id=self.account_id, + peer_id=peer_id, + peer_type=peer_type, + user_id=user_id, + message_id=message_id, + content=content, + metadata=metadata, + ) + + def _message_content(self, message_type: str, message: dict[str, Any]) -> str: + content = _parse_json_object(message.get("content")) + if message_type == "text": + return str(content.get("text") or "").strip() + file_name = _string_or_none(content.get("file_name") or content.get("name")) + return compact_media_summary(message_type, file_name=file_name) + + def _message_mentions_bot(self, message: dict[str, Any]) -> bool: + bot_open_id = _string_or_none(self.config.get("botOpenId")) + if not bot_open_id: + return False + mentions = message.get("mentions") + if not isinstance(mentions, list): + return False + for mention in mentions: + if not isinstance(mention, dict): + continue + mention_id = mention.get("id") if isinstance(mention.get("id"), dict) else {} + if _string_or_none(mention_id.get("open_id")) == bot_open_id: + return True + return False + + def _dm_allowed(self, identifier: str | None) -> bool: + allowlist = config_list(self.config, "allowFrom") + if not allowlist: + return True + return bool(identifier and identifier in allowlist) + + def _group_allowed(self, peer_id: str | None, user_id: str | None) -> bool: + allowlist = config_list(self.config, "groupAllowFrom") + if not allowlist: + return True + return bool((peer_id and peer_id in allowlist) or (user_id and user_id in allowlist)) + + def _require_client(self) -> Any: + if self._client is None: + self._client = self._build_client() + return self._client + + def _build_client(self) -> Any: + self._require_secret("appId") + self._require_secret("appSecret") + try: + import lark_oapi # noqa: F401 + except ImportError as exc: # pragma: no cover - optional live dependency + raise RuntimeError("Install beaver-backend[feishu] to enable FeishuAdapter") from exc + raise RuntimeError("Feishu live client is not configured for direct construction") + + def _require_secret(self, key: str) -> str: + value = self.secrets.get(key) + if not value: + raise ValueError(f"{key} is required") + return str(value) + + +def _parse_json_object(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if not isinstance(value, str): + return {} + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return {} + return parsed if isinstance(parsed, dict) else {} + + +def _sender_open_id(sender: dict[str, Any]) -> str | None: + sender_id = sender.get("sender_id") if isinstance(sender.get("sender_id"), dict) else {} + return _string_or_none(sender_id.get("open_id")) + + +def _string_or_none(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None diff --git a/app-instance/backend/beaver/interfaces/channels/platforms/qqbot.py b/app-instance/backend/beaver/interfaces/channels/platforms/qqbot.py new file mode 100644 index 0000000..4060677 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/platforms/qqbot.py @@ -0,0 +1,206 @@ +"""QQ Bot channel adapter.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from beaver.foundation.events import InboundMessage, OutboundMessage +from beaver.interfaces.channels.base import ChannelInboundSink + +from .base import ( + allowed_by_policy, + build_inbound_message, + chunk_text, + compact_media_summary, + config_list, + mark_unclaimed, + outbound_target, +) + +EventRecorder = Callable[..., None] + + +class QQBotAdapter: + """QQ Bot API adapter with injectable client support.""" + + KIND = "qqbot" + + def __init__( + self, + *, + channel_id: str, + kind: str, + mode: str, + account_id: str, + display_name: str | None, + inbound_sink: ChannelInboundSink, + secrets: dict[str, Any] | None = None, + config: dict[str, Any] | None = None, + event_recorder: EventRecorder | None = None, + client: Any | None = None, + ) -> None: + self.channel_id = channel_id + self.kind = kind + self.mode = mode + self.account_id = account_id + self.display_name = display_name + self.inbound_sink = inbound_sink + self.secrets = secrets or {} + self.config = config or {} + self.event_recorder = event_recorder + self._client = client + self.max_message_chars = int(self.config.get("maxMessageChars") or 2000) + + async def start(self) -> None: + if self._client is not None: + return + if self.mode != "websocket": + raise ValueError(f"Unsupported qqbot mode: {self.mode}") + self._client = self._build_client() + + async def stop(self) -> None: + close = getattr(self._client, "close", None) + if close is not None: + result = close() + if hasattr(result, "__await__"): + await result + + async def handle_event_payload(self, payload: dict[str, Any]) -> None: + message = self._normalize_payload(payload) + if message is None: + return + await self.inbound_sink.accept_inbound(message) + + async def send(self, message: OutboundMessage) -> None: + target = outbound_target(message) + if not target.peer_id: + mark_unclaimed(message) + return + client = self._require_client() + platform_message_id = message.channel_identity.message_id if message.channel_identity else None + for chunk in chunk_text(message.content, max_chars=self.max_message_chars): + await client.send_text( + peer_type=target.peer_type, + peer_id=target.peer_id, + content=chunk, + message_id=platform_message_id, + ) + + def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None: + event_type = str(payload.get("t") or payload.get("type") or "") + data = payload.get("d") if isinstance(payload.get("d"), dict) else payload + author = data.get("author") if isinstance(data.get("author"), dict) else {} + + route = self._route(event_type, data, author) + if route is None: + return None + peer_id, peer_type, user_id, thread_id = route + + if peer_type == "dm": + if not allowed_by_policy( + policy=self.config.get("dmPolicy"), + identifier=user_id or peer_id, + allowlist=config_list(self.config, "allowFrom"), + default="open", + ): + return None + elif peer_type == "group": + if not allowed_by_policy( + policy=self.config.get("groupPolicy"), + identifier=peer_id, + allowlist=config_list(self.config, "groupAllowFrom"), + default="open", + ): + return None + + message_id = _string_or_none(data.get("id")) + content = str(data.get("content") or "").strip() + media_entries = self._media_entries(data) + if media_entries: + content = "\n".join([part for part in [content, *media_entries] if part]).strip() + if not content: + return None + + metadata = { + "event_type": event_type, + "message_id": message_id, + "peer_type": peer_type, + } + if media_entries: + metadata["media"] = media_entries + + return build_inbound_message( + channel_id=self.channel_id, + kind=self.kind, + account_id=self.account_id, + peer_id=peer_id, + thread_id=thread_id, + peer_type=peer_type, + user_id=user_id, + message_id=message_id, + content=content, + metadata=metadata, + ) + + def _route( + self, + event_type: str, + data: dict[str, Any], + author: dict[str, Any], + ) -> tuple[str, str, str | None, str | None] | None: + if event_type == "C2C_MESSAGE_CREATE": + peer_id = _string_or_none(author.get("user_openid")) + if not peer_id: + return None + return peer_id, "dm", peer_id, None + if event_type == "GROUP_AT_MESSAGE_CREATE": + peer_id = _string_or_none(data.get("group_openid")) + if not peer_id: + return None + return peer_id, "group", _string_or_none(author.get("member_openid")), None + if data.get("guild_id") and data.get("channel_id"): + peer_id = _string_or_none(data.get("channel_id")) + if not peer_id: + return None + return peer_id, "channel", _string_or_none(author.get("id")), _string_or_none(data.get("guild_id")) + return None + + def _media_entries(self, data: dict[str, Any]) -> list[str]: + entries: list[str] = [] + attachments = data.get("attachments") + if not isinstance(attachments, list): + return entries + for attachment in attachments: + if not isinstance(attachment, dict): + continue + media_type = str(attachment.get("content_type") or attachment.get("type") or "attachment") + entries.append(compact_media_summary(media_type, file_name=_string_or_none(attachment.get("filename")))) + return entries + + def _require_client(self) -> Any: + if self._client is None: + self._client = self._build_client() + return self._client + + def _build_client(self) -> Any: + self._require_secret("appId") + self._require_secret("clientSecret") + try: + import aiohttp # noqa: F401 + except ImportError as exc: # pragma: no cover - optional live dependency + raise RuntimeError("Install beaver-backend[qqbot] to enable QQBotAdapter") from exc + raise RuntimeError("QQBot live client is not configured for direct construction") + + def _require_secret(self, key: str) -> str: + value = self.secrets.get(key) + if not value: + raise ValueError(f"{key} is required") + return str(value) + + +def _string_or_none(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None diff --git a/app-instance/backend/beaver/interfaces/channels/platforms/telegram.py b/app-instance/backend/beaver/interfaces/channels/platforms/telegram.py new file mode 100644 index 0000000..51ccda6 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/platforms/telegram.py @@ -0,0 +1,244 @@ +"""Telegram channel adapter.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any + +from beaver.foundation.events import InboundMessage, OutboundMessage +from beaver.interfaces.channels.base import ChannelInboundSink + +from .base import ( + build_inbound_message, + chunk_text, + compact_media_summary, + config_bool, + config_list, + mark_unclaimed, + outbound_target, +) + +EventRecorder = Callable[..., None] + + +class TelegramAdapter: + """Telegram Bot API adapter with injectable client support.""" + + KIND = "telegram" + + def __init__( + self, + *, + channel_id: str, + kind: str, + mode: str, + account_id: str, + display_name: str | None, + inbound_sink: ChannelInboundSink, + secrets: dict[str, Any] | None = None, + config: dict[str, Any] | None = None, + event_recorder: EventRecorder | None = None, + client: Any | None = None, + application_factory: Callable[[], Any] | None = None, + ) -> None: + self.channel_id = channel_id + self.kind = kind + self.mode = mode + self.account_id = account_id + self.display_name = display_name + self.inbound_sink = inbound_sink + self.secrets = secrets or {} + self.config = config or {} + self.event_recorder = event_recorder + self._client = client + self._application_factory = application_factory + self._application: Any | None = None + self.max_message_chars = int(self.config.get("maxMessageChars") or 4096) + + async def start(self) -> None: + if self._client is not None: + return + if self.mode == "polling": + self._application = self._build_application() + await self._application.initialize() + await self._application.start() + if getattr(self._application, "updater", None) is not None: + await self._application.updater.start_polling() + self._client = self._application.bot + return + if self.mode == "webhook": + self._client = self._build_bot() + return + raise ValueError(f"Unsupported telegram mode: {self.mode}") + + async def stop(self) -> None: + if self._application is None: + return + updater = getattr(self._application, "updater", None) + if updater is not None: + await updater.stop() + await self._application.stop() + await self._application.shutdown() + self._application = None + + async def handle_update_payload(self, payload: dict[str, Any]) -> None: + message = self._normalize_payload(payload) + if message is None: + return + await self.inbound_sink.accept_inbound(message) + + async def send(self, message: OutboundMessage) -> None: + target = outbound_target(message) + if not target.peer_id: + mark_unclaimed(message) + return + client = self._require_client() + kwargs: dict[str, Any] = {"chat_id": target.peer_id} + if target.thread_id: + kwargs["message_thread_id"] = int(target.thread_id) if str(target.thread_id).isdigit() else target.thread_id + for chunk in chunk_text(message.content, max_chars=self.max_message_chars): + await client.send_message(**kwargs, text=chunk) + + def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None: + data = payload.get("message") or payload.get("edited_message") + if not isinstance(data, dict): + return None + + chat = data.get("chat") if isinstance(data.get("chat"), dict) else {} + sender = data.get("from") if isinstance(data.get("from"), dict) else {} + peer_id = _string_or_none(chat.get("id")) + if not peer_id: + return None + + chat_type = str(chat.get("type") or "unknown") + peer_type = self._peer_type(chat_type) + user_id = _string_or_none(sender.get("id")) + message_id = _string_or_none(data.get("message_id")) + thread_id = _string_or_none(data.get("message_thread_id")) + + content = str(data.get("text") or data.get("caption") or "").strip() + media_entries = self._media_entries(data) + if media_entries: + content = "\n".join([part for part in [content, *media_entries] if part]).strip() + if not content: + return None + + if peer_type in {"group", "channel"} and not self._group_allowed(peer_id, user_id): + return None + if peer_type == "dm" and not self._dm_allowed(user_id or peer_id): + return None + + if peer_type in {"group", "channel"} and config_bool(self.config, "requireMentionInGroups", default=False): + gated = self._strip_required_mention(content) + if gated is None: + return None + content = gated + + metadata = { + "chat_id": peer_id, + "message_id": message_id, + "chat_type": chat_type, + } + if media_entries: + metadata["media"] = media_entries + + return build_inbound_message( + channel_id=self.channel_id, + kind=self.kind, + account_id=self.account_id, + peer_id=peer_id, + thread_id=thread_id, + peer_type=peer_type, + user_id=user_id, + message_id=message_id, + content=content, + metadata=metadata, + ) + + def _media_entries(self, data: dict[str, Any]) -> list[str]: + entries: list[str] = [] + if data.get("photo"): + entries.append(compact_media_summary("photo")) + for media_type in ("document", "audio", "video"): + value = data.get(media_type) + if isinstance(value, dict): + entries.append(compact_media_summary(media_type, file_name=_string_or_none(value.get("file_name")))) + return entries + + def _strip_required_mention(self, content: str) -> str | None: + username = str(self.config.get("botUsername") or "").strip().lstrip("@") + if not username: + return None + mention = f"@{username}" + if mention not in content: + return None + return content.replace(mention, "", 1).strip() + + def _dm_allowed(self, identifier: str | None) -> bool: + allowlist = config_list(self.config, "allowFrom") + if not allowlist: + return True + return bool(identifier and identifier in allowlist) + + def _group_allowed(self, peer_id: str | None, user_id: str | None) -> bool: + allowlist = config_list(self.config, "groupAllowFrom") + if not allowlist: + return True + return bool((peer_id and peer_id in allowlist) or (user_id and user_id in allowlist)) + + def _peer_type(self, chat_type: str) -> str: + if chat_type == "private": + return "dm" + if chat_type in {"group", "supergroup"}: + return "group" + if chat_type == "channel": + return "channel" + return chat_type or "unknown" + + def _require_client(self) -> Any: + if self._client is None: + self._client = self._build_bot() + return self._client + + def _build_bot(self) -> Any: + token = self._require_secret("botToken") + try: + from telegram import Bot + except ImportError as exc: # pragma: no cover - optional live dependency + raise RuntimeError("Install beaver-backend[telegram] to enable TelegramAdapter") from exc + return Bot(token=token) + + def _build_application(self) -> Any: + if self._application_factory is not None: + return self._application_factory() + token = self._require_secret("botToken") + try: + from telegram.ext import Application + except ImportError as exc: # pragma: no cover - optional live dependency + raise RuntimeError("Install beaver-backend[telegram] to enable TelegramAdapter") from exc + + async def handle(update: Any, context: Any) -> None: + if hasattr(update, "to_dict"): + await self.handle_update_payload(update.to_dict()) + + application = Application.builder().token(token).build() + try: + from telegram.ext import MessageHandler, filters + + application.add_handler(MessageHandler(filters.ALL, handle)) + except Exception: + pass + return application + + def _require_secret(self, key: str) -> str: + value = self.secrets.get(key) + if not value: + raise ValueError(f"{key} is required") + return str(value) + + +def _string_or_none(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None diff --git a/app-instance/backend/beaver/interfaces/channels/platforms/weixin.py b/app-instance/backend/beaver/interfaces/channels/platforms/weixin.py new file mode 100644 index 0000000..e52f089 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/platforms/weixin.py @@ -0,0 +1,180 @@ +"""Weixin channel adapter.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from beaver.foundation.events import InboundMessage, OutboundMessage +from beaver.interfaces.channels.base import ChannelInboundSink + +from .base import ( + allowed_by_policy, + build_inbound_message, + chunk_text, + compact_media_summary, + config_list, + mark_unclaimed, + outbound_target, +) + +EventRecorder = Callable[..., None] + + +class WeixinAdapter: + """Tencent iLink-style Weixin adapter with injectable client support.""" + + KIND = "weixin" + + def __init__( + self, + *, + channel_id: str, + kind: str, + mode: str, + account_id: str, + display_name: str | None, + inbound_sink: ChannelInboundSink, + secrets: dict[str, Any] | None = None, + config: dict[str, Any] | None = None, + event_recorder: EventRecorder | None = None, + client: Any | None = None, + ) -> None: + self.channel_id = channel_id + self.kind = kind + self.mode = mode + self.account_id = account_id + self.display_name = display_name + self.inbound_sink = inbound_sink + self.secrets = secrets or {} + self.config = config or {} + self.event_recorder = event_recorder + self._client = client + self.max_message_chars = int(self.config.get("maxMessageChars") or 2000) + + async def start(self) -> None: + if self._client is not None: + return + if self.mode != "polling": + raise ValueError(f"Unsupported weixin mode: {self.mode}") + self._client = self._build_client() + + async def stop(self) -> None: + close = getattr(self._client, "close", None) + if close is not None: + result = close() + if hasattr(result, "__await__"): + await result + + async def handle_message_payload(self, payload: dict[str, Any]) -> None: + message = self._normalize_payload(payload) + if message is None: + return + await self.inbound_sink.accept_inbound(message) + + async def send(self, message: OutboundMessage) -> None: + target = outbound_target(message) + if not target.peer_id: + mark_unclaimed(message) + return + client = self._require_client() + context_token = self._context_token(message) + for chunk in chunk_text(message.content, max_chars=self.max_message_chars): + await client.send_text(peer_id=target.peer_id, text=chunk, context_token=context_token) + + def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None: + sender_id = _string_or_none(payload.get("from") or payload.get("from_user")) + room_id = _string_or_none(payload.get("room_id") or payload.get("roomId")) + message_id = _string_or_none(payload.get("id") or payload.get("message_id")) + message_type = str(payload.get("type") or payload.get("message_type") or "text") + + if room_id: + peer_id = room_id + peer_type = "group" + user_id = sender_id + if not allowed_by_policy( + policy=self.config.get("groupPolicy"), + identifier=peer_id, + allowlist=config_list(self.config, "groupAllowFrom"), + default="disabled", + ): + return None + else: + peer_id = sender_id + peer_type = "dm" + user_id = sender_id + if not allowed_by_policy( + policy=self.config.get("dmPolicy"), + identifier=peer_id, + allowlist=config_list(self.config, "allowFrom"), + default="open", + ): + return None + if not peer_id: + return None + + content = self._content(message_type, payload) + if not content: + return None + + metadata = { + "message_id": message_id, + "message_type": message_type, + } + context_token = _string_or_none(payload.get("context_token") or payload.get("contextToken")) + if context_token: + metadata["context_token"] = context_token + if room_id: + metadata["room_id"] = room_id + + return build_inbound_message( + channel_id=self.channel_id, + kind=self.kind, + account_id=self.account_id, + peer_id=peer_id, + peer_type=peer_type, + user_id=user_id, + message_id=message_id, + content=content, + metadata=metadata, + ) + + def _content(self, message_type: str, payload: dict[str, Any]) -> str: + if message_type == "text": + return str(payload.get("text") or payload.get("content") or "").strip() + file_name = _string_or_none(payload.get("file_name") or payload.get("filename")) + return compact_media_summary(message_type, file_name=file_name) + + def _context_token(self, message: OutboundMessage) -> str | None: + inbound_metadata = message.metadata.get("inbound_metadata") + if isinstance(inbound_metadata, dict): + value = _string_or_none(inbound_metadata.get("context_token")) + if value: + return value + return _string_or_none(message.metadata.get("context_token")) + + def _require_client(self) -> Any: + if self._client is None: + self._client = self._build_client() + return self._client + + def _build_client(self) -> Any: + self._require_secret("token") + try: + import aiohttp # noqa: F401 + except ImportError as exc: # pragma: no cover - optional live dependency + raise RuntimeError("Install beaver-backend[weixin] to enable WeixinAdapter") from exc + raise RuntimeError("Weixin live client is not configured for direct construction") + + def _require_secret(self, key: str) -> str: + value = self.secrets.get(key) + if not value: + raise ValueError(f"{key} is required") + return str(value) + + +def _string_or_none(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None diff --git a/app-instance/backend/beaver/interfaces/channels/runtime.py b/app-instance/backend/beaver/interfaces/channels/runtime.py new file mode 100644 index 0000000..f55e910 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/runtime.py @@ -0,0 +1,526 @@ +"""Channel runtime host for adapter lifecycle and bus-first routing.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from beaver.foundation.config.schema import ChannelConfig +from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage +from beaver.interfaces.channels.base import ChannelAdapter +from beaver.interfaces.channels.manager import ChannelManager +from beaver.interfaces.channels.state import ChannelDedupeStore, ChannelEventLog +from beaver.services.agent_service import AgentService + + +def _iso_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _channel_capabilities(kind: str, mode: str) -> list[str]: + if kind == "webhook": + return ["receive_text", "send_text", "sync_webhook_response"] + if kind == "terminal" and mode == "websocket": + return ["receive_text", "send_text", "persistent_connection"] + if kind in {"feishu", "qqbot", "telegram"}: + return ["receive_text", "send_text", "receive_media", "groups"] + if kind == "weixin": + return ["receive_text", "send_text", "receive_media", "direct_messages"] + return [] + + +@dataclass(slots=True) +class ChannelAcceptResult: + accepted: bool + duplicate: bool = False + pending: bool = False + rejected: bool = False + session_id: str | None = None + dedupe_key: str | None = None + record: dict[str, Any] | None = None + error: str | None = None + + +class ChannelRuntime: + """Own channel adapters, state, and the inbound/outbound bus bridge.""" + + def __init__( + self, + *, + service: AgentService, + workspace: Path, + channels: dict[str, ChannelConfig], + bus: MessageBus | None = None, + ) -> None: + self.service = service + self.workspace = Path(workspace) + self.bus = bus or MessageBus() + self.manager = ChannelManager(self.bus) + self.channel_configs = dict(channels) + self.adapters: dict[str, ChannelAdapter] = {} + self.states: dict[str, dict[str, Any]] = {} + state_dir = self.workspace / "state" / "channels" + retention = self._default_dedupe_retention_hours() + self.dedupe = ChannelDedupeStore(state_dir / "dedupe.json", retention_hours=retention) + self.events = ChannelEventLog(state_dir / "events.jsonl") + self._bridge_task: asyncio.Task[None] | None = None + self._dispatch_task: asyncio.Task[None] | None = None + self._stop_event = asyncio.Event() + self._dispatch_stop_event = asyncio.Event() + self._lifecycle_lock = asyncio.Lock() + + async def start(self) -> None: + self._stop_event.clear() + self._dispatch_stop_event.clear() + for channel_id, cfg in self.channel_configs.items(): + if not cfg.enabled: + self.states[channel_id] = {"state": "disabled", "last_error": None} + continue + try: + adapter = self._build_adapter(channel_id, cfg) + self.adapters[channel_id] = adapter + self.manager.register(adapter) + await adapter.start() + self.states[channel_id] = { + "state": "running", + "last_error": None, + "started_at": _iso_now(), + } + self.events.record(channel_id=channel_id, kind="adapter_started") + except Exception as exc: # pragma: no cover - defensive startup isolation + self.states[channel_id] = {"state": "error", "last_error": str(exc)} + self.events.record( + channel_id=channel_id, + kind="adapter_error", + status="error", + error=str(exc), + ) + self._bridge_task = asyncio.create_task(self._bridge_inbound_to_agent()) + self._dispatch_task = asyncio.create_task( + self.manager.dispatch_outbound( + self._dispatch_stop_event, + on_delivered=self._record_outbound_delivered, + on_failed=self._record_outbound_failed, + ) + ) + + async def stop(self) -> None: + self._stop_event.set() + if self._bridge_task is not None: + self._bridge_task.cancel() + try: + await self._bridge_task + except asyncio.CancelledError: + pass + self._dispatch_stop_event.set() + if self._dispatch_task is not None: + try: + await asyncio.wait_for(self._dispatch_task, timeout=1.0) + except asyncio.TimeoutError: + self._dispatch_task.cancel() + try: + await self._dispatch_task + except asyncio.CancelledError: + pass + await self.manager.stop() + for channel_id in self.adapters: + self.events.record(channel_id=channel_id, kind="adapter_stopped") + + async def add_channel(self, channel_id: str, config: ChannelConfig) -> None: + async with self._lifecycle_lock: + current = self.channel_configs.get(channel_id) + if current == config and channel_id in self.adapters: + return + if not config.enabled: + await self._remove_channel_locked(channel_id) + self.channel_configs[channel_id] = config + self.states[channel_id] = {"state": "disabled", "last_error": None} + return + + adapter = self._build_adapter(channel_id, config) + await adapter.start() + old_adapter = self.adapters.get(channel_id) + self.manager.replace_registered(adapter) + self.adapters[channel_id] = adapter + self.channel_configs[channel_id] = config + self.states[channel_id] = {"state": "running", "last_error": None, "started_at": _iso_now()} + self.events.record(channel_id=channel_id, kind="adapter_started") + if old_adapter is not None and old_adapter is not adapter: + await old_adapter.stop() + + async def remove_channel(self, channel_id: str) -> None: + async with self._lifecycle_lock: + await self._remove_channel_locked(channel_id) + + async def _remove_channel_locked(self, channel_id: str) -> None: + adapter = self.adapters.pop(channel_id, None) + self.manager.unregister(channel_id) + self.channel_configs.pop(channel_id, None) + if adapter is not None: + await adapter.stop() + self.events.record(channel_id=channel_id, kind="adapter_stopped") + self.states[channel_id] = {"state": "removed", "last_error": None} + + async def accept_inbound(self, message: InboundMessage) -> ChannelAcceptResult: + identity = message.channel_identity + if identity is None: + self.events.record( + channel_id=message.channel, + kind="inbound_rejected", + status="error", + error="channel_identity is required", + ) + return ChannelAcceptResult( + accepted=False, + rejected=True, + error="channel_identity is required", + ) + + validation_error = identity.validation_error() + if validation_error: + self.events.record( + channel_id=identity.channel_id, + kind="inbound_rejected", + status="error", + error=validation_error, + ) + return ChannelAcceptResult(accepted=False, rejected=True, error=validation_error) + + expected_session_id = identity.session_id() + if message.session_id != expected_session_id: + self.events.record( + channel_id=identity.channel_id, + kind="session_id_normalized", + session_id=expected_session_id, + message_id=identity.message_id, + ) + message.session_id = expected_session_id + message.channel = identity.channel_id + + dedupe_key = identity.dedupe_key() + if dedupe_key: + write = self.dedupe.mark_processing( + dedupe_key=dedupe_key, + session_id=expected_session_id, + message_id=identity.message_id or "", + ) + if not write.created: + record = write.record or {} + self.events.record( + channel_id=identity.channel_id, + kind="inbound_duplicate", + session_id=expected_session_id, + message_id=identity.message_id, + status=str(record.get("status") or "processing"), + ) + return ChannelAcceptResult( + accepted=False, + duplicate=True, + pending=record.get("status") == "processing", + session_id=expected_session_id, + dedupe_key=dedupe_key, + record=record, + ) + + self.events.record( + channel_id=identity.channel_id, + kind="inbound_accepted", + session_id=expected_session_id, + message_id=identity.message_id, + text=message.content, + ) + await self.bus.publish_inbound(message) + return ChannelAcceptResult( + accepted=True, + session_id=expected_session_id, + dedupe_key=dedupe_key, + ) + + def statuses(self) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + recent = self.events.recent(limit=500) + last_by_channel = {event["channel_id"]: event for event in recent if event.get("channel_id")} + for channel_id, cfg in self.channel_configs.items(): + state = self.states.get(channel_id, {"state": "configured", "last_error": None}) + capabilities = _channel_capabilities(cfg.kind, cfg.mode) + webhook_url = None + websocket_url = None + connected_peers = 0 + if cfg.kind == "webhook": + webhook_url = f"/api/channels/{channel_id}/webhook" + elif cfg.kind == "terminal" and cfg.mode == "websocket": + websocket_url = f"/api/channels/{channel_id}/ws" + adapter = self.adapters.get(channel_id) + if adapter is not None and hasattr(adapter, "status_extra"): + extra = adapter.status_extra() # type: ignore[attr-defined] + connected_peers = int(extra.get("connected_peers") or 0) + items.append( + { + "channel_id": channel_id, + "name": channel_id, + "kind": cfg.kind, + "mode": cfg.mode, + "display_name": cfg.display_name or channel_id, + "enabled": cfg.enabled, + "state": state.get("state", "configured"), + "account_id": cfg.account_id, + "last_error": state.get("last_error"), + "started_at": state.get("started_at"), + "last_event_at": last_by_channel.get(channel_id, {}).get("created_at"), + "capabilities": capabilities, + "webhook_url": webhook_url, + "websocket_url": websocket_url, + "connected_peers": connected_peers, + } + ) + return items + + def recent_events(self, channel_id: str, *, limit: int = 100) -> list[dict[str, Any]]: + return self.events.recent(channel_id=channel_id, limit=limit) + + def record_event( + self, + *, + channel_id: str, + kind: str, + session_id: str | None = None, + message_id: str | None = None, + run_id: str | None = None, + status: str = "ok", + error: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> None: + self.events.record( + channel_id=channel_id, + kind=kind, + session_id=session_id, + message_id=message_id, + run_id=run_id, + status=status, + error=error, + metadata=metadata, + ) + + def _build_adapter(self, channel_id: str, cfg: ChannelConfig) -> ChannelAdapter: + if cfg.kind == "webhook" and cfg.mode == "webhook": + from beaver.interfaces.channels.generic_webhook import GenericWebhookAdapter + + return GenericWebhookAdapter( + channel_id=channel_id, + kind=cfg.kind, + mode=cfg.mode, + account_id=cfg.account_id, + display_name=cfg.display_name, + inbound_sink=self, + response_timeout_seconds=float(cfg.config.get("response_timeout_seconds") or 1800), + ) + + if cfg.kind == "terminal" and cfg.mode == "websocket": + from beaver.interfaces.channels.terminal_websocket import TerminalWebSocketAdapter + + return TerminalWebSocketAdapter( + channel_id=channel_id, + kind=cfg.kind, + mode=cfg.mode, + account_id=cfg.account_id, + display_name=cfg.display_name, + inbound_sink=self, + event_recorder=self.record_event, + heartbeat_seconds=float(cfg.config.get("heartbeat_seconds") or 30), + max_message_chars=int(cfg.config.get("max_message_chars") or 20000), + ) + + if cfg.kind == "telegram" and cfg.mode in {"polling", "webhook"}: + from beaver.interfaces.channels.platforms.telegram import TelegramAdapter + + return TelegramAdapter( + channel_id=channel_id, + kind=cfg.kind, + mode=cfg.mode, + account_id=cfg.account_id, + display_name=cfg.display_name, + inbound_sink=self, + secrets=cfg.secrets, + config=cfg.config, + event_recorder=self.record_event, + ) + + if cfg.kind == "feishu" and cfg.mode in {"websocket", "webhook"}: + from beaver.interfaces.channels.platforms.feishu import FeishuAdapter + + return FeishuAdapter( + channel_id=channel_id, + kind=cfg.kind, + mode=cfg.mode, + account_id=cfg.account_id, + display_name=cfg.display_name, + inbound_sink=self, + secrets=cfg.secrets, + config=cfg.config, + event_recorder=self.record_event, + ) + + if cfg.kind == "qqbot" and cfg.mode == "websocket": + from beaver.interfaces.channels.platforms.qqbot import QQBotAdapter + + return QQBotAdapter( + channel_id=channel_id, + kind=cfg.kind, + mode=cfg.mode, + account_id=cfg.account_id, + display_name=cfg.display_name, + inbound_sink=self, + secrets=cfg.secrets, + config=cfg.config, + event_recorder=self.record_event, + ) + + if cfg.kind == "weixin" and cfg.mode == "polling": + from beaver.interfaces.channels.platforms.weixin import WeixinAdapter + + return WeixinAdapter( + channel_id=channel_id, + kind=cfg.kind, + mode=cfg.mode, + account_id=cfg.account_id, + display_name=cfg.display_name, + inbound_sink=self, + secrets=cfg.secrets, + config=cfg.config, + event_recorder=self.record_event, + ) + + if cfg.kind == "external_connector" and cfg.mode == "http": + import os + + from beaver.interfaces.channels.connections.sidecar_client import ConnectorSidecarClient + from beaver.interfaces.channels.external_connector import ExternalConnectorChannel + + base_url = str(cfg.config.get("sidecarBaseUrl") or os.getenv("EXTERNAL_CONNECTOR_BASE_URL") or "").strip() + token = os.getenv("EXTERNAL_CONNECTOR_TOKEN", "") + platform_kind = str(cfg.config.get("platformKind") or "").strip() + connection_id = str(cfg.config.get("connectionId") or "").strip() + if not base_url: + raise ValueError("external connector sidecarBaseUrl is required") + if not platform_kind: + raise ValueError("external connector platformKind is required") + if not connection_id: + raise ValueError("external connector connectionId is required") + return ExternalConnectorChannel( + channel_id=channel_id, + platform_kind=platform_kind, + connection_id=connection_id, + account_id=cfg.account_id, + display_name=cfg.display_name, + sidecar_client=ConnectorSidecarClient(base_url=base_url, token=token), + ) + + raise ValueError(f"Unsupported channel kind/mode: {cfg.kind}/{cfg.mode}") + + async def _bridge_inbound_to_agent(self) -> None: + current_inbound: InboundMessage | None = None + while not self._stop_event.is_set(): + try: + current_inbound = await asyncio.wait_for(self.bus.consume_inbound(), timeout=0.25) + except asyncio.TimeoutError: + continue + except asyncio.CancelledError: + raise + inbound = current_inbound + identity = inbound.channel_identity + try: + self.events.record( + channel_id=inbound.channel, + kind="direct_run_started", + session_id=inbound.session_id, + message_id=identity.message_id if identity else inbound.message_id, + ) + outbound = await self.service.handle_inbound_message(inbound) + except asyncio.CancelledError: + outbound = AgentService.build_outbound_error( + inbound, + detail="Channel runtime stopped before completing the inbound message", + finish_reason="cancelled", + ) + self._mark_dedupe_result(inbound, outbound) + await self.bus.publish_outbound(outbound) + current_inbound = None + raise + except Exception as exc: + self.events.record( + channel_id=inbound.channel, + kind="direct_run_failed", + session_id=inbound.session_id, + message_id=identity.message_id if identity else inbound.message_id, + status="error", + error=str(exc), + ) + outbound = AgentService.build_outbound_error( + inbound, + detail=str(exc), + finish_reason="error", + ) + else: + self.events.record( + channel_id=outbound.channel, + kind="direct_run_finished", + session_id=outbound.session_id, + message_id=identity.message_id if identity else inbound.message_id, + run_id=outbound.run_id, + ) + self._mark_dedupe_result(inbound, outbound) + await self.bus.publish_outbound(outbound) + current_inbound = None + + def _mark_dedupe_result(self, inbound: InboundMessage, outbound: OutboundMessage) -> None: + identity = inbound.channel_identity + dedupe_key = identity.dedupe_key() if identity else None + if not dedupe_key: + return + cfg = self.channel_configs.get(identity.channel_id) + max_reply_chars = int((cfg.config if cfg else {}).get("max_cached_reply_chars") or 20000) + max_error_chars = int((cfg.config if cfg else {}).get("max_cached_error_chars") or 4000) + if outbound.finish_reason == "error": + self.dedupe.mark_error( + dedupe_key=dedupe_key, + error=outbound.content, + max_error_chars=max_error_chars, + ) + else: + self.dedupe.mark_done( + dedupe_key=dedupe_key, + run_id=outbound.run_id, + reply=outbound.content, + max_reply_chars=max_reply_chars, + ) + + async def _record_outbound_delivered(self, message: OutboundMessage) -> None: + kind = "outbound_unclaimed" if message.metadata.get("delivery_status") == "unclaimed" else "outbound_delivered" + self.events.record( + channel_id=message.channel, + kind=kind, + session_id=message.session_id, + message_id=message.channel_identity.message_id if message.channel_identity else message.message_id, + run_id=message.run_id, + ) + + async def _record_outbound_failed(self, message: OutboundMessage, exc: Exception | None) -> None: + self.events.record( + channel_id=message.channel, + kind="outbound_delivery_failed", + session_id=message.session_id, + message_id=message.channel_identity.message_id if message.channel_identity else message.message_id, + run_id=message.run_id, + status="error", + error=str(exc) if exc else "channel not registered", + ) + + def _default_dedupe_retention_hours(self) -> int: + for cfg in self.channel_configs.values(): + value = cfg.config.get("dedupe_retention_hours") + if value is not None: + return int(value) + return 48 diff --git a/app-instance/backend/beaver/interfaces/channels/state.py b/app-instance/backend/beaver/interfaces/channels/state.py new file mode 100644 index 0000000..1c2b7d9 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/state.py @@ -0,0 +1,198 @@ +"""Persistent channel runtime state.""" + +from __future__ import annotations + +import json +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from threading import Lock +from typing import Any +from uuid import uuid4 + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _iso_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +@dataclass(slots=True) +class DedupeWriteResult: + created: bool + record: dict[str, Any] | None = None + + +class ChannelDedupeStore: + def __init__(self, path: Path, *, retention_hours: int = 48) -> None: + self.path = path + self.retention_ms = max(1, int(retention_hours)) * 60 * 60 * 1000 + self._lock = Lock() + + def get(self, dedupe_key: str) -> dict[str, Any] | None: + with self._lock: + data = self._load() + self._prune_unlocked(data, _now_ms()) + record = data["records"].get(dedupe_key) + self._save(data) + return record + + def mark_processing(self, *, dedupe_key: str, session_id: str, message_id: str) -> DedupeWriteResult: + with self._lock: + data = self._load() + now_ms = _now_ms() + self._prune_unlocked(data, now_ms) + existing = data["records"].get(dedupe_key) + if existing is not None: + self._save(data) + return DedupeWriteResult(created=False, record=existing) + + record = { + "dedupe_key": dedupe_key, + "status": "processing", + "session_id": session_id, + "message_id": message_id, + "run_id": None, + "reply": None, + "error": None, + "created_at_ms": now_ms, + "updated_at_ms": now_ms, + } + data["records"][dedupe_key] = record + self._save(data) + return DedupeWriteResult(created=True, record=record) + + def mark_done( + self, + *, + dedupe_key: str, + run_id: str | None, + reply: str, + max_reply_chars: int, + ) -> None: + self._mark_result( + dedupe_key=dedupe_key, + status="done", + run_id=run_id, + reply=reply[: max(0, int(max_reply_chars))], + error=None, + ) + + def mark_error(self, *, dedupe_key: str, error: str, max_error_chars: int) -> None: + self._mark_result( + dedupe_key=dedupe_key, + status="error", + run_id=None, + reply=None, + error=error[: max(0, int(max_error_chars))], + ) + + def _mark_result( + self, + *, + dedupe_key: str, + status: str, + run_id: str | None, + reply: str | None, + error: str | None, + ) -> None: + with self._lock: + data = self._load() + record = data["records"].get(dedupe_key) + if record is None: + record = {"dedupe_key": dedupe_key, "created_at_ms": _now_ms()} + data["records"][dedupe_key] = record + record.update( + { + "status": status, + "run_id": run_id, + "reply": reply, + "error": error, + "updated_at_ms": _now_ms(), + } + ) + self._save(data) + + def _load(self) -> dict[str, Any]: + if not self.path.exists(): + return {"records": {}} + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"records": {}} + if not isinstance(data, dict) or not isinstance(data.get("records"), dict): + return {"records": {}} + return data + + def _save(self, data: dict[str, Any]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = self.path.with_name(f"{self.path.name}.tmp") + tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + tmp_path.replace(self.path) + + def _prune_unlocked(self, data: dict[str, Any], now_ms: int) -> None: + records = data.get("records", {}) + expired_before = now_ms - self.retention_ms + for key, record in list(records.items()): + updated_at_ms = int(record.get("updated_at_ms") or record.get("created_at_ms") or 0) + if updated_at_ms < expired_before: + records.pop(key, None) + + +class ChannelEventLog: + def __init__(self, path: Path) -> None: + self.path = path + self._lock = Lock() + + def record( + self, + *, + channel_id: str, + kind: str, + session_id: str | None = None, + message_id: str | None = None, + run_id: str | None = None, + status: str = "ok", + error: str | None = None, + text: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> dict[str, Any]: + entry = { + "event_id": uuid4().hex, + "channel_id": channel_id, + "kind": kind, + "session_id": session_id, + "message_id": message_id, + "run_id": run_id, + "status": status, + "error": error, + "text_preview": (text or "")[:120] if text else None, + "text_length": len(text) if text else 0, + "metadata": metadata or {}, + "created_at": _iso_now(), + } + with self._lock: + self.path.parent.mkdir(parents=True, exist_ok=True) + with self.path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(entry, ensure_ascii=False) + "\n") + return entry + + def recent(self, *, channel_id: str | None = None, limit: int = 100) -> list[dict[str, Any]]: + if not self.path.exists(): + return [] + lines = self.path.read_text(encoding="utf-8").splitlines() + items: list[dict[str, Any]] = [] + for line in reversed(lines): + try: + item = json.loads(line) + except json.JSONDecodeError: + continue + if channel_id and item.get("channel_id") != channel_id: + continue + items.append(item) + if len(items) >= max(1, int(limit)): + break + return list(reversed(items)) diff --git a/app-instance/backend/beaver/interfaces/channels/terminal_websocket.py b/app-instance/backend/beaver/interfaces/channels/terminal_websocket.py new file mode 100644 index 0000000..e13a846 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/terminal_websocket.py @@ -0,0 +1,301 @@ +"""Text-only terminal WebSocket channel adapter.""" + +from __future__ import annotations + +from collections.abc import Callable +from contextlib import suppress +from dataclasses import dataclass, field +from typing import Any + +from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage +from beaver.interfaces.channels.base import ChannelInboundSink + +try: + from fastapi import WebSocket + from starlette.websockets import WebSocketDisconnect +except ModuleNotFoundError: # pragma: no cover - import-only fallback + class WebSocketDisconnect(Exception): + """Fallback disconnect exception for skeleton import environments.""" + + class WebSocket: # type: ignore[override] + """Fallback websocket annotation shim.""" + + +def _clean(value: Any) -> str: + return str(value or "").strip() + + +@dataclass(slots=True) +class TerminalConnection: + websocket: WebSocket + peer_id: str + session_id: str + thread_id: str | None = None + user_id: str | None = None + device_name: str = "" + capabilities: list[str] = field(default_factory=list) + + +class TerminalWebSocketAdapter: + """Accept text terminal websocket frames and deliver final assistant replies.""" + + def __init__( + self, + *, + channel_id: str, + kind: str, + mode: str, + account_id: str, + display_name: str = "", + inbound_sink: ChannelInboundSink, + event_recorder: Callable[..., None] | None = None, + heartbeat_seconds: float = 30, + max_message_chars: int = 20000, + ) -> None: + self.channel_id = channel_id + self.kind = kind + self.mode = mode + self.account_id = account_id + self.display_name = display_name or channel_id + self.inbound_sink = inbound_sink + self.event_recorder = event_recorder + self.heartbeat_seconds = max(1.0, float(heartbeat_seconds)) + self.max_message_chars = max(1, int(max_message_chars)) + self.started = False + self._connections_by_session: dict[str, TerminalConnection] = {} + self._session_by_peer: dict[str, str] = {} + + async def start(self) -> None: + self.started = True + + async def stop(self) -> None: + self.started = False + for connection in list(self._connections_by_session.values()): + with suppress(Exception): + await connection.websocket.close(code=1001) + self._connections_by_session.clear() + self._session_by_peer.clear() + + def status_extra(self) -> dict[str, Any]: + return {"connected_peers": len(self._connections_by_session)} + + async def handle_websocket(self, websocket: WebSocket) -> None: + await websocket.accept() + connection: TerminalConnection | None = None + try: + while True: + try: + payload = await websocket.receive_json() + except WebSocketDisconnect: + break + except ValueError: + await websocket.send_json({"type": "error", "error": "Invalid websocket JSON payload"}) + continue + if not isinstance(payload, dict): + await websocket.send_json({"type": "error", "error": "Websocket payload must be a JSON object"}) + continue + + frame_type = _clean(payload.get("type")).lower() + if frame_type == "ping": + await websocket.send_json({"type": "pong"}) + continue + if frame_type == "connect": + connection = await self._handle_connect(websocket, payload, current=connection) + continue + if frame_type == "message": + if connection is None: + await websocket.send_json({"type": "error", "error": "connect is required before message"}) + continue + await self._handle_message(websocket, connection, payload) + continue + + await websocket.send_json( + { + "type": "error", + "error": f"Unsupported websocket frame type: {frame_type or ''}", + } + ) + finally: + if connection is not None: + self._remove_connection(connection) + self._record( + kind="terminal_disconnected", + session_id=connection.session_id, + metadata={"peer_id": connection.peer_id, "device_name": connection.device_name}, + ) + + async def _handle_connect( + self, + websocket: WebSocket, + payload: dict[str, Any], + *, + current: TerminalConnection | None, + ) -> TerminalConnection | None: + peer_id = _clean(payload.get("peer_id")) + if not peer_id: + await websocket.send_json({"type": "error", "error": "peer_id is required"}) + return current + + thread_id = _clean(payload.get("thread_id")) or None + user_id = _clean(payload.get("user_id")) or None + device_name = _clean(payload.get("device_name")) + capabilities = [str(item) for item in payload.get("capabilities") or [] if item is not None] + identity = ChannelIdentity( + channel_id=self.channel_id, + kind=self.kind, + account_id=self.account_id, + peer_id=peer_id, + thread_id=thread_id, + peer_type="terminal", + user_id=user_id, + ) + session_id = identity.session_id() + connection = TerminalConnection( + websocket=websocket, + peer_id=peer_id, + session_id=session_id, + thread_id=thread_id, + user_id=user_id, + device_name=device_name, + capabilities=capabilities, + ) + + if current is not None and current.session_id != session_id: + self._remove_connection(current) + old = self._connections_by_session.get(session_id) + if old is not None and old.websocket is not websocket: + with suppress(Exception): + await old.websocket.close(code=1000) + self._connections_by_session[session_id] = connection + self._session_by_peer[peer_id] = session_id + self._record( + kind="terminal_connected", + session_id=session_id, + metadata={"peer_id": peer_id, "device_name": device_name, "capabilities": capabilities}, + ) + await websocket.send_json( + { + "type": "connected", + "channel_id": self.channel_id, + "session_id": session_id, + } + ) + return connection + + async def _handle_message( + self, + websocket: WebSocket, + connection: TerminalConnection, + payload: dict[str, Any], + ) -> None: + message_id = _clean(payload.get("message_id")) + text = _clean(payload.get("text")) + if not message_id: + await websocket.send_json({"type": "error", "error": "message_id is required"}) + return + if not text: + await websocket.send_json({"type": "error", "error": "text is required"}) + return + if len(text) > self.max_message_chars: + await websocket.send_json( + { + "type": "error", + "error": f"text exceeds max_message_chars ({self.max_message_chars})", + } + ) + return + + thread_id = _clean(payload.get("thread_id")) or connection.thread_id + user_id = _clean(payload.get("user_id")) or connection.user_id + identity = ChannelIdentity( + channel_id=self.channel_id, + kind=self.kind, + account_id=self.account_id, + peer_id=connection.peer_id, + thread_id=thread_id, + peer_type="terminal", + user_id=user_id, + message_id=message_id, + ) + inbound = InboundMessage( + channel=self.channel_id, + content=text, + content_type="text", + user_id=user_id, + channel_identity=identity, + metadata={ + "terminal": { + "peer_id": connection.peer_id, + "device_name": connection.device_name, + "capabilities": connection.capabilities, + } + }, + ) + accept = await self.inbound_sink.accept_inbound(inbound) + ack: dict[str, Any] = { + "type": "ack", + "message_id": message_id, + "session_id": accept.session_id or identity.session_id(), + "accepted": accept.accepted, + } + if accept.duplicate: + ack["duplicate"] = True + ack["pending"] = accept.pending + record = accept.record or {} + if record.get("reply"): + ack["reply"] = record["reply"] + if accept.error or record.get("error"): + ack["error"] = accept.error or record.get("error") + await websocket.send_json(ack) + + async def send(self, message: OutboundMessage) -> None: + session_id = message.session_id + if not session_id and message.channel_identity is not None: + session_id = message.channel_identity.session_id() + connection = self._connections_by_session.get(session_id or "") + if connection is None: + message.metadata["delivery_status"] = "unclaimed" + return + + payload = { + "type": "message", + "role": "assistant", + "message_id": message.channel_identity.message_id if message.channel_identity else message.message_id, + "run_id": message.run_id, + "text": message.content, + "finish_reason": message.finish_reason, + } + try: + await connection.websocket.send_json(payload) + except Exception: + message.metadata["delivery_status"] = "unclaimed" + self._remove_connection(connection) + + def _remove_connection(self, connection: TerminalConnection) -> None: + current = self._connections_by_session.get(connection.session_id) + if current is connection: + self._connections_by_session.pop(connection.session_id, None) + if self._session_by_peer.get(connection.peer_id) == connection.session_id: + self._session_by_peer.pop(connection.peer_id, None) + + def _record( + self, + *, + kind: str, + session_id: str | None = None, + message_id: str | None = None, + status: str = "ok", + error: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> None: + if self.event_recorder is None: + return + self.event_recorder( + channel_id=self.channel_id, + kind=kind, + session_id=session_id, + message_id=message_id, + status=status, + error=error, + metadata=metadata, + ) diff --git a/app-instance/backend/beaver/interfaces/web/app.py b/app-instance/backend/beaver/interfaces/web/app.py index ce8e188..84707c5 100644 --- a/app-instance/backend/beaver/interfaces/web/app.py +++ b/app-instance/backend/beaver/interfaces/web/app.py @@ -19,6 +19,18 @@ from typing import Any from beaver.engine.providers.registry import PROVIDERS, find_by_name from beaver.foundation.config import default_config_path, load_config +from beaver.foundation.events import ChannelIdentity, InboundMessage +from beaver.interfaces.channels.runtime import ChannelRuntime +from beaver.interfaces.channels.connections import ( + ChannelConnectionStore, + ChannelConnectorRegistry, + ConnectorSidecarClient, + CredentialStore, + FeishuConnector, + MessageDedupeStore, + TelegramConnector, + WeixinConnector, +) from beaver.foundation.models import CronExecutionResult, CronRunRecord from beaver.integrations.mcp import MCPConnectionManager from beaver.services.agent_service import NOTIFICATION_SESSION_ID, AgentService @@ -53,6 +65,16 @@ from .schemas import ( WebErrorResponse, WebAgentConfigRequest, WebAgentConfigResponse, + WebChannelConfigRequest, + WebChannelConfigResponse, + WebChannelConnectionCreateRequest, + WebChannelConnectionResponse, + WebChannelConnectionUpdateRequest, + WebChannelValidationResponse, + WebConnectorBridgeEventRequest, + WebConnectorBridgeEventResponse, + WebConnectorSessionCreateRequest, + WebConnectorSessionResponse, WebProviderConfigRequest, WebProviderConfigResponse, WebStatusResponse, @@ -60,7 +82,7 @@ from .schemas import ( try: from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect - from fastapi.responses import Response + from fastapi.responses import JSONResponse, Response except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments def File(default: Any = None) -> Any: # type: ignore[override] return default @@ -94,6 +116,11 @@ except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only env self.media_type = media_type self.headers = headers or {} + class JSONResponse(Response): # type: ignore[override] + def __init__(self, content: Any, status_code: int = 200) -> None: + super().__init__(json.dumps(content).encode("utf-8"), media_type="application/json") + self.status_code = status_code + class WebSocketDisconnect(Exception): """Fallback websocket disconnect exception.""" @@ -183,7 +210,9 @@ async def _app_lifespan( owns_service = manage_service_lifecycle if manage_service_lifecycle is not None else service is None app.state.agent_service = attached_service app.state.cron_service = _build_cron_service(attached_service) if owns_service else None + app.state.channel_runtime = None started = False + channel_runtime: ChannelRuntime | None = None if owns_service: try: await attached_service.start() @@ -200,6 +229,29 @@ async def _app_lifespan( else: attached_service.close() raise + try: + loaded = attached_service.create_loop().boot() + app.state.channel_connection_workspace = loaded.workspace + connector_registry = _build_channel_connector_registry(loaded.workspace) + app.state.channel_connector_registry = connector_registry + connection_channels = await connector_registry.materialize_channel_configs() + runtime_channels = dict(loaded.config.channels) + runtime_channels.update(connection_channels) + channel_runtime = ChannelRuntime( + service=attached_service, + workspace=loaded.workspace, + channels=runtime_channels, + ) + app.state.channel_runtime = channel_runtime + await channel_runtime.start() + except BaseException: + if owns_service and started: + with suppress(BaseException): + await attached_service.shutdown( + timeout_seconds=shutdown_timeout_seconds, + force=shutdown_force, + ) + raise worker: SkillLearningWorker | None = None worker_task = None worker_config = SkillLearningWorkerConfig.from_env() @@ -216,6 +268,10 @@ async def _app_lifespan( try: yield finally: + runtime = getattr(app.state, "channel_runtime", None) + if isinstance(runtime, ChannelRuntime): + with suppress(BaseException): + await runtime.stop() cron_service = getattr(app.state, "cron_service", None) if isinstance(cron_service, CronService): cron_service.stop() @@ -283,6 +339,118 @@ def get_cron_service(request: Request) -> CronService: return service +def get_channel_runtime(request: Request) -> ChannelRuntime: + runtime = getattr(request.app.state, "channel_runtime", None) + if not isinstance(runtime, ChannelRuntime): + raise HTTPException(status_code=503, detail="Channel runtime is not running") + return runtime + + +def _connection_state_dir(workspace: Path) -> Path: + return Path(workspace) / "state" / "channel_connections" + + +def _channel_connection_workspace(request: Request) -> Path: + workspace = getattr(request.app.state, "channel_connection_workspace", None) + if workspace is not None: + return Path(workspace) + return Path(get_agent_service(request).loader.workspace) + + +def _message_dedupe_store(workspace: Path) -> MessageDedupeStore: + return MessageDedupeStore(_connection_state_dir(workspace) / "message_dedupe.json") + + +def _bridge_token() -> str: + return os.getenv("BEAVER_BRIDGE_TOKEN", "") + + +def _build_channel_connector_registry(workspace: Path) -> ChannelConnectorRegistry: + state_dir = _connection_state_dir(workspace) + connection_store = ChannelConnectionStore(state_dir / "connections.json") + credential_store = CredentialStore(state_dir / "credentials.json") + registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store) + registry.register( + TelegramConnector( + connection_store=connection_store, + credential_store=credential_store, + ) + ) + sidecar_base_url = os.getenv("EXTERNAL_CONNECTOR_BASE_URL", "http://external-connector:8787") + sidecar_token = os.getenv("EXTERNAL_CONNECTOR_TOKEN", "") + sidecar_client = ConnectorSidecarClient(base_url=sidecar_base_url, token=sidecar_token) + registry.register( + WeixinConnector( + connection_store=connection_store, + credential_store=credential_store, + sidecar_client=sidecar_client, + sidecar_base_url=sidecar_base_url, + ) + ) + registry.register( + FeishuConnector( + connection_store=connection_store, + credential_store=credential_store, + sidecar_client=sidecar_client, + sidecar_base_url=sidecar_base_url, + ) + ) + return registry + + +def get_channel_connector_registry(request: Request) -> ChannelConnectorRegistry: + registry = getattr(request.app.state, "channel_connector_registry", None) + if isinstance(registry, ChannelConnectorRegistry): + return registry + workspace = getattr(request.app.state, "channel_connection_workspace", None) + if workspace is None: + raise RuntimeError("Channel connector registry unavailable before service boot") + registry = _build_channel_connector_registry(workspace) + request.app.state.channel_connector_registry = registry + return registry + + +def _connection_response_view(connection: Any) -> dict[str, Any]: + view = connection.to_dict() + view.pop("credentials_ref", None) + view.pop("connector_ref", None) + view.pop("pairing_session_id", None) + return view + + +def _normalize_connection_config(config: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(config, dict): + return {} + return { + _camel_to_snake_text(str(key)): value + for key, value in config.items() + if str(key).strip() + } + + +def _camel_to_snake_text(value: str) -> str: + result: list[str] = [] + for char in value.strip(): + if char.isupper() and result: + result.append("_") + result.append(char.lower()) + return "".join(result) + + +def _self_restart_enabled() -> bool: + return os.getenv("BEAVER_ENABLE_SELF_RESTART", "1").strip() not in {"0", "false", "False"} + + +def _schedule_self_restart(delay_seconds: float = 0.75) -> None: + import threading + + def _exit_later() -> None: + time.sleep(delay_seconds) + os._exit(0) + + threading.Thread(target=_exit_later, daemon=True).start() + + def create_app( *, workspace: str | Path | None = None, @@ -380,10 +548,330 @@ def create_app( "temperature": agent_service.profile.temperature, "max_tool_iterations": agent_service.profile.max_tool_iterations, "providers": providers_status, - "channels": [{"name": "web", "enabled": True}], + "channels": get_channel_runtime(request).statuses(), + "runtime_controls": {"self_restart": _self_restart_enabled()}, "cron": cron_service.status(), } + @app.get("/api/channels") + async def list_channels(request: Request) -> list[dict[str, Any]]: + return get_channel_runtime(request).statuses() + + @app.get("/api/channel-connectors") + async def list_channel_connectors(request: Request) -> list[dict[str, str]]: + return get_channel_connector_registry(request).connectors() + + @app.get("/api/channel-connections") + async def list_channel_connections(request: Request) -> list[dict[str, Any]]: + registry = get_channel_connector_registry(request) + return [_connection_response_view(connection) for connection in registry.connection_store.list()] + + @app.post("/api/channel-connections", response_model=WebChannelConnectionResponse) + async def create_channel_connection( + request: Request, + payload: WebChannelConnectionCreateRequest, + ) -> WebChannelConnectionResponse: + registry = get_channel_connector_registry(request) + kind = _clean_text(payload.kind) + mode = _clean_text(payload.mode) + if not kind: + raise HTTPException(status_code=400, detail="Connection kind is required") + if not mode: + raise HTTPException(status_code=400, detail="Connection mode is required") + secrets_payload = payload.secrets or {} + secrets = {key: value for key, value in secrets_payload.items() if value} + credentials_ref = registry.credential_store.put(kind=kind, values=secrets) if secrets else None + connection = registry.connection_store.create( + kind=kind, + mode=mode, + display_name=_clean_text(payload.display_name) or kind, + account_id=_clean_text(payload.account_id) or "", + owner_user_id=_clean_text(payload.owner_user_id) or None, + auth_type=_clean_text(payload.auth_type) or "token", + credentials_ref=credentials_ref, + runtime_config=_normalize_connection_config(payload.config), + ) + return WebChannelConnectionResponse( + connection=_connection_response_view(connection), + credentials=registry.credential_store.redacted(credentials_ref), + ) + + @app.patch("/api/channel-connections/{connection_id}", response_model=WebChannelConnectionResponse) + async def update_channel_connection( + connection_id: str, + request: Request, + payload: WebChannelConnectionUpdateRequest, + ) -> WebChannelConnectionResponse: + registry = get_channel_connector_registry(request) + try: + connection = registry.connection_store.get(connection_id) + except KeyError: + raise HTTPException(status_code=404, detail="Channel connection not found") + if payload.display_name is not None: + connection.display_name = _clean_text(payload.display_name) or connection.display_name + if payload.account_id is not None: + connection.account_id = _clean_text(payload.account_id) or connection.account_id + if payload.config is not None: + connection.runtime_config = _normalize_connection_config(payload.config) + if payload.secrets: + secrets = {key: value for key, value in payload.secrets.items() if value} + if secrets: + # TODO: add credential GC when connection updates credentials. + connection.credentials_ref = registry.credential_store.put(kind=connection.kind, values=secrets) + connection = registry.connection_store.update(connection) + return WebChannelConnectionResponse( + connection=_connection_response_view(connection), + credentials=registry.credential_store.redacted(connection.credentials_ref), + ) + + @app.get("/api/channel-connections/{connection_id}", response_model=WebChannelConnectionResponse) + async def get_channel_connection(connection_id: str, request: Request) -> WebChannelConnectionResponse: + registry = get_channel_connector_registry(request) + try: + connection = registry.connection_store.get(connection_id) + except KeyError: + raise HTTPException(status_code=404, detail="Channel connection not found") + return WebChannelConnectionResponse( + connection=_connection_response_view(connection), + credentials=registry.credential_store.redacted(connection.credentials_ref), + ) + + @app.post("/api/channel-connections/{connection_id}/validate", response_model=WebChannelValidationResponse) + async def validate_channel_connection(connection_id: str, request: Request) -> WebChannelValidationResponse: + registry = get_channel_connector_registry(request) + try: + result = await registry.validate(connection_id) + connection = registry.connection_store.get(connection_id) + except KeyError: + raise HTTPException(status_code=404, detail="Channel connection not found") + return WebChannelValidationResponse( + ok=result.ok, + status=result.status, + account_id=result.account_id, + display_name=result.display_name, + error=result.error, + metadata=result.metadata, + connection=_connection_response_view(connection), + ) + + @app.post("/api/channel-connections/{connection_id}/revoke", response_model=WebChannelConnectionResponse) + async def revoke_channel_connection(connection_id: str, request: Request) -> WebChannelConnectionResponse: + registry = get_channel_connector_registry(request) + try: + await registry.revoke(connection_id) + connection = registry.connection_store.get(connection_id) + except KeyError: + raise HTTPException(status_code=404, detail="Channel connection not found") + return WebChannelConnectionResponse(connection=_connection_response_view(connection), credentials={}) + + @app.post("/api/channel-connector-sessions", response_model=WebConnectorSessionResponse) + async def start_channel_connector_session( + request: Request, + payload: WebConnectorSessionCreateRequest, + ) -> WebConnectorSessionResponse: + registry = get_channel_connector_registry(request) + kind = _clean_text(payload.kind) + try: + connector = registry.connector_for_kind(kind) + except KeyError: + raise HTTPException(status_code=404, detail="Connector not found") + start_session = getattr(connector, "start_session", None) + if start_session is None: + raise HTTPException(status_code=400, detail="Connector does not support sessions") + view = await start_session( + display_name=_clean_text(payload.display_name) or kind, + owner_user_id=_clean_text(payload.owner_user_id) or None, + options=payload.options, + ) + connection_id = _clean_text(view.get("connectionId")) + connection_view = None + if connection_id: + connection_view = _connection_response_view(registry.connection_store.get(connection_id)) + return WebConnectorSessionResponse(session=view, connection=connection_view) + + @app.get("/api/channel-connector-sessions/{session_id}", response_model=WebConnectorSessionResponse) + async def get_channel_connector_session(session_id: str, request: Request) -> WebConnectorSessionResponse: + registry = get_channel_connector_registry(request) + connection = next( + (item for item in registry.connection_store.list() if item.pairing_session_id == session_id), + None, + ) + if connection is None: + raise HTTPException(status_code=404, detail="Connector session not found") + connector = registry.connector_for_kind(connection.kind) + poll_session = getattr(connector, "poll_session", None) + if poll_session is None: + raise HTTPException(status_code=400, detail="Connector does not support sessions") + view = await poll_session(session_id) + connection = registry.connection_store.get(connection.connection_id) + if connection.status == "connected": + runtime = get_channel_runtime(request) + config = (await registry.materialize_channel_configs())[connection.channel_id] + await runtime.add_channel(connection.channel_id, config) + return WebConnectorSessionResponse(session=view, connection=_connection_response_view(connection)) + + @app.post("/api/channel-connector-bridge/events", response_model=WebConnectorBridgeEventResponse) + async def accept_connector_bridge_event( + request: Request, + payload: WebConnectorBridgeEventRequest, + authorization: str | None = Header(default=None), + ) -> Any: + expected = _bridge_token() + if not expected or authorization != f"Bearer {expected}": + raise HTTPException(status_code=401, detail="Invalid connector bridge token") + + registry = get_channel_connector_registry(request) + try: + connection = registry.connection_store.get(payload.connection_id) + except KeyError: + raise HTTPException(status_code=404, detail="Channel connection not found") + if connection.status == "revoked": + raise HTTPException(status_code=404, detail="Channel connection not found") + + store = _message_dedupe_store(_channel_connection_workspace(request)) + begin = store.begin( + connection_id=payload.connection_id, + event_id=payload.event_id, + delivery_attempt=payload.delivery_attempt, + ) + if not begin.should_process: + body = WebConnectorBridgeEventResponse( + accepted=begin.http_status == 200, + duplicate=True, + pending=begin.http_status == 409, + retryAfterSeconds=begin.retry_after_seconds, + ).model_dump(by_alias=True) + return JSONResponse(status_code=begin.http_status, content=body) + + runtime = get_channel_runtime(request) + identity = ChannelIdentity( + channel_id=payload.channel_id, + kind=payload.kind, + account_id=payload.account_id, + peer_id=payload.peer_id, + thread_id=payload.thread_id, + peer_type=payload.peer_type, + user_id=payload.user_id, + message_id=payload.message_id, + ) + inbound = InboundMessage( + channel=payload.channel_id, + content=payload.content, + content_type=payload.message_type, + channel_identity=identity, + user_id=payload.user_id, + message_id=payload.message_id, + metadata=dict(payload.metadata), + ) + result = await runtime.accept_inbound(inbound) + if result.accepted or result.duplicate: + store.complete(begin.dedupe_key, message_id=payload.message_id) + else: + store.fail(begin.dedupe_key, error=result.error or "runtime rejected bridge event") + return WebConnectorBridgeEventResponse( + accepted=result.accepted, + duplicate=result.duplicate, + pending=result.pending, + ) + + @app.get("/api/channels/{channel_id}/config") + async def get_channel_config(channel_id: str, request: Request) -> dict[str, Any]: + agent_service = get_agent_service(request) + config_path = agent_service.loader.config.config_path or default_config_path(workspace=agent_service.loader.workspace) + raw = _read_config_json(config_path) + channel = _ensure_dict(raw, "channels").get(channel_id) + if not isinstance(channel, dict): + raise HTTPException(status_code=404, detail="Channel not found") + return _channel_config_view(channel_id, channel) + + @app.post("/api/channels/{channel_id}/config", response_model=WebChannelConfigResponse) + async def update_channel_config( + channel_id: str, + request: Request, + payload: WebChannelConfigRequest, + ) -> WebChannelConfigResponse: + if not _clean_text(channel_id): + raise HTTPException(status_code=400, detail="Channel id is required") + kind = _clean_text(payload.kind) + mode = _clean_text(payload.mode) + if not kind: + raise HTTPException(status_code=400, detail="Channel kind is required") + if not mode: + raise HTTPException(status_code=400, detail="Channel mode is required") + + agent_service = get_agent_service(request) + config_path = agent_service.loader.config.config_path or default_config_path(workspace=agent_service.loader.workspace) + raw = _read_config_json(config_path) + channels = _ensure_dict(raw, "channels") + current = channels.get(channel_id) if isinstance(channels.get(channel_id), dict) else {} + current_secrets = current.get("secrets") if isinstance(current.get("secrets"), dict) else {} + next_secrets = dict(current_secrets) + for key, value in (payload.secrets or {}).items(): + cleaned_key = _clean_text(key) + cleaned_value = _clean_text(value) + if not cleaned_key or not cleaned_value: + continue + next_secrets[cleaned_key] = cleaned_value + + channel_payload: dict[str, Any] = { + "enabled": bool(payload.enabled), + "kind": kind, + "mode": mode, + "accountId": _clean_text(payload.account_id) or "", + "displayName": _clean_text(payload.display_name) or channel_id, + "config": payload.config or {}, + "secrets": next_secrets, + } + channels[channel_id] = channel_payload + _write_config_json(config_path, raw) + _reload_agent_config(agent_service, config_path) + return WebChannelConfigResponse( + ok=True, + channel_id=channel_id, + restart_required=True, + channel=_channel_config_view(channel_id, channel_payload), + ) + + @app.get("/api/channels/{channel_id}/events") + async def list_channel_events(channel_id: str, request: Request, limit: int = 100) -> list[dict[str, Any]]: + return get_channel_runtime(request).recent_events(channel_id, limit=limit) + + @app.post("/api/channels/{channel_id}/webhook") + async def post_channel_webhook(channel_id: str, request: Request) -> JSONResponse: + runtime = get_channel_runtime(request) + adapter = runtime.adapters.get(channel_id) + if adapter is None or not hasattr(adapter, "handle_webhook_payload"): + raise HTTPException(status_code=404, detail="Webhook channel not found") + payload = await request.json() + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Webhook payload must be a JSON object") + result = await adapter.handle_webhook_payload(payload) # type: ignore[attr-defined] + status_code = 202 if result.get("pending") else 200 + return JSONResponse(result, status_code=status_code) + + @app.websocket("/api/channels/{channel_id}/ws") + async def channel_websocket(websocket: WebSocket, channel_id: str) -> None: + runtime = getattr(websocket.app.state, "channel_runtime", None) + if not isinstance(runtime, ChannelRuntime): + await websocket.accept() + await websocket.send_json({"type": "error", "error": "Channel runtime is not running"}) + await websocket.close(code=1011) + return + adapter = runtime.adapters.get(channel_id) + if adapter is None or not hasattr(adapter, "handle_websocket"): + await websocket.accept() + await websocket.send_json({"type": "error", "error": "WebSocket channel not found"}) + await websocket.close(code=1008) + return + await adapter.handle_websocket(websocket) # type: ignore[attr-defined] + + @app.post("/api/runtime/restart") + async def restart_runtime() -> JSONResponse: + if not _self_restart_enabled(): + raise HTTPException(status_code=403, detail="Self restart is disabled") + _schedule_self_restart() + return JSONResponse({"ok": True, "restarting": True}, status_code=202) + @app.post("/api/auth/login") async def auth_login(request: Request, payload: dict[str, Any]) -> dict[str, Any]: username = _clean_text(payload.get("username")) @@ -3011,6 +3499,25 @@ def _mask_secret(value: str | None) -> str: return f"{secret[:4]}••••{secret[-4:]}" +def _channel_config_view(channel_id: str, data: dict[str, Any]) -> dict[str, Any]: + secrets_payload = data.get("secrets") if isinstance(data.get("secrets"), dict) else {} + config_payload = data.get("config") if isinstance(data.get("config"), dict) else {} + return { + "channel_id": channel_id, + "enabled": bool(data.get("enabled")), + "kind": _clean_text(data.get("kind")) or "", + "mode": _clean_text(data.get("mode")) or "webhook", + "account_id": _clean_text(data.get("accountId") or data.get("account_id")) or "", + "display_name": _clean_text(data.get("displayName") or data.get("display_name")) or channel_id, + "config": dict(config_payload), + "secrets": { + str(key): _mask_secret(str(value) if value is not None else None) + for key, value in secrets_payload.items() + if str(key).strip() + }, + } + + def _read_config_json(path: Path) -> dict[str, Any]: if not path.exists(): return {} @@ -3082,7 +3589,14 @@ def _reload_agent_config(agent_service: AgentService, config_path: Path) -> None old_manager = getattr(loaded, "mcp_manager", None) if old_manager is not None: async def _close_old_manager() -> None: - await old_manager.close() + try: + await old_manager.close() + except Exception: + # MCP transports may own anyio cancel scopes created by a + # previous request task. Config reload must not leak that + # cleanup failure as an unhandled background exception or + # knock the app out of running mode. + pass try: running_loop = asyncio.get_running_loop() diff --git a/app-instance/backend/beaver/interfaces/web/schemas/__init__.py b/app-instance/backend/beaver/interfaces/web/schemas/__init__.py index 150ef10..33f1b99 100644 --- a/app-instance/backend/beaver/interfaces/web/schemas/__init__.py +++ b/app-instance/backend/beaver/interfaces/web/schemas/__init__.py @@ -10,6 +10,16 @@ from .chat import ( WebErrorResponse, WebAgentConfigRequest, WebAgentConfigResponse, + WebChannelConfigRequest, + WebChannelConfigResponse, + WebChannelConnectionCreateRequest, + WebChannelConnectionResponse, + WebChannelConnectionUpdateRequest, + WebChannelValidationResponse, + WebConnectorBridgeEventRequest, + WebConnectorBridgeEventResponse, + WebConnectorSessionCreateRequest, + WebConnectorSessionResponse, WebProviderConfigRequest, WebProviderConfigResponse, WebProviderTarget, @@ -26,6 +36,16 @@ __all__ = [ "WebErrorResponse", "WebAgentConfigRequest", "WebAgentConfigResponse", + "WebChannelConfigRequest", + "WebChannelConfigResponse", + "WebChannelConnectionCreateRequest", + "WebChannelConnectionResponse", + "WebChannelConnectionUpdateRequest", + "WebChannelValidationResponse", + "WebConnectorBridgeEventRequest", + "WebConnectorBridgeEventResponse", + "WebConnectorSessionCreateRequest", + "WebConnectorSessionResponse", "WebProviderConfigRequest", "WebProviderConfigResponse", "WebProviderTarget", diff --git a/app-instance/backend/beaver/interfaces/web/schemas/chat.py b/app-instance/backend/beaver/interfaces/web/schemas/chat.py index bd6cd5d..a22cfc2 100644 --- a/app-instance/backend/beaver/interfaces/web/schemas/chat.py +++ b/app-instance/backend/beaver/interfaces/web/schemas/chat.py @@ -139,6 +139,113 @@ class WebProviderConfigResponse(BaseModel): enabled: bool +class WebChannelConfigRequest(BaseModel): + """Channel config update from the settings page.""" + + enabled: bool = False + kind: str + mode: str + account_id: str | None = None + display_name: str | None = None + config: dict[str, Any] = Field(default_factory=dict) + secrets: dict[str, str | None] = Field(default_factory=dict) + + +class WebChannelConfigResponse(BaseModel): + """Channel config update result.""" + + ok: bool + channel_id: str + restart_required: bool + channel: dict[str, Any] + + +class WebChannelConnectionCreateRequest(BaseModel): + """Create a channel connection from the setup UI.""" + + kind: str + mode: str + display_name: str | None = Field(default=None, alias="displayName") + owner_user_id: str | None = Field(default=None, alias="ownerUserId") + auth_type: str = Field(default="token", alias="authType") + account_id: str | None = Field(default=None, alias="accountId") + config: dict[str, Any] = Field(default_factory=dict) + secrets: dict[str, str | None] = Field(default_factory=dict) + + +class WebChannelConnectionResponse(BaseModel): + """Channel connection response with redacted credentials.""" + + connection: dict[str, Any] + credentials: dict[str, str] = Field(default_factory=dict) + + +class WebChannelConnectionUpdateRequest(BaseModel): + """Update editable channel connection setup fields.""" + + display_name: str | None = Field(default=None, alias="displayName") + account_id: str | None = Field(default=None, alias="accountId") + config: dict[str, Any] | None = None + secrets: dict[str, str | None] | None = None + + +class WebChannelValidationResponse(BaseModel): + """Connector validation response.""" + + ok: bool + status: str + account_id: str | None = None + display_name: str | None = None + error: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + connection: dict[str, Any] + + +class WebConnectorBridgeEventRequest(BaseModel): + """Inbound connector bridge event from the external sidecar.""" + + event_id: str = Field(alias="eventId") + timestamp: str + delivery_attempt: int = Field(default=1, alias="deliveryAttempt") + connection_id: str = Field(alias="connectionId") + channel_id: str = Field(alias="channelId") + kind: str + account_id: str = Field(alias="accountId") + peer_id: str = Field(alias="peerId") + peer_type: str = Field(default="unknown", alias="peerType") + user_id: str | None = Field(default=None, alias="userId") + thread_id: str | None = Field(default=None, alias="threadId") + message_id: str = Field(alias="messageId") + message_type: str = Field(default="text", alias="messageType") + content: str + metadata: dict[str, Any] = Field(default_factory=dict) + + +class WebConnectorBridgeEventResponse(BaseModel): + """Connector bridge event accept/dedupe response.""" + + accepted: bool + duplicate: bool = False + pending: bool = False + retry_after_seconds: int | None = Field(default=None, alias="retryAfterSeconds") + + +class WebConnectorSessionCreateRequest(BaseModel): + """Start a connector-managed onboarding session.""" + + kind: str + display_name: str | None = Field(default=None, alias="displayName") + owner_user_id: str | None = Field(default=None, alias="ownerUserId") + options: dict[str, Any] = Field(default_factory=dict) + + +class WebConnectorSessionResponse(BaseModel): + """Connector session view plus optional connection view.""" + + session: dict[str, Any] + connection: dict[str, Any] | None = None + + class WebAgentConfigRequest(BaseModel): """Agent runtime defaults update from the settings page.""" diff --git a/app-instance/backend/beaver/services/agent_service.py b/app-instance/backend/beaver/services/agent_service.py index 842c4b5..c2d1750 100644 --- a/app-instance/backend/beaver/services/agent_service.py +++ b/app-instance/backend/beaver/services/agent_service.py @@ -1237,17 +1237,19 @@ class AgentService: async def handle_inbound_message(self, inbound: InboundMessage) -> OutboundMessage: """把 bus inbound 映射成标准 runtime 调用,并返回结构化 outbound。""" + channel_identity = inbound.channel_identity try: result = await self.submit_direct( inbound.content, session_id=inbound.session_id, source=f"gateway:{inbound.channel}", - user_id=inbound.user_id, + user_id=inbound.user_id or (channel_identity.user_id if channel_identity else None), title=inbound.title, execution_context=inbound.execution_context, model=inbound.model, provider_name=inbound.provider_name, embedding_model=inbound.embedding_model, + channel_identity=channel_identity, ) except Exception as exc: return self.build_outbound_error( @@ -1283,6 +1285,8 @@ class AgentService: finish_reason=result.finish_reason, provider_name=result.provider_name, model=result.model, + content_type=inbound.content_type, + channel_identity=inbound.channel_identity, usage=dict(result.usage), metadata={ "inbound_metadata": dict(inbound.metadata), @@ -1308,6 +1312,8 @@ class AgentService: session_id=inbound.session_id, content=detail, finish_reason=finish_reason, + content_type=inbound.content_type, + channel_identity=inbound.channel_identity, metadata={"error": detail, "inbound_metadata": dict(inbound.metadata)}, ) diff --git a/app-instance/backend/pyproject.toml b/app-instance/backend/pyproject.toml index 4abada7..182357c 100644 --- a/app-instance/backend/pyproject.toml +++ b/app-instance/backend/pyproject.toml @@ -22,6 +22,23 @@ dependencies = [ dev = [ "pytest>=9.0.0,<10.0.0", ] +telegram = [ + "python-telegram-bot>=22.0,<23.0", +] +feishu = [ + "lark-oapi>=1.4.22,<2.0.0", +] +qqbot = [ + "aiohttp>=3.9.0,<4.0.0", +] +weixin = [ + "aiohttp>=3.9.0,<4.0.0", +] +channels = [ + "python-telegram-bot>=22.0,<23.0", + "lark-oapi>=1.4.22,<2.0.0", + "aiohttp>=3.9.0,<4.0.0", +] [project.scripts] beaver = "beaver.interfaces.cli.main:main" diff --git a/app-instance/backend/tests/unit/test_channel_connection_api.py b/app-instance/backend/tests/unit/test_channel_connection_api.py new file mode 100644 index 0000000..48eeb2f --- /dev/null +++ b/app-instance/backend/tests/unit/test_channel_connection_api.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from beaver.interfaces.web.app import create_app +from beaver.services.agent_service import AgentService + + +def test_channel_connection_api_creates_updates_lists_and_revokes(tmp_path) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + '{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path), + encoding="utf-8", + ) + service = AgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + + try: + with TestClient(app) as client: + created = client.post( + "/api/channel-connections", + json={ + "kind": "telegram", + "mode": "polling", + "displayName": "Telegram Main", + "authType": "token", + "secrets": {"botToken": "token-1"}, + "config": {"maxMessageChars": 4096, "requireMentionInGroups": True}, + }, + ) + assert created.status_code == 200 + body = created.json() + connection_id = body["connection"]["connection_id"] + assert body["connection"]["kind"] == "telegram" + assert body["connection"]["status"] == "draft" + assert "credentials_ref" not in body["connection"] + assert body["connection"]["runtime_config"] == { + "max_message_chars": 4096, + "require_mention_in_groups": True, + } + assert body["credentials"] == {"botToken": "***"} + + patched = client.patch( + f"/api/channel-connections/{connection_id}", + json={ + "displayName": "Telegram Ops", + "config": {"maxMessageChars": 2048}, + "secrets": {"botToken": "token-2"}, + }, + ) + assert patched.status_code == 200 + assert patched.json()["connection"]["display_name"] == "Telegram Ops" + assert patched.json()["connection"]["runtime_config"] == {"max_message_chars": 2048} + assert patched.json()["credentials"] == {"botToken": "***"} + + listed = client.get("/api/channel-connections") + assert listed.status_code == 200 + assert listed.json()[0]["connection_id"] == connection_id + assert "credentials_ref" not in listed.json()[0] + + revoked = client.post(f"/api/channel-connections/{connection_id}/revoke") + assert revoked.status_code == 200 + assert revoked.json()["connection"]["status"] == "revoked" + finally: + service.close() + + +def test_channel_connectors_api_lists_registered_connectors(tmp_path) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + '{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path), + encoding="utf-8", + ) + service = AgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + + try: + with TestClient(app) as client: + response = client.get("/api/channel-connectors") + finally: + service.close() + + assert response.status_code == 200 + assert response.json() == [{"kind": "feishu"}, {"kind": "telegram"}, {"kind": "weixin"}] diff --git a/app-instance/backend/tests/unit/test_channel_connection_store.py b/app-instance/backend/tests/unit/test_channel_connection_store.py new file mode 100644 index 0000000..76082f6 --- /dev/null +++ b/app-instance/backend/tests/unit/test_channel_connection_store.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from beaver.interfaces.channels.connections import ( + ChannelConnectionStore, + CredentialStore, + PairingTokenStore, +) + + +def test_channel_connection_store_creates_updates_lists_and_revokes(tmp_path) -> None: + store = ChannelConnectionStore(tmp_path / "connections.json") + + created = store.create( + kind="telegram", + mode="polling", + display_name="Telegram Main", + account_id="telegram:bot-main", + owner_user_id="user-1", + auth_type="token", + runtime_config={"max_message_chars": 4096}, + capabilities=["receive_text", "send_text"], + ) + updated = store.update_status(created.connection_id, status="connected", last_error=None) + revoked = store.revoke(created.connection_id) + + assert created.connection_id + assert created.channel_id.startswith("telegram-") + assert created.status == "draft" + assert updated.status == "connected" + assert revoked.status == "revoked" + assert store.get(created.connection_id).status == "revoked" + assert [item.connection_id for item in store.list()] == [created.connection_id] + + +def test_credential_store_saves_values_by_reference_and_redacts_views(tmp_path) -> None: + store = CredentialStore(tmp_path / "credentials.json") + + ref = store.put(kind="telegram", values={"botToken": "secret-token", "empty": ""}) + + assert ref.startswith("cred_") + assert store.get(ref) == {"botToken": "secret-token"} + assert store.redacted(ref) == {"botToken": "***"} + + +def test_pairing_token_store_uses_one_time_expiring_tokens(tmp_path) -> None: + store = PairingTokenStore(tmp_path / "pairing.json") + + session = store.create(kind="terminal", ttl_seconds=60, scope="channel:pair") + consumed = store.consume(session.token, expected_kind="terminal") + reused = store.consume(session.token, expected_kind="terminal") + + assert session.status == "pending" + assert consumed is not None + assert consumed.status == "consumed" + assert reused is None + + +def test_pairing_token_store_rejects_expired_tokens(tmp_path) -> None: + store = PairingTokenStore(tmp_path / "pairing.json") + + session = store.create(kind="weixin", ttl_seconds=-1, scope="channel:pair") + + assert store.consume(session.token, expected_kind="weixin") is None diff --git a/app-instance/backend/tests/unit/test_channel_connector_registry.py b/app-instance/backend/tests/unit/test_channel_connector_registry.py new file mode 100644 index 0000000..012cced --- /dev/null +++ b/app-instance/backend/tests/unit/test_channel_connector_registry.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import asyncio + +from beaver.foundation.config.schema import ChannelConfig +from beaver.interfaces.channels.connections import ( + ChannelConnectionStore, + ChannelConnectorRegistry, + ChannelRuntimeSpec, + CredentialStore, + ValidationResult, +) + + +class FakeConnector: + kind = "fake" + + def __init__(self) -> None: + self.validated: list[str] = [] + self.revoked: list[str] = [] + + async def validate(self, connection_id: str) -> ValidationResult: + self.validated.append(connection_id) + return ValidationResult(ok=True, status="connected", account_id="fake-account", display_name="Fake") + + async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec: + return ChannelRuntimeSpec( + channel_id="fake-channel", + kind="fake", + mode="webhook", + account_id="fake-account", + display_name="Fake", + config={"enabled": True}, + ) + + async def revoke(self, connection_id: str) -> None: + self.revoked.append(connection_id) + return None + + +def test_connector_registry_dispatches_by_kind(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + connector = FakeConnector() + registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store) + registry.register(connector) + + connection = connection_store.create( + kind="fake", + mode="webhook", + display_name="Fake", + account_id="fake-account", + owner_user_id=None, + auth_type="token", + ) + result = await registry.validate(connection.connection_id) + spec = await registry.materialize_runtime(connection.connection_id) + + assert result.ok is True + assert connector.validated == [connection.connection_id] + assert spec.channel_id == "fake-channel" + + asyncio.run(run()) + + +def test_connector_registry_materializes_channel_configs_with_credentials(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"}) + connection = connection_store.create( + kind="fake", + mode="webhook", + display_name="Connected", + account_id="connected", + owner_user_id=None, + auth_type="token", + credentials_ref=credentials_ref, + ) + connection_store.update_status(connection.connection_id, status="connected", last_error=None) + + class CredentialAwareConnector(FakeConnector): + async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec: + stored = connection_store.get(connection_id) + return ChannelRuntimeSpec( + channel_id="fake-channel", + kind="fake", + mode="webhook", + account_id="fake-account", + display_name="Fake", + config={"enabled": True}, + secrets_ref=stored.credentials_ref, + ) + + registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store) + registry.register(CredentialAwareConnector()) + + configs = await registry.materialize_channel_configs() + + assert isinstance(configs["fake-channel"], ChannelConfig) + assert configs["fake-channel"].enabled is True + assert configs["fake-channel"].secrets == {"botToken": "token-1"} + + asyncio.run(run()) + + +def test_connector_registry_materializes_only_connected_connections(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store) + registry.register(FakeConnector()) + + draft = connection_store.create( + kind="fake", + mode="webhook", + display_name="Draft", + account_id="draft", + owner_user_id=None, + auth_type="token", + ) + connected = connection_store.create( + kind="fake", + mode="webhook", + display_name="Connected", + account_id="connected", + owner_user_id=None, + auth_type="token", + ) + connection_store.update_status(connected.connection_id, status="connected", last_error=None) + + specs = await registry.materialize_connected_runtime_specs() + + assert [spec.channel_id for spec in specs] == ["fake-channel"] + assert connection_store.get(draft.connection_id).status == "draft" + + asyncio.run(run()) + + +def test_connector_registry_revoke_calls_connector_and_updates_store(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + connector = FakeConnector() + registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store) + registry.register(connector) + + connection = connection_store.create( + kind="fake", + mode="webhook", + display_name="Fake", + account_id="fake-account", + owner_user_id=None, + auth_type="token", + ) + connection_store.update_status(connection.connection_id, status="connected", last_error=None) + + await registry.revoke(connection.connection_id) + + assert connector.revoked == [connection.connection_id] + assert connection_store.get(connection.connection_id).status == "revoked" + + asyncio.run(run()) diff --git a/app-instance/backend/tests/unit/test_channel_runtime.py b/app-instance/backend/tests/unit/test_channel_runtime.py new file mode 100644 index 0000000..eff2e15 --- /dev/null +++ b/app-instance/backend/tests/unit/test_channel_runtime.py @@ -0,0 +1,414 @@ +import asyncio +import json + +from fastapi.testclient import TestClient + +from beaver.foundation.config.schema import ChannelConfig +from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage +from beaver.foundation.events import MessageBus +from beaver.interfaces.channels.generic_webhook import GenericWebhookAdapter +from beaver.interfaces.channels.runtime import ChannelRuntime +from beaver.interfaces.channels.state import ChannelDedupeStore, ChannelEventLog +from beaver.interfaces.web.app import _self_restart_enabled, create_app +from beaver.services.agent_service import AgentService + + +def test_channel_identity_builds_stable_session_id() -> None: + identity = ChannelIdentity( + channel_id="webhook-dev", + kind="webhook", + account_id="local", + peer_id="demo-user", + thread_id="main", + peer_type="dm", + message_id="msg-1", + ) + + assert identity.session_id() == "webhook-dev:local:demo-user:main" + assert identity.dedupe_key() == "webhook-dev:local:demo-user:main:msg-1" + + +def test_channel_identity_requires_routing_fields() -> None: + identity = ChannelIdentity(channel_id="webhook-dev", kind="webhook", account_id="", peer_id="demo") + + assert identity.validation_error() == "account_id is required" + + +def test_messages_carry_channel_identity() -> None: + identity = ChannelIdentity( + channel_id="webhook-dev", + kind="webhook", + account_id="local", + peer_id="demo-user", + message_id="msg-1", + ) + + inbound = InboundMessage(channel="webhook-dev", content="hello", channel_identity=identity) + outbound = OutboundMessage( + channel="webhook-dev", + content="ok", + session_id=identity.session_id(), + finish_reason="stop", + channel_identity=identity, + ) + + assert inbound.channel_identity is identity + assert outbound.channel_identity is identity + + +def test_dedupe_store_tracks_processing_and_done(tmp_path) -> None: + store = ChannelDedupeStore(tmp_path / "dedupe.json", retention_hours=48) + + created = store.mark_processing( + dedupe_key="webhook-dev:local:demo:msg-1", + session_id="webhook-dev:local:demo", + message_id="msg-1", + ) + duplicate = store.mark_processing( + dedupe_key="webhook-dev:local:demo:msg-1", + session_id="webhook-dev:local:demo", + message_id="msg-1", + ) + + assert created.created is True + assert duplicate.created is False + assert duplicate.record is not None + assert duplicate.record["status"] == "processing" + + store.mark_done( + dedupe_key="webhook-dev:local:demo:msg-1", + run_id="run-1", + reply="hello" * 10000, + max_reply_chars=20, + ) + + done = store.get("webhook-dev:local:demo:msg-1") + assert done is not None + assert done["status"] == "done" + assert done["reply"] == "hellohellohellohello" + + +def test_channel_event_log_writes_recent_events(tmp_path) -> None: + log = ChannelEventLog(tmp_path / "events.jsonl") + log.record( + channel_id="webhook-dev", + kind="inbound_accepted", + session_id="webhook-dev:local:demo", + message_id="msg-1", + status="ok", + text="hello world", + ) + + events = log.recent(channel_id="webhook-dev", limit=10) + + assert len(events) == 1 + assert events[0]["kind"] == "inbound_accepted" + assert events[0]["text_preview"] == "hello world" + assert "raw_channel_payload" not in json.dumps(events[0]) + + +class FakeAgentService: + is_running = True + + async def handle_inbound_message(self, inbound): + return OutboundMessage( + message_id=inbound.message_id, + channel=inbound.channel, + content=f"echo:{inbound.content}", + session_id=inbound.session_id, + finish_reason="stop", + run_id="run-1", + channel_identity=inbound.channel_identity, + ) + + +class SlowFakeAgentService(FakeAgentService): + async def handle_inbound_message(self, inbound): + await asyncio.sleep(0.05) + return await super().handle_inbound_message(inbound) + + +def test_channel_runtime_accept_inbound_normalizes_session_and_dedupes(tmp_path) -> None: + async def run() -> None: + bus = MessageBus() + runtime = ChannelRuntime( + service=FakeAgentService(), + bus=bus, + workspace=tmp_path, + channels={}, + ) + identity = ChannelIdentity( + channel_id="webhook-dev", + kind="webhook", + account_id="local", + peer_id="demo", + message_id="msg-1", + ) + result = await runtime.accept_inbound( + InboundMessage( + channel="webhook-dev", + content="hello", + session_id="wrong", + channel_identity=identity, + ) + ) + duplicate = await runtime.accept_inbound( + InboundMessage( + channel="webhook-dev", + content="hello", + channel_identity=identity, + ) + ) + + queued = await bus.consume_inbound() + assert result.accepted is True + assert queued.session_id == "webhook-dev:local:demo" + assert duplicate.accepted is False + assert duplicate.duplicate is True + + asyncio.run(run()) + + +def test_generic_webhook_adapter_waits_for_outbound_reply(tmp_path) -> None: + async def run() -> None: + bus = MessageBus() + runtime = ChannelRuntime( + service=FakeAgentService(), + bus=bus, + workspace=tmp_path, + channels={}, + ) + adapter = GenericWebhookAdapter( + channel_id="webhook-dev", + kind="webhook", + mode="webhook", + account_id="local", + display_name="Webhook Dev", + inbound_sink=runtime, + response_timeout_seconds=1, + ) + runtime.manager.register(adapter) + await runtime.start() + try: + response = await adapter.handle_webhook_payload( + { + "peer_id": "demo", + "message_id": "msg-1", + "text": "hello", + "peer_type": "dm", + } + ) + finally: + await runtime.stop() + + assert response["ok"] is True + assert response["reply"] == "echo:hello" + assert response["session_id"] == "webhook-dev:local:demo" + + asyncio.run(run()) + + +def test_generic_webhook_records_unclaimed_outbound_after_timeout(tmp_path) -> None: + async def run() -> None: + bus = MessageBus() + runtime = ChannelRuntime( + service=SlowFakeAgentService(), + bus=bus, + workspace=tmp_path, + channels={}, + ) + adapter = GenericWebhookAdapter( + channel_id="webhook-dev", + kind="webhook", + mode="webhook", + account_id="local", + display_name="Webhook Dev", + inbound_sink=runtime, + response_timeout_seconds=1, + ) + adapter.response_timeout_seconds = 0.01 + runtime.manager.register(adapter) + await runtime.start() + try: + response = await adapter.handle_webhook_payload( + { + "peer_id": "demo", + "message_id": "msg-1", + "text": "hello", + "peer_type": "dm", + } + ) + await asyncio.sleep(0.1) + events = runtime.recent_events("webhook-dev", limit=20) + finally: + await runtime.stop() + + assert response["pending"] is True + assert any(event["kind"] == "outbound_unclaimed" for event in events) + + asyncio.run(run()) + + +def test_channel_runtime_starts_enabled_generic_webhook_and_reports_status(tmp_path) -> None: + async def run() -> None: + runtime = ChannelRuntime( + service=FakeAgentService(), + workspace=tmp_path, + channels={ + "webhook-dev": ChannelConfig( + enabled=True, + kind="webhook", + mode="webhook", + account_id="local", + display_name="Webhook Dev", + config={"response_timeout_seconds": 1800}, + ), + "off": ChannelConfig( + enabled=False, + kind="webhook", + mode="webhook", + account_id="local", + ), + }, + ) + await runtime.start() + try: + statuses = runtime.statuses() + finally: + await runtime.stop() + + by_id = {item["channel_id"]: item for item in statuses} + assert by_id["webhook-dev"]["state"] == "running" + assert by_id["webhook-dev"]["webhook_url"] == "/api/channels/webhook-dev/webhook" + assert by_id["off"]["state"] == "disabled" + + asyncio.run(run()) + + +def test_channel_runtime_builds_platform_adapters_without_starting_networks(tmp_path) -> None: + runtime = ChannelRuntime( + service=FakeAgentService(), + workspace=tmp_path, + channels={}, + ) + + cases = { + "telegram-main": ChannelConfig(enabled=True, kind="telegram", mode="polling", account_id="bot-main"), + "feishu-main": ChannelConfig(enabled=True, kind="feishu", mode="websocket", account_id="tenant-main"), + "qq-main": ChannelConfig(enabled=True, kind="qqbot", mode="websocket", account_id="qq-main"), + "weixin-main": ChannelConfig(enabled=True, kind="weixin", mode="polling", account_id="wx-main"), + } + + for channel_id, cfg in cases.items(): + adapter = runtime._build_adapter(channel_id, cfg) + assert adapter.channel_id == channel_id + assert adapter.kind == cfg.kind + assert adapter.mode == cfg.mode + + +def test_channel_runtime_reports_platform_capabilities(tmp_path) -> None: + runtime = ChannelRuntime( + service=FakeAgentService(), + workspace=tmp_path, + channels={ + "telegram-main": ChannelConfig(enabled=True, kind="telegram", mode="polling", account_id="bot-main"), + "weixin-main": ChannelConfig(enabled=True, kind="weixin", mode="polling", account_id="wx-main"), + }, + ) + + by_id = {item["channel_id"]: item for item in runtime.statuses()} + + assert by_id["telegram-main"]["capabilities"] == [ + "receive_text", + "send_text", + "receive_media", + "groups", + ] + assert by_id["weixin-main"]["capabilities"] == [ + "receive_text", + "send_text", + "receive_media", + "direct_messages", + ] + + +def test_channel_runtime_platform_start_failure_does_not_stop_other_channels(tmp_path) -> None: + async def run() -> None: + runtime = ChannelRuntime( + service=FakeAgentService(), + workspace=tmp_path, + channels={ + "telegram-main": ChannelConfig( + enabled=True, + kind="telegram", + mode="polling", + account_id="bot-main", + secrets={}, + ), + "off": ChannelConfig( + enabled=False, + kind="weixin", + mode="polling", + account_id="wx-main", + ), + }, + ) + + await runtime.start() + try: + by_id = {item["channel_id"]: item for item in runtime.statuses()} + finally: + await runtime.stop() + + assert by_id["telegram-main"]["state"] == "error" + assert "botToken" in by_id["telegram-main"]["last_error"] + assert by_id["off"]["state"] == "disabled" + + asyncio.run(run()) + + +def test_web_app_status_exposes_configured_channel(tmp_path) -> None: + config_path = tmp_path / "config.json" + workspace = tmp_path / "workspace" + workspace.mkdir() + config_path.write_text( + json.dumps( + { + "agents": {"defaults": {"workspace": str(workspace), "model": "openai/gpt-5"}}, + "providers": {}, + "channels": { + "webhook-dev": { + "enabled": True, + "kind": "webhook", + "mode": "webhook", + "accountId": "local", + "displayName": "Webhook Dev", + } + }, + } + ), + encoding="utf-8", + ) + service = AgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + payload = client.get("/api/status").json() + + service.close() + assert payload["channels"][0]["channel_id"] == "webhook-dev" + assert payload["channels"][0]["state"] == "running" + assert payload["channels"][0]["webhook_url"] == "/api/channels/webhook-dev/webhook" + assert payload["runtime_controls"]["self_restart"] is True + + +def test_self_restart_env_defaults_enabled(monkeypatch) -> None: + monkeypatch.delenv("BEAVER_ENABLE_SELF_RESTART", raising=False) + + assert _self_restart_enabled() is True + + +def test_self_restart_env_can_disable(monkeypatch) -> None: + monkeypatch.setenv("BEAVER_ENABLE_SELF_RESTART", "0") + + assert _self_restart_enabled() is False diff --git a/app-instance/backend/tests/unit/test_channel_runtime_dynamic_channels.py b/app-instance/backend/tests/unit/test_channel_runtime_dynamic_channels.py new file mode 100644 index 0000000..0818556 --- /dev/null +++ b/app-instance/backend/tests/unit/test_channel_runtime_dynamic_channels.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import asyncio + +from beaver.foundation.config.schema import ChannelConfig +from beaver.foundation.events import MessageBus, OutboundMessage +from beaver.interfaces.channels.runtime import ChannelRuntime + + +class FakeService: + async def handle_inbound_message(self, inbound): + return OutboundMessage(channel=inbound.channel, content="ok", session_id=inbound.session_id, finish_reason="stop") + + +def test_runtime_add_channel_starts_new_channel_after_runtime_start(tmp_path) -> None: + async def run() -> None: + runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus()) + await runtime.start() + try: + await runtime.add_channel( + "webhook-dev", + ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct"), + ) + assert "webhook-dev" in runtime.adapters + assert runtime.states["webhook-dev"]["state"] == "running" + finally: + await runtime.stop() + + asyncio.run(run()) + + +def test_runtime_add_channel_noops_for_same_config(tmp_path) -> None: + async def run() -> None: + cfg = ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct") + runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus()) + await runtime.start() + try: + await runtime.add_channel("webhook-dev", cfg) + first = runtime.adapters["webhook-dev"] + await runtime.add_channel("webhook-dev", cfg) + assert runtime.adapters["webhook-dev"] is first + finally: + await runtime.stop() + + asyncio.run(run()) + + +def test_runtime_replacement_failure_keeps_old_channel(tmp_path) -> None: + async def run() -> None: + good = ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct") + bad = ChannelConfig(enabled=True, kind="missing", mode="http", account_id="acct") + runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus()) + await runtime.start() + try: + await runtime.add_channel("webhook-dev", good) + old = runtime.adapters["webhook-dev"] + try: + await runtime.add_channel("webhook-dev", bad) + except ValueError: + pass + else: + raise AssertionError("Expected ValueError") + assert runtime.adapters["webhook-dev"] is old + assert runtime.channel_configs["webhook-dev"] == good + assert runtime.states["webhook-dev"]["state"] == "running" + finally: + await runtime.stop() + + asyncio.run(run()) + + +def test_runtime_remove_channel_stops_and_unregisters(tmp_path) -> None: + async def run() -> None: + runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus()) + await runtime.start() + try: + await runtime.add_channel( + "webhook-dev", + ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct"), + ) + await runtime.remove_channel("webhook-dev") + assert "webhook-dev" not in runtime.adapters + assert "webhook-dev" not in runtime.manager.channels + assert runtime.states["webhook-dev"]["state"] == "removed" + finally: + await runtime.stop() + + asyncio.run(run()) + + +def test_runtime_builds_external_connector_channel(tmp_path, monkeypatch) -> None: + async def run() -> None: + monkeypatch.setenv("EXTERNAL_CONNECTOR_TOKEN", "connector-token") + runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus()) + await runtime.start() + try: + await runtime.add_channel( + "weixin-main", + ChannelConfig( + enabled=True, + kind="external_connector", + mode="http", + account_id="weixin:me", + display_name="Weixin Main", + config={ + "platformKind": "weixin", + "connectionId": "conn_1", + "sidecarBaseUrl": "http://external-connector:8787", + }, + ), + ) + adapter = runtime.adapters["weixin-main"] + assert adapter.kind == "external_connector" + assert adapter.mode == "http" + assert getattr(adapter, "platform_kind") == "weixin" + finally: + await runtime.stop() + + asyncio.run(run()) diff --git a/app-instance/backend/tests/unit/test_config_loader.py b/app-instance/backend/tests/unit/test_config_loader.py index ec46fa4..e877132 100644 --- a/app-instance/backend/tests/unit/test_config_loader.py +++ b/app-instance/backend/tests/unit/test_config_loader.py @@ -1,4 +1,5 @@ import json +import asyncio from fastapi.testclient import TestClient @@ -46,6 +47,44 @@ def test_load_config_reads_current_instance_shape(tmp_path) -> None: assert target["extra_headers"] == {"X-Test": "1"} +def test_config_loader_reads_channels(tmp_path) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "agents": {"defaults": {"model": "openai/gpt-5"}}, + "channels": { + "webhook-dev": { + "enabled": True, + "kind": "webhook", + "mode": "webhook", + "accountId": "local", + "displayName": "Webhook Dev", + "config": { + "responseTimeoutSeconds": 1800, + "dedupeRetentionHours": 48, + }, + "secrets": {"ignored_for_status": "secret-value"}, + } + }, + } + ), + encoding="utf-8", + ) + + config = load_config(config_path=config_path) + + channel = config.channels["webhook-dev"] + assert channel.enabled is True + assert channel.kind == "webhook" + assert channel.mode == "webhook" + assert channel.account_id == "local" + assert channel.display_name == "Webhook Dev" + assert channel.config["response_timeout_seconds"] == 1800 + assert channel.config["dedupe_retention_hours"] == 48 + assert channel.secrets == {"ignored_for_status": "secret-value"} + + def test_provider_resolution_ignores_custom_and_disabled_overrides(tmp_path) -> None: config_path = tmp_path / "config.json" config_path.write_text( @@ -163,6 +202,58 @@ def test_reload_agent_config_updates_booted_loop_config(tmp_path) -> None: service.close() +def test_reload_agent_config_keeps_running_service_when_old_mcp_close_fails(tmp_path) -> None: + async def run_case() -> None: + workspace = tmp_path / "workspace" + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "agents": {"defaults": {"workspace": str(workspace), "model": "old-model"}}, + "providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://old.example.com/v1"}}, + } + ), + encoding="utf-8", + ) + service = AgentService(config_path=config_path) + await service.start() + + class FailingMCPManager: + async def close(self) -> None: + raise RuntimeError("Attempted to exit cancel scope in a different task than it was entered in") + + loaded = service.create_loop().boot() + loaded.mcp_manager = FailingMCPManager() + config_path.write_text( + json.dumps( + { + "agents": {"defaults": {"workspace": str(workspace), "model": "new-model"}}, + "providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://new.example.com/v1"}}, + } + ), + encoding="utf-8", + ) + + loop = asyncio.get_running_loop() + unhandled: list[dict[str, object]] = [] + previous_handler = loop.get_exception_handler() + loop.set_exception_handler(lambda _loop, context: unhandled.append(context)) + try: + _reload_agent_config(service, config_path) + await asyncio.sleep(0) + + target = service.create_loop().boot().config.resolve_provider_target() + assert service.is_running is True + assert target["model"] == "new-model" + assert target["api_base"] == "https://new.example.com/v1" + assert unhandled == [] + finally: + loop.set_exception_handler(previous_handler) + await service.shutdown(force=True) + + asyncio.run(run_case()) + + def test_agent_defaults_include_runtime_controls(tmp_path) -> None: config_path = tmp_path / "config.json" config_path.write_text( @@ -245,6 +336,67 @@ def test_agent_config_api_accepts_zero_temperature_and_iterations(tmp_path) -> N service.close() +def test_channel_config_api_persists_and_masks_secrets(tmp_path) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "agents": {"defaults": {"model": "openai/gpt-5"}}, + "channels": { + "telegram-main": { + "enabled": False, + "kind": "telegram", + "mode": "polling", + "accountId": "bot-main", + "displayName": "Telegram Main", + "secrets": {"botToken": "1234567890abcdef"}, + "config": {"requireMentionInGroups": True}, + } + }, + } + ), + encoding="utf-8", + ) + service = AgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + before = client.get("/api/channels/telegram-main/config") + response = client.post( + "/api/channels/telegram-main/config", + json={ + "enabled": True, + "kind": "telegram", + "mode": "polling", + "account_id": "bot-main", + "display_name": "Telegram Primary", + "secrets": {"botToken": ""}, + "config": { + "requireMentionInGroups": False, + "allowFrom": ["1001", "1002"], + "maxMessageChars": 3000, + }, + }, + ) + + saved = json.loads(config_path.read_text(encoding="utf-8")) + channel = saved["channels"]["telegram-main"] + + assert before.status_code == 200 + assert before.json()["secrets"] == {"botToken": "1234••••cdef"} + assert response.status_code == 200 + assert response.json()["ok"] is True + assert response.json()["restart_required"] is True + assert response.json()["channel"]["display_name"] == "Telegram Primary" + assert response.json()["channel"]["secrets"] == {"botToken": "1234••••cdef"} + assert channel["enabled"] is True + assert channel["displayName"] == "Telegram Primary" + assert channel["secrets"]["botToken"] == "1234567890abcdef" + assert channel["config"]["allowFrom"] == ["1001", "1002"] + assert load_config(config_path=config_path).channels["telegram-main"].enabled is True + service.close() + + def test_openai_compatible_qwen_config_keeps_openai_provider() -> None: bundle = make_provider_bundle( model="qwen-plus", diff --git a/app-instance/backend/tests/unit/test_connector_message_dedupe_store.py b/app-instance/backend/tests/unit/test_connector_message_dedupe_store.py new file mode 100644 index 0000000..4df0294 --- /dev/null +++ b/app-instance/backend/tests/unit/test_connector_message_dedupe_store.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from beaver.interfaces.channels.connections import MessageDedupeStore + + +def test_message_dedupe_store_completes_and_dedupes_completed(tmp_path) -> None: + store = MessageDedupeStore(tmp_path / "message_dedupe.json") + + first = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1) + store.complete(first.dedupe_key, message_id="msg_1") + duplicate = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2) + + assert first.should_process is True + assert duplicate.should_process is False + assert duplicate.status == "completed" + assert duplicate.http_status == 200 + + +def test_message_dedupe_store_returns_conflict_for_active_processing(tmp_path) -> None: + store = MessageDedupeStore(tmp_path / "message_dedupe.json", processing_ttl_seconds=60) + + store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1) + duplicate = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2) + + assert duplicate.should_process is False + assert duplicate.status == "processing" + assert duplicate.http_status == 409 + assert duplicate.retry_after_seconds == 5 + + +def test_message_dedupe_store_reprocesses_stale_processing(tmp_path) -> None: + store = MessageDedupeStore(tmp_path / "message_dedupe.json", processing_ttl_seconds=0) + + store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1) + stale = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2) + + assert stale.should_process is True + assert stale.status == "processing" + assert stale.record.delivery_attempts == 2 + + +def test_message_dedupe_store_reprocesses_failed_records(tmp_path) -> None: + store = MessageDedupeStore(tmp_path / "message_dedupe.json") + + first = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1) + store.fail(first.dedupe_key, error="runtime rejected") + retry = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2) + + assert retry.should_process is True + assert retry.record.delivery_attempts == 2 + assert retry.record.last_error is None diff --git a/app-instance/backend/tests/unit/test_external_connector_bridge_api.py b/app-instance/backend/tests/unit/test_external_connector_bridge_api.py new file mode 100644 index 0000000..91d20c7 --- /dev/null +++ b/app-instance/backend/tests/unit/test_external_connector_bridge_api.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from beaver.interfaces.channels.connections import ChannelConnectionStore +from beaver.interfaces.web.app import create_app +from beaver.services.agent_service import AgentService + + +def _app(tmp_path, monkeypatch): + monkeypatch.setenv("BEAVER_BRIDGE_TOKEN", "bridge-token") + config_path = tmp_path / "config.json" + config_path.write_text( + '{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path), + encoding="utf-8", + ) + service = AgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + return app, service + + +def _connected_connection(tmp_path): + state_dir = tmp_path / "state" / "channel_connections" + store = ChannelConnectionStore(state_dir / "connections.json") + connection = store.create( + kind="weixin", + mode="sidecar", + display_name="Weixin Main", + account_id="weixin:me", + owner_user_id=None, + auth_type="connector_session", + ) + store.update_status(connection.connection_id, status="connected", last_error=None) + return connection + + +def _payload(connection, *, event_id: str = "evt-1", delivery_attempt: int = 1) -> dict: + return { + "eventId": event_id, + "timestamp": "2026-06-02T09:30:00Z", + "deliveryAttempt": delivery_attempt, + "connectionId": connection.connection_id, + "channelId": connection.channel_id, + "kind": "weixin", + "accountId": "weixin:me", + "peerId": "peer-1", + "peerType": "dm", + "userId": "sender-1", + "threadId": None, + "messageId": "msg-1", + "messageType": "text", + "content": "hello", + "metadata": {}, + } + + +def test_bridge_endpoint_accepts_valid_event(tmp_path, monkeypatch) -> None: + app, service = _app(tmp_path, monkeypatch) + try: + with TestClient(app) as client: + connection = _connected_connection(tmp_path) + response = client.post( + "/api/channel-connector-bridge/events", + headers={"Authorization": "Bearer bridge-token"}, + json=_payload(connection), + ) + assert response.status_code == 200 + assert response.json()["accepted"] is True + finally: + service.close() + + +def test_bridge_endpoint_rejects_invalid_token(tmp_path, monkeypatch) -> None: + app, service = _app(tmp_path, monkeypatch) + try: + with TestClient(app) as client: + connection = _connected_connection(tmp_path) + response = client.post( + "/api/channel-connector-bridge/events", + headers={"Authorization": "Bearer wrong"}, + json=_payload(connection), + ) + assert response.status_code == 401 + finally: + service.close() + + +def test_bridge_endpoint_dedupes_repeated_event(tmp_path, monkeypatch) -> None: + app, service = _app(tmp_path, monkeypatch) + try: + with TestClient(app) as client: + connection = _connected_connection(tmp_path) + first = client.post( + "/api/channel-connector-bridge/events", + headers={"Authorization": "Bearer bridge-token"}, + json=_payload(connection), + ) + second = client.post( + "/api/channel-connector-bridge/events", + headers={"Authorization": "Bearer bridge-token"}, + json=_payload(connection, delivery_attempt=2), + ) + assert first.status_code == 200 + assert second.status_code in {200, 409} + assert second.json()["duplicate"] is True + finally: + service.close() diff --git a/app-instance/backend/tests/unit/test_external_connector_channel.py b/app-instance/backend/tests/unit/test_external_connector_channel.py new file mode 100644 index 0000000..3c00772 --- /dev/null +++ b/app-instance/backend/tests/unit/test_external_connector_channel.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import asyncio + +from beaver.foundation.events import ChannelIdentity, OutboundMessage +from beaver.interfaces.channels.external_connector import ExternalConnectorChannel, _request_id + + +class FakeSidecarClient: + def __init__(self) -> None: + self.sent: list[dict] = [] + + async def send(self, payload: dict) -> dict: + self.sent.append(payload) + return {"ok": True, "providerMessageId": "provider-1"} + + +def test_external_connector_channel_sends_with_target_and_request_id() -> None: + async def run() -> None: + client = FakeSidecarClient() + channel = ExternalConnectorChannel( + channel_id="weixin-main", + platform_kind="weixin", + connection_id="conn_1", + account_id="weixin:me", + display_name="Weixin Main", + sidecar_client=client, + ) + message = OutboundMessage( + channel="weixin-main", + content="reply", + session_id="s1", + finish_reason="stop", + message_id="out-msg-1", + channel_identity=ChannelIdentity( + channel_id="weixin-main", + kind="weixin", + account_id="weixin:me", + peer_id="peer-1", + peer_type="dm", + thread_id=None, + user_id="sender-1", + message_id="in-msg-1", + ), + metadata={"inbound_metadata": {"contextToken": "ctx-1"}}, + ) + + await channel.send(message) + + assert client.sent == [ + { + "requestId": "out_weixin-main:s1:out-msg-1", + "connectionId": "conn_1", + "channelId": "weixin-main", + "kind": "weixin", + "target": {"peerId": "peer-1", "peerType": "dm", "threadId": None}, + "content": "reply", + "metadata": {"inboundMessageId": "in-msg-1", "sessionId": "s1", "contextToken": "ctx-1"}, + } + ] + + asyncio.run(run()) + + +def test_external_connector_request_id_falls_back_when_message_id_is_none_or_blank() -> None: + identity = ChannelIdentity( + channel_id="weixin-main", + kind="weixin", + account_id="weixin:me", + peer_id="peer-1", + peer_type="dm", + message_id="in-msg-1", + ) + first = OutboundMessage( + channel="weixin-main", + content="same reply", + session_id="s1", + finish_reason="stop", + message_id=None, # type: ignore[arg-type] + channel_identity=identity, + ) + second = OutboundMessage( + channel="weixin-main", + content="same reply", + session_id="s1", + finish_reason="stop", + message_id="", + channel_identity=identity, + ) + + assert _request_id(first) == _request_id(second) + assert _request_id(first).startswith("out_weixin-main:s1:") + + +def test_external_connector_channel_requires_identity() -> None: + async def run() -> None: + channel = ExternalConnectorChannel( + channel_id="weixin-main", + platform_kind="weixin", + connection_id="conn_1", + account_id="weixin:me", + display_name="Weixin Main", + sidecar_client=FakeSidecarClient(), + ) + message = OutboundMessage(channel="weixin-main", content="reply", session_id="s1", finish_reason="stop") + + try: + await channel.send(message) + except ValueError as exc: + assert "channel_identity is required" in str(exc) + else: + raise AssertionError("Expected ValueError") + + asyncio.run(run()) diff --git a/app-instance/backend/tests/unit/test_external_sidecar_connectors.py b/app-instance/backend/tests/unit/test_external_sidecar_connectors.py new file mode 100644 index 0000000..40ed8a0 --- /dev/null +++ b/app-instance/backend/tests/unit/test_external_sidecar_connectors.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import asyncio + +from fastapi.testclient import TestClient + +from beaver.interfaces.channels.connections import ( + ChannelConnectionStore, + ChannelConnectorRegistry, + CredentialStore, + FeishuConnector, + WeixinConnector, +) +from beaver.interfaces.web.app import create_app +from beaver.services.agent_service import AgentService + + +class FakeSidecarClient: + def __init__(self) -> None: + self.sessions: dict[str, dict] = {} + self.started: list[dict] = [] + self.logged_out: list[str] = [] + + async def start_session(self, payload: dict) -> dict: + self.started.append(payload) + session = { + "sessionId": "cs_1", + "kind": payload["kind"], + "status": "qr_ready", + "qrImage": "data:image/png;base64,abc", + "accountId": None, + "displayName": None, + "metadata": {}, + } + self.sessions["cs_1"] = session + return session + + async def get_session(self, session_id: str) -> dict: + return self.sessions[session_id] + + async def logout(self, connection_id: str) -> dict: + self.logged_out.append(connection_id) + return {"ok": True} + + +def test_weixin_connector_starts_connector_session(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + client = FakeSidecarClient() + connector = WeixinConnector( + connection_store=connection_store, + credential_store=credential_store, + sidecar_client=client, + sidecar_base_url="http://external-connector:8787", + ) + + view = await connector.start_session(display_name="Weixin Main", owner_user_id="user-1", options={}) + + assert view["sessionId"] == "cs_1" + assert view["connectionId"].startswith("conn_") + assert client.started[0]["kind"] == "weixin" + assert client.started[0]["connectionId"].startswith("conn_") + assert connection_store.list()[0].kind == "weixin" + assert connection_store.list()[0].status == "pairing" + + asyncio.run(run()) + + +def test_weixin_connector_poll_connected_materializes_external_runtime(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + client = FakeSidecarClient() + connector = WeixinConnector( + connection_store=connection_store, + credential_store=credential_store, + sidecar_client=client, + sidecar_base_url="http://external-connector:8787", + ) + await connector.start_session(display_name="Weixin Main", owner_user_id=None, options={}) + connection = connection_store.list()[0] + client.sessions["cs_1"] = { + "sessionId": "cs_1", + "kind": "weixin", + "status": "connected", + "accountId": "weixin:me", + "displayName": "Me", + "metadata": {"stateRef": "state-1"}, + } + + result = await connector.poll_session("cs_1") + updated = connection_store.get(connection.connection_id) + spec = await connector.materialize_runtime(connection.connection_id) + + assert result["status"] == "connected" + assert updated.status == "connected" + assert updated.account_id == "weixin:me" + assert spec.kind == "external_connector" + assert spec.mode == "http" + assert spec.config["platformKind"] == "weixin" + assert spec.config["sidecarBaseUrl"] == "http://external-connector:8787" + + asyncio.run(run()) + + +def test_feishu_connector_uses_feishu_kind(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + client = FakeSidecarClient() + connector = FeishuConnector( + connection_store=connection_store, + credential_store=credential_store, + sidecar_client=client, + sidecar_base_url="http://external-connector:8787", + ) + + await connector.start_session(display_name="Feishu Main", owner_user_id=None, options={"domain": "feishu"}) + + assert client.started[0]["kind"] == "feishu" + assert client.started[0]["options"] == {"domain": "feishu"} + + asyncio.run(run()) + + +def test_connector_session_api_starts_and_polls_connected_session(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("EXTERNAL_CONNECTOR_TOKEN", "connector-token") + config_path = tmp_path / "config.json" + config_path.write_text( + '{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path), + encoding="utf-8", + ) + service = AgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + client = FakeSidecarClient() + + try: + with TestClient(app) as http: + state_dir = tmp_path / "state" / "channel_connections" + connection_store = ChannelConnectionStore(state_dir / "connections.json") + credential_store = CredentialStore(state_dir / "credentials.json") + registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store) + registry.register( + WeixinConnector( + connection_store=connection_store, + credential_store=credential_store, + sidecar_client=client, + sidecar_base_url="http://external-connector:8787", + ) + ) + app.state.channel_connector_registry = registry + + started = http.post( + "/api/channel-connector-sessions", + json={"kind": "weixin", "displayName": "Weixin Main", "options": {}}, + ) + session_id = started.json()["session"]["sessionId"] + connection_id = started.json()["connection"]["connection_id"] + client.sessions[session_id] = { + "sessionId": session_id, + "kind": "weixin", + "status": "connected", + "accountId": "weixin:me", + "displayName": "Me", + "metadata": {}, + } + polled = http.get(f"/api/channel-connector-sessions/{session_id}") + + assert started.status_code == 200 + assert polled.status_code == 200 + assert polled.json()["connection"]["status"] == "connected" + assert connection_store.get(connection_id).status == "connected" + assert polled.json()["connection"]["channel_id"] in app.state.channel_runtime.adapters + finally: + service.close() diff --git a/app-instance/backend/tests/unit/test_feishu_channel_adapter.py b/app-instance/backend/tests/unit/test_feishu_channel_adapter.py new file mode 100644 index 0000000..54a25bb --- /dev/null +++ b/app-instance/backend/tests/unit/test_feishu_channel_adapter.py @@ -0,0 +1,154 @@ +import asyncio + +from beaver.foundation.events import OutboundMessage +from beaver.interfaces.channels.platforms.feishu import FeishuAdapter + + +class FakeSink: + def __init__(self) -> None: + self.messages = [] + + async def accept_inbound(self, message): + self.messages.append(message) + + +class FakeFeishuClient: + def __init__(self) -> None: + self.sent = [] + + async def send_text(self, *, receive_id_type: str, receive_id: str, text: str): + self.sent.append({"receive_id_type": receive_id_type, "receive_id": receive_id, "text": text}) + + +def test_feishu_normalizes_direct_text_event() -> None: + async def run() -> None: + sink = FakeSink() + adapter = FeishuAdapter( + channel_id="feishu-main", + kind="feishu", + mode="websocket", + account_id="tenant-main", + display_name=None, + inbound_sink=sink, + secrets={"appId": "app", "appSecret": "secret"}, + config={}, + client=FakeFeishuClient(), + ) + + await adapter.handle_event_payload( + { + "event": { + "message": { + "message_id": "m1", + "chat_id": "oc_chat", + "chat_type": "p2p", + "message_type": "text", + "content": "{\"text\":\"hello\"}", + }, + "sender": {"sender_id": {"open_id": "ou_user"}}, + } + } + ) + + message = sink.messages[0] + assert message.content == "hello" + assert message.session_id == "feishu-main:tenant-main:oc_chat" + assert message.channel_identity.peer_type == "dm" + assert message.channel_identity.user_id == "ou_user" + + asyncio.run(run()) + + +def test_feishu_group_mention_gate() -> None: + async def run() -> None: + sink = FakeSink() + adapter = FeishuAdapter( + channel_id="feishu-main", + kind="feishu", + mode="websocket", + account_id="tenant-main", + display_name=None, + inbound_sink=sink, + secrets={"appId": "app", "appSecret": "secret"}, + config={"requireMentionInGroups": True, "botOpenId": "ou_bot"}, + client=FakeFeishuClient(), + ) + + await adapter.handle_event_payload( + { + "event": { + "message": { + "message_id": "m1", + "chat_id": "oc_group", + "chat_type": "group", + "message_type": "text", + "content": "{\"text\":\"hello\"}", + "mentions": [], + }, + "sender": {"sender_id": {"open_id": "ou_user"}}, + } + } + ) + await adapter.handle_event_payload( + { + "event": { + "message": { + "message_id": "m2", + "chat_id": "oc_group", + "chat_type": "group", + "message_type": "text", + "content": "{\"text\":\"hello\"}", + "mentions": [{"id": {"open_id": "ou_bot"}}], + }, + "sender": {"sender_id": {"open_id": "ou_user"}}, + } + } + ) + + assert len(sink.messages) == 1 + + asyncio.run(run()) + + +def test_feishu_sends_text_to_chat_id() -> None: + async def run() -> None: + sink = FakeSink() + client = FakeFeishuClient() + adapter = FeishuAdapter( + channel_id="feishu-main", + kind="feishu", + mode="websocket", + account_id="tenant-main", + display_name=None, + inbound_sink=sink, + secrets={"appId": "app", "appSecret": "secret"}, + config={}, + client=client, + ) + await adapter.handle_event_payload( + { + "event": { + "message": { + "message_id": "m1", + "chat_id": "oc_chat", + "chat_type": "p2p", + "message_type": "text", + "content": "{\"text\":\"hello\"}", + }, + "sender": {"sender_id": {"open_id": "ou_user"}}, + } + } + ) + await adapter.send( + OutboundMessage( + channel="feishu-main", + content="ok", + session_id=sink.messages[0].session_id, + finish_reason="stop", + channel_identity=sink.messages[0].channel_identity, + ) + ) + + assert client.sent == [{"receive_id_type": "chat_id", "receive_id": "oc_chat", "text": "ok"}] + + asyncio.run(run()) diff --git a/app-instance/backend/tests/unit/test_gateway_channels.py b/app-instance/backend/tests/unit/test_gateway_channels.py index 2fbc1da..d230581 100644 --- a/app-instance/backend/tests/unit/test_gateway_channels.py +++ b/app-instance/backend/tests/unit/test_gateway_channels.py @@ -2,9 +2,10 @@ import asyncio from dataclasses import dataclass, field from typing import Any -from beaver.foundation.events import InboundMessage, MessageBus +from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage from beaver.interfaces.channels import ChannelManager, MemoryChannelAdapter from beaver.interfaces.gateway.main import run_gateway +from beaver.interfaces.channels.runtime import ChannelRuntime from beaver.services.agent_service import AgentService @@ -52,22 +53,15 @@ class InvalidService: is_running = True -def test_gateway_routes_memory_channel_roundtrip() -> None: +def test_gateway_routes_memory_channel_roundtrip(tmp_path) -> None: async def run() -> None: bus = MessageBus() - channel = MemoryChannelAdapter(bus) - stop_event = asyncio.Event() - task = asyncio.create_task( - run_gateway( - service=FakeService(), - manage_service_lifecycle=False, - bus=bus, - channels=[channel], - stop_event=stop_event, - ) - ) + runtime = ChannelRuntime(service=FakeService(), bus=bus, channels={}, workspace=tmp_path) + channel = MemoryChannelAdapter(runtime) + runtime.manager.register(channel) + await runtime.start() - await channel.publish_text("hello", session_id="s1") + await channel.publish_text("hello", peer_id="s1", message_id="m1") for _ in range(40): if channel.sent_messages: break @@ -76,38 +70,73 @@ def test_gateway_routes_memory_channel_roundtrip() -> None: assert channel.sent_messages message = channel.sent_messages[0] assert message.content == "echo:hello" - assert message.session_id == "s1" + assert message.session_id == "memory-dev:memory:s1" assert message.finish_reason == "stop" assert message.metadata["task_id"] == "task-1" assert message.metadata["task_status"] == "awaiting_acceptance" assert message.metadata["evidence_status"] == "recorded" assert message.metadata["validation_result"] is None - stop_event.set() - await asyncio.wait_for(task, timeout=2) + await runtime.stop() asyncio.run(run()) -def test_gateway_delivers_cancelled_outbound_to_channel() -> None: +def test_channel_manager_dispatches_by_channel_id() -> None: + class CaptureChannel: + channel_id = "webhook-dev" + kind = "webhook" + mode = "webhook" + + def __init__(self) -> None: + self.sent = [] + + async def start(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def send(self, message: Any) -> None: + self.sent.append(message) + async def run() -> None: bus = MessageBus() - channel = MemoryChannelAdapter(bus) - stop_event = asyncio.Event() - task = asyncio.create_task( - run_gateway( - service=SlowService(), - manage_service_lifecycle=False, - bus=bus, - channels=[channel], - stop_event=stop_event, + channel = CaptureChannel() + manager = ChannelManager(bus) + manager.register(channel) + await bus.publish_outbound( + OutboundMessage( + channel="webhook-dev", + content="ok", + session_id="webhook-dev:local:demo", + finish_reason="stop", ) ) - - await channel.publish_text("slow", session_id="s1") - await asyncio.sleep(0.05) + stop_event = asyncio.Event() stop_event.set() - await asyncio.wait_for(task, timeout=3) + + await manager.dispatch_outbound(stop_event) + + assert channel.sent[0].content == "ok" + + asyncio.run(run()) + + +def test_gateway_delivers_cancelled_outbound_to_channel(tmp_path) -> None: + async def run() -> None: + bus = MessageBus() + runtime = ChannelRuntime(service=SlowService(), bus=bus, channels={}, workspace=tmp_path) + channel = MemoryChannelAdapter(runtime) + runtime.manager.register(channel) + await runtime.start() + + await channel.publish_text("slow", peer_id="s1", message_id="m1") + for _ in range(40): + if any(event["kind"] == "direct_run_started" for event in runtime.events.recent(limit=20)): + break + await asyncio.sleep(0.05) + await runtime.stop() assert channel.sent_messages assert channel.sent_messages[0].finish_reason == "cancelled" @@ -118,13 +147,27 @@ def test_gateway_delivers_cancelled_outbound_to_channel() -> None: def test_gateway_rejects_channel_manager_and_channels_together() -> None: async def run() -> None: bus = MessageBus() + class CaptureChannel: + channel_id = "memory-dev" + kind = "memory" + mode = "webhook" + + async def start(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def send(self, message: Any) -> None: + pass + try: await run_gateway( service=FakeService(), manage_service_lifecycle=False, bus=bus, channel_manager=ChannelManager(bus), - channels=[MemoryChannelAdapter(bus)], + channels=[CaptureChannel()], stop_event=asyncio.Event(), ) except ValueError as exc: @@ -212,10 +255,16 @@ def test_channel_manager_keeps_unknown_channel_outbound_undeliverable() -> None: asyncio.run(run()) -def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None: +def test_memory_channel_adapts_payload_to_channel_identity_session_id(tmp_path) -> None: async def run() -> None: bus = MessageBus() - channel = MemoryChannelAdapter(bus, name="telegram") + runtime = ChannelRuntime(service=FakeService(), bus=bus, channels={}, workspace=tmp_path) + channel = MemoryChannelAdapter( + runtime, + channel_id="telegram-main", + kind="telegram", + account_id="bot-main", + ) inbound = await channel.publish_external_text( "hello", chat_id="chat-1", @@ -225,8 +274,10 @@ def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None: queued = await bus.consume_inbound() assert queued is inbound - assert queued.channel == "telegram" - assert queued.session_id == "telegram:chat-1" + assert queued.channel == "telegram-main" + assert queued.session_id == "telegram-main:bot-main:chat-1" + assert queued.channel_identity is not None + assert queued.channel_identity.kind == "telegram" assert queued.metadata["chat_id"] == "chat-1" assert queued.metadata["message_id"] == "message-1" assert queued.metadata["raw_channel_payload"] == {"platform": "telegram", "text": "hello"} @@ -236,7 +287,9 @@ def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None: def test_channel_manager_start_cancellation_rolls_back_started_channels() -> None: class StartedChannel: - name = "started" + channel_id = "started" + kind = "memory" + mode = "webhook" def __init__(self, bus: MessageBus) -> None: self.bus = bus @@ -252,7 +305,9 @@ def test_channel_manager_start_cancellation_rolls_back_started_channels() -> Non pass class BlockingChannel: - name = "blocking" + channel_id = "blocking" + kind = "memory" + mode = "webhook" def __init__(self, bus: MessageBus) -> None: self.bus = bus diff --git a/app-instance/backend/tests/unit/test_imports.py b/app-instance/backend/tests/unit/test_imports.py index b0eef33..9dac5a5 100644 --- a/app-instance/backend/tests/unit/test_imports.py +++ b/app-instance/backend/tests/unit/test_imports.py @@ -6,6 +6,34 @@ from beaver.interfaces.web.app import create_app from beaver.interfaces.web.schemas import WebChatRequest, WebChatResponse +def test_platform_channel_modules_import_without_live_clients() -> None: + from beaver.interfaces.channels.platforms.feishu import FeishuAdapter + from beaver.interfaces.channels.platforms.qqbot import QQBotAdapter + from beaver.interfaces.channels.platforms.telegram import TelegramAdapter + from beaver.interfaces.channels.platforms.weixin import WeixinAdapter + + assert FeishuAdapter.KIND == "feishu" + assert QQBotAdapter.KIND == "qqbot" + assert TelegramAdapter.KIND == "telegram" + assert WeixinAdapter.KIND == "weixin" + + +def test_platform_channel_optional_extras_are_declared() -> None: + import tomllib + from pathlib import Path + + pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml" + data = tomllib.loads(pyproject.read_text(encoding="utf-8")) + extras = data["project"]["optional-dependencies"] + + assert "python-telegram-bot>=22.0,<23.0" in extras["telegram"] + assert "lark-oapi>=1.4.22,<2.0.0" in extras["feishu"] + assert "aiohttp>=3.9.0,<4.0.0" in extras["qqbot"] + assert "aiohttp>=3.9.0,<4.0.0" in extras["weixin"] + assert "python-telegram-bot>=22.0,<23.0" in extras["channels"] + assert "lark-oapi>=1.4.22,<2.0.0" in extras["channels"] + + def test_agent_loop_boots(tmp_path) -> None: loop = AgentLoop(loader=EngineLoader(workspace=tmp_path)) loaded = loop.boot() @@ -32,10 +60,14 @@ def test_message_bus_imports() -> None: def test_channel_imports() -> None: bus = MessageBus() - channel = MemoryChannelAdapter(bus) + class Sink: + async def accept_inbound(self, message): + await bus.publish_inbound(message) + + channel = MemoryChannelAdapter(Sink()) manager = ChannelManager(bus) manager.register(channel) - assert manager.channels["memory"] is channel + assert manager.channels["memory-dev"] is channel def test_web_schema_imports() -> None: diff --git a/app-instance/backend/tests/unit/test_platform_channel_helpers.py b/app-instance/backend/tests/unit/test_platform_channel_helpers.py new file mode 100644 index 0000000..44defce --- /dev/null +++ b/app-instance/backend/tests/unit/test_platform_channel_helpers.py @@ -0,0 +1,66 @@ +from beaver.foundation.events import ChannelIdentity, OutboundMessage +from beaver.interfaces.channels.platforms.base import ( + chunk_text, + compact_media_summary, + config_bool, + config_list, + outbound_target, +) + + +def test_config_helpers_normalize_common_values() -> None: + assert config_bool({"enabled": "true"}, "enabled", default=False) is True + assert config_bool({"enabled": "0"}, "enabled", default=True) is False + assert config_list({"allowFrom": "u1,u2"}, "allowFrom") == ["u1", "u2"] + assert config_list({"allowFrom": ["u1", 2]}, "allowFrom") == ["u1", "2"] + + +def test_chunk_text_preserves_order_and_limit() -> None: + chunks = chunk_text("abcdef", max_chars=2) + + assert chunks == ["ab", "cd", "ef"] + + +def test_outbound_target_prefers_channel_identity() -> None: + identity = ChannelIdentity( + channel_id="telegram-main", + kind="telegram", + account_id="bot-main", + peer_id="chat-1", + thread_id="topic-1", + peer_type="group", + user_id="user-1", + ) + message = OutboundMessage( + channel="telegram-main", + content="ok", + session_id="ignored", + finish_reason="stop", + channel_identity=identity, + ) + + target = outbound_target(message) + + assert target.peer_id == "chat-1" + assert target.thread_id == "topic-1" + assert target.peer_type == "group" + assert target.user_id == "user-1" + + +def test_outbound_target_falls_back_to_session_id() -> None: + message = OutboundMessage( + channel="telegram-main", + content="ok", + session_id="telegram-main:bot-main:chat-1:topic-1", + finish_reason="stop", + ) + + target = outbound_target(message) + + assert target.peer_id == "chat-1" + assert target.thread_id == "topic-1" + + +def test_compact_media_summary_mentions_attachment_type() -> None: + assert compact_media_summary("photo", file_name="cat.png") == "[photo: cat.png]" + assert compact_media_summary("document") == "[document]" diff --git a/app-instance/backend/tests/unit/test_qqbot_channel_adapter.py b/app-instance/backend/tests/unit/test_qqbot_channel_adapter.py new file mode 100644 index 0000000..2eb3455 --- /dev/null +++ b/app-instance/backend/tests/unit/test_qqbot_channel_adapter.py @@ -0,0 +1,143 @@ +import asyncio + +from beaver.foundation.events import OutboundMessage +from beaver.interfaces.channels.platforms.qqbot import QQBotAdapter + + +class FakeSink: + def __init__(self) -> None: + self.messages = [] + + async def accept_inbound(self, message): + self.messages.append(message) + + +class FakeQQBotClient: + def __init__(self) -> None: + self.sent = [] + + async def send_text(self, *, peer_type: str, peer_id: str, content: str, message_id: str | None): + self.sent.append( + { + "peer_type": peer_type, + "peer_id": peer_id, + "content": content, + "message_id": message_id, + } + ) + + +def test_qqbot_normalizes_private_c2c_message() -> None: + async def run() -> None: + sink = FakeSink() + adapter = QQBotAdapter( + channel_id="qq-main", + kind="qqbot", + mode="websocket", + account_id="qq-bot", + display_name=None, + inbound_sink=sink, + secrets={"appId": "app", "clientSecret": "secret"}, + config={}, + client=FakeQQBotClient(), + ) + + await adapter.handle_event_payload( + { + "t": "C2C_MESSAGE_CREATE", + "d": { + "id": "m1", + "author": {"user_openid": "u1"}, + "content": "hello", + }, + } + ) + + message = sink.messages[0] + assert message.content == "hello" + assert message.session_id == "qq-main:qq-bot:u1" + assert message.channel_identity.peer_type == "dm" + assert message.channel_identity.user_id == "u1" + + asyncio.run(run()) + + +def test_qqbot_normalizes_group_message() -> None: + async def run() -> None: + sink = FakeSink() + adapter = QQBotAdapter( + channel_id="qq-main", + kind="qqbot", + mode="websocket", + account_id="qq-bot", + display_name=None, + inbound_sink=sink, + secrets={"appId": "app", "clientSecret": "secret"}, + config={}, + client=FakeQQBotClient(), + ) + + await adapter.handle_event_payload( + { + "t": "GROUP_AT_MESSAGE_CREATE", + "d": { + "id": "m2", + "group_openid": "g1", + "author": {"member_openid": "u1"}, + "content": "hello group", + }, + } + ) + + message = sink.messages[0] + assert message.session_id == "qq-main:qq-bot:g1" + assert message.channel_identity.peer_type == "group" + assert message.channel_identity.user_id == "u1" + + asyncio.run(run()) + + +def test_qqbot_sends_reply_with_original_message_id() -> None: + async def run() -> None: + sink = FakeSink() + client = FakeQQBotClient() + adapter = QQBotAdapter( + channel_id="qq-main", + kind="qqbot", + mode="websocket", + account_id="qq-bot", + display_name=None, + inbound_sink=sink, + secrets={"appId": "app", "clientSecret": "secret"}, + config={}, + client=client, + ) + await adapter.handle_event_payload( + { + "t": "GROUP_AT_MESSAGE_CREATE", + "d": { + "id": "m2", + "group_openid": "g1", + "author": {"member_openid": "u1"}, + "content": "hello group", + }, + } + ) + await adapter.send( + OutboundMessage( + channel="qq-main", + content="ok", + session_id=sink.messages[0].session_id, + finish_reason="stop", + channel_identity=sink.messages[0].channel_identity, + ) + ) + + assert client.sent[0] == { + "peer_type": "group", + "peer_id": "g1", + "content": "ok", + "message_id": "m2", + } + + asyncio.run(run()) diff --git a/app-instance/backend/tests/unit/test_telegram_channel_adapter.py b/app-instance/backend/tests/unit/test_telegram_channel_adapter.py new file mode 100644 index 0000000..65f2f10 --- /dev/null +++ b/app-instance/backend/tests/unit/test_telegram_channel_adapter.py @@ -0,0 +1,141 @@ +import asyncio + +from beaver.foundation.events import OutboundMessage +from beaver.interfaces.channels.platforms.telegram import TelegramAdapter + + +class FakeSink: + def __init__(self) -> None: + self.messages = [] + + async def accept_inbound(self, message): + self.messages.append(message) + + +class FakeTelegramClient: + def __init__(self) -> None: + self.sent = [] + + async def send_message(self, **kwargs): + self.sent.append(kwargs) + + +def test_telegram_normalizes_private_text_message() -> None: + async def run() -> None: + sink = FakeSink() + adapter = TelegramAdapter( + channel_id="telegram-main", + kind="telegram", + mode="polling", + account_id="bot-main", + display_name=None, + inbound_sink=sink, + secrets={"botToken": "x"}, + config={}, + client=FakeTelegramClient(), + ) + + await adapter.handle_update_payload( + { + "message": { + "message_id": 100, + "text": "hello", + "chat": {"id": 200, "type": "private"}, + "from": {"id": 300, "username": "ivan"}, + } + } + ) + + message = sink.messages[0] + assert message.channel == "telegram-main" + assert message.content == "hello" + assert message.session_id == "telegram-main:bot-main:200" + assert message.channel_identity.peer_type == "dm" + assert message.channel_identity.user_id == "300" + assert message.channel_identity.message_id == "100" + + asyncio.run(run()) + + +def test_telegram_group_requires_mention_when_configured() -> None: + async def run() -> None: + sink = FakeSink() + adapter = TelegramAdapter( + channel_id="telegram-main", + kind="telegram", + mode="polling", + account_id="bot-main", + display_name=None, + inbound_sink=sink, + secrets={"botToken": "x"}, + config={"requireMentionInGroups": True, "botUsername": "beaver_bot"}, + client=FakeTelegramClient(), + ) + + await adapter.handle_update_payload( + { + "message": { + "message_id": 101, + "text": "hello group", + "chat": {"id": -20, "type": "group"}, + "from": {"id": 300}, + } + } + ) + await adapter.handle_update_payload( + { + "message": { + "message_id": 102, + "text": "@beaver_bot hello", + "chat": {"id": -20, "type": "group"}, + "from": {"id": 300}, + } + } + ) + + assert len(sink.messages) == 1 + assert sink.messages[0].content == "hello" + + asyncio.run(run()) + + +def test_telegram_sends_chunked_reply_to_identity_target() -> None: + async def run() -> None: + sink = FakeSink() + client = FakeTelegramClient() + adapter = TelegramAdapter( + channel_id="telegram-main", + kind="telegram", + mode="polling", + account_id="bot-main", + display_name=None, + inbound_sink=sink, + secrets={"botToken": "x"}, + config={"maxMessageChars": 3}, + client=client, + ) + await adapter.handle_update_payload( + { + "message": { + "message_id": 100, + "text": "hello", + "chat": {"id": 200, "type": "private"}, + "from": {"id": 300}, + } + } + ) + + await adapter.send( + OutboundMessage( + channel="telegram-main", + content="abcdef", + session_id=sink.messages[0].session_id, + finish_reason="stop", + channel_identity=sink.messages[0].channel_identity, + ) + ) + + assert [item["text"] for item in client.sent] == ["abc", "def"] + assert client.sent[0]["chat_id"] == "200" + + asyncio.run(run()) diff --git a/app-instance/backend/tests/unit/test_telegram_channel_connector.py b/app-instance/backend/tests/unit/test_telegram_channel_connector.py new file mode 100644 index 0000000..ed0e822 --- /dev/null +++ b/app-instance/backend/tests/unit/test_telegram_channel_connector.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import asyncio + +from beaver.interfaces.channels.connections import ( + ChannelConnectionStore, + CredentialStore, + TelegramConnector, +) + + +class FakeTelegramClient: + async def get_me(self): + return {"id": 12345, "username": "beaver_bot", "first_name": "Beaver"} + + +class BrokenTelegramClient: + async def get_me(self): + raise RuntimeError("invalid token") + + +def test_telegram_connector_validates_token_and_updates_connection(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"}) + connection = connection_store.create( + kind="telegram", + mode="polling", + display_name="Telegram Main", + account_id="", + owner_user_id="user-1", + auth_type="token", + credentials_ref=credentials_ref, + runtime_config={"max_message_chars": 4096}, + ) + connector = TelegramConnector( + connection_store=connection_store, + credential_store=credential_store, + client_factory=lambda token: FakeTelegramClient(), + ) + + result = await connector.validate(connection.connection_id) + updated = connection_store.get(connection.connection_id) + + assert result.ok is True + assert result.status == "connected" + assert result.account_id == "telegram:12345" + assert updated.account_id == "telegram:12345" + assert updated.display_name == "Beaver (@beaver_bot)" + assert updated.capabilities == ["receive_text", "send_text", "receive_media", "groups"] + + asyncio.run(run()) + + +def test_telegram_connector_materializes_runtime_spec(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"}) + connection = connection_store.create( + kind="telegram", + mode="polling", + display_name="Telegram Main", + account_id="telegram:12345", + owner_user_id=None, + auth_type="token", + credentials_ref=credentials_ref, + runtime_config={"max_message_chars": 4096, "require_mention_in_groups": True}, + ) + connection_store.update_status(connection.connection_id, status="connected", last_error=None) + connector = TelegramConnector( + connection_store=connection_store, + credential_store=credential_store, + client_factory=lambda token: FakeTelegramClient(), + ) + + spec = await connector.materialize_runtime(connection.connection_id) + + assert spec.channel_id == connection.channel_id + assert spec.kind == "telegram" + assert spec.mode == "polling" + assert spec.account_id == "telegram:12345" + assert spec.config["max_message_chars"] == 4096 + assert spec.config["require_mention_in_groups"] is True + assert spec.secrets_ref == credentials_ref + + asyncio.run(run()) + + +def test_telegram_connector_validation_failure_sets_error_status(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + credentials_ref = credential_store.put(kind="telegram", values={"botToken": "bad-token"}) + connection = connection_store.create( + kind="telegram", + mode="polling", + display_name="Telegram Main", + account_id="", + owner_user_id=None, + auth_type="token", + credentials_ref=credentials_ref, + ) + connector = TelegramConnector( + connection_store=connection_store, + credential_store=credential_store, + client_factory=lambda token: BrokenTelegramClient(), + ) + + result = await connector.validate(connection.connection_id) + + assert result.ok is False + assert result.status == "error" + assert "invalid token" in (result.error or "") + + asyncio.run(run()) + + +def test_telegram_connector_revoke_leaves_store_status_to_registry(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + connection = connection_store.create( + kind="telegram", + mode="polling", + display_name="Telegram Main", + account_id="telegram:12345", + owner_user_id=None, + auth_type="token", + ) + connection_store.update_status(connection.connection_id, status="connected", last_error=None) + connector = TelegramConnector( + connection_store=connection_store, + credential_store=credential_store, + client_factory=lambda token: FakeTelegramClient(), + ) + + await connector.revoke(connection.connection_id) + + assert connection_store.get(connection.connection_id).status == "connected" + + asyncio.run(run()) diff --git a/app-instance/backend/tests/unit/test_terminal_websocket_channel.py b/app-instance/backend/tests/unit/test_terminal_websocket_channel.py new file mode 100644 index 0000000..0246805 --- /dev/null +++ b/app-instance/backend/tests/unit/test_terminal_websocket_channel.py @@ -0,0 +1,243 @@ +import asyncio +import json +import time +from pathlib import Path + +from fastapi.testclient import TestClient + +from beaver.foundation.events import InboundMessage, OutboundMessage +from beaver.interfaces.web.app import create_app +from beaver.services.agent_service import AgentService + + +class TerminalFakeAgentService(AgentService): + def __init__(self, *, config_path: Path, delay_seconds: float = 0.0) -> None: + super().__init__(config_path=config_path) + self.delay_seconds = delay_seconds + self.inbound_calls: list[InboundMessage] = [] + + async def handle_inbound_message(self, inbound: InboundMessage) -> OutboundMessage: + self.inbound_calls.append(inbound) + if self.delay_seconds: + await asyncio.sleep(self.delay_seconds) + return OutboundMessage( + message_id=inbound.message_id, + channel=inbound.channel, + content=f"echo:{inbound.content}", + session_id=inbound.session_id, + finish_reason="stop", + run_id="run-1", + channel_identity=inbound.channel_identity, + ) + + +def write_terminal_config(tmp_path: Path) -> Path: + workspace = tmp_path / "workspace" + workspace.mkdir() + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "agents": {"defaults": {"workspace": str(workspace), "model": "openai/gpt-5"}}, + "providers": {}, + "channels": { + "terminal-dev": { + "enabled": True, + "kind": "terminal", + "mode": "websocket", + "accountId": "local", + "displayName": "Terminal Dev", + "config": {"heartbeatSeconds": 30, "maxMessageChars": 20000}, + } + }, + } + ), + encoding="utf-8", + ) + return config_path + + +def test_terminal_websocket_connect_ping_and_message_roundtrip(tmp_path: Path) -> None: + config_path = write_terminal_config(tmp_path) + service = TerminalFakeAgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket: + websocket.send_json( + { + "type": "connect", + "peer_id": "device-001", + "device_name": "desk-terminal", + "capabilities": ["text"], + } + ) + assert websocket.receive_json() == { + "type": "connected", + "channel_id": "terminal-dev", + "session_id": "terminal-dev:local:device-001", + } + + websocket.send_json({"type": "ping"}) + assert websocket.receive_json() == {"type": "pong"} + + websocket.send_json( + { + "type": "message", + "message_id": "device-001-000001", + "text": "hello", + } + ) + assert websocket.receive_json() == { + "type": "ack", + "message_id": "device-001-000001", + "session_id": "terminal-dev:local:device-001", + "accepted": True, + } + reply = websocket.receive_json() + + service.close() + assert reply == { + "type": "message", + "role": "assistant", + "message_id": "device-001-000001", + "run_id": "run-1", + "text": "echo:hello", + "finish_reason": "stop", + } + assert len(service.inbound_calls) == 1 + inbound = service.inbound_calls[0] + assert inbound.channel == "terminal-dev" + assert inbound.content == "hello" + assert inbound.content_type == "text" + assert inbound.session_id == "terminal-dev:local:device-001" + assert inbound.channel_identity is not None + assert inbound.channel_identity.peer_id == "device-001" + assert inbound.channel_identity.peer_type == "terminal" + assert inbound.channel_identity.message_id == "device-001-000001" + + +def test_terminal_websocket_rejects_message_before_connect(tmp_path: Path) -> None: + config_path = write_terminal_config(tmp_path) + service = TerminalFakeAgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket: + websocket.send_json({"type": "message", "message_id": "m1", "text": "hello"}) + assert websocket.receive_json() == { + "type": "error", + "error": "connect is required before message", + } + websocket.send_json({"type": "ping"}) + assert websocket.receive_json() == {"type": "pong"} + + service.close() + assert service.inbound_calls == [] + + +def test_terminal_websocket_unknown_frame_keeps_connection_open(tmp_path: Path) -> None: + config_path = write_terminal_config(tmp_path) + service = TerminalFakeAgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket: + websocket.send_json({"type": "example"}) + assert websocket.receive_json() == { + "type": "error", + "error": "Unsupported websocket frame type: example", + } + websocket.send_json({"type": "ping"}) + assert websocket.receive_json() == {"type": "pong"} + + service.close() + + +def test_terminal_websocket_validates_message_fields(tmp_path: Path) -> None: + config_path = write_terminal_config(tmp_path) + service = TerminalFakeAgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket: + websocket.send_json({"type": "connect", "peer_id": "device-001"}) + assert websocket.receive_json()["type"] == "connected" + + websocket.send_json({"type": "message", "text": "hello"}) + assert websocket.receive_json() == {"type": "error", "error": "message_id is required"} + + websocket.send_json({"type": "message", "message_id": "m1", "text": " "}) + assert websocket.receive_json() == {"type": "error", "error": "text is required"} + + service.close() + assert service.inbound_calls == [] + + +def test_terminal_websocket_duplicate_message_returns_cached_reply(tmp_path: Path) -> None: + config_path = write_terminal_config(tmp_path) + service = TerminalFakeAgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket: + websocket.send_json({"type": "connect", "peer_id": "device-001"}) + assert websocket.receive_json()["type"] == "connected" + + frame = {"type": "message", "message_id": "device-001-000001", "text": "hello"} + websocket.send_json(frame) + assert websocket.receive_json()["accepted"] is True + assert websocket.receive_json()["text"] == "echo:hello" + + websocket.send_json(frame) + duplicate = websocket.receive_json() + + service.close() + assert duplicate["type"] == "ack" + assert duplicate["accepted"] is False + assert duplicate["duplicate"] is True + assert duplicate["pending"] is False + assert duplicate["reply"] == "echo:hello" + assert len(service.inbound_calls) == 1 + + +def test_terminal_websocket_disconnect_before_reply_records_unclaimed(tmp_path: Path) -> None: + config_path = write_terminal_config(tmp_path) + service = TerminalFakeAgentService(config_path=config_path, delay_seconds=0.05) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket: + websocket.send_json({"type": "connect", "peer_id": "device-001"}) + assert websocket.receive_json()["type"] == "connected" + websocket.send_json({"type": "message", "message_id": "device-001-000001", "text": "slow"}) + assert websocket.receive_json()["accepted"] is True + + time.sleep(0.15) + events = client.get("/api/channels/terminal-dev/events").json() + + service.close() + kinds = [event["kind"] for event in events] + assert "terminal_disconnected" in kinds + assert "outbound_unclaimed" in kinds + + +def test_terminal_channel_status_exposes_websocket_url_and_peer_count(tmp_path: Path) -> None: + config_path = write_terminal_config(tmp_path) + service = TerminalFakeAgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + initial = client.get("/api/status").json()["channels"][0] + assert initial["channel_id"] == "terminal-dev" + assert initial["websocket_url"] == "/api/channels/terminal-dev/ws" + assert initial["connected_peers"] == 0 + assert "persistent_connection" in initial["capabilities"] + + with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket: + websocket.send_json({"type": "connect", "peer_id": "device-001"}) + assert websocket.receive_json()["type"] == "connected" + connected = client.get("/api/status").json()["channels"][0] + assert connected["connected_peers"] == 1 + + service.close() diff --git a/app-instance/backend/tests/unit/test_weixin_channel_adapter.py b/app-instance/backend/tests/unit/test_weixin_channel_adapter.py new file mode 100644 index 0000000..9edbdb5 --- /dev/null +++ b/app-instance/backend/tests/unit/test_weixin_channel_adapter.py @@ -0,0 +1,129 @@ +import asyncio + +from beaver.foundation.events import OutboundMessage +from beaver.interfaces.channels.platforms.weixin import WeixinAdapter + + +class FakeSink: + def __init__(self) -> None: + self.messages = [] + + async def accept_inbound(self, message): + self.messages.append(message) + + +class FakeWeixinClient: + def __init__(self) -> None: + self.sent = [] + + async def send_text(self, *, peer_id: str, text: str, context_token: str | None): + self.sent.append({"peer_id": peer_id, "text": text, "context_token": context_token}) + + +def test_weixin_normalizes_direct_text_message() -> None: + async def run() -> None: + sink = FakeSink() + adapter = WeixinAdapter( + channel_id="weixin-main", + kind="weixin", + mode="polling", + account_id="wx-main", + display_name=None, + inbound_sink=sink, + secrets={"token": "token"}, + config={}, + client=FakeWeixinClient(), + ) + + await adapter.handle_message_payload( + { + "id": "m1", + "from": "wx_user", + "room_id": "", + "type": "text", + "text": "hello", + "context_token": "ctx1", + } + ) + + message = sink.messages[0] + assert message.content == "hello" + assert message.session_id == "weixin-main:wx-main:wx_user" + assert message.channel_identity.peer_type == "dm" + assert message.metadata["context_token"] == "ctx1" + + asyncio.run(run()) + + +def test_weixin_group_message_is_best_effort() -> None: + async def run() -> None: + sink = FakeSink() + adapter = WeixinAdapter( + channel_id="weixin-main", + kind="weixin", + mode="polling", + account_id="wx-main", + display_name=None, + inbound_sink=sink, + secrets={"token": "token"}, + config={"groupPolicy": "open"}, + client=FakeWeixinClient(), + ) + + await adapter.handle_message_payload( + { + "id": "m2", + "from": "wx_user", + "room_id": "room1", + "type": "text", + "text": "hello room", + "context_token": "ctx2", + } + ) + + message = sink.messages[0] + assert message.session_id == "weixin-main:wx-main:room1" + assert message.channel_identity.peer_type == "group" + assert message.channel_identity.user_id == "wx_user" + + asyncio.run(run()) + + +def test_weixin_sends_text_with_context_token() -> None: + async def run() -> None: + sink = FakeSink() + client = FakeWeixinClient() + adapter = WeixinAdapter( + channel_id="weixin-main", + kind="weixin", + mode="polling", + account_id="wx-main", + display_name=None, + inbound_sink=sink, + secrets={"token": "token"}, + config={}, + client=client, + ) + await adapter.handle_message_payload( + { + "id": "m1", + "from": "wx_user", + "type": "text", + "text": "hello", + "context_token": "ctx1", + } + ) + await adapter.send( + OutboundMessage( + channel="weixin-main", + content="ok", + session_id=sink.messages[0].session_id, + finish_reason="stop", + channel_identity=sink.messages[0].channel_identity, + metadata={"inbound_metadata": sink.messages[0].metadata}, + ) + ) + + assert client.sent == [{"peer_id": "wx_user", "text": "ok", "context_token": "ctx1"}] + + asyncio.run(run()) diff --git a/app-instance/backend/uv.lock b/app-instance/backend/uv.lock index 43b08d5..8997993 100644 --- a/app-instance/backend/uv.lock +++ b/app-instance/backend/uv.lock @@ -252,27 +252,51 @@ dependencies = [ ] [package.optional-dependencies] +channels = [ + { name = "aiohttp" }, + { name = "lark-oapi" }, + { name = "python-telegram-bot" }, +] dev = [ { name = "pytest" }, ] +feishu = [ + { name = "lark-oapi" }, +] +qqbot = [ + { name = "aiohttp" }, +] +telegram = [ + { name = "python-telegram-bot" }, +] +weixin = [ + { name = "aiohttp" }, +] [package.metadata] requires-dist = [ + { name = "aiohttp", marker = "extra == 'channels'", specifier = ">=3.9.0,<4.0.0" }, + { name = "aiohttp", marker = "extra == 'qqbot'", specifier = ">=3.9.0,<4.0.0" }, + { name = "aiohttp", marker = "extra == 'weixin'", specifier = ">=3.9.0,<4.0.0" }, { name = "anthropic", specifier = ">=0.51.0,<1.0.0" }, { name = "croniter", specifier = ">=6.0.0,<7.0.0" }, { name = "fastapi", specifier = ">=0.115.0,<1.0.0" }, { name = "fastmcp", specifier = ">=3.0.0,<4.0.0" }, { name = "httpx", specifier = ">=0.28.0,<1.0.0" }, { name = "json-repair", specifier = ">=0.39.0,<1.0.0" }, + { name = "lark-oapi", marker = "extra == 'channels'", specifier = ">=1.4.22,<2.0.0" }, + { name = "lark-oapi", marker = "extra == 'feishu'", specifier = ">=1.4.22,<2.0.0" }, { name = "litellm", specifier = ">=1.79.0,<2.0.0" }, { name = "openai", specifier = ">=1.79.0,<2.0.0" }, { name = "pydantic", specifier = ">=2.12.0,<3.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" }, { name = "python-multipart", specifier = ">=0.0.20,<1.0.0" }, + { name = "python-telegram-bot", marker = "extra == 'channels'", specifier = ">=22.0,<23.0" }, + { name = "python-telegram-bot", marker = "extra == 'telegram'", specifier = ">=22.0,<23.0" }, { name = "typer", specifier = ">=0.20.0,<1.0.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0,<1.0.0" }, ] -provides-extras = ["dev"] +provides-extras = ["dev", "telegram", "feishu", "qqbot", "weixin", "channels"] [[package]] name = "cachetools" @@ -1277,6 +1301,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] +[[package]] +name = "lark-oapi" +version = "1.6.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pycryptodome" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "websockets" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/54/a3b649b83299606aa7ebfd2391663fde650e934421dfba37af171bfbf456/lark_oapi-1.6.7-py3-none-any.whl", hash = "sha256:df1d44891d266f5c063daa1d37ae6f72c7f166bdc2fb01e607088410e952b92c", size = 7146261, upload-time = "2026-05-28T03:32:21.268Z" }, +] + [[package]] name = "litellm" version = "1.80.0" @@ -1759,6 +1798,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, +] + [[package]] name = "pydantic" version = "2.13.3" @@ -1973,6 +2042,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, ] +[[package]] +name = "python-telegram-bot" +version = "22.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpcore", marker = "python_full_version >= '3.14'" }, + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/25/2258161b1069e66d6c39c0a602dbe57461d4767dc0012539970ea40bc9d6/python_telegram_bot-22.7.tar.gz", hash = "sha256:784b59ea3852fe4616ad63b4a0264c755637f5d725e87755ecdee28300febf61", size = 1516454, upload-time = "2026-03-16T09:36:03.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/f7/0e2f89dd62f45d46d4ea0d8aec5893ce5b37389638db010c117f46f11450/python_telegram_bot-22.7-py3-none-any.whl", hash = "sha256:d72eed532cf763758cd9331b57a6d790aff0bb4d37d8f4e92149436fe21c6475", size = 745365, upload-time = "2026-03-16T09:36:01.498Z" }, +] + [[package]] name = "pywin32" version = "311" @@ -2189,6 +2271,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -2687,61 +2781,44 @@ wheels = [ [[package]] name = "websockets" -version = "16.0" +version = "15.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, - { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, - { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, - { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, - { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, - { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, - { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, - { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] [[package]] diff --git a/app-instance/create-instance.sh b/app-instance/create-instance.sh index d2a40f1..872af2b 100755 --- a/app-instance/create-instance.sh +++ b/app-instance/create-instance.sh @@ -37,6 +37,8 @@ INSTANCES_ROOT="${INSTANCES_ROOT:-$INSTANCES_ROOT_DEFAULT}" REGISTRY_PATH="${REGISTRY_PATH:-$REGISTRY_PATH_DEFAULT}" NETWORK_NAME="${NETWORK_NAME:-}" HOST_BIND_IP="${HOST_BIND_IP:-127.0.0.1}" +INITIAL_SKILLS_DIR="${INITIAL_SKILLS_DIR:-${SCRIPT_DIR}/../skills}" +SEED_INITIAL_SKILLS=1 FORCE_BUILD=0 REPLACE=0 @@ -78,6 +80,9 @@ Optional: --registry Registry JSON path. Default: ./runtime/registry/instances.json --network Optional docker network name. --host-bind-ip Host bind IP for published port. Default: 127.0.0.1 + --initial-skills-dir Directory copied into workspace/skills on first create. + Default: ../skills + --skip-initial-skills Do not seed initial workspace skills. --build Force rebuild image before running. --replace Remove existing container with same name before running. --help Show this help. @@ -225,6 +230,69 @@ data = { "name": os.environ["BACKEND_NAME"].strip(), "publicBaseUrl": os.environ["PUBLIC_URL"].strip(), }, + "channels": { + "telegram-main": { + "enabled": False, + "kind": "telegram", + "mode": "polling", + "accountId": "bot-main", + "displayName": "Telegram Main", + "secrets": { + "botToken": "", + }, + "config": { + "requireMentionInGroups": True, + "maxMessageChars": 4096, + }, + }, + "feishu-main": { + "enabled": False, + "kind": "feishu", + "mode": "websocket", + "accountId": "tenant-main", + "displayName": "Feishu Main", + "secrets": { + "appId": "", + "appSecret": "", + }, + "config": { + "domain": "feishu", + "connectionMode": "websocket", + "requireMentionInGroups": True, + }, + }, + "qqbot-main": { + "enabled": False, + "kind": "qqbot", + "mode": "websocket", + "accountId": "qqbot-main", + "displayName": "QQ Bot Main", + "secrets": { + "appId": "", + "clientSecret": "", + }, + "config": { + "dmPolicy": "open", + "groupPolicy": "allowlist", + "markdownSupport": False, + }, + }, + "weixin-main": { + "enabled": False, + "kind": "weixin", + "mode": "polling", + "accountId": "wx-main", + "displayName": "Weixin Main", + "secrets": { + "token": "", + }, + "config": { + "dmPolicy": "open", + "groupPolicy": "disabled", + "textBatchDelaySeconds": 0.5, + }, + }, + }, } target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") @@ -255,6 +323,66 @@ target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encodin PY } +seed_initial_skills() { + local workspace_path="$1" + local initial_skills_dir="$2" + local target_dir="${workspace_path}/skills" + + if [[ "$SEED_INITIAL_SKILLS" -ne 1 ]]; then + return + fi + if [[ ! -d "$initial_skills_dir" ]]; then + log "initial skills directory not found, skipping: ${initial_skills_dir}" + return + fi + + mkdir -p "$target_dir" + INITIAL_SKILLS_DIR="$initial_skills_dir" TARGET_DIR="$target_dir" python3 - <<'PY' +import json +import shutil +import os +from pathlib import Path + +initial = Path(os.environ["INITIAL_SKILLS_DIR"]).resolve() +target = Path(os.environ["TARGET_DIR"]).resolve() + +for child in sorted(initial.iterdir()): + if child.name.startswith("."): + continue + destination = target / child.name + if destination.exists(): + continue + if child.is_dir(): + shutil.copytree(child, destination) + elif child.is_file(): + shutil.copy2(child, destination) + +for index_name in ("published", "disabled"): + initial_index = initial / "_index" / f"{index_name}.json" + target_index = target / "_index" / f"{index_name}.json" + if not initial_index.exists(): + continue + try: + initial_items = json.loads(initial_index.read_text(encoding="utf-8")).get("items", []) + except json.JSONDecodeError: + initial_items = [] + if target_index.exists(): + try: + target_items = json.loads(target_index.read_text(encoding="utf-8")).get("items", []) + except json.JSONDecodeError: + target_items = [] + else: + target_items = [] + merged = [] + for item in [*target_items, *initial_items]: + text = str(item).strip() + if text and text not in merged: + merged.append(text) + target_index.parent.mkdir(parents=True, exist_ok=True) + target_index.write_text(json.dumps({"items": merged}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") +PY +} + render_runtime_env_file() { local target_path="$1" @@ -428,6 +556,14 @@ while [[ $# -gt 0 ]]; do HOST_BIND_IP="${2:-}" shift 2 ;; + --initial-skills-dir) + INITIAL_SKILLS_DIR="${2:-}" + shift 2 + ;; + --skip-initial-skills) + SEED_INITIAL_SKILLS=0 + shift + ;; --build) FORCE_BUILD=1 shift @@ -531,6 +667,7 @@ mkdir -p "$BEAVER_HOME" "$WORKSPACE_PATH" render_config_json "$CONFIG_PATH" render_auth_users_json "$AUTH_USERS_PATH" render_runtime_env_file "$RUNTIME_ENV_PATH" +seed_initial_skills "$WORKSPACE_PATH" "$INITIAL_SKILLS_DIR" if [[ "$FORCE_BUILD" -eq 1 ]] || ! image_exists; then log "building image ${IMAGE_NAME}" @@ -564,6 +701,7 @@ RUN_ARGS=( -e "APP_PUBLIC_PORT=8080" -e "APP_FRONTEND_PORT=3000" -e "APP_BACKEND_PORT=18080" + -e "BEAVER_ENABLE_SELF_RESTART=1" -e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}" --label "beaver.instance.id=${INSTANCE_ID}" --label "beaver.instance.slug=${INSTANCE_SLUG}" diff --git a/app-instance/frontend/app/(app)/logs/page.tsx b/app-instance/frontend/app/(app)/logs/page.tsx index 281d953..fca55af 100644 --- a/app-instance/frontend/app/(app)/logs/page.tsx +++ b/app-instance/frontend/app/(app)/logs/page.tsx @@ -10,6 +10,7 @@ import type { ChatLogEvent, ChatLogSession } from '@/types'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; +import { containedJsonTextClass } from '@/lib/text-wrapping'; function eventLabel(event: ChatLogEvent): string { return event.event_type || event.role || 'event'; @@ -175,7 +176,7 @@ export default function LogsPage() { return (
@@ -188,7 +189,7 @@ export default function LogsPage() {
{timestampLabel(event.timestamp)}
-
+                        
                           {body || formatPayload(event)}
                         
diff --git a/app-instance/frontend/app/(app)/page.tsx b/app-instance/frontend/app/(app)/page.tsx index 5fbda45..169c869 100644 --- a/app-instance/frontend/app/(app)/page.tsx +++ b/app-instance/frontend/app/(app)/page.tsx @@ -19,7 +19,12 @@ import { uploadFile, wsManager, } from '@/lib/api'; -import { mergeServerWithPendingUsers, shouldDisplayChatMessage, shouldMergePendingUsers } from '@/lib/chat-messages'; +import { + getSessionRefreshIntervalMs, + mergeServerWithPendingUsers, + shouldDisplayChatMessage, + shouldMergePendingUsers, +} from '@/lib/chat-messages'; import { pickAppText } from '@/lib/i18n/core'; import { useAppI18n } from '@/lib/i18n/provider'; import { buildSessionProgressView } from '@/lib/session-progress'; @@ -47,6 +52,10 @@ function loadThinkingModePreference(): boolean { return stored == null ? false : stored !== 'false'; } +function isDocumentHidden(): boolean { + return typeof document !== 'undefined' && document.visibilityState === 'hidden'; +} + export default function ChatPage() { const { locale } = useAppI18n(); const { @@ -78,6 +87,7 @@ export default function ChatPage() { const [pendingFiles, setPendingFiles] = useState>([]); const [activeTask, setActiveTask] = useState(null); const [revisionTargetRunId, setRevisionTargetRunId] = useState(null); + const [documentHidden, setDocumentHidden] = useState(isDocumentHidden); const messagesEndRef = useRef(null); const messageViewportRef = useRef(null); const textareaRef = useRef(null); @@ -247,14 +257,26 @@ export default function ChatPage() { }, [addMessage, loadActiveTask, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]); useEffect(() => { - if (!isLoading && !isThinking) { + const intervalMs = getSessionRefreshIntervalMs({ isLoading, isThinking, documentHidden }); + if (intervalMs == null) { return; } const timer = setInterval(() => { - loadSessionMessages(useChatStore.getState().sessionId); - }, 1500); + const currentSessionId = useChatStore.getState().sessionId; + void loadSessionMessages(currentSessionId); + void loadSessions(); + }, intervalMs); return () => clearInterval(timer); - }, [isLoading, isThinking, loadSessionMessages]); + }, [documentHidden, isLoading, isThinking, loadSessionMessages, loadSessions]); + + useEffect(() => { + if (typeof document === 'undefined') { + return; + } + const updateVisibility = () => setDocumentHidden(isDocumentHidden()); + document.addEventListener('visibilitychange', updateVisibility); + return () => document.removeEventListener('visibilitychange', updateVisibility); + }, []); const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior) => { const viewport = messageViewportRef.current; diff --git a/app-instance/frontend/app/(app)/skills/page.tsx b/app-instance/frontend/app/(app)/skills/page.tsx index ba9cb04..caaaecc 100644 --- a/app-instance/frontend/app/(app)/skills/page.tsx +++ b/app-instance/frontend/app/(app)/skills/page.tsx @@ -73,6 +73,7 @@ import type { } from '@/types'; import { pickAppText } from '@/lib/i18n/core'; import { useAppI18n } from '@/lib/i18n/provider'; +import { containedJsonTextClass, containedLongTextClass } from '@/lib/text-wrapping'; const TERMINAL_DRAFT_STATUSES = new Set(['rejected', 'published', 'disabled', 'archived']); const REJECTABLE_DRAFT_STATUSES = new Set(['draft', 'in_review', 'approved']); @@ -1094,7 +1095,7 @@ function ReadableFact({ {icon} {label} -
{value || '-'}
+
{value || '-'}
); } @@ -1119,12 +1120,12 @@ function MetricTile({ function RawDetails({ title, payload }: { title: string; payload: unknown }) { return ( -
+
{title} -
+      
         {JSON.stringify(payload, null, 2)}
       
diff --git a/app-instance/frontend/app/(app)/status/page.tsx b/app-instance/frontend/app/(app)/status/page.tsx index 9aae720..7b57a88 100644 --- a/app-instance/frontend/app/(app)/status/page.tsx +++ b/app-instance/frontend/app/(app)/status/page.tsx @@ -14,8 +14,21 @@ import { Loader2, Settings2, ScrollText, + QrCode, + PlugZap, } from 'lucide-react'; -import { getStatus, updateAgentConfig, updateProviderConfig } from '@/lib/api'; +import { + getChannelConfig, + getChannelConnectorSession, + getStatus, + listChannelConnectors, + listChannelEvents, + restartRuntime, + startChannelConnectorSession, + updateAgentConfig, + updateChannelConfig, + updateProviderConfig, +} from '@/lib/api'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; @@ -29,9 +42,25 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; -import type { ProviderStatus, SystemStatus } from '@/types'; -import { pickAppText } from '@/lib/i18n/core'; +import { Textarea } from '@/components/ui/textarea'; +import type { + ChannelConfigDetail, + ChannelConnectorDescriptor, + ChannelEventRecord, + ChannelStatus, + ConnectorSessionResponse, + ProviderStatus, + SystemStatus, +} from '@/types'; +import { AppLocale, pickAppText } from '@/lib/i18n/core'; import { useAppI18n } from '@/lib/i18n/provider'; type ProviderFormState = { @@ -48,6 +77,87 @@ type AgentFormState = { maxToolIterations: string; }; +type ChannelFormState = { + enabled: boolean; + kind: string; + mode: string; + accountId: string; + displayName: string; + botToken: string; + appId: string; + appSecret: string; + clientSecret: string; + token: string; + domain: string; + connectionMode: string; + botUsername: string; + botOpenId: string; + webhookUrl: string; + webhookSecret: string; + requireMentionInGroups: boolean; + allowFrom: string; + groupAllowFrom: string; + dmPolicy: string; + groupPolicy: string; + markdownSupport: boolean; + baseUrl: string; + cdnBaseUrl: string; + maxMessageChars: string; + textBatchDelaySeconds: string; +}; + +const EMPTY_CHANNEL_FORM: ChannelFormState = { + enabled: false, + kind: 'telegram', + mode: 'polling', + accountId: '', + displayName: '', + botToken: '', + appId: '', + appSecret: '', + clientSecret: '', + token: '', + domain: 'feishu', + connectionMode: 'websocket', + botUsername: '', + botOpenId: '', + webhookUrl: '', + webhookSecret: '', + requireMentionInGroups: true, + allowFrom: '', + groupAllowFrom: '', + dmPolicy: 'open', + groupPolicy: 'allowlist', + markdownSupport: false, + baseUrl: '', + cdnBaseUrl: '', + maxMessageChars: '', + textBatchDelaySeconds: '', +}; + +const CONFIGURABLE_CHANNEL_KINDS = new Set(['telegram', 'feishu', 'qqbot', 'weixin']); +const SESSION_CONNECTOR_KINDS = new Set(['weixin', 'feishu']); + +type ConnectorWizardForm = { + kind: string; + displayName: string; + domain: string; + mode: 'create' | 'link'; + appId: string; + appSecret: string; + verificationToken: string; +}; + +const EMPTY_CONNECTOR_WIZARD: ConnectorWizardForm = { + kind: '', + displayName: '', + domain: 'feishu', + mode: 'create', + appId: '', + appSecret: '', + verificationToken: '', +}; + export default function StatusPage() { const { locale } = useAppI18n(); const [status, setStatus] = useState(null); @@ -70,6 +180,26 @@ export default function StatusPage() { })); const [savingAgent, setSavingAgent] = useState(false); const [agentError, setAgentError] = useState(null); + const [selectedChannel, setSelectedChannel] = useState(null); + const [channelConfig, setChannelConfig] = useState(null); + const [channelForm, setChannelForm] = useState(() => ({ ...EMPTY_CHANNEL_FORM })); + const [channelEvents, setChannelEvents] = useState([]); + const [loadingChannelConfig, setLoadingChannelConfig] = useState(false); + const [loadingChannelEvents, setLoadingChannelEvents] = useState(false); + const [savingChannel, setSavingChannel] = useState(false); + const [channelError, setChannelError] = useState(null); + const [channelRestartRequired, setChannelRestartRequired] = useState(false); + const [restartOpen, setRestartOpen] = useState(false); + const [restarting, setRestarting] = useState(false); + const [restartError, setRestartError] = useState(null); + const [connectors, setConnectors] = useState([]); + const [loadingConnectors, setLoadingConnectors] = useState(false); + const [connectorDialogOpen, setConnectorDialogOpen] = useState(false); + const [connectorForm, setConnectorForm] = useState(() => ({ ...EMPTY_CONNECTOR_WIZARD })); + const [connectorSession, setConnectorSession] = useState(null); + const [startingConnector, setStartingConnector] = useState(false); + const [pollingConnector, setPollingConnector] = useState(false); + const [connectorError, setConnectorError] = useState(null); const loadStatus = async () => { setLoading(true); @@ -93,6 +223,32 @@ export default function StatusPage() { loadStatus(); }, []); + const loadConnectors = async () => { + setLoadingConnectors(true); + try { + setConnectors(await listChannelConnectors()); + } catch { + setConnectors([]); + } finally { + setLoadingConnectors(false); + } + }; + + useEffect(() => { + loadConnectors(); + }, []); + + useEffect(() => { + const sessionId = connectorSession?.session.sessionId; + const status = connectorSession?.session.status; + if (!sessionId || connectorSessionDone(status)) return; + + const timer = window.setInterval(() => { + void pollConnectorSession(sessionId); + }, 2500); + return () => window.clearInterval(timer); + }, [connectorSession?.session.sessionId, connectorSession?.session.status]); + const openProviderDialog = (provider: ProviderStatus) => { setSelectedProvider(provider); setProviderError(null); @@ -166,6 +322,126 @@ export default function StatusPage() { } }; + const openChannelDetails = async (channel: ChannelStatus) => { + setSelectedChannel(channel); + setChannelConfig(null); + setChannelForm(channelFormFromStatus(channel)); + setChannelError(null); + setChannelRestartRequired(false); + setChannelEvents([]); + setLoadingChannelConfig(true); + setLoadingChannelEvents(true); + try { + const config = await getChannelConfig(channel.channel_id); + setChannelConfig(config); + setChannelForm(channelFormFromConfig(config)); + } catch (err: any) { + setChannelError(err.message || pickAppText(locale, '加载通道配置失败', 'Failed to load channel configuration')); + } finally { + setLoadingChannelConfig(false); + } + try { + setChannelEvents(await listChannelEvents(channel.channel_id, 20)); + } catch { + setChannelEvents([]); + } finally { + setLoadingChannelEvents(false); + } + }; + + const handleSaveChannel = async () => { + if (!selectedChannel) return; + setSavingChannel(true); + setChannelError(null); + try { + const payload = channelPayloadFromForm(channelForm); + const result = await updateChannelConfig(selectedChannel.channel_id, payload); + setChannelConfig(result.channel); + setChannelForm(channelFormFromConfig(result.channel)); + setChannelRestartRequired(Boolean(result.restart_required)); + await loadStatus(); + } catch (err: any) { + setChannelError(err.message || pickAppText(locale, '保存通道配置失败', 'Failed to save channel configuration')); + } finally { + setSavingChannel(false); + } + }; + + const handleRestart = async () => { + setRestarting(true); + setRestartError(null); + try { + await restartRuntime(); + setRestartOpen(false); + window.setTimeout(() => { + void loadStatus(); + }, 5000); + } catch (err: any) { + setRestartError(err.message || pickAppText(locale, '重启失败', 'Restart failed')); + } finally { + setRestarting(false); + } + }; + + const openConnectorDialog = (connector: ChannelConnectorDescriptor) => { + const kind = connector.kind; + setConnectorDialogOpen(true); + setConnectorSession(null); + setConnectorError(null); + setConnectorForm({ + ...EMPTY_CONNECTOR_WIZARD, + kind, + displayName: connectorDisplayName(connector), + domain: kind === 'feishu' ? 'feishu' : '', + }); + }; + + const handleStartConnectorSession = async () => { + if (!connectorForm.kind || !SESSION_CONNECTOR_KINDS.has(connectorForm.kind)) return; + setStartingConnector(true); + setConnectorError(null); + try { + const options: Record = {}; + if (connectorForm.kind === 'feishu') { + options.domain = connectorForm.domain || 'feishu'; + options.mode = connectorForm.mode; + if (connectorForm.appId.trim()) options.appId = connectorForm.appId.trim(); + if (connectorForm.appSecret.trim()) options.appSecret = connectorForm.appSecret.trim(); + if (connectorForm.verificationToken.trim()) options.verificationToken = connectorForm.verificationToken.trim(); + } + const response = await startChannelConnectorSession({ + kind: connectorForm.kind, + displayName: connectorForm.displayName.trim() || connectorDisplayName({ kind: connectorForm.kind }), + options, + }); + setConnectorSession(response); + if (!connectorSessionDone(response.session.status)) { + window.setTimeout(() => { + void pollConnectorSession(response.session.sessionId); + }, 1000); + } + } catch (err: any) { + setConnectorError(err.message || pickAppText(locale, '启动连接失败', 'Failed to start connector session')); + } finally { + setStartingConnector(false); + } + }; + + const pollConnectorSession = async (sessionId: string) => { + setPollingConnector(true); + try { + const response = await getChannelConnectorSession(sessionId); + setConnectorSession(response); + if (response.session.status === 'connected') { + await loadStatus(); + } + } catch (err: any) { + setConnectorError(err.message || pickAppText(locale, '刷新连接状态失败', 'Failed to refresh connector status')); + } finally { + setPollingConnector(false); + } + }; + if (loading) { return (
@@ -241,6 +517,12 @@ export default function StatusPage() { {pickAppText(locale, '运行日志', 'Runtime Logs')} + {status.runtime_controls?.self_restart !== false ? ( + + ) : null}
@@ -442,6 +724,345 @@ export default function StatusPage() { + !open && setSelectedChannel(null)}> + + + {selectedChannel?.display_name || selectedChannel?.channel_id} + + {selectedChannel ? `${selectedChannel.kind}/${selectedChannel.mode} · ${selectedChannel.channel_id}` : ''} + + + {selectedChannel ? ( +
+ {CONFIGURABLE_CHANNEL_KINDS.has(channelForm.kind) ? ( +
+
+
+

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

+

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

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

{channelError}

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

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

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

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

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

{restartError}

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

{item}

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

{connectorSession.session.sessionId}

+

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

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

{connectorSession.session.error}

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

{connectorError}

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

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

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

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

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

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

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