docs: harden external connector implementation plans

This commit is contained in:
2026-06-03 10:32:50 +08:00
parent d335199a64
commit ee972441f5
3 changed files with 448 additions and 279 deletions

View File

@ -315,257 +315,7 @@ git commit -m "feat: add connector bridge dedupe store"
---
### Task 2: External Connector Channel
**Files:**
- Create: `app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py`
- Create: `app-instance/backend/beaver/interfaces/channels/external_connector.py`
- Modify: `app-instance/backend/beaver/interfaces/channels/__init__.py`
- Test: `app-instance/backend/tests/unit/test_external_connector_channel.py`
- [ ] **Step 1: Write failing channel tests**
Create `app-instance/backend/tests/unit/test_external_connector_channel.py`:
```python
from __future__ import annotations
import asyncio
from beaver.foundation.events import ChannelIdentity, OutboundMessage
from beaver.interfaces.channels.external_connector import ExternalConnectorChannel
class FakeSidecarClient:
def __init__(self) -> None:
self.sent: list[dict] = []
async def send(self, payload: dict) -> dict:
self.sent.append(payload)
return {"ok": True, "providerMessageId": "provider-1"}
def test_external_connector_channel_sends_with_target_and_request_id() -> None:
async def run() -> None:
client = FakeSidecarClient()
channel = ExternalConnectorChannel(
channel_id="weixin-main",
platform_kind="weixin",
connection_id="conn_1",
account_id="weixin:me",
display_name="Weixin Main",
sidecar_client=client,
)
message = OutboundMessage(
channel="weixin-main",
content="reply",
session_id="s1",
finish_reason="stop",
message_id="out-msg-1",
channel_identity=ChannelIdentity(
channel_id="weixin-main",
kind="weixin",
account_id="weixin:me",
peer_id="peer-1",
peer_type="dm",
thread_id=None,
user_id="sender-1",
message_id="in-msg-1",
),
)
await channel.send(message)
assert client.sent == [
{
"requestId": "out_out-msg-1",
"connectionId": "conn_1",
"channelId": "weixin-main",
"kind": "weixin",
"target": {"peerId": "peer-1", "peerType": "dm", "threadId": None},
"content": "reply",
"metadata": {"inboundMessageId": "in-msg-1", "sessionId": "s1"},
}
]
asyncio.run(run())
def test_external_connector_channel_requires_identity() -> None:
async def run() -> None:
channel = ExternalConnectorChannel(
channel_id="weixin-main",
platform_kind="weixin",
connection_id="conn_1",
account_id="weixin:me",
display_name="Weixin Main",
sidecar_client=FakeSidecarClient(),
)
message = OutboundMessage(channel="weixin-main", content="reply", session_id="s1", finish_reason="stop")
try:
await channel.send(message)
except ValueError as exc:
assert "channel_identity is required" in str(exc)
else:
raise AssertionError("Expected ValueError")
asyncio.run(run())
```
- [ ] **Step 2: Run tests to verify failure**
Run:
```bash
cd app-instance/backend
uv run pytest tests/unit/test_external_connector_channel.py -q
```
Expected: fail with `ModuleNotFoundError: No module named 'beaver.interfaces.channels.external_connector'`.
- [ ] **Step 3: Implement sidecar client**
Create `app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py`:
```python
from __future__ import annotations
from typing import Any
import httpx
class ConnectorSidecarClient:
def __init__(self, *, base_url: str, token: str, timeout_seconds: float = 20.0) -> None:
self.base_url = base_url.rstrip("/")
self.token = token
self.timeout_seconds = float(timeout_seconds)
async def get_connectors(self) -> list[dict[str, Any]]:
return await self._request("GET", "/connectors")
async def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
return await self._request("POST", "/connector-sessions", json=payload)
async def get_session(self, session_id: str) -> dict[str, Any]:
return await self._request("GET", f"/connector-sessions/{session_id}")
async def cancel_session(self, session_id: str) -> dict[str, Any]:
return await self._request("POST", f"/connector-sessions/{session_id}/cancel", json={})
async def logout(self, connection_id: str) -> dict[str, Any]:
return await self._request("POST", f"/connections/{connection_id}/logout", json={})
async def send(self, payload: dict[str, Any]) -> dict[str, Any]:
return await self._request("POST", "/send", json=payload)
async def _request(self, method: str, path: str, *, json: dict[str, Any] | None = None) -> Any:
headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
response = await client.request(method, f"{self.base_url}{path}", json=json, headers=headers)
response.raise_for_status()
return response.json()
```
- [ ] **Step 4: Implement external channel**
Create `app-instance/backend/beaver/interfaces/channels/external_connector.py`:
```python
from __future__ import annotations
from typing import Any
from beaver.foundation.events import OutboundMessage
from beaver.interfaces.channels.connections.sidecar_client import ConnectorSidecarClient
class ExternalConnectorChannel:
def __init__(
self,
*,
channel_id: str,
platform_kind: str,
connection_id: str,
account_id: str,
display_name: str,
sidecar_client: ConnectorSidecarClient | Any,
) -> None:
self.channel_id = channel_id
self.kind = "external_connector"
self.mode = "http"
self.platform_kind = platform_kind
self.connection_id = connection_id
self.account_id = account_id
self.display_name = display_name or channel_id
self.sidecar_client = sidecar_client
self.started = False
async def start(self) -> None:
self.started = True
async def stop(self) -> None:
self.started = False
async def send(self, message: OutboundMessage) -> None:
identity = message.channel_identity
if identity is None:
raise ValueError("channel_identity is required for external connector sends")
payload = {
"requestId": _request_id(message),
"connectionId": self.connection_id,
"channelId": self.channel_id,
"kind": self.platform_kind,
"target": {
"peerId": identity.peer_id,
"peerType": identity.peer_type,
"threadId": identity.thread_id,
},
"content": message.content,
"metadata": {
"inboundMessageId": identity.message_id,
"sessionId": message.session_id,
},
}
await self.sidecar_client.send(payload)
def _request_id(message: OutboundMessage) -> str:
return f"out_{message.message_id}"
```
- [ ] **Step 5: Export channel symbol**
Modify `app-instance/backend/beaver/interfaces/channels/__init__.py`:
```python
from .external_connector import ExternalConnectorChannel
```
Add `ExternalConnectorChannel` to `__all__`.
- [ ] **Step 6: Run channel tests**
Run:
```bash
cd app-instance/backend
uv run pytest tests/unit/test_external_connector_channel.py -q
```
Expected: `2 passed`.
- [ ] **Step 7: Commit Task 2**
```bash
git add app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py app-instance/backend/beaver/interfaces/channels/external_connector.py app-instance/backend/beaver/interfaces/channels/__init__.py app-instance/backend/tests/unit/test_external_connector_channel.py
git commit -m "feat: add external connector channel"
```
---
### Task 3: Dynamic Runtime Channels
### Task 2: Dynamic Runtime Channels
**Files:**
- Modify: `app-instance/backend/beaver/interfaces/channels/manager.py`
@ -716,7 +466,7 @@ Add methods to `ChannelRuntime`:
if current == config and channel_id in self.adapters:
return
if not config.enabled:
await self.remove_channel(channel_id)
await self._remove_channel_locked(channel_id)
self.channel_configs[channel_id] = config
self.states[channel_id] = {"state": "disabled", "last_error": None}
return
@ -733,16 +483,17 @@ Add methods to `ChannelRuntime`:
async def remove_channel(self, channel_id: str) -> None:
async with self._lifecycle_lock:
adapter = self.adapters.pop(channel_id, None)
self.manager.unregister(channel_id)
self.channel_configs.pop(channel_id, None)
if adapter is not None:
await adapter.stop()
self.events.record(channel_id=channel_id, kind="adapter_stopped")
self.states[channel_id] = {"state": "removed", "last_error": None}
```
await self._remove_channel_locked(channel_id)
If this direct implementation deadlocks because `add_channel()` calls `remove_channel()` under the same lock, split the locked removal body into a private `_remove_channel_locked()` helper and call that from both public methods.
async def _remove_channel_locked(self, channel_id: str) -> None:
adapter = self.adapters.pop(channel_id, None)
self.manager.unregister(channel_id)
self.channel_configs.pop(channel_id, None)
if adapter is not None:
await adapter.stop()
self.events.record(channel_id=channel_id, kind="adapter_stopped")
self.states[channel_id] = {"state": "removed", "last_error": None}
```
- [ ] **Step 5: Run dynamic runtime tests**
@ -755,7 +506,7 @@ uv run pytest tests/unit/test_channel_runtime_dynamic_channels.py tests/unit/tes
Expected: all listed tests pass.
- [ ] **Step 6: Commit Task 3**
- [ ] **Step 6: Commit Task 2**
```bash
git add app-instance/backend/beaver/interfaces/channels/manager.py app-instance/backend/beaver/interfaces/channels/runtime.py app-instance/backend/tests/unit/test_channel_runtime_dynamic_channels.py
@ -764,6 +515,301 @@ git commit -m "feat: support dynamic runtime channels"
---
### Task 3: External Connector Channel
**Files:**
- Create: `app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py`
- Create: `app-instance/backend/beaver/interfaces/channels/external_connector.py`
- Modify: `app-instance/backend/beaver/interfaces/channels/__init__.py`
- Test: `app-instance/backend/tests/unit/test_external_connector_channel.py`
- [ ] **Step 1: Write failing channel tests**
Create `app-instance/backend/tests/unit/test_external_connector_channel.py`:
```python
from __future__ import annotations
import asyncio
from beaver.foundation.events import ChannelIdentity, OutboundMessage
from beaver.interfaces.channels.external_connector import ExternalConnectorChannel, _request_id
class FakeSidecarClient:
def __init__(self) -> None:
self.sent: list[dict] = []
async def send(self, payload: dict) -> dict:
self.sent.append(payload)
return {"ok": True, "providerMessageId": "provider-1"}
def test_external_connector_channel_sends_with_target_and_request_id() -> None:
async def run() -> None:
client = FakeSidecarClient()
channel = ExternalConnectorChannel(
channel_id="weixin-main",
platform_kind="weixin",
connection_id="conn_1",
account_id="weixin:me",
display_name="Weixin Main",
sidecar_client=client,
)
message = OutboundMessage(
channel="weixin-main",
content="reply",
session_id="s1",
finish_reason="stop",
message_id="out-msg-1",
channel_identity=ChannelIdentity(
channel_id="weixin-main",
kind="weixin",
account_id="weixin:me",
peer_id="peer-1",
peer_type="dm",
thread_id=None,
user_id="sender-1",
message_id="in-msg-1",
),
)
await channel.send(message)
assert client.sent == [
{
"requestId": "out_weixin-main:s1:out-msg-1",
"connectionId": "conn_1",
"channelId": "weixin-main",
"kind": "weixin",
"target": {"peerId": "peer-1", "peerType": "dm", "threadId": None},
"content": "reply",
"metadata": {"inboundMessageId": "in-msg-1", "sessionId": "s1"},
}
]
asyncio.run(run())
def test_external_connector_request_id_falls_back_when_message_id_is_none_or_blank() -> None:
identity = ChannelIdentity(
channel_id="weixin-main",
kind="weixin",
account_id="weixin:me",
peer_id="peer-1",
peer_type="dm",
message_id="in-msg-1",
)
first = OutboundMessage(
channel="weixin-main",
content="same reply",
session_id="s1",
finish_reason="stop",
message_id=None, # type: ignore[arg-type]
channel_identity=identity,
)
second = OutboundMessage(
channel="weixin-main",
content="same reply",
session_id="s1",
finish_reason="stop",
message_id="",
channel_identity=identity,
)
assert _request_id(first) == _request_id(second)
assert _request_id(first).startswith("out_weixin-main:s1:")
def test_external_connector_channel_requires_identity() -> None:
async def run() -> None:
channel = ExternalConnectorChannel(
channel_id="weixin-main",
platform_kind="weixin",
connection_id="conn_1",
account_id="weixin:me",
display_name="Weixin Main",
sidecar_client=FakeSidecarClient(),
)
message = OutboundMessage(channel="weixin-main", content="reply", session_id="s1", finish_reason="stop")
try:
await channel.send(message)
except ValueError as exc:
assert "channel_identity is required" in str(exc)
else:
raise AssertionError("Expected ValueError")
asyncio.run(run())
```
- [ ] **Step 2: Run tests to verify failure**
Run:
```bash
cd app-instance/backend
uv run pytest tests/unit/test_external_connector_channel.py -q
```
Expected: fail with `ModuleNotFoundError: No module named 'beaver.interfaces.channels.external_connector'`.
- [ ] **Step 3: Implement sidecar client**
Create `app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py`:
```python
from __future__ import annotations
import hashlib
from typing import Any
import httpx
class ConnectorSidecarClient:
def __init__(self, *, base_url: str, token: str, timeout_seconds: float = 20.0) -> None:
self.base_url = base_url.rstrip("/")
self.token = token
self.timeout_seconds = float(timeout_seconds)
async def get_connectors(self) -> list[dict[str, Any]]:
return await self._request("GET", "/connectors")
async def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
return await self._request("POST", "/connector-sessions", json=payload)
async def get_session(self, session_id: str) -> dict[str, Any]:
return await self._request("GET", f"/connector-sessions/{session_id}")
async def cancel_session(self, session_id: str) -> dict[str, Any]:
return await self._request("POST", f"/connector-sessions/{session_id}/cancel", json={})
async def logout(self, connection_id: str) -> dict[str, Any]:
return await self._request("POST", f"/connections/{connection_id}/logout", json={})
async def send(self, payload: dict[str, Any]) -> dict[str, Any]:
return await self._request("POST", "/send", json=payload)
async def _request(self, method: str, path: str, *, json: dict[str, Any] | None = None) -> Any:
headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
response = await client.request(method, f"{self.base_url}{path}", json=json, headers=headers)
response.raise_for_status()
return response.json()
```
- [ ] **Step 4: Implement external channel**
Create `app-instance/backend/beaver/interfaces/channels/external_connector.py`:
```python
from __future__ import annotations
from typing import Any
from beaver.foundation.events import OutboundMessage
from beaver.interfaces.channels.connections.sidecar_client import ConnectorSidecarClient
class ExternalConnectorChannel:
def __init__(
self,
*,
channel_id: str,
platform_kind: str,
connection_id: str,
account_id: str,
display_name: str,
sidecar_client: ConnectorSidecarClient | Any,
) -> None:
self.channel_id = channel_id
self.kind = "external_connector"
self.mode = "http"
self.platform_kind = platform_kind
self.connection_id = connection_id
self.account_id = account_id
self.display_name = display_name or channel_id
self.sidecar_client = sidecar_client
self.started = False
async def start(self) -> None:
self.started = True
async def stop(self) -> None:
self.started = False
async def send(self, message: OutboundMessage) -> None:
identity = message.channel_identity
if identity is None:
raise ValueError("channel_identity is required for external connector sends")
payload = {
"requestId": _request_id(message),
"connectionId": self.connection_id,
"channelId": self.channel_id,
"kind": self.platform_kind,
"target": {
"peerId": identity.peer_id,
"peerType": identity.peer_type,
"threadId": identity.thread_id,
},
"content": message.content,
"metadata": {
"inboundMessageId": identity.message_id,
"sessionId": message.session_id,
},
}
await self.sidecar_client.send(payload)
def _request_id(message: OutboundMessage) -> str:
identity = message.channel_identity
channel = message.channel or (identity.channel_id if identity else "unknown")
session_id = message.session_id or (identity.session_id() if identity else "unknown")
message_id = str(message.message_id or "").strip()
if not message_id:
basis = "|".join(
[
message.content,
identity.message_id if identity and identity.message_id else "",
identity.peer_id if identity else "",
message.finish_reason,
]
)
message_id = hashlib.sha256(basis.encode("utf-8")).hexdigest()[:24]
return f"out_{channel}:{session_id}:{message_id}"
```
- [ ] **Step 5: Export channel symbol**
Modify `app-instance/backend/beaver/interfaces/channels/__init__.py`:
```python
from .external_connector import ExternalConnectorChannel
```
Add `ExternalConnectorChannel` to `__all__`.
- [ ] **Step 6: Run channel tests**
Run:
```bash
cd app-instance/backend
uv run pytest tests/unit/test_external_connector_channel.py -q
```
Expected: `2 passed`.
- [ ] **Step 7: Commit Task 3**
```bash
git add app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py app-instance/backend/beaver/interfaces/channels/external_connector.py app-instance/backend/beaver/interfaces/channels/__init__.py app-instance/backend/tests/unit/test_external_connector_channel.py
git commit -m "feat: add external connector channel"
```
---
### Task 4: Runtime Factory For External Connector Channel
**Files:**