63 KiB
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.
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:
ChannelConnectiondata 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 external connector process.
- QQBot connector.
- Frontend connection wizard.
- Hot starting/stopping adapters without backend restart.
- Multi-process-safe storage. The JSON stores use
threading.Lockplus 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.
- Dataclasses and constants for
- Create
app-instance/backend/beaver/interfaces/channels/connections/store.py- JSON-backed
ChannelConnectionStore,CredentialStore, andPairingTokenStore.
- JSON-backed
- Create
app-instance/backend/beaver/interfaces/channels/connections/connectors.pyChannelConnectorprotocol andChannelConnectorRegistry.
- 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-connectorsand/api/channel-connectionsAPIs, and merge materialized connection configs into runtime startup.
- Instantiate connection stores/registry, expose
- 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:
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:
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:
"""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:
"""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:
"""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:
cd app-instance/backend
uv run pytest tests/unit/test_channel_connection_store.py -q
Expected: 4 passed.
- Step 7: Commit Task 1
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:
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:
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:
"""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:
"""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:
cd app-instance/backend
uv run pytest tests/unit/test_channel_connector_registry.py -q
Expected: 3 passed.
- Step 6: Commit Task 2
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:
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:
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:
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:
"""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:
"""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:
cd app-instance/backend
uv run pytest tests/unit/test_telegram_channel_connector.py -q
Expected: 4 passed.
- Step 7: Commit Task 3
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:
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:
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:
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:
from beaver.foundation.config.schema import ChannelConfig
Add this method to ChannelConnectorRegistry:
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:
from beaver.interfaces.channels.connections import (
ChannelConnectionStore,
ChannelConnectorRegistry,
CredentialStore,
TelegramConnector,
)
Add helper functions near get_channel_runtime():
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:
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:
cd app-instance/backend
uv run pytest tests/unit/test_channel_connector_registry.py -q
Expected: all tests pass.
- Step 8: Commit Task 4
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:
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:
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:
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:
WebChannelConnectionCreateRequest,
WebChannelConnectionResponse,
WebChannelConnectionUpdateRequest,
WebChannelValidationResponse,
- Step 5: Add connector registry accessors to app.py
Modify imports in app-instance/backend/beaver/interfaces/web/app.py:
from beaver.interfaces.web.schemas import (
WebChannelConnectionCreateRequest,
WebChannelConnectionResponse,
WebChannelConnectionUpdateRequest,
WebChannelValidationResponse,
)
Add helper:
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:
@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:
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:
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
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:
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:
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:
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 adapter spec wording if still contradictory
If docs/superpowers/specs/2026-06-02-chat-platform-channel-adapters-design.md still says pairing is out of scope and Node sidecars are disallowed, change only the Non-Goals and Access Control text:
- Use internal adapters by default, but allow external connector processes where platform SDK or login state requires them.
Pairing is owned by the connector layer. Platform adapters assume a materialized `ChannelConnection` and adapter-ready runtime config.
- Step 5: Commit spec alignment if changed
If Step 4 changed docs:
git add docs/superpowers/specs/2026-06-02-chat-platform-channel-adapters-design.md
git commit -m "docs: align channel adapter spec with connector layer"
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 external connector, QQBot connector, frontend wizard, or hot adapter restart. Those are separate plans.