docs: harden external connector implementation plans
This commit is contained in:
@ -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:**
|
||||
|
||||
Reference in New Issue
Block a user