# Chat Platform Channel Adapters Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` for implementation across the independent adapter tasks, or `superpowers:executing-plans` for inline execution. Track progress with the checkbox steps below.

**Goal:** Add first-class Beaver adapters for `telegram`, `feishu`, `qqbot`, and `weixin`, each plugged into `ChannelRuntime`, normalizing inbound platform events into `InboundMessage` with `ChannelIdentity`, and delivering final `OutboundMessage` replies back to the original platform conversation.

**Architecture:** Platform adapters are hosted in the backend process. They own transport and platform delivery details. `ChannelRuntime` remains the owner of lifecycle, dedupe, event logging, bus routing, and agent dispatch.

```text
platform SDK/API
-> {Channel}Adapter
-> ChannelRuntime.accept_inbound()
-> MessageBus.inbound
-> ChannelRuntime bridge
-> AgentService.handle_inbound_message()
-> MessageBus.outbound
-> ChannelManager.dispatch_outbound()
-> {Channel}Adapter.send()
-> platform SDK/API
```

## Locked Decisions

- Implement these adapters only: `FeishuAdapter`, `QQBotAdapter`, `TelegramAdapter`, `WeixinAdapter`.
- Do not add WhatsApp in this phase.
- Do not add a sidecar process as the default channel path.
- Keep adapter class names platform-only: `FeishuAdapter`, `QQBotAdapter`, `TelegramAdapter`, `WeixinAdapter`.
- Keep platform imports lazy, so backend imports and unrelated channels work without every optional package installed.
- Adapter constructors must accept injectable clients or factories for unit tests.
- Unit tests must not call real platform networks.
- Runtime adapter selection is by existing `ChannelConfig.kind` and `ChannelConfig.mode`.
- Inbound messages must include `ChannelIdentity`.
- Outbound send targets must prefer `message.channel_identity`, then fall back to the normalized `message.session_id`.
- Unsupported or incomplete media appears in content and compact metadata instead of being silently dropped.
- Streaming token deltas, pairing flows, hot reload, and platform config UI are not included.
- Weixin group delivery is best-effort only and is not listed as a stable group capability.

## File Structure

- Create: `app-instance/backend/beaver/interfaces/channels/platforms/__init__.py`
- Create: `app-instance/backend/beaver/interfaces/channels/platforms/base.py`
- Create: `app-instance/backend/beaver/interfaces/channels/platforms/telegram.py`
- Create: `app-instance/backend/beaver/interfaces/channels/platforms/feishu.py`
- Create: `app-instance/backend/beaver/interfaces/channels/platforms/qqbot.py`
- Create: `app-instance/backend/beaver/interfaces/channels/platforms/weixin.py`
- Modify: `app-instance/backend/beaver/interfaces/channels/runtime.py`
- Modify: `app-instance/backend/pyproject.toml`
- Create: `app-instance/backend/tests/unit/test_platform_channel_helpers.py`
- Create: `app-instance/backend/tests/unit/test_telegram_channel_adapter.py`
- Create: `app-instance/backend/tests/unit/test_feishu_channel_adapter.py`
- Create: `app-instance/backend/tests/unit/test_qqbot_channel_adapter.py`
- Create: `app-instance/backend/tests/unit/test_weixin_channel_adapter.py`
- Modify: `app-instance/backend/tests/unit/test_channel_runtime.py`
- Modify: `app-instance/backend/tests/unit/test_imports.py`

## Dependency Strategy

Add optional extras for live adapters. Do not put these packages in the base backend dependency list.

```toml
[project.optional-dependencies]
dev = [
    "pytest>=9.0.0,<10.0.0",
]
telegram = [
    "python-telegram-bot>=22.0,<23.0",
]
feishu = [
    "lark-oapi>=1.4.22,<2.0.0",
]
qqbot = [
    "aiohttp>=3.9.0,<4.0.0",
]
weixin = [
    "aiohttp>=3.9.0,<4.0.0",
]
channels = [
    "python-telegram-bot>=22.0,<23.0",
    "lark-oapi>=1.4.22,<2.0.0",
    "aiohttp>=3.9.0,<4.0.0",
]
```

The plan keeps `httpx` as the default HTTP client where it is already sufficient. Use `aiohttp` only for platform SDK paths that require it.

## Shared Data Rules

All normalized identities use:

```text
<channel_id>:<account_id>:<peer_id>[:<thread_id>]
```

Outbound target resolution:

```python
def outbound_target(message: OutboundMessage) -> OutboundTarget:
    if message.channel_identity is not None:
        identity = message.channel_identity
        return OutboundTarget(
            peer_id=identity.peer_id,
            thread_id=identity.thread_id,
            peer_type=identity.peer_type,
            user_id=identity.user_id,
        )
    return target_from_session_id(message.session_id)
```

If neither identity nor session id can produce a peer id, adapter `send()` sets `message.metadata["delivery_status"] = "unclaimed"` and returns without calling the platform client.

## Task 1: Shared Platform Helpers

**Files:**

- Create `app-instance/backend/beaver/interfaces/channels/platforms/__init__.py`
- Create `app-instance/backend/beaver/interfaces/channels/platforms/base.py`
- Create `app-instance/backend/tests/unit/test_platform_channel_helpers.py`

### Steps

- [ ] Write failing tests in `test_platform_channel_helpers.py`:

```python
from beaver.foundation.events import ChannelIdentity, OutboundMessage
from beaver.interfaces.channels.platforms.base import (
    chunk_text,
    compact_media_summary,
    config_bool,
    config_list,
    outbound_target,
)


def test_config_helpers_normalize_common_values() -> None:
    assert config_bool({"enabled": "true"}, "enabled", default=False) is True
    assert config_bool({"enabled": "0"}, "enabled", default=True) is False
    assert config_list({"allowFrom": "u1,u2"}, "allowFrom") == ["u1", "u2"]
    assert config_list({"allowFrom": ["u1", 2]}, "allowFrom") == ["u1", "2"]


def test_chunk_text_preserves_order_and_limit() -> None:
    chunks = chunk_text("abcdef", max_chars=2)
    assert chunks == ["ab", "cd", "ef"]


def test_outbound_target_prefers_channel_identity() -> None:
    identity = ChannelIdentity(
        channel_id="telegram-main",
        kind="telegram",
        account_id="bot-main",
        peer_id="chat-1",
        thread_id="topic-1",
        peer_type="group",
        user_id="user-1",
    )
    message = OutboundMessage(
        channel="telegram-main",
        content="ok",
        session_id="ignored",
        finish_reason="stop",
        channel_identity=identity,
    )

    target = outbound_target(message)

    assert target.peer_id == "chat-1"
    assert target.thread_id == "topic-1"
    assert target.peer_type == "group"
    assert target.user_id == "user-1"


def test_outbound_target_falls_back_to_session_id() -> None:
    message = OutboundMessage(
        channel="telegram-main",
        content="ok",
        session_id="telegram-main:bot-main:chat-1:topic-1",
        finish_reason="stop",
    )

    target = outbound_target(message)

    assert target.peer_id == "chat-1"
    assert target.thread_id == "topic-1"


def test_compact_media_summary_mentions_attachment_type() -> None:
    assert compact_media_summary("photo", file_name="cat.png") == "[photo: cat.png]"
    assert compact_media_summary("document") == "[document]"
```

- [ ] Run the focused test and confirm it fails because the module does not exist:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_platform_channel_helpers.py -q
```

Expected first result:

```text
ModuleNotFoundError: No module named 'beaver.interfaces.channels.platforms'
```

- [ ] Implement `platforms/__init__.py`:

```python
"""Platform channel adapters."""
```

- [ ] Implement `platforms/base.py`:

```python
"""Shared helpers for platform channel adapters."""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any

from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage


@dataclass(slots=True)
class OutboundTarget:
    peer_id: str | None
    thread_id: str | None = None
    peer_type: str = "unknown"
    user_id: str | None = None


class PlatformDeliveryError(RuntimeError):
    """Raised when a platform client rejects a delivery."""


def config_bool(config: dict[str, Any], key: str, *, default: bool = False) -> bool:
    value = config.get(key)
    if value is None:
        return default
    if isinstance(value, bool):
        return value
    if isinstance(value, (int, float)):
        return bool(value)
    text = str(value).strip().lower()
    if text in {"1", "true", "yes", "on"}:
        return True
    if text in {"0", "false", "no", "off"}:
        return False
    return default


def config_list(config: dict[str, Any], key: str) -> list[str]:
    value = config.get(key)
    if value is None:
        return []
    if isinstance(value, str):
        return [part.strip() for part in value.split(",") if part.strip()]
    if isinstance(value, (list, tuple, set)):
        return [str(item).strip() for item in value if str(item).strip()]
    return [str(value).strip()] if str(value).strip() else []


def chunk_text(text: str, *, max_chars: int) -> list[str]:
    if max_chars <= 0:
        raise ValueError("max_chars must be positive")
    if not text:
        return [""]
    return [text[index : index + max_chars] for index in range(0, len(text), max_chars)]


def compact_media_summary(media_type: str, *, file_name: str | None = None) -> str:
    label = str(media_type or "attachment").strip() or "attachment"
    if file_name:
        return f"[{label}: {file_name}]"
    return f"[{label}]"


def target_from_session_id(session_id: str | None) -> OutboundTarget:
    if not session_id:
        return OutboundTarget(peer_id=None)
    parts = str(session_id).split(":")
    if len(parts) < 3:
        return OutboundTarget(peer_id=None)
    thread_id = parts[3] if len(parts) > 3 and parts[3] else None
    return OutboundTarget(peer_id=parts[2] or None, thread_id=thread_id)


def outbound_target(message: OutboundMessage) -> OutboundTarget:
    identity = message.channel_identity
    if identity is None:
        return target_from_session_id(message.session_id)
    return OutboundTarget(
        peer_id=identity.peer_id,
        thread_id=identity.thread_id,
        peer_type=identity.peer_type,
        user_id=identity.user_id,
    )


def mark_unclaimed(message: OutboundMessage) -> None:
    message.metadata["delivery_status"] = "unclaimed"


def build_inbound_message(
    *,
    channel_id: str,
    kind: str,
    account_id: str,
    peer_id: str,
    content: str,
    message_id: str | None,
    peer_type: str,
    user_id: str | None = None,
    thread_id: str | None = None,
    metadata: dict[str, Any] | None = None,
) -> InboundMessage:
    identity = ChannelIdentity(
        channel_id=channel_id,
        kind=kind,
        account_id=account_id,
        peer_id=peer_id,
        thread_id=thread_id,
        peer_type=peer_type,
        user_id=user_id,
        message_id=message_id,
    )
    return InboundMessage(
        channel=channel_id,
        content=content,
        session_id=identity.session_id(),
        user_id=user_id,
        message_id=message_id or "",
        channel_identity=identity,
        metadata=metadata or {},
    )
```

- [ ] Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_platform_channel_helpers.py -q
```

Expected result:

```text
5 passed
```

## Task 2: Runtime Factory and Status

**Files:**

- Modify `app-instance/backend/beaver/interfaces/channels/runtime.py`
- Modify `app-instance/backend/tests/unit/test_channel_runtime.py`

Execute this task after the four adapter files exist from Tasks 3 through 6. The factory imports concrete adapter modules, so those modules must be present before the runtime test can pass.

### Steps

- [ ] Add failing tests to `test_channel_runtime.py`:

```python
def test_channel_runtime_builds_platform_adapters_without_starting_networks(tmp_path) -> None:
    runtime = ChannelRuntime(
        service=FakeAgentService(),
        workspace=tmp_path,
        channels={},
    )

    cases = {
        "telegram-main": ChannelConfig(enabled=True, kind="telegram", mode="polling", account_id="bot-main"),
        "feishu-main": ChannelConfig(enabled=True, kind="feishu", mode="websocket", account_id="tenant-main"),
        "qq-main": ChannelConfig(enabled=True, kind="qqbot", mode="websocket", account_id="qq-main"),
        "weixin-main": ChannelConfig(enabled=True, kind="weixin", mode="polling", account_id="wx-main"),
    }

    for channel_id, cfg in cases.items():
        adapter = runtime._build_adapter(channel_id, cfg)
        assert adapter.channel_id == channel_id
        assert adapter.kind == cfg.kind
        assert adapter.mode == cfg.mode


def test_channel_runtime_reports_platform_capabilities(tmp_path) -> None:
    runtime = ChannelRuntime(
        service=FakeAgentService(),
        workspace=tmp_path,
        channels={
            "telegram-main": ChannelConfig(enabled=True, kind="telegram", mode="polling", account_id="bot-main"),
            "weixin-main": ChannelConfig(enabled=True, kind="weixin", mode="polling", account_id="wx-main"),
        },
    )

    by_id = {item["channel_id"]: item for item in runtime.statuses()}

    assert by_id["telegram-main"]["capabilities"] == [
        "receive_text",
        "send_text",
        "receive_media",
        "groups",
    ]
    assert by_id["weixin-main"]["capabilities"] == [
        "receive_text",
        "send_text",
        "receive_media",
        "direct_messages",
    ]
```

- [ ] Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_channel_runtime.py -q
```

Expected first result:

```text
ValueError: Unsupported channel kind/mode
```

- [ ] Add a compact capability helper in `runtime.py`:

```python
def _channel_capabilities(kind: str, mode: str) -> list[str]:
    if kind == "webhook":
        return ["receive_text", "send_text", "sync_webhook_response"]
    if kind == "terminal" and mode == "websocket":
        return ["receive_text", "send_text", "persistent_connection"]
    if kind in {"feishu", "qqbot", "telegram"}:
        return ["receive_text", "send_text", "receive_media", "groups"]
    if kind == "weixin":
        return ["receive_text", "send_text", "receive_media", "direct_messages"]
    return []
```

- [ ] Use `_channel_capabilities(cfg.kind, cfg.mode)` inside `statuses()` while preserving the existing webhook and terminal URL fields.

- [ ] Extend `_build_adapter()`:

```python
        if cfg.kind == "telegram" and cfg.mode in {"polling", "webhook"}:
            from beaver.interfaces.channels.platforms.telegram import TelegramAdapter

            return TelegramAdapter(
                channel_id=channel_id,
                kind=cfg.kind,
                mode=cfg.mode,
                account_id=cfg.account_id,
                display_name=cfg.display_name,
                inbound_sink=self,
                secrets=cfg.secrets,
                config=cfg.config,
                event_recorder=self.record_event,
            )

        if cfg.kind == "feishu" and cfg.mode in {"websocket", "webhook"}:
            from beaver.interfaces.channels.platforms.feishu import FeishuAdapter

            return FeishuAdapter(
                channel_id=channel_id,
                kind=cfg.kind,
                mode=cfg.mode,
                account_id=cfg.account_id,
                display_name=cfg.display_name,
                inbound_sink=self,
                secrets=cfg.secrets,
                config=cfg.config,
                event_recorder=self.record_event,
            )

        if cfg.kind == "qqbot" and cfg.mode == "websocket":
            from beaver.interfaces.channels.platforms.qqbot import QQBotAdapter

            return QQBotAdapter(
                channel_id=channel_id,
                kind=cfg.kind,
                mode=cfg.mode,
                account_id=cfg.account_id,
                display_name=cfg.display_name,
                inbound_sink=self,
                secrets=cfg.secrets,
                config=cfg.config,
                event_recorder=self.record_event,
            )

        if cfg.kind == "weixin" and cfg.mode == "polling":
            from beaver.interfaces.channels.platforms.weixin import WeixinAdapter

            return WeixinAdapter(
                channel_id=channel_id,
                kind=cfg.kind,
                mode=cfg.mode,
                account_id=cfg.account_id,
                display_name=cfg.display_name,
                inbound_sink=self,
                secrets=cfg.secrets,
                config=cfg.config,
                event_recorder=self.record_event,
            )
```

- [ ] Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_channel_runtime.py -q
```

Expected result includes:

```text
passed
```

## Task 3: TelegramAdapter

**Files:**

- Create `app-instance/backend/beaver/interfaces/channels/platforms/telegram.py`
- Create `app-instance/backend/tests/unit/test_telegram_channel_adapter.py`

### Steps

- [ ] Write tests using fake sink and fake client:

```python
import asyncio

from beaver.foundation.events import OutboundMessage
from beaver.interfaces.channels.platforms.telegram import TelegramAdapter


class FakeSink:
    def __init__(self) -> None:
        self.messages = []

    async def accept_inbound(self, message):
        self.messages.append(message)


class FakeTelegramClient:
    def __init__(self) -> None:
        self.sent = []

    async def send_message(self, **kwargs):
        self.sent.append(kwargs)


def test_telegram_normalizes_private_text_message() -> None:
    async def run() -> None:
        sink = FakeSink()
        adapter = TelegramAdapter(
            channel_id="telegram-main",
            kind="telegram",
            mode="polling",
            account_id="bot-main",
            display_name=None,
            inbound_sink=sink,
            secrets={"botToken": "x"},
            config={},
            client=FakeTelegramClient(),
        )

        await adapter.handle_update_payload(
            {
                "message": {
                    "message_id": 100,
                    "text": "hello",
                    "chat": {"id": 200, "type": "private"},
                    "from": {"id": 300, "username": "ivan"},
                }
            }
        )

        message = sink.messages[0]
        assert message.channel == "telegram-main"
        assert message.content == "hello"
        assert message.session_id == "telegram-main:bot-main:200"
        assert message.channel_identity.peer_type == "dm"
        assert message.channel_identity.user_id == "300"
        assert message.channel_identity.message_id == "100"

    asyncio.run(run())


def test_telegram_group_requires_mention_when_configured() -> None:
    async def run() -> None:
        sink = FakeSink()
        adapter = TelegramAdapter(
            channel_id="telegram-main",
            kind="telegram",
            mode="polling",
            account_id="bot-main",
            display_name=None,
            inbound_sink=sink,
            secrets={"botToken": "x"},
            config={"requireMentionInGroups": True, "botUsername": "beaver_bot"},
            client=FakeTelegramClient(),
        )

        await adapter.handle_update_payload(
            {
                "message": {
                    "message_id": 101,
                    "text": "hello group",
                    "chat": {"id": -20, "type": "group"},
                    "from": {"id": 300},
                }
            }
        )
        await adapter.handle_update_payload(
            {
                "message": {
                    "message_id": 102,
                    "text": "@beaver_bot hello",
                    "chat": {"id": -20, "type": "group"},
                    "from": {"id": 300},
                }
            }
        )

        assert len(sink.messages) == 1
        assert sink.messages[0].content == "hello"

    asyncio.run(run())


def test_telegram_sends_chunked_reply_to_identity_target() -> None:
    async def run() -> None:
        sink = FakeSink()
        client = FakeTelegramClient()
        adapter = TelegramAdapter(
            channel_id="telegram-main",
            kind="telegram",
            mode="polling",
            account_id="bot-main",
            display_name=None,
            inbound_sink=sink,
            secrets={"botToken": "x"},
            config={"maxMessageChars": 3},
            client=client,
        )
        await adapter.handle_update_payload(
            {
                "message": {
                    "message_id": 100,
                    "text": "hello",
                    "chat": {"id": 200, "type": "private"},
                    "from": {"id": 300},
                }
            }
        )

        await adapter.send(
            OutboundMessage(
                channel="telegram-main",
                content="abcdef",
                session_id=sink.messages[0].session_id,
                finish_reason="stop",
                channel_identity=sink.messages[0].channel_identity,
            )
        )

        assert [item["text"] for item in client.sent] == ["abc", "def"]
        assert client.sent[0]["chat_id"] == "200"

    asyncio.run(run())
```

- [ ] Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_telegram_channel_adapter.py -q
```

Expected first result:

```text
ModuleNotFoundError
```

- [ ] Implement `TelegramAdapter` with these methods:

```python
class TelegramAdapter:
    channel_id: str
    kind: str
    mode: str

    def __init__(..., client: Any | None = None, application_factory: Any | None = None) -> None:
        ...

    async def start(self) -> None:
        if self._client is not None:
            return
        if self.mode == "polling":
            self._application = self._build_application()
            await self._application.initialize()
            await self._application.start()
            await self._application.updater.start_polling()
            self._client = self._application.bot
            return
        if self.mode == "webhook":
            self._client = self._build_bot()
            return
        raise ValueError(f"Unsupported telegram mode: {self.mode}")

    async def stop(self) -> None:
        if self._application is not None:
            await self._application.updater.stop()
            await self._application.stop()
            await self._application.shutdown()

    async def handle_update_payload(self, payload: dict[str, Any]) -> None:
        message = self._normalize_payload(payload)
        if message is not None:
            await self.inbound_sink.accept_inbound(message)

    async def send(self, message: OutboundMessage) -> None:
        target = outbound_target(message)
        if not target.peer_id:
            mark_unclaimed(message)
            return
        client = self._require_client()
        for chunk in chunk_text(message.content, max_chars=self.max_message_chars):
            await client.send_message(chat_id=target.peer_id, text=chunk)
```

- [ ] Normalization requirements:

  - Use `message.text` or `message.caption`.
  - Map private chats to `peer_type="dm"`.
  - Map group, supergroup, and channel chats to `peer_type="group"` or `peer_type="channel"`.
  - Use `message.message_thread_id` as `thread_id` when present.
  - For photo/document/audio/video, append `compact_media_summary()` to content and add compact media metadata.
  - If `requireMentionInGroups` is true, accept only text containing `@<botUsername>`, then strip that mention from content.

- [ ] Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_telegram_channel_adapter.py -q
```

Expected result:

```text
3 passed
```

## Task 4: FeishuAdapter

**Files:**

- Create `app-instance/backend/beaver/interfaces/channels/platforms/feishu.py`
- Create `app-instance/backend/tests/unit/test_feishu_channel_adapter.py`

### Steps

- [ ] Write tests:

```python
import asyncio

from beaver.foundation.events import OutboundMessage
from beaver.interfaces.channels.platforms.feishu import FeishuAdapter


class FakeSink:
    def __init__(self) -> None:
        self.messages = []

    async def accept_inbound(self, message):
        self.messages.append(message)


class FakeFeishuClient:
    def __init__(self) -> None:
        self.sent = []

    async def send_text(self, *, receive_id_type: str, receive_id: str, text: str):
        self.sent.append(
            {"receive_id_type": receive_id_type, "receive_id": receive_id, "text": text}
        )


def test_feishu_normalizes_direct_text_event() -> None:
    async def run() -> None:
        sink = FakeSink()
        adapter = FeishuAdapter(
            channel_id="feishu-main",
            kind="feishu",
            mode="websocket",
            account_id="tenant-main",
            display_name=None,
            inbound_sink=sink,
            secrets={"appId": "app", "appSecret": "secret"},
            config={},
            client=FakeFeishuClient(),
        )

        await adapter.handle_event_payload(
            {
                "event": {
                    "message": {
                        "message_id": "m1",
                        "chat_id": "oc_chat",
                        "chat_type": "p2p",
                        "message_type": "text",
                        "content": "{\"text\":\"hello\"}",
                    },
                    "sender": {"sender_id": {"open_id": "ou_user"}},
                }
            }
        )

        message = sink.messages[0]
        assert message.content == "hello"
        assert message.session_id == "feishu-main:tenant-main:oc_chat"
        assert message.channel_identity.peer_type == "dm"
        assert message.channel_identity.user_id == "ou_user"

    asyncio.run(run())


def test_feishu_group_mention_gate() -> None:
    async def run() -> None:
        sink = FakeSink()
        adapter = FeishuAdapter(
            channel_id="feishu-main",
            kind="feishu",
            mode="websocket",
            account_id="tenant-main",
            display_name=None,
            inbound_sink=sink,
            secrets={"appId": "app", "appSecret": "secret"},
            config={"requireMentionInGroups": True, "botOpenId": "ou_bot"},
            client=FakeFeishuClient(),
        )

        await adapter.handle_event_payload(
            {
                "event": {
                    "message": {
                        "message_id": "m1",
                        "chat_id": "oc_group",
                        "chat_type": "group",
                        "message_type": "text",
                        "content": "{\"text\":\"hello\"}",
                        "mentions": [],
                    },
                    "sender": {"sender_id": {"open_id": "ou_user"}},
                }
            }
        )
        await adapter.handle_event_payload(
            {
                "event": {
                    "message": {
                        "message_id": "m2",
                        "chat_id": "oc_group",
                        "chat_type": "group",
                        "message_type": "text",
                        "content": "{\"text\":\"hello\"}",
                        "mentions": [{"id": {"open_id": "ou_bot"}}],
                    },
                    "sender": {"sender_id": {"open_id": "ou_user"}},
                }
            }
        )

        assert len(sink.messages) == 1

    asyncio.run(run())


def test_feishu_sends_text_to_chat_id() -> None:
    async def run() -> None:
        sink = FakeSink()
        client = FakeFeishuClient()
        adapter = FeishuAdapter(
            channel_id="feishu-main",
            kind="feishu",
            mode="websocket",
            account_id="tenant-main",
            display_name=None,
            inbound_sink=sink,
            secrets={"appId": "app", "appSecret": "secret"},
            config={},
            client=client,
        )
        await adapter.handle_event_payload(
            {
                "event": {
                    "message": {
                        "message_id": "m1",
                        "chat_id": "oc_chat",
                        "chat_type": "p2p",
                        "message_type": "text",
                        "content": "{\"text\":\"hello\"}",
                    },
                    "sender": {"sender_id": {"open_id": "ou_user"}},
                }
            }
        )
        await adapter.send(
            OutboundMessage(
                channel="feishu-main",
                content="ok",
                session_id=sink.messages[0].session_id,
                finish_reason="stop",
                channel_identity=sink.messages[0].channel_identity,
            )
        )

        assert client.sent == [
            {"receive_id_type": "chat_id", "receive_id": "oc_chat", "text": "ok"}
        ]

    asyncio.run(run())
```

- [ ] Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_feishu_channel_adapter.py -q
```

Expected first result:

```text
ModuleNotFoundError
```

- [ ] Implement `FeishuAdapter`:

  - Parse `event.message.content` as JSON when possible.
  - `message_type="text"` reads `content["text"]`.
  - Non-text message types use `compact_media_summary(message_type, file_name=...)`.
  - `chat_type="p2p"` maps to `peer_type="dm"`.
  - Any other chat type maps to `peer_type="group"`.
  - `sender.sender_id.open_id` becomes `user_id`.
  - Mention gate checks `message.mentions[*].id.open_id` against `config.botOpenId`.
  - `send()` calls `client.send_text(receive_id_type="chat_id", receive_id=target.peer_id, text=chunk)`.
  - Live client creation is isolated in `_build_client()` and imports `lark_oapi` only inside that method.

- [ ] Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_feishu_channel_adapter.py -q
```

Expected result:

```text
3 passed
```

## Task 5: QQBotAdapter

**Files:**

- Create `app-instance/backend/beaver/interfaces/channels/platforms/qqbot.py`
- Create `app-instance/backend/tests/unit/test_qqbot_channel_adapter.py`

### Steps

- [ ] Write tests:

```python
import asyncio

from beaver.foundation.events import OutboundMessage
from beaver.interfaces.channels.platforms.qqbot import QQBotAdapter


class FakeSink:
    def __init__(self) -> None:
        self.messages = []

    async def accept_inbound(self, message):
        self.messages.append(message)


class FakeQQBotClient:
    def __init__(self) -> None:
        self.sent = []

    async def send_text(self, *, peer_type: str, peer_id: str, content: str, message_id: str | None):
        self.sent.append(
            {
                "peer_type": peer_type,
                "peer_id": peer_id,
                "content": content,
                "message_id": message_id,
            }
        )


def test_qqbot_normalizes_private_c2c_message() -> None:
    async def run() -> None:
        sink = FakeSink()
        adapter = QQBotAdapter(
            channel_id="qq-main",
            kind="qqbot",
            mode="websocket",
            account_id="qq-bot",
            display_name=None,
            inbound_sink=sink,
            secrets={"appId": "app", "clientSecret": "secret"},
            config={},
            client=FakeQQBotClient(),
        )

        await adapter.handle_event_payload(
            {
                "t": "C2C_MESSAGE_CREATE",
                "d": {
                    "id": "m1",
                    "author": {"user_openid": "u1"},
                    "content": "hello",
                },
            }
        )

        message = sink.messages[0]
        assert message.content == "hello"
        assert message.session_id == "qq-main:qq-bot:u1"
        assert message.channel_identity.peer_type == "dm"
        assert message.channel_identity.user_id == "u1"

    asyncio.run(run())


def test_qqbot_normalizes_group_message() -> None:
    async def run() -> None:
        sink = FakeSink()
        adapter = QQBotAdapter(
            channel_id="qq-main",
            kind="qqbot",
            mode="websocket",
            account_id="qq-bot",
            display_name=None,
            inbound_sink=sink,
            secrets={"appId": "app", "clientSecret": "secret"},
            config={},
            client=FakeQQBotClient(),
        )

        await adapter.handle_event_payload(
            {
                "t": "GROUP_AT_MESSAGE_CREATE",
                "d": {
                    "id": "m2",
                    "group_openid": "g1",
                    "author": {"member_openid": "u1"},
                    "content": "hello group",
                },
            }
        )

        message = sink.messages[0]
        assert message.session_id == "qq-main:qq-bot:g1"
        assert message.channel_identity.peer_type == "group"
        assert message.channel_identity.user_id == "u1"

    asyncio.run(run())


def test_qqbot_sends_reply_with_original_message_id() -> None:
    async def run() -> None:
        sink = FakeSink()
        client = FakeQQBotClient()
        adapter = QQBotAdapter(
            channel_id="qq-main",
            kind="qqbot",
            mode="websocket",
            account_id="qq-bot",
            display_name=None,
            inbound_sink=sink,
            secrets={"appId": "app", "clientSecret": "secret"},
            config={},
            client=client,
        )
        await adapter.handle_event_payload(
            {
                "t": "GROUP_AT_MESSAGE_CREATE",
                "d": {
                    "id": "m2",
                    "group_openid": "g1",
                    "author": {"member_openid": "u1"},
                    "content": "hello group",
                },
            }
        )
        await adapter.send(
            OutboundMessage(
                channel="qq-main",
                content="ok",
                session_id=sink.messages[0].session_id,
                finish_reason="stop",
                channel_identity=sink.messages[0].channel_identity,
            )
        )

        assert client.sent[0] == {
            "peer_type": "group",
            "peer_id": "g1",
            "content": "ok",
            "message_id": "m2",
        }

    asyncio.run(run())
```

- [ ] Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_qqbot_channel_adapter.py -q
```

Expected first result:

```text
ModuleNotFoundError
```

- [ ] Implement `QQBotAdapter`:

  - `C2C_MESSAGE_CREATE`: `peer_id=author.user_openid`, `peer_type="dm"`.
  - `GROUP_AT_MESSAGE_CREATE`: `peer_id=group_openid`, `peer_type="group"`, `user_id=author.member_openid`.
  - Guild/channel events with `guild_id` and `channel_id`: `peer_id=channel_id`, `thread_id=guild_id`, `peer_type="channel"`.
  - Attachment arrays become compact media summaries and metadata entries.
  - Apply `dmPolicy`, `groupPolicy`, `allowFrom`, and `groupAllowFrom` before accepting.
  - `send()` calls the injected client `send_text()`.
  - Live websocket and REST client setup live behind methods that import optional packages only on start.

- [ ] Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_qqbot_channel_adapter.py -q
```

Expected result:

```text
3 passed
```

## Task 6: WeixinAdapter

**Files:**

- Create `app-instance/backend/beaver/interfaces/channels/platforms/weixin.py`
- Create `app-instance/backend/tests/unit/test_weixin_channel_adapter.py`

### Steps

- [ ] Write tests:

```python
import asyncio

from beaver.foundation.events import OutboundMessage
from beaver.interfaces.channels.platforms.weixin import WeixinAdapter


class FakeSink:
    def __init__(self) -> None:
        self.messages = []

    async def accept_inbound(self, message):
        self.messages.append(message)


class FakeWeixinClient:
    def __init__(self) -> None:
        self.sent = []

    async def send_text(self, *, peer_id: str, text: str, context_token: str | None):
        self.sent.append({"peer_id": peer_id, "text": text, "context_token": context_token})


def test_weixin_normalizes_direct_text_message() -> None:
    async def run() -> None:
        sink = FakeSink()
        adapter = WeixinAdapter(
            channel_id="weixin-main",
            kind="weixin",
            mode="polling",
            account_id="wx-main",
            display_name=None,
            inbound_sink=sink,
            secrets={"token": "token"},
            config={},
            client=FakeWeixinClient(),
        )

        await adapter.handle_message_payload(
            {
                "id": "m1",
                "from": "wx_user",
                "room_id": "",
                "type": "text",
                "text": "hello",
                "context_token": "ctx1",
            }
        )

        message = sink.messages[0]
        assert message.content == "hello"
        assert message.session_id == "weixin-main:wx-main:wx_user"
        assert message.channel_identity.peer_type == "dm"
        assert message.metadata["context_token"] == "ctx1"

    asyncio.run(run())


def test_weixin_group_message_is_best_effort() -> None:
    async def run() -> None:
        sink = FakeSink()
        adapter = WeixinAdapter(
            channel_id="weixin-main",
            kind="weixin",
            mode="polling",
            account_id="wx-main",
            display_name=None,
            inbound_sink=sink,
            secrets={"token": "token"},
            config={"groupPolicy": "open"},
            client=FakeWeixinClient(),
        )

        await adapter.handle_message_payload(
            {
                "id": "m2",
                "from": "wx_user",
                "room_id": "room1",
                "type": "text",
                "text": "hello room",
                "context_token": "ctx2",
            }
        )

        message = sink.messages[0]
        assert message.session_id == "weixin-main:wx-main:room1"
        assert message.channel_identity.peer_type == "group"
        assert message.channel_identity.user_id == "wx_user"

    asyncio.run(run())


def test_weixin_sends_text_with_context_token() -> None:
    async def run() -> None:
        sink = FakeSink()
        client = FakeWeixinClient()
        adapter = WeixinAdapter(
            channel_id="weixin-main",
            kind="weixin",
            mode="polling",
            account_id="wx-main",
            display_name=None,
            inbound_sink=sink,
            secrets={"token": "token"},
            config={},
            client=client,
        )
        await adapter.handle_message_payload(
            {
                "id": "m1",
                "from": "wx_user",
                "type": "text",
                "text": "hello",
                "context_token": "ctx1",
            }
        )
        await adapter.send(
            OutboundMessage(
                channel="weixin-main",
                content="ok",
                session_id=sink.messages[0].session_id,
                finish_reason="stop",
                channel_identity=sink.messages[0].channel_identity,
                metadata={"inbound_metadata": sink.messages[0].metadata},
            )
        )

        assert client.sent == [{"peer_id": "wx_user", "text": "ok", "context_token": "ctx1"}]

    asyncio.run(run())
```

- [ ] Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_weixin_channel_adapter.py -q
```

Expected first result:

```text
ModuleNotFoundError
```

- [ ] Implement `WeixinAdapter`:

  - Direct messages use `peer_id=from`, `peer_type="dm"`.
  - Group messages use `peer_id=room_id`, `peer_type="group"`, `user_id=from`.
  - Default `dmPolicy` is `open`.
  - Default `groupPolicy` is `disabled`.
  - Preserve `context_token` in inbound metadata.
  - `send()` reads `context_token` from `message.metadata["inbound_metadata"]` when available.
  - Live polling setup uses configured `baseUrl`, `cdnBaseUrl`, and `token`.
  - QR credential loading is represented as explicit config/state read inside the live client builder.

- [ ] Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_weixin_channel_adapter.py -q
```

Expected result:

```text
3 passed
```

## Task 7: Import Guards and Optional Extras

**Files:**

- Modify `app-instance/backend/pyproject.toml`
- Modify `app-instance/backend/tests/unit/test_imports.py`

### Steps

- [ ] Update `pyproject.toml` optional extras exactly as described in Dependency Strategy.

- [ ] Add import tests:

```python
def test_platform_channel_modules_import_without_live_clients() -> None:
    from beaver.interfaces.channels.platforms.feishu import FeishuAdapter
    from beaver.interfaces.channels.platforms.qqbot import QQBotAdapter
    from beaver.interfaces.channels.platforms.telegram import TelegramAdapter
    from beaver.interfaces.channels.platforms.weixin import WeixinAdapter

    assert FeishuAdapter.KIND == "feishu"
    assert QQBotAdapter.KIND == "qqbot"
    assert TelegramAdapter.KIND == "telegram"
    assert WeixinAdapter.KIND == "weixin"
```

- [ ] Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_imports.py -q
```

Expected result:

```text
passed
```

## Task 8: Runtime Startup Isolation

**Files:**

- Modify `app-instance/backend/tests/unit/test_channel_runtime.py`
- Modify adapter files as needed

### Steps

- [ ] Add a startup isolation test:

```python
def test_channel_runtime_platform_start_failure_does_not_stop_other_channels(tmp_path) -> None:
    async def run() -> None:
        runtime = ChannelRuntime(
            service=FakeAgentService(),
            workspace=tmp_path,
            channels={
                "telegram-main": ChannelConfig(
                    enabled=True,
                    kind="telegram",
                    mode="polling",
                    account_id="bot-main",
                    secrets={},
                ),
                "off": ChannelConfig(
                    enabled=False,
                    kind="weixin",
                    mode="polling",
                    account_id="wx-main",
                ),
            },
        )

        await runtime.start()
        try:
            by_id = {item["channel_id"]: item for item in runtime.statuses()}
        finally:
            await runtime.stop()

        assert by_id["telegram-main"]["state"] == "error"
        assert "botToken" in by_id["telegram-main"]["last_error"]
        assert by_id["off"]["state"] == "disabled"

    asyncio.run(run())
```

- [ ] Ensure each live adapter builder validates required secrets only when `start()` needs a live client:

```python
def _require_secret(self, key: str) -> str:
    value = self.secrets.get(key)
    if not value:
        raise ValueError(f"{key} is required")
    return str(value)
```

- [ ] Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_channel_runtime.py -q
```

Expected result:

```text
passed
```

## Task 9: Cross-Channel Verification

**Files:** no new files unless a previous task reveals a test gap.

### Steps

- [ ] Run all focused channel tests:

```bash
cd app-instance/backend
uv run pytest \
  tests/unit/test_platform_channel_helpers.py \
  tests/unit/test_telegram_channel_adapter.py \
  tests/unit/test_feishu_channel_adapter.py \
  tests/unit/test_qqbot_channel_adapter.py \
  tests/unit/test_weixin_channel_adapter.py \
  tests/unit/test_channel_runtime.py \
  tests/unit/test_imports.py \
  -q
```

Expected result:

```text
passed
```

- [ ] Run the broader backend unit suite:

```bash
cd app-instance/backend
uv run pytest tests/unit -q
```

Expected result:

```text
passed
```

- [ ] Inspect changed files:

```bash
git status --short
git diff -- app-instance/backend/beaver/interfaces/channels app-instance/backend/tests/unit app-instance/backend/pyproject.toml
```

- [ ] Confirm these invariants manually from the diff:

  - `ChannelRuntime` still owns `MessageBus` and agent dispatch.
  - Platform adapters call only `ChannelInboundSink.accept_inbound()` for inbound routing.
  - Platform adapters do not call `AgentService`.
  - Optional platform packages are imported only inside live client or live transport builders.
  - Tests use fake clients and fake payloads.
  - No adapter sends when the outbound target cannot be resolved; it marks `delivery_status="unclaimed"` instead.

## Implementation Order

1. Task 1: Shared Platform Helpers
2. Task 3: TelegramAdapter
3. Task 4: FeishuAdapter
4. Task 5: QQBotAdapter
5. Task 6: WeixinAdapter
6. Task 2: Runtime Factory and Status
7. Task 7: Import Guards and Optional Extras
8. Task 8: Runtime Startup Isolation
9. Task 9: Cross-Channel Verification

This order proves the shared adapter pattern with Telegram first, then adds the two official bot-style channels, leaves Weixin adapter behavior last because context tokens and login state make it the most specialized, and only then connects the concrete modules to the runtime factory.
