diff --git a/.github/workflows/plugin-api-v2.yml b/.github/workflows/plugin-api-v2.yml index 7c1876b..b30920d 100644 --- a/.github/workflows/plugin-api-v2.yml +++ b/.github/workflows/plugin-api-v2.yml @@ -26,3 +26,22 @@ jobs: env: PYTHONPATH: .plugin-contracts run: python -m akashic_plugin_contracts check plugin.py + + host-channel-contract: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: kachofugetsu09/akashic-agent + ref: 2bf05e320103bf63b67693d114fd2433fd10a0ea + path: .akashic-agent + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install channel contract dependencies + run: python -m pip install -r .akashic-agent/requirements.txt pytest pytest-asyncio + - name: Verify pinned host channel contract + env: + AKASHIC_AGENT_ROOT: ${{ github.workspace }}/.akashic-agent + run: python -m pytest -q tests diff --git a/channel.py b/channel.py index d63beb0..17b9163 100644 --- a/channel.py +++ b/channel.py @@ -13,7 +13,7 @@ import json import logging import time -from dataclasses import dataclass +from dataclasses import dataclass, replace from collections.abc import Callable, Coroutine from typing import TYPE_CHECKING, Any, cast @@ -21,10 +21,17 @@ import websockets from agent.looping.interrupt import InterruptController -from bus.events import InboundMessage, OutboundMessage +from bus.events import ( + ChannelMessage, + DeliveryReceipt, + InboundMessage, + OutboundMessage, + channel_message_from_outbound, +) from bus.events_lifecycle import StreamDeltaReady, TurnStarted from bus.queue import MessageBus from infra.channels.contract import ChannelContext +from infra.channels.delivery import deliver_message_parts if TYPE_CHECKING: from .plugin import QQBotGroupConfigModel @@ -102,8 +109,7 @@ async def start(self, ctx: ChannelContext) -> None: self._events_bound = True ctx.push_tool.register_channel( self.name, - text=self.send_proactive, - stream_text=self.send_stream, + deliver=self._deliver_message, ) self._stopped.clear() self._task = asyncio.create_task(self._gateway_loop()) @@ -261,8 +267,12 @@ async def _on_response(self, msg: OutboundMessage) -> None: else: await self._delete_live_preview(session_key) self._clear_live_session(session_key) - if content and not sent_as_stream: - await self.send(msg.chat_id, msg.content) + outbound = channel_message_from_outbound(msg) + if sent_as_stream: + outbound = replace(outbound, content="") + receipt = await self._deliver_message(outbound) + if not receipt.succeeded: + raise RuntimeError(receipt.detail or "QQBot 消息提交失败") async def send_proactive(self, chat_id: str, message: str) -> None: kind, _target = self._parse_chat_id(chat_id) @@ -292,6 +302,26 @@ async def send_stream(self, chat_id: str, message: str) -> None: logger.warning("[qqbot] 私聊流式发送失败,回退普通发送: %s", e) await self.send(chat_id, message) + async def _deliver_message(self, message: ChannelMessage) -> DeliveryReceipt: + """提交 QQBot 文本消息,并明确拒绝未支持的附件。""" + + async def unsupported_file( + _chat_id: str, + _path: str, + _name: str | None, + ) -> None: + raise RuntimeError("官方 QQBot 当前不支持发送文件") + + async def unsupported_image(_chat_id: str, _path: str) -> None: + raise RuntimeError("官方 QQBot 当前不支持发送图片") + + return await deliver_message_parts( + message, + send_text=self.send_proactive, + send_file=unsupported_file, + send_image=unsupported_image, + ) + async def _send_stream_c2c(self, openid: str, msg_id: str, message: str) -> None: token = await self._get_access_token() msg_seq = self._next_msg_seq() diff --git a/tests/test_plugin.py b/tests/test_plugin.py index f5d143a..c5c7bc9 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -9,6 +9,14 @@ import pytest +from agent.tools.message_push import MessagePushTool +from bus.events import ( + AttachmentKind, + ChannelAttachment, + ChannelMessage, + DeliveryStatus, +) + def _load_plugin_module(): path = Path(__file__).parents[1] / "plugin.py" @@ -131,22 +139,55 @@ async def gateway_loop() -> None: channel._gateway_loop = gateway_loop registry = SimpleNamespace( on=lambda *_args: object(), - register_channel=lambda *_args, **_kwargs: object(), subscribe_outbound=lambda *_args: object(), ) + push_tools = [MessagePushTool(), MessagePushTool()] context = SimpleNamespace( bus=registry, event_bus=registry, - push_tool=registry, + push_tool=push_tools[0], interrupt_controller=None, ) await channel.start(context) await asyncio.sleep(0) await channel.stop() + context.push_tool = push_tools[1] await channel.start(context) await asyncio.sleep(0) await channel.stop() assert starts == 2 assert channel._task is None + assert all("qqbot" in tool._adapters for tool in push_tools) + + +@pytest.mark.asyncio +async def test_delivery_adapter_reports_unsupported_attachment() -> None: + plugin = QQBotPlugin() + plugin.context = type( + "Ctx", + (), + {"config": QQBotConfigModel(app_id="app", client_secret="secret")}, + )() + channel = plugin.channels()[0] + sent: list[tuple[str, str]] = [] + + async def send(chat_id: str, content: str) -> None: + sent.append((chat_id, content)) + + channel.send_proactive = send + receipt = await channel._deliver_message( + ChannelMessage( + channel="qqbot", + chat_id="c2c:user", + content="正文", + attachments=( + ChannelAttachment(AttachmentKind.FILE, "/tmp/a.txt", "a.txt"), + ), + ) + ) + + assert receipt.status is DeliveryStatus.PARTIAL + assert receipt.detail == "官方 QQBot 当前不支持发送文件" + assert sent == [("c2c:user", "正文")]