merge: channel runtime v1
This commit is contained in:
16
.env.example
16
.env.example
@ -28,3 +28,19 @@ BEAVER_OUTLOOK_MCP_SERVER_ID=outlook_mcp
|
|||||||
|
|
||||||
# Must be reachable from auth-portal and authz-service containers.
|
# Must be reachable from auth-portal and authz-service containers.
|
||||||
BEAVER_DEPLOY_URL=http://beaver-deploy-control:8090
|
BEAVER_DEPLOY_URL=http://beaver-deploy-control:8090
|
||||||
|
|
||||||
|
# External connector sidecar
|
||||||
|
EXTERNAL_CONNECTOR_TOKEN=
|
||||||
|
BEAVER_BRIDGE_TOKEN=
|
||||||
|
BEAVER_BRIDGE_BASE_URL=http://app-instance:8080
|
||||||
|
EXTERNAL_CONNECTOR_PORT=8787
|
||||||
|
CONNECTOR_PUBLIC_BASE_URL=http://localhost:8787
|
||||||
|
# fake | vendor_cli | weixin_ilink
|
||||||
|
CONNECTOR_PROVIDER=vendor_cli
|
||||||
|
CONNECTOR_COMMAND_TIMEOUT_SECONDS=120
|
||||||
|
WEIXIN_CONNECT_COMMAND=
|
||||||
|
WEIXIN_STATUS_COMMAND=
|
||||||
|
WEIXIN_SEND_COMMAND=
|
||||||
|
FEISHU_CONNECT_COMMAND=
|
||||||
|
FEISHU_STATUS_COMMAND=
|
||||||
|
FEISHU_SEND_COMMAND=
|
||||||
|
|||||||
@ -1,177 +1,458 @@
|
|||||||
# Hermes Gateway LLM Design
|
# Beaver Terminal WebSocket Integration Guide
|
||||||
|
|
||||||
Date: 2026-06-01
|
Date: 2026-06-01
|
||||||
|
|
||||||
|
Audience: the small-terminal-side Codex agent that will modify terminal firmware or terminal app code.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Replace the OpenAI-compatible LLM call path in `custom/custom_agent.py` with a LiveKit LLM
|
Connect the small terminal device to Beaver through a text-only WebSocket channel.
|
||||||
adapter that talks to NousResearch Hermes Agent through the OpenClaw gateway protocol.
|
|
||||||
|
|
||||||
The integration must keep the existing custom agent behavior:
|
The first acceptance target is simple:
|
||||||
|
|
||||||
- Chinese room-locator and general assistant instructions
|
1. The terminal opens a WebSocket connection to Beaver.
|
||||||
- Emotion prefix parsing with `<emotion=...>`
|
2. The terminal sends a `connect` frame with a stable `peer_id`.
|
||||||
- Memory recall for room-locator queries
|
3. The terminal sends one text `message` frame.
|
||||||
- Optional vision-frame attachment
|
4. The terminal receives an `ack`.
|
||||||
- LiveKit ASR, TTS, VAD, turn handling, metrics, and interruption behavior
|
5. The terminal receives the final assistant text response from Beaver.
|
||||||
|
6. The terminal can reconnect with the same `peer_id` and keep the same Beaver session.
|
||||||
|
|
||||||
The Hermes session strategy is `per_room`: one LiveKit room should map to one Hermes gateway
|
This document replaces the earlier Hermes LiveKit LLM adapter design for the terminal-side work. Do not implement a LiveKit LLM adapter from this document.
|
||||||
session for the lifetime of that room.
|
|
||||||
|
|
||||||
## Non-Goals
|
## Non-Goals
|
||||||
|
|
||||||
- Do not replace LiveKit `AgentSession`, ASR, TTS, VAD, or room I/O.
|
- Do not implement audio streaming.
|
||||||
- Do not move room-locator classification into Hermes Agent.
|
- Do not implement camera, screen, image, or multimodal frames.
|
||||||
- Do not implement Hermes-side tools in the first pass.
|
- Do not implement token streaming.
|
||||||
- Do not require an OpenAI-compatible proxy in front of the gateway.
|
- Do not implement terminal-side tools.
|
||||||
|
- Do not implement AuthZ, device registration, OAuth, or pairing in the first pass.
|
||||||
|
- Do not call Beaver REST chat endpoints or the existing Web UI `/ws/{session_id}` endpoint.
|
||||||
|
- Do not build an OpenAI-compatible proxy.
|
||||||
|
- Do not implement Hermes Agent or LiveKit changes on the terminal side.
|
||||||
|
|
||||||
## Recommended Architecture
|
## Beaver Endpoint
|
||||||
|
|
||||||
Add a new custom LiveKit LLM implementation in `custom/hermes_gateway.py`.
|
The terminal connects to:
|
||||||
|
|
||||||
The adapter will implement the LiveKit `llm.LLM` interface and return a custom `LLMStream`.
|
```text
|
||||||
The stream will own a single gateway request/response cycle while the LLM object owns the
|
ws://<beaver-host>/api/channels/<channel_id>/ws
|
||||||
per-room gateway session state.
|
```
|
||||||
|
|
||||||
`custom/custom_agent.py` will continue to call `selected_llm.chat(...)` through
|
For local development through the Beaver app instance nginx port:
|
||||||
`_run_selected_llm()`. That preserves the existing `llm_node()` pipeline and keeps Hermes
|
|
||||||
behind the same abstraction as OpenAI-compatible models.
|
|
||||||
|
|
||||||
## Components
|
```text
|
||||||
|
ws://127.0.0.1:8080/api/channels/terminal-dev/ws
|
||||||
|
```
|
||||||
|
|
||||||
### HermesGatewayLLM
|
For direct backend development without nginx:
|
||||||
|
|
||||||
Responsibilities:
|
```text
|
||||||
|
ws://127.0.0.1:18080/api/channels/terminal-dev/ws
|
||||||
|
```
|
||||||
|
|
||||||
- Store gateway configuration: URL, auth token, agent identifier, request timeout, and reconnect
|
Use `wss://` when Beaver is deployed behind TLS.
|
||||||
policy.
|
|
||||||
- Lazily create one Hermes gateway session per LiveKit room.
|
|
||||||
- Expose `model` as the configured Hermes agent/model identifier.
|
|
||||||
- Expose `provider` as `hermes-gateway`.
|
|
||||||
- Create `HermesGatewayLLMStream` from `chat(...)`.
|
|
||||||
- Close any persistent WebSocket/session resources in `aclose()`.
|
|
||||||
|
|
||||||
### HermesGatewayLLMStream
|
The expected first channel id is:
|
||||||
|
|
||||||
Responsibilities:
|
```text
|
||||||
|
terminal-dev
|
||||||
|
```
|
||||||
|
|
||||||
- Serialize LiveKit `ChatContext` into the gateway request payload.
|
The terminal implementation should make the URL configurable, for example:
|
||||||
- Send the latest turn to the per-room Hermes session.
|
|
||||||
- Consume gateway events until the turn completes or fails.
|
|
||||||
- Yield LiveKit `llm.ChatChunk` objects for assistant text deltas.
|
|
||||||
- Surface recoverable connection failures through the normal LiveKit LLM error path.
|
|
||||||
|
|
||||||
### custom_agent.py Wiring
|
```text
|
||||||
|
BEAVER_WS_URL=ws://127.0.0.1:8080/api/channels/terminal-dev/ws
|
||||||
|
TERMINAL_PEER_ID=device-001
|
||||||
|
TERMINAL_DEVICE_NAME=desk-terminal
|
||||||
|
```
|
||||||
|
|
||||||
Add env-driven provider selection:
|
## Protocol Overview
|
||||||
|
|
||||||
- `CUSTOM_LLM_PROVIDER=openai` keeps the current behavior.
|
The transport is JSON over WebSocket.
|
||||||
- `CUSTOM_LLM_PROVIDER=hermes_gateway` constructs `HermesGatewayLLM`.
|
|
||||||
|
|
||||||
New Hermes-specific env vars:
|
All frames are UTF-8 JSON objects. The terminal should ignore unknown fields. Beaver will ignore unknown fields unless the frame type is invalid.
|
||||||
|
|
||||||
- `CUSTOM_HERMES_GATEWAY_URL`
|
The protocol is request/reply oriented in this phase. Beaver sends only final assistant messages, not token deltas.
|
||||||
- `CUSTOM_HERMES_API_KEY`
|
|
||||||
- `CUSTOM_HERMES_AGENT_ID`
|
|
||||||
- `CUSTOM_HERMES_SESSION_MODE=per_room`
|
|
||||||
- `CUSTOM_HERMES_REQUEST_TIMEOUT`
|
|
||||||
- `CUSTOM_HERMES_VERIFY_SSL`
|
|
||||||
|
|
||||||
When `CUSTOM_LLM_PROVIDER=hermes_gateway`, `base_llm`, `text_llm`, and `vision_llm` should all
|
Required frame flow:
|
||||||
point at the same Hermes adapter. Separate Hermes text/vision agent IDs are out of scope for this
|
|
||||||
design.
|
|
||||||
|
|
||||||
## Data Flow
|
```text
|
||||||
|
terminal -> Beaver: connect
|
||||||
|
Beaver -> terminal: connected
|
||||||
|
terminal -> Beaver: message
|
||||||
|
Beaver -> terminal: ack
|
||||||
|
Beaver -> terminal: message
|
||||||
|
```
|
||||||
|
|
||||||
1. User speaks or sends text.
|
Optional heartbeat:
|
||||||
2. Existing LiveKit/STT flow updates `ChatContext`.
|
|
||||||
3. `CustomAgent.llm_node()` selects `general` or `room_locator`.
|
|
||||||
4. Existing code injects the appropriate instructions and emotion-prefix requirement.
|
|
||||||
5. Existing code optionally augments the latest user message with memory context.
|
|
||||||
6. Existing code optionally attaches a fresh vision frame.
|
|
||||||
7. `_run_selected_llm()` calls `HermesGatewayLLM.chat(...)`.
|
|
||||||
8. The Hermes adapter sends the request to the per-room gateway session.
|
|
||||||
9. Gateway text events are converted to `llm.ChatChunk` deltas.
|
|
||||||
10. Existing emotion observation and TTS stripping continue unchanged.
|
|
||||||
|
|
||||||
## ChatContext Serialization
|
```text
|
||||||
|
terminal -> Beaver: ping
|
||||||
|
Beaver -> terminal: pong
|
||||||
|
```
|
||||||
|
|
||||||
Text messages should be serialized first.
|
## Connect Frame
|
||||||
|
|
||||||
Supported LiveKit content:
|
The terminal must send `connect` immediately after the WebSocket opens.
|
||||||
|
|
||||||
- `str`: send as normal message content.
|
Terminal to Beaver:
|
||||||
- instruction/config updates: preserve the final active instructions as the leading instruction
|
|
||||||
message in the gateway payload. If the deployed gateway only accepts user/assistant messages,
|
|
||||||
prepend the instruction text to the latest user message before sending.
|
|
||||||
- image content: attempt to send through the gateway image/multimodal field. If the deployed
|
|
||||||
Hermes gateway rejects or ignores image content, log a warning and fall back to text-only
|
|
||||||
generation for that turn.
|
|
||||||
|
|
||||||
Function tool calls should not be sent in the first implementation. If tool messages appear, log
|
```json
|
||||||
that they were omitted.
|
{
|
||||||
|
"type": "connect",
|
||||||
|
"peer_id": "device-001",
|
||||||
|
"device_name": "desk-terminal",
|
||||||
|
"capabilities": ["text"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## per_room Session Lifecycle
|
Required fields:
|
||||||
|
|
||||||
The adapter should derive a stable room key from the active LiveKit session or job context. If a
|
- `type`: must be `"connect"`.
|
||||||
room name/SID is not available, fall back to one adapter-local session.
|
- `peer_id`: stable terminal identity. Reuse this value across reconnects.
|
||||||
|
|
||||||
For each room key:
|
Recommended fields:
|
||||||
|
|
||||||
1. Open or reuse a gateway connection.
|
- `device_name`: human-readable terminal name.
|
||||||
2. Send the gateway `connect` handshake if needed.
|
- `capabilities`: include `"text"`.
|
||||||
3. Create a Hermes session once.
|
|
||||||
4. Reuse that Hermes session for all future turns from the same room.
|
|
||||||
5. Close the gateway connection when the LiveKit LLM is closed.
|
|
||||||
|
|
||||||
This lets Hermes maintain its own conversational state while LiveKit still keeps the visible
|
Optional fields:
|
||||||
conversation history.
|
|
||||||
|
|
||||||
## Gateway Event Mapping
|
- `thread_id`: optional sub-session key. Omit it for the first pass.
|
||||||
|
- `user_id`: optional user identity. Omit it unless the terminal already has a stable user id.
|
||||||
|
|
||||||
Map streaming text events to LiveKit chunks:
|
Beaver to terminal:
|
||||||
|
|
||||||
- Gateway assistant text delta -> `llm.ChatChunk(delta=llm.ChoiceDelta(content=delta))`
|
```json
|
||||||
- Gateway final assistant message -> emit any remaining text not already streamed
|
{
|
||||||
- Gateway usage metadata -> `llm.CompletionUsage` when token counts are available
|
"type": "connected",
|
||||||
- Gateway tool/action events -> log at debug/info level in the first implementation
|
"channel_id": "terminal-dev",
|
||||||
- Gateway error event -> raise a LiveKit `APIError` or `APIConnectionError`
|
"session_id": "terminal-dev:local:device-001"
|
||||||
- Gateway completion event -> finish the async iterator
|
}
|
||||||
|
```
|
||||||
|
|
||||||
The implementation should make the event parser tolerant of protocol field-name differences by
|
The terminal should store `session_id` for logging and diagnostics. It does not need to send `session_id` back in message frames.
|
||||||
isolating event normalization in one helper function. Unknown event types should be logged and
|
|
||||||
ignored unless they indicate failure.
|
|
||||||
|
|
||||||
## Error Handling
|
## Message Frame
|
||||||
|
|
||||||
- Missing Hermes env vars should fail fast at startup when provider is `hermes_gateway`.
|
Terminal to Beaver:
|
||||||
- Gateway connect/session-create failures should raise connection errors.
|
|
||||||
- A failed request should not discard the per-room session unless the gateway reports that the
|
|
||||||
session is invalid or closed.
|
|
||||||
- If the gateway connection closes mid-turn, reconnect once and retry only if no assistant text
|
|
||||||
has been yielded yet.
|
|
||||||
- If assistant text has already been yielded, fail the turn instead of replaying partial output.
|
|
||||||
|
|
||||||
## Testing
|
```json
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"message_id": "m-001",
|
||||||
|
"text": "hello"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Add focused tests around the adapter:
|
Required fields:
|
||||||
|
|
||||||
- Serializes simple system/user/assistant chat context.
|
- `type`: must be `"message"`.
|
||||||
- Creates one gateway session and reuses it across two turns for the same room.
|
- `message_id`: unique id for this user message.
|
||||||
- Converts text deltas into `llm.ChatChunk` content.
|
- `text`: non-empty user text.
|
||||||
- Handles final full-message events without duplicate text.
|
|
||||||
- Raises on gateway error events.
|
|
||||||
- Logs and skips unsupported image/tool content.
|
|
||||||
|
|
||||||
Add a small wiring test or import-level test for `CUSTOM_LLM_PROVIDER=hermes_gateway` if the
|
Recommended `message_id` format:
|
||||||
custom module is testable without external services.
|
|
||||||
|
|
||||||
## Rollout
|
```text
|
||||||
|
<peer_id>-<monotonic-counter>
|
||||||
|
```
|
||||||
|
|
||||||
1. Implement the adapter behind `CUSTOM_LLM_PROVIDER=hermes_gateway`.
|
Example:
|
||||||
2. Keep `openai` as the default provider.
|
|
||||||
3. Run unit tests for the adapter and a syntax/type smoke check on `custom/custom_agent.py`.
|
```text
|
||||||
4. Test manually with a local gateway using `python custom/custom_agent.py console` or the
|
device-001-000001
|
||||||
existing LiveKit development mode.
|
device-001-000002
|
||||||
5. If vision payloads are unsupported by the deployed gateway, document that the first Hermes
|
```
|
||||||
rollout is text-only for vision turns.
|
|
||||||
|
The terminal should persist the counter if practical. If persistence is unavailable, generate a UUID or timestamp-based id. Reusing the same `message_id` tells Beaver to treat the frame as a duplicate.
|
||||||
|
|
||||||
|
Optional fields:
|
||||||
|
|
||||||
|
- `thread_id`: use only when the terminal intentionally wants a separate Beaver session.
|
||||||
|
- `user_id`: use only when the terminal has a stable user id.
|
||||||
|
|
||||||
|
## Ack Frame
|
||||||
|
|
||||||
|
Beaver sends an ack after accepting or deduplicating the inbound message.
|
||||||
|
|
||||||
|
Accepted:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "ack",
|
||||||
|
"message_id": "device-001-000001",
|
||||||
|
"session_id": "terminal-dev:local:device-001",
|
||||||
|
"accepted": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Duplicate still processing:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "ack",
|
||||||
|
"message_id": "device-001-000001",
|
||||||
|
"session_id": "terminal-dev:local:device-001",
|
||||||
|
"accepted": false,
|
||||||
|
"duplicate": true,
|
||||||
|
"pending": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Duplicate already completed:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "ack",
|
||||||
|
"message_id": "device-001-000001",
|
||||||
|
"session_id": "terminal-dev:local:device-001",
|
||||||
|
"accepted": false,
|
||||||
|
"duplicate": true,
|
||||||
|
"pending": false,
|
||||||
|
"reply": "cached assistant reply"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Terminal behavior:
|
||||||
|
|
||||||
|
- If `accepted` is true, wait for the assistant `message`.
|
||||||
|
- If `duplicate` and `reply` is present, display the cached reply.
|
||||||
|
- If `duplicate` and `pending` is true, keep waiting on the socket.
|
||||||
|
- If `error` is present, display or log the error.
|
||||||
|
|
||||||
|
## Assistant Message Frame
|
||||||
|
|
||||||
|
Beaver to terminal:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"message_id": "device-001-000001",
|
||||||
|
"run_id": "run-id",
|
||||||
|
"text": "assistant reply",
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
- `type`: `"message"`.
|
||||||
|
- `role`: `"assistant"`.
|
||||||
|
- `message_id`: the user message id this response belongs to.
|
||||||
|
- `run_id`: Beaver run id for diagnostics.
|
||||||
|
- `text`: final assistant response.
|
||||||
|
- `finish_reason`: usually `"stop"`, or `"error"` when the run failed.
|
||||||
|
|
||||||
|
Terminal behavior:
|
||||||
|
|
||||||
|
- Render or speak `text`.
|
||||||
|
- Treat `finish_reason == "error"` as a failed turn.
|
||||||
|
- Do not expect token-level streaming in this phase.
|
||||||
|
|
||||||
|
## Ping And Pong
|
||||||
|
|
||||||
|
Terminal to Beaver:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type": "ping"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Beaver to terminal:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type": "pong"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended heartbeat interval:
|
||||||
|
|
||||||
|
```text
|
||||||
|
30 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
If no pong or other frame is received after a reasonable timeout, reconnect.
|
||||||
|
|
||||||
|
## Error Frame
|
||||||
|
|
||||||
|
Beaver may send:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"error": "human readable error"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Terminal behavior:
|
||||||
|
|
||||||
|
- Log the error.
|
||||||
|
- Keep the connection open unless the WebSocket closes.
|
||||||
|
- If the error is for a user message, allow the user to retry with a new `message_id`.
|
||||||
|
|
||||||
|
Common first-pass errors:
|
||||||
|
|
||||||
|
- `connect` is required before `message`.
|
||||||
|
- `peer_id` is required.
|
||||||
|
- `message_id` is required.
|
||||||
|
- `text` is required.
|
||||||
|
- Unsupported websocket frame type.
|
||||||
|
|
||||||
|
## Terminal State Machine
|
||||||
|
|
||||||
|
Implement the terminal client as a small state machine.
|
||||||
|
|
||||||
|
```text
|
||||||
|
DISCONNECTED
|
||||||
|
-> connect websocket
|
||||||
|
CONNECTING
|
||||||
|
-> websocket open, send connect frame
|
||||||
|
WAIT_CONNECTED
|
||||||
|
-> receive connected
|
||||||
|
READY
|
||||||
|
-> send message frame
|
||||||
|
WAIT_ACK
|
||||||
|
-> receive ack
|
||||||
|
WAIT_REPLY
|
||||||
|
-> receive assistant message
|
||||||
|
READY
|
||||||
|
```
|
||||||
|
|
||||||
|
On WebSocket close or network failure, transition to `DISCONNECTED` and reconnect with backoff.
|
||||||
|
|
||||||
|
Recommended reconnect policy:
|
||||||
|
|
||||||
|
- Start at 1 second.
|
||||||
|
- Double up to 30 seconds.
|
||||||
|
- Reset backoff after a successful `connected` frame.
|
||||||
|
|
||||||
|
On reconnect, use the same `peer_id`.
|
||||||
|
|
||||||
|
## Terminal Implementation Requirements
|
||||||
|
|
||||||
|
The terminal-side code should provide:
|
||||||
|
|
||||||
|
- A configurable Beaver WebSocket URL.
|
||||||
|
- A stable `peer_id`.
|
||||||
|
- A configurable `device_name`.
|
||||||
|
- A monotonic or otherwise unique `message_id` generator.
|
||||||
|
- JSON encoding and decoding.
|
||||||
|
- Connect frame on socket open.
|
||||||
|
- Ping/pong heartbeat.
|
||||||
|
- Reconnect with backoff.
|
||||||
|
- A queue or guard so only one user text turn is in flight at a time for the first pass.
|
||||||
|
- Logging for `session_id`, `message_id`, `run_id`, and errors.
|
||||||
|
|
||||||
|
The terminal-side code does not need:
|
||||||
|
|
||||||
|
- Multi-room session logic.
|
||||||
|
- Hermes session management.
|
||||||
|
- LiveKit `AgentSession`.
|
||||||
|
- Audio chunking.
|
||||||
|
- Tool calls.
|
||||||
|
- OAuth or token refresh.
|
||||||
|
|
||||||
|
## Example Client Pseudocode
|
||||||
|
|
||||||
|
```python
|
||||||
|
peer_id = load_or_create_peer_id()
|
||||||
|
counter = load_counter()
|
||||||
|
|
||||||
|
async def run_terminal_client():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
async with connect(BEAVER_WS_URL) as ws:
|
||||||
|
await ws.send_json({
|
||||||
|
"type": "connect",
|
||||||
|
"peer_id": peer_id,
|
||||||
|
"device_name": DEVICE_NAME,
|
||||||
|
"capabilities": ["text"],
|
||||||
|
})
|
||||||
|
|
||||||
|
connected = await ws.receive_json()
|
||||||
|
assert connected["type"] == "connected"
|
||||||
|
log("session_id", connected["session_id"])
|
||||||
|
|
||||||
|
await read_send_receive_loop(ws)
|
||||||
|
except Exception as exc:
|
||||||
|
log("websocket disconnected", exc)
|
||||||
|
await sleep(next_backoff())
|
||||||
|
|
||||||
|
async def send_user_text(ws, text):
|
||||||
|
global counter
|
||||||
|
counter += 1
|
||||||
|
save_counter(counter)
|
||||||
|
message_id = f"{peer_id}-{counter:06d}"
|
||||||
|
|
||||||
|
await ws.send_json({
|
||||||
|
"type": "message",
|
||||||
|
"message_id": message_id,
|
||||||
|
"text": text,
|
||||||
|
})
|
||||||
|
|
||||||
|
while True:
|
||||||
|
frame = await ws.receive_json()
|
||||||
|
if frame["type"] == "ack" and frame.get("message_id") == message_id:
|
||||||
|
if frame.get("reply"):
|
||||||
|
return frame["reply"]
|
||||||
|
continue
|
||||||
|
if frame["type"] == "message" and frame.get("role") == "assistant":
|
||||||
|
if frame.get("message_id") == message_id:
|
||||||
|
return frame.get("text", "")
|
||||||
|
if frame["type"] == "error":
|
||||||
|
raise RuntimeError(frame.get("error", "unknown error"))
|
||||||
|
```
|
||||||
|
|
||||||
|
Adapt the pseudocode to the terminal runtime language and WebSocket library.
|
||||||
|
|
||||||
|
## Manual Test With websocat
|
||||||
|
|
||||||
|
If `websocat` is available, a developer can manually test the protocol:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
websocat ws://127.0.0.1:8080/api/channels/terminal-dev/ws
|
||||||
|
```
|
||||||
|
|
||||||
|
Then paste:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"connect","peer_id":"device-001","device_name":"desk-terminal","capabilities":["text"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"connected","channel_id":"terminal-dev","session_id":"terminal-dev:local:device-001"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then paste:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"message","message_id":"device-001-000001","text":"hello"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected responses:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"ack","message_id":"device-001-000001","session_id":"terminal-dev:local:device-001","accepted":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, after Beaver finishes the run:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"message","role":"assistant","message_id":"device-001-000001","run_id":"...","text":"...","finish_reason":"stop"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance Checklist For Terminal-Side Codex
|
||||||
|
|
||||||
|
- The terminal opens the configured Beaver WebSocket URL.
|
||||||
|
- The terminal sends `connect` immediately after open.
|
||||||
|
- The terminal receives and logs `connected.session_id`.
|
||||||
|
- The terminal sends text using a unique `message_id`.
|
||||||
|
- The terminal receives `ack`.
|
||||||
|
- The terminal receives and displays assistant `message.text`.
|
||||||
|
- The terminal handles `ping`/`pong`.
|
||||||
|
- The terminal reconnects with the same `peer_id`.
|
||||||
|
- The terminal does not use REST chat or `/ws/{session_id}`.
|
||||||
|
- The terminal implementation remains text-only for the first pass.
|
||||||
|
|
||||||
|
When this checklist passes against Beaver, the first-stage device integration is accepted from the terminal side.
|
||||||
|
|||||||
@ -47,8 +47,12 @@ ARG NPM_REGISTRY="https://registry.npmmirror.com"
|
|||||||
ARG NPM_FETCH_RETRIES="5"
|
ARG NPM_FETCH_RETRIES="5"
|
||||||
ARG NPM_FETCH_RETRY_MIN_TIMEOUT="20000"
|
ARG NPM_FETCH_RETRY_MIN_TIMEOUT="20000"
|
||||||
ARG NPM_FETCH_RETRY_MAX_TIMEOUT="120000"
|
ARG NPM_FETCH_RETRY_MAX_TIMEOUT="120000"
|
||||||
|
ARG APT_MIRROR="https://mirrors.tuna.tsinghua.edu.cn/debian"
|
||||||
|
ARG PYPI_INDEX_URL="https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN find /etc/apt -type f \( -name "*.list" -o -name "*.sources" \) -exec \
|
||||||
|
sed -i "s|http://deb.debian.org/debian-security|${APT_MIRROR}-security|g; s|http://deb.debian.org/debian|${APT_MIRROR}|g; s|http://security.debian.org/debian-security|${APT_MIRROR}-security|g" {} + && \
|
||||||
|
apt-get update && \
|
||||||
apt-get install -y --no-install-recommends curl ca-certificates gnupg git nginx dumb-init && \
|
apt-get install -y --no-install-recommends curl ca-certificates gnupg git nginx dumb-init && \
|
||||||
mkdir -p /etc/apt/keyrings && \
|
mkdir -p /etc/apt/keyrings && \
|
||||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
|
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
|
||||||
@ -63,7 +67,7 @@ WORKDIR /opt/app/backend
|
|||||||
|
|
||||||
COPY backend/pyproject.toml backend/README.md ./
|
COPY backend/pyproject.toml backend/README.md ./
|
||||||
COPY backend/beaver/ ./beaver/
|
COPY backend/beaver/ ./beaver/
|
||||||
RUN uv pip install --system --no-cache .
|
RUN uv pip install --system --no-cache --index-url "${PYPI_INDEX_URL}" ".[channels]"
|
||||||
|
|
||||||
WORKDIR /opt/app/frontend
|
WORKDIR /opt/app/frontend
|
||||||
COPY --from=frontend-builder /build/frontend/next.config.js ./
|
COPY --from=frontend-builder /build/frontend/next.config.js ./
|
||||||
|
|||||||
@ -76,7 +76,12 @@ class SessionContext:
|
|||||||
model: str | None = None
|
model: str | None = None
|
||||||
user_id: str | None = None
|
user_id: str | None = None
|
||||||
channel: str | None = None
|
channel: str | None = None
|
||||||
|
channel_kind: str | None = None
|
||||||
|
account_id: str | None = None
|
||||||
|
peer_id: str | None = None
|
||||||
|
peer_type: str | None = None
|
||||||
chat_id: str | None = None
|
chat_id: str | None = None
|
||||||
|
thread_id: str | None = None
|
||||||
parent_session_id: str | None = None
|
parent_session_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@ -354,8 +359,18 @@ class ContextBuilder:
|
|||||||
rows.append(f"User ID: {session_context.user_id}")
|
rows.append(f"User ID: {session_context.user_id}")
|
||||||
if session_context.channel:
|
if session_context.channel:
|
||||||
rows.append(f"Channel: {session_context.channel}")
|
rows.append(f"Channel: {session_context.channel}")
|
||||||
|
if session_context.channel_kind:
|
||||||
|
rows.append(f"Channel Kind: {session_context.channel_kind}")
|
||||||
|
if session_context.account_id:
|
||||||
|
rows.append(f"Account ID: {session_context.account_id}")
|
||||||
|
if session_context.peer_id:
|
||||||
|
rows.append(f"Peer ID: {session_context.peer_id}")
|
||||||
|
if session_context.peer_type:
|
||||||
|
rows.append(f"Peer Type: {session_context.peer_type}")
|
||||||
if session_context.chat_id:
|
if session_context.chat_id:
|
||||||
rows.append(f"Chat ID: {session_context.chat_id}")
|
rows.append(f"Chat ID: {session_context.chat_id}")
|
||||||
|
if session_context.thread_id:
|
||||||
|
rows.append(f"Thread ID: {session_context.thread_id}")
|
||||||
if session_context.parent_session_id:
|
if session_context.parent_session_id:
|
||||||
rows.append(f"Parent Session ID: {session_context.parent_session_id}")
|
rows.append(f"Parent Session ID: {session_context.parent_session_id}")
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ from uuid import uuid4
|
|||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from beaver.engine.context import ContextBuildInput, RuntimeContext, SessionContext, SkillContext
|
from beaver.engine.context import ContextBuildInput, RuntimeContext, SessionContext, SkillContext
|
||||||
|
from beaver.foundation.events import ChannelIdentity
|
||||||
from beaver.memory.runs import RunRecord, SkillEffectRecord
|
from beaver.memory.runs import RunRecord, SkillEffectRecord
|
||||||
from beaver.skills.learning import RunReceiptContext
|
from beaver.skills.learning import RunReceiptContext
|
||||||
from beaver.skills.catalog.utils import strip_frontmatter
|
from beaver.skills.catalog.utils import strip_frontmatter
|
||||||
@ -248,6 +249,7 @@ class AgentLoop:
|
|||||||
pinned_skill_contexts: list[SkillContext] | None = None,
|
pinned_skill_contexts: list[SkillContext] | None = None,
|
||||||
allow_candidate_generation: bool = False,
|
allow_candidate_generation: bool = False,
|
||||||
intent_agent_decision: dict[str, Any] | None = None,
|
intent_agent_decision: dict[str, Any] | None = None,
|
||||||
|
channel_identity: ChannelIdentity | None = None,
|
||||||
) -> AgentRunResult:
|
) -> AgentRunResult:
|
||||||
"""跑通最小 direct run 主链。
|
"""跑通最小 direct run 主链。
|
||||||
|
|
||||||
@ -297,6 +299,7 @@ class AgentLoop:
|
|||||||
pinned_skill_contexts=pinned_skill_contexts,
|
pinned_skill_contexts=pinned_skill_contexts,
|
||||||
allow_candidate_generation=allow_candidate_generation,
|
allow_candidate_generation=allow_candidate_generation,
|
||||||
intent_agent_decision=intent_agent_decision,
|
intent_agent_decision=intent_agent_decision,
|
||||||
|
channel_identity=channel_identity,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _process_direct_impl(
|
async def _process_direct_impl(
|
||||||
@ -334,6 +337,7 @@ class AgentLoop:
|
|||||||
pinned_skill_contexts: list[SkillContext] | None = None,
|
pinned_skill_contexts: list[SkillContext] | None = None,
|
||||||
allow_candidate_generation: bool = False,
|
allow_candidate_generation: bool = False,
|
||||||
intent_agent_decision: dict[str, Any] | None = None,
|
intent_agent_decision: dict[str, Any] | None = None,
|
||||||
|
channel_identity: ChannelIdentity | None = None,
|
||||||
) -> AgentRunResult:
|
) -> AgentRunResult:
|
||||||
"""真正执行一轮 direct run 的内部实现。
|
"""真正执行一轮 direct run 的内部实现。
|
||||||
|
|
||||||
@ -576,6 +580,13 @@ class AgentLoop:
|
|||||||
source=source,
|
source=source,
|
||||||
model=resolved_model,
|
model=resolved_model,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
channel=channel_identity.channel_id if channel_identity else None,
|
||||||
|
channel_kind=channel_identity.kind if channel_identity else None,
|
||||||
|
account_id=channel_identity.account_id if channel_identity else None,
|
||||||
|
peer_id=channel_identity.peer_id if channel_identity else None,
|
||||||
|
peer_type=channel_identity.peer_type if channel_identity else None,
|
||||||
|
chat_id=channel_identity.peer_id if channel_identity else None,
|
||||||
|
thread_id=channel_identity.thread_id if channel_identity else None,
|
||||||
parent_session_id=parent_session_id,
|
parent_session_id=parent_session_id,
|
||||||
),
|
),
|
||||||
runtime_context=self._current_runtime_context(),
|
runtime_context=self._current_runtime_context(),
|
||||||
|
|||||||
@ -13,6 +13,7 @@ from .schema import (
|
|||||||
AuthzConfig,
|
AuthzConfig,
|
||||||
BackendIdentityConfig,
|
BackendIdentityConfig,
|
||||||
BeaverConfig,
|
BeaverConfig,
|
||||||
|
ChannelConfig,
|
||||||
EmbeddingConfig,
|
EmbeddingConfig,
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
@ -73,6 +74,7 @@ def load_config(
|
|||||||
embedding=_parse_embedding(data),
|
embedding=_parse_embedding(data),
|
||||||
tools=_parse_tools(data.get("tools")),
|
tools=_parse_tools(data.get("tools")),
|
||||||
authz=_parse_authz(data.get("authz")),
|
authz=_parse_authz(data.get("authz")),
|
||||||
|
channels=_parse_channels(data.get("channels")),
|
||||||
backend_identity=_parse_backend_identity(data.get("backend_identity") or data.get("backendIdentity")),
|
backend_identity=_parse_backend_identity(data.get("backend_identity") or data.get("backendIdentity")),
|
||||||
config_path=path,
|
config_path=path,
|
||||||
)
|
)
|
||||||
@ -196,6 +198,48 @@ def _parse_authz(raw: Any) -> AuthzConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_channels(raw: Any) -> dict[str, ChannelConfig]:
|
||||||
|
channels: dict[str, ChannelConfig] = {}
|
||||||
|
for channel_id, payload in _as_dict(raw).items():
|
||||||
|
cleaned_id = str(channel_id).strip()
|
||||||
|
if not cleaned_id:
|
||||||
|
continue
|
||||||
|
channels[cleaned_id] = _parse_channel_config(payload)
|
||||||
|
return channels
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_channel_config(payload: Any) -> ChannelConfig:
|
||||||
|
data = _as_dict(payload)
|
||||||
|
return ChannelConfig(
|
||||||
|
enabled=_bool(data.get("enabled"), default=False),
|
||||||
|
kind=_string(data.get("kind")) or "",
|
||||||
|
mode=_string(data.get("mode")) or "webhook",
|
||||||
|
account_id=_string(data.get("accountId") or data.get("account_id")) or "",
|
||||||
|
display_name=_string(data.get("displayName") or data.get("display_name")) or "",
|
||||||
|
config=_normalize_config_map(data.get("config")),
|
||||||
|
secrets=_string_dict(data.get("secrets")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_config_map(value: Any) -> dict[str, Any]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
_camel_to_snake_key(str(key)): item
|
||||||
|
for key, item in value.items()
|
||||||
|
if str(key).strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _camel_to_snake_key(value: str) -> str:
|
||||||
|
result: list[str] = []
|
||||||
|
for char in value:
|
||||||
|
if char.isupper() and result:
|
||||||
|
result.append("_")
|
||||||
|
result.append(char.lower())
|
||||||
|
return "".join(result)
|
||||||
|
|
||||||
|
|
||||||
def _parse_backend_identity(raw: Any) -> BackendIdentityConfig:
|
def _parse_backend_identity(raw: Any) -> BackendIdentityConfig:
|
||||||
data = _as_dict(raw)
|
data = _as_dict(raw)
|
||||||
return BackendIdentityConfig(
|
return BackendIdentityConfig(
|
||||||
|
|||||||
@ -91,6 +91,19 @@ class AuthzConfig:
|
|||||||
outlook_mcp_url: str = ""
|
outlook_mcp_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ChannelConfig:
|
||||||
|
"""One configured channel adapter instance."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
kind: str = ""
|
||||||
|
mode: str = "webhook"
|
||||||
|
account_id: str = ""
|
||||||
|
display_name: str = ""
|
||||||
|
config: dict[str, Any] = field(default_factory=dict)
|
||||||
|
secrets: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class BackendIdentityConfig:
|
class BackendIdentityConfig:
|
||||||
"""This backend's AuthZ client identity."""
|
"""This backend's AuthZ client identity."""
|
||||||
@ -111,6 +124,7 @@ class BeaverConfig:
|
|||||||
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
|
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
|
||||||
tools: ToolsConfig = field(default_factory=ToolsConfig)
|
tools: ToolsConfig = field(default_factory=ToolsConfig)
|
||||||
authz: AuthzConfig = field(default_factory=AuthzConfig)
|
authz: AuthzConfig = field(default_factory=AuthzConfig)
|
||||||
|
channels: dict[str, ChannelConfig] = field(default_factory=dict)
|
||||||
backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig)
|
backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig)
|
||||||
config_path: Path | None = None
|
config_path: Path | None = None
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
"""Event contracts and dispatch helpers."""
|
"""Event contracts and dispatch helpers."""
|
||||||
|
|
||||||
from .message_bus import InboundMessage, MessageBus, OutboundMessage
|
from .message_bus import ChannelIdentity, InboundMessage, MessageBus, OutboundMessage
|
||||||
|
|
||||||
__all__ = ["InboundMessage", "MessageBus", "OutboundMessage"]
|
__all__ = ["ChannelIdentity", "InboundMessage", "MessageBus", "OutboundMessage"]
|
||||||
|
|||||||
@ -9,12 +9,58 @@ from typing import Any
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ChannelIdentity:
|
||||||
|
"""Normalized channel routing identity.
|
||||||
|
|
||||||
|
`channel_id` is the Beaver adapter instance id, not the platform kind.
|
||||||
|
"""
|
||||||
|
|
||||||
|
channel_id: str
|
||||||
|
kind: str
|
||||||
|
account_id: str
|
||||||
|
peer_id: str
|
||||||
|
thread_id: str | None = None
|
||||||
|
peer_type: str = "unknown"
|
||||||
|
user_id: str | None = None
|
||||||
|
message_id: str | None = None
|
||||||
|
|
||||||
|
def validation_error(self) -> str | None:
|
||||||
|
if not self.channel_id.strip():
|
||||||
|
return "channel_id is required"
|
||||||
|
if not self.account_id.strip():
|
||||||
|
return "account_id is required"
|
||||||
|
if not self.peer_id.strip():
|
||||||
|
return "peer_id is required"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def session_id(self) -> str:
|
||||||
|
parts = [self.channel_id, self.account_id, self.peer_id]
|
||||||
|
if self.thread_id:
|
||||||
|
parts.append(self.thread_id)
|
||||||
|
return ":".join(_clean_session_part(part) for part in parts)
|
||||||
|
|
||||||
|
def dedupe_key(self) -> str | None:
|
||||||
|
if not self.message_id:
|
||||||
|
return None
|
||||||
|
return f"{self.session_id()}:{_clean_session_part(self.message_id)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_session_part(value: str) -> str:
|
||||||
|
cleaned = str(value).strip()
|
||||||
|
if not cleaned:
|
||||||
|
return "unknown"
|
||||||
|
return cleaned.replace(":", "_")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class InboundMessage:
|
class InboundMessage:
|
||||||
"""A minimal inbound message accepted by the gateway bridge."""
|
"""A minimal inbound message accepted by the gateway bridge."""
|
||||||
|
|
||||||
channel: str
|
channel: str
|
||||||
content: str
|
content: str
|
||||||
|
content_type: str = "text"
|
||||||
|
channel_identity: ChannelIdentity | None = None
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
user_id: str | None = None
|
user_id: str | None = None
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
@ -35,6 +81,8 @@ class OutboundMessage:
|
|||||||
content: str
|
content: str
|
||||||
session_id: str | None
|
session_id: str | None
|
||||||
finish_reason: str
|
finish_reason: str
|
||||||
|
content_type: str = "text"
|
||||||
|
channel_identity: ChannelIdentity | None = None
|
||||||
message_id: str = field(default_factory=lambda: str(uuid4()))
|
message_id: str = field(default_factory=lambda: str(uuid4()))
|
||||||
run_id: str | None = None
|
run_id: str | None = None
|
||||||
provider_name: str | None = None
|
provider_name: str | None = None
|
||||||
|
|||||||
@ -1,7 +1,17 @@
|
|||||||
"""Channel interfaces."""
|
"""Channel interfaces."""
|
||||||
|
|
||||||
from .base import ChannelAdapter
|
from .base import ChannelAdapter
|
||||||
|
from .base import ChannelInboundSink
|
||||||
|
from .external_connector import ExternalConnectorChannel
|
||||||
from .manager import ChannelManager
|
from .manager import ChannelManager
|
||||||
from .memory import MemoryChannelAdapter
|
from .memory import MemoryChannelAdapter
|
||||||
|
from .terminal_websocket import TerminalWebSocketAdapter
|
||||||
|
|
||||||
__all__ = ["ChannelAdapter", "ChannelManager", "MemoryChannelAdapter"]
|
__all__ = [
|
||||||
|
"ChannelAdapter",
|
||||||
|
"ChannelInboundSink",
|
||||||
|
"ExternalConnectorChannel",
|
||||||
|
"ChannelManager",
|
||||||
|
"MemoryChannelAdapter",
|
||||||
|
"TerminalWebSocketAdapter",
|
||||||
|
]
|
||||||
|
|||||||
@ -2,16 +2,17 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
from beaver.foundation.events import MessageBus, OutboundMessage
|
from beaver.foundation.events import InboundMessage, OutboundMessage
|
||||||
|
|
||||||
|
|
||||||
class ChannelAdapter(Protocol):
|
class ChannelAdapter(Protocol):
|
||||||
"""Minimal contract every gateway channel must implement."""
|
"""Minimal contract every runtime channel adapter must implement."""
|
||||||
|
|
||||||
name: str
|
channel_id: str
|
||||||
bus: MessageBus
|
kind: str
|
||||||
|
mode: str
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Prepare the channel before messages are routed."""
|
"""Prepare the channel before messages are routed."""
|
||||||
@ -22,3 +23,9 @@ class ChannelAdapter(Protocol):
|
|||||||
async def send(self, message: OutboundMessage) -> None:
|
async def send(self, message: OutboundMessage) -> None:
|
||||||
"""Deliver an outbound message to the concrete channel."""
|
"""Deliver an outbound message to the concrete channel."""
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelInboundSink(Protocol):
|
||||||
|
"""Runtime callback used by adapters to submit normalized inbound messages."""
|
||||||
|
|
||||||
|
async def accept_inbound(self, message: InboundMessage) -> Any:
|
||||||
|
"""Accept a normalized inbound message from an adapter."""
|
||||||
|
|||||||
@ -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",
|
||||||
|
]
|
||||||
@ -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
|
||||||
@ -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)
|
||||||
@ -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"]
|
||||||
@ -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
|
||||||
@ -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()
|
||||||
@ -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"
|
||||||
@ -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)
|
||||||
@ -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
|
||||||
@ -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)
|
||||||
@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
|
||||||
from beaver.foundation.events import MessageBus, OutboundMessage
|
from beaver.foundation.events import MessageBus, OutboundMessage
|
||||||
@ -20,13 +21,17 @@ class ChannelManager:
|
|||||||
self.started = False
|
self.started = False
|
||||||
|
|
||||||
def register(self, channel: ChannelAdapter) -> None:
|
def register(self, channel: ChannelAdapter) -> None:
|
||||||
if self.started:
|
if channel.channel_id in self.channels:
|
||||||
raise RuntimeError("Cannot register channels after ChannelManager.start()")
|
raise ValueError(f"Channel already registered: {channel.channel_id}")
|
||||||
if channel.name in self.channels:
|
self.channels[channel.channel_id] = channel
|
||||||
raise ValueError(f"Channel already registered: {channel.name}")
|
|
||||||
if channel.bus is not self.bus:
|
def unregister(self, channel_id: str) -> ChannelAdapter | None:
|
||||||
raise ValueError("Channel must share the same MessageBus as ChannelManager")
|
return self.channels.pop(channel_id, None)
|
||||||
self.channels[channel.name] = channel
|
|
||||||
|
def replace_registered(self, channel: ChannelAdapter) -> ChannelAdapter | None:
|
||||||
|
old = self.channels.get(channel.channel_id)
|
||||||
|
self.channels[channel.channel_id] = channel
|
||||||
|
return old
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
started: list[ChannelAdapter] = []
|
started: list[ChannelAdapter] = []
|
||||||
@ -53,7 +58,13 @@ class ChannelManager:
|
|||||||
if errors:
|
if errors:
|
||||||
raise RuntimeError(f"Failed to stop {len(errors)} channel(s)") from errors[0]
|
raise RuntimeError(f"Failed to stop {len(errors)} channel(s)") from errors[0]
|
||||||
|
|
||||||
async def dispatch_outbound(self, stop_event: asyncio.Event) -> None:
|
async def dispatch_outbound(
|
||||||
|
self,
|
||||||
|
stop_event: asyncio.Event,
|
||||||
|
*,
|
||||||
|
on_delivered: Callable[[OutboundMessage], Awaitable[None]] | None = None,
|
||||||
|
on_failed: Callable[[OutboundMessage, Exception | None], Awaitable[None]] | None = None,
|
||||||
|
) -> None:
|
||||||
"""Route bus outbound messages until stopped and the queue is drained."""
|
"""Route bus outbound messages until stopped and the queue is drained."""
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
@ -68,9 +79,16 @@ class ChannelManager:
|
|||||||
channel = self.channels.get(message.channel)
|
channel = self.channels.get(message.channel)
|
||||||
if channel is None:
|
if channel is None:
|
||||||
self.undeliverable.append(message)
|
self.undeliverable.append(message)
|
||||||
|
if on_failed is not None:
|
||||||
|
await on_failed(message, None)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await channel.send(message)
|
await channel.send(message)
|
||||||
except Exception: # pragma: no cover - defensive channel isolation
|
except Exception as exc: # pragma: no cover - defensive channel isolation
|
||||||
self.undeliverable.append(message)
|
self.undeliverable.append(message)
|
||||||
|
if on_failed is not None:
|
||||||
|
await on_failed(message, exc)
|
||||||
|
else:
|
||||||
|
if on_delivered is not None:
|
||||||
|
await on_delivered(message)
|
||||||
|
|||||||
@ -4,15 +4,27 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage
|
from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage
|
||||||
|
from beaver.interfaces.channels.base import ChannelInboundSink
|
||||||
|
|
||||||
|
|
||||||
class MemoryChannelAdapter:
|
class MemoryChannelAdapter:
|
||||||
"""A local channel that stores outbound messages in memory."""
|
"""A local channel that stores outbound messages in memory."""
|
||||||
|
|
||||||
def __init__(self, bus: MessageBus, *, name: str = "memory") -> None:
|
def __init__(
|
||||||
self.name = name
|
self,
|
||||||
self.bus = bus
|
inbound_sink: ChannelInboundSink,
|
||||||
|
*,
|
||||||
|
channel_id: str = "memory-dev",
|
||||||
|
kind: str = "memory",
|
||||||
|
mode: str = "webhook",
|
||||||
|
account_id: str = "memory",
|
||||||
|
) -> None:
|
||||||
|
self.channel_id = channel_id
|
||||||
|
self.kind = kind
|
||||||
|
self.mode = mode
|
||||||
|
self.account_id = account_id
|
||||||
|
self.inbound_sink = inbound_sink
|
||||||
self.started = False
|
self.started = False
|
||||||
self.sent_messages: list[OutboundMessage] = []
|
self.sent_messages: list[OutboundMessage] = []
|
||||||
|
|
||||||
@ -36,12 +48,24 @@ class MemoryChannelAdapter:
|
|||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
provider_name: str | None = None,
|
provider_name: str | None = None,
|
||||||
embedding_model: str | None = None,
|
embedding_model: str | None = None,
|
||||||
|
peer_id: str = "default",
|
||||||
|
thread_id: str | None = None,
|
||||||
|
message_id: str | None = None,
|
||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
) -> InboundMessage:
|
) -> InboundMessage:
|
||||||
"""Publish a text message from this channel into the shared bus."""
|
"""Publish a text message from this channel into the shared bus."""
|
||||||
|
|
||||||
|
identity = ChannelIdentity(
|
||||||
|
channel_id=self.channel_id,
|
||||||
|
kind=self.kind,
|
||||||
|
account_id=self.account_id,
|
||||||
|
peer_id=peer_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
user_id=user_id,
|
||||||
|
message_id=message_id,
|
||||||
|
)
|
||||||
message = InboundMessage(
|
message = InboundMessage(
|
||||||
channel=self.name,
|
channel=self.channel_id,
|
||||||
content=content,
|
content=content,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@ -50,9 +74,10 @@ class MemoryChannelAdapter:
|
|||||||
model=model,
|
model=model,
|
||||||
provider_name=provider_name,
|
provider_name=provider_name,
|
||||||
embedding_model=embedding_model,
|
embedding_model=embedding_model,
|
||||||
|
channel_identity=identity,
|
||||||
metadata=metadata or {},
|
metadata=metadata or {},
|
||||||
)
|
)
|
||||||
await self.bus.publish_inbound(message)
|
await self.inbound_sink.accept_inbound(message)
|
||||||
return message
|
return message
|
||||||
|
|
||||||
async def publish_external_text(
|
async def publish_external_text(
|
||||||
@ -73,9 +98,6 @@ class MemoryChannelAdapter:
|
|||||||
the shared gateway bus.
|
the shared gateway bus.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
session_parts = [self.name, chat_id]
|
|
||||||
if thread_id:
|
|
||||||
session_parts.append(thread_id)
|
|
||||||
metadata = {
|
metadata = {
|
||||||
"chat_id": chat_id,
|
"chat_id": chat_id,
|
||||||
"message_id": message_id,
|
"message_id": message_id,
|
||||||
@ -84,8 +106,10 @@ class MemoryChannelAdapter:
|
|||||||
}
|
}
|
||||||
return await self.publish_text(
|
return await self.publish_text(
|
||||||
content,
|
content,
|
||||||
session_id=":".join(str(part) for part in session_parts if str(part)),
|
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
title=title,
|
title=title,
|
||||||
|
peer_id=chat_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
message_id=message_id,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
"""Platform channel adapters."""
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
526
app-instance/backend/beaver/interfaces/channels/runtime.py
Normal file
526
app-instance/backend/beaver/interfaces/channels/runtime.py
Normal file
@ -0,0 +1,526 @@
|
|||||||
|
"""Channel runtime host for adapter lifecycle and bus-first routing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from beaver.foundation.config.schema import ChannelConfig
|
||||||
|
from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage
|
||||||
|
from beaver.interfaces.channels.base import ChannelAdapter
|
||||||
|
from beaver.interfaces.channels.manager import ChannelManager
|
||||||
|
from beaver.interfaces.channels.state import ChannelDedupeStore, ChannelEventLog
|
||||||
|
from beaver.services.agent_service import AgentService
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_capabilities(kind: str, mode: str) -> list[str]:
|
||||||
|
if kind == "webhook":
|
||||||
|
return ["receive_text", "send_text", "sync_webhook_response"]
|
||||||
|
if kind == "terminal" and mode == "websocket":
|
||||||
|
return ["receive_text", "send_text", "persistent_connection"]
|
||||||
|
if kind in {"feishu", "qqbot", "telegram"}:
|
||||||
|
return ["receive_text", "send_text", "receive_media", "groups"]
|
||||||
|
if kind == "weixin":
|
||||||
|
return ["receive_text", "send_text", "receive_media", "direct_messages"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ChannelAcceptResult:
|
||||||
|
accepted: bool
|
||||||
|
duplicate: bool = False
|
||||||
|
pending: bool = False
|
||||||
|
rejected: bool = False
|
||||||
|
session_id: str | None = None
|
||||||
|
dedupe_key: str | None = None
|
||||||
|
record: dict[str, Any] | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelRuntime:
|
||||||
|
"""Own channel adapters, state, and the inbound/outbound bus bridge."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
service: AgentService,
|
||||||
|
workspace: Path,
|
||||||
|
channels: dict[str, ChannelConfig],
|
||||||
|
bus: MessageBus | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.service = service
|
||||||
|
self.workspace = Path(workspace)
|
||||||
|
self.bus = bus or MessageBus()
|
||||||
|
self.manager = ChannelManager(self.bus)
|
||||||
|
self.channel_configs = dict(channels)
|
||||||
|
self.adapters: dict[str, ChannelAdapter] = {}
|
||||||
|
self.states: dict[str, dict[str, Any]] = {}
|
||||||
|
state_dir = self.workspace / "state" / "channels"
|
||||||
|
retention = self._default_dedupe_retention_hours()
|
||||||
|
self.dedupe = ChannelDedupeStore(state_dir / "dedupe.json", retention_hours=retention)
|
||||||
|
self.events = ChannelEventLog(state_dir / "events.jsonl")
|
||||||
|
self._bridge_task: asyncio.Task[None] | None = None
|
||||||
|
self._dispatch_task: asyncio.Task[None] | None = None
|
||||||
|
self._stop_event = asyncio.Event()
|
||||||
|
self._dispatch_stop_event = asyncio.Event()
|
||||||
|
self._lifecycle_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._dispatch_stop_event.clear()
|
||||||
|
for channel_id, cfg in self.channel_configs.items():
|
||||||
|
if not cfg.enabled:
|
||||||
|
self.states[channel_id] = {"state": "disabled", "last_error": None}
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
adapter = self._build_adapter(channel_id, cfg)
|
||||||
|
self.adapters[channel_id] = adapter
|
||||||
|
self.manager.register(adapter)
|
||||||
|
await adapter.start()
|
||||||
|
self.states[channel_id] = {
|
||||||
|
"state": "running",
|
||||||
|
"last_error": None,
|
||||||
|
"started_at": _iso_now(),
|
||||||
|
}
|
||||||
|
self.events.record(channel_id=channel_id, kind="adapter_started")
|
||||||
|
except Exception as exc: # pragma: no cover - defensive startup isolation
|
||||||
|
self.states[channel_id] = {"state": "error", "last_error": str(exc)}
|
||||||
|
self.events.record(
|
||||||
|
channel_id=channel_id,
|
||||||
|
kind="adapter_error",
|
||||||
|
status="error",
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
self._bridge_task = asyncio.create_task(self._bridge_inbound_to_agent())
|
||||||
|
self._dispatch_task = asyncio.create_task(
|
||||||
|
self.manager.dispatch_outbound(
|
||||||
|
self._dispatch_stop_event,
|
||||||
|
on_delivered=self._record_outbound_delivered,
|
||||||
|
on_failed=self._record_outbound_failed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._bridge_task is not None:
|
||||||
|
self._bridge_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._bridge_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._dispatch_stop_event.set()
|
||||||
|
if self._dispatch_task is not None:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._dispatch_task, timeout=1.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
self._dispatch_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._dispatch_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
await self.manager.stop()
|
||||||
|
for channel_id in self.adapters:
|
||||||
|
self.events.record(channel_id=channel_id, kind="adapter_stopped")
|
||||||
|
|
||||||
|
async def add_channel(self, channel_id: str, config: ChannelConfig) -> None:
|
||||||
|
async with self._lifecycle_lock:
|
||||||
|
current = self.channel_configs.get(channel_id)
|
||||||
|
if current == config and channel_id in self.adapters:
|
||||||
|
return
|
||||||
|
if not config.enabled:
|
||||||
|
await self._remove_channel_locked(channel_id)
|
||||||
|
self.channel_configs[channel_id] = config
|
||||||
|
self.states[channel_id] = {"state": "disabled", "last_error": None}
|
||||||
|
return
|
||||||
|
|
||||||
|
adapter = self._build_adapter(channel_id, config)
|
||||||
|
await adapter.start()
|
||||||
|
old_adapter = self.adapters.get(channel_id)
|
||||||
|
self.manager.replace_registered(adapter)
|
||||||
|
self.adapters[channel_id] = adapter
|
||||||
|
self.channel_configs[channel_id] = config
|
||||||
|
self.states[channel_id] = {"state": "running", "last_error": None, "started_at": _iso_now()}
|
||||||
|
self.events.record(channel_id=channel_id, kind="adapter_started")
|
||||||
|
if old_adapter is not None and old_adapter is not adapter:
|
||||||
|
await old_adapter.stop()
|
||||||
|
|
||||||
|
async def remove_channel(self, channel_id: str) -> None:
|
||||||
|
async with self._lifecycle_lock:
|
||||||
|
await self._remove_channel_locked(channel_id)
|
||||||
|
|
||||||
|
async def _remove_channel_locked(self, channel_id: str) -> None:
|
||||||
|
adapter = self.adapters.pop(channel_id, None)
|
||||||
|
self.manager.unregister(channel_id)
|
||||||
|
self.channel_configs.pop(channel_id, None)
|
||||||
|
if adapter is not None:
|
||||||
|
await adapter.stop()
|
||||||
|
self.events.record(channel_id=channel_id, kind="adapter_stopped")
|
||||||
|
self.states[channel_id] = {"state": "removed", "last_error": None}
|
||||||
|
|
||||||
|
async def accept_inbound(self, message: InboundMessage) -> ChannelAcceptResult:
|
||||||
|
identity = message.channel_identity
|
||||||
|
if identity is None:
|
||||||
|
self.events.record(
|
||||||
|
channel_id=message.channel,
|
||||||
|
kind="inbound_rejected",
|
||||||
|
status="error",
|
||||||
|
error="channel_identity is required",
|
||||||
|
)
|
||||||
|
return ChannelAcceptResult(
|
||||||
|
accepted=False,
|
||||||
|
rejected=True,
|
||||||
|
error="channel_identity is required",
|
||||||
|
)
|
||||||
|
|
||||||
|
validation_error = identity.validation_error()
|
||||||
|
if validation_error:
|
||||||
|
self.events.record(
|
||||||
|
channel_id=identity.channel_id,
|
||||||
|
kind="inbound_rejected",
|
||||||
|
status="error",
|
||||||
|
error=validation_error,
|
||||||
|
)
|
||||||
|
return ChannelAcceptResult(accepted=False, rejected=True, error=validation_error)
|
||||||
|
|
||||||
|
expected_session_id = identity.session_id()
|
||||||
|
if message.session_id != expected_session_id:
|
||||||
|
self.events.record(
|
||||||
|
channel_id=identity.channel_id,
|
||||||
|
kind="session_id_normalized",
|
||||||
|
session_id=expected_session_id,
|
||||||
|
message_id=identity.message_id,
|
||||||
|
)
|
||||||
|
message.session_id = expected_session_id
|
||||||
|
message.channel = identity.channel_id
|
||||||
|
|
||||||
|
dedupe_key = identity.dedupe_key()
|
||||||
|
if dedupe_key:
|
||||||
|
write = self.dedupe.mark_processing(
|
||||||
|
dedupe_key=dedupe_key,
|
||||||
|
session_id=expected_session_id,
|
||||||
|
message_id=identity.message_id or "",
|
||||||
|
)
|
||||||
|
if not write.created:
|
||||||
|
record = write.record or {}
|
||||||
|
self.events.record(
|
||||||
|
channel_id=identity.channel_id,
|
||||||
|
kind="inbound_duplicate",
|
||||||
|
session_id=expected_session_id,
|
||||||
|
message_id=identity.message_id,
|
||||||
|
status=str(record.get("status") or "processing"),
|
||||||
|
)
|
||||||
|
return ChannelAcceptResult(
|
||||||
|
accepted=False,
|
||||||
|
duplicate=True,
|
||||||
|
pending=record.get("status") == "processing",
|
||||||
|
session_id=expected_session_id,
|
||||||
|
dedupe_key=dedupe_key,
|
||||||
|
record=record,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.events.record(
|
||||||
|
channel_id=identity.channel_id,
|
||||||
|
kind="inbound_accepted",
|
||||||
|
session_id=expected_session_id,
|
||||||
|
message_id=identity.message_id,
|
||||||
|
text=message.content,
|
||||||
|
)
|
||||||
|
await self.bus.publish_inbound(message)
|
||||||
|
return ChannelAcceptResult(
|
||||||
|
accepted=True,
|
||||||
|
session_id=expected_session_id,
|
||||||
|
dedupe_key=dedupe_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
def statuses(self) -> list[dict[str, Any]]:
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
recent = self.events.recent(limit=500)
|
||||||
|
last_by_channel = {event["channel_id"]: event for event in recent if event.get("channel_id")}
|
||||||
|
for channel_id, cfg in self.channel_configs.items():
|
||||||
|
state = self.states.get(channel_id, {"state": "configured", "last_error": None})
|
||||||
|
capabilities = _channel_capabilities(cfg.kind, cfg.mode)
|
||||||
|
webhook_url = None
|
||||||
|
websocket_url = None
|
||||||
|
connected_peers = 0
|
||||||
|
if cfg.kind == "webhook":
|
||||||
|
webhook_url = f"/api/channels/{channel_id}/webhook"
|
||||||
|
elif cfg.kind == "terminal" and cfg.mode == "websocket":
|
||||||
|
websocket_url = f"/api/channels/{channel_id}/ws"
|
||||||
|
adapter = self.adapters.get(channel_id)
|
||||||
|
if adapter is not None and hasattr(adapter, "status_extra"):
|
||||||
|
extra = adapter.status_extra() # type: ignore[attr-defined]
|
||||||
|
connected_peers = int(extra.get("connected_peers") or 0)
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"name": channel_id,
|
||||||
|
"kind": cfg.kind,
|
||||||
|
"mode": cfg.mode,
|
||||||
|
"display_name": cfg.display_name or channel_id,
|
||||||
|
"enabled": cfg.enabled,
|
||||||
|
"state": state.get("state", "configured"),
|
||||||
|
"account_id": cfg.account_id,
|
||||||
|
"last_error": state.get("last_error"),
|
||||||
|
"started_at": state.get("started_at"),
|
||||||
|
"last_event_at": last_by_channel.get(channel_id, {}).get("created_at"),
|
||||||
|
"capabilities": capabilities,
|
||||||
|
"webhook_url": webhook_url,
|
||||||
|
"websocket_url": websocket_url,
|
||||||
|
"connected_peers": connected_peers,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
def recent_events(self, channel_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||||
|
return self.events.recent(channel_id=channel_id, limit=limit)
|
||||||
|
|
||||||
|
def record_event(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
channel_id: str,
|
||||||
|
kind: str,
|
||||||
|
session_id: str | None = None,
|
||||||
|
message_id: str | None = None,
|
||||||
|
run_id: str | None = None,
|
||||||
|
status: str = "ok",
|
||||||
|
error: str | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.events.record(
|
||||||
|
channel_id=channel_id,
|
||||||
|
kind=kind,
|
||||||
|
session_id=session_id,
|
||||||
|
message_id=message_id,
|
||||||
|
run_id=run_id,
|
||||||
|
status=status,
|
||||||
|
error=error,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_adapter(self, channel_id: str, cfg: ChannelConfig) -> ChannelAdapter:
|
||||||
|
if cfg.kind == "webhook" and cfg.mode == "webhook":
|
||||||
|
from beaver.interfaces.channels.generic_webhook import GenericWebhookAdapter
|
||||||
|
|
||||||
|
return GenericWebhookAdapter(
|
||||||
|
channel_id=channel_id,
|
||||||
|
kind=cfg.kind,
|
||||||
|
mode=cfg.mode,
|
||||||
|
account_id=cfg.account_id,
|
||||||
|
display_name=cfg.display_name,
|
||||||
|
inbound_sink=self,
|
||||||
|
response_timeout_seconds=float(cfg.config.get("response_timeout_seconds") or 1800),
|
||||||
|
)
|
||||||
|
|
||||||
|
if cfg.kind == "terminal" and cfg.mode == "websocket":
|
||||||
|
from beaver.interfaces.channels.terminal_websocket import TerminalWebSocketAdapter
|
||||||
|
|
||||||
|
return TerminalWebSocketAdapter(
|
||||||
|
channel_id=channel_id,
|
||||||
|
kind=cfg.kind,
|
||||||
|
mode=cfg.mode,
|
||||||
|
account_id=cfg.account_id,
|
||||||
|
display_name=cfg.display_name,
|
||||||
|
inbound_sink=self,
|
||||||
|
event_recorder=self.record_event,
|
||||||
|
heartbeat_seconds=float(cfg.config.get("heartbeat_seconds") or 30),
|
||||||
|
max_message_chars=int(cfg.config.get("max_message_chars") or 20000),
|
||||||
|
)
|
||||||
|
|
||||||
|
if cfg.kind == "telegram" and cfg.mode in {"polling", "webhook"}:
|
||||||
|
from beaver.interfaces.channels.platforms.telegram import TelegramAdapter
|
||||||
|
|
||||||
|
return TelegramAdapter(
|
||||||
|
channel_id=channel_id,
|
||||||
|
kind=cfg.kind,
|
||||||
|
mode=cfg.mode,
|
||||||
|
account_id=cfg.account_id,
|
||||||
|
display_name=cfg.display_name,
|
||||||
|
inbound_sink=self,
|
||||||
|
secrets=cfg.secrets,
|
||||||
|
config=cfg.config,
|
||||||
|
event_recorder=self.record_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
if cfg.kind == "feishu" and cfg.mode in {"websocket", "webhook"}:
|
||||||
|
from beaver.interfaces.channels.platforms.feishu import FeishuAdapter
|
||||||
|
|
||||||
|
return FeishuAdapter(
|
||||||
|
channel_id=channel_id,
|
||||||
|
kind=cfg.kind,
|
||||||
|
mode=cfg.mode,
|
||||||
|
account_id=cfg.account_id,
|
||||||
|
display_name=cfg.display_name,
|
||||||
|
inbound_sink=self,
|
||||||
|
secrets=cfg.secrets,
|
||||||
|
config=cfg.config,
|
||||||
|
event_recorder=self.record_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
if cfg.kind == "qqbot" and cfg.mode == "websocket":
|
||||||
|
from beaver.interfaces.channels.platforms.qqbot import QQBotAdapter
|
||||||
|
|
||||||
|
return QQBotAdapter(
|
||||||
|
channel_id=channel_id,
|
||||||
|
kind=cfg.kind,
|
||||||
|
mode=cfg.mode,
|
||||||
|
account_id=cfg.account_id,
|
||||||
|
display_name=cfg.display_name,
|
||||||
|
inbound_sink=self,
|
||||||
|
secrets=cfg.secrets,
|
||||||
|
config=cfg.config,
|
||||||
|
event_recorder=self.record_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
if cfg.kind == "weixin" and cfg.mode == "polling":
|
||||||
|
from beaver.interfaces.channels.platforms.weixin import WeixinAdapter
|
||||||
|
|
||||||
|
return WeixinAdapter(
|
||||||
|
channel_id=channel_id,
|
||||||
|
kind=cfg.kind,
|
||||||
|
mode=cfg.mode,
|
||||||
|
account_id=cfg.account_id,
|
||||||
|
display_name=cfg.display_name,
|
||||||
|
inbound_sink=self,
|
||||||
|
secrets=cfg.secrets,
|
||||||
|
config=cfg.config,
|
||||||
|
event_recorder=self.record_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
if cfg.kind == "external_connector" and cfg.mode == "http":
|
||||||
|
import os
|
||||||
|
|
||||||
|
from beaver.interfaces.channels.connections.sidecar_client import ConnectorSidecarClient
|
||||||
|
from beaver.interfaces.channels.external_connector import ExternalConnectorChannel
|
||||||
|
|
||||||
|
base_url = str(cfg.config.get("sidecarBaseUrl") or os.getenv("EXTERNAL_CONNECTOR_BASE_URL") or "").strip()
|
||||||
|
token = os.getenv("EXTERNAL_CONNECTOR_TOKEN", "")
|
||||||
|
platform_kind = str(cfg.config.get("platformKind") or "").strip()
|
||||||
|
connection_id = str(cfg.config.get("connectionId") or "").strip()
|
||||||
|
if not base_url:
|
||||||
|
raise ValueError("external connector sidecarBaseUrl is required")
|
||||||
|
if not platform_kind:
|
||||||
|
raise ValueError("external connector platformKind is required")
|
||||||
|
if not connection_id:
|
||||||
|
raise ValueError("external connector connectionId is required")
|
||||||
|
return ExternalConnectorChannel(
|
||||||
|
channel_id=channel_id,
|
||||||
|
platform_kind=platform_kind,
|
||||||
|
connection_id=connection_id,
|
||||||
|
account_id=cfg.account_id,
|
||||||
|
display_name=cfg.display_name,
|
||||||
|
sidecar_client=ConnectorSidecarClient(base_url=base_url, token=token),
|
||||||
|
)
|
||||||
|
|
||||||
|
raise ValueError(f"Unsupported channel kind/mode: {cfg.kind}/{cfg.mode}")
|
||||||
|
|
||||||
|
async def _bridge_inbound_to_agent(self) -> None:
|
||||||
|
current_inbound: InboundMessage | None = None
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
try:
|
||||||
|
current_inbound = await asyncio.wait_for(self.bus.consume_inbound(), timeout=0.25)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
continue
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
inbound = current_inbound
|
||||||
|
identity = inbound.channel_identity
|
||||||
|
try:
|
||||||
|
self.events.record(
|
||||||
|
channel_id=inbound.channel,
|
||||||
|
kind="direct_run_started",
|
||||||
|
session_id=inbound.session_id,
|
||||||
|
message_id=identity.message_id if identity else inbound.message_id,
|
||||||
|
)
|
||||||
|
outbound = await self.service.handle_inbound_message(inbound)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
outbound = AgentService.build_outbound_error(
|
||||||
|
inbound,
|
||||||
|
detail="Channel runtime stopped before completing the inbound message",
|
||||||
|
finish_reason="cancelled",
|
||||||
|
)
|
||||||
|
self._mark_dedupe_result(inbound, outbound)
|
||||||
|
await self.bus.publish_outbound(outbound)
|
||||||
|
current_inbound = None
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
self.events.record(
|
||||||
|
channel_id=inbound.channel,
|
||||||
|
kind="direct_run_failed",
|
||||||
|
session_id=inbound.session_id,
|
||||||
|
message_id=identity.message_id if identity else inbound.message_id,
|
||||||
|
status="error",
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
outbound = AgentService.build_outbound_error(
|
||||||
|
inbound,
|
||||||
|
detail=str(exc),
|
||||||
|
finish_reason="error",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.events.record(
|
||||||
|
channel_id=outbound.channel,
|
||||||
|
kind="direct_run_finished",
|
||||||
|
session_id=outbound.session_id,
|
||||||
|
message_id=identity.message_id if identity else inbound.message_id,
|
||||||
|
run_id=outbound.run_id,
|
||||||
|
)
|
||||||
|
self._mark_dedupe_result(inbound, outbound)
|
||||||
|
await self.bus.publish_outbound(outbound)
|
||||||
|
current_inbound = None
|
||||||
|
|
||||||
|
def _mark_dedupe_result(self, inbound: InboundMessage, outbound: OutboundMessage) -> None:
|
||||||
|
identity = inbound.channel_identity
|
||||||
|
dedupe_key = identity.dedupe_key() if identity else None
|
||||||
|
if not dedupe_key:
|
||||||
|
return
|
||||||
|
cfg = self.channel_configs.get(identity.channel_id)
|
||||||
|
max_reply_chars = int((cfg.config if cfg else {}).get("max_cached_reply_chars") or 20000)
|
||||||
|
max_error_chars = int((cfg.config if cfg else {}).get("max_cached_error_chars") or 4000)
|
||||||
|
if outbound.finish_reason == "error":
|
||||||
|
self.dedupe.mark_error(
|
||||||
|
dedupe_key=dedupe_key,
|
||||||
|
error=outbound.content,
|
||||||
|
max_error_chars=max_error_chars,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.dedupe.mark_done(
|
||||||
|
dedupe_key=dedupe_key,
|
||||||
|
run_id=outbound.run_id,
|
||||||
|
reply=outbound.content,
|
||||||
|
max_reply_chars=max_reply_chars,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _record_outbound_delivered(self, message: OutboundMessage) -> None:
|
||||||
|
kind = "outbound_unclaimed" if message.metadata.get("delivery_status") == "unclaimed" else "outbound_delivered"
|
||||||
|
self.events.record(
|
||||||
|
channel_id=message.channel,
|
||||||
|
kind=kind,
|
||||||
|
session_id=message.session_id,
|
||||||
|
message_id=message.channel_identity.message_id if message.channel_identity else message.message_id,
|
||||||
|
run_id=message.run_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _record_outbound_failed(self, message: OutboundMessage, exc: Exception | None) -> None:
|
||||||
|
self.events.record(
|
||||||
|
channel_id=message.channel,
|
||||||
|
kind="outbound_delivery_failed",
|
||||||
|
session_id=message.session_id,
|
||||||
|
message_id=message.channel_identity.message_id if message.channel_identity else message.message_id,
|
||||||
|
run_id=message.run_id,
|
||||||
|
status="error",
|
||||||
|
error=str(exc) if exc else "channel not registered",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _default_dedupe_retention_hours(self) -> int:
|
||||||
|
for cfg in self.channel_configs.values():
|
||||||
|
value = cfg.config.get("dedupe_retention_hours")
|
||||||
|
if value is not None:
|
||||||
|
return int(value)
|
||||||
|
return 48
|
||||||
198
app-instance/backend/beaver/interfaces/channels/state.py
Normal file
198
app-instance/backend/beaver/interfaces/channels/state.py
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
"""Persistent channel runtime state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
|
||||||
|
def _now_ms() -> int:
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class DedupeWriteResult:
|
||||||
|
created: bool
|
||||||
|
record: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelDedupeStore:
|
||||||
|
def __init__(self, path: Path, *, retention_hours: int = 48) -> None:
|
||||||
|
self.path = path
|
||||||
|
self.retention_ms = max(1, int(retention_hours)) * 60 * 60 * 1000
|
||||||
|
self._lock = Lock()
|
||||||
|
|
||||||
|
def get(self, dedupe_key: str) -> dict[str, Any] | None:
|
||||||
|
with self._lock:
|
||||||
|
data = self._load()
|
||||||
|
self._prune_unlocked(data, _now_ms())
|
||||||
|
record = data["records"].get(dedupe_key)
|
||||||
|
self._save(data)
|
||||||
|
return record
|
||||||
|
|
||||||
|
def mark_processing(self, *, dedupe_key: str, session_id: str, message_id: str) -> DedupeWriteResult:
|
||||||
|
with self._lock:
|
||||||
|
data = self._load()
|
||||||
|
now_ms = _now_ms()
|
||||||
|
self._prune_unlocked(data, now_ms)
|
||||||
|
existing = data["records"].get(dedupe_key)
|
||||||
|
if existing is not None:
|
||||||
|
self._save(data)
|
||||||
|
return DedupeWriteResult(created=False, record=existing)
|
||||||
|
|
||||||
|
record = {
|
||||||
|
"dedupe_key": dedupe_key,
|
||||||
|
"status": "processing",
|
||||||
|
"session_id": session_id,
|
||||||
|
"message_id": message_id,
|
||||||
|
"run_id": None,
|
||||||
|
"reply": None,
|
||||||
|
"error": None,
|
||||||
|
"created_at_ms": now_ms,
|
||||||
|
"updated_at_ms": now_ms,
|
||||||
|
}
|
||||||
|
data["records"][dedupe_key] = record
|
||||||
|
self._save(data)
|
||||||
|
return DedupeWriteResult(created=True, record=record)
|
||||||
|
|
||||||
|
def mark_done(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
dedupe_key: str,
|
||||||
|
run_id: str | None,
|
||||||
|
reply: str,
|
||||||
|
max_reply_chars: int,
|
||||||
|
) -> None:
|
||||||
|
self._mark_result(
|
||||||
|
dedupe_key=dedupe_key,
|
||||||
|
status="done",
|
||||||
|
run_id=run_id,
|
||||||
|
reply=reply[: max(0, int(max_reply_chars))],
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def mark_error(self, *, dedupe_key: str, error: str, max_error_chars: int) -> None:
|
||||||
|
self._mark_result(
|
||||||
|
dedupe_key=dedupe_key,
|
||||||
|
status="error",
|
||||||
|
run_id=None,
|
||||||
|
reply=None,
|
||||||
|
error=error[: max(0, int(max_error_chars))],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mark_result(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
dedupe_key: str,
|
||||||
|
status: str,
|
||||||
|
run_id: str | None,
|
||||||
|
reply: str | None,
|
||||||
|
error: str | None,
|
||||||
|
) -> None:
|
||||||
|
with self._lock:
|
||||||
|
data = self._load()
|
||||||
|
record = data["records"].get(dedupe_key)
|
||||||
|
if record is None:
|
||||||
|
record = {"dedupe_key": dedupe_key, "created_at_ms": _now_ms()}
|
||||||
|
data["records"][dedupe_key] = record
|
||||||
|
record.update(
|
||||||
|
{
|
||||||
|
"status": status,
|
||||||
|
"run_id": run_id,
|
||||||
|
"reply": reply,
|
||||||
|
"error": error,
|
||||||
|
"updated_at_ms": _now_ms(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._save(data)
|
||||||
|
|
||||||
|
def _load(self) -> dict[str, Any]:
|
||||||
|
if not self.path.exists():
|
||||||
|
return {"records": {}}
|
||||||
|
try:
|
||||||
|
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {"records": {}}
|
||||||
|
if not isinstance(data, dict) or not isinstance(data.get("records"), dict):
|
||||||
|
return {"records": {}}
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _save(self, data: dict[str, Any]) -> None:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp_path = self.path.with_name(f"{self.path.name}.tmp")
|
||||||
|
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
tmp_path.replace(self.path)
|
||||||
|
|
||||||
|
def _prune_unlocked(self, data: dict[str, Any], now_ms: int) -> None:
|
||||||
|
records = data.get("records", {})
|
||||||
|
expired_before = now_ms - self.retention_ms
|
||||||
|
for key, record in list(records.items()):
|
||||||
|
updated_at_ms = int(record.get("updated_at_ms") or record.get("created_at_ms") or 0)
|
||||||
|
if updated_at_ms < expired_before:
|
||||||
|
records.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelEventLog:
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
self.path = path
|
||||||
|
self._lock = Lock()
|
||||||
|
|
||||||
|
def record(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
channel_id: str,
|
||||||
|
kind: str,
|
||||||
|
session_id: str | None = None,
|
||||||
|
message_id: str | None = None,
|
||||||
|
run_id: str | None = None,
|
||||||
|
status: str = "ok",
|
||||||
|
error: str | None = None,
|
||||||
|
text: str | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
entry = {
|
||||||
|
"event_id": uuid4().hex,
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"kind": kind,
|
||||||
|
"session_id": session_id,
|
||||||
|
"message_id": message_id,
|
||||||
|
"run_id": run_id,
|
||||||
|
"status": status,
|
||||||
|
"error": error,
|
||||||
|
"text_preview": (text or "")[:120] if text else None,
|
||||||
|
"text_length": len(text) if text else 0,
|
||||||
|
"metadata": metadata or {},
|
||||||
|
"created_at": _iso_now(),
|
||||||
|
}
|
||||||
|
with self._lock:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self.path.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def recent(self, *, channel_id: str | None = None, limit: int = 100) -> list[dict[str, Any]]:
|
||||||
|
if not self.path.exists():
|
||||||
|
return []
|
||||||
|
lines = self.path.read_text(encoding="utf-8").splitlines()
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for line in reversed(lines):
|
||||||
|
try:
|
||||||
|
item = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if channel_id and item.get("channel_id") != channel_id:
|
||||||
|
continue
|
||||||
|
items.append(item)
|
||||||
|
if len(items) >= max(1, int(limit)):
|
||||||
|
break
|
||||||
|
return list(reversed(items))
|
||||||
@ -0,0 +1,301 @@
|
|||||||
|
"""Text-only terminal WebSocket channel adapter."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from contextlib import suppress
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage
|
||||||
|
from beaver.interfaces.channels.base import ChannelInboundSink
|
||||||
|
|
||||||
|
try:
|
||||||
|
from fastapi import WebSocket
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
except ModuleNotFoundError: # pragma: no cover - import-only fallback
|
||||||
|
class WebSocketDisconnect(Exception):
|
||||||
|
"""Fallback disconnect exception for skeleton import environments."""
|
||||||
|
|
||||||
|
class WebSocket: # type: ignore[override]
|
||||||
|
"""Fallback websocket annotation shim."""
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value: Any) -> str:
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class TerminalConnection:
|
||||||
|
websocket: WebSocket
|
||||||
|
peer_id: str
|
||||||
|
session_id: str
|
||||||
|
thread_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
device_name: str = ""
|
||||||
|
capabilities: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalWebSocketAdapter:
|
||||||
|
"""Accept text terminal websocket frames and deliver final assistant replies."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
channel_id: str,
|
||||||
|
kind: str,
|
||||||
|
mode: str,
|
||||||
|
account_id: str,
|
||||||
|
display_name: str = "",
|
||||||
|
inbound_sink: ChannelInboundSink,
|
||||||
|
event_recorder: Callable[..., None] | None = None,
|
||||||
|
heartbeat_seconds: float = 30,
|
||||||
|
max_message_chars: int = 20000,
|
||||||
|
) -> None:
|
||||||
|
self.channel_id = channel_id
|
||||||
|
self.kind = kind
|
||||||
|
self.mode = mode
|
||||||
|
self.account_id = account_id
|
||||||
|
self.display_name = display_name or channel_id
|
||||||
|
self.inbound_sink = inbound_sink
|
||||||
|
self.event_recorder = event_recorder
|
||||||
|
self.heartbeat_seconds = max(1.0, float(heartbeat_seconds))
|
||||||
|
self.max_message_chars = max(1, int(max_message_chars))
|
||||||
|
self.started = False
|
||||||
|
self._connections_by_session: dict[str, TerminalConnection] = {}
|
||||||
|
self._session_by_peer: dict[str, str] = {}
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
self.started = True
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self.started = False
|
||||||
|
for connection in list(self._connections_by_session.values()):
|
||||||
|
with suppress(Exception):
|
||||||
|
await connection.websocket.close(code=1001)
|
||||||
|
self._connections_by_session.clear()
|
||||||
|
self._session_by_peer.clear()
|
||||||
|
|
||||||
|
def status_extra(self) -> dict[str, Any]:
|
||||||
|
return {"connected_peers": len(self._connections_by_session)}
|
||||||
|
|
||||||
|
async def handle_websocket(self, websocket: WebSocket) -> None:
|
||||||
|
await websocket.accept()
|
||||||
|
connection: TerminalConnection | None = None
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
payload = await websocket.receive_json()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
await websocket.send_json({"type": "error", "error": "Invalid websocket JSON payload"})
|
||||||
|
continue
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
await websocket.send_json({"type": "error", "error": "Websocket payload must be a JSON object"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
frame_type = _clean(payload.get("type")).lower()
|
||||||
|
if frame_type == "ping":
|
||||||
|
await websocket.send_json({"type": "pong"})
|
||||||
|
continue
|
||||||
|
if frame_type == "connect":
|
||||||
|
connection = await self._handle_connect(websocket, payload, current=connection)
|
||||||
|
continue
|
||||||
|
if frame_type == "message":
|
||||||
|
if connection is None:
|
||||||
|
await websocket.send_json({"type": "error", "error": "connect is required before message"})
|
||||||
|
continue
|
||||||
|
await self._handle_message(websocket, connection, payload)
|
||||||
|
continue
|
||||||
|
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"error": f"Unsupported websocket frame type: {frame_type or '<empty>'}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if connection is not None:
|
||||||
|
self._remove_connection(connection)
|
||||||
|
self._record(
|
||||||
|
kind="terminal_disconnected",
|
||||||
|
session_id=connection.session_id,
|
||||||
|
metadata={"peer_id": connection.peer_id, "device_name": connection.device_name},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _handle_connect(
|
||||||
|
self,
|
||||||
|
websocket: WebSocket,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
current: TerminalConnection | None,
|
||||||
|
) -> TerminalConnection | None:
|
||||||
|
peer_id = _clean(payload.get("peer_id"))
|
||||||
|
if not peer_id:
|
||||||
|
await websocket.send_json({"type": "error", "error": "peer_id is required"})
|
||||||
|
return current
|
||||||
|
|
||||||
|
thread_id = _clean(payload.get("thread_id")) or None
|
||||||
|
user_id = _clean(payload.get("user_id")) or None
|
||||||
|
device_name = _clean(payload.get("device_name"))
|
||||||
|
capabilities = [str(item) for item in payload.get("capabilities") or [] if item is not None]
|
||||||
|
identity = ChannelIdentity(
|
||||||
|
channel_id=self.channel_id,
|
||||||
|
kind=self.kind,
|
||||||
|
account_id=self.account_id,
|
||||||
|
peer_id=peer_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
peer_type="terminal",
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
session_id = identity.session_id()
|
||||||
|
connection = TerminalConnection(
|
||||||
|
websocket=websocket,
|
||||||
|
peer_id=peer_id,
|
||||||
|
session_id=session_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
user_id=user_id,
|
||||||
|
device_name=device_name,
|
||||||
|
capabilities=capabilities,
|
||||||
|
)
|
||||||
|
|
||||||
|
if current is not None and current.session_id != session_id:
|
||||||
|
self._remove_connection(current)
|
||||||
|
old = self._connections_by_session.get(session_id)
|
||||||
|
if old is not None and old.websocket is not websocket:
|
||||||
|
with suppress(Exception):
|
||||||
|
await old.websocket.close(code=1000)
|
||||||
|
self._connections_by_session[session_id] = connection
|
||||||
|
self._session_by_peer[peer_id] = session_id
|
||||||
|
self._record(
|
||||||
|
kind="terminal_connected",
|
||||||
|
session_id=session_id,
|
||||||
|
metadata={"peer_id": peer_id, "device_name": device_name, "capabilities": capabilities},
|
||||||
|
)
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"type": "connected",
|
||||||
|
"channel_id": self.channel_id,
|
||||||
|
"session_id": session_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return connection
|
||||||
|
|
||||||
|
async def _handle_message(
|
||||||
|
self,
|
||||||
|
websocket: WebSocket,
|
||||||
|
connection: TerminalConnection,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
message_id = _clean(payload.get("message_id"))
|
||||||
|
text = _clean(payload.get("text"))
|
||||||
|
if not message_id:
|
||||||
|
await websocket.send_json({"type": "error", "error": "message_id is required"})
|
||||||
|
return
|
||||||
|
if not text:
|
||||||
|
await websocket.send_json({"type": "error", "error": "text is required"})
|
||||||
|
return
|
||||||
|
if len(text) > self.max_message_chars:
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"error": f"text exceeds max_message_chars ({self.max_message_chars})",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
thread_id = _clean(payload.get("thread_id")) or connection.thread_id
|
||||||
|
user_id = _clean(payload.get("user_id")) or connection.user_id
|
||||||
|
identity = ChannelIdentity(
|
||||||
|
channel_id=self.channel_id,
|
||||||
|
kind=self.kind,
|
||||||
|
account_id=self.account_id,
|
||||||
|
peer_id=connection.peer_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
peer_type="terminal",
|
||||||
|
user_id=user_id,
|
||||||
|
message_id=message_id,
|
||||||
|
)
|
||||||
|
inbound = InboundMessage(
|
||||||
|
channel=self.channel_id,
|
||||||
|
content=text,
|
||||||
|
content_type="text",
|
||||||
|
user_id=user_id,
|
||||||
|
channel_identity=identity,
|
||||||
|
metadata={
|
||||||
|
"terminal": {
|
||||||
|
"peer_id": connection.peer_id,
|
||||||
|
"device_name": connection.device_name,
|
||||||
|
"capabilities": connection.capabilities,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
accept = await self.inbound_sink.accept_inbound(inbound)
|
||||||
|
ack: dict[str, Any] = {
|
||||||
|
"type": "ack",
|
||||||
|
"message_id": message_id,
|
||||||
|
"session_id": accept.session_id or identity.session_id(),
|
||||||
|
"accepted": accept.accepted,
|
||||||
|
}
|
||||||
|
if accept.duplicate:
|
||||||
|
ack["duplicate"] = True
|
||||||
|
ack["pending"] = accept.pending
|
||||||
|
record = accept.record or {}
|
||||||
|
if record.get("reply"):
|
||||||
|
ack["reply"] = record["reply"]
|
||||||
|
if accept.error or record.get("error"):
|
||||||
|
ack["error"] = accept.error or record.get("error")
|
||||||
|
await websocket.send_json(ack)
|
||||||
|
|
||||||
|
async def send(self, message: OutboundMessage) -> None:
|
||||||
|
session_id = message.session_id
|
||||||
|
if not session_id and message.channel_identity is not None:
|
||||||
|
session_id = message.channel_identity.session_id()
|
||||||
|
connection = self._connections_by_session.get(session_id or "")
|
||||||
|
if connection is None:
|
||||||
|
message.metadata["delivery_status"] = "unclaimed"
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"message_id": message.channel_identity.message_id if message.channel_identity else message.message_id,
|
||||||
|
"run_id": message.run_id,
|
||||||
|
"text": message.content,
|
||||||
|
"finish_reason": message.finish_reason,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
await connection.websocket.send_json(payload)
|
||||||
|
except Exception:
|
||||||
|
message.metadata["delivery_status"] = "unclaimed"
|
||||||
|
self._remove_connection(connection)
|
||||||
|
|
||||||
|
def _remove_connection(self, connection: TerminalConnection) -> None:
|
||||||
|
current = self._connections_by_session.get(connection.session_id)
|
||||||
|
if current is connection:
|
||||||
|
self._connections_by_session.pop(connection.session_id, None)
|
||||||
|
if self._session_by_peer.get(connection.peer_id) == connection.session_id:
|
||||||
|
self._session_by_peer.pop(connection.peer_id, None)
|
||||||
|
|
||||||
|
def _record(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
session_id: str | None = None,
|
||||||
|
message_id: str | None = None,
|
||||||
|
status: str = "ok",
|
||||||
|
error: str | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
if self.event_recorder is None:
|
||||||
|
return
|
||||||
|
self.event_recorder(
|
||||||
|
channel_id=self.channel_id,
|
||||||
|
kind=kind,
|
||||||
|
session_id=session_id,
|
||||||
|
message_id=message_id,
|
||||||
|
status=status,
|
||||||
|
error=error,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
@ -19,6 +19,18 @@ from typing import Any
|
|||||||
|
|
||||||
from beaver.engine.providers.registry import PROVIDERS, find_by_name
|
from beaver.engine.providers.registry import PROVIDERS, find_by_name
|
||||||
from beaver.foundation.config import default_config_path, load_config
|
from beaver.foundation.config import default_config_path, load_config
|
||||||
|
from beaver.foundation.events import ChannelIdentity, InboundMessage
|
||||||
|
from beaver.interfaces.channels.runtime import ChannelRuntime
|
||||||
|
from beaver.interfaces.channels.connections import (
|
||||||
|
ChannelConnectionStore,
|
||||||
|
ChannelConnectorRegistry,
|
||||||
|
ConnectorSidecarClient,
|
||||||
|
CredentialStore,
|
||||||
|
FeishuConnector,
|
||||||
|
MessageDedupeStore,
|
||||||
|
TelegramConnector,
|
||||||
|
WeixinConnector,
|
||||||
|
)
|
||||||
from beaver.foundation.models import CronExecutionResult, CronRunRecord
|
from beaver.foundation.models import CronExecutionResult, CronRunRecord
|
||||||
from beaver.integrations.mcp import MCPConnectionManager
|
from beaver.integrations.mcp import MCPConnectionManager
|
||||||
from beaver.services.agent_service import NOTIFICATION_SESSION_ID, AgentService
|
from beaver.services.agent_service import NOTIFICATION_SESSION_ID, AgentService
|
||||||
@ -53,6 +65,16 @@ from .schemas import (
|
|||||||
WebErrorResponse,
|
WebErrorResponse,
|
||||||
WebAgentConfigRequest,
|
WebAgentConfigRequest,
|
||||||
WebAgentConfigResponse,
|
WebAgentConfigResponse,
|
||||||
|
WebChannelConfigRequest,
|
||||||
|
WebChannelConfigResponse,
|
||||||
|
WebChannelConnectionCreateRequest,
|
||||||
|
WebChannelConnectionResponse,
|
||||||
|
WebChannelConnectionUpdateRequest,
|
||||||
|
WebChannelValidationResponse,
|
||||||
|
WebConnectorBridgeEventRequest,
|
||||||
|
WebConnectorBridgeEventResponse,
|
||||||
|
WebConnectorSessionCreateRequest,
|
||||||
|
WebConnectorSessionResponse,
|
||||||
WebProviderConfigRequest,
|
WebProviderConfigRequest,
|
||||||
WebProviderConfigResponse,
|
WebProviderConfigResponse,
|
||||||
WebStatusResponse,
|
WebStatusResponse,
|
||||||
@ -60,7 +82,7 @@ from .schemas import (
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import JSONResponse, Response
|
||||||
except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments
|
except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments
|
||||||
def File(default: Any = None) -> Any: # type: ignore[override]
|
def File(default: Any = None) -> Any: # type: ignore[override]
|
||||||
return default
|
return default
|
||||||
@ -94,6 +116,11 @@ except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only env
|
|||||||
self.media_type = media_type
|
self.media_type = media_type
|
||||||
self.headers = headers or {}
|
self.headers = headers or {}
|
||||||
|
|
||||||
|
class JSONResponse(Response): # type: ignore[override]
|
||||||
|
def __init__(self, content: Any, status_code: int = 200) -> None:
|
||||||
|
super().__init__(json.dumps(content).encode("utf-8"), media_type="application/json")
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
class WebSocketDisconnect(Exception):
|
class WebSocketDisconnect(Exception):
|
||||||
"""Fallback websocket disconnect exception."""
|
"""Fallback websocket disconnect exception."""
|
||||||
|
|
||||||
@ -183,7 +210,9 @@ async def _app_lifespan(
|
|||||||
owns_service = manage_service_lifecycle if manage_service_lifecycle is not None else service is None
|
owns_service = manage_service_lifecycle if manage_service_lifecycle is not None else service is None
|
||||||
app.state.agent_service = attached_service
|
app.state.agent_service = attached_service
|
||||||
app.state.cron_service = _build_cron_service(attached_service) if owns_service else None
|
app.state.cron_service = _build_cron_service(attached_service) if owns_service else None
|
||||||
|
app.state.channel_runtime = None
|
||||||
started = False
|
started = False
|
||||||
|
channel_runtime: ChannelRuntime | None = None
|
||||||
if owns_service:
|
if owns_service:
|
||||||
try:
|
try:
|
||||||
await attached_service.start()
|
await attached_service.start()
|
||||||
@ -200,6 +229,29 @@ async def _app_lifespan(
|
|||||||
else:
|
else:
|
||||||
attached_service.close()
|
attached_service.close()
|
||||||
raise
|
raise
|
||||||
|
try:
|
||||||
|
loaded = attached_service.create_loop().boot()
|
||||||
|
app.state.channel_connection_workspace = loaded.workspace
|
||||||
|
connector_registry = _build_channel_connector_registry(loaded.workspace)
|
||||||
|
app.state.channel_connector_registry = connector_registry
|
||||||
|
connection_channels = await connector_registry.materialize_channel_configs()
|
||||||
|
runtime_channels = dict(loaded.config.channels)
|
||||||
|
runtime_channels.update(connection_channels)
|
||||||
|
channel_runtime = ChannelRuntime(
|
||||||
|
service=attached_service,
|
||||||
|
workspace=loaded.workspace,
|
||||||
|
channels=runtime_channels,
|
||||||
|
)
|
||||||
|
app.state.channel_runtime = channel_runtime
|
||||||
|
await channel_runtime.start()
|
||||||
|
except BaseException:
|
||||||
|
if owns_service and started:
|
||||||
|
with suppress(BaseException):
|
||||||
|
await attached_service.shutdown(
|
||||||
|
timeout_seconds=shutdown_timeout_seconds,
|
||||||
|
force=shutdown_force,
|
||||||
|
)
|
||||||
|
raise
|
||||||
worker: SkillLearningWorker | None = None
|
worker: SkillLearningWorker | None = None
|
||||||
worker_task = None
|
worker_task = None
|
||||||
worker_config = SkillLearningWorkerConfig.from_env()
|
worker_config = SkillLearningWorkerConfig.from_env()
|
||||||
@ -216,6 +268,10 @@ async def _app_lifespan(
|
|||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
runtime = getattr(app.state, "channel_runtime", None)
|
||||||
|
if isinstance(runtime, ChannelRuntime):
|
||||||
|
with suppress(BaseException):
|
||||||
|
await runtime.stop()
|
||||||
cron_service = getattr(app.state, "cron_service", None)
|
cron_service = getattr(app.state, "cron_service", None)
|
||||||
if isinstance(cron_service, CronService):
|
if isinstance(cron_service, CronService):
|
||||||
cron_service.stop()
|
cron_service.stop()
|
||||||
@ -283,6 +339,118 @@ def get_cron_service(request: Request) -> CronService:
|
|||||||
return service
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_runtime(request: Request) -> ChannelRuntime:
|
||||||
|
runtime = getattr(request.app.state, "channel_runtime", None)
|
||||||
|
if not isinstance(runtime, ChannelRuntime):
|
||||||
|
raise HTTPException(status_code=503, detail="Channel runtime is not running")
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
|
||||||
|
def _connection_state_dir(workspace: Path) -> Path:
|
||||||
|
return Path(workspace) / "state" / "channel_connections"
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_connection_workspace(request: Request) -> Path:
|
||||||
|
workspace = getattr(request.app.state, "channel_connection_workspace", None)
|
||||||
|
if workspace is not None:
|
||||||
|
return Path(workspace)
|
||||||
|
return Path(get_agent_service(request).loader.workspace)
|
||||||
|
|
||||||
|
|
||||||
|
def _message_dedupe_store(workspace: Path) -> MessageDedupeStore:
|
||||||
|
return MessageDedupeStore(_connection_state_dir(workspace) / "message_dedupe.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _bridge_token() -> str:
|
||||||
|
return os.getenv("BEAVER_BRIDGE_TOKEN", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_channel_connector_registry(workspace: Path) -> ChannelConnectorRegistry:
|
||||||
|
state_dir = _connection_state_dir(workspace)
|
||||||
|
connection_store = ChannelConnectionStore(state_dir / "connections.json")
|
||||||
|
credential_store = CredentialStore(state_dir / "credentials.json")
|
||||||
|
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
|
||||||
|
registry.register(
|
||||||
|
TelegramConnector(
|
||||||
|
connection_store=connection_store,
|
||||||
|
credential_store=credential_store,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sidecar_base_url = os.getenv("EXTERNAL_CONNECTOR_BASE_URL", "http://external-connector:8787")
|
||||||
|
sidecar_token = os.getenv("EXTERNAL_CONNECTOR_TOKEN", "")
|
||||||
|
sidecar_client = ConnectorSidecarClient(base_url=sidecar_base_url, token=sidecar_token)
|
||||||
|
registry.register(
|
||||||
|
WeixinConnector(
|
||||||
|
connection_store=connection_store,
|
||||||
|
credential_store=credential_store,
|
||||||
|
sidecar_client=sidecar_client,
|
||||||
|
sidecar_base_url=sidecar_base_url,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry.register(
|
||||||
|
FeishuConnector(
|
||||||
|
connection_store=connection_store,
|
||||||
|
credential_store=credential_store,
|
||||||
|
sidecar_client=sidecar_client,
|
||||||
|
sidecar_base_url=sidecar_base_url,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_connector_registry(request: Request) -> ChannelConnectorRegistry:
|
||||||
|
registry = getattr(request.app.state, "channel_connector_registry", None)
|
||||||
|
if isinstance(registry, ChannelConnectorRegistry):
|
||||||
|
return registry
|
||||||
|
workspace = getattr(request.app.state, "channel_connection_workspace", None)
|
||||||
|
if workspace is None:
|
||||||
|
raise RuntimeError("Channel connector registry unavailable before service boot")
|
||||||
|
registry = _build_channel_connector_registry(workspace)
|
||||||
|
request.app.state.channel_connector_registry = registry
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
def _connection_response_view(connection: Any) -> dict[str, Any]:
|
||||||
|
view = connection.to_dict()
|
||||||
|
view.pop("credentials_ref", None)
|
||||||
|
view.pop("connector_ref", None)
|
||||||
|
view.pop("pairing_session_id", None)
|
||||||
|
return view
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_connection_config(config: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
_camel_to_snake_text(str(key)): value
|
||||||
|
for key, value in config.items()
|
||||||
|
if str(key).strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _camel_to_snake_text(value: str) -> str:
|
||||||
|
result: list[str] = []
|
||||||
|
for char in value.strip():
|
||||||
|
if char.isupper() and result:
|
||||||
|
result.append("_")
|
||||||
|
result.append(char.lower())
|
||||||
|
return "".join(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _self_restart_enabled() -> bool:
|
||||||
|
return os.getenv("BEAVER_ENABLE_SELF_RESTART", "1").strip() not in {"0", "false", "False"}
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_self_restart(delay_seconds: float = 0.75) -> None:
|
||||||
|
import threading
|
||||||
|
|
||||||
|
def _exit_later() -> None:
|
||||||
|
time.sleep(delay_seconds)
|
||||||
|
os._exit(0)
|
||||||
|
|
||||||
|
threading.Thread(target=_exit_later, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
def create_app(
|
def create_app(
|
||||||
*,
|
*,
|
||||||
workspace: str | Path | None = None,
|
workspace: str | Path | None = None,
|
||||||
@ -380,10 +548,330 @@ def create_app(
|
|||||||
"temperature": agent_service.profile.temperature,
|
"temperature": agent_service.profile.temperature,
|
||||||
"max_tool_iterations": agent_service.profile.max_tool_iterations,
|
"max_tool_iterations": agent_service.profile.max_tool_iterations,
|
||||||
"providers": providers_status,
|
"providers": providers_status,
|
||||||
"channels": [{"name": "web", "enabled": True}],
|
"channels": get_channel_runtime(request).statuses(),
|
||||||
|
"runtime_controls": {"self_restart": _self_restart_enabled()},
|
||||||
"cron": cron_service.status(),
|
"cron": cron_service.status(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@app.get("/api/channels")
|
||||||
|
async def list_channels(request: Request) -> list[dict[str, Any]]:
|
||||||
|
return get_channel_runtime(request).statuses()
|
||||||
|
|
||||||
|
@app.get("/api/channel-connectors")
|
||||||
|
async def list_channel_connectors(request: Request) -> list[dict[str, str]]:
|
||||||
|
return get_channel_connector_registry(request).connectors()
|
||||||
|
|
||||||
|
@app.get("/api/channel-connections")
|
||||||
|
async def list_channel_connections(request: Request) -> list[dict[str, Any]]:
|
||||||
|
registry = get_channel_connector_registry(request)
|
||||||
|
return [_connection_response_view(connection) for connection in registry.connection_store.list()]
|
||||||
|
|
||||||
|
@app.post("/api/channel-connections", response_model=WebChannelConnectionResponse)
|
||||||
|
async def create_channel_connection(
|
||||||
|
request: Request,
|
||||||
|
payload: WebChannelConnectionCreateRequest,
|
||||||
|
) -> WebChannelConnectionResponse:
|
||||||
|
registry = get_channel_connector_registry(request)
|
||||||
|
kind = _clean_text(payload.kind)
|
||||||
|
mode = _clean_text(payload.mode)
|
||||||
|
if not kind:
|
||||||
|
raise HTTPException(status_code=400, detail="Connection kind is required")
|
||||||
|
if not mode:
|
||||||
|
raise HTTPException(status_code=400, detail="Connection mode is required")
|
||||||
|
secrets_payload = payload.secrets or {}
|
||||||
|
secrets = {key: value for key, value in secrets_payload.items() if value}
|
||||||
|
credentials_ref = registry.credential_store.put(kind=kind, values=secrets) if secrets else None
|
||||||
|
connection = registry.connection_store.create(
|
||||||
|
kind=kind,
|
||||||
|
mode=mode,
|
||||||
|
display_name=_clean_text(payload.display_name) or kind,
|
||||||
|
account_id=_clean_text(payload.account_id) or "",
|
||||||
|
owner_user_id=_clean_text(payload.owner_user_id) or None,
|
||||||
|
auth_type=_clean_text(payload.auth_type) or "token",
|
||||||
|
credentials_ref=credentials_ref,
|
||||||
|
runtime_config=_normalize_connection_config(payload.config),
|
||||||
|
)
|
||||||
|
return WebChannelConnectionResponse(
|
||||||
|
connection=_connection_response_view(connection),
|
||||||
|
credentials=registry.credential_store.redacted(credentials_ref),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.patch("/api/channel-connections/{connection_id}", response_model=WebChannelConnectionResponse)
|
||||||
|
async def update_channel_connection(
|
||||||
|
connection_id: str,
|
||||||
|
request: Request,
|
||||||
|
payload: WebChannelConnectionUpdateRequest,
|
||||||
|
) -> WebChannelConnectionResponse:
|
||||||
|
registry = get_channel_connector_registry(request)
|
||||||
|
try:
|
||||||
|
connection = registry.connection_store.get(connection_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="Channel connection not found")
|
||||||
|
if payload.display_name is not None:
|
||||||
|
connection.display_name = _clean_text(payload.display_name) or connection.display_name
|
||||||
|
if payload.account_id is not None:
|
||||||
|
connection.account_id = _clean_text(payload.account_id) or connection.account_id
|
||||||
|
if payload.config is not None:
|
||||||
|
connection.runtime_config = _normalize_connection_config(payload.config)
|
||||||
|
if payload.secrets:
|
||||||
|
secrets = {key: value for key, value in payload.secrets.items() if value}
|
||||||
|
if secrets:
|
||||||
|
# TODO: add credential GC when connection updates credentials.
|
||||||
|
connection.credentials_ref = registry.credential_store.put(kind=connection.kind, values=secrets)
|
||||||
|
connection = registry.connection_store.update(connection)
|
||||||
|
return WebChannelConnectionResponse(
|
||||||
|
connection=_connection_response_view(connection),
|
||||||
|
credentials=registry.credential_store.redacted(connection.credentials_ref),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/api/channel-connections/{connection_id}", response_model=WebChannelConnectionResponse)
|
||||||
|
async def get_channel_connection(connection_id: str, request: Request) -> WebChannelConnectionResponse:
|
||||||
|
registry = get_channel_connector_registry(request)
|
||||||
|
try:
|
||||||
|
connection = registry.connection_store.get(connection_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="Channel connection not found")
|
||||||
|
return WebChannelConnectionResponse(
|
||||||
|
connection=_connection_response_view(connection),
|
||||||
|
credentials=registry.credential_store.redacted(connection.credentials_ref),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/api/channel-connections/{connection_id}/validate", response_model=WebChannelValidationResponse)
|
||||||
|
async def validate_channel_connection(connection_id: str, request: Request) -> WebChannelValidationResponse:
|
||||||
|
registry = get_channel_connector_registry(request)
|
||||||
|
try:
|
||||||
|
result = await registry.validate(connection_id)
|
||||||
|
connection = registry.connection_store.get(connection_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="Channel connection not found")
|
||||||
|
return WebChannelValidationResponse(
|
||||||
|
ok=result.ok,
|
||||||
|
status=result.status,
|
||||||
|
account_id=result.account_id,
|
||||||
|
display_name=result.display_name,
|
||||||
|
error=result.error,
|
||||||
|
metadata=result.metadata,
|
||||||
|
connection=_connection_response_view(connection),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/api/channel-connections/{connection_id}/revoke", response_model=WebChannelConnectionResponse)
|
||||||
|
async def revoke_channel_connection(connection_id: str, request: Request) -> WebChannelConnectionResponse:
|
||||||
|
registry = get_channel_connector_registry(request)
|
||||||
|
try:
|
||||||
|
await registry.revoke(connection_id)
|
||||||
|
connection = registry.connection_store.get(connection_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="Channel connection not found")
|
||||||
|
return WebChannelConnectionResponse(connection=_connection_response_view(connection), credentials={})
|
||||||
|
|
||||||
|
@app.post("/api/channel-connector-sessions", response_model=WebConnectorSessionResponse)
|
||||||
|
async def start_channel_connector_session(
|
||||||
|
request: Request,
|
||||||
|
payload: WebConnectorSessionCreateRequest,
|
||||||
|
) -> WebConnectorSessionResponse:
|
||||||
|
registry = get_channel_connector_registry(request)
|
||||||
|
kind = _clean_text(payload.kind)
|
||||||
|
try:
|
||||||
|
connector = registry.connector_for_kind(kind)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="Connector not found")
|
||||||
|
start_session = getattr(connector, "start_session", None)
|
||||||
|
if start_session is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Connector does not support sessions")
|
||||||
|
view = await start_session(
|
||||||
|
display_name=_clean_text(payload.display_name) or kind,
|
||||||
|
owner_user_id=_clean_text(payload.owner_user_id) or None,
|
||||||
|
options=payload.options,
|
||||||
|
)
|
||||||
|
connection_id = _clean_text(view.get("connectionId"))
|
||||||
|
connection_view = None
|
||||||
|
if connection_id:
|
||||||
|
connection_view = _connection_response_view(registry.connection_store.get(connection_id))
|
||||||
|
return WebConnectorSessionResponse(session=view, connection=connection_view)
|
||||||
|
|
||||||
|
@app.get("/api/channel-connector-sessions/{session_id}", response_model=WebConnectorSessionResponse)
|
||||||
|
async def get_channel_connector_session(session_id: str, request: Request) -> WebConnectorSessionResponse:
|
||||||
|
registry = get_channel_connector_registry(request)
|
||||||
|
connection = next(
|
||||||
|
(item for item in registry.connection_store.list() if item.pairing_session_id == session_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if connection is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Connector session not found")
|
||||||
|
connector = registry.connector_for_kind(connection.kind)
|
||||||
|
poll_session = getattr(connector, "poll_session", None)
|
||||||
|
if poll_session is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Connector does not support sessions")
|
||||||
|
view = await poll_session(session_id)
|
||||||
|
connection = registry.connection_store.get(connection.connection_id)
|
||||||
|
if connection.status == "connected":
|
||||||
|
runtime = get_channel_runtime(request)
|
||||||
|
config = (await registry.materialize_channel_configs())[connection.channel_id]
|
||||||
|
await runtime.add_channel(connection.channel_id, config)
|
||||||
|
return WebConnectorSessionResponse(session=view, connection=_connection_response_view(connection))
|
||||||
|
|
||||||
|
@app.post("/api/channel-connector-bridge/events", response_model=WebConnectorBridgeEventResponse)
|
||||||
|
async def accept_connector_bridge_event(
|
||||||
|
request: Request,
|
||||||
|
payload: WebConnectorBridgeEventRequest,
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
) -> Any:
|
||||||
|
expected = _bridge_token()
|
||||||
|
if not expected or authorization != f"Bearer {expected}":
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid connector bridge token")
|
||||||
|
|
||||||
|
registry = get_channel_connector_registry(request)
|
||||||
|
try:
|
||||||
|
connection = registry.connection_store.get(payload.connection_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="Channel connection not found")
|
||||||
|
if connection.status == "revoked":
|
||||||
|
raise HTTPException(status_code=404, detail="Channel connection not found")
|
||||||
|
|
||||||
|
store = _message_dedupe_store(_channel_connection_workspace(request))
|
||||||
|
begin = store.begin(
|
||||||
|
connection_id=payload.connection_id,
|
||||||
|
event_id=payload.event_id,
|
||||||
|
delivery_attempt=payload.delivery_attempt,
|
||||||
|
)
|
||||||
|
if not begin.should_process:
|
||||||
|
body = WebConnectorBridgeEventResponse(
|
||||||
|
accepted=begin.http_status == 200,
|
||||||
|
duplicate=True,
|
||||||
|
pending=begin.http_status == 409,
|
||||||
|
retryAfterSeconds=begin.retry_after_seconds,
|
||||||
|
).model_dump(by_alias=True)
|
||||||
|
return JSONResponse(status_code=begin.http_status, content=body)
|
||||||
|
|
||||||
|
runtime = get_channel_runtime(request)
|
||||||
|
identity = ChannelIdentity(
|
||||||
|
channel_id=payload.channel_id,
|
||||||
|
kind=payload.kind,
|
||||||
|
account_id=payload.account_id,
|
||||||
|
peer_id=payload.peer_id,
|
||||||
|
thread_id=payload.thread_id,
|
||||||
|
peer_type=payload.peer_type,
|
||||||
|
user_id=payload.user_id,
|
||||||
|
message_id=payload.message_id,
|
||||||
|
)
|
||||||
|
inbound = InboundMessage(
|
||||||
|
channel=payload.channel_id,
|
||||||
|
content=payload.content,
|
||||||
|
content_type=payload.message_type,
|
||||||
|
channel_identity=identity,
|
||||||
|
user_id=payload.user_id,
|
||||||
|
message_id=payload.message_id,
|
||||||
|
metadata=dict(payload.metadata),
|
||||||
|
)
|
||||||
|
result = await runtime.accept_inbound(inbound)
|
||||||
|
if result.accepted or result.duplicate:
|
||||||
|
store.complete(begin.dedupe_key, message_id=payload.message_id)
|
||||||
|
else:
|
||||||
|
store.fail(begin.dedupe_key, error=result.error or "runtime rejected bridge event")
|
||||||
|
return WebConnectorBridgeEventResponse(
|
||||||
|
accepted=result.accepted,
|
||||||
|
duplicate=result.duplicate,
|
||||||
|
pending=result.pending,
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/api/channels/{channel_id}/config")
|
||||||
|
async def get_channel_config(channel_id: str, request: Request) -> dict[str, Any]:
|
||||||
|
agent_service = get_agent_service(request)
|
||||||
|
config_path = agent_service.loader.config.config_path or default_config_path(workspace=agent_service.loader.workspace)
|
||||||
|
raw = _read_config_json(config_path)
|
||||||
|
channel = _ensure_dict(raw, "channels").get(channel_id)
|
||||||
|
if not isinstance(channel, dict):
|
||||||
|
raise HTTPException(status_code=404, detail="Channel not found")
|
||||||
|
return _channel_config_view(channel_id, channel)
|
||||||
|
|
||||||
|
@app.post("/api/channels/{channel_id}/config", response_model=WebChannelConfigResponse)
|
||||||
|
async def update_channel_config(
|
||||||
|
channel_id: str,
|
||||||
|
request: Request,
|
||||||
|
payload: WebChannelConfigRequest,
|
||||||
|
) -> WebChannelConfigResponse:
|
||||||
|
if not _clean_text(channel_id):
|
||||||
|
raise HTTPException(status_code=400, detail="Channel id is required")
|
||||||
|
kind = _clean_text(payload.kind)
|
||||||
|
mode = _clean_text(payload.mode)
|
||||||
|
if not kind:
|
||||||
|
raise HTTPException(status_code=400, detail="Channel kind is required")
|
||||||
|
if not mode:
|
||||||
|
raise HTTPException(status_code=400, detail="Channel mode is required")
|
||||||
|
|
||||||
|
agent_service = get_agent_service(request)
|
||||||
|
config_path = agent_service.loader.config.config_path or default_config_path(workspace=agent_service.loader.workspace)
|
||||||
|
raw = _read_config_json(config_path)
|
||||||
|
channels = _ensure_dict(raw, "channels")
|
||||||
|
current = channels.get(channel_id) if isinstance(channels.get(channel_id), dict) else {}
|
||||||
|
current_secrets = current.get("secrets") if isinstance(current.get("secrets"), dict) else {}
|
||||||
|
next_secrets = dict(current_secrets)
|
||||||
|
for key, value in (payload.secrets or {}).items():
|
||||||
|
cleaned_key = _clean_text(key)
|
||||||
|
cleaned_value = _clean_text(value)
|
||||||
|
if not cleaned_key or not cleaned_value:
|
||||||
|
continue
|
||||||
|
next_secrets[cleaned_key] = cleaned_value
|
||||||
|
|
||||||
|
channel_payload: dict[str, Any] = {
|
||||||
|
"enabled": bool(payload.enabled),
|
||||||
|
"kind": kind,
|
||||||
|
"mode": mode,
|
||||||
|
"accountId": _clean_text(payload.account_id) or "",
|
||||||
|
"displayName": _clean_text(payload.display_name) or channel_id,
|
||||||
|
"config": payload.config or {},
|
||||||
|
"secrets": next_secrets,
|
||||||
|
}
|
||||||
|
channels[channel_id] = channel_payload
|
||||||
|
_write_config_json(config_path, raw)
|
||||||
|
_reload_agent_config(agent_service, config_path)
|
||||||
|
return WebChannelConfigResponse(
|
||||||
|
ok=True,
|
||||||
|
channel_id=channel_id,
|
||||||
|
restart_required=True,
|
||||||
|
channel=_channel_config_view(channel_id, channel_payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/api/channels/{channel_id}/events")
|
||||||
|
async def list_channel_events(channel_id: str, request: Request, limit: int = 100) -> list[dict[str, Any]]:
|
||||||
|
return get_channel_runtime(request).recent_events(channel_id, limit=limit)
|
||||||
|
|
||||||
|
@app.post("/api/channels/{channel_id}/webhook")
|
||||||
|
async def post_channel_webhook(channel_id: str, request: Request) -> JSONResponse:
|
||||||
|
runtime = get_channel_runtime(request)
|
||||||
|
adapter = runtime.adapters.get(channel_id)
|
||||||
|
if adapter is None or not hasattr(adapter, "handle_webhook_payload"):
|
||||||
|
raise HTTPException(status_code=404, detail="Webhook channel not found")
|
||||||
|
payload = await request.json()
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="Webhook payload must be a JSON object")
|
||||||
|
result = await adapter.handle_webhook_payload(payload) # type: ignore[attr-defined]
|
||||||
|
status_code = 202 if result.get("pending") else 200
|
||||||
|
return JSONResponse(result, status_code=status_code)
|
||||||
|
|
||||||
|
@app.websocket("/api/channels/{channel_id}/ws")
|
||||||
|
async def channel_websocket(websocket: WebSocket, channel_id: str) -> None:
|
||||||
|
runtime = getattr(websocket.app.state, "channel_runtime", None)
|
||||||
|
if not isinstance(runtime, ChannelRuntime):
|
||||||
|
await websocket.accept()
|
||||||
|
await websocket.send_json({"type": "error", "error": "Channel runtime is not running"})
|
||||||
|
await websocket.close(code=1011)
|
||||||
|
return
|
||||||
|
adapter = runtime.adapters.get(channel_id)
|
||||||
|
if adapter is None or not hasattr(adapter, "handle_websocket"):
|
||||||
|
await websocket.accept()
|
||||||
|
await websocket.send_json({"type": "error", "error": "WebSocket channel not found"})
|
||||||
|
await websocket.close(code=1008)
|
||||||
|
return
|
||||||
|
await adapter.handle_websocket(websocket) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
@app.post("/api/runtime/restart")
|
||||||
|
async def restart_runtime() -> JSONResponse:
|
||||||
|
if not _self_restart_enabled():
|
||||||
|
raise HTTPException(status_code=403, detail="Self restart is disabled")
|
||||||
|
_schedule_self_restart()
|
||||||
|
return JSONResponse({"ok": True, "restarting": True}, status_code=202)
|
||||||
|
|
||||||
@app.post("/api/auth/login")
|
@app.post("/api/auth/login")
|
||||||
async def auth_login(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
|
async def auth_login(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
username = _clean_text(payload.get("username"))
|
username = _clean_text(payload.get("username"))
|
||||||
@ -3011,6 +3499,25 @@ def _mask_secret(value: str | None) -> str:
|
|||||||
return f"{secret[:4]}••••{secret[-4:]}"
|
return f"{secret[:4]}••••{secret[-4:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_config_view(channel_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
secrets_payload = data.get("secrets") if isinstance(data.get("secrets"), dict) else {}
|
||||||
|
config_payload = data.get("config") if isinstance(data.get("config"), dict) else {}
|
||||||
|
return {
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"enabled": bool(data.get("enabled")),
|
||||||
|
"kind": _clean_text(data.get("kind")) or "",
|
||||||
|
"mode": _clean_text(data.get("mode")) or "webhook",
|
||||||
|
"account_id": _clean_text(data.get("accountId") or data.get("account_id")) or "",
|
||||||
|
"display_name": _clean_text(data.get("displayName") or data.get("display_name")) or channel_id,
|
||||||
|
"config": dict(config_payload),
|
||||||
|
"secrets": {
|
||||||
|
str(key): _mask_secret(str(value) if value is not None else None)
|
||||||
|
for key, value in secrets_payload.items()
|
||||||
|
if str(key).strip()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _read_config_json(path: Path) -> dict[str, Any]:
|
def _read_config_json(path: Path) -> dict[str, Any]:
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return {}
|
return {}
|
||||||
@ -3082,7 +3589,14 @@ def _reload_agent_config(agent_service: AgentService, config_path: Path) -> None
|
|||||||
old_manager = getattr(loaded, "mcp_manager", None)
|
old_manager = getattr(loaded, "mcp_manager", None)
|
||||||
if old_manager is not None:
|
if old_manager is not None:
|
||||||
async def _close_old_manager() -> None:
|
async def _close_old_manager() -> None:
|
||||||
await old_manager.close()
|
try:
|
||||||
|
await old_manager.close()
|
||||||
|
except Exception:
|
||||||
|
# MCP transports may own anyio cancel scopes created by a
|
||||||
|
# previous request task. Config reload must not leak that
|
||||||
|
# cleanup failure as an unhandled background exception or
|
||||||
|
# knock the app out of running mode.
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
running_loop = asyncio.get_running_loop()
|
running_loop = asyncio.get_running_loop()
|
||||||
|
|||||||
@ -10,6 +10,16 @@ from .chat import (
|
|||||||
WebErrorResponse,
|
WebErrorResponse,
|
||||||
WebAgentConfigRequest,
|
WebAgentConfigRequest,
|
||||||
WebAgentConfigResponse,
|
WebAgentConfigResponse,
|
||||||
|
WebChannelConfigRequest,
|
||||||
|
WebChannelConfigResponse,
|
||||||
|
WebChannelConnectionCreateRequest,
|
||||||
|
WebChannelConnectionResponse,
|
||||||
|
WebChannelConnectionUpdateRequest,
|
||||||
|
WebChannelValidationResponse,
|
||||||
|
WebConnectorBridgeEventRequest,
|
||||||
|
WebConnectorBridgeEventResponse,
|
||||||
|
WebConnectorSessionCreateRequest,
|
||||||
|
WebConnectorSessionResponse,
|
||||||
WebProviderConfigRequest,
|
WebProviderConfigRequest,
|
||||||
WebProviderConfigResponse,
|
WebProviderConfigResponse,
|
||||||
WebProviderTarget,
|
WebProviderTarget,
|
||||||
@ -26,6 +36,16 @@ __all__ = [
|
|||||||
"WebErrorResponse",
|
"WebErrorResponse",
|
||||||
"WebAgentConfigRequest",
|
"WebAgentConfigRequest",
|
||||||
"WebAgentConfigResponse",
|
"WebAgentConfigResponse",
|
||||||
|
"WebChannelConfigRequest",
|
||||||
|
"WebChannelConfigResponse",
|
||||||
|
"WebChannelConnectionCreateRequest",
|
||||||
|
"WebChannelConnectionResponse",
|
||||||
|
"WebChannelConnectionUpdateRequest",
|
||||||
|
"WebChannelValidationResponse",
|
||||||
|
"WebConnectorBridgeEventRequest",
|
||||||
|
"WebConnectorBridgeEventResponse",
|
||||||
|
"WebConnectorSessionCreateRequest",
|
||||||
|
"WebConnectorSessionResponse",
|
||||||
"WebProviderConfigRequest",
|
"WebProviderConfigRequest",
|
||||||
"WebProviderConfigResponse",
|
"WebProviderConfigResponse",
|
||||||
"WebProviderTarget",
|
"WebProviderTarget",
|
||||||
|
|||||||
@ -139,6 +139,113 @@ class WebProviderConfigResponse(BaseModel):
|
|||||||
enabled: bool
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class WebChannelConfigRequest(BaseModel):
|
||||||
|
"""Channel config update from the settings page."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
kind: str
|
||||||
|
mode: str
|
||||||
|
account_id: str | None = None
|
||||||
|
display_name: str | None = None
|
||||||
|
config: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
secrets: dict[str, str | None] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class WebChannelConfigResponse(BaseModel):
|
||||||
|
"""Channel config update result."""
|
||||||
|
|
||||||
|
ok: bool
|
||||||
|
channel_id: str
|
||||||
|
restart_required: bool
|
||||||
|
channel: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class WebChannelConnectionCreateRequest(BaseModel):
|
||||||
|
"""Create a channel connection from the setup UI."""
|
||||||
|
|
||||||
|
kind: str
|
||||||
|
mode: str
|
||||||
|
display_name: str | None = Field(default=None, alias="displayName")
|
||||||
|
owner_user_id: str | None = Field(default=None, alias="ownerUserId")
|
||||||
|
auth_type: str = Field(default="token", alias="authType")
|
||||||
|
account_id: str | None = Field(default=None, alias="accountId")
|
||||||
|
config: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
secrets: dict[str, str | None] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class WebChannelConnectionResponse(BaseModel):
|
||||||
|
"""Channel connection response with redacted credentials."""
|
||||||
|
|
||||||
|
connection: dict[str, Any]
|
||||||
|
credentials: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class WebChannelConnectionUpdateRequest(BaseModel):
|
||||||
|
"""Update editable channel connection setup fields."""
|
||||||
|
|
||||||
|
display_name: str | None = Field(default=None, alias="displayName")
|
||||||
|
account_id: str | None = Field(default=None, alias="accountId")
|
||||||
|
config: dict[str, Any] | None = None
|
||||||
|
secrets: dict[str, str | None] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WebChannelValidationResponse(BaseModel):
|
||||||
|
"""Connector validation response."""
|
||||||
|
|
||||||
|
ok: bool
|
||||||
|
status: str
|
||||||
|
account_id: str | None = None
|
||||||
|
display_name: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
connection: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class WebConnectorBridgeEventRequest(BaseModel):
|
||||||
|
"""Inbound connector bridge event from the external sidecar."""
|
||||||
|
|
||||||
|
event_id: str = Field(alias="eventId")
|
||||||
|
timestamp: str
|
||||||
|
delivery_attempt: int = Field(default=1, alias="deliveryAttempt")
|
||||||
|
connection_id: str = Field(alias="connectionId")
|
||||||
|
channel_id: str = Field(alias="channelId")
|
||||||
|
kind: str
|
||||||
|
account_id: str = Field(alias="accountId")
|
||||||
|
peer_id: str = Field(alias="peerId")
|
||||||
|
peer_type: str = Field(default="unknown", alias="peerType")
|
||||||
|
user_id: str | None = Field(default=None, alias="userId")
|
||||||
|
thread_id: str | None = Field(default=None, alias="threadId")
|
||||||
|
message_id: str = Field(alias="messageId")
|
||||||
|
message_type: str = Field(default="text", alias="messageType")
|
||||||
|
content: str
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class WebConnectorBridgeEventResponse(BaseModel):
|
||||||
|
"""Connector bridge event accept/dedupe response."""
|
||||||
|
|
||||||
|
accepted: bool
|
||||||
|
duplicate: bool = False
|
||||||
|
pending: bool = False
|
||||||
|
retry_after_seconds: int | None = Field(default=None, alias="retryAfterSeconds")
|
||||||
|
|
||||||
|
|
||||||
|
class WebConnectorSessionCreateRequest(BaseModel):
|
||||||
|
"""Start a connector-managed onboarding session."""
|
||||||
|
|
||||||
|
kind: str
|
||||||
|
display_name: str | None = Field(default=None, alias="displayName")
|
||||||
|
owner_user_id: str | None = Field(default=None, alias="ownerUserId")
|
||||||
|
options: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class WebConnectorSessionResponse(BaseModel):
|
||||||
|
"""Connector session view plus optional connection view."""
|
||||||
|
|
||||||
|
session: dict[str, Any]
|
||||||
|
connection: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class WebAgentConfigRequest(BaseModel):
|
class WebAgentConfigRequest(BaseModel):
|
||||||
"""Agent runtime defaults update from the settings page."""
|
"""Agent runtime defaults update from the settings page."""
|
||||||
|
|
||||||
|
|||||||
@ -1237,17 +1237,19 @@ class AgentService:
|
|||||||
async def handle_inbound_message(self, inbound: InboundMessage) -> OutboundMessage:
|
async def handle_inbound_message(self, inbound: InboundMessage) -> OutboundMessage:
|
||||||
"""把 bus inbound 映射成标准 runtime 调用,并返回结构化 outbound。"""
|
"""把 bus inbound 映射成标准 runtime 调用,并返回结构化 outbound。"""
|
||||||
|
|
||||||
|
channel_identity = inbound.channel_identity
|
||||||
try:
|
try:
|
||||||
result = await self.submit_direct(
|
result = await self.submit_direct(
|
||||||
inbound.content,
|
inbound.content,
|
||||||
session_id=inbound.session_id,
|
session_id=inbound.session_id,
|
||||||
source=f"gateway:{inbound.channel}",
|
source=f"gateway:{inbound.channel}",
|
||||||
user_id=inbound.user_id,
|
user_id=inbound.user_id or (channel_identity.user_id if channel_identity else None),
|
||||||
title=inbound.title,
|
title=inbound.title,
|
||||||
execution_context=inbound.execution_context,
|
execution_context=inbound.execution_context,
|
||||||
model=inbound.model,
|
model=inbound.model,
|
||||||
provider_name=inbound.provider_name,
|
provider_name=inbound.provider_name,
|
||||||
embedding_model=inbound.embedding_model,
|
embedding_model=inbound.embedding_model,
|
||||||
|
channel_identity=channel_identity,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return self.build_outbound_error(
|
return self.build_outbound_error(
|
||||||
@ -1283,6 +1285,8 @@ class AgentService:
|
|||||||
finish_reason=result.finish_reason,
|
finish_reason=result.finish_reason,
|
||||||
provider_name=result.provider_name,
|
provider_name=result.provider_name,
|
||||||
model=result.model,
|
model=result.model,
|
||||||
|
content_type=inbound.content_type,
|
||||||
|
channel_identity=inbound.channel_identity,
|
||||||
usage=dict(result.usage),
|
usage=dict(result.usage),
|
||||||
metadata={
|
metadata={
|
||||||
"inbound_metadata": dict(inbound.metadata),
|
"inbound_metadata": dict(inbound.metadata),
|
||||||
@ -1308,6 +1312,8 @@ class AgentService:
|
|||||||
session_id=inbound.session_id,
|
session_id=inbound.session_id,
|
||||||
content=detail,
|
content=detail,
|
||||||
finish_reason=finish_reason,
|
finish_reason=finish_reason,
|
||||||
|
content_type=inbound.content_type,
|
||||||
|
channel_identity=inbound.channel_identity,
|
||||||
metadata={"error": detail, "inbound_metadata": dict(inbound.metadata)},
|
metadata={"error": detail, "inbound_metadata": dict(inbound.metadata)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,23 @@ dependencies = [
|
|||||||
dev = [
|
dev = [
|
||||||
"pytest>=9.0.0,<10.0.0",
|
"pytest>=9.0.0,<10.0.0",
|
||||||
]
|
]
|
||||||
|
telegram = [
|
||||||
|
"python-telegram-bot>=22.0,<23.0",
|
||||||
|
]
|
||||||
|
feishu = [
|
||||||
|
"lark-oapi>=1.4.22,<2.0.0",
|
||||||
|
]
|
||||||
|
qqbot = [
|
||||||
|
"aiohttp>=3.9.0,<4.0.0",
|
||||||
|
]
|
||||||
|
weixin = [
|
||||||
|
"aiohttp>=3.9.0,<4.0.0",
|
||||||
|
]
|
||||||
|
channels = [
|
||||||
|
"python-telegram-bot>=22.0,<23.0",
|
||||||
|
"lark-oapi>=1.4.22,<2.0.0",
|
||||||
|
"aiohttp>=3.9.0,<4.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
beaver = "beaver.interfaces.cli.main:main"
|
beaver = "beaver.interfaces.cli.main:main"
|
||||||
|
|||||||
@ -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"}]
|
||||||
@ -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
|
||||||
@ -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())
|
||||||
414
app-instance/backend/tests/unit/test_channel_runtime.py
Normal file
414
app-instance/backend/tests/unit/test_channel_runtime.py
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from beaver.foundation.config.schema import ChannelConfig
|
||||||
|
from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage
|
||||||
|
from beaver.foundation.events import MessageBus
|
||||||
|
from beaver.interfaces.channels.generic_webhook import GenericWebhookAdapter
|
||||||
|
from beaver.interfaces.channels.runtime import ChannelRuntime
|
||||||
|
from beaver.interfaces.channels.state import ChannelDedupeStore, ChannelEventLog
|
||||||
|
from beaver.interfaces.web.app import _self_restart_enabled, create_app
|
||||||
|
from beaver.services.agent_service import AgentService
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_identity_builds_stable_session_id() -> None:
|
||||||
|
identity = ChannelIdentity(
|
||||||
|
channel_id="webhook-dev",
|
||||||
|
kind="webhook",
|
||||||
|
account_id="local",
|
||||||
|
peer_id="demo-user",
|
||||||
|
thread_id="main",
|
||||||
|
peer_type="dm",
|
||||||
|
message_id="msg-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert identity.session_id() == "webhook-dev:local:demo-user:main"
|
||||||
|
assert identity.dedupe_key() == "webhook-dev:local:demo-user:main:msg-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_identity_requires_routing_fields() -> None:
|
||||||
|
identity = ChannelIdentity(channel_id="webhook-dev", kind="webhook", account_id="", peer_id="demo")
|
||||||
|
|
||||||
|
assert identity.validation_error() == "account_id is required"
|
||||||
|
|
||||||
|
|
||||||
|
def test_messages_carry_channel_identity() -> None:
|
||||||
|
identity = ChannelIdentity(
|
||||||
|
channel_id="webhook-dev",
|
||||||
|
kind="webhook",
|
||||||
|
account_id="local",
|
||||||
|
peer_id="demo-user",
|
||||||
|
message_id="msg-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
inbound = InboundMessage(channel="webhook-dev", content="hello", channel_identity=identity)
|
||||||
|
outbound = OutboundMessage(
|
||||||
|
channel="webhook-dev",
|
||||||
|
content="ok",
|
||||||
|
session_id=identity.session_id(),
|
||||||
|
finish_reason="stop",
|
||||||
|
channel_identity=identity,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert inbound.channel_identity is identity
|
||||||
|
assert outbound.channel_identity is identity
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedupe_store_tracks_processing_and_done(tmp_path) -> None:
|
||||||
|
store = ChannelDedupeStore(tmp_path / "dedupe.json", retention_hours=48)
|
||||||
|
|
||||||
|
created = store.mark_processing(
|
||||||
|
dedupe_key="webhook-dev:local:demo:msg-1",
|
||||||
|
session_id="webhook-dev:local:demo",
|
||||||
|
message_id="msg-1",
|
||||||
|
)
|
||||||
|
duplicate = store.mark_processing(
|
||||||
|
dedupe_key="webhook-dev:local:demo:msg-1",
|
||||||
|
session_id="webhook-dev:local:demo",
|
||||||
|
message_id="msg-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert created.created is True
|
||||||
|
assert duplicate.created is False
|
||||||
|
assert duplicate.record is not None
|
||||||
|
assert duplicate.record["status"] == "processing"
|
||||||
|
|
||||||
|
store.mark_done(
|
||||||
|
dedupe_key="webhook-dev:local:demo:msg-1",
|
||||||
|
run_id="run-1",
|
||||||
|
reply="hello" * 10000,
|
||||||
|
max_reply_chars=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
done = store.get("webhook-dev:local:demo:msg-1")
|
||||||
|
assert done is not None
|
||||||
|
assert done["status"] == "done"
|
||||||
|
assert done["reply"] == "hellohellohellohello"
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_event_log_writes_recent_events(tmp_path) -> None:
|
||||||
|
log = ChannelEventLog(tmp_path / "events.jsonl")
|
||||||
|
log.record(
|
||||||
|
channel_id="webhook-dev",
|
||||||
|
kind="inbound_accepted",
|
||||||
|
session_id="webhook-dev:local:demo",
|
||||||
|
message_id="msg-1",
|
||||||
|
status="ok",
|
||||||
|
text="hello world",
|
||||||
|
)
|
||||||
|
|
||||||
|
events = log.recent(channel_id="webhook-dev", limit=10)
|
||||||
|
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0]["kind"] == "inbound_accepted"
|
||||||
|
assert events[0]["text_preview"] == "hello world"
|
||||||
|
assert "raw_channel_payload" not in json.dumps(events[0])
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAgentService:
|
||||||
|
is_running = True
|
||||||
|
|
||||||
|
async def handle_inbound_message(self, inbound):
|
||||||
|
return OutboundMessage(
|
||||||
|
message_id=inbound.message_id,
|
||||||
|
channel=inbound.channel,
|
||||||
|
content=f"echo:{inbound.content}",
|
||||||
|
session_id=inbound.session_id,
|
||||||
|
finish_reason="stop",
|
||||||
|
run_id="run-1",
|
||||||
|
channel_identity=inbound.channel_identity,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SlowFakeAgentService(FakeAgentService):
|
||||||
|
async def handle_inbound_message(self, inbound):
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
return await super().handle_inbound_message(inbound)
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_runtime_accept_inbound_normalizes_session_and_dedupes(tmp_path) -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
bus = MessageBus()
|
||||||
|
runtime = ChannelRuntime(
|
||||||
|
service=FakeAgentService(),
|
||||||
|
bus=bus,
|
||||||
|
workspace=tmp_path,
|
||||||
|
channels={},
|
||||||
|
)
|
||||||
|
identity = ChannelIdentity(
|
||||||
|
channel_id="webhook-dev",
|
||||||
|
kind="webhook",
|
||||||
|
account_id="local",
|
||||||
|
peer_id="demo",
|
||||||
|
message_id="msg-1",
|
||||||
|
)
|
||||||
|
result = await runtime.accept_inbound(
|
||||||
|
InboundMessage(
|
||||||
|
channel="webhook-dev",
|
||||||
|
content="hello",
|
||||||
|
session_id="wrong",
|
||||||
|
channel_identity=identity,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
duplicate = await runtime.accept_inbound(
|
||||||
|
InboundMessage(
|
||||||
|
channel="webhook-dev",
|
||||||
|
content="hello",
|
||||||
|
channel_identity=identity,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
queued = await bus.consume_inbound()
|
||||||
|
assert result.accepted is True
|
||||||
|
assert queued.session_id == "webhook-dev:local:demo"
|
||||||
|
assert duplicate.accepted is False
|
||||||
|
assert duplicate.duplicate is True
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_generic_webhook_adapter_waits_for_outbound_reply(tmp_path) -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
bus = MessageBus()
|
||||||
|
runtime = ChannelRuntime(
|
||||||
|
service=FakeAgentService(),
|
||||||
|
bus=bus,
|
||||||
|
workspace=tmp_path,
|
||||||
|
channels={},
|
||||||
|
)
|
||||||
|
adapter = GenericWebhookAdapter(
|
||||||
|
channel_id="webhook-dev",
|
||||||
|
kind="webhook",
|
||||||
|
mode="webhook",
|
||||||
|
account_id="local",
|
||||||
|
display_name="Webhook Dev",
|
||||||
|
inbound_sink=runtime,
|
||||||
|
response_timeout_seconds=1,
|
||||||
|
)
|
||||||
|
runtime.manager.register(adapter)
|
||||||
|
await runtime.start()
|
||||||
|
try:
|
||||||
|
response = await adapter.handle_webhook_payload(
|
||||||
|
{
|
||||||
|
"peer_id": "demo",
|
||||||
|
"message_id": "msg-1",
|
||||||
|
"text": "hello",
|
||||||
|
"peer_type": "dm",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await runtime.stop()
|
||||||
|
|
||||||
|
assert response["ok"] is True
|
||||||
|
assert response["reply"] == "echo:hello"
|
||||||
|
assert response["session_id"] == "webhook-dev:local:demo"
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_generic_webhook_records_unclaimed_outbound_after_timeout(tmp_path) -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
bus = MessageBus()
|
||||||
|
runtime = ChannelRuntime(
|
||||||
|
service=SlowFakeAgentService(),
|
||||||
|
bus=bus,
|
||||||
|
workspace=tmp_path,
|
||||||
|
channels={},
|
||||||
|
)
|
||||||
|
adapter = GenericWebhookAdapter(
|
||||||
|
channel_id="webhook-dev",
|
||||||
|
kind="webhook",
|
||||||
|
mode="webhook",
|
||||||
|
account_id="local",
|
||||||
|
display_name="Webhook Dev",
|
||||||
|
inbound_sink=runtime,
|
||||||
|
response_timeout_seconds=1,
|
||||||
|
)
|
||||||
|
adapter.response_timeout_seconds = 0.01
|
||||||
|
runtime.manager.register(adapter)
|
||||||
|
await runtime.start()
|
||||||
|
try:
|
||||||
|
response = await adapter.handle_webhook_payload(
|
||||||
|
{
|
||||||
|
"peer_id": "demo",
|
||||||
|
"message_id": "msg-1",
|
||||||
|
"text": "hello",
|
||||||
|
"peer_type": "dm",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
events = runtime.recent_events("webhook-dev", limit=20)
|
||||||
|
finally:
|
||||||
|
await runtime.stop()
|
||||||
|
|
||||||
|
assert response["pending"] is True
|
||||||
|
assert any(event["kind"] == "outbound_unclaimed" for event in events)
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_runtime_starts_enabled_generic_webhook_and_reports_status(tmp_path) -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
runtime = ChannelRuntime(
|
||||||
|
service=FakeAgentService(),
|
||||||
|
workspace=tmp_path,
|
||||||
|
channels={
|
||||||
|
"webhook-dev": ChannelConfig(
|
||||||
|
enabled=True,
|
||||||
|
kind="webhook",
|
||||||
|
mode="webhook",
|
||||||
|
account_id="local",
|
||||||
|
display_name="Webhook Dev",
|
||||||
|
config={"response_timeout_seconds": 1800},
|
||||||
|
),
|
||||||
|
"off": ChannelConfig(
|
||||||
|
enabled=False,
|
||||||
|
kind="webhook",
|
||||||
|
mode="webhook",
|
||||||
|
account_id="local",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await runtime.start()
|
||||||
|
try:
|
||||||
|
statuses = runtime.statuses()
|
||||||
|
finally:
|
||||||
|
await runtime.stop()
|
||||||
|
|
||||||
|
by_id = {item["channel_id"]: item for item in statuses}
|
||||||
|
assert by_id["webhook-dev"]["state"] == "running"
|
||||||
|
assert by_id["webhook-dev"]["webhook_url"] == "/api/channels/webhook-dev/webhook"
|
||||||
|
assert by_id["off"]["state"] == "disabled"
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_runtime_builds_platform_adapters_without_starting_networks(tmp_path) -> None:
|
||||||
|
runtime = ChannelRuntime(
|
||||||
|
service=FakeAgentService(),
|
||||||
|
workspace=tmp_path,
|
||||||
|
channels={},
|
||||||
|
)
|
||||||
|
|
||||||
|
cases = {
|
||||||
|
"telegram-main": ChannelConfig(enabled=True, kind="telegram", mode="polling", account_id="bot-main"),
|
||||||
|
"feishu-main": ChannelConfig(enabled=True, kind="feishu", mode="websocket", account_id="tenant-main"),
|
||||||
|
"qq-main": ChannelConfig(enabled=True, kind="qqbot", mode="websocket", account_id="qq-main"),
|
||||||
|
"weixin-main": ChannelConfig(enabled=True, kind="weixin", mode="polling", account_id="wx-main"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for channel_id, cfg in cases.items():
|
||||||
|
adapter = runtime._build_adapter(channel_id, cfg)
|
||||||
|
assert adapter.channel_id == channel_id
|
||||||
|
assert adapter.kind == cfg.kind
|
||||||
|
assert adapter.mode == cfg.mode
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_runtime_reports_platform_capabilities(tmp_path) -> None:
|
||||||
|
runtime = ChannelRuntime(
|
||||||
|
service=FakeAgentService(),
|
||||||
|
workspace=tmp_path,
|
||||||
|
channels={
|
||||||
|
"telegram-main": ChannelConfig(enabled=True, kind="telegram", mode="polling", account_id="bot-main"),
|
||||||
|
"weixin-main": ChannelConfig(enabled=True, kind="weixin", mode="polling", account_id="wx-main"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
by_id = {item["channel_id"]: item for item in runtime.statuses()}
|
||||||
|
|
||||||
|
assert by_id["telegram-main"]["capabilities"] == [
|
||||||
|
"receive_text",
|
||||||
|
"send_text",
|
||||||
|
"receive_media",
|
||||||
|
"groups",
|
||||||
|
]
|
||||||
|
assert by_id["weixin-main"]["capabilities"] == [
|
||||||
|
"receive_text",
|
||||||
|
"send_text",
|
||||||
|
"receive_media",
|
||||||
|
"direct_messages",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_runtime_platform_start_failure_does_not_stop_other_channels(tmp_path) -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
runtime = ChannelRuntime(
|
||||||
|
service=FakeAgentService(),
|
||||||
|
workspace=tmp_path,
|
||||||
|
channels={
|
||||||
|
"telegram-main": ChannelConfig(
|
||||||
|
enabled=True,
|
||||||
|
kind="telegram",
|
||||||
|
mode="polling",
|
||||||
|
account_id="bot-main",
|
||||||
|
secrets={},
|
||||||
|
),
|
||||||
|
"off": ChannelConfig(
|
||||||
|
enabled=False,
|
||||||
|
kind="weixin",
|
||||||
|
mode="polling",
|
||||||
|
account_id="wx-main",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await runtime.start()
|
||||||
|
try:
|
||||||
|
by_id = {item["channel_id"]: item for item in runtime.statuses()}
|
||||||
|
finally:
|
||||||
|
await runtime.stop()
|
||||||
|
|
||||||
|
assert by_id["telegram-main"]["state"] == "error"
|
||||||
|
assert "botToken" in by_id["telegram-main"]["last_error"]
|
||||||
|
assert by_id["off"]["state"] == "disabled"
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_web_app_status_exposes_configured_channel(tmp_path) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"agents": {"defaults": {"workspace": str(workspace), "model": "openai/gpt-5"}},
|
||||||
|
"providers": {},
|
||||||
|
"channels": {
|
||||||
|
"webhook-dev": {
|
||||||
|
"enabled": True,
|
||||||
|
"kind": "webhook",
|
||||||
|
"mode": "webhook",
|
||||||
|
"accountId": "local",
|
||||||
|
"displayName": "Webhook Dev",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
service = AgentService(config_path=config_path)
|
||||||
|
app = create_app(service=service, manage_service_lifecycle=False)
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
payload = client.get("/api/status").json()
|
||||||
|
|
||||||
|
service.close()
|
||||||
|
assert payload["channels"][0]["channel_id"] == "webhook-dev"
|
||||||
|
assert payload["channels"][0]["state"] == "running"
|
||||||
|
assert payload["channels"][0]["webhook_url"] == "/api/channels/webhook-dev/webhook"
|
||||||
|
assert payload["runtime_controls"]["self_restart"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_self_restart_env_defaults_enabled(monkeypatch) -> None:
|
||||||
|
monkeypatch.delenv("BEAVER_ENABLE_SELF_RESTART", raising=False)
|
||||||
|
|
||||||
|
assert _self_restart_enabled() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_self_restart_env_can_disable(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("BEAVER_ENABLE_SELF_RESTART", "0")
|
||||||
|
|
||||||
|
assert _self_restart_enabled() is False
|
||||||
@ -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())
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
@ -46,6 +47,44 @@ def test_load_config_reads_current_instance_shape(tmp_path) -> None:
|
|||||||
assert target["extra_headers"] == {"X-Test": "1"}
|
assert target["extra_headers"] == {"X-Test": "1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_loader_reads_channels(tmp_path) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"agents": {"defaults": {"model": "openai/gpt-5"}},
|
||||||
|
"channels": {
|
||||||
|
"webhook-dev": {
|
||||||
|
"enabled": True,
|
||||||
|
"kind": "webhook",
|
||||||
|
"mode": "webhook",
|
||||||
|
"accountId": "local",
|
||||||
|
"displayName": "Webhook Dev",
|
||||||
|
"config": {
|
||||||
|
"responseTimeoutSeconds": 1800,
|
||||||
|
"dedupeRetentionHours": 48,
|
||||||
|
},
|
||||||
|
"secrets": {"ignored_for_status": "secret-value"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_config(config_path=config_path)
|
||||||
|
|
||||||
|
channel = config.channels["webhook-dev"]
|
||||||
|
assert channel.enabled is True
|
||||||
|
assert channel.kind == "webhook"
|
||||||
|
assert channel.mode == "webhook"
|
||||||
|
assert channel.account_id == "local"
|
||||||
|
assert channel.display_name == "Webhook Dev"
|
||||||
|
assert channel.config["response_timeout_seconds"] == 1800
|
||||||
|
assert channel.config["dedupe_retention_hours"] == 48
|
||||||
|
assert channel.secrets == {"ignored_for_status": "secret-value"}
|
||||||
|
|
||||||
|
|
||||||
def test_provider_resolution_ignores_custom_and_disabled_overrides(tmp_path) -> None:
|
def test_provider_resolution_ignores_custom_and_disabled_overrides(tmp_path) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@ -163,6 +202,58 @@ def test_reload_agent_config_updates_booted_loop_config(tmp_path) -> None:
|
|||||||
service.close()
|
service.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reload_agent_config_keeps_running_service_when_old_mcp_close_fails(tmp_path) -> None:
|
||||||
|
async def run_case() -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"agents": {"defaults": {"workspace": str(workspace), "model": "old-model"}},
|
||||||
|
"providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://old.example.com/v1"}},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
service = AgentService(config_path=config_path)
|
||||||
|
await service.start()
|
||||||
|
|
||||||
|
class FailingMCPManager:
|
||||||
|
async def close(self) -> None:
|
||||||
|
raise RuntimeError("Attempted to exit cancel scope in a different task than it was entered in")
|
||||||
|
|
||||||
|
loaded = service.create_loop().boot()
|
||||||
|
loaded.mcp_manager = FailingMCPManager()
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"agents": {"defaults": {"workspace": str(workspace), "model": "new-model"}},
|
||||||
|
"providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://new.example.com/v1"}},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
unhandled: list[dict[str, object]] = []
|
||||||
|
previous_handler = loop.get_exception_handler()
|
||||||
|
loop.set_exception_handler(lambda _loop, context: unhandled.append(context))
|
||||||
|
try:
|
||||||
|
_reload_agent_config(service, config_path)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
target = service.create_loop().boot().config.resolve_provider_target()
|
||||||
|
assert service.is_running is True
|
||||||
|
assert target["model"] == "new-model"
|
||||||
|
assert target["api_base"] == "https://new.example.com/v1"
|
||||||
|
assert unhandled == []
|
||||||
|
finally:
|
||||||
|
loop.set_exception_handler(previous_handler)
|
||||||
|
await service.shutdown(force=True)
|
||||||
|
|
||||||
|
asyncio.run(run_case())
|
||||||
|
|
||||||
|
|
||||||
def test_agent_defaults_include_runtime_controls(tmp_path) -> None:
|
def test_agent_defaults_include_runtime_controls(tmp_path) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@ -245,6 +336,67 @@ def test_agent_config_api_accepts_zero_temperature_and_iterations(tmp_path) -> N
|
|||||||
service.close()
|
service.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_config_api_persists_and_masks_secrets(tmp_path) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"agents": {"defaults": {"model": "openai/gpt-5"}},
|
||||||
|
"channels": {
|
||||||
|
"telegram-main": {
|
||||||
|
"enabled": False,
|
||||||
|
"kind": "telegram",
|
||||||
|
"mode": "polling",
|
||||||
|
"accountId": "bot-main",
|
||||||
|
"displayName": "Telegram Main",
|
||||||
|
"secrets": {"botToken": "1234567890abcdef"},
|
||||||
|
"config": {"requireMentionInGroups": True},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
service = AgentService(config_path=config_path)
|
||||||
|
app = create_app(service=service, manage_service_lifecycle=False)
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
before = client.get("/api/channels/telegram-main/config")
|
||||||
|
response = client.post(
|
||||||
|
"/api/channels/telegram-main/config",
|
||||||
|
json={
|
||||||
|
"enabled": True,
|
||||||
|
"kind": "telegram",
|
||||||
|
"mode": "polling",
|
||||||
|
"account_id": "bot-main",
|
||||||
|
"display_name": "Telegram Primary",
|
||||||
|
"secrets": {"botToken": ""},
|
||||||
|
"config": {
|
||||||
|
"requireMentionInGroups": False,
|
||||||
|
"allowFrom": ["1001", "1002"],
|
||||||
|
"maxMessageChars": 3000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
channel = saved["channels"]["telegram-main"]
|
||||||
|
|
||||||
|
assert before.status_code == 200
|
||||||
|
assert before.json()["secrets"] == {"botToken": "1234••••cdef"}
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["ok"] is True
|
||||||
|
assert response.json()["restart_required"] is True
|
||||||
|
assert response.json()["channel"]["display_name"] == "Telegram Primary"
|
||||||
|
assert response.json()["channel"]["secrets"] == {"botToken": "1234••••cdef"}
|
||||||
|
assert channel["enabled"] is True
|
||||||
|
assert channel["displayName"] == "Telegram Primary"
|
||||||
|
assert channel["secrets"]["botToken"] == "1234567890abcdef"
|
||||||
|
assert channel["config"]["allowFrom"] == ["1001", "1002"]
|
||||||
|
assert load_config(config_path=config_path).channels["telegram-main"].enabled is True
|
||||||
|
service.close()
|
||||||
|
|
||||||
|
|
||||||
def test_openai_compatible_qwen_config_keeps_openai_provider() -> None:
|
def test_openai_compatible_qwen_config_keeps_openai_provider() -> None:
|
||||||
bundle = make_provider_bundle(
|
bundle = make_provider_bundle(
|
||||||
model="qwen-plus",
|
model="qwen-plus",
|
||||||
|
|||||||
@ -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
|
||||||
@ -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()
|
||||||
@ -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())
|
||||||
@ -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()
|
||||||
154
app-instance/backend/tests/unit/test_feishu_channel_adapter.py
Normal file
154
app-instance/backend/tests/unit/test_feishu_channel_adapter.py
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from beaver.foundation.events import OutboundMessage
|
||||||
|
from beaver.interfaces.channels.platforms.feishu import FeishuAdapter
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSink:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.messages = []
|
||||||
|
|
||||||
|
async def accept_inbound(self, message):
|
||||||
|
self.messages.append(message)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeFeishuClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.sent = []
|
||||||
|
|
||||||
|
async def send_text(self, *, receive_id_type: str, receive_id: str, text: str):
|
||||||
|
self.sent.append({"receive_id_type": receive_id_type, "receive_id": receive_id, "text": text})
|
||||||
|
|
||||||
|
|
||||||
|
def test_feishu_normalizes_direct_text_event() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
sink = FakeSink()
|
||||||
|
adapter = FeishuAdapter(
|
||||||
|
channel_id="feishu-main",
|
||||||
|
kind="feishu",
|
||||||
|
mode="websocket",
|
||||||
|
account_id="tenant-main",
|
||||||
|
display_name=None,
|
||||||
|
inbound_sink=sink,
|
||||||
|
secrets={"appId": "app", "appSecret": "secret"},
|
||||||
|
config={},
|
||||||
|
client=FakeFeishuClient(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await adapter.handle_event_payload(
|
||||||
|
{
|
||||||
|
"event": {
|
||||||
|
"message": {
|
||||||
|
"message_id": "m1",
|
||||||
|
"chat_id": "oc_chat",
|
||||||
|
"chat_type": "p2p",
|
||||||
|
"message_type": "text",
|
||||||
|
"content": "{\"text\":\"hello\"}",
|
||||||
|
},
|
||||||
|
"sender": {"sender_id": {"open_id": "ou_user"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
message = sink.messages[0]
|
||||||
|
assert message.content == "hello"
|
||||||
|
assert message.session_id == "feishu-main:tenant-main:oc_chat"
|
||||||
|
assert message.channel_identity.peer_type == "dm"
|
||||||
|
assert message.channel_identity.user_id == "ou_user"
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_feishu_group_mention_gate() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
sink = FakeSink()
|
||||||
|
adapter = FeishuAdapter(
|
||||||
|
channel_id="feishu-main",
|
||||||
|
kind="feishu",
|
||||||
|
mode="websocket",
|
||||||
|
account_id="tenant-main",
|
||||||
|
display_name=None,
|
||||||
|
inbound_sink=sink,
|
||||||
|
secrets={"appId": "app", "appSecret": "secret"},
|
||||||
|
config={"requireMentionInGroups": True, "botOpenId": "ou_bot"},
|
||||||
|
client=FakeFeishuClient(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await adapter.handle_event_payload(
|
||||||
|
{
|
||||||
|
"event": {
|
||||||
|
"message": {
|
||||||
|
"message_id": "m1",
|
||||||
|
"chat_id": "oc_group",
|
||||||
|
"chat_type": "group",
|
||||||
|
"message_type": "text",
|
||||||
|
"content": "{\"text\":\"hello\"}",
|
||||||
|
"mentions": [],
|
||||||
|
},
|
||||||
|
"sender": {"sender_id": {"open_id": "ou_user"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await adapter.handle_event_payload(
|
||||||
|
{
|
||||||
|
"event": {
|
||||||
|
"message": {
|
||||||
|
"message_id": "m2",
|
||||||
|
"chat_id": "oc_group",
|
||||||
|
"chat_type": "group",
|
||||||
|
"message_type": "text",
|
||||||
|
"content": "{\"text\":\"hello\"}",
|
||||||
|
"mentions": [{"id": {"open_id": "ou_bot"}}],
|
||||||
|
},
|
||||||
|
"sender": {"sender_id": {"open_id": "ou_user"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(sink.messages) == 1
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_feishu_sends_text_to_chat_id() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
sink = FakeSink()
|
||||||
|
client = FakeFeishuClient()
|
||||||
|
adapter = FeishuAdapter(
|
||||||
|
channel_id="feishu-main",
|
||||||
|
kind="feishu",
|
||||||
|
mode="websocket",
|
||||||
|
account_id="tenant-main",
|
||||||
|
display_name=None,
|
||||||
|
inbound_sink=sink,
|
||||||
|
secrets={"appId": "app", "appSecret": "secret"},
|
||||||
|
config={},
|
||||||
|
client=client,
|
||||||
|
)
|
||||||
|
await adapter.handle_event_payload(
|
||||||
|
{
|
||||||
|
"event": {
|
||||||
|
"message": {
|
||||||
|
"message_id": "m1",
|
||||||
|
"chat_id": "oc_chat",
|
||||||
|
"chat_type": "p2p",
|
||||||
|
"message_type": "text",
|
||||||
|
"content": "{\"text\":\"hello\"}",
|
||||||
|
},
|
||||||
|
"sender": {"sender_id": {"open_id": "ou_user"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await adapter.send(
|
||||||
|
OutboundMessage(
|
||||||
|
channel="feishu-main",
|
||||||
|
content="ok",
|
||||||
|
session_id=sink.messages[0].session_id,
|
||||||
|
finish_reason="stop",
|
||||||
|
channel_identity=sink.messages[0].channel_identity,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert client.sent == [{"receive_id_type": "chat_id", "receive_id": "oc_chat", "text": "ok"}]
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
@ -2,9 +2,10 @@ import asyncio
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from beaver.foundation.events import InboundMessage, MessageBus
|
from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage
|
||||||
from beaver.interfaces.channels import ChannelManager, MemoryChannelAdapter
|
from beaver.interfaces.channels import ChannelManager, MemoryChannelAdapter
|
||||||
from beaver.interfaces.gateway.main import run_gateway
|
from beaver.interfaces.gateway.main import run_gateway
|
||||||
|
from beaver.interfaces.channels.runtime import ChannelRuntime
|
||||||
from beaver.services.agent_service import AgentService
|
from beaver.services.agent_service import AgentService
|
||||||
|
|
||||||
|
|
||||||
@ -52,22 +53,15 @@ class InvalidService:
|
|||||||
is_running = True
|
is_running = True
|
||||||
|
|
||||||
|
|
||||||
def test_gateway_routes_memory_channel_roundtrip() -> None:
|
def test_gateway_routes_memory_channel_roundtrip(tmp_path) -> None:
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
channel = MemoryChannelAdapter(bus)
|
runtime = ChannelRuntime(service=FakeService(), bus=bus, channels={}, workspace=tmp_path)
|
||||||
stop_event = asyncio.Event()
|
channel = MemoryChannelAdapter(runtime)
|
||||||
task = asyncio.create_task(
|
runtime.manager.register(channel)
|
||||||
run_gateway(
|
await runtime.start()
|
||||||
service=FakeService(),
|
|
||||||
manage_service_lifecycle=False,
|
|
||||||
bus=bus,
|
|
||||||
channels=[channel],
|
|
||||||
stop_event=stop_event,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
await channel.publish_text("hello", session_id="s1")
|
await channel.publish_text("hello", peer_id="s1", message_id="m1")
|
||||||
for _ in range(40):
|
for _ in range(40):
|
||||||
if channel.sent_messages:
|
if channel.sent_messages:
|
||||||
break
|
break
|
||||||
@ -76,38 +70,73 @@ def test_gateway_routes_memory_channel_roundtrip() -> None:
|
|||||||
assert channel.sent_messages
|
assert channel.sent_messages
|
||||||
message = channel.sent_messages[0]
|
message = channel.sent_messages[0]
|
||||||
assert message.content == "echo:hello"
|
assert message.content == "echo:hello"
|
||||||
assert message.session_id == "s1"
|
assert message.session_id == "memory-dev:memory:s1"
|
||||||
assert message.finish_reason == "stop"
|
assert message.finish_reason == "stop"
|
||||||
assert message.metadata["task_id"] == "task-1"
|
assert message.metadata["task_id"] == "task-1"
|
||||||
assert message.metadata["task_status"] == "awaiting_acceptance"
|
assert message.metadata["task_status"] == "awaiting_acceptance"
|
||||||
assert message.metadata["evidence_status"] == "recorded"
|
assert message.metadata["evidence_status"] == "recorded"
|
||||||
assert message.metadata["validation_result"] is None
|
assert message.metadata["validation_result"] is None
|
||||||
|
|
||||||
stop_event.set()
|
await runtime.stop()
|
||||||
await asyncio.wait_for(task, timeout=2)
|
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
def test_gateway_delivers_cancelled_outbound_to_channel() -> None:
|
def test_channel_manager_dispatches_by_channel_id() -> None:
|
||||||
|
class CaptureChannel:
|
||||||
|
channel_id = "webhook-dev"
|
||||||
|
kind = "webhook"
|
||||||
|
mode = "webhook"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.sent = []
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def send(self, message: Any) -> None:
|
||||||
|
self.sent.append(message)
|
||||||
|
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
channel = MemoryChannelAdapter(bus)
|
channel = CaptureChannel()
|
||||||
stop_event = asyncio.Event()
|
manager = ChannelManager(bus)
|
||||||
task = asyncio.create_task(
|
manager.register(channel)
|
||||||
run_gateway(
|
await bus.publish_outbound(
|
||||||
service=SlowService(),
|
OutboundMessage(
|
||||||
manage_service_lifecycle=False,
|
channel="webhook-dev",
|
||||||
bus=bus,
|
content="ok",
|
||||||
channels=[channel],
|
session_id="webhook-dev:local:demo",
|
||||||
stop_event=stop_event,
|
finish_reason="stop",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
stop_event = asyncio.Event()
|
||||||
await channel.publish_text("slow", session_id="s1")
|
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
stop_event.set()
|
stop_event.set()
|
||||||
await asyncio.wait_for(task, timeout=3)
|
|
||||||
|
await manager.dispatch_outbound(stop_event)
|
||||||
|
|
||||||
|
assert channel.sent[0].content == "ok"
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_gateway_delivers_cancelled_outbound_to_channel(tmp_path) -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
bus = MessageBus()
|
||||||
|
runtime = ChannelRuntime(service=SlowService(), bus=bus, channels={}, workspace=tmp_path)
|
||||||
|
channel = MemoryChannelAdapter(runtime)
|
||||||
|
runtime.manager.register(channel)
|
||||||
|
await runtime.start()
|
||||||
|
|
||||||
|
await channel.publish_text("slow", peer_id="s1", message_id="m1")
|
||||||
|
for _ in range(40):
|
||||||
|
if any(event["kind"] == "direct_run_started" for event in runtime.events.recent(limit=20)):
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
await runtime.stop()
|
||||||
|
|
||||||
assert channel.sent_messages
|
assert channel.sent_messages
|
||||||
assert channel.sent_messages[0].finish_reason == "cancelled"
|
assert channel.sent_messages[0].finish_reason == "cancelled"
|
||||||
@ -118,13 +147,27 @@ def test_gateway_delivers_cancelled_outbound_to_channel() -> None:
|
|||||||
def test_gateway_rejects_channel_manager_and_channels_together() -> None:
|
def test_gateway_rejects_channel_manager_and_channels_together() -> None:
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
|
class CaptureChannel:
|
||||||
|
channel_id = "memory-dev"
|
||||||
|
kind = "memory"
|
||||||
|
mode = "webhook"
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def send(self, message: Any) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await run_gateway(
|
await run_gateway(
|
||||||
service=FakeService(),
|
service=FakeService(),
|
||||||
manage_service_lifecycle=False,
|
manage_service_lifecycle=False,
|
||||||
bus=bus,
|
bus=bus,
|
||||||
channel_manager=ChannelManager(bus),
|
channel_manager=ChannelManager(bus),
|
||||||
channels=[MemoryChannelAdapter(bus)],
|
channels=[CaptureChannel()],
|
||||||
stop_event=asyncio.Event(),
|
stop_event=asyncio.Event(),
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@ -212,10 +255,16 @@ def test_channel_manager_keeps_unknown_channel_outbound_undeliverable() -> None:
|
|||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None:
|
def test_memory_channel_adapts_payload_to_channel_identity_session_id(tmp_path) -> None:
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
channel = MemoryChannelAdapter(bus, name="telegram")
|
runtime = ChannelRuntime(service=FakeService(), bus=bus, channels={}, workspace=tmp_path)
|
||||||
|
channel = MemoryChannelAdapter(
|
||||||
|
runtime,
|
||||||
|
channel_id="telegram-main",
|
||||||
|
kind="telegram",
|
||||||
|
account_id="bot-main",
|
||||||
|
)
|
||||||
inbound = await channel.publish_external_text(
|
inbound = await channel.publish_external_text(
|
||||||
"hello",
|
"hello",
|
||||||
chat_id="chat-1",
|
chat_id="chat-1",
|
||||||
@ -225,8 +274,10 @@ def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None:
|
|||||||
|
|
||||||
queued = await bus.consume_inbound()
|
queued = await bus.consume_inbound()
|
||||||
assert queued is inbound
|
assert queued is inbound
|
||||||
assert queued.channel == "telegram"
|
assert queued.channel == "telegram-main"
|
||||||
assert queued.session_id == "telegram:chat-1"
|
assert queued.session_id == "telegram-main:bot-main:chat-1"
|
||||||
|
assert queued.channel_identity is not None
|
||||||
|
assert queued.channel_identity.kind == "telegram"
|
||||||
assert queued.metadata["chat_id"] == "chat-1"
|
assert queued.metadata["chat_id"] == "chat-1"
|
||||||
assert queued.metadata["message_id"] == "message-1"
|
assert queued.metadata["message_id"] == "message-1"
|
||||||
assert queued.metadata["raw_channel_payload"] == {"platform": "telegram", "text": "hello"}
|
assert queued.metadata["raw_channel_payload"] == {"platform": "telegram", "text": "hello"}
|
||||||
@ -236,7 +287,9 @@ def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None:
|
|||||||
|
|
||||||
def test_channel_manager_start_cancellation_rolls_back_started_channels() -> None:
|
def test_channel_manager_start_cancellation_rolls_back_started_channels() -> None:
|
||||||
class StartedChannel:
|
class StartedChannel:
|
||||||
name = "started"
|
channel_id = "started"
|
||||||
|
kind = "memory"
|
||||||
|
mode = "webhook"
|
||||||
|
|
||||||
def __init__(self, bus: MessageBus) -> None:
|
def __init__(self, bus: MessageBus) -> None:
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
@ -252,7 +305,9 @@ def test_channel_manager_start_cancellation_rolls_back_started_channels() -> Non
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
class BlockingChannel:
|
class BlockingChannel:
|
||||||
name = "blocking"
|
channel_id = "blocking"
|
||||||
|
kind = "memory"
|
||||||
|
mode = "webhook"
|
||||||
|
|
||||||
def __init__(self, bus: MessageBus) -> None:
|
def __init__(self, bus: MessageBus) -> None:
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
|
|||||||
@ -6,6 +6,34 @@ from beaver.interfaces.web.app import create_app
|
|||||||
from beaver.interfaces.web.schemas import WebChatRequest, WebChatResponse
|
from beaver.interfaces.web.schemas import WebChatRequest, WebChatResponse
|
||||||
|
|
||||||
|
|
||||||
|
def test_platform_channel_modules_import_without_live_clients() -> None:
|
||||||
|
from beaver.interfaces.channels.platforms.feishu import FeishuAdapter
|
||||||
|
from beaver.interfaces.channels.platforms.qqbot import QQBotAdapter
|
||||||
|
from beaver.interfaces.channels.platforms.telegram import TelegramAdapter
|
||||||
|
from beaver.interfaces.channels.platforms.weixin import WeixinAdapter
|
||||||
|
|
||||||
|
assert FeishuAdapter.KIND == "feishu"
|
||||||
|
assert QQBotAdapter.KIND == "qqbot"
|
||||||
|
assert TelegramAdapter.KIND == "telegram"
|
||||||
|
assert WeixinAdapter.KIND == "weixin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_platform_channel_optional_extras_are_declared() -> None:
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
||||||
|
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
||||||
|
extras = data["project"]["optional-dependencies"]
|
||||||
|
|
||||||
|
assert "python-telegram-bot>=22.0,<23.0" in extras["telegram"]
|
||||||
|
assert "lark-oapi>=1.4.22,<2.0.0" in extras["feishu"]
|
||||||
|
assert "aiohttp>=3.9.0,<4.0.0" in extras["qqbot"]
|
||||||
|
assert "aiohttp>=3.9.0,<4.0.0" in extras["weixin"]
|
||||||
|
assert "python-telegram-bot>=22.0,<23.0" in extras["channels"]
|
||||||
|
assert "lark-oapi>=1.4.22,<2.0.0" in extras["channels"]
|
||||||
|
|
||||||
|
|
||||||
def test_agent_loop_boots(tmp_path) -> None:
|
def test_agent_loop_boots(tmp_path) -> None:
|
||||||
loop = AgentLoop(loader=EngineLoader(workspace=tmp_path))
|
loop = AgentLoop(loader=EngineLoader(workspace=tmp_path))
|
||||||
loaded = loop.boot()
|
loaded = loop.boot()
|
||||||
@ -32,10 +60,14 @@ def test_message_bus_imports() -> None:
|
|||||||
|
|
||||||
def test_channel_imports() -> None:
|
def test_channel_imports() -> None:
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
channel = MemoryChannelAdapter(bus)
|
class Sink:
|
||||||
|
async def accept_inbound(self, message):
|
||||||
|
await bus.publish_inbound(message)
|
||||||
|
|
||||||
|
channel = MemoryChannelAdapter(Sink())
|
||||||
manager = ChannelManager(bus)
|
manager = ChannelManager(bus)
|
||||||
manager.register(channel)
|
manager.register(channel)
|
||||||
assert manager.channels["memory"] is channel
|
assert manager.channels["memory-dev"] is channel
|
||||||
|
|
||||||
|
|
||||||
def test_web_schema_imports() -> None:
|
def test_web_schema_imports() -> None:
|
||||||
|
|||||||
@ -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]"
|
||||||
143
app-instance/backend/tests/unit/test_qqbot_channel_adapter.py
Normal file
143
app-instance/backend/tests/unit/test_qqbot_channel_adapter.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from beaver.foundation.events import OutboundMessage
|
||||||
|
from beaver.interfaces.channels.platforms.qqbot import QQBotAdapter
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSink:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.messages = []
|
||||||
|
|
||||||
|
async def accept_inbound(self, message):
|
||||||
|
self.messages.append(message)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeQQBotClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.sent = []
|
||||||
|
|
||||||
|
async def send_text(self, *, peer_type: str, peer_id: str, content: str, message_id: str | None):
|
||||||
|
self.sent.append(
|
||||||
|
{
|
||||||
|
"peer_type": peer_type,
|
||||||
|
"peer_id": peer_id,
|
||||||
|
"content": content,
|
||||||
|
"message_id": message_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_qqbot_normalizes_private_c2c_message() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
sink = FakeSink()
|
||||||
|
adapter = QQBotAdapter(
|
||||||
|
channel_id="qq-main",
|
||||||
|
kind="qqbot",
|
||||||
|
mode="websocket",
|
||||||
|
account_id="qq-bot",
|
||||||
|
display_name=None,
|
||||||
|
inbound_sink=sink,
|
||||||
|
secrets={"appId": "app", "clientSecret": "secret"},
|
||||||
|
config={},
|
||||||
|
client=FakeQQBotClient(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await adapter.handle_event_payload(
|
||||||
|
{
|
||||||
|
"t": "C2C_MESSAGE_CREATE",
|
||||||
|
"d": {
|
||||||
|
"id": "m1",
|
||||||
|
"author": {"user_openid": "u1"},
|
||||||
|
"content": "hello",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
message = sink.messages[0]
|
||||||
|
assert message.content == "hello"
|
||||||
|
assert message.session_id == "qq-main:qq-bot:u1"
|
||||||
|
assert message.channel_identity.peer_type == "dm"
|
||||||
|
assert message.channel_identity.user_id == "u1"
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_qqbot_normalizes_group_message() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
sink = FakeSink()
|
||||||
|
adapter = QQBotAdapter(
|
||||||
|
channel_id="qq-main",
|
||||||
|
kind="qqbot",
|
||||||
|
mode="websocket",
|
||||||
|
account_id="qq-bot",
|
||||||
|
display_name=None,
|
||||||
|
inbound_sink=sink,
|
||||||
|
secrets={"appId": "app", "clientSecret": "secret"},
|
||||||
|
config={},
|
||||||
|
client=FakeQQBotClient(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await adapter.handle_event_payload(
|
||||||
|
{
|
||||||
|
"t": "GROUP_AT_MESSAGE_CREATE",
|
||||||
|
"d": {
|
||||||
|
"id": "m2",
|
||||||
|
"group_openid": "g1",
|
||||||
|
"author": {"member_openid": "u1"},
|
||||||
|
"content": "hello group",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
message = sink.messages[0]
|
||||||
|
assert message.session_id == "qq-main:qq-bot:g1"
|
||||||
|
assert message.channel_identity.peer_type == "group"
|
||||||
|
assert message.channel_identity.user_id == "u1"
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_qqbot_sends_reply_with_original_message_id() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
sink = FakeSink()
|
||||||
|
client = FakeQQBotClient()
|
||||||
|
adapter = QQBotAdapter(
|
||||||
|
channel_id="qq-main",
|
||||||
|
kind="qqbot",
|
||||||
|
mode="websocket",
|
||||||
|
account_id="qq-bot",
|
||||||
|
display_name=None,
|
||||||
|
inbound_sink=sink,
|
||||||
|
secrets={"appId": "app", "clientSecret": "secret"},
|
||||||
|
config={},
|
||||||
|
client=client,
|
||||||
|
)
|
||||||
|
await adapter.handle_event_payload(
|
||||||
|
{
|
||||||
|
"t": "GROUP_AT_MESSAGE_CREATE",
|
||||||
|
"d": {
|
||||||
|
"id": "m2",
|
||||||
|
"group_openid": "g1",
|
||||||
|
"author": {"member_openid": "u1"},
|
||||||
|
"content": "hello group",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await adapter.send(
|
||||||
|
OutboundMessage(
|
||||||
|
channel="qq-main",
|
||||||
|
content="ok",
|
||||||
|
session_id=sink.messages[0].session_id,
|
||||||
|
finish_reason="stop",
|
||||||
|
channel_identity=sink.messages[0].channel_identity,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert client.sent[0] == {
|
||||||
|
"peer_type": "group",
|
||||||
|
"peer_id": "g1",
|
||||||
|
"content": "ok",
|
||||||
|
"message_id": "m2",
|
||||||
|
}
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
141
app-instance/backend/tests/unit/test_telegram_channel_adapter.py
Normal file
141
app-instance/backend/tests/unit/test_telegram_channel_adapter.py
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from beaver.foundation.events import OutboundMessage
|
||||||
|
from beaver.interfaces.channels.platforms.telegram import TelegramAdapter
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSink:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.messages = []
|
||||||
|
|
||||||
|
async def accept_inbound(self, message):
|
||||||
|
self.messages.append(message)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTelegramClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.sent = []
|
||||||
|
|
||||||
|
async def send_message(self, **kwargs):
|
||||||
|
self.sent.append(kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_normalizes_private_text_message() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
sink = FakeSink()
|
||||||
|
adapter = TelegramAdapter(
|
||||||
|
channel_id="telegram-main",
|
||||||
|
kind="telegram",
|
||||||
|
mode="polling",
|
||||||
|
account_id="bot-main",
|
||||||
|
display_name=None,
|
||||||
|
inbound_sink=sink,
|
||||||
|
secrets={"botToken": "x"},
|
||||||
|
config={},
|
||||||
|
client=FakeTelegramClient(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await adapter.handle_update_payload(
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"message_id": 100,
|
||||||
|
"text": "hello",
|
||||||
|
"chat": {"id": 200, "type": "private"},
|
||||||
|
"from": {"id": 300, "username": "ivan"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
message = sink.messages[0]
|
||||||
|
assert message.channel == "telegram-main"
|
||||||
|
assert message.content == "hello"
|
||||||
|
assert message.session_id == "telegram-main:bot-main:200"
|
||||||
|
assert message.channel_identity.peer_type == "dm"
|
||||||
|
assert message.channel_identity.user_id == "300"
|
||||||
|
assert message.channel_identity.message_id == "100"
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_group_requires_mention_when_configured() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
sink = FakeSink()
|
||||||
|
adapter = TelegramAdapter(
|
||||||
|
channel_id="telegram-main",
|
||||||
|
kind="telegram",
|
||||||
|
mode="polling",
|
||||||
|
account_id="bot-main",
|
||||||
|
display_name=None,
|
||||||
|
inbound_sink=sink,
|
||||||
|
secrets={"botToken": "x"},
|
||||||
|
config={"requireMentionInGroups": True, "botUsername": "beaver_bot"},
|
||||||
|
client=FakeTelegramClient(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await adapter.handle_update_payload(
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"message_id": 101,
|
||||||
|
"text": "hello group",
|
||||||
|
"chat": {"id": -20, "type": "group"},
|
||||||
|
"from": {"id": 300},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await adapter.handle_update_payload(
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"message_id": 102,
|
||||||
|
"text": "@beaver_bot hello",
|
||||||
|
"chat": {"id": -20, "type": "group"},
|
||||||
|
"from": {"id": 300},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(sink.messages) == 1
|
||||||
|
assert sink.messages[0].content == "hello"
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_sends_chunked_reply_to_identity_target() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
sink = FakeSink()
|
||||||
|
client = FakeTelegramClient()
|
||||||
|
adapter = TelegramAdapter(
|
||||||
|
channel_id="telegram-main",
|
||||||
|
kind="telegram",
|
||||||
|
mode="polling",
|
||||||
|
account_id="bot-main",
|
||||||
|
display_name=None,
|
||||||
|
inbound_sink=sink,
|
||||||
|
secrets={"botToken": "x"},
|
||||||
|
config={"maxMessageChars": 3},
|
||||||
|
client=client,
|
||||||
|
)
|
||||||
|
await adapter.handle_update_payload(
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"message_id": 100,
|
||||||
|
"text": "hello",
|
||||||
|
"chat": {"id": 200, "type": "private"},
|
||||||
|
"from": {"id": 300},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await adapter.send(
|
||||||
|
OutboundMessage(
|
||||||
|
channel="telegram-main",
|
||||||
|
content="abcdef",
|
||||||
|
session_id=sink.messages[0].session_id,
|
||||||
|
finish_reason="stop",
|
||||||
|
channel_identity=sink.messages[0].channel_identity,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [item["text"] for item in client.sent] == ["abc", "def"]
|
||||||
|
assert client.sent[0]["chat_id"] == "200"
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
@ -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())
|
||||||
@ -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()
|
||||||
129
app-instance/backend/tests/unit/test_weixin_channel_adapter.py
Normal file
129
app-instance/backend/tests/unit/test_weixin_channel_adapter.py
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from beaver.foundation.events import OutboundMessage
|
||||||
|
from beaver.interfaces.channels.platforms.weixin import WeixinAdapter
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSink:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.messages = []
|
||||||
|
|
||||||
|
async def accept_inbound(self, message):
|
||||||
|
self.messages.append(message)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWeixinClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.sent = []
|
||||||
|
|
||||||
|
async def send_text(self, *, peer_id: str, text: str, context_token: str | None):
|
||||||
|
self.sent.append({"peer_id": peer_id, "text": text, "context_token": context_token})
|
||||||
|
|
||||||
|
|
||||||
|
def test_weixin_normalizes_direct_text_message() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
sink = FakeSink()
|
||||||
|
adapter = WeixinAdapter(
|
||||||
|
channel_id="weixin-main",
|
||||||
|
kind="weixin",
|
||||||
|
mode="polling",
|
||||||
|
account_id="wx-main",
|
||||||
|
display_name=None,
|
||||||
|
inbound_sink=sink,
|
||||||
|
secrets={"token": "token"},
|
||||||
|
config={},
|
||||||
|
client=FakeWeixinClient(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await adapter.handle_message_payload(
|
||||||
|
{
|
||||||
|
"id": "m1",
|
||||||
|
"from": "wx_user",
|
||||||
|
"room_id": "",
|
||||||
|
"type": "text",
|
||||||
|
"text": "hello",
|
||||||
|
"context_token": "ctx1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
message = sink.messages[0]
|
||||||
|
assert message.content == "hello"
|
||||||
|
assert message.session_id == "weixin-main:wx-main:wx_user"
|
||||||
|
assert message.channel_identity.peer_type == "dm"
|
||||||
|
assert message.metadata["context_token"] == "ctx1"
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_weixin_group_message_is_best_effort() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
sink = FakeSink()
|
||||||
|
adapter = WeixinAdapter(
|
||||||
|
channel_id="weixin-main",
|
||||||
|
kind="weixin",
|
||||||
|
mode="polling",
|
||||||
|
account_id="wx-main",
|
||||||
|
display_name=None,
|
||||||
|
inbound_sink=sink,
|
||||||
|
secrets={"token": "token"},
|
||||||
|
config={"groupPolicy": "open"},
|
||||||
|
client=FakeWeixinClient(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await adapter.handle_message_payload(
|
||||||
|
{
|
||||||
|
"id": "m2",
|
||||||
|
"from": "wx_user",
|
||||||
|
"room_id": "room1",
|
||||||
|
"type": "text",
|
||||||
|
"text": "hello room",
|
||||||
|
"context_token": "ctx2",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
message = sink.messages[0]
|
||||||
|
assert message.session_id == "weixin-main:wx-main:room1"
|
||||||
|
assert message.channel_identity.peer_type == "group"
|
||||||
|
assert message.channel_identity.user_id == "wx_user"
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_weixin_sends_text_with_context_token() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
sink = FakeSink()
|
||||||
|
client = FakeWeixinClient()
|
||||||
|
adapter = WeixinAdapter(
|
||||||
|
channel_id="weixin-main",
|
||||||
|
kind="weixin",
|
||||||
|
mode="polling",
|
||||||
|
account_id="wx-main",
|
||||||
|
display_name=None,
|
||||||
|
inbound_sink=sink,
|
||||||
|
secrets={"token": "token"},
|
||||||
|
config={},
|
||||||
|
client=client,
|
||||||
|
)
|
||||||
|
await adapter.handle_message_payload(
|
||||||
|
{
|
||||||
|
"id": "m1",
|
||||||
|
"from": "wx_user",
|
||||||
|
"type": "text",
|
||||||
|
"text": "hello",
|
||||||
|
"context_token": "ctx1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await adapter.send(
|
||||||
|
OutboundMessage(
|
||||||
|
channel="weixin-main",
|
||||||
|
content="ok",
|
||||||
|
session_id=sink.messages[0].session_id,
|
||||||
|
finish_reason="stop",
|
||||||
|
channel_identity=sink.messages[0].channel_identity,
|
||||||
|
metadata={"inbound_metadata": sink.messages[0].metadata},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert client.sent == [{"peer_id": "wx_user", "text": "ok", "context_token": "ctx1"}]
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
185
app-instance/backend/uv.lock
generated
185
app-instance/backend/uv.lock
generated
@ -252,27 +252,51 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
|
channels = [
|
||||||
|
{ name = "aiohttp" },
|
||||||
|
{ name = "lark-oapi" },
|
||||||
|
{ name = "python-telegram-bot" },
|
||||||
|
]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
]
|
]
|
||||||
|
feishu = [
|
||||||
|
{ name = "lark-oapi" },
|
||||||
|
]
|
||||||
|
qqbot = [
|
||||||
|
{ name = "aiohttp" },
|
||||||
|
]
|
||||||
|
telegram = [
|
||||||
|
{ name = "python-telegram-bot" },
|
||||||
|
]
|
||||||
|
weixin = [
|
||||||
|
{ name = "aiohttp" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "aiohttp", marker = "extra == 'channels'", specifier = ">=3.9.0,<4.0.0" },
|
||||||
|
{ name = "aiohttp", marker = "extra == 'qqbot'", specifier = ">=3.9.0,<4.0.0" },
|
||||||
|
{ name = "aiohttp", marker = "extra == 'weixin'", specifier = ">=3.9.0,<4.0.0" },
|
||||||
{ name = "anthropic", specifier = ">=0.51.0,<1.0.0" },
|
{ name = "anthropic", specifier = ">=0.51.0,<1.0.0" },
|
||||||
{ name = "croniter", specifier = ">=6.0.0,<7.0.0" },
|
{ name = "croniter", specifier = ">=6.0.0,<7.0.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.115.0,<1.0.0" },
|
{ name = "fastapi", specifier = ">=0.115.0,<1.0.0" },
|
||||||
{ name = "fastmcp", specifier = ">=3.0.0,<4.0.0" },
|
{ name = "fastmcp", specifier = ">=3.0.0,<4.0.0" },
|
||||||
{ name = "httpx", specifier = ">=0.28.0,<1.0.0" },
|
{ name = "httpx", specifier = ">=0.28.0,<1.0.0" },
|
||||||
{ name = "json-repair", specifier = ">=0.39.0,<1.0.0" },
|
{ name = "json-repair", specifier = ">=0.39.0,<1.0.0" },
|
||||||
|
{ name = "lark-oapi", marker = "extra == 'channels'", specifier = ">=1.4.22,<2.0.0" },
|
||||||
|
{ name = "lark-oapi", marker = "extra == 'feishu'", specifier = ">=1.4.22,<2.0.0" },
|
||||||
{ name = "litellm", specifier = ">=1.79.0,<2.0.0" },
|
{ name = "litellm", specifier = ">=1.79.0,<2.0.0" },
|
||||||
{ name = "openai", specifier = ">=1.79.0,<2.0.0" },
|
{ name = "openai", specifier = ">=1.79.0,<2.0.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.12.0,<3.0.0" },
|
{ name = "pydantic", specifier = ">=2.12.0,<3.0.0" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" },
|
||||||
{ name = "python-multipart", specifier = ">=0.0.20,<1.0.0" },
|
{ name = "python-multipart", specifier = ">=0.0.20,<1.0.0" },
|
||||||
|
{ name = "python-telegram-bot", marker = "extra == 'channels'", specifier = ">=22.0,<23.0" },
|
||||||
|
{ name = "python-telegram-bot", marker = "extra == 'telegram'", specifier = ">=22.0,<23.0" },
|
||||||
{ name = "typer", specifier = ">=0.20.0,<1.0.0" },
|
{ name = "typer", specifier = ">=0.20.0,<1.0.0" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0,<1.0.0" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0,<1.0.0" },
|
||||||
]
|
]
|
||||||
provides-extras = ["dev"]
|
provides-extras = ["dev", "telegram", "feishu", "qqbot", "weixin", "channels"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cachetools"
|
name = "cachetools"
|
||||||
@ -1277,6 +1301,21 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
|
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lark-oapi"
|
||||||
|
version = "1.6.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "pycryptodome" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "requests-toolbelt" },
|
||||||
|
{ name = "websockets" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/54/a3b649b83299606aa7ebfd2391663fde650e934421dfba37af171bfbf456/lark_oapi-1.6.7-py3-none-any.whl", hash = "sha256:df1d44891d266f5c063daa1d37ae6f72c7f166bdc2fb01e607088410e952b92c", size = 7146261, upload-time = "2026-05-28T03:32:21.268Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litellm"
|
name = "litellm"
|
||||||
version = "1.80.0"
|
version = "1.80.0"
|
||||||
@ -1759,6 +1798,36 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pycryptodome"
|
||||||
|
version = "3.23.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.13.3"
|
version = "2.13.3"
|
||||||
@ -1973,6 +2042,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" },
|
{ url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-telegram-bot"
|
||||||
|
version = "22.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "httpcore", marker = "python_full_version >= '3.14'" },
|
||||||
|
{ name = "httpx" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e4/25/2258161b1069e66d6c39c0a602dbe57461d4767dc0012539970ea40bc9d6/python_telegram_bot-22.7.tar.gz", hash = "sha256:784b59ea3852fe4616ad63b4a0264c755637f5d725e87755ecdee28300febf61", size = 1516454, upload-time = "2026-03-16T09:36:03.174Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/f7/0e2f89dd62f45d46d4ea0d8aec5893ce5b37389638db010c117f46f11450/python_telegram_bot-22.7-py3-none-any.whl", hash = "sha256:d72eed532cf763758cd9331b57a6d790aff0bb4d37d8f4e92149436fe21c6475", size = 745365, upload-time = "2026-03-16T09:36:01.498Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pywin32"
|
name = "pywin32"
|
||||||
version = "311"
|
version = "311"
|
||||||
@ -2189,6 +2271,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests-toolbelt"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "requests" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rich"
|
name = "rich"
|
||||||
version = "15.0.0"
|
version = "15.0.0"
|
||||||
@ -2687,61 +2781,44 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "websockets"
|
name = "websockets"
|
||||||
version = "16.0"
|
version = "15.0.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" },
|
{ url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" },
|
{ url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" },
|
{ url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" },
|
{ url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" },
|
{ url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" },
|
{ url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" },
|
{ url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" },
|
{ url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
|
{ url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
|
{ url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
|
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
|
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
|
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
|
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
|
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
|
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
|
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
|
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
|
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
|
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
|
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
|
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
|
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
|
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
|
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
|
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@ -37,6 +37,8 @@ INSTANCES_ROOT="${INSTANCES_ROOT:-$INSTANCES_ROOT_DEFAULT}"
|
|||||||
REGISTRY_PATH="${REGISTRY_PATH:-$REGISTRY_PATH_DEFAULT}"
|
REGISTRY_PATH="${REGISTRY_PATH:-$REGISTRY_PATH_DEFAULT}"
|
||||||
NETWORK_NAME="${NETWORK_NAME:-}"
|
NETWORK_NAME="${NETWORK_NAME:-}"
|
||||||
HOST_BIND_IP="${HOST_BIND_IP:-127.0.0.1}"
|
HOST_BIND_IP="${HOST_BIND_IP:-127.0.0.1}"
|
||||||
|
INITIAL_SKILLS_DIR="${INITIAL_SKILLS_DIR:-${SCRIPT_DIR}/../skills}"
|
||||||
|
SEED_INITIAL_SKILLS=1
|
||||||
FORCE_BUILD=0
|
FORCE_BUILD=0
|
||||||
REPLACE=0
|
REPLACE=0
|
||||||
|
|
||||||
@ -78,6 +80,9 @@ Optional:
|
|||||||
--registry <path> Registry JSON path. Default: ./runtime/registry/instances.json
|
--registry <path> Registry JSON path. Default: ./runtime/registry/instances.json
|
||||||
--network <name> Optional docker network name.
|
--network <name> Optional docker network name.
|
||||||
--host-bind-ip <ip> Host bind IP for published port. Default: 127.0.0.1
|
--host-bind-ip <ip> Host bind IP for published port. Default: 127.0.0.1
|
||||||
|
--initial-skills-dir <path> Directory copied into workspace/skills on first create.
|
||||||
|
Default: ../skills
|
||||||
|
--skip-initial-skills Do not seed initial workspace skills.
|
||||||
--build Force rebuild image before running.
|
--build Force rebuild image before running.
|
||||||
--replace Remove existing container with same name before running.
|
--replace Remove existing container with same name before running.
|
||||||
--help Show this help.
|
--help Show this help.
|
||||||
@ -225,6 +230,69 @@ data = {
|
|||||||
"name": os.environ["BACKEND_NAME"].strip(),
|
"name": os.environ["BACKEND_NAME"].strip(),
|
||||||
"publicBaseUrl": os.environ["PUBLIC_URL"].strip(),
|
"publicBaseUrl": os.environ["PUBLIC_URL"].strip(),
|
||||||
},
|
},
|
||||||
|
"channels": {
|
||||||
|
"telegram-main": {
|
||||||
|
"enabled": False,
|
||||||
|
"kind": "telegram",
|
||||||
|
"mode": "polling",
|
||||||
|
"accountId": "bot-main",
|
||||||
|
"displayName": "Telegram Main",
|
||||||
|
"secrets": {
|
||||||
|
"botToken": "",
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"requireMentionInGroups": True,
|
||||||
|
"maxMessageChars": 4096,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"feishu-main": {
|
||||||
|
"enabled": False,
|
||||||
|
"kind": "feishu",
|
||||||
|
"mode": "websocket",
|
||||||
|
"accountId": "tenant-main",
|
||||||
|
"displayName": "Feishu Main",
|
||||||
|
"secrets": {
|
||||||
|
"appId": "",
|
||||||
|
"appSecret": "",
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"domain": "feishu",
|
||||||
|
"connectionMode": "websocket",
|
||||||
|
"requireMentionInGroups": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"qqbot-main": {
|
||||||
|
"enabled": False,
|
||||||
|
"kind": "qqbot",
|
||||||
|
"mode": "websocket",
|
||||||
|
"accountId": "qqbot-main",
|
||||||
|
"displayName": "QQ Bot Main",
|
||||||
|
"secrets": {
|
||||||
|
"appId": "",
|
||||||
|
"clientSecret": "",
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"dmPolicy": "open",
|
||||||
|
"groupPolicy": "allowlist",
|
||||||
|
"markdownSupport": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"weixin-main": {
|
||||||
|
"enabled": False,
|
||||||
|
"kind": "weixin",
|
||||||
|
"mode": "polling",
|
||||||
|
"accountId": "wx-main",
|
||||||
|
"displayName": "Weixin Main",
|
||||||
|
"secrets": {
|
||||||
|
"token": "",
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"dmPolicy": "open",
|
||||||
|
"groupPolicy": "disabled",
|
||||||
|
"textBatchDelaySeconds": 0.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||||
@ -255,6 +323,66 @@ target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encodin
|
|||||||
PY
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
seed_initial_skills() {
|
||||||
|
local workspace_path="$1"
|
||||||
|
local initial_skills_dir="$2"
|
||||||
|
local target_dir="${workspace_path}/skills"
|
||||||
|
|
||||||
|
if [[ "$SEED_INITIAL_SKILLS" -ne 1 ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [[ ! -d "$initial_skills_dir" ]]; then
|
||||||
|
log "initial skills directory not found, skipping: ${initial_skills_dir}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$target_dir"
|
||||||
|
INITIAL_SKILLS_DIR="$initial_skills_dir" TARGET_DIR="$target_dir" python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
initial = Path(os.environ["INITIAL_SKILLS_DIR"]).resolve()
|
||||||
|
target = Path(os.environ["TARGET_DIR"]).resolve()
|
||||||
|
|
||||||
|
for child in sorted(initial.iterdir()):
|
||||||
|
if child.name.startswith("."):
|
||||||
|
continue
|
||||||
|
destination = target / child.name
|
||||||
|
if destination.exists():
|
||||||
|
continue
|
||||||
|
if child.is_dir():
|
||||||
|
shutil.copytree(child, destination)
|
||||||
|
elif child.is_file():
|
||||||
|
shutil.copy2(child, destination)
|
||||||
|
|
||||||
|
for index_name in ("published", "disabled"):
|
||||||
|
initial_index = initial / "_index" / f"{index_name}.json"
|
||||||
|
target_index = target / "_index" / f"{index_name}.json"
|
||||||
|
if not initial_index.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
initial_items = json.loads(initial_index.read_text(encoding="utf-8")).get("items", [])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
initial_items = []
|
||||||
|
if target_index.exists():
|
||||||
|
try:
|
||||||
|
target_items = json.loads(target_index.read_text(encoding="utf-8")).get("items", [])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
target_items = []
|
||||||
|
else:
|
||||||
|
target_items = []
|
||||||
|
merged = []
|
||||||
|
for item in [*target_items, *initial_items]:
|
||||||
|
text = str(item).strip()
|
||||||
|
if text and text not in merged:
|
||||||
|
merged.append(text)
|
||||||
|
target_index.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target_index.write_text(json.dumps({"items": merged}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
render_runtime_env_file() {
|
render_runtime_env_file() {
|
||||||
local target_path="$1"
|
local target_path="$1"
|
||||||
|
|
||||||
@ -428,6 +556,14 @@ while [[ $# -gt 0 ]]; do
|
|||||||
HOST_BIND_IP="${2:-}"
|
HOST_BIND_IP="${2:-}"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--initial-skills-dir)
|
||||||
|
INITIAL_SKILLS_DIR="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--skip-initial-skills)
|
||||||
|
SEED_INITIAL_SKILLS=0
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--build)
|
--build)
|
||||||
FORCE_BUILD=1
|
FORCE_BUILD=1
|
||||||
shift
|
shift
|
||||||
@ -531,6 +667,7 @@ mkdir -p "$BEAVER_HOME" "$WORKSPACE_PATH"
|
|||||||
render_config_json "$CONFIG_PATH"
|
render_config_json "$CONFIG_PATH"
|
||||||
render_auth_users_json "$AUTH_USERS_PATH"
|
render_auth_users_json "$AUTH_USERS_PATH"
|
||||||
render_runtime_env_file "$RUNTIME_ENV_PATH"
|
render_runtime_env_file "$RUNTIME_ENV_PATH"
|
||||||
|
seed_initial_skills "$WORKSPACE_PATH" "$INITIAL_SKILLS_DIR"
|
||||||
|
|
||||||
if [[ "$FORCE_BUILD" -eq 1 ]] || ! image_exists; then
|
if [[ "$FORCE_BUILD" -eq 1 ]] || ! image_exists; then
|
||||||
log "building image ${IMAGE_NAME}"
|
log "building image ${IMAGE_NAME}"
|
||||||
@ -564,6 +701,7 @@ RUN_ARGS=(
|
|||||||
-e "APP_PUBLIC_PORT=8080"
|
-e "APP_PUBLIC_PORT=8080"
|
||||||
-e "APP_FRONTEND_PORT=3000"
|
-e "APP_FRONTEND_PORT=3000"
|
||||||
-e "APP_BACKEND_PORT=18080"
|
-e "APP_BACKEND_PORT=18080"
|
||||||
|
-e "BEAVER_ENABLE_SELF_RESTART=1"
|
||||||
-e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}"
|
-e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}"
|
||||||
--label "beaver.instance.id=${INSTANCE_ID}"
|
--label "beaver.instance.id=${INSTANCE_ID}"
|
||||||
--label "beaver.instance.slug=${INSTANCE_SLUG}"
|
--label "beaver.instance.slug=${INSTANCE_SLUG}"
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import type { ChatLogEvent, ChatLogSession } from '@/types';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { containedJsonTextClass } from '@/lib/text-wrapping';
|
||||||
|
|
||||||
function eventLabel(event: ChatLogEvent): string {
|
function eventLabel(event: ChatLogEvent): string {
|
||||||
return event.event_type || event.role || 'event';
|
return event.event_type || event.role || 'event';
|
||||||
@ -175,7 +176,7 @@ export default function LogsPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${event.message_id ?? index}:${event.event_type}`}
|
key={`${event.message_id ?? index}:${event.event_type}`}
|
||||||
className="rounded-lg border border-border bg-background"
|
className="min-w-0 max-w-full overflow-hidden rounded-lg border border-border bg-background"
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b px-3 py-2">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b px-3 py-2">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
@ -188,7 +189,7 @@ export default function LogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">{timestampLabel(event.timestamp)}</span>
|
<span className="text-xs text-muted-foreground">{timestampLabel(event.timestamp)}</span>
|
||||||
</div>
|
</div>
|
||||||
<pre className="max-h-[520px] overflow-auto whitespace-pre-wrap break-words p-3 text-xs leading-5 text-foreground">
|
<pre className={`max-h-[520px] overflow-auto p-3 text-xs leading-5 text-foreground ${containedJsonTextClass}`}>
|
||||||
{body || formatPayload(event)}
|
{body || formatPayload(event)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -19,7 +19,12 @@ import {
|
|||||||
uploadFile,
|
uploadFile,
|
||||||
wsManager,
|
wsManager,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { mergeServerWithPendingUsers, shouldDisplayChatMessage, shouldMergePendingUsers } from '@/lib/chat-messages';
|
import {
|
||||||
|
getSessionRefreshIntervalMs,
|
||||||
|
mergeServerWithPendingUsers,
|
||||||
|
shouldDisplayChatMessage,
|
||||||
|
shouldMergePendingUsers,
|
||||||
|
} from '@/lib/chat-messages';
|
||||||
import { pickAppText } from '@/lib/i18n/core';
|
import { pickAppText } from '@/lib/i18n/core';
|
||||||
import { useAppI18n } from '@/lib/i18n/provider';
|
import { useAppI18n } from '@/lib/i18n/provider';
|
||||||
import { buildSessionProgressView } from '@/lib/session-progress';
|
import { buildSessionProgressView } from '@/lib/session-progress';
|
||||||
@ -47,6 +52,10 @@ function loadThinkingModePreference(): boolean {
|
|||||||
return stored == null ? false : stored !== 'false';
|
return stored == null ? false : stored !== 'false';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDocumentHidden(): boolean {
|
||||||
|
return typeof document !== 'undefined' && document.visibilityState === 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const { locale } = useAppI18n();
|
const { locale } = useAppI18n();
|
||||||
const {
|
const {
|
||||||
@ -78,6 +87,7 @@ export default function ChatPage() {
|
|||||||
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
|
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
|
||||||
const [activeTask, setActiveTask] = useState<ActiveTask | null>(null);
|
const [activeTask, setActiveTask] = useState<ActiveTask | null>(null);
|
||||||
const [revisionTargetRunId, setRevisionTargetRunId] = useState<string | null>(null);
|
const [revisionTargetRunId, setRevisionTargetRunId] = useState<string | null>(null);
|
||||||
|
const [documentHidden, setDocumentHidden] = useState(isDocumentHidden);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const messageViewportRef = useRef<HTMLDivElement>(null);
|
const messageViewportRef = useRef<HTMLDivElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@ -247,14 +257,26 @@ export default function ChatPage() {
|
|||||||
}, [addMessage, loadActiveTask, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]);
|
}, [addMessage, loadActiveTask, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !isThinking) {
|
const intervalMs = getSessionRefreshIntervalMs({ isLoading, isThinking, documentHidden });
|
||||||
|
if (intervalMs == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
loadSessionMessages(useChatStore.getState().sessionId);
|
const currentSessionId = useChatStore.getState().sessionId;
|
||||||
}, 1500);
|
void loadSessionMessages(currentSessionId);
|
||||||
|
void loadSessions();
|
||||||
|
}, intervalMs);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [isLoading, isThinking, loadSessionMessages]);
|
}, [documentHidden, isLoading, isThinking, loadSessionMessages, loadSessions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updateVisibility = () => setDocumentHidden(isDocumentHidden());
|
||||||
|
document.addEventListener('visibilitychange', updateVisibility);
|
||||||
|
return () => document.removeEventListener('visibilitychange', updateVisibility);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior) => {
|
const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior) => {
|
||||||
const viewport = messageViewportRef.current;
|
const viewport = messageViewportRef.current;
|
||||||
|
|||||||
@ -73,6 +73,7 @@ import type {
|
|||||||
} from '@/types';
|
} from '@/types';
|
||||||
import { pickAppText } from '@/lib/i18n/core';
|
import { pickAppText } from '@/lib/i18n/core';
|
||||||
import { useAppI18n } from '@/lib/i18n/provider';
|
import { useAppI18n } from '@/lib/i18n/provider';
|
||||||
|
import { containedJsonTextClass, containedLongTextClass } from '@/lib/text-wrapping';
|
||||||
|
|
||||||
const TERMINAL_DRAFT_STATUSES = new Set(['rejected', 'published', 'disabled', 'archived']);
|
const TERMINAL_DRAFT_STATUSES = new Set(['rejected', 'published', 'disabled', 'archived']);
|
||||||
const REJECTABLE_DRAFT_STATUSES = new Set(['draft', 'in_review', 'approved']);
|
const REJECTABLE_DRAFT_STATUSES = new Set(['draft', 'in_review', 'approved']);
|
||||||
@ -1094,7 +1095,7 @@ function ReadableFact({
|
|||||||
{icon}
|
{icon}
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<div className="break-words text-sm leading-5">{value || '-'}</div>
|
<div className={`text-sm leading-5 ${containedLongTextClass}`}>{value || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1119,12 +1120,12 @@ function MetricTile({
|
|||||||
|
|
||||||
function RawDetails({ title, payload }: { title: string; payload: unknown }) {
|
function RawDetails({ title, payload }: { title: string; payload: unknown }) {
|
||||||
return (
|
return (
|
||||||
<details className="mt-3 rounded-md border border-border bg-white">
|
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-white">
|
||||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-xs font-medium text-muted-foreground">
|
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||||
{title}
|
{title}
|
||||||
<ChevronDown className="h-3.5 w-3.5" />
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
</summary>
|
</summary>
|
||||||
<pre className="max-h-72 overflow-auto border-t border-border p-3 text-xs leading-5">
|
<pre className={`max-h-72 overflow-auto border-t border-border p-3 text-xs leading-5 ${containedJsonTextClass}`}>
|
||||||
{JSON.stringify(payload, null, 2)}
|
{JSON.stringify(payload, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -88,6 +88,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.contained-long-text {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contained-preserved-long-text {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contained-json-text {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Override Tailwind Typography table defaults for markdown rendering */
|
/* Override Tailwind Typography table defaults for markdown rendering */
|
||||||
.prose table {
|
.prose table {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
|
|||||||
@ -3,9 +3,11 @@
|
|||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
|
import { containedLongTextClass } from '@/lib/text-wrapping';
|
||||||
|
|
||||||
export function MarkdownContent({ content }: { content: string }) {
|
export function MarkdownContent({ content }: { content: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="prose prose-sm max-w-none text-[#1D1715] prose-headings:text-[#0B0B0B] prose-p:text-[#1D1715] prose-p:leading-7 prose-strong:text-[#0B0B0B] prose-a:text-[#342E2B] prose-a:underline prose-a:decoration-[#B8AEA8] prose-a:underline-offset-4 prose-li:text-[#1D1715] prose-blockquote:border-l-[#D8D2CE] prose-blockquote:text-[#4F4642] prose-code:rounded-md prose-code:bg-[#ECE8E5] prose-code:px-1.5 prose-code:py-0.5 prose-code:text-[#342E2B] prose-pre:border prose-pre:border-[#D8D2CE] prose-pre:bg-[#ECE8E5] prose-pre:text-[#342E2B] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
<div className={`prose prose-sm max-w-none text-[#1D1715] prose-headings:text-[#0B0B0B] prose-p:text-[#1D1715] prose-p:leading-7 prose-strong:text-[#0B0B0B] prose-a:text-[#342E2B] prose-a:underline prose-a:decoration-[#B8AEA8] prose-a:underline-offset-4 prose-li:text-[#1D1715] prose-blockquote:border-l-[#D8D2CE] prose-blockquote:text-[#4F4642] prose-code:rounded-md prose-code:bg-[#ECE8E5] prose-code:px-1.5 prose-code:py-0.5 prose-code:text-[#342E2B] prose-code:[overflow-wrap:anywhere] prose-pre:border prose-pre:border-[#D8D2CE] prose-pre:bg-[#ECE8E5] prose-pre:text-[#342E2B] prose-pre:whitespace-pre-wrap prose-pre:[overflow-wrap:anywhere] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${containedLongTextClass}`}>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
components={{
|
components={{
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
|
|||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { pickAppText } from '@/lib/i18n/core';
|
import { pickAppText } from '@/lib/i18n/core';
|
||||||
import { useAppI18n } from '@/lib/i18n/provider';
|
import { useAppI18n } from '@/lib/i18n/provider';
|
||||||
|
import { containedPreservedLongTextClass } from '@/lib/text-wrapping';
|
||||||
|
|
||||||
function AuthImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
function AuthImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
||||||
const [blobUrl, setBlobUrl] = React.useState<string | null>(null);
|
const [blobUrl, setBlobUrl] = React.useState<string | null>(null);
|
||||||
@ -66,7 +67,7 @@ function MessageBubble({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={`max-w-[88%] px-4 py-3 ${
|
className={`min-w-0 max-w-[88%] px-4 py-3 ${
|
||||||
isUser
|
isUser
|
||||||
? 'rounded-[28px] bg-primary text-primary-foreground'
|
? 'rounded-[28px] bg-primary text-primary-foreground'
|
||||||
: 'rounded-none bg-transparent text-[#1D1715]'
|
: 'rounded-none bg-transparent text-[#1D1715]'
|
||||||
@ -92,14 +93,14 @@ function MessageBubble({
|
|||||||
key={att.file_id}
|
key={att.file_id}
|
||||||
href={fileUrl}
|
href={fileUrl}
|
||||||
download={att.name}
|
download={att.name}
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm ${
|
className={`flex min-w-0 items-center gap-2 px-3 py-2 rounded-md text-sm ${
|
||||||
isUser
|
isUser
|
||||||
? 'bg-primary-foreground/10 hover:bg-primary-foreground/20'
|
? 'bg-primary-foreground/10 hover:bg-primary-foreground/20'
|
||||||
: 'bg-muted hover:bg-muted/80'
|
: 'bg-muted hover:bg-muted/80'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Paperclip className="w-3.5 h-3.5 flex-shrink-0" />
|
<Paperclip className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
<span className="truncate">{att.name}</span>
|
<span className="min-w-0 truncate">{att.name}</span>
|
||||||
{att.size && (
|
{att.size && (
|
||||||
<span className="text-xs opacity-70 flex-shrink-0">
|
<span className="text-xs opacity-70 flex-shrink-0">
|
||||||
{att.size > 1024 * 1024
|
{att.size > 1024 * 1024
|
||||||
@ -114,7 +115,7 @@ function MessageBubble({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isUser ? (
|
{isUser ? (
|
||||||
<p className="text-sm whitespace-pre-wrap">{textContent}</p>
|
<p className={`text-sm ${containedPreservedLongTextClass}`}>{textContent}</p>
|
||||||
) : (
|
) : (
|
||||||
<MarkdownContent content={textContent} />
|
<MarkdownContent content={textContent} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
import { pickAppText } from '@/lib/i18n/core';
|
import { pickAppText } from '@/lib/i18n/core';
|
||||||
import { useAppI18n } from '@/lib/i18n/provider';
|
import { useAppI18n } from '@/lib/i18n/provider';
|
||||||
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
|
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
|
||||||
|
import { containedPreservedLongTextClass } from '@/lib/text-wrapping';
|
||||||
|
|
||||||
export type TaskFeedbackType = 'accept' | 'revise' | 'abandon';
|
export type TaskFeedbackType = 'accept' | 'revise' | 'abandon';
|
||||||
|
|
||||||
@ -177,7 +178,7 @@ export function TaskAcceptanceControls({
|
|||||||
<CheckCircle2 className="h-4 w-4 text-[#657162]" />
|
<CheckCircle2 className="h-4 w-4 text-[#657162]" />
|
||||||
{pickAppText(locale, '已提交验收', 'Acceptance submitted')}: {humanFeedback(feedbackKind(recordedFeedback), locale)}
|
{pickAppText(locale, '已提交验收', 'Acceptance submitted')}: {humanFeedback(feedbackKind(recordedFeedback), locale)}
|
||||||
</div>
|
</div>
|
||||||
{recordedFeedback.comment ? <p className="mt-2 whitespace-pre-wrap text-muted-foreground">{String(recordedFeedback.comment)}</p> : null}
|
{recordedFeedback.comment ? <p className={`mt-2 text-muted-foreground ${containedPreservedLongTextClass}`}>{String(recordedFeedback.comment)}</p> : null}
|
||||||
{recordedFeedback.created_at ? (
|
{recordedFeedback.created_at ? (
|
||||||
<p className="mt-2 text-xs text-muted-foreground">{formatTaskRuntimeTime(String(recordedFeedback.created_at), locale)}</p>
|
<p className="mt-2 text-xs text-muted-foreground">{formatTaskRuntimeTime(String(recordedFeedback.created_at), locale)}</p>
|
||||||
) : null}
|
) : null}
|
||||||
@ -229,7 +230,7 @@ export function TaskAcceptanceControls({
|
|||||||
disabled={Boolean(recordedFeedback) || isFinalized || !isReadyForAcceptance || Boolean(actionBusy)}
|
disabled={Boolean(recordedFeedback) || isFinalized || !isReadyForAcceptance || Boolean(actionBusy)}
|
||||||
placeholder={pickAppText(locale, '需要修改时写下具体要求;接受或放弃可选填说明。', 'Describe requested changes; notes are optional for accept or abandon.')}
|
placeholder={pickAppText(locale, '需要修改时写下具体要求;接受或放弃可选填说明。', 'Describe requested changes; notes are optional for accept or abandon.')}
|
||||||
/>
|
/>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className={`text-xs text-muted-foreground ${containedPreservedLongTextClass}`}>
|
||||||
{pickAppText(locale, '验收将记录到当前任务运行:', 'Acceptance will be recorded on run: ')}
|
{pickAppText(locale, '验收将记录到当前任务运行:', 'Acceptance will be recorded on run: ')}
|
||||||
<span className="font-mono">{runId || '-'}</span>
|
<span className="font-mono">{runId || '-'}</span>
|
||||||
<span className="mx-1">·</span>
|
<span className="mx-1">·</span>
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
|||||||
import { pickAppText } from '@/lib/i18n/core';
|
import { pickAppText } from '@/lib/i18n/core';
|
||||||
import { useAppI18n } from '@/lib/i18n/provider';
|
import { useAppI18n } from '@/lib/i18n/provider';
|
||||||
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
|
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
|
||||||
|
import { containedJsonTextClass, containedLongTextClass, containedPreservedLongTextClass } from '@/lib/text-wrapping';
|
||||||
import type { TaskTimelineCard as TaskTimelineCardView, TaskTimelineCardType } from '@/types';
|
import type { TaskTimelineCard as TaskTimelineCardView, TaskTimelineCardType } from '@/types';
|
||||||
|
|
||||||
import { TaskAcceptanceControls, type TaskFeedbackItem, type TaskFeedbackType } from './TaskAcceptanceCard';
|
import { TaskAcceptanceControls, type TaskFeedbackItem, type TaskFeedbackType } from './TaskAcceptanceCard';
|
||||||
@ -146,14 +147,14 @@ function TaskResultHistory({ card }: { card: TaskTimelineCardView }) {
|
|||||||
const versions = historyVersions(card.details);
|
const versions = historyVersions(card.details);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<details className="mt-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-sm">
|
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-muted/20 px-3 py-2 text-sm">
|
||||||
<summary className="flex cursor-pointer select-none items-center justify-between gap-3 font-medium">
|
<summary className="flex cursor-pointer select-none items-center justify-between gap-3 font-medium">
|
||||||
<span>{pickAppText(locale, '展开历史版本', 'Show previous versions')}</span>
|
<span>{pickAppText(locale, '展开历史版本', 'Show previous versions')}</span>
|
||||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-3">
|
||||||
{versions.map((version, index) => (
|
{versions.map((version, index) => (
|
||||||
<div key={String(version.runId || index)} className="rounded-md border border-border bg-background p-3">
|
<div key={String(version.runId || index)} className="min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-background p-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="text-sm font-medium">
|
<div className="text-sm font-medium">
|
||||||
{pickAppText(locale, `第 ${index + 1} 轮结果`, `Version ${index + 1}`)}
|
{pickAppText(locale, `第 ${index + 1} 轮结果`, `Version ${index + 1}`)}
|
||||||
@ -162,9 +163,9 @@ function TaskResultHistory({ card }: { card: TaskTimelineCardView }) {
|
|||||||
{renderHistoryStatus(version, locale)}
|
{renderHistoryStatus(version, locale)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{version.result ? <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{String(version.result)}</p> : null}
|
{version.result ? <p className={`mt-2 text-sm leading-6 text-muted-foreground ${containedPreservedLongTextClass}`}>{String(version.result)}</p> : null}
|
||||||
{version.comment ? (
|
{version.comment ? (
|
||||||
<div className="mt-3 rounded-md bg-muted/35 p-2 text-xs text-muted-foreground">
|
<div className={`mt-3 rounded-md bg-muted/35 p-2 text-xs text-muted-foreground ${containedLongTextClass}`}>
|
||||||
{pickAppText(locale, '修改意见', 'Revision note')}: {String(version.comment)}
|
{pickAppText(locale, '修改意见', 'Revision note')}: {String(version.comment)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@ -181,7 +182,7 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
|
|||||||
const shouldRenderResultAcceptance = Boolean(card.type === 'result' && resultAcceptance && card.runId === resultAcceptance.runId);
|
const shouldRenderResultAcceptance = Boolean(card.type === 'result' && resultAcceptance && card.runId === resultAcceptance.runId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card id={shouldRenderResultAcceptance ? reviewTargetId : undefined} className="rounded-md scroll-mt-28">
|
<Card id={shouldRenderResultAcceptance ? reviewTargetId : undefined} className="scroll-mt-28 overflow-hidden rounded-md">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted">
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||||
@ -197,7 +198,7 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||||
{card.actorName ? <span>{card.actorName}</span> : null}
|
{card.actorName ? <span className={containedLongTextClass}>{card.actorName}</span> : null}
|
||||||
<span>{formatTaskRuntimeTime(card.createdAt, locale)}</span>
|
<span>{formatTaskRuntimeTime(card.createdAt, locale)}</span>
|
||||||
{card.runId ? <span className="font-mono">{card.runId.slice(0, 8)}</span> : null}
|
{card.runId ? <span className="font-mono">{card.runId.slice(0, 8)}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
@ -213,7 +214,7 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{card.summary ? <p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{card.summary}</p> : null}
|
{card.summary ? <p className={`mt-3 text-sm leading-6 text-muted-foreground ${containedPreservedLongTextClass}`}>{card.summary}</p> : null}
|
||||||
|
|
||||||
{shouldRenderResultAcceptance ? (
|
{shouldRenderResultAcceptance ? (
|
||||||
<div className="mt-4 border-t border-border pt-4">
|
<div className="mt-4 border-t border-border pt-4">
|
||||||
@ -222,11 +223,11 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{card.type === 'result_history' ? <TaskResultHistory card={card} /> : card.details ? (
|
{card.type === 'result_history' ? <TaskResultHistory card={card} /> : card.details ? (
|
||||||
<details className="mt-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs">
|
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-muted/20 px-3 py-2 text-xs">
|
||||||
<summary className="cursor-pointer select-none font-medium text-muted-foreground">
|
<summary className="cursor-pointer select-none font-medium text-muted-foreground">
|
||||||
{pickAppText(locale, '详情 JSON', 'Details JSON')}
|
{pickAppText(locale, '详情 JSON', 'Details JSON')}
|
||||||
</summary>
|
</summary>
|
||||||
<pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-5 text-muted-foreground">
|
<pre className={`mt-2 max-h-72 overflow-auto text-[11px] leading-5 text-muted-foreground ${containedJsonTextClass}`}>
|
||||||
{detailsJson(card.details)}
|
{detailsJson(card.details)}
|
||||||
</pre>
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@ -8,6 +8,12 @@ import type {
|
|||||||
ChatLogsResponse,
|
ChatLogsResponse,
|
||||||
BackendTask,
|
BackendTask,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
|
ChannelConfigDetail,
|
||||||
|
ChannelConfigPayload,
|
||||||
|
ChannelConnectorDescriptor,
|
||||||
|
ConnectorSessionResponse,
|
||||||
|
ConnectorSessionStartPayload,
|
||||||
|
ChannelEventRecord,
|
||||||
CronJob,
|
CronJob,
|
||||||
FileAttachment,
|
FileAttachment,
|
||||||
NotificationDetail,
|
NotificationDetail,
|
||||||
@ -638,6 +644,53 @@ export async function updateProviderConfig(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getChannelConfig(channelId: string): Promise<ChannelConfigDetail> {
|
||||||
|
return fetchJSON(`/api/channels/${encodeURIComponent(channelId)}/config`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateChannelConfig(
|
||||||
|
channelId: string,
|
||||||
|
payload: ChannelConfigPayload
|
||||||
|
): Promise<{ ok: boolean; channel_id: string; restart_required: boolean; channel: ChannelConfigDetail }> {
|
||||||
|
return fetchJSON(`/api/channels/${encodeURIComponent(channelId)}/config`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listChannelEvents(channelId: string, limit: number = 100): Promise<ChannelEventRecord[]> {
|
||||||
|
return fetchJSON(`/api/channels/${encodeURIComponent(channelId)}/events?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listChannelConnectors(): Promise<ChannelConnectorDescriptor[]> {
|
||||||
|
return fetchJSON('/api/channel-connectors');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startChannelConnectorSession(
|
||||||
|
payload: ConnectorSessionStartPayload
|
||||||
|
): Promise<ConnectorSessionResponse> {
|
||||||
|
return fetchJSON('/api/channel-connector-sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
kind: payload.kind,
|
||||||
|
displayName: payload.displayName,
|
||||||
|
ownerUserId: payload.ownerUserId,
|
||||||
|
options: payload.options || {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChannelConnectorSession(sessionId: string): Promise<ConnectorSessionResponse> {
|
||||||
|
return fetchJSON(`/api/channel-connector-sessions/${encodeURIComponent(sessionId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restartRuntime(): Promise<{ ok: boolean; restarting: boolean }> {
|
||||||
|
return fetchJSON('/api/runtime/restart', {
|
||||||
|
method: 'POST',
|
||||||
|
timeoutMs: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Cron (proxied)
|
// Cron (proxied)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
81
app-instance/frontend/lib/channel-connectors.test.ts
Normal file
81
app-instance/frontend/lib/channel-connectors.test.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getChannelConnectorSession,
|
||||||
|
listChannelConnectors,
|
||||||
|
startChannelConnectorSession,
|
||||||
|
} from '@/lib/api';
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.clear();
|
||||||
|
}
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockJsonResponse(body: unknown) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(body),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstFetchCall(fetchMock: any): [unknown, RequestInit] {
|
||||||
|
return fetchMock.mock.calls[0] as [unknown, RequestInit];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('channel connector api', () => {
|
||||||
|
it('lists available channel connectors', async () => {
|
||||||
|
const fetchMock = vi.fn(() => mockJsonResponse([{ kind: 'weixin', displayName: 'Weixin' }]));
|
||||||
|
globalThis.fetch = fetchMock as typeof fetch;
|
||||||
|
|
||||||
|
const connectors = await listChannelConnectors();
|
||||||
|
|
||||||
|
expect(connectors).toEqual([{ kind: 'weixin', displayName: 'Weixin' }]);
|
||||||
|
expect(String(firstFetchCall(fetchMock)[0])).toMatch(/\/api\/channel-connectors$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts a connector session with options', async () => {
|
||||||
|
const fetchMock = vi.fn(() =>
|
||||||
|
mockJsonResponse({
|
||||||
|
session: { sessionId: 'cs_1', kind: 'weixin', status: 'qr_ready' },
|
||||||
|
connection: { connection_id: 'conn_1', kind: 'weixin', status: 'pairing' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
globalThis.fetch = fetchMock as typeof fetch;
|
||||||
|
|
||||||
|
const response = await startChannelConnectorSession({
|
||||||
|
kind: 'weixin',
|
||||||
|
displayName: 'Weixin Main',
|
||||||
|
options: { mode: 'qr' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.session.sessionId).toBe('cs_1');
|
||||||
|
const [, request] = firstFetchCall(fetchMock);
|
||||||
|
expect(String(firstFetchCall(fetchMock)[0])).toMatch(/\/api\/channel-connector-sessions$/);
|
||||||
|
expect(request).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ kind: 'weixin', displayName: 'Weixin Main', options: { mode: 'qr' } }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('polls a connector session by id', async () => {
|
||||||
|
const fetchMock = vi.fn(() =>
|
||||||
|
mockJsonResponse({
|
||||||
|
session: { sessionId: 'cs_1', kind: 'weixin', status: 'connected' },
|
||||||
|
connection: { connection_id: 'conn_1', kind: 'weixin', status: 'connected' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
globalThis.fetch = fetchMock as typeof fetch;
|
||||||
|
|
||||||
|
const response = await getChannelConnectorSession('cs_1');
|
||||||
|
|
||||||
|
expect(response.connection?.status).toBe('connected');
|
||||||
|
expect(String(firstFetchCall(fetchMock)[0])).toMatch(/\/api\/channel-connector-sessions\/cs_1$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,6 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { getTaskCardMessageIndexes, mergeServerWithPendingUsers, shouldDisplayChatMessage, shouldMergePendingUsers } from '@/lib/chat-messages';
|
import {
|
||||||
|
getSessionRefreshIntervalMs,
|
||||||
|
getTaskCardMessageIndexes,
|
||||||
|
mergeServerWithPendingUsers,
|
||||||
|
shouldDisplayChatMessage,
|
||||||
|
shouldMergePendingUsers,
|
||||||
|
} from '@/lib/chat-messages';
|
||||||
import type { ChatMessage } from '@/types';
|
import type { ChatMessage } from '@/types';
|
||||||
|
|
||||||
describe('chat message helpers', () => {
|
describe('chat message helpers', () => {
|
||||||
@ -98,4 +104,11 @@ describe('chat message helpers', () => {
|
|||||||
expect(shouldDisplayChatMessage({ role: 'assistant', content: 'Final answer.', task_id: 'task-1', run_id: 'run-1' })).toBe(true);
|
expect(shouldDisplayChatMessage({ role: 'assistant', content: 'Final answer.', task_id: 'task-1', run_id: 'run-1' })).toBe(true);
|
||||||
expect(shouldDisplayChatMessage({ role: 'user', content: '' })).toBe(true);
|
expect(shouldDisplayChatMessage({ role: 'user', content: '' })).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps polling idle visible chats so external channel messages appear', () => {
|
||||||
|
expect(getSessionRefreshIntervalMs({ isLoading: true, isThinking: false, documentHidden: false })).toBe(1500);
|
||||||
|
expect(getSessionRefreshIntervalMs({ isLoading: false, isThinking: true, documentHidden: false })).toBe(1500);
|
||||||
|
expect(getSessionRefreshIntervalMs({ isLoading: false, isThinking: false, documentHidden: false })).toBe(5000);
|
||||||
|
expect(getSessionRefreshIntervalMs({ isLoading: false, isThinking: false, documentHidden: true })).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,26 @@
|
|||||||
import type { ChatMessage } from '@/types';
|
import type { ChatMessage } from '@/types';
|
||||||
|
|
||||||
const INVISIBLE_CONTENT_CHARS = /[\u200B-\u200D\uFEFF]/g;
|
const INVISIBLE_CONTENT_CHARS = /[\u200B-\u200D\uFEFF]/g;
|
||||||
|
export const CHAT_WAITING_REFRESH_INTERVAL_MS = 1500;
|
||||||
|
export const CHAT_IDLE_REFRESH_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
|
export function getSessionRefreshIntervalMs({
|
||||||
|
isLoading,
|
||||||
|
isThinking,
|
||||||
|
documentHidden,
|
||||||
|
}: {
|
||||||
|
isLoading: boolean;
|
||||||
|
isThinking: boolean;
|
||||||
|
documentHidden: boolean;
|
||||||
|
}): number | null {
|
||||||
|
if (documentHidden) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (isLoading || isThinking) {
|
||||||
|
return CHAT_WAITING_REFRESH_INTERVAL_MS;
|
||||||
|
}
|
||||||
|
return CHAT_IDLE_REFRESH_INTERVAL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizedMessageText(content: unknown): string {
|
export function normalizedMessageText(content: unknown): string {
|
||||||
if (typeof content === 'string') {
|
if (typeof content === 'string') {
|
||||||
|
|||||||
22
app-instance/frontend/lib/text-wrapping.test.ts
Normal file
22
app-instance/frontend/lib/text-wrapping.test.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { containedJsonTextClass, containedLongTextClass } from './text-wrapping';
|
||||||
|
|
||||||
|
const globalsCss = readFileSync(join(process.cwd(), 'app/globals.css'), 'utf8');
|
||||||
|
|
||||||
|
describe('contained long text classes', () => {
|
||||||
|
it('keeps long plain text inside its container', () => {
|
||||||
|
expect(containedLongTextClass).toBe('contained-long-text');
|
||||||
|
expect(globalsCss).toContain('.contained-long-text');
|
||||||
|
expect(globalsCss).toContain('overflow-wrap: anywhere');
|
||||||
|
expect(globalsCss).toContain('word-break: break-word');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps long JSON and monospace output inside its container', () => {
|
||||||
|
expect(containedJsonTextClass).toBe('contained-json-text');
|
||||||
|
expect(globalsCss).toContain('.contained-json-text');
|
||||||
|
expect(globalsCss).toContain('white-space: pre-wrap');
|
||||||
|
});
|
||||||
|
});
|
||||||
5
app-instance/frontend/lib/text-wrapping.ts
Normal file
5
app-instance/frontend/lib/text-wrapping.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export const containedLongTextClass = 'contained-long-text';
|
||||||
|
|
||||||
|
export const containedPreservedLongTextClass = 'contained-preserved-long-text';
|
||||||
|
|
||||||
|
export const containedJsonTextClass = 'contained-json-text';
|
||||||
@ -6,6 +6,7 @@ const config: Config = {
|
|||||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./lib/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
|||||||
@ -148,9 +148,116 @@ export interface AgentConfigPayload {
|
|||||||
max_tool_iterations: number;
|
max_tool_iterations: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChannelStatus {
|
export interface ChannelConfigDetail {
|
||||||
name: string;
|
channel_id: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
kind: string;
|
||||||
|
mode: string;
|
||||||
|
account_id: string;
|
||||||
|
display_name: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
secrets: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelConfigPayload {
|
||||||
|
enabled: boolean;
|
||||||
|
kind: string;
|
||||||
|
mode: string;
|
||||||
|
account_id?: string;
|
||||||
|
display_name?: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
secrets: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelStatus {
|
||||||
|
channel_id: string;
|
||||||
|
name?: string;
|
||||||
|
kind: string;
|
||||||
|
mode: string;
|
||||||
|
display_name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
state: 'configured' | 'disabled' | 'starting' | 'running' | 'degraded' | 'error' | 'stopped';
|
||||||
|
account_id: string;
|
||||||
|
last_error?: string | null;
|
||||||
|
last_event_at?: string | null;
|
||||||
|
started_at?: string | null;
|
||||||
|
capabilities: string[];
|
||||||
|
webhook_url?: string | null;
|
||||||
|
websocket_url?: string | null;
|
||||||
|
connected_peers?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelEventRecord {
|
||||||
|
event_id: string;
|
||||||
|
channel_id: string;
|
||||||
|
kind: string;
|
||||||
|
session_id?: string | null;
|
||||||
|
message_id?: string | null;
|
||||||
|
run_id?: string | null;
|
||||||
|
status: string;
|
||||||
|
error?: string | null;
|
||||||
|
text_preview?: string | null;
|
||||||
|
text_length?: number;
|
||||||
|
created_at: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelConnectorDescriptor {
|
||||||
|
kind: string;
|
||||||
|
displayName?: string;
|
||||||
|
display_name?: string;
|
||||||
|
authType?: string;
|
||||||
|
auth_type?: string;
|
||||||
|
providerId?: string;
|
||||||
|
provider_id?: string;
|
||||||
|
capabilities?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelConnectionView {
|
||||||
|
connection_id: string;
|
||||||
|
owner_user_id?: string | null;
|
||||||
|
channel_id: string;
|
||||||
|
kind: string;
|
||||||
|
mode: string;
|
||||||
|
display_name: string;
|
||||||
|
account_id: string;
|
||||||
|
status: string;
|
||||||
|
auth_type: string;
|
||||||
|
runtime_config?: Record<string, unknown>;
|
||||||
|
capabilities?: string[];
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
last_seen_at?: string | null;
|
||||||
|
last_error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectorSessionView {
|
||||||
|
sessionId: string;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
qrCode?: string | null;
|
||||||
|
qrImage?: string | null;
|
||||||
|
instructions?: string[];
|
||||||
|
accountId?: string | null;
|
||||||
|
displayName?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectorSessionResponse {
|
||||||
|
session: ConnectorSessionView;
|
||||||
|
connection?: ChannelConnectionView | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectorSessionStartPayload {
|
||||||
|
kind: string;
|
||||||
|
displayName?: string;
|
||||||
|
ownerUserId?: string;
|
||||||
|
options?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeControls {
|
||||||
|
self_restart: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemStatus {
|
export interface SystemStatus {
|
||||||
@ -165,6 +272,7 @@ export interface SystemStatus {
|
|||||||
max_tool_iterations: number;
|
max_tool_iterations: number;
|
||||||
providers: ProviderStatus[];
|
providers: ProviderStatus[];
|
||||||
channels: ChannelStatus[];
|
channels: ChannelStatus[];
|
||||||
|
runtime_controls?: RuntimeControls;
|
||||||
cron: {
|
cron: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
jobs: number;
|
jobs: number;
|
||||||
|
|||||||
@ -36,6 +36,15 @@ http {
|
|||||||
proxy_pass http://127.0.0.1:18080;
|
proxy_pass http://127.0.0.1:18080;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /api/channels/ {
|
||||||
|
proxy_pass http://127.0.0.1:18080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_read_timeout 3600;
|
||||||
|
proxy_send_timeout 3600;
|
||||||
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://127.0.0.1:18080;
|
proxy_pass http://127.0.0.1:18080;
|
||||||
}
|
}
|
||||||
@ -69,4 +78,3 @@ http {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -52,18 +52,18 @@ export default function LoginPage() {
|
|||||||
className="login-logo"
|
className="login-logo"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<h1>Beaver Agentsandbox</h1>
|
<h1>Beaver AgentSandbox</h1>
|
||||||
|
|
||||||
<form className="auth-form" onSubmit={handleSubmit}>
|
<form className="auth-form" onSubmit={handleSubmit}>
|
||||||
<div className="field login-field">
|
<div className="field login-field">
|
||||||
<label className="visually-hidden" htmlFor="username">{pickPortalText(locale, '邮箱或用户名', 'Email or username')}</label>
|
<label className="visually-hidden" htmlFor="username">{pickPortalText(locale, '用户名', 'Username')}</label>
|
||||||
<MailIcon />
|
<UserIcon />
|
||||||
<input
|
<input
|
||||||
id="username"
|
id="username"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
placeholder={pickPortalText(locale, '邮箱', 'Email')}
|
placeholder={pickPortalText(locale, '用户名', 'Username')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -114,11 +114,11 @@ export default function LoginPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MailIcon() {
|
function UserIcon() {
|
||||||
return (
|
return (
|
||||||
<svg className="field-icon" viewBox="0 0 24 24" aria-hidden="true">
|
<svg className="field-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path d="M4.75 6.75h14.5v10.5H4.75z" />
|
<path d="M12 12.25a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5Z" />
|
||||||
<path d="m5.25 7.25 6.75 5.5 6.75-5.5" />
|
<path d="M5.75 19.25a6.25 6.25 0 0 1 12.5 0" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -182,7 +182,7 @@ export default function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="auth-card login-card register-card">
|
<div className="auth-card login-card register-card">
|
||||||
<BrandHeader title="Beaver Agentsandbox" />
|
<BrandHeader title="Beaver AgentSandbox" />
|
||||||
|
|
||||||
<form className="auth-form" onSubmit={handleSubmit}>
|
<form className="auth-form" onSubmit={handleSubmit}>
|
||||||
<div className="field login-field">
|
<div className="field login-field">
|
||||||
|
|||||||
32
docker-compose.external-connectors.yml
Normal file
32
docker-compose.external-connectors.yml
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
services:
|
||||||
|
external-connector:
|
||||||
|
build: ./external-connector
|
||||||
|
container_name: external-connector
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
BEAVER_BRIDGE_BASE_URL: ${BEAVER_BRIDGE_BASE_URL:-http://app-instance:8080}
|
||||||
|
BEAVER_BRIDGE_TOKEN: ${BEAVER_BRIDGE_TOKEN}
|
||||||
|
CONNECTOR_API_TOKEN: ${EXTERNAL_CONNECTOR_TOKEN}
|
||||||
|
CONNECTOR_HOME: /var/lib/external-connector
|
||||||
|
CONNECTOR_PUBLIC_BASE_URL: ${CONNECTOR_PUBLIC_BASE_URL:-http://localhost:8787}
|
||||||
|
CONNECTOR_PROVIDER: ${CONNECTOR_PROVIDER:-vendor_cli}
|
||||||
|
CONNECTOR_COMMAND_TIMEOUT_SECONDS: ${CONNECTOR_COMMAND_TIMEOUT_SECONDS:-120}
|
||||||
|
WEIXIN_CONNECT_COMMAND: ${WEIXIN_CONNECT_COMMAND:-}
|
||||||
|
WEIXIN_STATUS_COMMAND: ${WEIXIN_STATUS_COMMAND:-}
|
||||||
|
WEIXIN_SEND_COMMAND: ${WEIXIN_SEND_COMMAND:-}
|
||||||
|
FEISHU_CONNECT_COMMAND: ${FEISHU_CONNECT_COMMAND:-}
|
||||||
|
FEISHU_STATUS_COMMAND: ${FEISHU_STATUS_COMMAND:-}
|
||||||
|
FEISHU_SEND_COMMAND: ${FEISHU_SEND_COMMAND:-}
|
||||||
|
volumes:
|
||||||
|
- external-connector-state:/var/lib/external-connector
|
||||||
|
ports:
|
||||||
|
- "${EXTERNAL_CONNECTOR_PORT:-8787}:8787"
|
||||||
|
networks:
|
||||||
|
- beaver-instance-edge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
external-connector-state:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
beaver-instance-edge:
|
||||||
|
external: true
|
||||||
1105
docs/superpowers/plans/2026-06-01-terminal-websocket-channel.md
Normal file
1105
docs/superpowers/plans/2026-06-01-terminal-websocket-channel.md
Normal file
File diff suppressed because it is too large
Load Diff
1802
docs/superpowers/plans/2026-06-02-channel-connectors-foundation.md
Normal file
1802
docs/superpowers/plans/2026-06-02-channel-connectors-foundation.md
Normal file
File diff suppressed because it is too large
Load Diff
1515
docs/superpowers/plans/2026-06-02-chat-platform-channel-adapters.md
Normal file
1515
docs/superpowers/plans/2026-06-02-chat-platform-channel-adapters.md
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,790 @@
|
|||||||
|
# External Connector Frontend And Deploy Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add a connector-driven onboarding UI for Weixin and Feishu/Lark, wire frontend API helpers to backend connector-session APIs, and verify the docker-compose sidecar deployment path.
|
||||||
|
|
||||||
|
**Architecture:** The Status page keeps the existing advanced channel config editor, but adds a connector onboarding section backed by `/api/channel-connectors`, `/api/channel-connections`, and `/api/channel-connector-sessions`. Weixin shows QR status; Feishu/Lark shows provider instructions/status. Successful sessions become active without restart through backend dynamic runtime activation.
|
||||||
|
|
||||||
|
**Tech Stack:** Next.js 13, React, TypeScript, existing shadcn/Radix UI components, lucide-react, Vitest, Docker Compose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
Execute after:
|
||||||
|
|
||||||
|
- `docs/superpowers/plans/2026-06-03-external-connector-backend-runtime.md`
|
||||||
|
- `docs/superpowers/plans/2026-06-03-external-connector-sidecar.md`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Included:
|
||||||
|
|
||||||
|
- Frontend TypeScript API helpers and types for connectors, connections, and connector sessions.
|
||||||
|
- Status page connector onboarding UI.
|
||||||
|
- QR/instruction modal and polling.
|
||||||
|
- Logout/revoke action using existing connection revoke API.
|
||||||
|
- Frontend tests for API mapping and UI state helpers.
|
||||||
|
- Docker compose smoke verification instructions for local sidecar.
|
||||||
|
|
||||||
|
Excluded:
|
||||||
|
|
||||||
|
- Replacing the advanced `/api/channels` static config editor.
|
||||||
|
- Live vendor account verification logic inside frontend.
|
||||||
|
- New top-level navigation route.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Modify `app-instance/frontend/types/index.ts`
|
||||||
|
- Add connector and connector-session types.
|
||||||
|
- Modify `app-instance/frontend/lib/api.ts`
|
||||||
|
- Add connector API functions.
|
||||||
|
- Create `app-instance/frontend/lib/channel-connectors.ts`
|
||||||
|
- Small UI state helpers for connector labels/status.
|
||||||
|
- Create `app-instance/frontend/components/channel-connector-wizard.tsx`
|
||||||
|
- Connector cards, session modal, QR/instruction rendering, poll controls.
|
||||||
|
- Modify `app-instance/frontend/app/(app)/status/page.tsx`
|
||||||
|
- Fetch connector data and render wizard above advanced Channels list.
|
||||||
|
- Create `app-instance/frontend/lib/channel-connectors.test.ts`
|
||||||
|
- Helper tests.
|
||||||
|
- Create `app-instance/frontend/components/channel-connector-wizard.test.tsx`
|
||||||
|
- Component tests if the existing Vitest setup supports React Testing Library; otherwise keep helper tests and verify with typecheck/build.
|
||||||
|
- Review `docker-compose.external-connectors.yml`
|
||||||
|
- Confirm sidecar env names match backend and frontend assumptions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Frontend Types And API Client
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app-instance/frontend/types/index.ts`
|
||||||
|
- Modify: `app-instance/frontend/lib/api.ts`
|
||||||
|
- Test: `app-instance/frontend/lib/channel-connectors.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add frontend connector types**
|
||||||
|
|
||||||
|
Append to `app-instance/frontend/types/index.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface ChannelConnectorDescriptor {
|
||||||
|
kind: string;
|
||||||
|
displayName?: string;
|
||||||
|
display_name?: string;
|
||||||
|
authType?: string;
|
||||||
|
auth_type?: string;
|
||||||
|
providerId?: string;
|
||||||
|
provider_id?: string;
|
||||||
|
capabilities?: string[];
|
||||||
|
available?: boolean;
|
||||||
|
unavailableReason?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelConnectionView {
|
||||||
|
connection_id: string;
|
||||||
|
owner_user_id?: string | null;
|
||||||
|
channel_id: string;
|
||||||
|
kind: string;
|
||||||
|
mode: string;
|
||||||
|
display_name: string;
|
||||||
|
account_id: string;
|
||||||
|
status: string;
|
||||||
|
auth_type: string;
|
||||||
|
runtime_config: Record<string, unknown>;
|
||||||
|
capabilities: string[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
last_seen_at?: string | null;
|
||||||
|
last_error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelConnectionResponse {
|
||||||
|
connection: ChannelConnectionView;
|
||||||
|
credentials?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectorSessionView {
|
||||||
|
sessionId: string;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
qrCode?: string | null;
|
||||||
|
qrImage?: string | null;
|
||||||
|
instructions?: string[];
|
||||||
|
accountId?: string | null;
|
||||||
|
displayName?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectorSessionResponse {
|
||||||
|
session: ConnectorSessionView;
|
||||||
|
connection?: ChannelConnectionView | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add API imports**
|
||||||
|
|
||||||
|
Modify the import list in `app-instance/frontend/lib/api.ts` to include:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
ChannelConnectionResponse,
|
||||||
|
ChannelConnectionView,
|
||||||
|
ChannelConnectorDescriptor,
|
||||||
|
ConnectorSessionResponse,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add connector API functions**
|
||||||
|
|
||||||
|
Append to `app-instance/frontend/lib/api.ts` near the channel API functions:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export async function listChannelConnectors(): Promise<ChannelConnectorDescriptor[]> {
|
||||||
|
return fetchJSON('/api/channel-connectors');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listChannelConnections(): Promise<ChannelConnectionView[]> {
|
||||||
|
return fetchJSON('/api/channel-connections');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startConnectorSession(params: {
|
||||||
|
kind: string;
|
||||||
|
displayName?: string;
|
||||||
|
ownerUserId?: string;
|
||||||
|
options?: Record<string, unknown>;
|
||||||
|
}): Promise<ConnectorSessionResponse> {
|
||||||
|
return fetchJSON('/api/channel-connector-sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
timeoutMs: 45000,
|
||||||
|
body: JSON.stringify({
|
||||||
|
kind: params.kind,
|
||||||
|
displayName: params.displayName,
|
||||||
|
ownerUserId: params.ownerUserId,
|
||||||
|
options: params.options || {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConnectorSession(sessionId: string): Promise<ConnectorSessionResponse> {
|
||||||
|
return fetchJSON(`/api/channel-connector-sessions/${encodeURIComponent(sessionId)}`, {
|
||||||
|
timeoutMs: 45000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeChannelConnection(connectionId: string): Promise<ChannelConnectionResponse> {
|
||||||
|
return fetchJSON(`/api/channel-connections/${encodeURIComponent(connectionId)}/revoke`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run frontend typecheck**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app-instance/frontend
|
||||||
|
npm run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: typecheck passes. If it fails because these types are appended inside another interface, move them below the closing brace for `SystemStatus`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit Task 1**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app-instance/frontend/types/index.ts app-instance/frontend/lib/api.ts
|
||||||
|
git commit -m "feat: add connector frontend api client"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Connector UI Helpers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app-instance/frontend/lib/channel-connectors.ts`
|
||||||
|
- Create: `app-instance/frontend/lib/channel-connectors.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write helper tests**
|
||||||
|
|
||||||
|
Create `app-instance/frontend/lib/channel-connectors.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
connectorDisplayName,
|
||||||
|
connectorStatusLabel,
|
||||||
|
isTerminalConnectorSessionStatus,
|
||||||
|
} from './channel-connectors';
|
||||||
|
|
||||||
|
describe('channel connector helpers', () => {
|
||||||
|
it('returns friendly connector names', () => {
|
||||||
|
expect(connectorDisplayName({ kind: 'weixin' })).toBe('Weixin');
|
||||||
|
expect(connectorDisplayName({ kind: 'feishu' })).toBe('Feishu/Lark');
|
||||||
|
expect(connectorDisplayName({ kind: 'telegram', displayName: 'Telegram' })).toBe('Telegram');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps connector session statuses', () => {
|
||||||
|
expect(connectorStatusLabel('qr_ready')).toBe('QR ready');
|
||||||
|
expect(connectorStatusLabel('waiting_for_user')).toBe('Waiting for user');
|
||||||
|
expect(connectorStatusLabel('connected')).toBe('Connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects terminal statuses', () => {
|
||||||
|
expect(isTerminalConnectorSessionStatus('connected')).toBe(true);
|
||||||
|
expect(isTerminalConnectorSessionStatus('expired')).toBe(true);
|
||||||
|
expect(isTerminalConnectorSessionStatus('qr_ready')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify failure**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app-instance/frontend
|
||||||
|
npm run test -- lib/channel-connectors.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: fail with `Cannot find module './channel-connectors'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement helpers**
|
||||||
|
|
||||||
|
Create `app-instance/frontend/lib/channel-connectors.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { ChannelConnectorDescriptor } from '@/types';
|
||||||
|
|
||||||
|
export function connectorDisplayName(connector: Pick<ChannelConnectorDescriptor, 'kind' | 'displayName' | 'display_name'>): string {
|
||||||
|
if (connector.displayName) return connector.displayName;
|
||||||
|
if (connector.display_name) return connector.display_name;
|
||||||
|
if (connector.kind === 'weixin') return 'Weixin';
|
||||||
|
if (connector.kind === 'feishu') return 'Feishu/Lark';
|
||||||
|
if (connector.kind === 'telegram') return 'Telegram';
|
||||||
|
return connector.kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectorStatusLabel(status: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
pending: 'Pending',
|
||||||
|
qr_ready: 'QR ready',
|
||||||
|
scanned: 'Scanned',
|
||||||
|
confirmed: 'Confirmed',
|
||||||
|
installing: 'Installing',
|
||||||
|
waiting_for_user: 'Waiting for user',
|
||||||
|
connected: 'Connected',
|
||||||
|
expired: 'Expired',
|
||||||
|
error: 'Error',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
};
|
||||||
|
return labels[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTerminalConnectorSessionStatus(status: string): boolean {
|
||||||
|
return ['connected', 'expired', 'error', 'cancelled'].includes(status);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run helper tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app-instance/frontend
|
||||||
|
npm run test -- lib/channel-connectors.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: helper tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit Task 2**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app-instance/frontend/lib/channel-connectors.ts app-instance/frontend/lib/channel-connectors.test.ts
|
||||||
|
git commit -m "feat: add channel connector ui helpers"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Connector Wizard Component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app-instance/frontend/components/channel-connector-wizard.tsx`
|
||||||
|
- Modify: `app-instance/frontend/app/(app)/status/page.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create wizard component**
|
||||||
|
|
||||||
|
Create `app-instance/frontend/components/channel-connector-wizard.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { CheckCircle2, Loader2, QrCode, RefreshCw, Unplug } from 'lucide-react';
|
||||||
|
import type {
|
||||||
|
ChannelConnectionView,
|
||||||
|
ChannelConnectorDescriptor,
|
||||||
|
ConnectorSessionResponse,
|
||||||
|
ConnectorSessionView,
|
||||||
|
} from '@/types';
|
||||||
|
import {
|
||||||
|
getConnectorSession,
|
||||||
|
revokeChannelConnection,
|
||||||
|
startConnectorSession,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import {
|
||||||
|
connectorDisplayName,
|
||||||
|
connectorStatusLabel,
|
||||||
|
isTerminalConnectorSessionStatus,
|
||||||
|
} from '@/lib/channel-connectors';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
connectors: ChannelConnectorDescriptor[];
|
||||||
|
connections: ChannelConnectionView[];
|
||||||
|
onChanged: () => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChannelConnectorWizard({ connectors, connections, onChanged }: Props) {
|
||||||
|
const [activeKind, setActiveKind] = useState<string | null>(null);
|
||||||
|
const [session, setSession] = useState<ConnectorSessionView | null>(null);
|
||||||
|
const [connection, setConnection] = useState<ChannelConnectionView | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [feishuDomain, setFeishuDomain] = useState('feishu');
|
||||||
|
|
||||||
|
const visibleConnectors = useMemo(
|
||||||
|
() => connectors.filter((item) => ['telegram', 'weixin', 'feishu'].includes(item.kind)),
|
||||||
|
[connectors],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session || isTerminalConnectorSessionStatus(session.status)) return;
|
||||||
|
const timer = window.setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const next = await getConnectorSession(session.sessionId);
|
||||||
|
setSession(next.session);
|
||||||
|
if (next.connection) setConnection(next.connection);
|
||||||
|
if (next.session.status === 'connected') await onChanged();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to refresh connector session');
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [session?.sessionId, session?.status, onChanged]);
|
||||||
|
|
||||||
|
const start = async (kind: string) => {
|
||||||
|
setActiveKind(kind);
|
||||||
|
setSession(null);
|
||||||
|
setConnection(null);
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const options = kind === 'feishu' ? { domain: feishuDomain } : {};
|
||||||
|
const response: ConnectorSessionResponse = await startConnectorSession({
|
||||||
|
kind,
|
||||||
|
displayName: connectorDisplayName({ kind }),
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
setSession(response.session);
|
||||||
|
setConnection(response.connection || null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to start connector session');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revoke = async (item: ChannelConnectionView) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await revokeChannelConnection(item.connection_id);
|
||||||
|
await onChanged();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to logout connector');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
{visibleConnectors.map((connector) => {
|
||||||
|
const existing = connections.find((item) => item.kind === connector.kind && item.status !== 'revoked');
|
||||||
|
return (
|
||||||
|
<Card key={connector.kind} className="rounded-md">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center justify-between text-base">
|
||||||
|
<span>{connectorDisplayName(connector)}</span>
|
||||||
|
{existing ? <Badge variant="secondary">{existing.status}</Badge> : null}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{connector.kind === 'feishu' ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="feishu-domain">Domain</Label>
|
||||||
|
<Input id="feishu-domain" value={feishuDomain} onChange={(event) => setFeishuDomain(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{existing ? (
|
||||||
|
<div className="flex items-center justify-between gap-2 text-sm">
|
||||||
|
<span className="truncate">{existing.display_name || existing.account_id || existing.channel_id}</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => revoke(existing)} disabled={busy}>
|
||||||
|
<Unplug className="mr-2 h-4 w-4" />
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" onClick={() => start(connector.kind)} disabled={busy || connector.kind === 'telegram'}>
|
||||||
|
{busy && activeKind === connector.kind ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <QrCode className="mr-2 h-4 w-4" />}
|
||||||
|
{connector.kind === 'telegram' ? 'Use token setup' : 'Connect'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||||
|
|
||||||
|
<Dialog open={Boolean(activeKind && session)} onOpenChange={(open) => !open && setActiveKind(null)}>
|
||||||
|
<DialogContent className="sm:max-w-[520px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{activeKind ? connectorDisplayName({ kind: activeKind }) : 'Connector'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{session ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge variant={session.status === 'connected' ? 'default' : 'secondary'}>
|
||||||
|
{connectorStatusLabel(session.status)}
|
||||||
|
</Badge>
|
||||||
|
{session.status === 'connected' ? <CheckCircle2 className="h-5 w-5 text-emerald-600" /> : <RefreshCw className="h-5 w-5 text-muted-foreground" />}
|
||||||
|
</div>
|
||||||
|
{session.qrImage ? (
|
||||||
|
<img alt="Connector QR code" src={session.qrImage} className="mx-auto aspect-square w-64 rounded-md border object-contain" />
|
||||||
|
) : null}
|
||||||
|
{session.instructions && session.instructions.length > 0 ? (
|
||||||
|
<div className="space-y-2 rounded-md border p-3 text-sm">
|
||||||
|
{session.instructions.map((item) => <p key={item}>{item}</p>)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{connection ? <p className="text-sm text-muted-foreground">{connection.display_name || connection.account_id}</p> : null}
|
||||||
|
{session.error ? <p className="text-sm text-destructive">{session.error}</p> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setActiveKind(null)}>Close</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Wire Status page imports**
|
||||||
|
|
||||||
|
Modify imports in `app-instance/frontend/app/(app)/status/page.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ChannelConnectorWizard } from '@/components/channel-connector-wizard';
|
||||||
|
import { getChannelConfig, getStatus, listChannelConnections, listChannelConnectors, listChannelEvents, restartRuntime, updateAgentConfig, updateChannelConfig, updateProviderConfig } from '@/lib/api';
|
||||||
|
import type { ChannelConfigDetail, ChannelConnectionView, ChannelConnectorDescriptor, ChannelEventRecord, ChannelStatus, ProviderStatus, SystemStatus } from '@/types';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add connector state to Status page**
|
||||||
|
|
||||||
|
Inside `StatusPage()` state declarations:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [channelConnectors, setChannelConnectors] = useState<ChannelConnectorDescriptor[]>([]);
|
||||||
|
const [channelConnections, setChannelConnections] = useState<ChannelConnectionView[]>([]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add loader:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const loadChannelConnectors = async () => {
|
||||||
|
const [connectors, connections] = await Promise.all([
|
||||||
|
listChannelConnectors(),
|
||||||
|
listChannelConnections(),
|
||||||
|
]);
|
||||||
|
setChannelConnectors(connectors);
|
||||||
|
setChannelConnections(connections);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Call it after status load:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useEffect(() => {
|
||||||
|
loadStatus();
|
||||||
|
loadChannelConnectors().catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
In `handleSaveChannel()` after `await loadStatus();`, add:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
await loadChannelConnectors();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Render wizard above advanced Channels list**
|
||||||
|
|
||||||
|
In `app-instance/frontend/app/(app)/status/page.tsx`, render before the existing `{/* Channels */}` section:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">{pickAppText(locale, '连接器', 'Connectors')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{pickAppText(locale, '连接微信或飞书后会立即进入运行时。', 'Connected Weixin or Feishu channels activate immediately.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ChannelConnectorWizard
|
||||||
|
connectors={channelConnectors}
|
||||||
|
connections={channelConnections}
|
||||||
|
onChanged={async () => {
|
||||||
|
await loadChannelConnectors();
|
||||||
|
await loadStatus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run frontend checks**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app-instance/frontend
|
||||||
|
npm run typecheck
|
||||||
|
npm run test -- lib/channel-connectors.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: typecheck and helper tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit Task 3**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app-instance/frontend/components/channel-connector-wizard.tsx app-instance/frontend/app/'(app)'/status/page.tsx
|
||||||
|
git commit -m "feat: add channel connector wizard"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Frontend Build And Browser Smoke
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Review: `app-instance/frontend/app/(app)/status/page.tsx`
|
||||||
|
- Review: `app-instance/frontend/components/channel-connector-wizard.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run frontend build**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app-instance/frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Next build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Start frontend dev server if visual smoke is needed**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app-instance/frontend
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: dev server listens on `http://127.0.0.1:3080`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Browser smoke check**
|
||||||
|
|
||||||
|
Open the Status page in the running app instance and verify:
|
||||||
|
|
||||||
|
- The Connectors section appears above Channels.
|
||||||
|
- Telegram shows token setup disabled in the connector wizard.
|
||||||
|
- Weixin has a Connect button.
|
||||||
|
- Feishu/Lark has a Domain input and Connect button.
|
||||||
|
- Starting a fake Weixin session opens a modal with a QR image.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Stop frontend dev server**
|
||||||
|
|
||||||
|
If Step 2 started a dev server, stop it with `Ctrl-C`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit fixes if needed**
|
||||||
|
|
||||||
|
If build or smoke required fixes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app-instance/frontend
|
||||||
|
git commit -m "fix: stabilize channel connector wizard"
|
||||||
|
```
|
||||||
|
|
||||||
|
If no files changed, do not create an empty commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Compose Integration Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Review: `docker-compose.external-connectors.yml`
|
||||||
|
- Review: `.env.example`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build backend and sidecar images**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t beaver/app-instance:latest app-instance
|
||||||
|
docker compose -f docker-compose.external-connectors.yml build external-connector
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both builds succeed.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Start sidecar with fake provider**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CONNECTOR_PROVIDER=fake \
|
||||||
|
EXTERNAL_CONNECTOR_TOKEN=dev-token \
|
||||||
|
BEAVER_BRIDGE_TOKEN=dev-token \
|
||||||
|
docker compose -f docker-compose.external-connectors.yml up -d external-connector
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `external-connector` starts and stays running.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify sidecar connector API**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -H 'Authorization: Bearer dev-token' http://127.0.0.1:8787/connectors
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: JSON contains `weixin` and `feishu`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Attach sidecar to Beaver instance network**
|
||||||
|
|
||||||
|
For a local `create-instance.sh` deployment using `beaver-instance-edge`, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network connect beaver-instance-edge external-connector 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: command succeeds or reports that the endpoint already exists.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Restart target app instance with connector env**
|
||||||
|
|
||||||
|
For `terminaltest`, ensure the app container has:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
EXTERNAL_CONNECTOR_BASE_URL=http://external-connector:8787
|
||||||
|
EXTERNAL_CONNECTOR_TOKEN=dev-token
|
||||||
|
BEAVER_BRIDGE_TOKEN=dev-token
|
||||||
|
```
|
||||||
|
|
||||||
|
Then recreate the instance with the deployment script used by this repo. Do not mount `/var/run/docker.sock` into Beaver.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Manual fake-provider onboarding**
|
||||||
|
|
||||||
|
In `terminaltest`:
|
||||||
|
|
||||||
|
- Open Status.
|
||||||
|
- Click Weixin Connect.
|
||||||
|
- Confirm QR modal appears.
|
||||||
|
- Poll until fake status remains visible.
|
||||||
|
- Confirm backend `/api/channel-connectors` returns `telegram`, `weixin`, and `feishu`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Stop fake sidecar if no longer needed**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.external-connectors.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: sidecar stops; named volume remains.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Final Frontend And Deploy Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Review: `docs/superpowers/specs/2026-06-02-external-sidecar-connectors-design.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run frontend verification**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app-instance/frontend
|
||||||
|
npm run typecheck
|
||||||
|
npm run build
|
||||||
|
npm run test -- lib/channel-connectors.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all commands pass.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run backend connector smoke tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app-instance/backend
|
||||||
|
uv run pytest \
|
||||||
|
tests/unit/test_external_sidecar_connectors.py \
|
||||||
|
tests/unit/test_external_connector_bridge_api.py \
|
||||||
|
tests/unit/test_channel_runtime_dynamic_channels.py \
|
||||||
|
-q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all listed tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run sidecar verification**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd external-connector
|
||||||
|
uv run pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all sidecar tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Scan for provider-runtime naming in new files**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "[Oo]pen[Cc]law" docs/superpowers app-instance/frontend external-connector docker-compose.external-connectors.yml || true
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no matches.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit verification fixes if needed**
|
||||||
|
|
||||||
|
If any verification step required fixes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app-instance/frontend external-connector docker-compose.external-connectors.yml docs/superpowers
|
||||||
|
git commit -m "fix: stabilize external connector onboarding"
|
||||||
|
```
|
||||||
|
|
||||||
|
If no files changed, do not create an empty commit.
|
||||||
1267
docs/superpowers/plans/2026-06-03-external-connector-sidecar.md
Normal file
1267
docs/superpowers/plans/2026-06-03-external-connector-sidecar.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,279 @@
|
|||||||
|
# Terminal WebSocket Channel Design
|
||||||
|
|
||||||
|
Date: 2026-06-01
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a text-only WebSocket channel adapter so a small terminal device can connect to Beaver and exchange messages through the channel runtime.
|
||||||
|
|
||||||
|
This is a first-stage acceptance path for proving Beaver can talk to the terminal device. The terminal must enter through `ChannelRuntime` and `MessageBus`; it must not use the existing Web UI `/ws/{session_id}` direct-chat path.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Do not implement audio, camera, screen, image, or multimodal payloads.
|
||||||
|
- Do not stream token deltas to the terminal in this phase.
|
||||||
|
- Do not add AuthZ or device registration in this phase.
|
||||||
|
- Do not implement the Hermes LiveKit LLM adapter in this phase.
|
||||||
|
- Do not route terminal messages directly to `AgentService`.
|
||||||
|
|
||||||
|
## Recommended Architecture
|
||||||
|
|
||||||
|
Add a channel-native WebSocket adapter named `TerminalWebSocketAdapter`.
|
||||||
|
|
||||||
|
The Web backend exposes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/api/channels/{channel_id}/ws
|
||||||
|
```
|
||||||
|
|
||||||
|
The route resolves the configured channel adapter from `ChannelRuntime` and delegates the accepted WebSocket to the adapter. The adapter owns terminal connection state, normalizes incoming frames into `InboundMessage`, and receives `OutboundMessage` objects through `ChannelManager.dispatch_outbound()`.
|
||||||
|
|
||||||
|
The path remains bus-first:
|
||||||
|
|
||||||
|
```text
|
||||||
|
terminal websocket
|
||||||
|
-> TerminalWebSocketAdapter
|
||||||
|
-> ChannelRuntime.accept_inbound()
|
||||||
|
-> MessageBus.inbound
|
||||||
|
-> ChannelRuntime bridge
|
||||||
|
-> AgentService.handle_inbound_message()
|
||||||
|
-> MessageBus.outbound
|
||||||
|
-> ChannelManager.dispatch_outbound()
|
||||||
|
-> TerminalWebSocketAdapter.send()
|
||||||
|
-> terminal websocket
|
||||||
|
```
|
||||||
|
|
||||||
|
## Channel Configuration
|
||||||
|
|
||||||
|
The terminal channel uses the existing `BeaverConfig.channels` map.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"terminal-dev": {
|
||||||
|
"enabled": true,
|
||||||
|
"kind": "terminal",
|
||||||
|
"mode": "websocket",
|
||||||
|
"accountId": "local",
|
||||||
|
"displayName": "Terminal Dev",
|
||||||
|
"config": {
|
||||||
|
"heartbeatSeconds": 30,
|
||||||
|
"maxMessageChars": 20000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`kind` is the platform family. `mode` is the transport mode. The adapter factory must instantiate `TerminalWebSocketAdapter` when `kind == "terminal"` and `mode == "websocket"`.
|
||||||
|
|
||||||
|
## Protocol
|
||||||
|
|
||||||
|
The protocol is JSON over WebSocket. All payloads are text-only.
|
||||||
|
|
||||||
|
The terminal starts with a connect frame:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "connect",
|
||||||
|
"peer_id": "device-001",
|
||||||
|
"device_name": "desk-terminal",
|
||||||
|
"capabilities": ["text"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Beaver replies:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "connected",
|
||||||
|
"channel_id": "terminal-dev",
|
||||||
|
"session_id": "terminal-dev:local:device-001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The terminal sends user text:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"message_id": "m-001",
|
||||||
|
"text": "你好"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Beaver acknowledges accepted inbound:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "ack",
|
||||||
|
"message_id": "m-001",
|
||||||
|
"session_id": "terminal-dev:local:device-001",
|
||||||
|
"accepted": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Beaver sends the final assistant response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"message_id": "m-001",
|
||||||
|
"run_id": "run-id",
|
||||||
|
"text": "你好,我在。",
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ping/pong frames are supported:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type": "ping"}
|
||||||
|
{"type": "pong"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Unsupported frame types return an error frame and keep the connection open:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type": "error", "error": "Unsupported websocket frame type: example"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Identity And Session Mapping
|
||||||
|
|
||||||
|
The adapter builds a `ChannelIdentity` from the connect and message frames:
|
||||||
|
|
||||||
|
- `channel_id`: path/config channel id, such as `terminal-dev`
|
||||||
|
- `kind`: `terminal`
|
||||||
|
- `account_id`: channel config account id, such as `local`
|
||||||
|
- `peer_id`: terminal `peer_id`
|
||||||
|
- `peer_type`: `terminal`
|
||||||
|
- `message_id`: message frame `message_id`
|
||||||
|
- `thread_id`: optional message or connect frame field
|
||||||
|
- `user_id`: optional message or connect frame field
|
||||||
|
|
||||||
|
The session id stays aligned with channel runtime v1:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<channel_id>:<account_id>:<peer_id>[:<thread_id>]
|
||||||
|
```
|
||||||
|
|
||||||
|
For the first terminal rollout, a terminal connection is treated as one active peer. A reconnect with the same `peer_id` reuses the same session id.
|
||||||
|
|
||||||
|
## Delivery Semantics
|
||||||
|
|
||||||
|
Inbound messages are accepted through `ChannelRuntime.accept_inbound()`.
|
||||||
|
|
||||||
|
If dedupe sees a duplicate message id:
|
||||||
|
|
||||||
|
- return an ack with `duplicate: true`
|
||||||
|
- include cached `reply` when the prior run is done
|
||||||
|
- include `pending: true` when the prior run is still processing
|
||||||
|
- do not publish a second inbound message
|
||||||
|
|
||||||
|
Outbound delivery is connection-bound. `TerminalWebSocketAdapter.send()` looks up the active connection for the outbound session or peer. If found, it sends the final assistant message. If no connection is available, it marks the outbound message as unclaimed so runtime records `outbound_unclaimed`.
|
||||||
|
|
||||||
|
No retry queue is required in this phase.
|
||||||
|
|
||||||
|
## Runtime Status And Events
|
||||||
|
|
||||||
|
`/api/status` and `/api/channels` include terminal channels with:
|
||||||
|
|
||||||
|
- `channel_id`
|
||||||
|
- `kind`
|
||||||
|
- `mode`
|
||||||
|
- `display_name`
|
||||||
|
- `enabled`
|
||||||
|
- `state`
|
||||||
|
- `account_id`
|
||||||
|
- `last_event_at`
|
||||||
|
- `websocket_url`
|
||||||
|
- `capabilities`, including `receive_text`, `send_text`, and `persistent_connection`
|
||||||
|
- `connected_peers`
|
||||||
|
|
||||||
|
Channel events should record:
|
||||||
|
|
||||||
|
- `adapter_started`
|
||||||
|
- `terminal_connected`
|
||||||
|
- `terminal_disconnected`
|
||||||
|
- `inbound_accepted`
|
||||||
|
- `inbound_duplicate`
|
||||||
|
- `direct_run_started`
|
||||||
|
- `direct_run_finished`
|
||||||
|
- `outbound_delivered`
|
||||||
|
- `outbound_unclaimed`
|
||||||
|
- `adapter_stopped`
|
||||||
|
|
||||||
|
Do not store raw terminal payloads or full message text in the event log. Existing text preview behavior is enough.
|
||||||
|
|
||||||
|
## Nginx And Deployment
|
||||||
|
|
||||||
|
The existing `/api/channels/` nginx location must support WebSocket upgrade because terminal WebSockets live under that prefix.
|
||||||
|
|
||||||
|
The location should include:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_read_timeout 3600;
|
||||||
|
proxy_send_timeout 3600;
|
||||||
|
```
|
||||||
|
|
||||||
|
The 1800 second timeout used by synchronous webhooks can stay, but WebSocket upgrade headers are required for terminal devices.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Before connect:
|
||||||
|
|
||||||
|
- only `connect` and `ping` are accepted
|
||||||
|
- `message` returns an error requiring connect first
|
||||||
|
|
||||||
|
On connect:
|
||||||
|
|
||||||
|
- missing `peer_id` closes or rejects with an error frame
|
||||||
|
- unsupported capabilities are ignored for now as long as text is available
|
||||||
|
|
||||||
|
On message:
|
||||||
|
|
||||||
|
- missing `message_id` returns an error
|
||||||
|
- missing or blank `text` returns an error
|
||||||
|
- oversized text returns an error based on `max_message_chars`
|
||||||
|
|
||||||
|
On disconnect:
|
||||||
|
|
||||||
|
- remove the active connection
|
||||||
|
- record `terminal_disconnected`
|
||||||
|
- do not cancel an already running Beaver direct run
|
||||||
|
|
||||||
|
If the run completes after disconnect, outbound is recorded as `outbound_unclaimed`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Add focused backend tests:
|
||||||
|
|
||||||
|
- WebSocket connect returns `connected` with stable session id.
|
||||||
|
- Message frame publishes through runtime and returns ack plus assistant message.
|
||||||
|
- Duplicate message id does not publish a second inbound and returns duplicate status.
|
||||||
|
- Disconnect before outbound records `outbound_unclaimed`.
|
||||||
|
- Unknown frame type returns an error and keeps the connection alive.
|
||||||
|
- Channel status exposes `websocket_url` and connected peer count.
|
||||||
|
- Config loader accepts `kind=terminal`, `mode=websocket` through existing channel config.
|
||||||
|
|
||||||
|
Run the existing backend unit suite and frontend type/test checks after implementation.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
The first-stage acceptance is complete when a small terminal can:
|
||||||
|
|
||||||
|
1. Connect to `/api/channels/terminal-dev/ws`.
|
||||||
|
2. Send a `connect` frame with a stable `peer_id`.
|
||||||
|
3. Send a text `message` frame.
|
||||||
|
4. Receive an ack.
|
||||||
|
5. Receive the final assistant text response from Beaver.
|
||||||
|
6. Reconnect with the same `peer_id` and keep the same Beaver session id.
|
||||||
|
7. Show connection and message events in Beaver channel status/events.
|
||||||
|
|
||||||
|
This validates the Beaver-to-terminal path through the new channel runtime without introducing AuthZ, multimodal payloads, or Hermes LiveKit LLM work.
|
||||||
@ -0,0 +1,404 @@
|
|||||||
|
# Channel Connectors And Pairing Design
|
||||||
|
|
||||||
|
Date: 2026-06-02
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a first-class connection layer above Beaver's channel runtime so users can connect messaging platforms through plugin, QR, OAuth, token, or app-credential flows instead of editing static channel JSON by hand.
|
||||||
|
|
||||||
|
This design reframes platform channels as two cooperating layers:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ChannelConnector
|
||||||
|
-> install / auth / QR / OAuth / credential validation / login state
|
||||||
|
-> ChannelConnectionStore
|
||||||
|
-> ChannelRuntime
|
||||||
|
-> ChannelAdapter or ExternalConnectorChannel
|
||||||
|
-> MessageBus
|
||||||
|
-> AgentService
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing `ChannelRuntime`, `MessageBus`, `ChannelManager`, and `ChannelAdapter` contracts remain the message routing core. The new connector layer owns user-visible setup and connection lifecycle. For platforms backed by predeclared sidecar services, Beaver should expose the sidecar to the runtime as an `ExternalConnectorChannel` rather than a Beaver-owned platform protocol adapter.
|
||||||
|
|
||||||
|
## Why This Is Required
|
||||||
|
|
||||||
|
The current channel design assumes a channel is already configured before the backend starts. That is enough for local development and simple webhook/token channels, but it does not match real platform onboarding:
|
||||||
|
|
||||||
|
- Feishu/Lark now has a Channel SDK pattern that packages bot channel setup, WebSocket or webhook transport, event handling, and replies around an installed app identity.
|
||||||
|
- Weixin personal-account setup uses a docker-compose predeclared sidecar connector plus QR login and persistent login state.
|
||||||
|
- Terminal devices need pairing or device registration; a raw `peer_id` connect frame is not enough for a real deployment.
|
||||||
|
- Even simple token platforms such as Telegram need a UI flow for token entry, validation, status, revoke, and restart.
|
||||||
|
|
||||||
|
So Beaver needs a connection lifecycle layer. Adapters should not be responsible for prompting the user, installing packages, storing long-lived credentials, or deciding whether an unknown device is allowed to bind.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Do not replace `ChannelRuntime`, `MessageBus`, `ChannelManager`, or `AgentService`.
|
||||||
|
- Do not make every connector a Node sidecar. Node sidecars are allowed when the official or practical SDK path requires them.
|
||||||
|
- Do not implement every channel in this phase.
|
||||||
|
- Do not build a plugin marketplace in this phase.
|
||||||
|
- Do not store platform secrets in plain channel config when a credential store is available.
|
||||||
|
- Do not let external connector code call `AgentService` directly.
|
||||||
|
|
||||||
|
## Core Terms
|
||||||
|
|
||||||
|
`ChannelConnection` is the user-visible connection instance. Examples: "Weixin personal account", "Lark workspace bot", "Telegram main bot", "Desk terminal".
|
||||||
|
|
||||||
|
`ChannelConnector` is the setup and lifecycle controller for one platform family. It starts pairing sessions, validates credentials, checks preconfigured connector endpoints when needed, handles reconnects, and emits runtime channel config.
|
||||||
|
|
||||||
|
`ChannelAdapter` is the message transport adapter used by `ChannelRuntime`. It receives normalized inbound messages and sends outbound replies. It does not own onboarding.
|
||||||
|
|
||||||
|
`ExternalConnectorChannel` is the runtime channel object used when a platform protocol lives outside the Python backend. It implements the same `start()`, `stop()`, and `send()` contract as an adapter, but its `send()` method calls an external connector HTTP API and inbound messages enter Beaver through a connector bridge endpoint.
|
||||||
|
|
||||||
|
`ExternalConnectorProcess` is an optional preconfigured service for platforms whose SDK or login behavior is better isolated outside the Python backend. For Weixin, this process is a docker-compose predeclared sidecar service. Beaver must not dynamically create containers or require Docker socket access.
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
Add a durable connection store under the backend workspace:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class ChannelConnection:
|
||||||
|
connection_id: str
|
||||||
|
owner_user_id: str | None
|
||||||
|
channel_id: str
|
||||||
|
kind: str
|
||||||
|
mode: str
|
||||||
|
display_name: str
|
||||||
|
account_id: str
|
||||||
|
status: str
|
||||||
|
auth_type: str
|
||||||
|
credentials_ref: str | None
|
||||||
|
connector_ref: str | None
|
||||||
|
pairing_session_id: str | None
|
||||||
|
runtime_config: dict[str, Any]
|
||||||
|
capabilities: list[str]
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
last_seen_at: str | None
|
||||||
|
last_error: str | None
|
||||||
|
```
|
||||||
|
|
||||||
|
`status` values:
|
||||||
|
|
||||||
|
- `draft`: setup has started but no credentials are usable.
|
||||||
|
- `pairing`: waiting for QR scan, OAuth callback, device approval, or token validation.
|
||||||
|
- `connected`: credentials are valid and the runtime channel can start.
|
||||||
|
- `running`: the runtime adapter or external connector is active.
|
||||||
|
- `degraded`: partially working, for example inbound works but media upload failed.
|
||||||
|
- `error`: connection cannot start or authenticate.
|
||||||
|
- `revoked`: user or platform revoked the connection.
|
||||||
|
|
||||||
|
Credential material should live behind `credentials_ref`, not inline in `ChannelConnection`. For the first local implementation, the reference may point to an encrypted file or a restricted JSON store. The interface should still look like a credential vault so AuthZ or a real secret backend can replace it later.
|
||||||
|
|
||||||
|
## Connector Contract
|
||||||
|
|
||||||
|
Every connector implements a setup contract:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ChannelConnector(Protocol):
|
||||||
|
kind: str
|
||||||
|
|
||||||
|
async def start_pairing(request: StartPairingRequest) -> PairingSession
|
||||||
|
async def complete_pairing(event: PairingEvent) -> ChannelConnection
|
||||||
|
async def validate(connection_id: str) -> ValidationResult
|
||||||
|
async def materialize_runtime(connection_id: str) -> ChannelRuntimeSpec
|
||||||
|
async def revoke(connection_id: str) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
`materialize_runtime()` returns the adapter-ready config:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class ChannelRuntimeSpec:
|
||||||
|
channel_id: str
|
||||||
|
kind: str
|
||||||
|
mode: str
|
||||||
|
account_id: str
|
||||||
|
display_name: str
|
||||||
|
config: dict[str, Any]
|
||||||
|
secrets_ref: str | None
|
||||||
|
external_endpoint: str | None
|
||||||
|
```
|
||||||
|
|
||||||
|
The runtime may still internally use `ChannelConfig`, but the source of truth becomes `ChannelConnectionStore`, not only static `BeaverConfig.channels`.
|
||||||
|
|
||||||
|
## Control APIs
|
||||||
|
|
||||||
|
Add backend APIs for the connection UI:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/channel-connectors
|
||||||
|
GET /api/channel-connections
|
||||||
|
POST /api/channel-connections
|
||||||
|
GET /api/channel-connections/{connection_id}
|
||||||
|
POST /api/channel-connections/{connection_id}/pairing/start
|
||||||
|
POST /api/channel-connections/{connection_id}/pairing/complete
|
||||||
|
POST /api/channel-connections/{connection_id}/validate
|
||||||
|
POST /api/channel-connections/{connection_id}/start
|
||||||
|
POST /api/channel-connections/{connection_id}/stop
|
||||||
|
POST /api/channel-connections/{connection_id}/revoke
|
||||||
|
GET /api/channel-connections/{connection_id}/events
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing `/api/channels` status endpoint can keep reporting runtime adapter status, but the UI should prefer `/api/channel-connections` for setup state.
|
||||||
|
|
||||||
|
## UI Flow
|
||||||
|
|
||||||
|
The status page becomes a channel connection page:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Add Channel
|
||||||
|
-> choose platform
|
||||||
|
-> connector-specific setup form
|
||||||
|
-> QR/OAuth/token/app credential validation
|
||||||
|
-> connection status
|
||||||
|
-> start runtime channel
|
||||||
|
-> test message or platform health check
|
||||||
|
```
|
||||||
|
|
||||||
|
The UI must distinguish:
|
||||||
|
|
||||||
|
- setup state: pairing, credential validation, revoked.
|
||||||
|
- runtime state: adapter running, disconnected, outbound failed.
|
||||||
|
- platform state: QR expired, app not installed, permission missing, token invalid.
|
||||||
|
|
||||||
|
This avoids the current problem where all failures collapse into adapter startup errors.
|
||||||
|
|
||||||
|
## External Connector Process
|
||||||
|
|
||||||
|
Some channels should run through an external process:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ExternalConnectorProcess
|
||||||
|
-> Beaver connector control API
|
||||||
|
-> local Unix/TCP/WebSocket bridge
|
||||||
|
-> ChannelRuntime ExternalConnectorChannel
|
||||||
|
```
|
||||||
|
|
||||||
|
The external process must not receive permanent backend admin credentials through QR codes or copied commands. It should receive a short-lived pairing token with a narrow scope:
|
||||||
|
|
||||||
|
```text
|
||||||
|
scope: channel:pair
|
||||||
|
kind: weixin
|
||||||
|
expires_in: 10 minutes
|
||||||
|
one_time: true
|
||||||
|
```
|
||||||
|
|
||||||
|
After pairing, Beaver stores the resulting connection credentials and gives the connector a renewable connection token scoped to that connection only. For docker-compose sidecars, that token is passed through the connector HTTP API or service configuration agreed for that sidecar; Beaver does not create or restart the sidecar container.
|
||||||
|
|
||||||
|
## Per-Channel Assessment
|
||||||
|
|
||||||
|
### Feishu / Lark
|
||||||
|
|
||||||
|
Feishu/Lark should be a first-class connector, not only a static adapter.
|
||||||
|
|
||||||
|
Recommended first implementation:
|
||||||
|
|
||||||
|
- connector kind: `feishu`
|
||||||
|
- setup fields: domain, app id, app secret, connection mode.
|
||||||
|
- default mode: WebSocket long connection.
|
||||||
|
- optional mode: webhook.
|
||||||
|
- runtime adapter: may be Python if coverage is sufficient, or an external Node connector when using official Channel SDK behavior.
|
||||||
|
|
||||||
|
Required setup checks:
|
||||||
|
|
||||||
|
- app credentials are present.
|
||||||
|
- bot/event permissions are configured.
|
||||||
|
- event subscription mode is valid.
|
||||||
|
- bot identity can be resolved.
|
||||||
|
- a test direct message or event subscription health check can run when available.
|
||||||
|
|
||||||
|
The connector should expose both "manual app credential setup" and future "install from app template" paths. The manual path is enough for the first Beaver release.
|
||||||
|
|
||||||
|
### Weixin
|
||||||
|
|
||||||
|
Weixin should use a docker-compose predeclared sidecar connector.
|
||||||
|
|
||||||
|
Recommended first implementation:
|
||||||
|
|
||||||
|
- connector kind: `weixin`
|
||||||
|
- setup mode: Beaver calls the sidecar HTTP API to start QR login and poll pairing state.
|
||||||
|
- external process: required, predeclared in docker-compose, and never dynamically created by Beaver.
|
||||||
|
- runtime channel: `ExternalConnectorChannel`.
|
||||||
|
|
||||||
|
Required setup checks:
|
||||||
|
|
||||||
|
- sidecar base URL is configured.
|
||||||
|
- sidecar health endpoint responds.
|
||||||
|
- connector version is compatible with Beaver.
|
||||||
|
- QR session is pending, scanned, confirmed, expired, or failed.
|
||||||
|
- login state is stored behind `credentials_ref`.
|
||||||
|
- connector heartbeat is visible.
|
||||||
|
|
||||||
|
The sidecar owns Weixin protocol handling, QR login, inbound receive, outbound send, and login-state persistence. Beaver owns connector setup state, bridge API validation, message normalization boundaries, runtime dedupe, and outbound HTTP calls to the sidecar `/send` API.
|
||||||
|
|
||||||
|
The agreed runtime flow is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Weixin sidecar connector
|
||||||
|
-> Beaver connector bridge endpoint
|
||||||
|
-> ChannelRuntime.accept_inbound()
|
||||||
|
-> MessageBus
|
||||||
|
-> AgentService
|
||||||
|
|
||||||
|
AgentService
|
||||||
|
-> MessageBus outbound
|
||||||
|
-> ExternalConnectorChannel.send()
|
||||||
|
-> Weixin sidecar connector /send
|
||||||
|
```
|
||||||
|
|
||||||
|
Group delivery remains best-effort. The connector must surface group capability separately from direct message capability.
|
||||||
|
|
||||||
|
### Telegram
|
||||||
|
|
||||||
|
Telegram can be implemented as an internal connector plus internal adapter.
|
||||||
|
|
||||||
|
Recommended first implementation:
|
||||||
|
|
||||||
|
- setup mode: bot token entry.
|
||||||
|
- validation: call Telegram `getMe`.
|
||||||
|
- runtime mode: polling by default, webhook optional.
|
||||||
|
- no external process required.
|
||||||
|
|
||||||
|
The UI still treats it as a connector so users can add, validate, revoke, and restart it without editing JSON.
|
||||||
|
|
||||||
|
### QQBot
|
||||||
|
|
||||||
|
QQBot should start as an internal connector with official gateway credentials.
|
||||||
|
|
||||||
|
Recommended first implementation:
|
||||||
|
|
||||||
|
- setup fields: app id, client secret, intents or permission hints.
|
||||||
|
- runtime mode: WebSocket gateway.
|
||||||
|
- validation: token exchange or gateway auth dry run when available.
|
||||||
|
|
||||||
|
If SDK/runtime behavior later becomes easier outside Python, this connector can move to an external process without changing the runtime message contract.
|
||||||
|
|
||||||
|
### Terminal
|
||||||
|
|
||||||
|
Terminal should move from raw `peer_id` to pairing.
|
||||||
|
|
||||||
|
Recommended first implementation:
|
||||||
|
|
||||||
|
- UI creates a terminal pairing session.
|
||||||
|
- Beaver displays a command or QR/setup code.
|
||||||
|
- device connects with one-time pairing token.
|
||||||
|
- Beaver binds a stable device identity to a `ChannelConnection`.
|
||||||
|
- subsequent WebSocket `connect` frames authenticate as the bound device.
|
||||||
|
|
||||||
|
The message protocol can keep `connect`, `connected`, `message`, `ack`, and assistant `message`, but production connections must include an authenticated device token.
|
||||||
|
|
||||||
|
## Message Flow After Pairing
|
||||||
|
|
||||||
|
Once a connection is paired, the message path stays unchanged:
|
||||||
|
|
||||||
|
```text
|
||||||
|
platform or device
|
||||||
|
-> connector transport
|
||||||
|
-> ChannelAdapter
|
||||||
|
-> ChannelRuntime.accept_inbound()
|
||||||
|
-> MessageBus.inbound
|
||||||
|
-> AgentService.handle_inbound_message()
|
||||||
|
-> MessageBus.outbound
|
||||||
|
-> ChannelManager.dispatch_outbound()
|
||||||
|
-> ChannelAdapter.send()
|
||||||
|
-> connector transport
|
||||||
|
-> platform or device
|
||||||
|
```
|
||||||
|
|
||||||
|
This is intentionally conservative. Pairing changes how a channel becomes trusted and running; it does not change the agent loop.
|
||||||
|
|
||||||
|
## Access Control
|
||||||
|
|
||||||
|
Connection setup requires a Beaver user or backend owner identity. The connector layer decides who may create, view, revoke, or start a connection.
|
||||||
|
|
||||||
|
Inbound platform messages still use adapter-level policy:
|
||||||
|
|
||||||
|
- `open`: accept platform scope.
|
||||||
|
- `allowlist`: accept only known users/groups.
|
||||||
|
- `disabled`: ignore that scope.
|
||||||
|
|
||||||
|
The important change is that allowlists belong to the connection settings, not ad hoc adapter config only.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Pairing errors:
|
||||||
|
|
||||||
|
- expired pairing token.
|
||||||
|
- QR not scanned before timeout.
|
||||||
|
- OAuth callback state mismatch.
|
||||||
|
- platform permission missing.
|
||||||
|
- credentials validation failed.
|
||||||
|
|
||||||
|
Runtime errors:
|
||||||
|
|
||||||
|
- adapter startup failed.
|
||||||
|
- connector process unavailable.
|
||||||
|
- heartbeat missed.
|
||||||
|
- inbound normalization failed.
|
||||||
|
- outbound delivery failed.
|
||||||
|
|
||||||
|
Each event should be recorded against `connection_id` and, when available, `channel_id` and `session_id`.
|
||||||
|
|
||||||
|
## Security Requirements
|
||||||
|
|
||||||
|
- Pairing tokens are short-lived, one-time, and scoped to one connector kind.
|
||||||
|
- QR codes never embed permanent backend credentials.
|
||||||
|
- External connector processes do not receive broad backend admin tokens.
|
||||||
|
- Revoking a connection invalidates connector tokens and stops the runtime channel.
|
||||||
|
- Stored platform credentials are referenced by `credentials_ref`.
|
||||||
|
- Event logs must not include raw secrets, tokens, QR payloads, or full platform credential responses.
|
||||||
|
|
||||||
|
## Relationship To Existing Channel Specs
|
||||||
|
|
||||||
|
The terminal WebSocket spec remains valid as a development transport spec, but production terminal setup must add pairing.
|
||||||
|
|
||||||
|
The chat platform adapter spec remains valid as a runtime adapter spec, but these statements should be revised before implementation:
|
||||||
|
|
||||||
|
- "Do not introduce a Node sidecar as the default channel architecture" should become "Use internal adapters by default, but allow external connector processes where platform SDK or login state requires them."
|
||||||
|
- "Pairing is out of scope for this phase" should become "Pairing is owned by the connector layer; adapters assume a materialized connection."
|
||||||
|
- Static `BeaverConfig.channels` should become a development override and backward-compatible import path, not the only source of runtime channels.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
Implement in this order:
|
||||||
|
|
||||||
|
1. `ChannelConnectionStore`, connector registry, and connection status APIs.
|
||||||
|
2. Telegram connector as the simplest token-based setup path.
|
||||||
|
3. Terminal pairing to remove raw unauthenticated `peer_id` usage.
|
||||||
|
4. Feishu/Lark connector with WebSocket long-connection mode and credential validation.
|
||||||
|
5. Weixin external connector bridge with QR pairing.
|
||||||
|
6. QQBot connector after the common credential and gateway patterns are stable.
|
||||||
|
|
||||||
|
This order proves the common connector lifecycle with a low-risk token channel before adding QR and external process complexity.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Add unit tests for:
|
||||||
|
|
||||||
|
- connection store create/update/revoke.
|
||||||
|
- pairing token expiry and one-time use.
|
||||||
|
- connector registry dispatch by kind.
|
||||||
|
- materializing runtime specs from connections.
|
||||||
|
- secret redaction in events.
|
||||||
|
- adapter runtime still receiving normalized `InboundMessage`.
|
||||||
|
|
||||||
|
Add integration-style tests with fake connectors for:
|
||||||
|
|
||||||
|
- successful token setup.
|
||||||
|
- QR expired and QR completed.
|
||||||
|
- external connector heartbeat loss.
|
||||||
|
- revoke stops runtime dispatch.
|
||||||
|
|
||||||
|
Live platform tests remain manual or gated behind explicit environment variables.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- A user can add a channel connection without editing backend JSON.
|
||||||
|
- Beaver can show setup state separately from runtime adapter state.
|
||||||
|
- Telegram can validate a bot token and materialize a runtime channel.
|
||||||
|
- Terminal can bind through a one-time pairing flow.
|
||||||
|
- Feishu/Lark design allows official SDK or Node connector use when needed.
|
||||||
|
- Weixin design requires an external connector and QR login state.
|
||||||
|
- Existing channel runtime message flow remains bus-first and adapter-mediated.
|
||||||
@ -0,0 +1,307 @@
|
|||||||
|
# Chat Platform Channel Adapters Design
|
||||||
|
|
||||||
|
Date: 2026-06-02
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add first-class Beaver channel adapters for four messaging platforms:
|
||||||
|
|
||||||
|
- `FeishuAdapter`
|
||||||
|
- `QQBotAdapter`
|
||||||
|
- `TelegramAdapter`
|
||||||
|
- `ExternalConnectorChannel` for Weixin personal-account sidecars
|
||||||
|
|
||||||
|
Each runtime channel must plug into the existing `ChannelRuntime`, normalize inbound platform messages into `InboundMessage` with `ChannelIdentity`, and deliver `OutboundMessage` replies back to the original platform conversation. Feishu, QQBot, and Telegram use Beaver-owned protocol adapters. Weixin personal-account support uses a docker-compose predeclared sidecar connector, so Beaver exposes it as an `ExternalConnectorChannel` rather than a Beaver-owned `WeixinAdapter`.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Use internal adapters by default, but allow external connector processes where platform SDK or login state requires them.
|
||||||
|
- Do not implement WhatsApp in this phase.
|
||||||
|
- Do not replace `ChannelRuntime`, `MessageBus`, or `ChannelManager`.
|
||||||
|
- Do not move platform access policy into `AgentService`.
|
||||||
|
- Do not implement streaming token deltas for these channels in this phase.
|
||||||
|
- Do not promise stable Weixin group support; Weixin group delivery is best-effort only.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Keep Beaver's channel runtime as the owner of lifecycle, dedupe, event logging, and agent dispatch.
|
||||||
|
|
||||||
|
```text
|
||||||
|
platform SDK/API or sidecar connector
|
||||||
|
-> {Channel}Adapter or ExternalConnectorChannel bridge endpoint
|
||||||
|
-> ChannelRuntime.accept_inbound()
|
||||||
|
-> MessageBus.inbound
|
||||||
|
-> ChannelRuntime bridge
|
||||||
|
-> AgentService.handle_inbound_message()
|
||||||
|
-> MessageBus.outbound
|
||||||
|
-> ChannelManager.dispatch_outbound()
|
||||||
|
-> {Channel}Adapter.send() or ExternalConnectorChannel.send()
|
||||||
|
-> platform SDK/API or sidecar connector API
|
||||||
|
```
|
||||||
|
|
||||||
|
Adapters own platform-specific transport and delivery details when Beaver directly integrates a platform API. For Weixin, the sidecar owns the platform protocol, QR login, receive loop, send behavior, and login-state persistence. The runtime owns Beaver session identity, dedupe, event logging, and run dispatch in both cases.
|
||||||
|
|
||||||
|
## Shared Adapter Contract
|
||||||
|
|
||||||
|
Each runtime channel implements the existing `ChannelAdapter` protocol:
|
||||||
|
|
||||||
|
```python
|
||||||
|
channel_id: str
|
||||||
|
kind: str
|
||||||
|
mode: str
|
||||||
|
|
||||||
|
async def start() -> None
|
||||||
|
async def stop() -> None
|
||||||
|
async def send(message: OutboundMessage) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Each Beaver-owned adapter receives a `ChannelInboundSink` and calls `accept_inbound()` for every normalized user message. `ExternalConnectorChannel` receives inbound Weixin messages through Beaver's connector bridge endpoint, then submits normalized messages to `ChannelRuntime.accept_inbound()`.
|
||||||
|
|
||||||
|
For all four adapters:
|
||||||
|
|
||||||
|
- `kind` is one of `feishu`, `qqbot`, `telegram`, `weixin`.
|
||||||
|
- `account_id` comes from channel config.
|
||||||
|
- inbound messages must include `ChannelIdentity`.
|
||||||
|
- outbound replies route by `message.channel_identity` when present, falling back to `message.session_id`.
|
||||||
|
- unsupported media is represented as text metadata in phase one rather than dropped silently.
|
||||||
|
|
||||||
|
## Channel Configuration
|
||||||
|
|
||||||
|
All channels use the existing `BeaverConfig.channels` map.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"telegram-main": {
|
||||||
|
"enabled": true,
|
||||||
|
"kind": "telegram",
|
||||||
|
"mode": "polling",
|
||||||
|
"accountId": "bot-main",
|
||||||
|
"displayName": "Telegram Main",
|
||||||
|
"secrets": {
|
||||||
|
"botToken": "..."
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"requireMentionInGroups": true,
|
||||||
|
"maxMessageChars": 4096
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Config keys stay channel-specific inside `config` and `secrets`. The factory chooses the adapter by `kind` and `mode`.
|
||||||
|
|
||||||
|
For sidecar-backed channels, config also includes the connector base URL and bridge settings. Beaver must call the already-running connector HTTP API and must not dynamically create containers or require Docker socket access.
|
||||||
|
|
||||||
|
## Identity Mapping
|
||||||
|
|
||||||
|
All adapters map platform identity into `ChannelIdentity`:
|
||||||
|
|
||||||
|
- `channel_id`: configured Beaver channel id, such as `telegram-main`
|
||||||
|
- `kind`: platform kind
|
||||||
|
- `account_id`: configured account id
|
||||||
|
- `peer_id`: platform chat, group, openid, or user conversation id
|
||||||
|
- `thread_id`: platform topic/thread id when applicable
|
||||||
|
- `peer_type`: `dm`, `group`, `channel`, or platform-specific value
|
||||||
|
- `user_id`: platform sender id when available
|
||||||
|
- `message_id`: platform message id or event id
|
||||||
|
|
||||||
|
The runtime continues to derive sessions as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<channel_id>:<account_id>:<peer_id>[:<thread_id>]
|
||||||
|
```
|
||||||
|
|
||||||
|
Group sessions can later become per-user or per-thread by adding adapter-level `thread_id` rules without changing `ChannelRuntime`.
|
||||||
|
|
||||||
|
## Adapter Scope
|
||||||
|
|
||||||
|
### FeishuAdapter
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
|
||||||
|
- WebSocket long connection as the preferred mode.
|
||||||
|
- Optional webhook mode if configured.
|
||||||
|
- Direct messages.
|
||||||
|
- Group messages gated by mention or config.
|
||||||
|
- Text outbound replies.
|
||||||
|
- Basic inbound media metadata and cached local file paths when available.
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
|
||||||
|
- `secrets.appId`
|
||||||
|
- `secrets.appSecret`
|
||||||
|
- `config.domain`: `feishu` or `lark`
|
||||||
|
- `config.connectionMode`: `websocket` or `webhook`
|
||||||
|
- `config.requireMentionInGroups`
|
||||||
|
- `config.allowFrom`
|
||||||
|
- `config.groupAllowFrom`
|
||||||
|
|
||||||
|
### QQBotAdapter
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
|
||||||
|
- Official QQ Bot WebSocket gateway for inbound events.
|
||||||
|
- Official REST API for outbound text replies.
|
||||||
|
- Private C2C messages.
|
||||||
|
- Group messages.
|
||||||
|
- Guild/channel messages when the platform event provides them.
|
||||||
|
- Basic rich media intake as cached local files or text metadata.
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
|
||||||
|
- `secrets.appId`
|
||||||
|
- `secrets.clientSecret`
|
||||||
|
- `config.markdownSupport`
|
||||||
|
- `config.dmPolicy`
|
||||||
|
- `config.allowFrom`
|
||||||
|
- `config.groupPolicy`
|
||||||
|
- `config.groupAllowFrom`
|
||||||
|
|
||||||
|
### TelegramAdapter
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
|
||||||
|
- Bot API long polling as the default mode.
|
||||||
|
- Optional webhook mode if configured.
|
||||||
|
- Direct messages.
|
||||||
|
- Group messages gated by mention or config.
|
||||||
|
- Text replies with platform-safe formatting and chunking.
|
||||||
|
- Photo/document/audio/video intake as cached local files or metadata.
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
|
||||||
|
- `secrets.botToken`
|
||||||
|
- `config.mode`: `polling` or `webhook`
|
||||||
|
- `config.webhookUrl`
|
||||||
|
- `config.webhookSecret`
|
||||||
|
- `config.requireMentionInGroups`
|
||||||
|
- `config.allowFrom`
|
||||||
|
- `config.groupAllowFrom`
|
||||||
|
- `config.maxMessageChars`
|
||||||
|
|
||||||
|
### ExternalConnectorChannel For Weixin
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
|
||||||
|
- Docker-compose predeclared sidecar connector.
|
||||||
|
- QR-login sessions started and observed through the sidecar HTTP API.
|
||||||
|
- Direct messages.
|
||||||
|
- Text replies sent through the sidecar `/send` API.
|
||||||
|
- Media send/receive when the sidecar provides normalized metadata.
|
||||||
|
- Group delivery as best-effort only.
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
|
||||||
|
- `secrets.connectionToken`
|
||||||
|
- `config.accountId`
|
||||||
|
- `config.baseUrl`
|
||||||
|
- `config.bridgeSecret`
|
||||||
|
- `config.dmPolicy`
|
||||||
|
- `config.allowFrom`
|
||||||
|
- `config.groupPolicy`
|
||||||
|
- `config.groupAllowFrom`
|
||||||
|
- `config.maxMessageChars`
|
||||||
|
|
||||||
|
Inbound flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Weixin sidecar connector
|
||||||
|
-> Beaver connector bridge endpoint
|
||||||
|
-> ChannelRuntime.accept_inbound()
|
||||||
|
-> MessageBus
|
||||||
|
-> AgentService
|
||||||
|
```
|
||||||
|
|
||||||
|
Outbound flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
AgentService
|
||||||
|
-> MessageBus outbound
|
||||||
|
-> ExternalConnectorChannel.send()
|
||||||
|
-> Weixin sidecar connector /send
|
||||||
|
```
|
||||||
|
|
||||||
|
The sidecar is the Weixin protocol adapter. Beaver's `ExternalConnectorChannel` only validates bridge calls, normalizes the sidecar event boundary, preserves runtime dedupe/session semantics, and forwards outbound sends to the sidecar HTTP API.
|
||||||
|
|
||||||
|
## Access Control
|
||||||
|
|
||||||
|
Adapters may block inbound messages before calling `accept_inbound()` when the platform has channel-native allowlist settings. Runtime dedupe still applies after adapter admission.
|
||||||
|
|
||||||
|
Initial policy values:
|
||||||
|
|
||||||
|
- `open`: allow matching platform scope.
|
||||||
|
- `allowlist`: require `allowFrom` or `groupAllowFrom`.
|
||||||
|
- `disabled`: ignore inbound messages for that scope.
|
||||||
|
|
||||||
|
Pairing is owned by the connector layer. Platform adapters assume a materialized `ChannelConnection` and adapter-ready runtime config. For Weixin personal-account support, the runtime channel is an `ExternalConnectorChannel`, not a Beaver-owned `WeixinAdapter`.
|
||||||
|
|
||||||
|
## Delivery Semantics
|
||||||
|
|
||||||
|
Inbound:
|
||||||
|
|
||||||
|
- validate required routing fields before submitting to runtime.
|
||||||
|
- preserve raw platform payload in metadata only when useful for debugging.
|
||||||
|
- keep metadata small enough for event logs.
|
||||||
|
- include media paths in metadata and text summaries in `content` when the agent needs to know an attachment exists.
|
||||||
|
|
||||||
|
Outbound:
|
||||||
|
|
||||||
|
- send only final assistant replies in phase one.
|
||||||
|
- chunk messages to platform limits.
|
||||||
|
- mark `delivery_status = "unclaimed"` when a target cannot be resolved.
|
||||||
|
- raise or return delivery failures so `ChannelManager` records `outbound_delivery_failed`.
|
||||||
|
|
||||||
|
## Runtime Status
|
||||||
|
|
||||||
|
`ChannelRuntime.statuses()` should report platform channels with:
|
||||||
|
|
||||||
|
- `channel_id`
|
||||||
|
- `kind`
|
||||||
|
- `mode`
|
||||||
|
- `display_name`
|
||||||
|
- `enabled`
|
||||||
|
- `state`
|
||||||
|
- `account_id`
|
||||||
|
- `last_error`
|
||||||
|
- `last_event_at`
|
||||||
|
- `capabilities`
|
||||||
|
|
||||||
|
Capabilities are conservative:
|
||||||
|
|
||||||
|
- Feishu: `receive_text`, `send_text`, `receive_media`, `groups`
|
||||||
|
- QQBot: `receive_text`, `send_text`, `receive_media`, `groups`
|
||||||
|
- Telegram: `receive_text`, `send_text`, `receive_media`, `groups`
|
||||||
|
- Weixin: `receive_text`, `send_text`, `receive_media`, `direct_messages`
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Adapter startup failure sets channel state to `error` and does not stop other channels.
|
||||||
|
- Runtime shutdown calls every adapter `stop()`.
|
||||||
|
- Platform transient errors should retry inside the adapter only when retrying cannot duplicate user-visible sends.
|
||||||
|
- Fatal credential/config errors should surface in channel status.
|
||||||
|
- Inbound duplicates are handled by existing `ChannelDedupeStore`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Add tests in small layers:
|
||||||
|
|
||||||
|
- factory tests for `kind` and `mode` adapter selection.
|
||||||
|
- identity normalization tests for each platform.
|
||||||
|
- inbound adapter tests using fake platform payloads.
|
||||||
|
- outbound adapter tests with fake platform clients.
|
||||||
|
- runtime status tests for configured enabled/disabled/error channels.
|
||||||
|
|
||||||
|
Network live tests are out of scope for unit tests. Adapter constructors should accept injectable clients or lightweight transport functions so tests do not call real platform APIs.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
Implement one adapter at a time:
|
||||||
|
|
||||||
|
1. Telegram
|
||||||
|
2. Feishu
|
||||||
|
3. QQBot
|
||||||
|
4. Weixin
|
||||||
|
|
||||||
|
Telegram is first because its bot-token flow and text path are the simplest proof of the shared adapter pattern. Weixin is last because QR/login state, context tokens, and media handling are more specialized.
|
||||||
@ -0,0 +1,592 @@
|
|||||||
|
# External Sidecar Connectors Design
|
||||||
|
|
||||||
|
Date: 2026-06-02
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add real Weixin personal-account QR login and Feishu/Lark plugin onboarding to Beaver through a docker-compose predeclared sidecar service, without binding Beaver's connector layer to one vendor runtime. Beaver must not dynamically create containers or require Docker socket access.
|
||||||
|
|
||||||
|
This design implements the next connector layer after `docs/superpowers/plans/2026-06-02-channel-connectors-foundation.md`.
|
||||||
|
|
||||||
|
## Design Corrections
|
||||||
|
|
||||||
|
This design intentionally fixes four architecture constraints before implementation:
|
||||||
|
|
||||||
|
- The sidecar is generic. Beaver depends on a connector HTTP contract, not on one vendor runtime.
|
||||||
|
- Pairing is modeled as a broader `ConnectorSession`, because Feishu/Lark install/link flows are not only QR pairing.
|
||||||
|
- Bridge events include `eventId`, `timestamp`, and `deliveryAttempt`, and Beaver dedupes bridge events before they can trigger duplicate agent replies.
|
||||||
|
- Bridge authentication is service-level in the first version. The shared connector token lives in environment variables, not per-connection credentials.
|
||||||
|
- Outbound sidecar sends include a required `requestId` so sidecar retries are idempotent.
|
||||||
|
- Connected sessions dynamically register runtime channels. A successful Weixin or Feishu/Lark connection must not require a Beaver restart.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Included:
|
||||||
|
|
||||||
|
- A repo-local `external-connector` sidecar service.
|
||||||
|
- A docker-compose service declaration for the sidecar.
|
||||||
|
- A sidecar `ConnectorProvider` abstraction.
|
||||||
|
- A production `VendorCliProvider` that runs the real vendor CLI/plugin commands required for Weixin personal-account QR login and Feishu/Lark plugin onboarding.
|
||||||
|
- Sidecar HTTP API for health, connector metadata, connector sessions, logout/remove, outbound send, and inbound event forwarding.
|
||||||
|
- Beaver `WeixinConnector` and `FeishuConnector` objects registered in `ChannelConnectorRegistry`.
|
||||||
|
- Beaver connector bridge endpoints that accept normalized sidecar inbound events and submit them to `ChannelRuntime.accept_inbound()`.
|
||||||
|
- `MessageDedupeStore` for connector bridge event idempotency.
|
||||||
|
- `ExternalConnectorChannel` runtime object for sidecar-backed outbound sends.
|
||||||
|
- `ChannelRuntime.add_channel()` and `ChannelRuntime.remove_channel()` for dynamic runtime activation.
|
||||||
|
- Web UI connection wizard for Weixin QR login and Feishu/Lark plugin onboarding.
|
||||||
|
- Unit tests using fake sidecar providers and bridge events.
|
||||||
|
|
||||||
|
Excluded:
|
||||||
|
|
||||||
|
- Dynamic Docker container creation from Beaver.
|
||||||
|
- Docker socket mounts in Beaver.
|
||||||
|
- Reimplementing Weixin iLink or Feishu/Lark plugin protocols inside Beaver.
|
||||||
|
- Building a generic plugin marketplace.
|
||||||
|
- Multi-user enterprise permission governance beyond local connector ownership and bridge token validation.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Use one predeclared sidecar for external connector providers:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Beaver backend
|
||||||
|
-> Connector HTTP client
|
||||||
|
-> external-connector sidecar
|
||||||
|
-> ConnectorProvider
|
||||||
|
-> provider-specific runtime or CLI
|
||||||
|
-> Weixin / Feishu / future platform
|
||||||
|
```
|
||||||
|
|
||||||
|
Beaver owns:
|
||||||
|
|
||||||
|
- connection state in `ChannelConnectionStore`
|
||||||
|
- credential references in `CredentialStore`
|
||||||
|
- connector session state exposed to the web UI
|
||||||
|
- service-level connector authentication
|
||||||
|
- bridge event dedupe
|
||||||
|
- normalized runtime message admission
|
||||||
|
- runtime channel lifecycle
|
||||||
|
- runtime dedupe/session identity
|
||||||
|
- outbound dispatch into sidecar `/send`
|
||||||
|
|
||||||
|
The sidecar owns:
|
||||||
|
|
||||||
|
- provider runtime state
|
||||||
|
- provider install/update commands
|
||||||
|
- Weixin QR login and login-state persistence
|
||||||
|
- Feishu/Lark plugin install, bot creation/linking, and provider-side verification
|
||||||
|
- platform receive loops
|
||||||
|
- sidecar-to-Beaver inbound event delivery
|
||||||
|
|
||||||
|
## ConnectorProvider
|
||||||
|
|
||||||
|
The sidecar must isolate provider-specific behavior behind a provider contract. Beaver must not know which provider implementation is active.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface ConnectorProvider {
|
||||||
|
providerId: string;
|
||||||
|
connectors(): ConnectorDescriptor[];
|
||||||
|
health(): Promise<ProviderHealth>;
|
||||||
|
startSession(input: StartConnectorSessionInput): Promise<ConnectorSessionView>;
|
||||||
|
getSession(sessionId: string): Promise<ConnectorSessionView>;
|
||||||
|
cancelSession(sessionId: string): Promise<void>;
|
||||||
|
logout(connectionId: string): Promise<void>;
|
||||||
|
send(input: SendMessageInput): Promise<SendMessageResult>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Initial provider:
|
||||||
|
|
||||||
|
- `VendorCliProvider`: runs the real CLI/plugin commands required by the current Weixin and Feishu/Lark vendor flows.
|
||||||
|
|
||||||
|
`VendorCliProvider` command execution is intentionally constrained:
|
||||||
|
|
||||||
|
- Command templates are read only from sidecar startup environment variables.
|
||||||
|
- Frontend requests and sidecar HTTP request bodies cannot provide command strings.
|
||||||
|
- Command working directory is fixed to `CONNECTOR_HOME`.
|
||||||
|
- Per-connection state paths may be passed to commands as formatted arguments.
|
||||||
|
- Every command has a hard timeout.
|
||||||
|
- stdout and stderr are redacted before storage or API responses.
|
||||||
|
|
||||||
|
Future providers can be added without changing Beaver runtime code:
|
||||||
|
|
||||||
|
- `WechatyProvider`
|
||||||
|
- `NapcatProvider`
|
||||||
|
- `OneBotProvider`
|
||||||
|
- `EnterpriseWeixinProvider`
|
||||||
|
|
||||||
|
Provider choice is sidecar configuration, not Beaver architecture. `ExternalConnectorChannel` only calls the sidecar HTTP contract.
|
||||||
|
|
||||||
|
## Runtime Flow
|
||||||
|
|
||||||
|
Inbound:
|
||||||
|
|
||||||
|
```text
|
||||||
|
platform event
|
||||||
|
-> ConnectorProvider inside sidecar
|
||||||
|
-> sidecar normalized bridge event
|
||||||
|
-> POST Beaver /api/channel-connector-bridge/events
|
||||||
|
-> MessageDedupeStore
|
||||||
|
-> ChannelRuntime.accept_inbound()
|
||||||
|
-> MessageBus
|
||||||
|
-> AgentService
|
||||||
|
```
|
||||||
|
|
||||||
|
Outbound:
|
||||||
|
|
||||||
|
```text
|
||||||
|
AgentService
|
||||||
|
-> MessageBus outbound
|
||||||
|
-> ChannelManager.dispatch_outbound()
|
||||||
|
-> ExternalConnectorChannel.send()
|
||||||
|
-> POST sidecar /send
|
||||||
|
-> ConnectorProvider.send()
|
||||||
|
-> platform
|
||||||
|
```
|
||||||
|
|
||||||
|
`ExternalConnectorChannel` implements the existing runtime channel protocol:
|
||||||
|
|
||||||
|
```python
|
||||||
|
channel_id: str
|
||||||
|
kind: str
|
||||||
|
mode: str
|
||||||
|
|
||||||
|
async def start() -> None
|
||||||
|
async def stop() -> None
|
||||||
|
async def send(message: OutboundMessage) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
It is not a platform protocol adapter. It is a generic HTTP bridge to a sidecar.
|
||||||
|
|
||||||
|
Runtime materialization for sidecar-backed connections always emits:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ChannelConfig(
|
||||||
|
enabled=True,
|
||||||
|
kind="external_connector",
|
||||||
|
mode="http",
|
||||||
|
account_id=spec.account_id,
|
||||||
|
display_name=spec.display_name,
|
||||||
|
config={
|
||||||
|
"platformKind": "weixin",
|
||||||
|
"connectionId": "conn_...",
|
||||||
|
"sidecarBaseUrl": "http://external-connector:8787",
|
||||||
|
},
|
||||||
|
secrets={},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The original `ChannelConnection.kind` remains `weixin` or `feishu`; only the runtime transport kind is generic.
|
||||||
|
|
||||||
|
`ExternalConnectorChannel` authenticates outbound calls with the service-level connector token configured in Beaver's process environment, not with a per-channel secret. The same first-version deployment may use one shared token value for both directions, exposed as `EXTERNAL_CONNECTOR_TOKEN` to Beaver and `BEAVER_BRIDGE_TOKEN` to the sidecar.
|
||||||
|
|
||||||
|
## Dynamic Runtime Activation
|
||||||
|
|
||||||
|
A connected connector session must activate without restarting Beaver.
|
||||||
|
|
||||||
|
Add runtime methods:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def add_channel(self, channel_id: str, config: ChannelConfig) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def remove_channel(self, channel_id: str) -> None:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
`add_channel()` must run under a runtime lifecycle lock and has deterministic duplicate semantics:
|
||||||
|
|
||||||
|
- Same `channel_id` and same effective `ChannelConfig`: no-op.
|
||||||
|
- Same `channel_id` and changed effective `ChannelConfig`: build and start the replacement adapter before swapping it into the manager; after the swap succeeds, stop the old adapter.
|
||||||
|
- Replacement start failure: keep the old adapter registered and running, and return the failure to the caller.
|
||||||
|
- First registration after runtime start: build the adapter, register it, then start only that adapter.
|
||||||
|
|
||||||
|
`remove_channel()` must also run under the lifecycle lock. Missing channel ids are no-op; existing channels are stopped and unregistered.
|
||||||
|
|
||||||
|
When a connector session reaches `connected`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Connector session connected
|
||||||
|
-> connector updates ChannelConnection
|
||||||
|
-> registry materializes ChannelConfig
|
||||||
|
-> ChannelRuntime.add_channel(channel_id, config)
|
||||||
|
-> ChannelManager.register(adapter)
|
||||||
|
-> adapter.start()
|
||||||
|
-> channel status becomes running
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a hard requirement for Weixin and Feishu/Lark onboarding. Manual backend restart is not an acceptable success path for this feature.
|
||||||
|
|
||||||
|
`remove_channel()` is used when a user logs out or revokes a sidecar connection:
|
||||||
|
|
||||||
|
```text
|
||||||
|
logout / revoke
|
||||||
|
-> sidecar logout
|
||||||
|
-> ChannelRuntime.remove_channel(channel_id)
|
||||||
|
-> connection status revoked or disconnected
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sidecar Deployment
|
||||||
|
|
||||||
|
Add a sidecar service that can be enabled in deployment:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
external-connector:
|
||||||
|
build: ./external-connector
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
BEAVER_BRIDGE_BASE_URL: http://app-instance:8080
|
||||||
|
BEAVER_BRIDGE_TOKEN: ${BEAVER_BRIDGE_TOKEN}
|
||||||
|
CONNECTOR_API_TOKEN: ${EXTERNAL_CONNECTOR_TOKEN}
|
||||||
|
CONNECTOR_HOME: /var/lib/external-connector
|
||||||
|
CONNECTOR_PROVIDER: vendor_cli
|
||||||
|
CONNECTOR_COMMAND_TIMEOUT_SECONDS: 120
|
||||||
|
volumes:
|
||||||
|
- external-connector-state:/var/lib/external-connector
|
||||||
|
```
|
||||||
|
|
||||||
|
For the current `create-instance.sh`-style deployment, the implementation adds:
|
||||||
|
|
||||||
|
- `docker-compose.external-connectors.yml` for local/development sidecar tests.
|
||||||
|
- documentation for attaching `external-connector` to the same Docker network as the target app instance.
|
||||||
|
- instance environment `EXTERNAL_CONNECTOR_BASE_URL=http://external-connector:8787`.
|
||||||
|
- instance environment `EXTERNAL_CONNECTOR_TOKEN=<service-level shared secret>`.
|
||||||
|
|
||||||
|
The implementation must not depend on Beaver mounting `/var/run/docker.sock`.
|
||||||
|
|
||||||
|
## Sidecar HTTP API
|
||||||
|
|
||||||
|
All sidecar requests and responses are JSON. The sidecar listens on port `8787`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /health
|
||||||
|
GET /connectors
|
||||||
|
POST /connector-sessions
|
||||||
|
GET /connector-sessions/{session_id}
|
||||||
|
POST /connector-sessions/{session_id}/cancel
|
||||||
|
POST /connections/{connection_id}/logout
|
||||||
|
POST /send
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /connectors` returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"kind": "weixin",
|
||||||
|
"displayName": "Weixin",
|
||||||
|
"authType": "qr",
|
||||||
|
"providerId": "vendor_cli",
|
||||||
|
"capabilities": ["receive_text", "send_text", "receive_media", "direct_messages"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "feishu",
|
||||||
|
"displayName": "Feishu/Lark",
|
||||||
|
"authType": "plugin_install",
|
||||||
|
"providerId": "vendor_cli",
|
||||||
|
"capabilities": ["receive_text", "send_text", "receive_media", "groups"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`POST /connector-sessions` request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "weixin",
|
||||||
|
"connectionId": "conn_...",
|
||||||
|
"channelId": "weixin-main",
|
||||||
|
"displayName": "Weixin Main",
|
||||||
|
"callbackBaseUrl": "http://app-instance:8080",
|
||||||
|
"options": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The sidecar authenticates the connector-session request with `Authorization: Bearer <EXTERNAL_CONNECTOR_TOKEN>`. It already has `BEAVER_BRIDGE_TOKEN` from its environment, so Beaver does not send bridge tokens in connector-session bodies.
|
||||||
|
|
||||||
|
For Feishu/Lark, `kind` is `feishu` and `options` may include `domain`, `mode`, and optional app credentials when linking an existing bot. If using the official plugin installer to create a bot, the sidecar starts that installer flow and reports QR, instruction, or action status back to Beaver.
|
||||||
|
|
||||||
|
`GET /connector-sessions/{session_id}` response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessionId": "cs_...",
|
||||||
|
"kind": "weixin",
|
||||||
|
"status": "qr_ready",
|
||||||
|
"qrCode": "weixin://...",
|
||||||
|
"qrImage": "data:image/png;base64,...",
|
||||||
|
"instructions": [],
|
||||||
|
"accountId": null,
|
||||||
|
"displayName": null,
|
||||||
|
"error": null,
|
||||||
|
"metadata": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed connector session statuses:
|
||||||
|
|
||||||
|
- `pending`
|
||||||
|
- `qr_ready`
|
||||||
|
- `scanned`
|
||||||
|
- `confirmed`
|
||||||
|
- `installing`
|
||||||
|
- `waiting_for_user`
|
||||||
|
- `connected`
|
||||||
|
- `expired`
|
||||||
|
- `error`
|
||||||
|
- `cancelled`
|
||||||
|
|
||||||
|
`POST /send` request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"requestId": "out_...",
|
||||||
|
"connectionId": "conn_...",
|
||||||
|
"channelId": "weixin-main",
|
||||||
|
"kind": "weixin",
|
||||||
|
"target": {
|
||||||
|
"peerId": "wx_user_or_chat_id",
|
||||||
|
"peerType": "dm",
|
||||||
|
"threadId": null
|
||||||
|
},
|
||||||
|
"content": "reply text",
|
||||||
|
"metadata": {
|
||||||
|
"contextToken": "optional"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`requestId` is required. Beaver must generate a stable request id for each outbound delivery attempt and must reuse the same `requestId` if the same outbound delivery is retried. The first-version rule is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
out_{channel}:{session_id}:{message_id or sha256(content + inbound_message_id + peer_id + finish_reason)}
|
||||||
|
```
|
||||||
|
|
||||||
|
The sidecar dedupes `connectionId + requestId`:
|
||||||
|
|
||||||
|
- `completed`: return the original send result and do not send a second platform message.
|
||||||
|
- `processing` updated less than 60 seconds ago: return `409 Conflict` with `{"retryAfterSeconds": 5}` so Beaver retries later.
|
||||||
|
- `processing` updated 60 seconds or more ago: treat as stale and retry the provider send.
|
||||||
|
|
||||||
|
## Beaver Bridge API
|
||||||
|
|
||||||
|
Add a backend bridge endpoint for sidecar inbound messages:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/channel-connector-bridge/events
|
||||||
|
```
|
||||||
|
|
||||||
|
The sidecar must authenticate every bridge request using the service-level bearer token from `BEAVER_BRIDGE_TOKEN`. Beaver rejects missing or invalid bridge tokens. Bridge tokens are deployment secrets, not connection records.
|
||||||
|
|
||||||
|
Bridge event body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"eventId": "provider-event-id",
|
||||||
|
"timestamp": "2026-06-02T09:30:00Z",
|
||||||
|
"deliveryAttempt": 1,
|
||||||
|
"connectionId": "conn_...",
|
||||||
|
"channelId": "weixin-main",
|
||||||
|
"kind": "weixin",
|
||||||
|
"accountId": "weixin:...",
|
||||||
|
"peerId": "wx_user_or_chat_id",
|
||||||
|
"peerType": "dm",
|
||||||
|
"userId": "wx_sender",
|
||||||
|
"threadId": null,
|
||||||
|
"messageId": "platform-message-id",
|
||||||
|
"messageType": "text",
|
||||||
|
"content": "hello",
|
||||||
|
"metadata": {
|
||||||
|
"contextToken": "optional"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The bridge endpoint must:
|
||||||
|
|
||||||
|
1. validate bearer token
|
||||||
|
2. load `ChannelConnection`
|
||||||
|
3. reject unknown or revoked connections
|
||||||
|
4. dedupe by `connectionId + eventId` through `MessageDedupeStore`
|
||||||
|
5. construct `ChannelIdentity`
|
||||||
|
6. construct `InboundMessage`
|
||||||
|
7. call `ChannelRuntime.accept_inbound()`
|
||||||
|
8. mark bridge event completed or failed
|
||||||
|
|
||||||
|
## MessageDedupeStore
|
||||||
|
|
||||||
|
Add a JSON-backed `MessageDedupeStore` under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<workspace>/state/channel_connections/message_dedupe.json
|
||||||
|
```
|
||||||
|
|
||||||
|
It stores:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class ConnectorMessageDedupeRecord:
|
||||||
|
dedupe_key: str
|
||||||
|
connection_id: str
|
||||||
|
event_id: str
|
||||||
|
status: str
|
||||||
|
first_seen_at: str
|
||||||
|
updated_at: str
|
||||||
|
delivery_attempts: int
|
||||||
|
message_id: str | None
|
||||||
|
last_error: str | None
|
||||||
|
```
|
||||||
|
|
||||||
|
`status` values:
|
||||||
|
|
||||||
|
- `processing`
|
||||||
|
- `completed`
|
||||||
|
- `failed`
|
||||||
|
|
||||||
|
Duplicate handling:
|
||||||
|
|
||||||
|
- `completed`: return idempotent success and do not call `ChannelRuntime.accept_inbound()` again.
|
||||||
|
- `processing` updated less than 60 seconds ago: return `409 Conflict` with `{"retryAfterSeconds": 5}` so the sidecar retries later.
|
||||||
|
- `processing` updated 60 seconds or more ago: treat the record as stale, increment `delivery_attempts`, update `updated_at`, and reprocess the event.
|
||||||
|
- `failed`: allow reprocessing on the next delivery attempt, increment `delivery_attempts`, and clear `last_error` before calling runtime.
|
||||||
|
|
||||||
|
This store is separate from runtime session dedupe. Runtime dedupe still protects platform message identity, while bridge dedupe protects connector retries.
|
||||||
|
|
||||||
|
## Beaver Connectors
|
||||||
|
|
||||||
|
### WeixinConnector
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- discover sidecar health
|
||||||
|
- start Weixin connector session through sidecar `/connector-sessions`
|
||||||
|
- poll sidecar connector session status
|
||||||
|
- create or update `ChannelConnection`
|
||||||
|
- store sidecar connection state reference in `CredentialStore` when the provider returns one
|
||||||
|
- validate by checking sidecar connection status
|
||||||
|
- materialize runtime config for `ExternalConnectorChannel`
|
||||||
|
- activate runtime via `ChannelRuntime.add_channel()` when connected
|
||||||
|
- revoke/logout by calling sidecar `/connections/{connection_id}/logout`
|
||||||
|
- deactivate runtime via `ChannelRuntime.remove_channel()` on logout/revoke
|
||||||
|
|
||||||
|
### FeishuConnector
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- discover sidecar health
|
||||||
|
- start Feishu/Lark plugin install/link connector session
|
||||||
|
- optionally pass appId/appSecret/domain/mode for existing bot linking
|
||||||
|
- poll installer/session status
|
||||||
|
- create or update `ChannelConnection`
|
||||||
|
- validate by sidecar session or connection status
|
||||||
|
- materialize runtime config for `ExternalConnectorChannel`
|
||||||
|
- activate runtime via `ChannelRuntime.add_channel()` when connected
|
||||||
|
- revoke/remove plugin connection by calling sidecar logout/remove API
|
||||||
|
- deactivate runtime via `ChannelRuntime.remove_channel()` on logout/revoke
|
||||||
|
|
||||||
|
Feishu is sidecar-backed in this design because the user's supplied Feishu article describes the official plugin flow, not only a static bot-credential runtime adapter.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
Replace the old static Weixin and Feishu fields with connector-driven UI:
|
||||||
|
|
||||||
|
- fetch `GET /api/channel-connectors`
|
||||||
|
- show Telegram, Weixin, and Feishu/Lark as connector options
|
||||||
|
- for Weixin:
|
||||||
|
- start connector session
|
||||||
|
- show QR image
|
||||||
|
- poll status until connected/expired/error
|
||||||
|
- show connected account and logout
|
||||||
|
- for Feishu/Lark:
|
||||||
|
- choose create bot or link existing bot
|
||||||
|
- collect domain and optional app credentials
|
||||||
|
- start sidecar connector session
|
||||||
|
- show QR/instructions/status returned by sidecar
|
||||||
|
- show connected account and logout
|
||||||
|
|
||||||
|
The old `/api/channels` static config editor may remain for advanced runtime config, but connector onboarding should not rely on manual JSON editing or direct token entry for Weixin/Feishu.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Sidecar unavailable: show connector as `unavailable`; do not create a running connection.
|
||||||
|
- Provider install command fails: status `error`, with redacted stderr summary.
|
||||||
|
- QR expired: status `expired`, user can start a new connector session.
|
||||||
|
- Bridge token invalid: reject with `401`, record event without platform secret values.
|
||||||
|
- Unknown connection id in bridge event: reject with `404`.
|
||||||
|
- Duplicate completed bridge event: return idempotent success and do not call runtime again.
|
||||||
|
- Duplicate in-flight bridge event: return `409 Conflict` until the 60-second processing TTL expires, then allow one reprocess.
|
||||||
|
- Outbound send failure: mark outbound delivery failed and record connector error.
|
||||||
|
- Duplicate completed outbound send `requestId`: sidecar returns the original send result and does not send a second platform message.
|
||||||
|
- Duplicate in-flight outbound send `requestId`: sidecar returns `409 Conflict` until the 60-second processing TTL expires, then allows one retry.
|
||||||
|
- Sidecar restart: persisted provider state should survive through sidecar volume.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Beaver never logs raw tokens, app secrets, bridge tokens, or sidecar connection tokens.
|
||||||
|
- Bridge authentication uses a service-level token from environment variables. It is not stored per connection and is never returned by APIs.
|
||||||
|
- Sidecar can only call bridge endpoints with the service-level bridge token.
|
||||||
|
- Beaver can only call sidecar control and send endpoints with the service-level connector token.
|
||||||
|
- Sidecar state volume contains login state and must be treated as sensitive.
|
||||||
|
- Vendor command strings are deployment configuration, not user input.
|
||||||
|
- Feishu user-identity mode has stronger privacy risk than bot-identity mode; UI must label it clearly if exposed.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Backend unit tests:
|
||||||
|
|
||||||
|
- sidecar client fake for Weixin connector session start/status/logout/send
|
||||||
|
- sidecar client fake for Feishu connector session start/status/logout/send
|
||||||
|
- `ExternalConnectorChannel.send()` target mapping
|
||||||
|
- `ExternalConnectorChannel.send()` includes stable `requestId` and connector bearer auth
|
||||||
|
- `ChannelRuntime.add_channel()` dynamically starts and registers a channel
|
||||||
|
- `ChannelRuntime.add_channel()` no-ops for identical config, replaces changed config, and keeps the old channel if replacement start fails
|
||||||
|
- `ChannelRuntime.remove_channel()` stops and unregisters a channel
|
||||||
|
- bridge endpoint accepts valid events
|
||||||
|
- bridge endpoint rejects invalid token and unknown connection id
|
||||||
|
- bridge endpoint dedupes repeated `eventId` and calls runtime once
|
||||||
|
- bridge endpoint returns `409 Conflict` for non-stale `processing` duplicates and reprocesses stale records
|
||||||
|
- registry lists `telegram`, `weixin`, and `feishu`
|
||||||
|
- materialized sidecar connections produce `ChannelConfig(kind="external_connector", mode="http")` compatible with runtime factory
|
||||||
|
|
||||||
|
Sidecar tests:
|
||||||
|
|
||||||
|
- HTTP API shape for health/connectors/connector-sessions/send
|
||||||
|
- fake provider status transitions
|
||||||
|
- provider command runner error redaction
|
||||||
|
- send idempotency for duplicate `connectionId + requestId`
|
||||||
|
- send `processing` TTL returns `409 Conflict` before stale retry
|
||||||
|
|
||||||
|
Frontend tests:
|
||||||
|
|
||||||
|
- Weixin connector option opens QR modal
|
||||||
|
- polling reaches connected state
|
||||||
|
- expired/error states are visible
|
||||||
|
- Feishu flow starts install/link and shows returned instructions/status
|
||||||
|
|
||||||
|
Manual verification:
|
||||||
|
|
||||||
|
- Build app and sidecar Docker images.
|
||||||
|
- Start docker-compose sidecar setup.
|
||||||
|
- In `terminaltest`, open Weixin connector, scan QR, observe connected status without restarting Beaver.
|
||||||
|
- Send a Weixin text message and verify Beaver receives it once.
|
||||||
|
- Force sidecar retry of the same event and verify Beaver does not produce a duplicate agent reply.
|
||||||
|
- Send a Beaver reply and verify sidecar `/send` path.
|
||||||
|
- Start Feishu connector flow using the official Feishu/Lark plugin install path and verify the vendor-provided start command.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
Implement in this order:
|
||||||
|
|
||||||
|
1. Sidecar HTTP contract with fake provider.
|
||||||
|
2. `MessageDedupeStore`.
|
||||||
|
3. Beaver `ExternalConnectorChannel` and bridge endpoint.
|
||||||
|
4. `ChannelRuntime.add_channel()` and `ChannelRuntime.remove_channel()`.
|
||||||
|
5. Weixin connector against fake sidecar client.
|
||||||
|
6. Feishu connector against fake sidecar client.
|
||||||
|
7. Frontend connector UI.
|
||||||
|
8. Production `VendorCliProvider` that shells out to real vendor CLI/plugin commands.
|
||||||
|
9. Docker build/compose integration.
|
||||||
|
10. Manual live verification.
|
||||||
|
|
||||||
|
The fake provider is test-only. The production provider must use the real vendor CLI/plugin commands for Weixin and Feishu/Lark; the fake provider only makes Beaver and frontend tests deterministic while the live provider handles non-deterministic external login and install flows.
|
||||||
23
external-connector/Dockerfile
Normal file
23
external-connector/Dockerfile
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends nodejs npm ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
COPY package.json ./
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
"fastapi>=0.115.0,<1.0" \
|
||||||
|
"httpx>=0.27.0,<1.0" \
|
||||||
|
"pydantic>=2.7.0,<3.0" \
|
||||||
|
"qrcode>=8.0,<9.0" \
|
||||||
|
"uvicorn[standard]>=0.30.0,<1.0" \
|
||||||
|
&& npm install --omit=dev --package-lock=false
|
||||||
|
|
||||||
|
COPY external_connector ./external_connector
|
||||||
|
|
||||||
|
ENV CONNECTOR_HOME=/var/lib/external-connector
|
||||||
|
EXPOSE 8787
|
||||||
|
|
||||||
|
CMD ["python", "-m", "external_connector.main"]
|
||||||
1
external-connector/external_connector/__init__.py
Normal file
1
external-connector/external_connector/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Generic external connector sidecar."""
|
||||||
74
external-connector/external_connector/app.py
Normal file
74
external-connector/external_connector/app.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Header, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from external_connector.models import ConnectorSessionRequest, SendRequest
|
||||||
|
from external_connector.providers.base import ConnectorProvider
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(*, provider: ConnectorProvider, api_token: str) -> FastAPI:
|
||||||
|
app = FastAPI(title="External Connector")
|
||||||
|
|
||||||
|
def require_auth(authorization: str | None) -> None:
|
||||||
|
if api_token and authorization != f"Bearer {api_token}":
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid connector token")
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict[str, Any]:
|
||||||
|
return provider.health()
|
||||||
|
|
||||||
|
@app.get("/connectors")
|
||||||
|
def connectors(authorization: str | None = Header(default=None)) -> list[dict[str, Any]]:
|
||||||
|
require_auth(authorization)
|
||||||
|
return provider.connectors()
|
||||||
|
|
||||||
|
@app.post("/connector-sessions")
|
||||||
|
def start_session(
|
||||||
|
payload: ConnectorSessionRequest,
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
require_auth(authorization)
|
||||||
|
return provider.start_session(payload.model_dump(by_alias=True))
|
||||||
|
|
||||||
|
@app.get("/connector-sessions/{session_id}")
|
||||||
|
def get_session(session_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||||||
|
require_auth(authorization)
|
||||||
|
try:
|
||||||
|
return provider.get_session(session_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="Connector session not found")
|
||||||
|
|
||||||
|
@app.post("/connector-sessions/{session_id}/cancel")
|
||||||
|
def cancel_session(session_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||||||
|
require_auth(authorization)
|
||||||
|
provider.cancel_session(session_id)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.post("/connections/{connection_id}/logout")
|
||||||
|
def logout(connection_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||||||
|
require_auth(authorization)
|
||||||
|
provider.logout(connection_id)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.post("/send", response_model=None)
|
||||||
|
def send(payload: SendRequest, authorization: str | None = Header(default=None)) -> JSONResponse | dict[str, Any]:
|
||||||
|
require_auth(authorization)
|
||||||
|
result = dict(provider.send(payload.model_dump(by_alias=True)))
|
||||||
|
status_code = int(result.pop("httpStatus", 200))
|
||||||
|
if status_code != 200:
|
||||||
|
return JSONResponse(status_code=status_code, content=result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if hasattr(provider, "handle_event"):
|
||||||
|
@app.post("/feishu/events", response_model=None)
|
||||||
|
def feishu_events(payload: dict[str, Any]) -> JSONResponse | dict[str, Any]:
|
||||||
|
result = dict(provider.handle_event(payload)) # type: ignore[attr-defined]
|
||||||
|
status_code = int(result.pop("httpStatus", 200))
|
||||||
|
if status_code != 200:
|
||||||
|
return JSONResponse(status_code=status_code, content=result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
return app
|
||||||
62
external-connector/external_connector/main.py
Normal file
62
external-connector/external_connector/main.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from external_connector.app import create_app
|
||||||
|
from external_connector.providers.composite import CompositeProvider
|
||||||
|
from external_connector.providers.fake import FakeProvider
|
||||||
|
from external_connector.providers.feishu_bot import FeishuBotProvider
|
||||||
|
from external_connector.providers.vendor_cli import VendorCliProvider
|
||||||
|
from external_connector.providers.weixin_ilink import WeixinIlinkProvider
|
||||||
|
from external_connector.state import SidecarStateStore
|
||||||
|
|
||||||
|
|
||||||
|
def build_app():
|
||||||
|
home = Path(os.getenv("CONNECTOR_HOME", "/var/lib/external-connector"))
|
||||||
|
store = SidecarStateStore(home / "state.json")
|
||||||
|
provider_name = os.getenv("CONNECTOR_PROVIDER", "fake")
|
||||||
|
if provider_name == "official":
|
||||||
|
provider = CompositeProvider([
|
||||||
|
_weixin_provider(store),
|
||||||
|
_feishu_provider(store),
|
||||||
|
])
|
||||||
|
elif provider_name == "weixin_ilink":
|
||||||
|
provider = _weixin_provider(store)
|
||||||
|
elif provider_name == "feishu_bot":
|
||||||
|
provider = _feishu_provider(store)
|
||||||
|
elif provider_name == "vendor_cli":
|
||||||
|
provider = VendorCliProvider(store=store, env=os.environ)
|
||||||
|
else:
|
||||||
|
provider = FakeProvider(store)
|
||||||
|
return create_app(provider=provider, api_token=os.getenv("CONNECTOR_API_TOKEN", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _weixin_provider(store: SidecarStateStore) -> WeixinIlinkProvider:
|
||||||
|
return WeixinIlinkProvider(
|
||||||
|
store=store,
|
||||||
|
bridge_base_url=os.getenv("BEAVER_BRIDGE_BASE_URL", ""),
|
||||||
|
bridge_token=os.getenv("BEAVER_BRIDGE_TOKEN", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _feishu_provider(store: SidecarStateStore) -> FeishuBotProvider:
|
||||||
|
return FeishuBotProvider(
|
||||||
|
store=store,
|
||||||
|
bridge_base_url=os.getenv("BEAVER_BRIDGE_BASE_URL", ""),
|
||||||
|
public_base_url=os.getenv("CONNECTOR_PUBLIC_BASE_URL", ""),
|
||||||
|
bridge_token=os.getenv("BEAVER_BRIDGE_TOKEN", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
app = build_app()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8787)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
24
external-connector/external_connector/models.py
Normal file
24
external-connector/external_connector/models.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorSessionRequest(BaseModel):
|
||||||
|
kind: str
|
||||||
|
connection_id: str = Field(alias="connectionId")
|
||||||
|
channel_id: str = Field(alias="channelId")
|
||||||
|
display_name: str = Field(alias="displayName")
|
||||||
|
callback_base_url: str = Field(alias="callbackBaseUrl")
|
||||||
|
options: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class SendRequest(BaseModel):
|
||||||
|
request_id: str = Field(alias="requestId")
|
||||||
|
connection_id: str = Field(alias="connectionId")
|
||||||
|
channel_id: str = Field(alias="channelId")
|
||||||
|
kind: str
|
||||||
|
target: dict[str, Any]
|
||||||
|
content: str
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
149
external-connector/external_connector/node/feishu_ws_receiver.js
Normal file
149
external-connector/external_connector/node/feishu_ws_receiver.js
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
const Lark = require("@larksuiteoapi/node-sdk");
|
||||||
|
|
||||||
|
const appId = requireEnv("FEISHU_APP_ID");
|
||||||
|
const appSecret = requireEnv("FEISHU_APP_SECRET");
|
||||||
|
const connectionId = requireEnv("FEISHU_CONNECTION_ID");
|
||||||
|
const channelId = requireEnv("FEISHU_CHANNEL_ID");
|
||||||
|
const accountId = process.env.FEISHU_ACCOUNT_ID || `feishu:${appId}`;
|
||||||
|
const bridgeBaseUrl = requireEnv("BEAVER_BRIDGE_BASE_URL").replace(/\/+$/, "");
|
||||||
|
const bridgeToken = requireEnv("BEAVER_BRIDGE_TOKEN");
|
||||||
|
const domain = (process.env.FEISHU_DOMAIN || "feishu").toLowerCase() === "lark"
|
||||||
|
? Lark.Domain.Lark
|
||||||
|
: Lark.Domain.Feishu;
|
||||||
|
|
||||||
|
const wsClient = new Lark.WSClient({
|
||||||
|
appId,
|
||||||
|
appSecret,
|
||||||
|
domain,
|
||||||
|
loggerLevel: Lark.LoggerLevel.info,
|
||||||
|
onReady: () => log("feishu_ws_ready", {}),
|
||||||
|
onError: (error) => log("feishu_ws_error", { error: redact(String(error && error.message ? error.message : error)) }),
|
||||||
|
onReconnecting: () => log("feishu_ws_reconnecting", {}),
|
||||||
|
onReconnected: () => log("feishu_ws_reconnected", {}),
|
||||||
|
handshakeTimeoutMs: 15000,
|
||||||
|
wsConfig: { pingTimeout: 10 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const dispatcher = new Lark.EventDispatcher({}).register({
|
||||||
|
"im.message.receive_v1": async (data) => {
|
||||||
|
const event = bridgeEventFromFeishu(data);
|
||||||
|
log("feishu_inbound_message", {
|
||||||
|
connectionId,
|
||||||
|
eventId: event.eventId,
|
||||||
|
messageId: event.messageId,
|
||||||
|
peerId: event.peerId,
|
||||||
|
textLength: event.content.length,
|
||||||
|
});
|
||||||
|
await postJson(`${bridgeBaseUrl}/api/channel-connector-bridge/events`, event);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("SIGTERM", () => {
|
||||||
|
wsClient.close({ force: true });
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
wsClient.close({ force: true });
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
wsClient.start({ eventDispatcher: dispatcher }).catch((error) => {
|
||||||
|
log("feishu_ws_start_failed", { error: redact(String(error && error.message ? error.message : error)) });
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
if (typeof wsClient.getConnectionStatus === "function") {
|
||||||
|
log("feishu_ws_status", wsClient.getConnectionStatus());
|
||||||
|
}
|
||||||
|
}, 60000).unref();
|
||||||
|
|
||||||
|
function bridgeEventFromFeishu(data) {
|
||||||
|
const message = objectValue(data.message);
|
||||||
|
const sender = objectValue(data.sender);
|
||||||
|
const senderId = objectValue(sender.sender_id);
|
||||||
|
const peerId = stringValue(senderId.open_id || senderId.user_id || "");
|
||||||
|
const messageId = stringValue(message.message_id || randomId());
|
||||||
|
const eventId = stringValue(data.event_id || data.eventId || `${channelId}:${messageId}`);
|
||||||
|
return {
|
||||||
|
eventId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
deliveryAttempt: 1,
|
||||||
|
connectionId,
|
||||||
|
channelId,
|
||||||
|
kind: "feishu",
|
||||||
|
accountId,
|
||||||
|
peerId,
|
||||||
|
peerType: message.chat_type === "group" ? "group" : "dm",
|
||||||
|
userId: peerId,
|
||||||
|
threadId: stringValue(message.chat_id || "") || null,
|
||||||
|
messageId,
|
||||||
|
messageType: stringValue(message.message_type || "text"),
|
||||||
|
content: extractText(message),
|
||||||
|
metadata: {
|
||||||
|
chatId: message.chat_id || null,
|
||||||
|
rawMessageType: message.message_type || null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractText(message) {
|
||||||
|
const content = message.content;
|
||||||
|
if (typeof content !== "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
if (parsed && parsed.text != null) {
|
||||||
|
return String(parsed.text);
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJson(url, payload) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${bridgeToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`bridge post failed ${response.status}: ${text.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireEnv(name) {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`${name} is required`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectValue(value) {
|
||||||
|
return value && typeof value === "object" ? value : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value) {
|
||||||
|
return value == null ? "" : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomId() {
|
||||||
|
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(event, fields) {
|
||||||
|
console.log(JSON.stringify({ event, ...fields }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function redact(text) {
|
||||||
|
return text
|
||||||
|
.replace(appSecret, "***")
|
||||||
|
.replace(bridgeToken, "***");
|
||||||
|
}
|
||||||
28
external-connector/external_connector/providers/base.py
Normal file
28
external-connector/external_connector/providers/base.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorProvider(Protocol):
|
||||||
|
provider_id: str
|
||||||
|
|
||||||
|
def connectors(self) -> list[dict[str, Any]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def health(self) -> dict[str, Any]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_session(self, session_id: str) -> dict[str, Any]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def cancel_session(self, session_id: str) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def logout(self, connection_id: str) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
...
|
||||||
70
external-connector/external_connector/providers/composite.py
Normal file
70
external-connector/external_connector/providers/composite.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from external_connector.providers.base import ConnectorProvider
|
||||||
|
|
||||||
|
|
||||||
|
class CompositeProvider:
|
||||||
|
provider_id = "composite"
|
||||||
|
|
||||||
|
def __init__(self, providers: list[ConnectorProvider]) -> None:
|
||||||
|
self.providers = providers
|
||||||
|
self._by_kind: dict[str, ConnectorProvider] = {}
|
||||||
|
for provider in providers:
|
||||||
|
for connector in provider.connectors():
|
||||||
|
self._by_kind[str(connector["kind"])] = provider
|
||||||
|
|
||||||
|
def connectors(self) -> list[dict[str, Any]]:
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for provider in self.providers:
|
||||||
|
items.extend(provider.connectors())
|
||||||
|
return items
|
||||||
|
|
||||||
|
def health(self) -> dict[str, Any]:
|
||||||
|
return {"ok": True, "providerId": self.provider_id, "providers": [provider.health() for provider in self.providers]}
|
||||||
|
|
||||||
|
def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return self._provider(str(payload["kind"])).start_session(payload)
|
||||||
|
|
||||||
|
def get_session(self, session_id: str) -> dict[str, Any]:
|
||||||
|
for provider in self.providers:
|
||||||
|
try:
|
||||||
|
return provider.get_session(session_id)
|
||||||
|
except KeyError:
|
||||||
|
continue
|
||||||
|
raise KeyError(session_id)
|
||||||
|
|
||||||
|
def cancel_session(self, session_id: str) -> None:
|
||||||
|
for provider in self.providers:
|
||||||
|
try:
|
||||||
|
provider.cancel_session(session_id)
|
||||||
|
return None
|
||||||
|
except KeyError:
|
||||||
|
continue
|
||||||
|
raise KeyError(session_id)
|
||||||
|
|
||||||
|
def logout(self, connection_id: str) -> None:
|
||||||
|
for provider in self.providers:
|
||||||
|
provider.logout(connection_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return self._provider(str(payload["kind"])).send(payload)
|
||||||
|
|
||||||
|
def handle_event(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
for provider in self.providers:
|
||||||
|
handler = getattr(provider, "handle_event", None)
|
||||||
|
if handler is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return dict(handler(payload))
|
||||||
|
except KeyError:
|
||||||
|
continue
|
||||||
|
raise KeyError("No event provider matched")
|
||||||
|
|
||||||
|
def _provider(self, kind: str) -> ConnectorProvider:
|
||||||
|
provider = self._by_kind.get(kind)
|
||||||
|
if provider is None:
|
||||||
|
raise KeyError(f"Unsupported connector kind: {kind}")
|
||||||
|
return provider
|
||||||
119
external-connector/external_connector/providers/fake.py
Normal file
119
external-connector/external_connector/providers/fake.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from external_connector.state import ConnectorSessionState, SidecarStateStore
|
||||||
|
|
||||||
|
|
||||||
|
def _session_view(session: ConnectorSessionState) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"sessionId": session.session_id,
|
||||||
|
"kind": session.kind,
|
||||||
|
"status": session.status,
|
||||||
|
"qrCode": session.qr_code,
|
||||||
|
"qrImage": session.qr_image,
|
||||||
|
"instructions": list(session.instructions),
|
||||||
|
"accountId": session.account_id,
|
||||||
|
"displayName": session.display_name if session.account_id else None,
|
||||||
|
"error": session.error,
|
||||||
|
"metadata": dict(session.metadata),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_qr_image() -> str:
|
||||||
|
svg = (
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">'
|
||||||
|
'<rect width="256" height="256" fill="#fff"/>'
|
||||||
|
'<rect x="16" y="16" width="58" height="58" fill="#111"/>'
|
||||||
|
'<rect x="28" y="28" width="34" height="34" fill="#fff"/>'
|
||||||
|
'<rect x="184" y="16" width="58" height="58" fill="#111"/>'
|
||||||
|
'<rect x="196" y="28" width="34" height="34" fill="#fff"/>'
|
||||||
|
'<rect x="16" y="184" width="58" height="58" fill="#111"/>'
|
||||||
|
'<rect x="28" y="196" width="34" height="34" fill="#fff"/>'
|
||||||
|
'<rect x="96" y="96" width="18" height="18" fill="#111"/>'
|
||||||
|
'<rect x="132" y="96" width="18" height="18" fill="#111"/>'
|
||||||
|
'<rect x="168" y="96" width="18" height="18" fill="#111"/>'
|
||||||
|
'<rect x="96" y="132" width="18" height="18" fill="#111"/>'
|
||||||
|
'<rect x="150" y="132" width="18" height="18" fill="#111"/>'
|
||||||
|
'<rect x="204" y="132" width="18" height="18" fill="#111"/>'
|
||||||
|
'<rect x="114" y="168" width="18" height="18" fill="#111"/>'
|
||||||
|
'<rect x="168" y="168" width="18" height="18" fill="#111"/>'
|
||||||
|
'<rect x="204" y="168" width="18" height="18" fill="#111"/>'
|
||||||
|
'<rect x="96" y="204" width="18" height="18" fill="#111"/>'
|
||||||
|
'<rect x="132" y="204" width="18" height="18" fill="#111"/>'
|
||||||
|
'<rect x="186" y="204" width="18" height="18" fill="#111"/>'
|
||||||
|
'<text x="128" y="248" text-anchor="middle" font-family="monospace" font-size="14" fill="#444">FAKE QR</text>'
|
||||||
|
"</svg>"
|
||||||
|
)
|
||||||
|
encoded = base64.b64encode(svg.encode("utf-8")).decode("ascii")
|
||||||
|
return f"data:image/svg+xml;base64,{encoded}"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeProvider:
|
||||||
|
provider_id = "fake"
|
||||||
|
|
||||||
|
def __init__(self, store: SidecarStateStore) -> None:
|
||||||
|
self.store = store
|
||||||
|
|
||||||
|
def connectors(self) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"kind": "weixin",
|
||||||
|
"displayName": "Weixin",
|
||||||
|
"authType": "qr",
|
||||||
|
"providerId": self.provider_id,
|
||||||
|
"capabilities": ["receive_text", "send_text", "receive_media", "direct_messages"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "feishu",
|
||||||
|
"displayName": "Feishu/Lark",
|
||||||
|
"authType": "plugin_install",
|
||||||
|
"providerId": self.provider_id,
|
||||||
|
"capabilities": ["receive_text", "send_text", "receive_media", "groups"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
def health(self) -> dict[str, Any]:
|
||||||
|
return {"ok": True, "providerId": self.provider_id}
|
||||||
|
|
||||||
|
def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
session = self.store.create_session(
|
||||||
|
kind=str(payload["kind"]),
|
||||||
|
connection_id=str(payload["connectionId"]),
|
||||||
|
channel_id=str(payload["channelId"]),
|
||||||
|
display_name=str(payload["displayName"]),
|
||||||
|
options=dict(payload.get("options") or {}),
|
||||||
|
)
|
||||||
|
session = self.store.update_session(
|
||||||
|
session.session_id,
|
||||||
|
status="qr_ready" if session.kind == "weixin" else "waiting_for_user",
|
||||||
|
qr_image=_fake_qr_image() if session.kind == "weixin" else None,
|
||||||
|
instructions=["Run the provider install flow and finish verification"] if session.kind == "feishu" else [],
|
||||||
|
)
|
||||||
|
return _session_view(session)
|
||||||
|
|
||||||
|
def get_session(self, session_id: str) -> dict[str, Any]:
|
||||||
|
return _session_view(self.store.get_session(session_id))
|
||||||
|
|
||||||
|
def cancel_session(self, session_id: str) -> None:
|
||||||
|
self.store.update_session(session_id, status="cancelled")
|
||||||
|
|
||||||
|
def logout(self, connection_id: str) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
begin = self.store.begin_send(connection_id=str(payload["connectionId"]), request_id=str(payload["requestId"]))
|
||||||
|
if not begin.should_send:
|
||||||
|
if begin.http_status == 409:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"status": begin.status,
|
||||||
|
"retryAfterSeconds": begin.retry_after_seconds,
|
||||||
|
"httpStatus": 409,
|
||||||
|
}
|
||||||
|
return {"ok": True, "providerMessageId": begin.provider_message_id}
|
||||||
|
provider_message_id = f"fake_{uuid4().hex}"
|
||||||
|
self.store.complete_send(begin.dedupe_key, provider_message_id=provider_message_id)
|
||||||
|
return {"ok": True, "providerMessageId": provider_message_id}
|
||||||
542
external-connector/external_connector/providers/feishu_bot.py
Normal file
542
external-connector/external_connector/providers/feishu_bot.py
Normal file
@ -0,0 +1,542 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import qrcode
|
||||||
|
import qrcode.image.svg
|
||||||
|
|
||||||
|
from external_connector.providers.fake import _session_view
|
||||||
|
from external_connector.state import ConnectorSessionState, SidecarStateStore
|
||||||
|
|
||||||
|
|
||||||
|
BridgePoster = Callable[[str, dict[str, Any], dict[str, str]], None]
|
||||||
|
ReceiverStart = Callable[[ConnectorSessionState], object]
|
||||||
|
|
||||||
|
|
||||||
|
class FeishuBotProvider:
|
||||||
|
provider_id = "feishu_bot"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
store: SidecarStateStore,
|
||||||
|
http_client: Any | None = None,
|
||||||
|
bridge_base_url: str,
|
||||||
|
public_base_url: str | None = None,
|
||||||
|
bridge_token: str,
|
||||||
|
bridge_post: BridgePoster | None = None,
|
||||||
|
api_base_url: str = "",
|
||||||
|
start_receivers: bool = True,
|
||||||
|
receiver_start: ReceiverStart | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.store = store
|
||||||
|
self.http = http_client or httpx.Client(timeout=30)
|
||||||
|
self.bridge_base_url = bridge_base_url.rstrip("/")
|
||||||
|
self.public_base_url = (public_base_url or bridge_base_url).rstrip("/")
|
||||||
|
self.bridge_token = bridge_token
|
||||||
|
self.bridge_post = bridge_post or self._default_bridge_post
|
||||||
|
self.api_base_url = api_base_url.rstrip("/")
|
||||||
|
self.start_receivers = start_receivers
|
||||||
|
self.receiver_start = receiver_start or self._start_receiver_process
|
||||||
|
self._receiver_processes: dict[str, object] = {}
|
||||||
|
self._receiver_lock = threading.Lock()
|
||||||
|
self._start_existing_connected_receivers()
|
||||||
|
|
||||||
|
def connectors(self) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"kind": "feishu",
|
||||||
|
"displayName": "Feishu/Lark",
|
||||||
|
"authType": "qr_or_bot_app",
|
||||||
|
"providerId": self.provider_id,
|
||||||
|
"capabilities": ["receive_text", "send_text", "groups"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def health(self) -> dict[str, Any]:
|
||||||
|
return {"ok": True, "providerId": self.provider_id}
|
||||||
|
|
||||||
|
def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
kind = str(payload["kind"])
|
||||||
|
if kind != "feishu":
|
||||||
|
raise KeyError(f"Unsupported connector kind: {kind}")
|
||||||
|
options = dict(payload.get("options") or {})
|
||||||
|
session = self.store.create_session(
|
||||||
|
kind=kind,
|
||||||
|
connection_id=str(payload["connectionId"]),
|
||||||
|
channel_id=str(payload["channelId"]),
|
||||||
|
display_name=str(payload["displayName"]),
|
||||||
|
options=options,
|
||||||
|
)
|
||||||
|
metadata = {
|
||||||
|
"eventCallbackPath": "/feishu/events",
|
||||||
|
"eventCallbackUrl": f"{self.public_base_url}/feishu/events",
|
||||||
|
}
|
||||||
|
app_id = str(options.get("appId") or options.get("app_id") or "").strip()
|
||||||
|
app_secret = str(options.get("appSecret") or options.get("app_secret") or "").strip()
|
||||||
|
verification_token = str(options.get("verificationToken") or options.get("verification_token") or "").strip()
|
||||||
|
mode = str(options.get("mode") or "create").strip().lower()
|
||||||
|
domain = _domain(options)
|
||||||
|
if not app_id or not app_secret:
|
||||||
|
if mode != "link":
|
||||||
|
return self._start_registration_session(session, metadata=metadata, domain=domain)
|
||||||
|
session = self.store.update_session(
|
||||||
|
session.session_id,
|
||||||
|
status="waiting_for_user",
|
||||||
|
instructions=_link_instructions(metadata["eventCallbackUrl"]),
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
return _session_view(session)
|
||||||
|
token_data = self._tenant_token(app_id, app_secret, domain=domain)
|
||||||
|
metadata.update(
|
||||||
|
{
|
||||||
|
"appId": app_id,
|
||||||
|
"appSecret": app_secret,
|
||||||
|
"verificationToken": verification_token,
|
||||||
|
"tenantAccessToken": token_data["token"],
|
||||||
|
"tenantTokenExpiresAt": token_data["expires_at"],
|
||||||
|
"domain": domain,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
session = self.store.update_session(
|
||||||
|
session.session_id,
|
||||||
|
status="connected",
|
||||||
|
account_id=f"feishu:{app_id}",
|
||||||
|
metadata=metadata,
|
||||||
|
instructions=_connected_instructions(),
|
||||||
|
)
|
||||||
|
self._ensure_receiver(session)
|
||||||
|
return _session_view(session)
|
||||||
|
|
||||||
|
def get_session(self, session_id: str) -> dict[str, Any]:
|
||||||
|
session = self.store.get_session(session_id)
|
||||||
|
if session.kind != "feishu":
|
||||||
|
raise KeyError(session_id)
|
||||||
|
if session.kind == "feishu" and session.status in {"qr_ready", "scanned", "confirmed", "waiting_for_user"} and session.metadata.get("deviceCode"):
|
||||||
|
session = self._poll_registration_session(session)
|
||||||
|
if session.status == "connected":
|
||||||
|
self._ensure_receiver(session)
|
||||||
|
return _session_view(session)
|
||||||
|
|
||||||
|
def cancel_session(self, session_id: str) -> None:
|
||||||
|
session = self.store.get_session(session_id)
|
||||||
|
if session.kind != "feishu":
|
||||||
|
raise KeyError(session_id)
|
||||||
|
self.store.update_session(session_id, status="cancelled")
|
||||||
|
self._stop_receiver(session.connection_id)
|
||||||
|
|
||||||
|
def logout(self, connection_id: str) -> None:
|
||||||
|
try:
|
||||||
|
session = self.store.find_session_by_connection_id(connection_id)
|
||||||
|
except KeyError:
|
||||||
|
return None
|
||||||
|
self._stop_receiver(connection_id)
|
||||||
|
self.store.update_session(session.session_id, status="cancelled")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
begin = self.store.begin_send(connection_id=str(payload["connectionId"]), request_id=str(payload["requestId"]))
|
||||||
|
if not begin.should_send:
|
||||||
|
if begin.http_status == 409:
|
||||||
|
return {"ok": False, "status": begin.status, "retryAfterSeconds": begin.retry_after_seconds, "httpStatus": 409}
|
||||||
|
return {"ok": True, "providerMessageId": begin.provider_message_id}
|
||||||
|
session = self.store.find_session_by_connection_id(str(payload["connectionId"]))
|
||||||
|
token = self._tenant_token_for_session(session)
|
||||||
|
target = dict(payload.get("target") or {})
|
||||||
|
metadata = dict(session.metadata)
|
||||||
|
api_base = _open_api_base_url(str(metadata.get("domain") or "feishu"), self.api_base_url)
|
||||||
|
try:
|
||||||
|
response = self.http.post(
|
||||||
|
f"{api_base}/open-apis/im/v1/messages?receive_id_type=open_id",
|
||||||
|
json={
|
||||||
|
"receive_id": str(target.get("peerId") or ""),
|
||||||
|
"msg_type": "text",
|
||||||
|
"content": json.dumps({"text": str(payload.get("content") or "")}, ensure_ascii=False),
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
except Exception as exc:
|
||||||
|
error = str(exc)
|
||||||
|
self.store.fail_send(begin.dedupe_key, error=error)
|
||||||
|
return {"ok": False, "error": error, "httpStatus": 502}
|
||||||
|
data = dict(response.json())
|
||||||
|
if int(data.get("code") or 0) != 0:
|
||||||
|
error = str(data.get("msg") or data)
|
||||||
|
self.store.fail_send(begin.dedupe_key, error=error)
|
||||||
|
return {"ok": False, "error": error, "httpStatus": 502}
|
||||||
|
provider_message_id = str((data.get("data") or {}).get("message_id") or f"feishu_{payload['requestId']}")
|
||||||
|
self.store.complete_send(begin.dedupe_key, provider_message_id=provider_message_id)
|
||||||
|
return {"ok": True, "providerMessageId": provider_message_id}
|
||||||
|
|
||||||
|
def handle_event(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
challenge = payload.get("challenge")
|
||||||
|
if challenge:
|
||||||
|
return {"challenge": challenge}
|
||||||
|
header = dict(payload.get("header") or {})
|
||||||
|
event = dict(payload.get("event") or {})
|
||||||
|
app_id = str(header.get("app_id") or "")
|
||||||
|
session = self._session_for_app_id(app_id)
|
||||||
|
expected_token = str(session.metadata.get("verificationToken") or "")
|
||||||
|
received_token = str(header.get("token") or payload.get("token") or "")
|
||||||
|
if expected_token and received_token != expected_token:
|
||||||
|
return {"ok": False, "error": "invalid verification token", "httpStatus": 401}
|
||||||
|
bridge_event = _bridge_event_from_feishu(session, header, event)
|
||||||
|
self.bridge_post(
|
||||||
|
f"{self.bridge_base_url}/api/channel-connector-bridge/events",
|
||||||
|
bridge_event,
|
||||||
|
{"Authorization": f"Bearer {self.bridge_token}"},
|
||||||
|
)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
def _tenant_token_for_session(self, session: ConnectorSessionState) -> str:
|
||||||
|
metadata = dict(session.metadata)
|
||||||
|
expires_at = float(metadata.get("tenantTokenExpiresAt") or 0)
|
||||||
|
token = str(metadata.get("tenantAccessToken") or "")
|
||||||
|
if token and expires_at - time.time() > 60:
|
||||||
|
return token
|
||||||
|
app_id = str(metadata.get("appId") or "")
|
||||||
|
app_secret = str(metadata.get("appSecret") or "")
|
||||||
|
token_data = self._tenant_token(app_id, app_secret, domain=str(metadata.get("domain") or "feishu"))
|
||||||
|
metadata.update({"tenantAccessToken": token_data["token"], "tenantTokenExpiresAt": token_data["expires_at"]})
|
||||||
|
self.store.update_session(session.session_id, metadata=metadata)
|
||||||
|
return str(token_data["token"])
|
||||||
|
|
||||||
|
def _tenant_token(self, app_id: str, app_secret: str, *, domain: str = "feishu") -> dict[str, Any]:
|
||||||
|
response = self.http.post(
|
||||||
|
f"{_open_api_base_url(domain, self.api_base_url)}/open-apis/auth/v3/tenant_access_token/internal",
|
||||||
|
json={"app_id": app_id, "app_secret": app_secret},
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = dict(response.json())
|
||||||
|
if int(data.get("code") or 0) != 0:
|
||||||
|
raise RuntimeError(str(data.get("msg") or data))
|
||||||
|
return {"token": str(data["tenant_access_token"]), "expires_at": time.time() + int(data.get("expire") or 7200)}
|
||||||
|
|
||||||
|
def _session_for_app_id(self, app_id: str) -> ConnectorSessionState:
|
||||||
|
sessions = self.store.list_sessions()
|
||||||
|
for session in sorted(sessions, key=lambda item: item.updated_at, reverse=True):
|
||||||
|
if session.kind == "feishu" and session.status == "connected" and session.metadata.get("appId") == app_id:
|
||||||
|
return session
|
||||||
|
raise KeyError(app_id)
|
||||||
|
|
||||||
|
def _default_bridge_post(self, url: str, payload: dict[str, Any], headers: dict[str, str]) -> None:
|
||||||
|
response = self.http.post(url, json=payload, headers=headers, timeout=20)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
def _start_registration_session(
|
||||||
|
self,
|
||||||
|
session: ConnectorSessionState,
|
||||||
|
*,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
domain: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
init_data = self._registration_post(domain, {"action": "init"})
|
||||||
|
supported = init_data.get("supported_auth_methods") or []
|
||||||
|
if "client_secret" not in supported:
|
||||||
|
session = self.store.update_session(
|
||||||
|
session.session_id,
|
||||||
|
status="error",
|
||||||
|
error="Current Feishu/Lark environment does not support client_secret bot registration",
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
return _session_view(session)
|
||||||
|
begin_data = self._registration_post(
|
||||||
|
domain,
|
||||||
|
{
|
||||||
|
"action": "begin",
|
||||||
|
"archetype": "PersonalAgent",
|
||||||
|
"auth_method": "client_secret",
|
||||||
|
"request_user_info": "open_id",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
session = self.store.update_session(
|
||||||
|
session.session_id,
|
||||||
|
status="error",
|
||||||
|
error=str(exc),
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
return _session_view(session)
|
||||||
|
|
||||||
|
qr_code = _with_onboard_from(str(begin_data.get("verification_uri_complete") or ""))
|
||||||
|
if not qr_code:
|
||||||
|
session = self.store.update_session(
|
||||||
|
session.session_id,
|
||||||
|
status="error",
|
||||||
|
error="Feishu/Lark registration did not return a QR URL",
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
return _session_view(session)
|
||||||
|
metadata.update(
|
||||||
|
{
|
||||||
|
"domain": domain,
|
||||||
|
"registrationBaseUrl": _registration_base_url(domain),
|
||||||
|
"deviceCode": str(begin_data.get("device_code") or ""),
|
||||||
|
"pollIntervalSeconds": int(begin_data.get("interval") or 5),
|
||||||
|
"expiresAt": time.time() + int(begin_data.get("expire_in") or 600),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
session = self.store.update_session(
|
||||||
|
session.session_id,
|
||||||
|
status="qr_ready",
|
||||||
|
qr_code=qr_code,
|
||||||
|
qr_image=_qr_svg_data_uri(qr_code),
|
||||||
|
instructions=_create_instructions(metadata["eventCallbackUrl"]),
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
return _session_view(session)
|
||||||
|
|
||||||
|
def _poll_registration_session(self, session: ConnectorSessionState) -> ConnectorSessionState:
|
||||||
|
metadata = dict(session.metadata)
|
||||||
|
expires_at = float(metadata.get("expiresAt") or 0)
|
||||||
|
if expires_at and time.time() > expires_at:
|
||||||
|
return self.store.update_session(session.session_id, status="expired", metadata=metadata)
|
||||||
|
domain = str(metadata.get("domain") or "feishu")
|
||||||
|
device_code = str(metadata.get("deviceCode") or "")
|
||||||
|
if not device_code:
|
||||||
|
return session
|
||||||
|
try:
|
||||||
|
poll_data = self._registration_post(domain, {"action": "poll", "device_code": device_code})
|
||||||
|
except Exception as exc:
|
||||||
|
return self.store.update_session(session.session_id, status="error", error=str(exc), metadata=metadata)
|
||||||
|
|
||||||
|
user_info = dict(poll_data.get("user_info") or {})
|
||||||
|
if user_info.get("tenant_brand") == "lark":
|
||||||
|
domain = "lark"
|
||||||
|
metadata["domain"] = domain
|
||||||
|
app_id = str(poll_data.get("client_id") or "").strip()
|
||||||
|
app_secret = str(poll_data.get("client_secret") or "").strip()
|
||||||
|
if app_id and app_secret:
|
||||||
|
token_data = self._tenant_token(app_id, app_secret, domain=domain)
|
||||||
|
metadata.update(
|
||||||
|
{
|
||||||
|
"appId": app_id,
|
||||||
|
"appSecret": app_secret,
|
||||||
|
"tenantAccessToken": token_data["token"],
|
||||||
|
"tenantTokenExpiresAt": token_data["expires_at"],
|
||||||
|
"registrationOpenId": str(user_info.get("open_id") or ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return self.store.update_session(
|
||||||
|
session.session_id,
|
||||||
|
status="connected",
|
||||||
|
account_id=f"feishu:{app_id}",
|
||||||
|
metadata=metadata,
|
||||||
|
instructions=_connected_instructions(),
|
||||||
|
)
|
||||||
|
|
||||||
|
error = str(poll_data.get("error") or "")
|
||||||
|
if error == "authorization_pending":
|
||||||
|
return session
|
||||||
|
if error == "slow_down":
|
||||||
|
metadata["pollIntervalSeconds"] = int(metadata.get("pollIntervalSeconds") or 5) + 5
|
||||||
|
return self.store.update_session(session.session_id, metadata=metadata)
|
||||||
|
if error == "expired_token":
|
||||||
|
return self.store.update_session(session.session_id, status="expired", metadata=metadata)
|
||||||
|
if error == "access_denied":
|
||||||
|
return self.store.update_session(session.session_id, status="error", error="Feishu/Lark authorization was denied", metadata=metadata)
|
||||||
|
if error:
|
||||||
|
description = str(poll_data.get("error_description") or error)
|
||||||
|
return self.store.update_session(session.session_id, status="error", error=description, metadata=metadata)
|
||||||
|
return session
|
||||||
|
|
||||||
|
def _registration_post(self, domain: str, values: dict[str, str]) -> dict[str, Any]:
|
||||||
|
response = self.http.post(
|
||||||
|
f"{_registration_base_url(domain)}/oauth/v1/app/registration",
|
||||||
|
data=urlencode(values),
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return dict(response.json())
|
||||||
|
|
||||||
|
def _start_existing_connected_receivers(self) -> None:
|
||||||
|
if not self.start_receivers:
|
||||||
|
return None
|
||||||
|
for session in self.store.list_sessions():
|
||||||
|
if session.kind == "feishu" and session.status == "connected":
|
||||||
|
self._ensure_receiver(session)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _ensure_receiver(self, session: ConnectorSessionState) -> None:
|
||||||
|
if not self.start_receivers or not _has_receiver_material(session):
|
||||||
|
return None
|
||||||
|
with self._receiver_lock:
|
||||||
|
existing = self._receiver_processes.get(session.connection_id)
|
||||||
|
if existing is not None and _receiver_is_alive(existing):
|
||||||
|
return None
|
||||||
|
receiver = self.receiver_start(session)
|
||||||
|
self._receiver_processes[session.connection_id] = receiver
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _stop_receiver(self, connection_id: str) -> None:
|
||||||
|
with self._receiver_lock:
|
||||||
|
receiver = self._receiver_processes.pop(connection_id, None)
|
||||||
|
if receiver is None:
|
||||||
|
return None
|
||||||
|
terminate = getattr(receiver, "terminate", None)
|
||||||
|
if callable(terminate):
|
||||||
|
terminate()
|
||||||
|
wait = getattr(receiver, "wait", None)
|
||||||
|
if callable(wait):
|
||||||
|
try:
|
||||||
|
wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
kill = getattr(receiver, "kill", None)
|
||||||
|
if callable(kill):
|
||||||
|
kill()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _start_receiver_process(self, session: ConnectorSessionState) -> subprocess.Popen[bytes]:
|
||||||
|
metadata = dict(session.metadata)
|
||||||
|
script = Path(__file__).resolve().parents[1] / "node" / "feishu_ws_receiver.js"
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.update(
|
||||||
|
{
|
||||||
|
"FEISHU_APP_ID": str(metadata.get("appId") or ""),
|
||||||
|
"FEISHU_APP_SECRET": str(metadata.get("appSecret") or ""),
|
||||||
|
"FEISHU_DOMAIN": str(metadata.get("domain") or "feishu"),
|
||||||
|
"FEISHU_CONNECTION_ID": session.connection_id,
|
||||||
|
"FEISHU_CHANNEL_ID": session.channel_id,
|
||||||
|
"FEISHU_ACCOUNT_ID": str(session.account_id or ""),
|
||||||
|
"BEAVER_BRIDGE_BASE_URL": self.bridge_base_url,
|
||||||
|
"BEAVER_BRIDGE_TOKEN": self.bridge_token,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return subprocess.Popen(["node", str(script)], env=env, cwd=str(script.parent))
|
||||||
|
|
||||||
|
|
||||||
|
def _bridge_event_from_feishu(session: ConnectorSessionState, header: dict[str, Any], event: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
message = dict(event.get("message") or {})
|
||||||
|
sender = dict(event.get("sender") or {})
|
||||||
|
sender_id = dict(sender.get("sender_id") or {})
|
||||||
|
peer_id = str(sender_id.get("open_id") or sender_id.get("user_id") or "")
|
||||||
|
message_id = str(message.get("message_id") or uuid4().hex)
|
||||||
|
event_id = str(header.get("event_id") or f"{session.channel_id}:{message_id}")
|
||||||
|
return {
|
||||||
|
"eventId": event_id,
|
||||||
|
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
"deliveryAttempt": 1,
|
||||||
|
"connectionId": session.connection_id,
|
||||||
|
"channelId": session.channel_id,
|
||||||
|
"kind": "feishu",
|
||||||
|
"accountId": session.account_id,
|
||||||
|
"peerId": peer_id,
|
||||||
|
"peerType": "group" if message.get("chat_type") == "group" else "dm",
|
||||||
|
"userId": peer_id,
|
||||||
|
"threadId": str(message.get("chat_id") or "") or None,
|
||||||
|
"messageId": message_id,
|
||||||
|
"messageType": str(message.get("message_type") or "text"),
|
||||||
|
"content": _extract_text(message),
|
||||||
|
"metadata": {"chatId": message.get("chat_id"), "rawMessageType": message.get("message_type")},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _has_receiver_material(session: ConnectorSessionState) -> bool:
|
||||||
|
metadata = dict(session.metadata)
|
||||||
|
return bool(
|
||||||
|
session.status == "connected"
|
||||||
|
and str(metadata.get("appId") or "").strip()
|
||||||
|
and str(metadata.get("appSecret") or "").strip()
|
||||||
|
and session.connection_id
|
||||||
|
and session.channel_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _receiver_is_alive(receiver: object) -> bool:
|
||||||
|
poll = getattr(receiver, "poll", None)
|
||||||
|
if callable(poll):
|
||||||
|
return poll() is None
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_text(message: dict[str, Any]) -> str:
|
||||||
|
content = message.get("content")
|
||||||
|
if isinstance(content, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(content)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return content
|
||||||
|
text = parsed.get("text")
|
||||||
|
if text is not None:
|
||||||
|
return str(text)
|
||||||
|
return content
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _domain(options: dict[str, Any]) -> str:
|
||||||
|
domain = str(options.get("domain") or "feishu").strip().lower()
|
||||||
|
return "lark" if domain == "lark" else "feishu"
|
||||||
|
|
||||||
|
|
||||||
|
def _registration_base_url(domain: str) -> str:
|
||||||
|
return "https://accounts.larksuite.com" if domain == "lark" else "https://accounts.feishu.cn"
|
||||||
|
|
||||||
|
|
||||||
|
def _open_api_base_url(domain: str, configured_base_url: str) -> str:
|
||||||
|
if configured_base_url:
|
||||||
|
return configured_base_url.rstrip("/")
|
||||||
|
return "https://open.larksuite.com" if domain == "lark" else "https://open.feishu.cn"
|
||||||
|
|
||||||
|
|
||||||
|
def _with_onboard_from(value: str) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
parts = urlsplit(value)
|
||||||
|
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
||||||
|
query.setdefault("from", "onboard")
|
||||||
|
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
||||||
|
|
||||||
|
|
||||||
|
def _qr_svg_data_uri(value: str) -> str:
|
||||||
|
image = qrcode.make(value, image_factory=qrcode.image.svg.SvgPathImage)
|
||||||
|
buffer = BytesIO()
|
||||||
|
image.save(buffer)
|
||||||
|
return "data:image/svg+xml;base64," + base64.b64encode(buffer.getvalue()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _create_instructions(event_callback_url: str) -> list[str]:
|
||||||
|
return [
|
||||||
|
"使用飞书客户端扫描二维码,选择一键创建飞书机器人。",
|
||||||
|
"创建完成后,点击打开机器人,在飞书中向机器人发送任意消息即可开始对话。",
|
||||||
|
"在飞书开放平台启用接收消息事件 im.message.receive_v1,并选择长连接事件通道。",
|
||||||
|
f"如果使用 HTTP 回调模式,事件请求 URL 可设为 {event_callback_url}。",
|
||||||
|
"如需用户身份授权,在飞书对话中发送 /feishu auth。",
|
||||||
|
"建议发送:学习一下我安装的新飞书插件,列出有哪些能力。",
|
||||||
|
"验证安装:在飞书对话中发送 /feishu start,返回版本号代表安装成功。",
|
||||||
|
"如果 Windows 设备扫码异常,通常是终端二维码分辨率问题,可换用 Cmder 或使用本窗口二维码。",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _link_instructions(event_callback_url: str) -> list[str]:
|
||||||
|
return [
|
||||||
|
"选择关联已有机器人时,请输入正确的 App ID 和 App Secret。",
|
||||||
|
"如果提示无效的 App ID 或 App Secret,请回到飞书开放平台复制最新应用凭证。",
|
||||||
|
"在飞书开放平台启用接收消息事件 im.message.receive_v1,并选择长连接事件通道。",
|
||||||
|
f"如果使用 HTTP 回调模式,事件请求 URL 可设为 {event_callback_url}。",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _connected_instructions() -> list[str]:
|
||||||
|
return [
|
||||||
|
"飞书机器人凭据已连接。",
|
||||||
|
"sidecar 会通过飞书长连接保持应用在线。",
|
||||||
|
"请确认飞书开放平台已启用接收消息事件 im.message.receive_v1。",
|
||||||
|
"在飞书对话中发送 /feishu start 验证插件返回版本号;发送任意消息验证 Beaver 是否收到。",
|
||||||
|
]
|
||||||
281
external-connector/external_connector/providers/vendor_cli.py
Normal file
281
external-connector/external_connector/providers/vendor_cli.py
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from external_connector.providers.fake import _session_view
|
||||||
|
from external_connector.state import SidecarStateStore
|
||||||
|
|
||||||
|
|
||||||
|
Runner = Callable[[list[str], str, float], tuple[int, str, str]]
|
||||||
|
|
||||||
|
|
||||||
|
def default_runner(command: list[str], cwd: str, timeout: float) -> tuple[int, str, str]:
|
||||||
|
completed = subprocess.run(command, cwd=cwd, text=True, capture_output=True, check=False, timeout=timeout)
|
||||||
|
return completed.returncode, completed.stdout, completed.stderr
|
||||||
|
|
||||||
|
|
||||||
|
class VendorCliProvider:
|
||||||
|
provider_id = "vendor_cli"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
store: SidecarStateStore,
|
||||||
|
env: Mapping[str, str] | None = None,
|
||||||
|
runner: Runner = default_runner,
|
||||||
|
) -> None:
|
||||||
|
self.store = store
|
||||||
|
self.env = env or os.environ
|
||||||
|
self.runner = runner
|
||||||
|
self.command_timeout_seconds = float(self.env.get("CONNECTOR_COMMAND_TIMEOUT_SECONDS") or 120)
|
||||||
|
|
||||||
|
def connectors(self) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"kind": "weixin",
|
||||||
|
"displayName": "Weixin",
|
||||||
|
"authType": "qr",
|
||||||
|
"providerId": self.provider_id,
|
||||||
|
"capabilities": ["receive_text", "send_text", "receive_media", "direct_messages"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "feishu",
|
||||||
|
"displayName": "Feishu/Lark",
|
||||||
|
"authType": "plugin_install",
|
||||||
|
"providerId": self.provider_id,
|
||||||
|
"capabilities": ["receive_text", "send_text", "receive_media", "groups"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
def health(self) -> dict[str, Any]:
|
||||||
|
return {"ok": True, "providerId": self.provider_id}
|
||||||
|
|
||||||
|
def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
kind = str(payload["kind"])
|
||||||
|
session = self.store.create_session(
|
||||||
|
kind=kind,
|
||||||
|
connection_id=str(payload["connectionId"]),
|
||||||
|
channel_id=str(payload["channelId"]),
|
||||||
|
display_name=str(payload["displayName"]),
|
||||||
|
options=dict(payload.get("options") or {}),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
command_template = self._command_template(kind)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
session = self.store.update_session(session.session_id, status="error", error=str(exc))
|
||||||
|
return _session_view(session)
|
||||||
|
|
||||||
|
connector_home = Path(self.store.path).parent
|
||||||
|
state_dir = connector_home / kind / session.connection_id
|
||||||
|
state_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
command = shlex.split(command_template.format(state_dir=str(state_dir), connection_id=session.connection_id))
|
||||||
|
try:
|
||||||
|
code, stdout, stderr = self.runner(command, str(connector_home), self.command_timeout_seconds)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
session = self.store.update_session(session.session_id, status="error", error="Provider command timed out")
|
||||||
|
return _session_view(session)
|
||||||
|
except Exception as exc:
|
||||||
|
session = self.store.update_session(session.session_id, status="error", error=_redact(str(exc)))
|
||||||
|
return _session_view(session)
|
||||||
|
|
||||||
|
if code != 0:
|
||||||
|
session = self.store.update_session(session.session_id, status="error", error=_redact(stderr or stdout))
|
||||||
|
return _session_view(session)
|
||||||
|
|
||||||
|
status = "connected" if "connected" in stdout.lower() else "waiting_for_user"
|
||||||
|
account_id = _extract_account_id(stdout)
|
||||||
|
session = self.store.update_session(
|
||||||
|
session.session_id,
|
||||||
|
status=status,
|
||||||
|
account_id=account_id,
|
||||||
|
metadata={"stateRef": str(state_dir)},
|
||||||
|
instructions=["Complete the vendor install or verification flow"] if status != "connected" else [],
|
||||||
|
)
|
||||||
|
return _session_view(session)
|
||||||
|
|
||||||
|
def get_session(self, session_id: str) -> dict[str, Any]:
|
||||||
|
session = self.store.get_session(session_id)
|
||||||
|
if session.status in {"cancelled", "error"}:
|
||||||
|
return _session_view(session)
|
||||||
|
command_template = self._optional_command_template(session.kind, "STATUS")
|
||||||
|
if not command_template:
|
||||||
|
return _session_view(session)
|
||||||
|
connector_home = Path(self.store.path).parent
|
||||||
|
state_dir = str(session.metadata.get("stateRef") or connector_home / session.kind / session.connection_id)
|
||||||
|
command = shlex.split(
|
||||||
|
command_template.format(
|
||||||
|
state_dir=state_dir,
|
||||||
|
connection_id=session.connection_id,
|
||||||
|
session_id=session.session_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
code, stdout, stderr = self.runner(command, str(connector_home), self.command_timeout_seconds)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
session = self.store.update_session(session.session_id, status="error", error="Provider status command timed out")
|
||||||
|
return _session_view(session)
|
||||||
|
except Exception as exc:
|
||||||
|
session = self.store.update_session(session.session_id, status="error", error=_redact(str(exc)))
|
||||||
|
return _session_view(session)
|
||||||
|
if code != 0:
|
||||||
|
session = self.store.update_session(session.session_id, status="error", error=_redact(stderr or stdout))
|
||||||
|
return _session_view(session)
|
||||||
|
updates = _provider_output_updates(stdout)
|
||||||
|
if updates:
|
||||||
|
metadata = dict(session.metadata)
|
||||||
|
metadata.update(dict(updates.pop("metadata", {}) or {}))
|
||||||
|
updates["metadata"] = metadata
|
||||||
|
session = self.store.update_session(session.session_id, **updates)
|
||||||
|
return _session_view(session)
|
||||||
|
|
||||||
|
def cancel_session(self, session_id: str) -> None:
|
||||||
|
self.store.update_session(session_id, status="cancelled")
|
||||||
|
|
||||||
|
def logout(self, connection_id: str) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
begin = self.store.begin_send(connection_id=str(payload["connectionId"]), request_id=str(payload["requestId"]))
|
||||||
|
if not begin.should_send:
|
||||||
|
if begin.http_status == 409:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"status": begin.status,
|
||||||
|
"retryAfterSeconds": begin.retry_after_seconds,
|
||||||
|
"httpStatus": 409,
|
||||||
|
}
|
||||||
|
return {"ok": True, "providerMessageId": begin.provider_message_id}
|
||||||
|
command_template = self._optional_command_template(str(payload["kind"]), "SEND")
|
||||||
|
if not command_template:
|
||||||
|
provider_message_id = f"vendor_{payload['requestId']}"
|
||||||
|
self.store.complete_send(begin.dedupe_key, provider_message_id=provider_message_id)
|
||||||
|
return {"ok": True, "providerMessageId": provider_message_id}
|
||||||
|
connector_home = Path(self.store.path).parent
|
||||||
|
state_dir = connector_home / str(payload["kind"]) / str(payload["connectionId"])
|
||||||
|
payload_path = _write_send_payload(state_dir, str(payload["requestId"]), payload)
|
||||||
|
command = shlex.split(
|
||||||
|
command_template.format(
|
||||||
|
state_dir=str(state_dir),
|
||||||
|
payload_path=str(payload_path),
|
||||||
|
connection_id=str(payload["connectionId"]),
|
||||||
|
channel_id=str(payload["channelId"]),
|
||||||
|
request_id=str(payload["requestId"]),
|
||||||
|
kind=str(payload["kind"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
code, stdout, stderr = self.runner(command, str(connector_home), self.command_timeout_seconds)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {"ok": False, "error": "Provider send command timed out"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": _redact(str(exc))}
|
||||||
|
if code != 0:
|
||||||
|
return {"ok": False, "error": _redact(stderr or stdout)}
|
||||||
|
provider_message_id = _extract_provider_message_id(stdout) or f"vendor_{payload['requestId']}"
|
||||||
|
self.store.complete_send(begin.dedupe_key, provider_message_id=provider_message_id)
|
||||||
|
return {"ok": True, "providerMessageId": provider_message_id}
|
||||||
|
|
||||||
|
def _command_template(self, kind: str) -> str:
|
||||||
|
key = "WEIXIN_CONNECT_COMMAND" if kind == "weixin" else "FEISHU_CONNECT_COMMAND"
|
||||||
|
command = str(self.env.get(key) or "").strip()
|
||||||
|
if not command:
|
||||||
|
raise RuntimeError(f"{key} is required")
|
||||||
|
return command
|
||||||
|
|
||||||
|
def _optional_command_template(self, kind: str, action: str) -> str | None:
|
||||||
|
prefix = "WEIXIN" if kind == "weixin" else "FEISHU"
|
||||||
|
command = str(self.env.get(f"{prefix}_{action}_COMMAND") or "").strip()
|
||||||
|
return command or None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_account_id(output: str) -> str | None:
|
||||||
|
for part in output.split():
|
||||||
|
if part.startswith("account="):
|
||||||
|
return part.split("=", 1)[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_output_updates(output: str) -> dict[str, Any]:
|
||||||
|
text = output.strip()
|
||||||
|
if not text:
|
||||||
|
return {}
|
||||||
|
parsed: dict[str, Any] = {}
|
||||||
|
try:
|
||||||
|
raw = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raw = _parse_key_value_output(text)
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
parsed = raw
|
||||||
|
updates: dict[str, Any] = {}
|
||||||
|
key_map = {
|
||||||
|
"status": "status",
|
||||||
|
"qrImage": "qr_image",
|
||||||
|
"qr_image": "qr_image",
|
||||||
|
"qrCode": "qr_code",
|
||||||
|
"qr_code": "qr_code",
|
||||||
|
"accountId": "account_id",
|
||||||
|
"account_id": "account_id",
|
||||||
|
"account": "account_id",
|
||||||
|
"displayName": "display_name",
|
||||||
|
"display_name": "display_name",
|
||||||
|
"error": "error",
|
||||||
|
}
|
||||||
|
for source_key, target_key in key_map.items():
|
||||||
|
value = parsed.get(source_key)
|
||||||
|
if value is not None:
|
||||||
|
updates[target_key] = str(value)
|
||||||
|
instructions = parsed.get("instructions")
|
||||||
|
if isinstance(instructions, list):
|
||||||
|
updates["instructions"] = [str(item) for item in instructions]
|
||||||
|
elif isinstance(instructions, str) and instructions.strip():
|
||||||
|
updates["instructions"] = [instructions.strip()]
|
||||||
|
metadata = parsed.get("metadata")
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
updates["metadata"] = metadata
|
||||||
|
return updates
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_key_value_output(text: str) -> dict[str, str]:
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
for part in text.split():
|
||||||
|
if "=" not in part:
|
||||||
|
continue
|
||||||
|
key, value = part.split("=", 1)
|
||||||
|
if key:
|
||||||
|
values[key] = value
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _write_send_payload(state_dir: Path, request_id: str, payload: dict[str, Any]) -> Path:
|
||||||
|
sends_dir = state_dir / "sends"
|
||||||
|
sends_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
safe_request_id = re.sub(r"[^A-Za-z0-9_.-]+", "_", request_id) or "request"
|
||||||
|
payload_path = sends_dir / f"{safe_request_id}.json"
|
||||||
|
payload_path.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
|
||||||
|
return payload_path
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_provider_message_id(output: str) -> str | None:
|
||||||
|
text = output.strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
raw = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raw = _parse_key_value_output(text)
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
value = raw.get("providerMessageId") or raw.get("provider_message_id") or raw.get("messageId") or raw.get("message_id")
|
||||||
|
return str(value) if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(text: str) -> str:
|
||||||
|
redacted = re.sub(r"\bsecret-[A-Za-z0-9._:-]+\b", "***", text)
|
||||||
|
return re.sub(r"\b(appSecret|token|secret)=\S+", r"\1=***", redacted)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user