1803 lines
65 KiB
Markdown
1803 lines
65 KiB
Markdown
# Channel Connectors Foundation 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:** Build the first connector slice: durable channel connections, pairing/credential primitives, connector registry, Telegram token connector, and backend APIs that materialize a runtime channel without manual JSON editing.
|
|
|
|
**Architecture:** Add a connector layer under `beaver/interfaces/channels/connections/` while keeping `ChannelRuntime`, `MessageBus`, and existing adapters as the message path. A `ChannelConnectionStore` persists setup state, a small credential vault stores secrets by reference, and `ChannelConnectorRegistry` materializes enabled connections into `ChannelConfig` objects during app startup. The first concrete connector is Telegram because token validation and runtime materialization are simple and testable with fake clients.
|
|
|
|
**Weixin Follow-Up Constraint:** Weixin personal-account support is not implemented in this foundation slice. The follow-up Weixin plan must use a docker-compose predeclared sidecar service and Beaver must only call the existing connector HTTP API; Beaver must not dynamically create containers or require Docker socket access. Because the sidecar owns Weixin protocol, QR login, receive, and send behavior, Beaver should expose it to `ChannelRuntime` as an `ExternalConnectorChannel`, not as a protocol-level `WeixinAdapter`.
|
|
|
|
**Tech Stack:** Python dataclasses, FastAPI, Pydantic v2, local JSON stores, pytest, existing Beaver channel runtime.
|
|
|
|
---
|
|
|
|
## Scope
|
|
|
|
This plan implements phase 1 of `docs/superpowers/specs/2026-06-02-channel-connectors-and-pairing-design.md`.
|
|
|
|
Included:
|
|
|
|
- `ChannelConnection` data model and persistent JSON store.
|
|
- Restricted local credential store with secret redaction.
|
|
- One-time pairing token store, used now by tests and future terminal/QR connectors. It is implemented in this phase but not exposed through APIs; future terminal and QR connectors will consume it.
|
|
- Connector protocol and registry.
|
|
- Telegram connector with fake-client test hooks.
|
|
- Connection control APIs.
|
|
- App startup materialization from connections into `ChannelRuntime`.
|
|
|
|
Excluded from this plan:
|
|
|
|
- Terminal authenticated pairing.
|
|
- Feishu/Lark official SDK integration.
|
|
- Weixin docker-compose sidecar pairing and bridge implementation. The later Weixin plan must use a predeclared sidecar service plus Beaver HTTP bridge endpoints, not local host dependencies, dynamic container creation, or a Beaver-owned Weixin protocol adapter.
|
|
- QQBot connector.
|
|
- Frontend connection wizard.
|
|
- Hot starting/stopping adapters without backend restart.
|
|
- Multi-process-safe storage. The JSON stores use `threading.Lock` plus atomic file replace for the single backend process used in phase 1. Production multi-worker deployment needs a file lock or database-backed store.
|
|
- Credential garbage collection. Updating secrets writes a new credential reference and leaves the old reference in the local credential file until a later cleanup pass.
|
|
|
|
## File Structure
|
|
|
|
- Create `app-instance/backend/beaver/interfaces/channels/connections/__init__.py`
|
|
- Exports connection models, stores, connector registry, and Telegram connector.
|
|
- Create `app-instance/backend/beaver/interfaces/channels/connections/models.py`
|
|
- Dataclasses and constants for `ChannelConnection`, `PairingSession`, `ChannelRuntimeSpec`, `ValidationResult`.
|
|
- Create `app-instance/backend/beaver/interfaces/channels/connections/store.py`
|
|
- JSON-backed `ChannelConnectionStore`, `CredentialStore`, and `PairingTokenStore`.
|
|
- Create `app-instance/backend/beaver/interfaces/channels/connections/connectors.py`
|
|
- `ChannelConnector` protocol and `ChannelConnectorRegistry`.
|
|
- Create `app-instance/backend/beaver/interfaces/channels/connections/telegram.py`
|
|
- Telegram token connector that validates via injected client factory and materializes a runtime spec.
|
|
- Modify `app-instance/backend/beaver/interfaces/web/schemas/chat.py`
|
|
- Add Pydantic request/response models for connection APIs.
|
|
- Modify `app-instance/backend/beaver/interfaces/web/schemas/__init__.py`
|
|
- Export the new schemas.
|
|
- Modify `app-instance/backend/beaver/interfaces/web/app.py`
|
|
- Instantiate connection stores/registry, expose `/api/channel-connectors` and `/api/channel-connections` APIs, and merge materialized connection configs into runtime startup.
|
|
- Create `app-instance/backend/tests/unit/test_channel_connection_store.py`
|
|
- Store, credential redaction, and pairing token tests.
|
|
- Create `app-instance/backend/tests/unit/test_channel_connector_registry.py`
|
|
- Registry dispatch and runtime materialization tests.
|
|
- Create `app-instance/backend/tests/unit/test_telegram_channel_connector.py`
|
|
- Telegram validation/materialization tests with fake client.
|
|
- Create `app-instance/backend/tests/unit/test_channel_connection_api.py`
|
|
- FastAPI endpoint tests with fake service/app context.
|
|
|
|
---
|
|
|
|
### Task 1: Connection Models And Store
|
|
|
|
**Files:**
|
|
- Create: `app-instance/backend/beaver/interfaces/channels/connections/__init__.py`
|
|
- Create: `app-instance/backend/beaver/interfaces/channels/connections/models.py`
|
|
- Create: `app-instance/backend/beaver/interfaces/channels/connections/store.py`
|
|
- Test: `app-instance/backend/tests/unit/test_channel_connection_store.py`
|
|
|
|
- [ ] **Step 1: Write failing store tests**
|
|
|
|
Create `app-instance/backend/tests/unit/test_channel_connection_store.py`:
|
|
|
|
```python
|
|
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
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd app-instance/backend
|
|
uv run pytest tests/unit/test_channel_connection_store.py -q
|
|
```
|
|
|
|
Expected: fail during import with `ModuleNotFoundError: No module named 'beaver.interfaces.channels.connections'`.
|
|
|
|
- [ ] **Step 3: Implement connection dataclasses**
|
|
|
|
Create `app-instance/backend/beaver/interfaces/channels/connections/models.py`:
|
|
|
|
```python
|
|
"""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
|
|
```
|
|
|
|
- [ ] **Step 4: Implement JSON stores**
|
|
|
|
Create `app-instance/backend/beaver/interfaces/channels/connections/store.py`:
|
|
|
|
```python
|
|
"""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"
|
|
```
|
|
|
|
- [ ] **Step 5: Export the connection package**
|
|
|
|
Create `app-instance/backend/beaver/interfaces/channels/connections/__init__.py`:
|
|
|
|
```python
|
|
"""Channel connection setup layer."""
|
|
|
|
from .models import ChannelConnection, ChannelRuntimeSpec, PairingSession, ValidationResult
|
|
from .store import ChannelConnectionStore, CredentialStore, PairingTokenStore
|
|
|
|
__all__ = [
|
|
"ChannelConnection",
|
|
"ChannelRuntimeSpec",
|
|
"PairingSession",
|
|
"ValidationResult",
|
|
"ChannelConnectionStore",
|
|
"CredentialStore",
|
|
"PairingTokenStore",
|
|
]
|
|
```
|
|
|
|
- [ ] **Step 6: Run store tests to verify they pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd app-instance/backend
|
|
uv run pytest tests/unit/test_channel_connection_store.py -q
|
|
```
|
|
|
|
Expected: `4 passed`.
|
|
|
|
- [ ] **Step 7: Commit Task 1**
|
|
|
|
```bash
|
|
git add app-instance/backend/beaver/interfaces/channels/connections app-instance/backend/tests/unit/test_channel_connection_store.py
|
|
git commit -m "feat: add channel connection store"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Connector Registry
|
|
|
|
**Files:**
|
|
- Create: `app-instance/backend/beaver/interfaces/channels/connections/connectors.py`
|
|
- Modify: `app-instance/backend/beaver/interfaces/channels/connections/__init__.py`
|
|
- Test: `app-instance/backend/tests/unit/test_channel_connector_registry.py`
|
|
|
|
- [ ] **Step 1: Write failing registry tests**
|
|
|
|
Create `app-instance/backend/tests/unit/test_channel_connector_registry.py`:
|
|
|
|
```python
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
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_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())
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd app-instance/backend
|
|
uv run pytest tests/unit/test_channel_connector_registry.py -q
|
|
```
|
|
|
|
Expected: fail with `ImportError: cannot import name 'ChannelConnectorRegistry'`.
|
|
|
|
- [ ] **Step 3: Implement connector protocol and registry**
|
|
|
|
Create `app-instance/backend/beaver/interfaces/channels/connections/connectors.py`:
|
|
|
|
```python
|
|
"""Channel connector registry."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Protocol
|
|
|
|
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)]
|
|
|
|
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 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
|
|
```
|
|
|
|
- [ ] **Step 4: Export registry symbols**
|
|
|
|
Modify `app-instance/backend/beaver/interfaces/channels/connections/__init__.py`:
|
|
|
|
```python
|
|
"""Channel connection setup layer."""
|
|
|
|
from .connectors import ChannelConnector, ChannelConnectorRegistry
|
|
from .models import ChannelConnection, ChannelRuntimeSpec, PairingSession, ValidationResult
|
|
from .store import ChannelConnectionStore, CredentialStore, PairingTokenStore
|
|
|
|
__all__ = [
|
|
"ChannelConnector",
|
|
"ChannelConnectorRegistry",
|
|
"ChannelConnection",
|
|
"ChannelRuntimeSpec",
|
|
"PairingSession",
|
|
"ValidationResult",
|
|
"ChannelConnectionStore",
|
|
"CredentialStore",
|
|
"PairingTokenStore",
|
|
]
|
|
```
|
|
|
|
- [ ] **Step 5: Run registry tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd app-instance/backend
|
|
uv run pytest tests/unit/test_channel_connector_registry.py -q
|
|
```
|
|
|
|
Expected: `3 passed`.
|
|
|
|
- [ ] **Step 6: Commit Task 2**
|
|
|
|
```bash
|
|
git add app-instance/backend/beaver/interfaces/channels/connections app-instance/backend/tests/unit/test_channel_connector_registry.py
|
|
git commit -m "feat: add channel connector registry"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Telegram Connector
|
|
|
|
**Files:**
|
|
- Create: `app-instance/backend/beaver/interfaces/channels/connections/telegram.py`
|
|
- Modify: `app-instance/backend/beaver/interfaces/channels/connections/__init__.py`
|
|
- Test: `app-instance/backend/tests/unit/test_telegram_channel_connector.py`
|
|
|
|
- [ ] **Step 1: Write failing Telegram connector tests**
|
|
|
|
Create `app-instance/backend/tests/unit/test_telegram_channel_connector.py`:
|
|
|
|
```python
|
|
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())
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd app-instance/backend
|
|
uv run pytest tests/unit/test_telegram_channel_connector.py -q
|
|
```
|
|
|
|
Expected: fail with `ImportError: cannot import name 'TelegramConnector'`.
|
|
|
|
- [ ] **Step 3: Verify Telegram dependency**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd app-instance/backend
|
|
rg -n "python-telegram-bot" pyproject.toml uv.lock | sed -n '1,20p'
|
|
```
|
|
|
|
Expected output includes `python-telegram-bot>=22.0,<23.0`. The default client factory may use `from telegram import Bot`, and `Bot.get_me()` is awaitable in this dependency line.
|
|
|
|
- [ ] **Step 4: Implement TelegramConnector**
|
|
|
|
Create `app-instance/backend/beaver/interfaces/channels/connections/telegram.py`:
|
|
|
|
```python
|
|
"""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)
|
|
```
|
|
|
|
- [ ] **Step 5: Export TelegramConnector**
|
|
|
|
Modify `app-instance/backend/beaver/interfaces/channels/connections/__init__.py`:
|
|
|
|
```python
|
|
"""Channel connection setup layer."""
|
|
|
|
from .connectors import ChannelConnector, ChannelConnectorRegistry
|
|
from .models import ChannelConnection, ChannelRuntimeSpec, PairingSession, ValidationResult
|
|
from .store import ChannelConnectionStore, CredentialStore, PairingTokenStore
|
|
from .telegram import TelegramConnector
|
|
|
|
__all__ = [
|
|
"ChannelConnector",
|
|
"ChannelConnectorRegistry",
|
|
"ChannelConnection",
|
|
"ChannelRuntimeSpec",
|
|
"PairingSession",
|
|
"ValidationResult",
|
|
"ChannelConnectionStore",
|
|
"CredentialStore",
|
|
"PairingTokenStore",
|
|
"TelegramConnector",
|
|
]
|
|
```
|
|
|
|
- [ ] **Step 6: Run Telegram connector tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd app-instance/backend
|
|
uv run pytest tests/unit/test_telegram_channel_connector.py -q
|
|
```
|
|
|
|
Expected: `4 passed`.
|
|
|
|
- [ ] **Step 7: Commit Task 3**
|
|
|
|
```bash
|
|
git add app-instance/backend/beaver/interfaces/channels/connections app-instance/backend/tests/unit/test_telegram_channel_connector.py
|
|
git commit -m "feat: add telegram channel connector"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Runtime Materialization From Connections
|
|
|
|
**Files:**
|
|
- Modify: `app-instance/backend/beaver/interfaces/web/app.py`
|
|
- Test: `app-instance/backend/tests/unit/test_channel_connector_registry.py`
|
|
|
|
- [ ] **Step 1: Verify ChannelConfig fields**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd app-instance/backend
|
|
uv run python - <<'PY'
|
|
from dataclasses import fields
|
|
from beaver.foundation.config.schema import ChannelConfig
|
|
print([field.name for field in fields(ChannelConfig)])
|
|
PY
|
|
```
|
|
|
|
Expected output includes `enabled`, `kind`, `mode`, `account_id`, `display_name`, `config`, and `secrets`.
|
|
|
|
- [ ] **Step 2: Extend registry tests for ChannelConfig materialization**
|
|
|
|
Append to `app-instance/backend/tests/unit/test_channel_connector_registry.py`:
|
|
|
|
```python
|
|
from beaver.foundation.config.schema import ChannelConfig
|
|
|
|
|
|
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())
|
|
```
|
|
|
|
- [ ] **Step 3: Run registry tests to verify failure**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd app-instance/backend
|
|
uv run pytest tests/unit/test_channel_connector_registry.py::test_connector_registry_materializes_channel_configs_with_credentials -q
|
|
```
|
|
|
|
Expected: fail with `AttributeError: 'ChannelConnectorRegistry' object has no attribute 'materialize_channel_configs'`.
|
|
|
|
- [ ] **Step 4: Implement channel config materialization**
|
|
|
|
Modify `app-instance/backend/beaver/interfaces/channels/connections/connectors.py`:
|
|
|
|
```python
|
|
from beaver.foundation.config.schema import ChannelConfig
|
|
```
|
|
|
|
Add this method to `ChannelConnectorRegistry`:
|
|
|
|
```python
|
|
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
|
|
```
|
|
|
|
- [ ] **Step 5: Add app helpers for connection state paths and registry construction**
|
|
|
|
Modify `app-instance/backend/beaver/interfaces/web/app.py` imports:
|
|
|
|
```python
|
|
from beaver.interfaces.channels.connections import (
|
|
ChannelConnectionStore,
|
|
ChannelConnectorRegistry,
|
|
CredentialStore,
|
|
TelegramConnector,
|
|
)
|
|
```
|
|
|
|
Add helper functions near `get_channel_runtime()`:
|
|
|
|
```python
|
|
def _connection_state_dir(workspace: Path) -> Path:
|
|
return Path(workspace) / "state" / "channel_connections"
|
|
|
|
|
|
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,
|
|
)
|
|
)
|
|
return registry
|
|
```
|
|
|
|
- [ ] **Step 6: Merge materialized connections into runtime startup**
|
|
|
|
Modify the lifespan block in `app-instance/backend/beaver/interfaces/web/app.py` where `ChannelRuntime` is created:
|
|
|
|
```python
|
|
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,
|
|
)
|
|
```
|
|
|
|
Keep `app.state.channel_connector_registry = connector_registry` before runtime startup so API handlers can reuse the same stores.
|
|
|
|
- [ ] **Step 7: Run registry tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd app-instance/backend
|
|
uv run pytest tests/unit/test_channel_connector_registry.py -q
|
|
```
|
|
|
|
Expected: all tests pass.
|
|
|
|
- [ ] **Step 8: Commit Task 4**
|
|
|
|
```bash
|
|
git add app-instance/backend/beaver/interfaces/channels/connections/connectors.py app-instance/backend/beaver/interfaces/web/app.py app-instance/backend/tests/unit/test_channel_connector_registry.py
|
|
git commit -m "feat: materialize channel connections into runtime config"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Connection Control API
|
|
|
|
**Files:**
|
|
- Modify: `app-instance/backend/beaver/interfaces/web/schemas/chat.py`
|
|
- Modify: `app-instance/backend/beaver/interfaces/web/schemas/__init__.py`
|
|
- Modify: `app-instance/backend/beaver/interfaces/web/app.py`
|
|
- Test: `app-instance/backend/tests/unit/test_channel_connection_api.py`
|
|
|
|
- [ ] **Step 1: Write failing API tests**
|
|
|
|
Create `app-instance/backend/tests/unit/test_channel_connection_api.py`:
|
|
|
|
```python
|
|
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": "telegram"}]
|
|
```
|
|
|
|
- [ ] **Step 2: Run API tests to verify failure**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd app-instance/backend
|
|
uv run pytest tests/unit/test_channel_connection_api.py -q
|
|
```
|
|
|
|
Expected: fail with `404 Not Found` for `/api/channel-connections`.
|
|
|
|
- [ ] **Step 3: Add web schemas**
|
|
|
|
Append to `app-instance/backend/beaver/interfaces/web/schemas/chat.py` after `WebChannelConfigResponse`:
|
|
|
|
```python
|
|
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]
|
|
```
|
|
|
|
- [ ] **Step 4: Export web schemas**
|
|
|
|
Modify `app-instance/backend/beaver/interfaces/web/schemas/__init__.py` imports and `__all__` to include:
|
|
|
|
```python
|
|
WebChannelConnectionCreateRequest,
|
|
WebChannelConnectionResponse,
|
|
WebChannelConnectionUpdateRequest,
|
|
WebChannelValidationResponse,
|
|
```
|
|
|
|
- [ ] **Step 5: Add connector registry accessors to app.py**
|
|
|
|
Modify imports in `app-instance/backend/beaver/interfaces/web/app.py`:
|
|
|
|
```python
|
|
from beaver.interfaces.web.schemas import (
|
|
WebChannelConnectionCreateRequest,
|
|
WebChannelConnectionResponse,
|
|
WebChannelConnectionUpdateRequest,
|
|
WebChannelValidationResponse,
|
|
)
|
|
```
|
|
|
|
Add helper:
|
|
|
|
```python
|
|
def get_channel_connector_registry(request: Request) -> ChannelConnectorRegistry:
|
|
registry = getattr(request.app.state, "channel_connector_registry", None)
|
|
if not isinstance(registry, ChannelConnectorRegistry):
|
|
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 _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:
|
|
if char.isupper() and result:
|
|
result.append("_")
|
|
result.append(char.lower())
|
|
return "".join(result)
|
|
|
|
|
|
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
|
|
```
|
|
|
|
- [ ] **Step 6: Add connection API routes**
|
|
|
|
Add routes near existing `/api/channels` routes in `app-instance/backend/beaver/interfaces/web/app.py`:
|
|
|
|
```python
|
|
@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 = {key: value for key, value in payload.secrets.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={})
|
|
```
|
|
|
|
- [ ] **Step 7: Run API tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd app-instance/backend
|
|
uv run pytest tests/unit/test_channel_connection_api.py -q
|
|
```
|
|
|
|
Expected: `2 passed`.
|
|
|
|
- [ ] **Step 8: Run focused backend tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd app-instance/backend
|
|
uv run pytest \
|
|
tests/unit/test_channel_connection_store.py \
|
|
tests/unit/test_channel_connector_registry.py \
|
|
tests/unit/test_telegram_channel_connector.py \
|
|
tests/unit/test_channel_connection_api.py \
|
|
-q
|
|
```
|
|
|
|
Expected: all focused connector tests pass.
|
|
|
|
- [ ] **Step 9: Commit Task 5**
|
|
|
|
```bash
|
|
git add \
|
|
app-instance/backend/beaver/interfaces/web/app.py \
|
|
app-instance/backend/beaver/interfaces/web/schemas/chat.py \
|
|
app-instance/backend/beaver/interfaces/web/schemas/__init__.py \
|
|
app-instance/backend/tests/unit/test_channel_connection_api.py
|
|
git commit -m "feat: add channel connection control api"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Final Verification And Spec Alignment
|
|
|
|
**Files:**
|
|
- Review: `docs/superpowers/specs/2026-06-02-channel-connectors-and-pairing-design.md`
|
|
- Review: `docs/superpowers/specs/2026-06-02-chat-platform-channel-adapters-design.md`
|
|
- Review: `docs/superpowers/specs/2026-06-01-terminal-websocket-channel-design.md`
|
|
|
|
- [ ] **Step 1: Run connector and existing channel tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd app-instance/backend
|
|
uv run pytest \
|
|
tests/unit/test_channel_connection_store.py \
|
|
tests/unit/test_channel_connector_registry.py \
|
|
tests/unit/test_telegram_channel_connector.py \
|
|
tests/unit/test_channel_connection_api.py \
|
|
tests/unit/test_channel_runtime.py \
|
|
tests/unit/test_telegram_channel_adapter.py \
|
|
-q
|
|
```
|
|
|
|
Expected: all listed tests pass.
|
|
|
|
- [ ] **Step 2: Run import tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd app-instance/backend
|
|
uv run pytest tests/unit/test_imports.py -q
|
|
```
|
|
|
|
Expected: all import tests pass.
|
|
|
|
- [ ] **Step 3: Scan for leaked secret values in connector events and responses**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd app-instance/backend
|
|
rg -n "token-1|token-2|bad-token|secret-token" tests/unit beaver || true
|
|
```
|
|
|
|
Expected: test fixture strings only appear in test files. They must not appear in implementation files or generated event log code.
|
|
|
|
- [ ] **Step 4: Update connector and adapter spec wording if still contradictory**
|
|
|
|
If `docs/superpowers/specs/2026-06-02-channel-connectors-and-pairing-design.md` still says Weixin uses a local plugin installer, dynamically launched connector process, or `ChannelRuntime external adapter`, change only the Weixin/external-process wording to this architecture:
|
|
|
|
```markdown
|
|
Weixin personal-account support uses a docker-compose predeclared sidecar connector. Beaver calls the sidecar's existing HTTP API and must not dynamically create containers or require Docker socket access.
|
|
```
|
|
|
|
```markdown
|
|
For Weixin, the sidecar owns the platform protocol, QR login, inbound receive loop, outbound send, and login-state persistence. Beaver exposes it to the runtime through an `ExternalConnectorChannel`: inbound sidecar webhooks call Beaver's connector bridge endpoint, which submits normalized messages to `ChannelRuntime.accept_inbound()`, while outbound runtime messages call the sidecar `/send` API.
|
|
```
|
|
|
|
If `docs/superpowers/specs/2026-06-02-chat-platform-channel-adapters-design.md` still describes `WeixinAdapter` as the Beaver-owned protocol adapter, change only the Weixin adapter scope and access-control text:
|
|
|
|
```markdown
|
|
- Use internal adapters by default, but allow external connector processes where platform SDK or login state requires them.
|
|
```
|
|
|
|
```markdown
|
|
Pairing is owned by the connector layer. Platform adapters assume a materialized `ChannelConnection` and adapter-ready runtime config.
|
|
```
|
|
|
|
```markdown
|
|
For Weixin personal-account support, the runtime channel is an `ExternalConnectorChannel`, not a Beaver-owned `WeixinAdapter`. The docker-compose sidecar is the Weixin protocol adapter; Beaver only owns connector setup state, bridge API validation, message normalization boundaries, runtime dedupe, and outbound HTTP calls to the sidecar.
|
|
```
|
|
|
|
- [ ] **Step 5: Commit spec alignment if changed**
|
|
|
|
If Step 4 changed docs:
|
|
|
|
```bash
|
|
git add \
|
|
docs/superpowers/specs/2026-06-02-channel-connectors-and-pairing-design.md \
|
|
docs/superpowers/specs/2026-06-02-chat-platform-channel-adapters-design.md
|
|
git commit -m "docs: align channel specs with external connector channels"
|
|
```
|
|
|
|
If Step 4 made no change, do not create an empty commit.
|
|
|
|
- [ ] **Step 6: Summarize remaining rollout**
|
|
|
|
Record in the final implementation response that this first plan does not implement Terminal pairing, Feishu/Lark connector, Weixin docker-compose sidecar pairing/bridge, QQBot connector, frontend wizard, or hot adapter restart. Those are separate plans.
|
|
|
|
For Weixin specifically, record the agreed follow-up architecture:
|
|
|
|
```text
|
|
Weixin sidecar connector
|
|
-> Beaver connector bridge endpoint
|
|
-> ChannelRuntime.accept_inbound()
|
|
-> MessageBus
|
|
-> AgentService
|
|
|
|
AgentService
|
|
-> MessageBus outbound
|
|
-> ExternalConnectorChannel.send()
|
|
-> Weixin sidecar connector /send
|
|
```
|
|
|
|
Do not describe the follow-up path as `sidecar -> WeixinAdapter -> ChannelRuntime`. The sidecar is the Weixin protocol adapter; Beaver's runtime object should be named `ExternalConnectorChannel` or an equivalently generic connector-bridge channel.
|