From 6c5f5ee967fc4a385815fc1858e5e0a97556abd8 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Mon, 17 Aug 2026 19:26:08 +0800 Subject: [PATCH 1/9] feat(plugin): migrate qqbot to api v3 --- .github/workflows/plugin-api-v2.yml | 47 -- .github/workflows/plugin-api-v3.yml | 65 ++ README.md | 8 +- akashic.plugin.toml | 8 + channel.py | 992 +++++++++++++++------------- config.py | 55 ++ plugin.py | 120 ++-- tests/test_plugin.py | 488 ++++++++++---- 8 files changed, 1083 insertions(+), 700 deletions(-) delete mode 100644 .github/workflows/plugin-api-v2.yml create mode 100644 .github/workflows/plugin-api-v3.yml create mode 100644 akashic.plugin.toml create mode 100644 config.py diff --git a/.github/workflows/plugin-api-v2.yml b/.github/workflows/plugin-api-v2.yml deleted file mode 100644 index b30920d..0000000 --- a/.github/workflows/plugin-api-v2.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: plugin-api-v2 - -on: - pull_request: - push: - branches: - - main - -permissions: - contents: read - -jobs: - contract: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/checkout@v4 - with: - repository: akashic-plugins/plugin-contracts - ref: 24543445c7b99ca63fcd90b5828f754a148b184c - path: .plugin-contracts - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - name: Check Plugin API v2 - 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/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml new file mode 100644 index 0000000..c3561f6 --- /dev/null +++ b/.github/workflows/plugin-api-v3.yml @@ -0,0 +1,65 @@ +name: plugin-api-v3 + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + contract: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: akashic-plugins/plugin-contracts + ref: 4dd69dd621e029e51e99aa428443fa3a4ec1f6cf + path: .plugin-contracts + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Check Plugin API v3 + env: + PYTHONPATH: .plugin-contracts + run: python -m akashic_plugin_contracts check plugin.py + + composition-parity: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: kachofugetsu09/akashic-agent + ref: b97f919b1fd865d23d11095cbc63d2354803bad9 + path: .akashic-core + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: .akashic-core/requirements.txt + - name: Install exact Core runtime + run: | + python -m venv .venv + .venv/bin/python -m pip install \ + -r .akashic-core/requirements.txt \ + -r .akashic-core/requirements-dev.txt \ + pytest pytest-asyncio + - name: Verify QQBot v3 composition + env: + AKASHIC_AGENT_ROOT: .akashic-core + PYTHONPATH: .akashic-core + run: .venv/bin/python -m pytest -q tests/ + - name: Check v3 source types + env: + PYTHONPATH: .akashic-core + run: .venv/bin/basedpyright --level error plugin.py channel.py config.py tests + - name: Compile Python sources + run: python -m compileall -q plugin.py channel.py config.py tests + - name: Check diff formatting + run: git diff --check diff --git a/README.md b/README.md index a4a9bfc..8df8377 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ -# qqbot +# QQBot -Akashic QQBot channel plugin. +Akashic 官方 QQBot 私聊渠道。插件已迁移到 pure v3 Channel API: + +- candidate 只注册静态 channel definition,不解密凭证、不建立网络连接; +- formal generation 通过 Core 提供的 exact binding 处理入站、`/stop`、临时预览和最终文本投递; +- 首批 v3 adapter 明确只支持文本,附件会在任何 provider 副作用前返回 `REJECTED`。 diff --git a/akashic.plugin.toml b/akashic.plugin.toml new file mode 100644 index 0000000..4c1ee12 --- /dev/null +++ b/akashic.plugin.toml @@ -0,0 +1,8 @@ +schema_version = 1 +name = "qqbot" +version = "3.0.0" +api_version = 3 +entrypoint = "plugin.py" + +[channel_credentials] +qqbot = ["appId", "app_id", "clientSecret", "client_secret"] diff --git a/channel.py b/channel.py index 17b9163..be9e5e9 100644 --- a/channel.py +++ b/channel.py @@ -1,11 +1,4 @@ -""" -官方 QQBot 通道。 - -MVP 只支持文本: -- WebSocket 接收私聊事件 -- REST API 发送私聊 markdown 文本 -- 私聊流式消息可用时走官方 stream_messages -""" +"""Pure v3 QQBot protocol adapter.""" from __future__ import annotations @@ -13,195 +6,303 @@ import json import logging import time -from dataclasses import dataclass, replace -from collections.abc import Callable, Coroutine -from typing import TYPE_CHECKING, Any, cast +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, cast import httpx import websockets -from agent.looping.interrupt import InterruptController -from bus.events import ( - ChannelMessage, - DeliveryReceipt, - InboundMessage, - OutboundMessage, - channel_message_from_outbound, +from agent.plugin_composition.channels import ( + ChannelAdapter, + ChannelCleanupFailure, + ChannelFactoryContext, + ChannelInboundMessage, + ChannelPresentationPorts, + ChannelReady, + ControlResponseBodies, + CredentialRef, + DeliveryStatus, + PresentationReceipt, + ProviderDeliveryReceipt, + ProviderDeliveryRequest, + RawInbound, + StopReceipt, + StreamDeltaPresentation, + TurnOutputCompletedPresentation, + TurnStartedPresentation, + TurnStreamEvent, + TurnStreamEventKind, ) -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 logger = logging.getLogger(__name__) _CHANNEL = "qqbot" _API_BASE = "https://api.sgroup.qq.com" _TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken" +_REJECTED_HTTP_STATUSES = frozenset({400, 401, 403, 404, 405, 413, 415, 422}) _LIVE_STREAM_MIN_CHARS = 120 _LIVE_STREAM_MIN_INTERVAL_S = 1.5 _LIVE_MAX_FAILURES = 3 _REPLY_LIVE_TAIL = 900 +_CREDENTIAL_ALIASES = { + "app_id": ("appId", "app_id"), + "client_secret": ("clientSecret", "client_secret"), +} -@dataclass +@dataclass(slots=True) class _TokenCache: token: str expires_at: float -@dataclass +@dataclass(slots=True) class _LiveStreamState: openid: str msg_id: str msg_seq: int stream_msg_id: str = "" index: int = 0 - completed: bool = False -class QQBotChannel: +def build_qqbot_channel(context: ChannelFactoryContext) -> ChannelAdapter: + """Build a side-effect-free QQBot adapter for one exact binding.""" + + if not isinstance(context, ChannelFactoryContext): + raise TypeError("QQBot channel factory 只接受 ChannelFactoryContext") + if context.ingress is None or context.identity is None: + raise RuntimeError("QQBot v3 channel 需要 Core ingress/identity ports") + if context.control is None or context.turn_stream is None: + raise RuntimeError("QQBot v3 channel 需要 Core control/turn-stream ports") + return QQBotAdapter(context) + + +class QQBotAdapter: + """Translate QQBot text, control, delivery, and preview events to C14 ports.""" + name = _CHANNEL - def __init__( - self, - app_id: str, - client_secret: str, - allow_from: list[str] | None = None, - groups: list["QQBotGroupConfigModel"] | None = None, - ) -> None: - self._app_id = app_id - self._client_secret = client_secret - self._bus: MessageBus | None = None - self._allow_from = set(allow_from or []) - self._groups: dict[str, QQBotGroupConfigModel] = { - g.group_openid: g for g in (groups or []) - } - self._interrupt_controller: InterruptController | None = None - self._client = httpx.AsyncClient(timeout=30.0) + def __init__(self, context: ChannelFactoryContext) -> None: + self._context = context + self._ingress = context.ingress + self._provider_factory = context.provider_client_factory + self._credentials = context.credentials + self._config = context.config + self._binding_token = context.binding_token + self._allow_from = _allow_from(self._config) + + self._presentation: ChannelPresentationPorts | None = None + self._stream_subscription: Any | None = None + self._provider_client: Any | None = None + self._client: httpx.AsyncClient | None = None self._token: _TokenCache | None = None - self._task: asyncio.Task[None] | None = None - self._outbound_bound = False - self._events_bound = False + self._gateway_task: asyncio.Task[None] | None = None self._stopped = asyncio.Event() - self._last_c2c_msg_id: dict[str, str] = {} - self._live_states: dict[str, _LiveStreamState] = {} + self._started = False + self._stopping = False + + self._message_recipients: dict[str, str] = {} + self._presentation_recipients: dict[str, str] = {} + self._presentation_message_ids: dict[str, str] = {} self._reply_buffers: dict[str, str] = {} + self._live_states: dict[str, _LiveStreamState] = {} self._live_next_at: dict[str, float] = {} self._live_last_lengths: dict[str, int] = {} self._live_failures: dict[str, int] = {} self._live_disabled: set[str] = set() self._live_locks: dict[str, asyncio.Lock] = {} - self._live_tasks: set[asyncio.Task[None]] = set() - self._live_tasks_by_session: dict[str, set[asyncio.Task[None]]] = {} - - async def start(self, ctx: ChannelContext) -> None: - if self._client.is_closed: - self._client = httpx.AsyncClient(timeout=30.0) - self._bus = ctx.bus - self._interrupt_controller = ctx.interrupt_controller - if not self._events_bound: - ctx.event_bus.on(TurnStarted, self._on_turn_started) - ctx.event_bus.on(StreamDeltaReady, self._on_stream_delta) - self._events_bound = True - ctx.push_tool.register_channel( - self.name, - deliver=self._deliver_message, - ) + + def attach_presentation(self, ports: ChannelPresentationPorts) -> None: + """Bind exact control and turn-stream facades before start.""" + + if self._presentation is not None: + raise RuntimeError("QQBot presentation ports 不能重复绑定") + if ports.control is None or ports.turn_stream is None: + raise RuntimeError("QQBot v3 必须同时绑定 control 与 turn_stream") + self._presentation = ports + + async def start(self) -> ChannelReady: + """Create formal provider resources and start the gateway closed.""" + + if self._started or self._stopping: + raise RuntimeError("QQBot adapter 已启动或正在停止") + if self._presentation is None: + raise RuntimeError("QQBot adapter 缺少 presentation ports") + + # 1. Resolve only through the formal provider factory. + self._provider_client = await self._provider_factory.create(self._credentials) + self._client = httpx.AsyncClient(timeout=30.0) + + # 2. Attach the exact presentation callback before receiving provider input. + turn_stream = self._presentation.turn_stream + if turn_stream is None: + raise RuntimeError("QQBot turn stream port 未绑定") + self._stream_subscription = turn_stream.subscribe(self._on_turn_stream) self._stopped.clear() - self._task = asyncio.create_task(self._gateway_loop()) - if not self._outbound_bound: - ctx.bus.subscribe_outbound(_CHANNEL, self._on_response) - self._outbound_bound = True - logger.info("[qqbot] 官方 QQBot 通道已启动") + self._gateway_task = asyncio.create_task( + self._gateway_loop(), + name=f"qqbot-gateway:{self._context.generation_id}", + ) + self._started = True + return ChannelReady( + self._binding_token, + subscriptions=("qqbot.gateway", "qqbot.turn_stream"), + admission_open=False, + ) + + async def deliver(self, request: ProviderDeliveryRequest) -> ProviderDeliveryReceipt: + """Send one text message and return a settled three-state receipt.""" + + if not isinstance(request, ProviderDeliveryRequest): + raise TypeError("QQBot deliver 只接受 ProviderDeliveryRequest") + if request.binding_token != self._binding_token: + raise RuntimeError("QQBot delivery binding token 不匹配") + if request.attachments: + return ProviderDeliveryReceipt( + request.delivery_id, + DeliveryStatus.REJECTED, + error="QQBot v3 首批 adapter 只支持文本,附件未被读取或上传", + ) + if not request.body.strip(): + return ProviderDeliveryReceipt( + request.delivery_id, + DeliveryStatus.REJECTED, + error="QQBot 空消息被拒绝", + ) + status, provider_id, error = await self._send_text( + request.recipient, + request.body, + ) + return ProviderDeliveryReceipt( + request.delivery_id, + status, + (provider_id,) if provider_id else (), + error=error, + ) + + async def stop(self) -> StopReceipt: + """Close gateway, stream subscription, HTTP, and provider resources.""" + + self._stopping = True + failures: list[ChannelCleanupFailure] = [] - async def stop(self) -> None: + # 1. Close callback admission before provider resources. + subscription = self._stream_subscription + if subscription is not None: + try: + subscription.close_admission() + await subscription.await_quiescence() + await subscription.close() + self._stream_subscription = None + except BaseException as error: + failures.append(self._cleanup_failure("turn-stream", error)) + + # 2. Stop the receive loop and drain its heartbeat child. self._stopped.set() - if self._task: - _ = self._task.cancel() + gateway = self._gateway_task + if gateway is not None: + gateway.cancel() + result = await asyncio.gather(gateway, return_exceptions=True) + error = result[0] + if isinstance(error, BaseException) and not isinstance( + error, asyncio.CancelledError + ): + failures.append(self._cleanup_failure("gateway", error)) + else: + self._gateway_task = None + + # 3. Release formal clients; failed owners remain for exact retry. + if self._client is not None: try: - await self._task - except asyncio.CancelledError: - pass - self._task = None - await self._drain_live_tasks() - await self._client.aclose() - self._events_bound = False - self._outbound_bound = False - logger.info("[qqbot] 官方 QQBot 通道已停止") - - def _require_bus(self) -> MessageBus: - if self._bus is None: - raise RuntimeError("QQBotChannel 尚未启动") - return self._bus + await self._client.aclose() + except BaseException as error: + failures.append(self._cleanup_failure("http-client", error)) + else: + self._client = None + if self._provider_client is not None: + try: + await self._provider_client.aclose() + except BaseException as error: + failures.append(self._cleanup_failure("provider-client", error)) + else: + self._provider_client = None + + self._token = None + if failures: + return StopReceipt(self._binding_token, False, tuple(failures)) + self._started = False + self._stopping = False + self._clear_presentations() + return StopReceipt(self._binding_token, True) async def _gateway_loop(self) -> None: + """Reconnect the external gateway until Core stops this exact binding.""" + while not self._stopped.is_set(): try: token = await self._get_access_token() gateway = await self._api_request("GET", "/gateway", token=token) - url = str(gateway["url"]) - await self._run_gateway(url, token) + await self._run_gateway(str(gateway["url"]), token) except asyncio.CancelledError: raise - except Exception as e: - logger.warning("[qqbot] gateway 连接失败: %s", e) + except Exception as error: + logger.warning("[qqbot] gateway 连接失败: %s", error) await asyncio.sleep(5) async def _run_gateway(self, url: str, token: str) -> None: last_seq: int | None = None heartbeat_task: asyncio.Task[None] | None = None try: - async with websockets.connect(url) as ws: - async for raw in ws: + async with websockets.connect(url) as websocket: + async for raw in websocket: payload = json.loads(raw) - op = payload.get("op") - raw_data = payload.get("d") - data = cast(dict[str, Any], raw_data) if isinstance(raw_data, dict) else {} - event_type = payload.get("t") + data = _as_dict(payload.get("d")) if isinstance(payload.get("s"), int): last_seq = int(payload["s"]) - - if op == 10: - heartbeat_ms = int(data["heartbeat_interval"]) - await ws.send(json.dumps({ - "op": 2, - "d": { - "token": f"QQBot {token}", - "intents": self._intents(), - "shard": [0, 1], - }, - })) + if payload.get("op") == 10: + await websocket.send( + json.dumps( + { + "op": 2, + "d": { + "token": f"QQBot {token}", + "intents": 1 << 25, + "shard": [0, 1], + }, + } + ) + ) if heartbeat_task is not None: - _ = heartbeat_task.cancel() + heartbeat_task.cancel() await asyncio.gather(heartbeat_task, return_exceptions=True) heartbeat_task = asyncio.create_task( - self._heartbeat(ws, heartbeat_ms, lambda: last_seq) + self._heartbeat( + websocket, + int(data["heartbeat_interval"]), + lambda: last_seq, + ) ) - elif op == 0: - await self._handle_dispatch(str(event_type), data) - elif op == 7: + elif payload.get("op") == 0: + await self._handle_dispatch(str(payload.get("t") or ""), data) + elif payload.get("op") == 7: break finally: if heartbeat_task is not None: - _ = heartbeat_task.cancel() + heartbeat_task.cancel() await asyncio.gather(heartbeat_task, return_exceptions=True) async def _heartbeat( self, - ws: Any, + websocket: Any, heartbeat_ms: int, - seq_fn: Callable[[], int | None], + sequence: Callable[[], int | None], ) -> None: while True: await asyncio.sleep(max(1, heartbeat_ms / 1000)) - await ws.send(json.dumps({"op": 1, "d": seq_fn()})) - - def _intents(self) -> int: - return 1 << 25 + await websocket.send(json.dumps({"op": 1, "d": sequence()})) async def _handle_dispatch(self, event_type: str, data: dict[str, Any]) -> None: if event_type == "C2C_MESSAGE_CREATE": @@ -211,369 +312,255 @@ async def _handle_dispatch(self, event_type: str, data: dict[str, Any]) -> None: async def _handle_c2c(self, data: dict[str, Any]) -> None: author = _as_dict(data.get("author")) - user_openid = str(author.get("user_openid") or data.get("user_openid") or "") - if not user_openid: - return - if self._allow_from and user_openid not in self._allow_from: - logger.warning("[qqbot] 拒绝未授权私聊用户 user_openid=%s", user_openid) - return + openid = str(author.get("user_openid") or data.get("user_openid") or "").strip() + message_id = str(data.get("id") or "").strip() content = str(data.get("content") or "").strip() - message_id = str(data.get("id") or "") - if message_id: - self._last_c2c_msg_id[user_openid] = message_id - await self._send_input_notify(user_openid, message_id) - logger.info("[qqbot] 收到私聊消息 user_openid=%s msg_id=%s", user_openid, message_id) - if content == "/stop": - await self._handle_stop(f"c2c:{user_openid}", user_openid) + if not openid or not message_id or not content: + logger.warning("[qqbot] 拒绝缺少 identity/message/content 的私聊事件") + return + if not self._allow_from or openid not in self._allow_from: + logger.warning("[qqbot] 拒绝未授权私聊用户 user_openid=%s", openid) return - await self._require_bus().publish_inbound( - InboundMessage( + raw = RawInbound( + message_id=message_id, + message=ChannelInboundMessage( channel=_CHANNEL, - sender=user_openid, - chat_id=f"c2c:{user_openid}", + sender=openid, + chat_id=f"c2c:{openid}", content=content, + timestamp=datetime.now(timezone.utc), metadata={ "chat_type": "private", - "user_openid": user_openid, + "user_openid": openid, "message_id": message_id, }, - ) + ), + provider_identity=openid, + recipient=f"c2c:{openid}", ) - - async def _handle_stop(self, chat_id: str, sender: str) -> None: - if self._interrupt_controller is None: - await self.send(chat_id, "当前未启用中断功能。") + if content == "/stop": + presentation = self._require_presentation() + control = presentation.control + if control is None: + raise RuntimeError("QQBot control port 未绑定") + await control.interrupt( + raw, + response_bodies=ControlResponseBodies( + interrupted="已停止当前回复。", + idle="当前没有正在进行的回复。", + ), + ) return - result = self._interrupt_controller.request_interrupt( - session_key=f"{_CHANNEL}:{chat_id}", - sender=sender, - command="/stop", - ) - await self.send(chat_id, result.message) - - async def _on_response(self, msg: OutboundMessage) -> None: - session_key = f"{_CHANNEL}:{msg.chat_id}" - content = msg.content.strip() - sent_as_stream = False - if session_key in self._live_states: - await self._cancel_live_tasks(session_key) - if content: - sent_as_stream = await self._send_live_stream( - session_key, - msg.chat_id, - content, - terminal=True, + ingress = self._ingress + if ingress is None: + raise RuntimeError("QQBot ingress port 未绑定") + if await ingress.admit(raw): + self._message_recipients[message_id] = f"c2c:{openid}" + + async def _on_turn_stream(self, event: TurnStreamEvent) -> PresentationReceipt: + """Project input notify and temporary stream without replacing final delivery.""" + + if event.kind is TurnStreamEventKind.TURN_STARTED: + payload = cast(TurnStartedPresentation, event.payload) + recipient = self._message_recipients.pop(payload.client_message_id, None) + if recipient is None: + return PresentationReceipt( + event.presentation_id, + DeliveryStatus.REJECTED, + error="QQBot turn 缺少 accepted provider message identity", ) - else: - await self._delete_live_preview(session_key) - self._clear_live_session(session_key) - 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) - if kind != "c2c": - raise ValueError("当前 QQBotChannel 仅支持私聊 c2c") - await self.send(chat_id, message) - - async def send(self, chat_id: str, message: str) -> None: - kind, target = self._parse_chat_id(chat_id) - if kind != "c2c": - raise ValueError("当前 QQBotChannel 仅支持私聊 c2c") - token = await self._get_access_token() - body = self._build_message_body(message) - _ = await self._api_request("POST", f"/v2/users/{target}/messages", body, token) - - async def send_stream(self, chat_id: str, message: str) -> None: - kind, target = self._parse_chat_id(chat_id) - if kind != "c2c": - raise ValueError("当前 QQBotChannel 仅支持私聊 c2c") - msg_id = self._last_c2c_msg_id.get(target) - if not msg_id: - await self.send(chat_id, message) - return - try: - await self._send_stream_c2c(target, msg_id, message) - except Exception as e: - 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() - stream_msg_id = "" - chunks = list(_iter_stream_chunks(message)) - if not chunks: - chunks = [""] - for index, content in enumerate(chunks): - is_last = index == len(chunks) - 1 + self._presentation_recipients[event.presentation_id] = recipient + self._presentation_message_ids[event.presentation_id] = payload.client_message_id + status, provider_id, error = await self._send_input_notify( + recipient, + payload.client_message_id, + ) + return PresentationReceipt( + event.presentation_id, + status, + (provider_id,) if provider_id else (), + error, + ) + if event.kind is TurnStreamEventKind.STREAM_DELTA: + payload = cast(StreamDeltaPresentation, event.payload) + reply = self._reply_buffers.get(event.presentation_id, "") + self._reply_buffers[event.presentation_id] = reply + payload.text_delta + return await self._refresh_preview(event.presentation_id) + if event.kind is TurnStreamEventKind.TURN_OUTPUT_COMPLETED: + _ = cast(TurnOutputCompletedPresentation, event.payload) + return await self._finish_preview(event.presentation_id) + return PresentationReceipt(event.presentation_id, DeliveryStatus.DELIVERED) + + async def _refresh_preview(self, presentation_id: str) -> PresentationReceipt: + recipient = self._presentation_recipients.get(presentation_id) + text = _tail_text(self._reply_buffers.get(presentation_id, "").strip(), _REPLY_LIVE_TAIL) + if recipient is None or not text: + return PresentationReceipt(presentation_id, DeliveryStatus.DELIVERED) + now = asyncio.get_running_loop().time() + previous = self._live_last_lengths.get(presentation_id, 0) + if ( + now < self._live_next_at.get(presentation_id, 0.0) + and len(text) - previous < _LIVE_STREAM_MIN_CHARS + ): + return PresentationReceipt(presentation_id, DeliveryStatus.DELIVERED) + self._live_next_at[presentation_id] = now + _LIVE_STREAM_MIN_INTERVAL_S + self._live_last_lengths[presentation_id] = len(text) + return await self._send_preview(presentation_id, recipient, text) + + async def _send_preview( + self, + presentation_id: str, + recipient: str, + text: str, + ) -> PresentationReceipt: + if presentation_id in self._live_disabled: + return PresentationReceipt( + presentation_id, + DeliveryStatus.REJECTED, + error="QQBot preview 已关闭", + ) + _, openid = _parse_recipient(recipient) + state = self._live_states.get(presentation_id) + if state is None: + message_id = self._presentation_message_ids.get(presentation_id) + if message_id is None: + raise RuntimeError( + f"QQBot preview 缺少 provider message id: {presentation_id}" + ) + state = _LiveStreamState(openid, message_id, _next_msg_seq()) + self._live_states[presentation_id] = state + lock = self._live_locks.setdefault(presentation_id, asyncio.Lock()) + async with lock: body: dict[str, Any] = { "input_mode": "replace", - "input_state": 10 if is_last else 1, + "input_state": 1, "content_type": "markdown", - "content_raw": content, - "event_id": msg_id, - "msg_id": msg_id, - "msg_seq": msg_seq, - "index": index, + "content_raw": text, + "event_id": state.msg_id, + "msg_id": state.msg_id, + "msg_seq": state.msg_seq, + "index": state.index, } - if stream_msg_id: - body["stream_msg_id"] = stream_msg_id - result = await self._api_request( + if state.stream_msg_id: + body["stream_msg_id"] = state.stream_msg_id + status, payload, error = await self._request_with_status( "POST", f"/v2/users/{openid}/stream_messages", body, - token, ) - stream_msg_id = str(result.get("id") or stream_msg_id) - - async def _on_turn_started(self, event: TurnStarted) -> None: - if event.channel != _CHANNEL: - return - await self._cancel_live_tasks(event.session_key) - self._clear_live_session(event.session_key) + if status is DeliveryStatus.DELIVERED: + state.stream_msg_id = str(payload.get("id") or state.stream_msg_id) + state.index += 1 + self._live_failures[presentation_id] = 0 + else: + failures = self._live_failures.get(presentation_id, 0) + 1 + self._live_failures[presentation_id] = failures + if status is DeliveryStatus.REJECTED or failures >= _LIVE_MAX_FAILURES: + self._live_disabled.add(presentation_id) + return PresentationReceipt( + presentation_id, + status, + (state.stream_msg_id,) if state.stream_msg_id else (), + error, + ) - async def _on_stream_delta(self, event: StreamDeltaReady) -> None: - if event.channel != _CHANNEL: - return - if not event.content_delta: - return - reply = self._reply_buffers.get(event.session_key, "") - self._reply_buffers[event.session_key] = reply + event.content_delta - live_len = len(self._reply_buffers.get(event.session_key, "")) - last_len = self._live_last_lengths.get(event.session_key, 0) - now = asyncio.get_running_loop().time() - next_at = self._live_next_at.get(event.session_key, 0.0) - if now < next_at and live_len - last_len < _LIVE_STREAM_MIN_CHARS: - return - self._live_next_at[event.session_key] = now + _LIVE_STREAM_MIN_INTERVAL_S - self._live_last_lengths[event.session_key] = live_len - self._start_live_task( - event.session_key, - self._sync_live_message(event.session_key, event.chat_id), - ) + async def _finish_preview(self, presentation_id: str) -> PresentationReceipt: + state = self._live_states.get(presentation_id) + try: + if state is None or not state.stream_msg_id: + return PresentationReceipt(presentation_id, DeliveryStatus.DELIVERED) + status, _payload, error = await self._request_with_status( + "DELETE", + f"/v2/users/{state.openid}/messages/{state.stream_msg_id}", + ) + return PresentationReceipt( + presentation_id, + status, + (state.stream_msg_id,), + error, + ) + finally: + self._clear_presentation(presentation_id) - async def _sync_live_message( + async def _send_input_notify( self, - session_key: str, - chat_id: str, - ) -> None: - text = _format_turn_live(self._reply_buffers.get(session_key, "")) - if text: - _ = await self._send_live_stream(session_key, chat_id, text, terminal=False) + recipient: str, + message_id: str, + ) -> tuple[DeliveryStatus, str | None, str | None]: + _, openid = _parse_recipient(recipient) + status, payload, error = await self._request_with_status( + "POST", + f"/v2/users/{openid}/messages", + { + "msg_type": 6, + "input_notify": {"input_type": 1, "input_second": 60}, + "msg_seq": _next_msg_seq(), + "msg_id": message_id, + }, + ) + provider_id = str(payload.get("id") or "").strip() or None + return status, provider_id, error - async def _delete_live_preview( + async def _send_text( self, - session_key: str, - ) -> None: - state = self._live_states.get(session_key) - if state is None or not state.stream_msg_id: - return - try: - await self._delete_message( - state.openid, - state.stream_msg_id, - ) - except Exception as e: - logger.debug("[qqbot] 临时流式消息撤回失败,忽略: %s", e) + recipient: str, + message: str, + ) -> tuple[DeliveryStatus, str | None, str | None]: + _, openid = _parse_recipient(recipient) + status, payload, error = await self._request_with_status( + "POST", + f"/v2/users/{openid}/messages", + { + "markdown": {"content": message}, + "msg_type": 2, + "msg_seq": _next_msg_seq(), + }, + ) + provider_id = str(payload.get("id") or "").strip() or None + if status is DeliveryStatus.DELIVERED and provider_id is None: + return DeliveryStatus.UNKNOWN, None, "QQBot response 缺少 message id" + return status, provider_id, error - async def _send_live_stream( - self, - session_key: str, - chat_id: str, - text: str, - *, - terminal: bool, - ) -> bool: - if session_key in self._live_disabled: - return False - kind, openid = self._parse_chat_id(chat_id) - if kind != "c2c": - return False - msg_id = self._last_c2c_msg_id.get(openid) - if not msg_id: - return False - lock = self._live_locks.setdefault(session_key, asyncio.Lock()) - async with lock: - if session_key in self._live_disabled: - return False - state = self._live_states.get(session_key) - if state is None: - state = _LiveStreamState( - openid=openid, - msg_id=msg_id, - msg_seq=self._next_msg_seq(), - ) - self._live_states[session_key] = state - if state.completed: - return False - try: - token = await self._get_access_token() - body: dict[str, Any] = { - "input_mode": "replace", - "input_state": 10 if terminal else 1, - "content_type": "markdown", - "content_raw": text, - "event_id": state.msg_id, - "msg_id": state.msg_id, - "msg_seq": state.msg_seq, - "index": state.index, - } - if state.stream_msg_id: - body["stream_msg_id"] = state.stream_msg_id - result = await self._api_request( - "POST", - f"/v2/users/{state.openid}/stream_messages", - body, - token, - ) - except Exception as e: - failures = self._live_failures.get(session_key, 0) + 1 - self._live_failures[session_key] = failures - status_code = _http_status_code(e) - if (status_code is not None and status_code != 429) or failures >= _LIVE_MAX_FAILURES: - self._live_disabled.add(session_key) - logger.warning( - "[qqbot] 临时流式刷新失败,跳过本帧 session=%s failures=%d disabled=%s err=%s", - session_key, - failures, - session_key in self._live_disabled, - e, - ) - return False - self._live_failures[session_key] = 0 - state.stream_msg_id = str(result.get("id") or state.stream_msg_id) - state.index += 1 - state.completed = terminal - return True - - def _start_live_task( + async def _request_with_status( self, - session_key: str, - coro: Coroutine[Any, Any, None], - ) -> None: - task = asyncio.create_task(coro) - self._live_tasks.add(task) - self._live_tasks_by_session.setdefault(session_key, set()).add(task) - task.add_done_callback(lambda done: self._on_live_task_done(session_key, done)) - - def _on_live_task_done(self, session_key: str, task: asyncio.Task[None]) -> None: - self._live_tasks.discard(task) - tasks = self._live_tasks_by_session.get(session_key) - if tasks is not None: - tasks.discard(task) - if not tasks: - _ = self._live_tasks_by_session.pop(session_key, None) - if task.cancelled(): - return - exc = task.exception() - if exc is not None: - logger.debug("[qqbot] 临时流式状态刷新失败: %s", exc) - - async def _cancel_live_tasks(self, session_key: str) -> None: - tasks = list(self._live_tasks_by_session.get(session_key, set())) - for task in tasks: - _ = task.cancel() - if tasks: - _ = await asyncio.gather(*tasks, return_exceptions=True) - - async def _drain_live_tasks(self) -> None: - tasks = [task for task in self._live_tasks if not task.done()] - if tasks: - _ = await asyncio.gather(*tasks, return_exceptions=True) - - def _clear_live_session(self, session_key: str) -> None: - _ = self._live_states.pop(session_key, None) - _ = self._reply_buffers.pop(session_key, None) - _ = self._live_next_at.pop(session_key, None) - _ = self._live_last_lengths.pop(session_key, None) - _ = self._live_failures.pop(session_key, None) - self._live_disabled.discard(session_key) - _ = self._live_locks.pop(session_key, None) - - async def _send_input_notify(self, openid: str, msg_id: str) -> None: + method: str, + path: str, + body: dict[str, Any] | None = None, + ) -> tuple[DeliveryStatus, dict[str, Any], str | None]: try: - token = await self._get_access_token() - _ = await self._api_request( - "POST", - f"/v2/users/{openid}/messages", - { - "msg_type": 6, - "input_notify": {"input_type": 1, "input_second": 60}, - "msg_seq": self._next_msg_seq(), - "msg_id": msg_id, - }, - token, + payload = await self._api_request(method, path, body) + except asyncio.CancelledError: + raise + except httpx.HTTPStatusError as error: + status = error.response.status_code + delivery = ( + DeliveryStatus.REJECTED + if status in _REJECTED_HTTP_STATUSES + else DeliveryStatus.UNKNOWN ) - except Exception as e: - logger.debug("[qqbot] 发送输入中提示失败: %s", e) - - async def _delete_message(self, openid: str, message_id: str) -> None: - token = await self._get_access_token() - _ = await self._api_request( - "DELETE", - f"/v2/users/{openid}/messages/{message_id}", - token=token, - ) - - def _build_message_body(self, message: str) -> dict[str, Any]: - return { - "markdown": {"content": message}, - "msg_type": 2, - "msg_seq": self._next_msg_seq(), - } - - def _next_msg_seq(self) -> int: - return int(time.time() * 1000) % 65536 - - def _parse_chat_id(self, chat_id: str) -> tuple[str, str]: - value = chat_id.strip() - if value.startswith("qqbot:"): - value = value[len("qqbot:"):] - if ":" not in value: - return "c2c", value - kind, target = value.split(":", 1) - if kind not in {"c2c", "group"} or not target: - raise ValueError(f"无效的 QQBot chat_id: {chat_id!r}") - return kind, target + return delivery, {}, f"HTTP {status}" + except Exception as error: + return DeliveryStatus.UNKNOWN, {}, str(error) or type(error).__name__ + return DeliveryStatus.DELIVERED, payload, None async def _get_access_token(self) -> str: now = time.time() - if self._token and now < self._token.expires_at - 300: + if self._token is not None and now < self._token.expires_at - 300: return self._token.token - resp = await self._client.post( + client = self._require_client() + provider = self._provider_client + if provider is None: + raise RuntimeError("QQBot provider client 尚未创建") + app_id = provider.credential(self._credential_ref("app_id")) + client_secret = provider.credential(self._credential_ref("client_secret")) + response = await client.post( _TOKEN_URL, - json={"appId": self._app_id, "clientSecret": self._client_secret}, + json={"appId": app_id, "clientSecret": client_secret}, ) - _ = resp.raise_for_status() - data = resp.json() + response.raise_for_status() + data = response.json() token = str(data["access_token"]) - expires_in = int(data.get("expires_in") or 7200) - self._token = _TokenCache(token=token, expires_at=now + expires_in) + self._token = _TokenCache(token, now + int(data.get("expires_in") or 7200)) return token async def _api_request( @@ -592,36 +579,99 @@ async def _api_request( } if body is not None: kwargs["json"] = body - resp = await self._client.request(method, f"{_API_BASE}{path}", **kwargs) - _ = resp.raise_for_status() - if not resp.content: + response = await self._require_client().request( + method, + f"{_API_BASE}{path}", + **kwargs, + ) + response.raise_for_status() + if not response.content: return {} - data = resp.json() - return cast(dict[str, Any], data) if isinstance(data, dict) else {} + payload = response.json() + return cast(dict[str, Any], payload) if isinstance(payload, dict) else {} + + def _credential_ref(self, name: str) -> CredentialRef: + matches = [ + ref + for path, ref in self._credentials.items() + if path in _CREDENTIAL_ALIASES[name] + ] + if len(matches) != 1: + raise RuntimeError( + f"QQBot credential {name} 必须恰好有一个 physical alias" + ) + return matches[0] + + def _require_client(self) -> httpx.AsyncClient: + if self._client is None: + raise RuntimeError("QQBot adapter 尚未 start") + return self._client + + def _require_presentation(self) -> ChannelPresentationPorts: + if self._presentation is None: + raise RuntimeError("QQBot presentation ports 未绑定") + return self._presentation + + def _cleanup_failure(self, resource: str, error: BaseException) -> ChannelCleanupFailure: + return ChannelCleanupFailure( + stage="channel-stop", + plugin_id=_CHANNEL, + generation_id=self._context.generation_id, + binding_token=self._binding_token, + resource=resource, + error_type=type(error).__name__, + message=str(error) or type(error).__name__, + retry_action="retry_generation_cleanup", + ) + + def _clear_presentation(self, presentation_id: str) -> None: + self._presentation_recipients.pop(presentation_id, None) + self._presentation_message_ids.pop(presentation_id, None) + self._reply_buffers.pop(presentation_id, None) + self._live_states.pop(presentation_id, None) + self._live_next_at.pop(presentation_id, None) + self._live_last_lengths.pop(presentation_id, None) + self._live_failures.pop(presentation_id, None) + self._live_disabled.discard(presentation_id) + self._live_locks.pop(presentation_id, None) + + def _clear_presentations(self) -> None: + for presentation_id in tuple(self._presentation_recipients): + self._clear_presentation(presentation_id) + self._message_recipients.clear() + + +def _allow_from(config: Mapping[str, object]) -> frozenset[str]: + aliases = [config[key] for key in ("allow_from", "allowFrom") if key in config] + if len(aliases) > 1 and aliases[0] != aliases[1]: + raise RuntimeError("QQBot allow_from/allowFrom 声明冲突") + value = aliases[0] if aliases else () + if not isinstance(value, tuple) or any(not isinstance(item, str) for item in value): + raise TypeError("QQBot allow_from 必须是字符串 tuple") + return frozenset(item for item in value if item) + + +def _parse_recipient(recipient: str) -> tuple[str, str]: + value = recipient.strip() + if value.startswith("qqbot:"): + value = value[len("qqbot:") :] + if ":" not in value: + return "c2c", value + kind, target = value.split(":", 1) + if kind != "c2c" or not target: + raise ValueError(f"无效的 QQBot recipient: {recipient!r}") + return kind, target + + +def _next_msg_seq() -> int: + return int(time.time() * 1000) % 65536 def _as_dict(value: object) -> dict[str, Any]: return cast(dict[str, Any], value) if isinstance(value, dict) else {} -def _http_status_code(err: Exception) -> int | None: - if isinstance(err, httpx.HTTPStatusError): - return err.response.status_code - return None - - -def _iter_stream_chunks(text: str, limit: int = 160) -> list[str]: - chunks: list[str] = [] - for end in range(limit, len(text) + limit, limit): - chunks.append(text[:end]) - return chunks - - -def _format_turn_live(reply: str) -> str: - return _tail_text(reply.strip(), _REPLY_LIVE_TAIL) - - def _tail_text(text: str, limit: int) -> str: if len(text) <= limit: return text - return "..." + text[-(limit - 3):] + return "..." + text[-(limit - 3) :] diff --git a/config.py b/config.py new file mode 100644 index 0000000..f261d6f --- /dev/null +++ b/config.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from typing import Annotated + +from pydantic import AliasChoices, BaseModel, ConfigDict, Field + +from agent.plugin_composition import CredentialRef + + +class QQBotGroupConfig(BaseModel): + """Preserve the existing group configuration while group events stay disabled.""" + + model_config = ConfigDict(extra="forbid", validate_by_alias=True, validate_by_name=True) + + group_openid: str = Field( + default="", + validation_alias=AliasChoices("group_openid", "groupOpenid"), + ) + allow_from: tuple[str, ...] = Field( + default=(), + validation_alias=AliasChoices("allow_from", "allowFrom"), + ) + require_at: bool = Field( + default=True, + validation_alias=AliasChoices("require_at", "requireAt"), + ) + allow_proactive: bool = Field( + default=False, + validation_alias=AliasChoices("allow_proactive", "allowProactive"), + ) + + +class QQBotConfig(BaseModel): + """Validate QQBot's redacted Core config projection.""" + + model_config = ConfigDict( + arbitrary_types_allowed=True, + extra="forbid", + validate_by_alias=True, + validate_by_name=False, + ) + + app_id: Annotated[ + CredentialRef | None, + Field(validation_alias=AliasChoices("appId", "app_id")), + ] = None + client_secret: Annotated[ + CredentialRef | None, + Field(validation_alias=AliasChoices("clientSecret", "client_secret")), + ] = None + allow_from: tuple[str, ...] = Field( + default=(), + validation_alias=AliasChoices("allow_from", "allowFrom"), + ) + groups: tuple[QQBotGroupConfig, ...] = () diff --git a/plugin.py b/plugin.py index 46adaaa..b60b6fc 100644 --- a/plugin.py +++ b/plugin.py @@ -1,78 +1,64 @@ from __future__ import annotations -import re -from typing import TYPE_CHECKING, cast +from agent.plugin_composition import ( + CHANNELS, + ChannelCapability, + ChannelDefinition, + Context, + InboundIdentity, + PluginChannels, +) -from pydantic import AliasChoices, BaseModel, Field, field_validator +from .channel import QQBotAdapter, build_qqbot_channel +from .config import QQBotConfig -from agent.plugins import Plugin -from .channel import QQBotChannel -if TYPE_CHECKING: - from infra.channels.contract import Channel +api_version = 3 +name = "qqbot" +version = "3.0.0" +desc = "官方 QQBot 私聊 v3 channel adapter" +author = "Akashic" +inject = (CHANNELS,) +Config = QQBotConfig -_UNRESOLVED_ENV_RE = re.compile(r"^\$\{\w+\}$") +async def apply(ctx: Context, config: QQBotConfig) -> None: + """Register the immutable QQBot channel definition in the exact Root.""" -class QQBotGroupConfigModel(BaseModel): - group_openid: str = Field( - default="", - validation_alias=AliasChoices("group_openid", "groupOpenid"), + channels: PluginChannels = ctx.require(CHANNELS) + await channels.register( + ctx, + ChannelDefinition( + name="qqbot", + capabilities=frozenset( + { + ChannelCapability.INBOUND, + ChannelCapability.OUTBOUND, + ChannelCapability.CONTROL, + ChannelCapability.TURN_STREAM, + } + ), + factory_export="build_qqbot_channel", + inbound_identity=InboundIdentity.PROVIDER_MESSAGE_ID, + credential_paths=( + "appId", + "app_id", + "clientSecret", + "client_secret", + ), + ), ) - allow_from: list[str] = Field( - default_factory=list, - validation_alias=AliasChoices("allow_from", "allowFrom"), - ) - require_at: bool = Field( - default=True, - validation_alias=AliasChoices("require_at", "requireAt"), - ) - allow_proactive: bool = Field( - default=False, - validation_alias=AliasChoices("allow_proactive", "allowProactive"), - ) - - -class QQBotConfigModel(BaseModel): - app_id: str = Field( - default="", - validation_alias=AliasChoices("app_id", "appId"), - ) - client_secret: str = Field( - default="", - validation_alias=AliasChoices("client_secret", "clientSecret"), - ) - allow_from: list[str] = Field( - default_factory=list, - validation_alias=AliasChoices("allow_from", "allowFrom"), - ) - groups: list[QQBotGroupConfigModel] = Field(default_factory=list) - - @field_validator("app_id", "client_secret", mode="before") - @classmethod - def _normalize_optional_text(cls, value: object) -> str: - text = str(value or "").strip() - if _UNRESOLVED_ENV_RE.fullmatch(text): - return "" - return text - -class QQBotPlugin(Plugin): - api_version = 2 - name = "qqbot" - version = "1.0.0" - desc = "官方 QQBot 渠道" - ConfigModel = QQBotConfigModel - def channels(self) -> list["Channel"]: - config = cast(QQBotConfigModel | None, self.context.config) - if config is None or not config.app_id or not config.client_secret: - return [] - return [ - QQBotChannel( - app_id=config.app_id, - client_secret=config.client_secret, - allow_from=config.allow_from, - groups=config.groups, - ) - ] +__all__ = [ + "Config", + "QQBotAdapter", + "api_version", + "apply", + "author", + "build_qqbot_channel", + "desc", + "inject", + "name", + "version", +] diff --git a/tests/test_plugin.py b/tests/test_plugin.py index c5c7bc9..a355295 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -9,24 +9,38 @@ import pytest -from agent.tools.message_push import MessagePushTool -from bus.events import ( +from agent.plugin_composition.channels import ( AttachmentKind, - ChannelAttachment, - ChannelMessage, + AttachmentRef, + ChannelDeliveryReceipt, + ChannelFactoryContext, + ChannelPresentationPorts, + ControlReceipt, + CredentialRef, DeliveryStatus, + PresentationReceipt, + ProviderDeliveryReceipt, + ProviderDeliveryRequest, + RawInbound, + StreamDeltaPresentation, + TurnOutputCompletedPresentation, + TurnStartedPresentation, + TurnStreamEvent, + TurnStreamEventKind, ) +ROOT = Path(__file__).parents[1] + + def _load_plugin_module(): - path = Path(__file__).parents[1] / "plugin.py" spec = importlib.util.spec_from_file_location( - "test_qqbot_plugin", - path, - submodule_search_locations=[str(path.parent)], + "qqbot_v3_test_plugin", + ROOT / "plugin.py", + submodule_search_locations=[str(ROOT)], ) if spec is None or spec.loader is None: - raise ImportError(str(path)) + raise ImportError("unable to load QQBot plugin") module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) @@ -34,45 +48,337 @@ def _load_plugin_module(): module = _load_plugin_module() -QQBotConfigModel = module.QQBotConfigModel -QQBotPlugin = module.QQBotPlugin -def test_qqbot_plugin_without_config_returns_no_channels() -> None: - plugin = QQBotPlugin() - plugin.context = type("Ctx", (), {"config": None})() - assert plugin.channels() == [] +class FakeProviderClient: + def __init__(self) -> None: + self.closed = False + self.requested: list[tuple[str, ...]] = [] + def credential(self, ref: CredentialRef) -> str: + self.requested.append(ref.path) + if ref.path == ("appId",): + return "app" + if ref.path == ("clientSecret",): + return "secret" + raise KeyError(ref.path) -def test_qqbot_plugin_with_config_returns_channel() -> None: - plugin = QQBotPlugin() - plugin.context = type( - "Ctx", - (), - { - "config": QQBotConfigModel( - app_id="app", - client_secret="secret", - allow_from=[], - groups=[], - ) + async def aclose(self) -> None: + self.closed = True + + +class FakeProviderFactory: + def __init__(self) -> None: + self.client = FakeProviderClient() + self.create_calls = 0 + self.received: dict[str, CredentialRef] | None = None + + async def create(self, credentials): + self.create_calls += 1 + self.received = dict(credentials) + return self.client + + async def aclose(self) -> None: + return None + + +class FakeIngress: + def __init__(self, accepted: bool = True) -> None: + self.accepted = accepted + self.raw: list[RawInbound] = [] + + async def admit(self, raw: RawInbound) -> bool: + self.raw.append(raw) + return self.accepted + + +class FakeIdentity: + def resolve(self, provider_identity: str) -> str | None: + _ = provider_identity + return None + + +class FakeControl: + def __init__(self) -> None: + self.raw: RawInbound | None = None + + async def interrupt(self, raw: RawInbound, *, response_bodies) -> ControlReceipt: + self.raw = raw + return ControlReceipt( + accepted=True, + reason="interrupted", + response=ChannelDeliveryReceipt("control", DeliveryStatus.DELIVERED), + ) + + +class FakeSubscription: + def __init__(self, callback) -> None: + self.callback = callback + self.admission_closed = False + self.closed = False + + def close_admission(self) -> None: + self.admission_closed = True + + async def await_quiescence(self) -> None: + return None + + async def close(self) -> None: + self.closed = True + + +class FakeTurnStream: + def __init__(self) -> None: + self.subscription: FakeSubscription | None = None + + def subscribe(self, callback) -> FakeSubscription: + self.subscription = FakeSubscription(callback) + return self.subscription + + +def _context( + *, + factory: FakeProviderFactory | None = None, + ingress: FakeIngress | None = None, + control: FakeControl | None = None, + stream: FakeTurnStream | None = None, + config: dict[str, object] | None = None, +) -> ChannelFactoryContext: + return ChannelFactoryContext( + snapshot_id="snapshot-1", + generation_id="generation-1", + binding_token="binding-1", + config=config or {"allow_from": ("allowed",)}, + credentials={ + "appId": CredentialRef(("appId",)), + "clientSecret": CredentialRef(("clientSecret",)), }, - )() - assert len(plugin.channels()) == 1 + provider_client_factory=factory or FakeProviderFactory(), + ingress=ingress or FakeIngress(), + identity=FakeIdentity(), + control=control or FakeControl(), + turn_stream=stream or FakeTurnStream(), + ) + + +def test_plugin_is_pure_v3_and_declares_exact_channel() -> None: + from agent.plugins.composable import ComposablePlugin + from agent.plugins.static_manifest import load_static_plugin_manifest + + instance = ComposablePlugin.from_module(module) + assert instance.api_version == 3 + assert not hasattr(module, "QQBotPlugin") + manifest = load_static_plugin_manifest(ROOT) + assert manifest.channel_credentials == ( + ( + "qqbot", + ("appId", "app_id", "clientSecret", "client_secret"), + ), + ) + + +def test_config_accepts_credential_refs_and_both_allowlist_aliases() -> None: + from pydantic import ValidationError + + config = module.Config.model_validate( + { + "appId": CredentialRef(("appId",)), + "clientSecret": CredentialRef(("clientSecret",)), + "allowFrom": ["alice"], + } + ) + assert config.allow_from == ("alice",) + with pytest.raises(ValidationError): + module.Config.model_validate({"appId": "raw-secret"}) + + +@pytest.mark.asyncio +async def test_apply_registers_exact_channel_definition() -> None: + calls = [] + + class Channels: + async def register(self, ctx, definition) -> None: + calls.append((ctx, definition)) + + class Context: + def require(self, key): + assert key.name == "core.channels" + return Channels() + + await module.apply(Context(), module.Config()) + definition = calls[0][1] + assert definition.name == "qqbot" + assert {item.value for item in definition.capabilities} == { + "inbound", + "outbound", + "control", + "turn_stream", + } + assert definition.factory_export == "build_qqbot_channel" + + +def test_candidate_factory_has_no_network_or_secret_effect() -> None: + factory = FakeProviderFactory() + adapter = module.build_qqbot_channel(_context(factory=factory)) + assert factory.create_calls == 0 + assert adapter._provider_client is None + assert adapter._client is None + assert adapter._gateway_task is None + + +@pytest.mark.asyncio +async def test_formal_start_delivery_and_stop_use_controlled_client() -> None: + factory = FakeProviderFactory() + stream = FakeTurnStream() + adapter = module.build_qqbot_channel(_context(factory=factory, stream=stream)) + + async def gateway() -> None: + await adapter._stopped.wait() + + adapter._gateway_loop = gateway + adapter.attach_presentation(ChannelPresentationPorts(FakeControl(), stream)) + ready = await adapter.start() + assert not ready.admission_open + assert factory.create_calls == 1 + + async def sent(_recipient: str, _message: str): + return DeliveryStatus.DELIVERED, "provider-1", None + + adapter._send_text = sent + receipt = await adapter.deliver( + ProviderDeliveryRequest("binding-1", "delivery-1", "c2c:alice", "hello") + ) + assert receipt == ProviderDeliveryReceipt( + "delivery-1", + DeliveryStatus.DELIVERED, + ("provider-1",), + ) + stopped = await adapter.stop() + assert stopped.resources_closed + assert factory.client.closed + assert stream.subscription is not None and stream.subscription.closed + + +@pytest.mark.asyncio +async def test_attachment_is_rejected_before_provider_effect() -> None: + factory = FakeProviderFactory() + adapter = module.build_qqbot_channel(_context(factory=factory)) + attachment = AttachmentRef( + artifact_id="artifact-1", + kind=AttachmentKind.FILE, + filename="a.txt", + media_type="text/plain", + size_bytes=1, + sha256="0" * 64, + ) + receipt = await adapter.deliver( + ProviderDeliveryRequest( + "binding-1", + "delivery-1", + "c2c:alice", + "body", + (attachment,), + ) + ) + assert receipt.status is DeliveryStatus.REJECTED + assert factory.create_calls == 0 + + +@pytest.mark.asyncio +async def test_inbound_is_allowlisted_and_stop_uses_control_port() -> None: + ingress = FakeIngress() + control = FakeControl() + stream = FakeTurnStream() + adapter = module.build_qqbot_channel( + _context(ingress=ingress, control=control, stream=stream) + ) + adapter.attach_presentation(ChannelPresentationPorts(control, stream)) + await adapter._handle_c2c( + {"id": "msg-1", "content": "hello", "author": {"user_openid": "allowed"}} + ) + assert ingress.raw[0].provider_identity == "allowed" + assert ingress.raw[0].recipient == "c2c:allowed" + + await adapter._handle_c2c( + {"id": "msg-2", "content": "/stop", "author": {"user_openid": "allowed"}} + ) + assert control.raw is not None and control.raw.message_id == "msg-2" + assert [raw.message_id for raw in ingress.raw] == ["msg-1"] + + await adapter._handle_c2c( + {"id": "msg-3", "content": "blocked", "author": {"user_openid": "mallory"}} + ) + assert [raw.message_id for raw in ingress.raw] == ["msg-1"] + + closed_ingress = FakeIngress() + closed = module.build_qqbot_channel( + _context(ingress=closed_ingress, config={"allow_from": ()}) + ) + await closed._handle_c2c( + {"id": "msg-4", "content": "blocked", "author": {"user_openid": "allowed"}} + ) + assert closed_ingress.raw == [] + + +@pytest.mark.asyncio +async def test_turn_preview_never_substitutes_final_delivery() -> None: + adapter = module.build_qqbot_channel(_context()) + adapter._message_recipients["msg-1"] = "c2c:allowed" + calls: list[tuple[str, str]] = [] + + async def request(method: str, path: str, body=None): + calls.append((method, path)) + return DeliveryStatus.DELIVERED, {"id": f"provider-{len(calls)}"}, None + + adapter._request_with_status = request + started = await adapter._on_turn_stream( + TurnStreamEvent( + "preview:turn-1", + TurnStreamEventKind.TURN_STARTED, + TurnStartedPresentation("turn-1", "msg-1"), + ) + ) + delta = await adapter._on_turn_stream( + TurnStreamEvent( + "preview:turn-1", + TurnStreamEventKind.STREAM_DELTA, + StreamDeltaPresentation("turn-1", 1, "answer", ""), + ) + ) + completed = await adapter._on_turn_stream( + TurnStreamEvent( + "preview:turn-1", + TurnStreamEventKind.TURN_OUTPUT_COMPLETED, + TurnOutputCompletedPresentation("turn-1", 2), + ) + ) + + assert started.status is DeliveryStatus.DELIVERED + assert delta.status is DeliveryStatus.DELIVERED + assert completed.status is DeliveryStatus.DELIVERED + assert [method for method, _path in calls] == ["POST", "POST", "DELETE"] + assert "preview:turn-1" not in adapter._live_states + + final_calls: list[str] = [] + + async def final(_recipient: str, body: str): + final_calls.append(body) + return DeliveryStatus.DELIVERED, "final-provider", None + + adapter._send_text = final + final_receipt = await adapter.deliver( + ProviderDeliveryRequest("binding-1", "final-1", "c2c:allowed", "answer") + ) + assert final_receipt.status is DeliveryStatus.DELIVERED + assert final_calls == ["answer"] @pytest.mark.asyncio async def test_gateway_cancellation_reaps_heartbeat( monkeypatch: pytest.MonkeyPatch, ) -> None: - plugin = QQBotPlugin() - plugin.context = type( - "Ctx", - (), - {"config": QQBotConfigModel(app_id="app", client_secret="secret")}, - )() - channel = plugin.channels()[0] - channel_module = sys.modules[type(channel).__module__] + adapter = module.build_qqbot_channel(_context()) + channel_module = sys.modules[type(adapter).__module__] heartbeat_started = asyncio.Event() heartbeat_cancelled = asyncio.Event() block_gateway = asyncio.Event() @@ -93,9 +399,7 @@ def __aiter__(self): async def __anext__(self): if not self.sent_ready: self.sent_ready = True - return json.dumps( - {"op": 10, "d": {"heartbeat_interval": 1000}} - ) + return json.dumps({"op": 10, "d": {"heartbeat_interval": 1000}}) await block_gateway.wait() raise StopAsyncIteration @@ -110,84 +414,42 @@ async def heartbeat(*_args) -> None: heartbeat_cancelled.set() monkeypatch.setattr(channel_module.websockets, "connect", lambda _url: WebSocket()) - channel._heartbeat = heartbeat - gateway = asyncio.create_task(channel._run_gateway("ws://test", "token")) + adapter._heartbeat = heartbeat + gateway = asyncio.create_task(adapter._run_gateway("ws://test", "token")) await heartbeat_started.wait() gateway.cancel() - with pytest.raises(asyncio.CancelledError): await gateway assert heartbeat_cancelled.is_set() @pytest.mark.asyncio -async def test_channel_can_start_stop_twice() -> None: - plugin = QQBotPlugin() - plugin.context = type( - "Ctx", - (), - {"config": QQBotConfigModel(app_id="app", client_secret="secret")}, - )() - channel = plugin.channels()[0] - starts = 0 - - async def gateway_loop() -> None: - nonlocal starts - starts += 1 - await asyncio.Event().wait() - - channel._gateway_loop = gateway_loop - registry = SimpleNamespace( - on=lambda *_args: object(), - subscribe_outbound=lambda *_args: object(), - ) - push_tools = [MessagePushTool(), MessagePushTool()] - context = SimpleNamespace( - bus=registry, - event_bus=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", "正文")] +async def test_provider_close_failure_is_retained_for_retry() -> None: + class FlakyClient(FakeProviderClient): + def __init__(self) -> None: + super().__init__() + self.attempts = 0 + + async def aclose(self) -> None: + self.attempts += 1 + if self.attempts == 1: + raise RuntimeError("close failed") + await super().aclose() + + factory = FakeProviderFactory() + factory.client = FlakyClient() + stream = FakeTurnStream() + adapter = module.build_qqbot_channel(_context(factory=factory, stream=stream)) + + async def gateway() -> None: + await adapter._stopped.wait() + + adapter._gateway_loop = gateway + adapter.attach_presentation(ChannelPresentationPorts(FakeControl(), stream)) + await adapter.start() + first = await adapter.stop() + assert not first.resources_closed + assert any(item.resource == "provider-client" for item in first.failures) + second = await adapter.stop() + assert second.resources_closed + assert factory.client.attempts == 2 From 0f40037229b47da570ff0116a5e368a4da3331d6 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Mon, 17 Aug 2026 19:51:49 +0800 Subject: [PATCH 2/9] fix(qqbot): fail closed at v3 provider boundaries --- .github/workflows/plugin-api-v3.yml | 2 +- channel.py | 108 ++++++++++++++++++++----- tests/test_plugin.py | 119 ++++++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 19 deletions(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index c3561f6..b224e65 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -58,7 +58,7 @@ jobs: - name: Check v3 source types env: PYTHONPATH: .akashic-core - run: .venv/bin/basedpyright --level error plugin.py channel.py config.py tests + run: .venv/bin/pyright --level error plugin.py channel.py config.py tests - name: Compile Python sources run: python -m compileall -q plugin.py channel.py config.py tests - name: Check diff formatting diff --git a/channel.py b/channel.py index be9e5e9..4c548c9 100644 --- a/channel.py +++ b/channel.py @@ -113,6 +113,7 @@ def __init__(self, context: ChannelFactoryContext) -> None: self._live_last_lengths: dict[str, int] = {} self._live_failures: dict[str, int] = {} self._live_disabled: set[str] = set() + self._live_uncertain: set[str] = set() self._live_locks: dict[str, asyncio.Lock] = {} def attach_presentation(self, ports: ChannelPresentationPorts) -> None: @@ -132,11 +133,15 @@ async def start(self) -> ChannelReady: if self._presentation is None: raise RuntimeError("QQBot adapter 缺少 presentation ports") - # 1. Resolve only through the formal provider factory. + # 1. Validate both credential identities before acquiring any resource. + self._credential_ref("app_id") + self._credential_ref("client_secret") + + # 2. Resolve only through the formal provider factory. self._provider_client = await self._provider_factory.create(self._credentials) self._client = httpx.AsyncClient(timeout=30.0) - # 2. Attach the exact presentation callback before receiving provider input. + # 3. Attach the exact presentation callback before receiving provider input. turn_stream = self._presentation.turn_stream if turn_stream is None: raise RuntimeError("QQBot turn stream port 未绑定") @@ -172,6 +177,20 @@ async def deliver(self, request: ProviderDeliveryRequest) -> ProviderDeliveryRec DeliveryStatus.REJECTED, error="QQBot 空消息被拒绝", ) + if not isinstance(request.recipient, str): + return ProviderDeliveryReceipt( + request.delivery_id, + DeliveryStatus.REJECTED, + error="QQBot recipient 必须是字符串", + ) + try: + _parse_recipient(request.recipient) + except ValueError as error: + return ProviderDeliveryReceipt( + request.delivery_id, + DeliveryStatus.REJECTED, + error=str(error), + ) status, provider_id, error = await self._send_text( request.recipient, request.body, @@ -259,7 +278,14 @@ async def _run_gateway(self, url: str, token: str) -> None: async with websockets.connect(url) as websocket: async for raw in websocket: payload = json.loads(raw) - data = _as_dict(payload.get("d")) + if not isinstance(payload, dict): + logger.warning("[qqbot] 拒绝非 object gateway payload") + continue + raw_data = payload.get("d") + if not isinstance(raw_data, dict): + logger.warning("[qqbot] 拒绝非 object gateway data") + continue + data = cast(dict[str, Any], raw_data) if isinstance(payload.get("s"), int): last_seq = int(payload["s"]) if payload.get("op") == 10: @@ -311,10 +337,30 @@ async def _handle_dispatch(self, event_type: str, data: dict[str, Any]) -> None: logger.debug("[qqbot] 当前仅启用私聊模式,忽略群事件 event=%s", event_type) async def _handle_c2c(self, data: dict[str, Any]) -> None: - author = _as_dict(data.get("author")) - openid = str(author.get("user_openid") or data.get("user_openid") or "").strip() - message_id = str(data.get("id") or "").strip() - content = str(data.get("content") or "").strip() + if not isinstance(data, dict): + logger.warning("[qqbot] 拒绝非 object 私聊 data") + return + raw_author = data.get("author") + if raw_author is not None and not isinstance(raw_author, dict): + logger.warning("[qqbot] 拒绝非 object 私聊 author") + return + author = raw_author if isinstance(raw_author, dict) else {} + raw_openid = ( + author["user_openid"] + if "user_openid" in author + else data.get("user_openid") + ) + if not isinstance(raw_openid, str): + logger.warning("[qqbot] 拒绝非 string user_openid") + return + openid = raw_openid.strip() + raw_message_id = data.get("id") + raw_content = data.get("content") + if not isinstance(raw_message_id, str) or not isinstance(raw_content, str): + logger.warning("[qqbot] 拒绝非 string identity/message/content") + return + message_id = raw_message_id.strip() + content = raw_content.strip() if not openid or not message_id or not content: logger.warning("[qqbot] 拒绝缺少 identity/message/content 的私聊事件") return @@ -449,13 +495,27 @@ async def _send_preview( body, ) if status is DeliveryStatus.DELIVERED: - state.stream_msg_id = str(payload.get("id") or state.stream_msg_id) - state.index += 1 - self._live_failures[presentation_id] = 0 - else: + remote_id = payload.get("id") + if isinstance(remote_id, str) and remote_id.strip(): + state.stream_msg_id = remote_id.strip() + state.index += 1 + self._live_failures[presentation_id] = 0 + self._live_uncertain.discard(presentation_id) + else: + status = DeliveryStatus.UNKNOWN + error = ( + "QQBot preview 2xx response 缺少 stream message id," + "外部效果未确认" + ) + self._live_uncertain.add(presentation_id) + if status is not DeliveryStatus.DELIVERED: failures = self._live_failures.get(presentation_id, 0) + 1 self._live_failures[presentation_id] = failures - if status is DeliveryStatus.REJECTED or failures >= _LIVE_MAX_FAILURES: + if ( + status is DeliveryStatus.REJECTED + or presentation_id in self._live_uncertain + or failures >= _LIVE_MAX_FAILURES + ): self._live_disabled.add(presentation_id) return PresentationReceipt( presentation_id, @@ -466,13 +526,25 @@ async def _send_preview( async def _finish_preview(self, presentation_id: str) -> PresentationReceipt: state = self._live_states.get(presentation_id) + clear_state = True try: - if state is None or not state.stream_msg_id: + if state is None: + return PresentationReceipt(presentation_id, DeliveryStatus.DELIVERED) + if presentation_id in self._live_uncertain: + clear_state = False + return PresentationReceipt( + presentation_id, + DeliveryStatus.UNKNOWN, + error="QQBot preview 外部效果未确认,保留本地失败状态", + ) + if not state.stream_msg_id: return PresentationReceipt(presentation_id, DeliveryStatus.DELIVERED) status, _payload, error = await self._request_with_status( "DELETE", f"/v2/users/{state.openid}/messages/{state.stream_msg_id}", ) + if status is DeliveryStatus.UNKNOWN: + clear_state = False return PresentationReceipt( presentation_id, status, @@ -480,7 +552,8 @@ async def _finish_preview(self, presentation_id: str) -> PresentationReceipt: error, ) finally: - self._clear_presentation(presentation_id) + if clear_state: + self._clear_presentation(presentation_id) async def _send_input_notify( self, @@ -633,6 +706,7 @@ def _clear_presentation(self, presentation_id: str) -> None: self._live_last_lengths.pop(presentation_id, None) self._live_failures.pop(presentation_id, None) self._live_disabled.discard(presentation_id) + self._live_uncertain.discard(presentation_id) self._live_locks.pop(presentation_id, None) def _clear_presentations(self) -> None: @@ -655,6 +729,8 @@ def _parse_recipient(recipient: str) -> tuple[str, str]: value = recipient.strip() if value.startswith("qqbot:"): value = value[len("qqbot:") :] + if not value: + raise ValueError(f"无效的 QQBot recipient: {recipient!r}") if ":" not in value: return "c2c", value kind, target = value.split(":", 1) @@ -667,10 +743,6 @@ def _next_msg_seq() -> int: return int(time.time() * 1000) % 65536 -def _as_dict(value: object) -> dict[str, Any]: - return cast(dict[str, Any], value) if isinstance(value, dict) else {} - - def _tail_text(text: str, limit: int) -> str: if len(text) <= limit: return text diff --git a/tests/test_plugin.py b/tests/test_plugin.py index a355295..2dec47e 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -4,6 +4,7 @@ import importlib.util import json import sys +from dataclasses import replace from pathlib import Path from types import SimpleNamespace @@ -259,6 +260,36 @@ async def sent(_recipient: str, _message: str): assert stream.subscription is not None and stream.subscription.closed +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("credentials", "missing"), + [ + ({"appId": CredentialRef(("appId",))}, "client_secret"), + ({"clientSecret": CredentialRef(("clientSecret",))}, "app_id"), + ], +) +async def test_formal_start_rejects_missing_credential_before_resources( + credentials: dict[str, CredentialRef], + missing: str, +) -> None: + factory = FakeProviderFactory() + stream = FakeTurnStream() + context = replace( + _context(factory=factory, stream=stream), + credentials=credentials, + ) + adapter = module.build_qqbot_channel(context) + adapter.attach_presentation(ChannelPresentationPorts(FakeControl(), stream)) + + with pytest.raises(RuntimeError, match=missing): + await adapter.start() + + assert factory.create_calls == 0 + assert adapter._provider_client is None + assert adapter._client is None + assert adapter._gateway_task is None + + @pytest.mark.asyncio async def test_attachment_is_rejected_before_provider_effect() -> None: factory = FakeProviderFactory() @@ -284,6 +315,29 @@ async def test_attachment_is_rejected_before_provider_effect() -> None: assert factory.create_calls == 0 +@pytest.mark.asyncio +@pytest.mark.parametrize("recipient", ["group:group-1"]) +async def test_invalid_recipient_returns_rejected_without_provider_effect( + recipient: str, +) -> None: + adapter = module.build_qqbot_channel(_context()) + called = False + + async def send(_recipient: str, _message: str): + nonlocal called + called = True + return DeliveryStatus.DELIVERED, "provider-1", None + + adapter._send_text = send + receipt = await adapter.deliver( + ProviderDeliveryRequest("binding-1", "delivery-1", recipient, "body") + ) + + assert receipt.status is DeliveryStatus.REJECTED + assert receipt.error is not None + assert not called + + @pytest.mark.asyncio async def test_inbound_is_allowlisted_and_stop_uses_control_port() -> None: ingress = FakeIngress() @@ -320,6 +374,28 @@ async def test_inbound_is_allowlisted_and_stop_uses_control_port() -> None: assert closed_ingress.raw == [] +@pytest.mark.asyncio +@pytest.mark.parametrize( + "payload", + [ + {"id": "msg-1", "content": "hello", "author": {"user_openid": 7}}, + {"id": "msg-2", "content": 7, "author": {"user_openid": "allowed"}}, + {"id": 7, "content": "hello", "author": {"user_openid": "allowed"}}, + {"id": "msg-4", "content": "hello", "author": []}, + {"id": "msg-5", "content": "hello", "user_openid": 7}, + ], +) +async def test_inbound_identity_and_payload_types_fail_closed( + payload: dict[str, object], +) -> None: + ingress = FakeIngress() + adapter = module.build_qqbot_channel(_context(ingress=ingress)) + + await adapter._handle_c2c(payload) + + assert ingress.raw == [] + + @pytest.mark.asyncio async def test_turn_preview_never_substitutes_final_delivery() -> None: adapter = module.build_qqbot_channel(_context()) @@ -373,6 +449,49 @@ async def final(_recipient: str, body: str): assert final_calls == ["answer"] +@pytest.mark.asyncio +async def test_preview_missing_remote_id_is_unknown_and_not_deleted() -> None: + adapter = module.build_qqbot_channel(_context()) + adapter._message_recipients["msg-1"] = "c2c:allowed" + calls: list[tuple[str, str]] = [] + + async def request(method: str, path: str, body=None): + _ = body + calls.append((method, path)) + return DeliveryStatus.DELIVERED, {}, None + + adapter._request_with_status = request + started = await adapter._on_turn_stream( + TurnStreamEvent( + "preview:turn-1", + TurnStreamEventKind.TURN_STARTED, + TurnStartedPresentation("turn-1", "msg-1"), + ) + ) + preview = await adapter._on_turn_stream( + TurnStreamEvent( + "preview:turn-1", + TurnStreamEventKind.STREAM_DELTA, + StreamDeltaPresentation("turn-1", 1, "x" * 120, ""), + ) + ) + completed = await adapter._on_turn_stream( + TurnStreamEvent( + "preview:turn-1", + TurnStreamEventKind.TURN_OUTPUT_COMPLETED, + TurnOutputCompletedPresentation("turn-1", 2), + ) + ) + + assert started.status is DeliveryStatus.DELIVERED + assert preview.status is DeliveryStatus.UNKNOWN + assert completed.status is DeliveryStatus.UNKNOWN + assert preview.error is not None and "缺少 stream message id" in preview.error + assert [method for method, _path in calls] == ["POST", "POST"] + assert "preview:turn-1" in adapter._live_states + assert "preview:turn-1" in adapter._live_uncertain + + @pytest.mark.asyncio async def test_gateway_cancellation_reaps_heartbeat( monkeypatch: pytest.MonkeyPatch, From b5abd0ad8db30aff75fd608623b1775e039a234e Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Mon, 17 Aug 2026 21:27:27 +0800 Subject: [PATCH 3/9] fix(qqbot): close v3 delivery and cleanup boundaries --- channel.py | 223 +++++++++++++++++++++++++++++++++---------- tests/test_plugin.py | 151 ++++++++++++++++++++++++++++- 2 files changed, 320 insertions(+), 54 deletions(-) diff --git a/channel.py b/channel.py index 4c548c9..b3dfc02 100644 --- a/channel.py +++ b/channel.py @@ -87,6 +87,7 @@ class QQBotAdapter: def __init__(self, context: ChannelFactoryContext) -> None: self._context = context + self._identity = context.identity self._ingress = context.ingress self._provider_factory = context.provider_client_factory self._credentials = context.credentials @@ -100,11 +101,13 @@ def __init__(self, context: ChannelFactoryContext) -> None: self._client: httpx.AsyncClient | None = None self._token: _TokenCache | None = None self._gateway_task: asyncio.Task[None] | None = None + self._stop_task: asyncio.Task[StopReceipt] | None = None self._stopped = asyncio.Event() self._started = False self._stopping = False self._message_recipients: dict[str, str] = {} + self._message_identities: dict[str, str] = {} self._presentation_recipients: dict[str, str] = {} self._presentation_message_ids: dict[str, str] = {} self._reply_buffers: dict[str, str] = {} @@ -133,30 +136,46 @@ async def start(self) -> ChannelReady: if self._presentation is None: raise RuntimeError("QQBot adapter 缺少 presentation ports") - # 1. Validate both credential identities before acquiring any resource. - self._credential_ref("app_id") - self._credential_ref("client_secret") - - # 2. Resolve only through the formal provider factory. - self._provider_client = await self._provider_factory.create(self._credentials) - self._client = httpx.AsyncClient(timeout=30.0) - - # 3. Attach the exact presentation callback before receiving provider input. - turn_stream = self._presentation.turn_stream - if turn_stream is None: - raise RuntimeError("QQBot turn stream port 未绑定") - self._stream_subscription = turn_stream.subscribe(self._on_turn_stream) - self._stopped.clear() - self._gateway_task = asyncio.create_task( - self._gateway_loop(), - name=f"qqbot-gateway:{self._context.generation_id}", - ) - self._started = True - return ChannelReady( - self._binding_token, - subscriptions=("qqbot.gateway", "qqbot.turn_stream"), - admission_open=False, - ) + try: + # 1. Validate both credential identities before acquiring any resource. + self._credential_ref("app_id") + self._credential_ref("client_secret") + + # 2. Resolve only through the formal provider factory. + self._provider_client = await self._provider_factory.create(self._credentials) + self._client = httpx.AsyncClient(timeout=30.0) + + # 3. Attach the exact presentation callback before receiving provider input. + turn_stream = self._presentation.turn_stream + if turn_stream is None: + raise RuntimeError("QQBot turn stream port 未绑定") + self._stream_subscription = turn_stream.subscribe(self._on_turn_stream) + self._stopped.clear() + self._gateway_task = asyncio.create_task( + self._gateway_loop(), + name=f"qqbot-gateway:{self._context.generation_id}", + ) + self._started = True + return ChannelReady( + self._binding_token, + subscriptions=("qqbot.gateway", "qqbot.turn_stream"), + admission_open=False, + ) + except BaseException as error: + cleanup = await _await_task_after_cancellation( + asyncio.create_task( + self._stop_impl(), + name=f"qqbot-start-cleanup:{self._context.generation_id}", + ) + ) + if cleanup.failures: + error.add_note( + "QQBot start cleanup failed: " + + "; ".join( + f"{item.resource}: {item.message}" for item in cleanup.failures + ) + ) + raise async def deliver(self, request: ProviderDeliveryRequest) -> ProviderDeliveryReceipt: """Send one text message and return a settled three-state receipt.""" @@ -205,6 +224,18 @@ async def deliver(self, request: ProviderDeliveryRequest) -> ProviderDeliveryRec async def stop(self) -> StopReceipt: """Close gateway, stream subscription, HTTP, and provider resources.""" + task = self._stop_task + if task is None or task.done(): + task = asyncio.create_task( + self._stop_impl(), + name=f"qqbot-stop:{self._context.generation_id}", + ) + self._stop_task = task + return await _await_task_after_cancellation(task) + + async def _stop_impl(self) -> StopReceipt: + """Close every owned resource and retain failed owners for retry.""" + self._stopping = True failures: list[ChannelCleanupFailure] = [] @@ -336,14 +367,14 @@ async def _handle_dispatch(self, event_type: str, data: dict[str, Any]) -> None: elif event_type.startswith("GROUP_"): logger.debug("[qqbot] 当前仅启用私聊模式,忽略群事件 event=%s", event_type) - async def _handle_c2c(self, data: dict[str, Any]) -> None: + async def _handle_c2c(self, data: dict[str, Any]) -> DeliveryStatus: if not isinstance(data, dict): logger.warning("[qqbot] 拒绝非 object 私聊 data") - return + return DeliveryStatus.REJECTED raw_author = data.get("author") if raw_author is not None and not isinstance(raw_author, dict): logger.warning("[qqbot] 拒绝非 object 私聊 author") - return + return DeliveryStatus.REJECTED author = raw_author if isinstance(raw_author, dict) else {} raw_openid = ( author["user_openid"] @@ -352,21 +383,24 @@ async def _handle_c2c(self, data: dict[str, Any]) -> None: ) if not isinstance(raw_openid, str): logger.warning("[qqbot] 拒绝非 string user_openid") - return + return DeliveryStatus.REJECTED openid = raw_openid.strip() raw_message_id = data.get("id") raw_content = data.get("content") if not isinstance(raw_message_id, str) or not isinstance(raw_content, str): logger.warning("[qqbot] 拒绝非 string identity/message/content") - return + return DeliveryStatus.REJECTED message_id = raw_message_id.strip() content = raw_content.strip() if not openid or not message_id or not content: logger.warning("[qqbot] 拒绝缺少 identity/message/content 的私聊事件") - return + return DeliveryStatus.REJECTED if not self._allow_from or openid not in self._allow_from: logger.warning("[qqbot] 拒绝未授权私聊用户 user_openid=%s", openid) - return + return DeliveryStatus.REJECTED + if _has_provider_attachments(data): + logger.info("[qqbot] 拒绝带附件的私聊事件 message_id=%s", message_id) + return DeliveryStatus.REJECTED raw = RawInbound( message_id=message_id, message=ChannelInboundMessage( @@ -389,19 +423,24 @@ async def _handle_c2c(self, data: dict[str, Any]) -> None: control = presentation.control if control is None: raise RuntimeError("QQBot control port 未绑定") - await control.interrupt( + result = await control.interrupt( raw, response_bodies=ControlResponseBodies( interrupted="已停止当前回复。", idle="当前没有正在进行的回复。", ), ) - return + if result.response is None: + return DeliveryStatus.REJECTED + return result.response.status ingress = self._ingress if ingress is None: raise RuntimeError("QQBot ingress port 未绑定") if await ingress.admit(raw): self._message_recipients[message_id] = f"c2c:{openid}" + self._message_identities[message_id] = openid + return DeliveryStatus.DELIVERED + return DeliveryStatus.REJECTED async def _on_turn_stream(self, event: TurnStreamEvent) -> PresentationReceipt: """Project input notify and temporary stream without replacing final delivery.""" @@ -409,7 +448,17 @@ async def _on_turn_stream(self, event: TurnStreamEvent) -> PresentationReceipt: if event.kind is TurnStreamEventKind.TURN_STARTED: payload = cast(TurnStartedPresentation, event.payload) recipient = self._message_recipients.pop(payload.client_message_id, None) + provider_identity = self._message_identities.pop( + payload.client_message_id, + None, + ) + if recipient is None and provider_identity is not None: + identity = self._identity + if identity is None: + raise RuntimeError("QQBot identity port 未绑定") + recipient = identity.resolve(provider_identity) if recipient is None: + self._live_disabled.add(event.presentation_id) return PresentationReceipt( event.presentation_id, DeliveryStatus.REJECTED, @@ -421,6 +470,11 @@ async def _on_turn_stream(self, event: TurnStreamEvent) -> PresentationReceipt: recipient, payload.client_message_id, ) + if status is DeliveryStatus.UNKNOWN: + self._live_uncertain.add(event.presentation_id) + self._live_disabled.add(event.presentation_id) + elif status is DeliveryStatus.REJECTED: + self._live_disabled.add(event.presentation_id) return PresentationReceipt( event.presentation_id, status, @@ -438,6 +492,18 @@ async def _on_turn_stream(self, event: TurnStreamEvent) -> PresentationReceipt: return PresentationReceipt(event.presentation_id, DeliveryStatus.DELIVERED) async def _refresh_preview(self, presentation_id: str) -> PresentationReceipt: + if presentation_id in self._live_uncertain: + return PresentationReceipt( + presentation_id, + DeliveryStatus.UNKNOWN, + error="QQBot preview 外部效果未确认,已停止后续 patch", + ) + if presentation_id in self._live_disabled: + return PresentationReceipt( + presentation_id, + DeliveryStatus.REJECTED, + error="QQBot preview 已关闭", + ) recipient = self._presentation_recipients.get(presentation_id) text = _tail_text(self._reply_buffers.get(presentation_id, "").strip(), _REPLY_LIVE_TAIL) if recipient is None or not text: @@ -459,6 +525,12 @@ async def _send_preview( recipient: str, text: str, ) -> PresentationReceipt: + if presentation_id in self._live_uncertain: + return PresentationReceipt( + presentation_id, + DeliveryStatus.UNKNOWN, + error="QQBot preview 外部效果未确认,已停止后续 patch", + ) if presentation_id in self._live_disabled: return PresentationReceipt( presentation_id, @@ -489,11 +561,16 @@ async def _send_preview( } if state.stream_msg_id: body["stream_msg_id"] = state.stream_msg_id - status, payload, error = await self._request_with_status( - "POST", - f"/v2/users/{openid}/stream_messages", - body, - ) + try: + status, payload, error = await self._request_with_status( + "POST", + f"/v2/users/{openid}/stream_messages", + body, + ) + except asyncio.CancelledError: + self._live_uncertain.add(presentation_id) + self._live_disabled.add(presentation_id) + raise if status is DeliveryStatus.DELIVERED: remote_id = payload.get("id") if isinstance(remote_id, str) and remote_id.strip(): @@ -508,15 +585,14 @@ async def _send_preview( "外部效果未确认" ) self._live_uncertain.add(presentation_id) + if status is DeliveryStatus.UNKNOWN: + self._live_uncertain.add(presentation_id) + self._live_disabled.add(presentation_id) + elif status is DeliveryStatus.REJECTED: + self._live_disabled.add(presentation_id) if status is not DeliveryStatus.DELIVERED: failures = self._live_failures.get(presentation_id, 0) + 1 self._live_failures[presentation_id] = failures - if ( - status is DeliveryStatus.REJECTED - or presentation_id in self._live_uncertain - or failures >= _LIVE_MAX_FAILURES - ): - self._live_disabled.add(presentation_id) return PresentationReceipt( presentation_id, status, @@ -528,8 +604,6 @@ async def _finish_preview(self, presentation_id: str) -> PresentationReceipt: state = self._live_states.get(presentation_id) clear_state = True try: - if state is None: - return PresentationReceipt(presentation_id, DeliveryStatus.DELIVERED) if presentation_id in self._live_uncertain: clear_state = False return PresentationReceipt( @@ -537,14 +611,32 @@ async def _finish_preview(self, presentation_id: str) -> PresentationReceipt: DeliveryStatus.UNKNOWN, error="QQBot preview 外部效果未确认,保留本地失败状态", ) + if presentation_id in self._live_disabled and ( + state is None or not state.stream_msg_id + ): + return PresentationReceipt( + presentation_id, + DeliveryStatus.REJECTED, + error="QQBot preview 已拒绝,未产生可清理的远端消息", + ) + if state is None: + return PresentationReceipt(presentation_id, DeliveryStatus.DELIVERED) if not state.stream_msg_id: return PresentationReceipt(presentation_id, DeliveryStatus.DELIVERED) - status, _payload, error = await self._request_with_status( - "DELETE", - f"/v2/users/{state.openid}/messages/{state.stream_msg_id}", - ) + try: + status, _payload, error = await self._request_with_status( + "DELETE", + f"/v2/users/{state.openid}/messages/{state.stream_msg_id}", + ) + except asyncio.CancelledError: + clear_state = False + self._live_uncertain.add(presentation_id) + self._live_disabled.add(presentation_id) + raise if status is DeliveryStatus.UNKNOWN: clear_state = False + self._live_uncertain.add(presentation_id) + self._live_disabled.add(presentation_id) return PresentationReceipt( presentation_id, status, @@ -713,6 +805,7 @@ def _clear_presentations(self) -> None: for presentation_id in tuple(self._presentation_recipients): self._clear_presentation(presentation_id) self._message_recipients.clear() + self._message_identities.clear() def _allow_from(config: Mapping[str, object]) -> frozenset[str]: @@ -725,6 +818,20 @@ def _allow_from(config: Mapping[str, object]) -> frozenset[str]: return frozenset(item for item in value if item) +def _has_provider_attachments(data: Mapping[str, Any]) -> bool: + """Reject provider media payloads while the v3 adapter remains text-only.""" + + attachments = data.get("attachments") + if attachments is not None and ( + not isinstance(attachments, list) or bool(attachments) + ): + return True + return any( + key in data and data[key] not in (None, "", [], {}) + for key in ("image", "file", "media") + ) + + def _parse_recipient(recipient: str) -> tuple[str, str]: value = recipient.strip() if value.startswith("qqbot:"): @@ -747,3 +854,19 @@ def _tail_text(text: str, limit: int) -> str: if len(text) <= limit: return text return "..." + text[-(limit - 3) :] + + +async def _await_task_after_cancellation(task: asyncio.Task[Any]) -> Any: + """Finish critical cleanup before restoring caller cancellation.""" + + cancelled = False + while not task.done(): + try: + await asyncio.shield(task) + except asyncio.CancelledError: + cancelled = True + continue + result = task.result() + if cancelled: + raise asyncio.CancelledError + return result diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 2dec47e..b8e39fe 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -94,9 +94,13 @@ async def admit(self, raw: RawInbound) -> bool: class FakeIdentity: + def __init__(self, values: dict[str, str] | None = None) -> None: + self.values = values or {} + self.lookups: list[str] = [] + def resolve(self, provider_identity: str) -> str | None: - _ = provider_identity - return None + self.lookups.append(provider_identity) + return self.values.get(provider_identity) class FakeControl: @@ -129,10 +133,13 @@ async def close(self) -> None: class FakeTurnStream: - def __init__(self) -> None: + def __init__(self, *, fail_subscribe: bool = False) -> None: self.subscription: FakeSubscription | None = None + self.fail_subscribe = fail_subscribe def subscribe(self, callback) -> FakeSubscription: + if self.fail_subscribe: + raise RuntimeError("stream subscribe failed") self.subscription = FakeSubscription(callback) return self.subscription @@ -141,6 +148,7 @@ def _context( *, factory: FakeProviderFactory | None = None, ingress: FakeIngress | None = None, + identity: FakeIdentity | None = None, control: FakeControl | None = None, stream: FakeTurnStream | None = None, config: dict[str, object] | None = None, @@ -156,7 +164,7 @@ def _context( }, provider_client_factory=factory or FakeProviderFactory(), ingress=ingress or FakeIngress(), - identity=FakeIdentity(), + identity=identity or FakeIdentity(), control=control or FakeControl(), turn_stream=stream or FakeTurnStream(), ) @@ -260,6 +268,44 @@ async def sent(_recipient: str, _message: str): assert stream.subscription is not None and stream.subscription.closed +@pytest.mark.asyncio +async def test_stop_cancellation_waits_for_internal_cleanup() -> None: + release = asyncio.Event() + cleanup_started = asyncio.Event() + + class BlockingSubscription(FakeSubscription): + async def await_quiescence(self) -> None: + cleanup_started.set() + await release.wait() + + class BlockingTurnStream(FakeTurnStream): + def subscribe(self, callback) -> BlockingSubscription: + self.subscription = BlockingSubscription(callback) + return self.subscription + + factory = FakeProviderFactory() + stream = BlockingTurnStream() + adapter = module.build_qqbot_channel(_context(factory=factory, stream=stream)) + + async def gateway() -> None: + await adapter._stopped.wait() + + adapter._gateway_loop = gateway + adapter.attach_presentation(ChannelPresentationPorts(FakeControl(), stream)) + await adapter.start() + + stopping = asyncio.create_task(adapter.stop()) + await cleanup_started.wait() + stopping.cancel() + await asyncio.sleep(0) + release.set() + + with pytest.raises(asyncio.CancelledError): + await stopping + assert stream.subscription is not None and stream.subscription.closed + assert factory.client.closed + + @pytest.mark.asyncio @pytest.mark.parametrize( ("credentials", "missing"), @@ -290,6 +336,22 @@ async def test_formal_start_rejects_missing_credential_before_resources( assert adapter._gateway_task is None +@pytest.mark.asyncio +async def test_start_failure_closes_provider_resources_before_reraising() -> None: + factory = FakeProviderFactory() + stream = FakeTurnStream(fail_subscribe=True) + adapter = module.build_qqbot_channel(_context(factory=factory, stream=stream)) + adapter.attach_presentation(ChannelPresentationPorts(FakeControl(), stream)) + + with pytest.raises(RuntimeError, match="stream subscribe failed"): + await adapter.start() + + assert factory.client.closed + assert adapter._provider_client is None + assert adapter._client is None + assert adapter._gateway_task is None + + @pytest.mark.asyncio async def test_attachment_is_rejected_before_provider_effect() -> None: factory = FakeProviderFactory() @@ -374,6 +436,77 @@ async def test_inbound_is_allowlisted_and_stop_uses_control_port() -> None: assert closed_ingress.raw == [] +@pytest.mark.asyncio +async def test_inbound_attachment_is_rejected_without_admission() -> None: + ingress = FakeIngress() + adapter = module.build_qqbot_channel(_context(ingress=ingress)) + + status = await adapter._handle_c2c( + { + "id": "media-1", + "content": "image", + "attachments": [{"url": "https://example.test/image"}], + "author": {"user_openid": "allowed"}, + } + ) + + assert status is DeliveryStatus.REJECTED + assert ingress.raw == [] + + +@pytest.mark.asyncio +async def test_turn_started_uses_core_identity_when_local_recipient_is_missing() -> None: + identity = FakeIdentity({"allowed": "c2c:resolved"}) + adapter = module.build_qqbot_channel(_context(identity=identity)) + adapter._message_identities["msg-1"] = "allowed" + + async def notify(_recipient: str, _message_id: str): + return DeliveryStatus.DELIVERED, None, None + + adapter._send_input_notify = notify + receipt = await adapter._on_turn_stream( + TurnStreamEvent( + "preview:turn-identity", + TurnStreamEventKind.TURN_STARTED, + TurnStartedPresentation("turn-identity", "msg-1"), + ) + ) + + assert receipt.status is DeliveryStatus.DELIVERED + assert identity.lookups == ["allowed"] + assert adapter._presentation_recipients["preview:turn-identity"] == "c2c:resolved" + + +@pytest.mark.asyncio +async def test_turn_started_without_identity_closes_preview_as_rejected() -> None: + adapter = module.build_qqbot_channel(_context()) + started = await adapter._on_turn_stream( + TurnStreamEvent( + "preview:turn-missing", + TurnStreamEventKind.TURN_STARTED, + TurnStartedPresentation("turn-missing", "unknown-message"), + ) + ) + delta = await adapter._on_turn_stream( + TurnStreamEvent( + "preview:turn-missing", + TurnStreamEventKind.STREAM_DELTA, + StreamDeltaPresentation("turn-missing", 1, "answer", ""), + ) + ) + completed = await adapter._on_turn_stream( + TurnStreamEvent( + "preview:turn-missing", + TurnStreamEventKind.TURN_OUTPUT_COMPLETED, + TurnOutputCompletedPresentation("turn-missing", 2), + ) + ) + + assert started.status is DeliveryStatus.REJECTED + assert delta.status is DeliveryStatus.REJECTED + assert completed.status is DeliveryStatus.REJECTED + + @pytest.mark.asyncio @pytest.mark.parametrize( "payload", @@ -491,6 +624,16 @@ async def request(method: str, path: str, body=None): assert "preview:turn-1" in adapter._live_states assert "preview:turn-1" in adapter._live_uncertain + retry = await adapter._on_turn_stream( + TurnStreamEvent( + "preview:turn-1", + TurnStreamEventKind.STREAM_DELTA, + StreamDeltaPresentation("turn-1", 3, "retry", ""), + ) + ) + assert retry.status is DeliveryStatus.UNKNOWN + assert [method for method, _path in calls] == ["POST", "POST"] + @pytest.mark.asyncio async def test_gateway_cancellation_reaps_heartbeat( From 9c4fa628a68cbed424d7bcf8d196cbcdfe93db3f Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Mon, 17 Aug 2026 21:30:51 +0800 Subject: [PATCH 4/9] test(qqbot): verify real v3 manager lifecycle --- tests/test_manager_integration.py | 142 ++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 tests/test_manager_integration.py diff --git a/tests/test_manager_integration.py b/tests/test_manager_integration.py new file mode 100644 index 0000000..88c011f --- /dev/null +++ b/tests/test_manager_integration.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Any, cast + +import pytest + +from agent.plugins import channel_generation_host +from agent.plugins.manager import PluginManager +from bus.event_bus import EventBus + + +ROOT = Path(__file__).parents[1] + + +class FakeProviderClient: + def __init__(self) -> None: + self.closed = 0 + + def credential(self, ref: Any) -> str: + if ref.path == ("appId",): + return "formal-app-id" + if ref.path == ("clientSecret",): + return "formal-client-secret" + raise KeyError(ref.path) + + async def aclose(self) -> None: + self.closed += 1 + + +class FakeProviderFactory: + def __init__(self) -> None: + self.client = FakeProviderClient() + self.create_calls = 0 + self.close_calls = 0 + + async def create(self, credentials: object) -> FakeProviderClient: + del credentials + self.create_calls += 1 + return self.client + + async def aclose(self) -> None: + self.close_calls += 1 + + +def _stage(tmp_path: Path) -> tuple[Path, Path]: + plugin_root = tmp_path / "plugins" / "qqbot" + plugin_root.mkdir(parents=True) + for filename in ( + "plugin.py", + "channel.py", + "config.py", + "akashic.plugin.toml", + ): + shutil.copy2(ROOT / filename, plugin_root / filename) + workspace = tmp_path / "workspace" + data_dir = workspace / "plugin-data" / "qqbot-builtin" + data_dir.mkdir(parents=True) + (data_dir / "config.local.toml").write_text( + 'appId = "formal-app-id"\n' + 'clientSecret = "formal-client-secret"\n' + 'allowFrom = ["allowed"]\n', + encoding="utf-8", + ) + return plugin_root, workspace + + +@pytest.mark.asyncio +async def test_manager_formal_candidate_discard_promote_and_cleanup( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Exercise QQBot through the real Manager and Channel Host without network.""" + + # 1. Stage one static v3 artifact and replace only its external gateway loop. + plugin_root, workspace = _stage(tmp_path) + factory = FakeProviderFactory() + original_resolver = channel_generation_host._resolve_sync_factory + + def resolve_factory(module: object, export: str) -> object: + factory_callable = original_resolver(module, export) + + def wrapped(context: object) -> object: + adapter = cast(Any, factory_callable(context)) + adapter._gateway_loop = lambda: adapter._stopped.wait() + return adapter + + return wrapped + + monkeypatch.setattr( + channel_generation_host, + "_resolve_sync_factory", + resolve_factory, + ) + manager = PluginManager( + plugin_dirs=[plugin_root.parent], + event_bus=EventBus(), + tool_registry=None, + workspace=workspace, + installed_cache_root=tmp_path / "home" / "cache", + ) + manager.bind_channel_provider_factory_resolver(lambda snapshot: {"qqbot": factory}) + + # 2. Formal boot owns one provider; candidate stays inert and secret-free. + await manager.load_all() + stable = manager.current_snapshot + runtime = manager.active_channel_generation + assert stable is not None and stable.state == "committed" + assert runtime is not None and runtime.channel("qqbot").admission_open + assert factory.create_calls == 1 + + candidate = await manager.prepare_candidate("qqbot") + assert candidate is not None and candidate.runtime_snapshot is not None + assert manager.current_snapshot is stable + assert factory.create_calls == 1 + assert candidate.validation_workspace is not None + validation_root = candidate.validation_workspace.parent + for path in validation_root.rglob("*"): + if path.is_file() and not path.is_symlink(): + assert b"formal-client-secret" not in path.read_bytes() + config_path = workspace / "plugin-data" / "qqbot-builtin" / "config.local.toml" + original_config = config_path.read_bytes() + await manager.discard_prepared("qqbot") + assert manager.current_snapshot is stable + assert factory.create_calls == 1 + assert config_path.read_bytes() == original_config + + # 3. Promotion rebuilds a formal binding; terminate drains every owned resource. + candidate = await manager.prepare_candidate("qqbot") + assert candidate is not None + publication = await manager.publish_prepared("qqbot") + assert publication["publication_state"] == "committed" + assert manager.current_snapshot is not stable + assert factory.create_calls == 2 + assert manager.active_channel_generation is not None + assert manager.active_channel_generation.channel("qqbot").admission_open + + await manager.terminate_all() + assert manager.active_channel_generation is None + assert factory.close_calls == 2 + assert factory.client.closed == 2 From 60497ef541d87c003cd6f5f687d7d5ee7756ad7e Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Thu, 20 Aug 2026 14:56:14 +0800 Subject: [PATCH 5/9] feat(qqbot): support v3 rich media attachments --- README.md | 3 +- channel.py | 306 ++++++++++++++++++++++++++++++++++++++----- tests/test_plugin.py | 167 +++++++++++++++++++++-- 3 files changed, 433 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 8df8377..7d80acb 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,5 @@ Akashic 官方 QQBot 私聊渠道。插件已迁移到 pure v3 Channel API: - candidate 只注册静态 channel definition,不解密凭证、不建立网络连接; - formal generation 通过 Core 提供的 exact binding 处理入站、`/stop`、临时预览和最终文本投递; -- 首批 v3 adapter 明确只支持文本,附件会在任何 provider 副作用前返回 `REJECTED`。 +- v3 adapter 支持 Core-owned 图片和文件附件:先 exact-ref/hash 校验并有界读取,再按文本、附件顺序走 QQ 富媒体上传;provider 效果使用 `DELIVERED`、`REJECTED`、`UNKNOWN` 三态。 +- 入站附件通过 provider URL 下载并导入 Core artifact store,再一次性提交给 ingress;插件不读取 workspace 路径、不拥有附件持久化。 diff --git a/channel.py b/channel.py index b3dfc02..78dc8d5 100644 --- a/channel.py +++ b/channel.py @@ -3,10 +3,13 @@ from __future__ import annotations import asyncio +import base64 +import hashlib import json import logging +import mimetypes import time -from collections.abc import Callable, Mapping +from collections.abc import AsyncIterable, Callable, Mapping from dataclasses import dataclass from datetime import datetime, timezone from typing import Any, cast @@ -15,6 +18,8 @@ import websockets from agent.plugin_composition.channels import ( + AttachmentKind, + AttachmentRef, ChannelAdapter, ChannelCleanupFailure, ChannelFactoryContext, @@ -51,6 +56,7 @@ "app_id": ("appId", "app_id"), "client_secret": ("clientSecret", "client_secret"), } +_MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024 @dataclass(slots=True) @@ -178,19 +184,13 @@ async def start(self) -> ChannelReady: raise async def deliver(self, request: ProviderDeliveryRequest) -> ProviderDeliveryReceipt: - """Send one text message and return a settled three-state receipt.""" + """Read exact Core attachments, send ordered parts, and settle one receipt.""" if not isinstance(request, ProviderDeliveryRequest): raise TypeError("QQBot deliver 只接受 ProviderDeliveryRequest") if request.binding_token != self._binding_token: raise RuntimeError("QQBot delivery binding token 不匹配") - if request.attachments: - return ProviderDeliveryReceipt( - request.delivery_id, - DeliveryStatus.REJECTED, - error="QQBot v3 首批 adapter 只支持文本,附件未被读取或上传", - ) - if not request.body.strip(): + if not request.body.strip() and not request.attachments: return ProviderDeliveryReceipt( request.delivery_id, DeliveryStatus.REJECTED, @@ -210,17 +210,133 @@ async def deliver(self, request: ProviderDeliveryRequest) -> ProviderDeliveryRec DeliveryStatus.REJECTED, error=str(error), ) - status, provider_id, error = await self._send_text( - request.recipient, - request.body, - ) + # 1. Read and hash-check every Core-owned attachment before provider effect. + try: + attachment_data = await self._read_attachments(request.attachments) + except asyncio.CancelledError: + raise + except (RuntimeError, TypeError, ValueError) as error: + return ProviderDeliveryReceipt( + request.delivery_id, + DeliveryStatus.REJECTED, + error=f"QQBot 附件读取失败: {error}", + ) + + # 2. Send text first, then media in exact request order. + provider_ids: list[str] = [] + if request.body.strip(): + status, provider_id, error = await self._send_text( + request.recipient, + request.body, + ) + if provider_id: + provider_ids.append(provider_id) + if status is not DeliveryStatus.DELIVERED: + if provider_ids and status is DeliveryStatus.REJECTED: + status = DeliveryStatus.UNKNOWN + return ProviderDeliveryReceipt( + request.delivery_id, + status, + tuple(provider_ids), + error=error, + ) + for ref, data in attachment_data: + status, provider_id, error = await self._send_attachment( + request.recipient, + ref, + data, + ) + if provider_id: + provider_ids.append(provider_id) + if status is not DeliveryStatus.DELIVERED: + return ProviderDeliveryReceipt( + request.delivery_id, + status, + tuple(provider_ids), + error=error, + ) return ProviderDeliveryReceipt( request.delivery_id, - status, - (provider_id,) if provider_id else (), - error=error, + DeliveryStatus.DELIVERED, + tuple(provider_ids), ) + async def _read_attachments( + self, + refs: tuple[AttachmentRef, ...], + ) -> list[tuple[AttachmentRef, bytes]]: + """Read and hash-check Core attachments while retaining no path access.""" + + if not refs: + return [] + attachment_read = self._context.attachment_read + if attachment_read is None: + raise RuntimeError("QQBot outbound 附件缺少 Core attachment_read") + result: list[tuple[AttachmentRef, bytes]] = [] + for ref in refs: + lease = await attachment_read.acquire(ref) + try: + if lease.ref != ref: + raise RuntimeError("QQBot attachment read lease ref 不匹配") + data = await lease.read_bytes( + max_bytes=min(max(ref.size_bytes, 1), _MAX_ATTACHMENT_BYTES) + ) + if len(data) != ref.size_bytes: + raise ValueError( + f"附件大小不匹配: expected={ref.size_bytes} actual={len(data)}" + ) + if hashlib.sha256(data).hexdigest() != ref.sha256: + raise ValueError("附件 sha256 不匹配") + result.append((ref, data)) + finally: + await _close_attachment_lease(lease) + return result + + async def _send_attachment( + self, + recipient: str, + ref: AttachmentRef, + data: bytes, + ) -> tuple[DeliveryStatus, str | None, str | None]: + """Upload one verified attachment and send its rich-media message.""" + + _, openid = _parse_recipient(recipient) + upload_status, upload_payload, upload_error = await self._request_with_status( + "POST", + f"/v2/users/{openid}/files", + { + "file_type": 1 if ref.kind is AttachmentKind.IMAGE else 4, + "file_data": base64.b64encode(data).decode("ascii"), + "srv_send_msg": False, + **( + {"file_name": ref.filename} + if ref.kind is AttachmentKind.FILE and ref.filename + else {} + ), + }, + ) + if upload_status is not DeliveryStatus.DELIVERED: + return upload_status, None, upload_error + file_info = str(upload_payload.get("file_info") or "").strip() + if not file_info: + return DeliveryStatus.UNKNOWN, None, "QQBot media upload response 缺少 file_info" + send_status, send_payload, send_error = await self._request_with_status( + "POST", + f"/v2/users/{openid}/messages", + { + "msg_type": 7, + "media": {"file_info": file_info}, + "msg_seq": _next_msg_seq(), + }, + ) + # Upload has already changed provider state; a failed follow-up send is unknown. + if send_status is not DeliveryStatus.DELIVERED: + return DeliveryStatus.UNKNOWN, None, send_error + provider_id = str(send_payload.get("id") or "").strip() + if not provider_id: + return DeliveryStatus.UNKNOWN, None, "QQBot rich-media response 缺少 message id" + return DeliveryStatus.DELIVERED, provider_id, None + async def stop(self) -> StopReceipt: """Close gateway, stream subscription, HTTP, and provider resources.""" @@ -387,20 +503,39 @@ async def _handle_c2c(self, data: dict[str, Any]) -> DeliveryStatus: openid = raw_openid.strip() raw_message_id = data.get("id") raw_content = data.get("content") - if not isinstance(raw_message_id, str) or not isinstance(raw_content, str): - logger.warning("[qqbot] 拒绝非 string identity/message/content") + if not isinstance(raw_message_id, str): + logger.warning("[qqbot] 拒绝非 string identity/message") + return DeliveryStatus.REJECTED + if raw_content is not None and not isinstance(raw_content, str): + logger.warning("[qqbot] 拒绝非 string content") return DeliveryStatus.REJECTED message_id = raw_message_id.strip() - content = raw_content.strip() - if not openid or not message_id or not content: - logger.warning("[qqbot] 拒绝缺少 identity/message/content 的私聊事件") + content = raw_content.strip() if isinstance(raw_content, str) else "" + if not openid or not message_id: + logger.warning("[qqbot] 拒绝缺少 identity/message 的私聊事件") return DeliveryStatus.REJECTED if not self._allow_from or openid not in self._allow_from: logger.warning("[qqbot] 拒绝未授权私聊用户 user_openid=%s", openid) return DeliveryStatus.REJECTED - if _has_provider_attachments(data): - logger.info("[qqbot] 拒绝带附件的私聊事件 message_id=%s", message_id) + try: + provider_attachments = _provider_attachments(data) + if provider_attachments is None: + raise ValueError("QQBot attachments 字段格式非法") + attachments = await self._import_provider_attachments(provider_attachments) + except asyncio.CancelledError: + raise + except (httpx.HTTPStatusError, RuntimeError, TypeError, ValueError) as error: + logger.warning( + "[qqbot] 入站附件未能导入 message_id=%s err=%s", + message_id, + error, + ) + return DeliveryStatus.REJECTED + if not content and not attachments: + logger.warning("[qqbot] 拒绝缺少 content/attachments 的私聊事件") return DeliveryStatus.REJECTED + if not content: + content = "[附件]" raw = RawInbound( message_id=message_id, message=ChannelInboundMessage( @@ -414,6 +549,7 @@ async def _handle_c2c(self, data: dict[str, Any]) -> DeliveryStatus: "user_openid": openid, "message_id": message_id, }, + attachments=tuple(attachments), ), provider_identity=openid, recipient=f"c2c:{openid}", @@ -442,6 +578,50 @@ async def _handle_c2c(self, data: dict[str, Any]) -> DeliveryStatus: return DeliveryStatus.DELIVERED return DeliveryStatus.REJECTED + async def _import_provider_attachments( + self, + provider_attachments: list[Mapping[str, Any]], + ) -> list[AttachmentRef]: + """Download QQ media URLs and import every byte through Core.""" + + if not provider_attachments: + return [] + attachment_import = self._context.attachment_import + if attachment_import is None: + raise RuntimeError("QQBot 入站附件缺少 Core attachment_import") + if self._client is None: + raise RuntimeError("QQBot HTTP client 尚未 start") + refs: list[AttachmentRef] = [] + for item in provider_attachments: + url = item.get("url") or item.get("resolved_url") + if not isinstance(url, str) or not url.startswith(("https://", "http://")): + raise ValueError("QQBot 入站附件缺少安全下载 URL") + declared_size = item.get("size") + if isinstance(declared_size, int) and declared_size > _MAX_ATTACHMENT_BYTES: + raise ValueError("QQBot 入站附件超过大小上限") + response = await self._client.get(url) + response.raise_for_status() + data = await _bounded_response_bytes(response) + filename = item.get("filename") + filename = filename.strip() if isinstance(filename, str) and filename.strip() else "attachment" + media_type = item.get("content_type") + media_type = ( + media_type.strip() + if isinstance(media_type, str) and media_type.strip() + else mimetypes.guess_type(filename)[0] or "application/octet-stream" + ) + kind = AttachmentKind.IMAGE if media_type.startswith("image/") else AttachmentKind.FILE + ref = await attachment_import.import_bytes( + data, + kind=kind, + filename=filename, + media_type=media_type, + ) + if not isinstance(ref, AttachmentRef): + raise TypeError("QQBot attachment_import 必须返回 AttachmentRef") + refs.append(ref) + return refs + async def _on_turn_stream(self, event: TurnStreamEvent) -> PresentationReceipt: """Project input notify and temporary stream without replacing final delivery.""" @@ -818,18 +998,35 @@ def _allow_from(config: Mapping[str, object]) -> frozenset[str]: return frozenset(item for item in value if item) +def _provider_attachments(data: Mapping[str, Any]) -> list[Mapping[str, Any]] | None: + """Normalize QQ provider attachment objects without retaining provider paths.""" + + raw = data.get("attachments") + if raw is None: + aliases: list[Mapping[str, Any]] = [] + for key in ("image", "file", "media"): + value = data.get(key) + if value in (None, "", [], {}): + continue + if not isinstance(value, Mapping): + return None + aliases.append(value) + return aliases + if not isinstance(raw, list): + return None + result: list[Mapping[str, Any]] = [] + for item in raw: + if not isinstance(item, Mapping): + return None + result.append(item) + return result + + def _has_provider_attachments(data: Mapping[str, Any]) -> bool: - """Reject provider media payloads while the v3 adapter remains text-only.""" + """Return whether a provider payload carries a non-empty attachment list.""" - attachments = data.get("attachments") - if attachments is not None and ( - not isinstance(attachments, list) or bool(attachments) - ): - return True - return any( - key in data and data[key] not in (None, "", [], {}) - for key in ("image", "file", "media") - ) + attachments = _provider_attachments(data) + return attachments is None or bool(attachments) def _parse_recipient(recipient: str) -> tuple[str, str]: @@ -856,6 +1053,49 @@ def _tail_text(text: str, limit: int) -> str: return "..." + text[-(limit - 3) :] +async def _close_attachment_lease(lease: Any) -> None: + """Finish attachment lease cleanup even when the caller is cancelled.""" + + task = asyncio.create_task(lease.aclose(), name="qqbot-attachment-lease-close") + cancelled = False + while not task.done(): + try: + await asyncio.shield(task) + except asyncio.CancelledError: + cancelled = True + continue + if task.cancelled(): + raise asyncio.CancelledError + result = task.result() + if cancelled: + raise asyncio.CancelledError + return result + + +async def _bounded_response_bytes(response: Any) -> bytes: + """Collect a provider response without exceeding the attachment memory bound.""" + + chunks: list[bytes] = [] + total = 0 + aiter_bytes = getattr(response, "aiter_bytes", None) + if callable(aiter_bytes): + aiter_bytes = cast(Callable[[], AsyncIterable[bytes]], aiter_bytes) + async for chunk in aiter_bytes(): + if not isinstance(chunk, bytes): + raise TypeError("QQBot provider response chunk 必须是 bytes") + total += len(chunk) + if total > _MAX_ATTACHMENT_BYTES: + raise ValueError("QQBot 入站附件超过大小上限") + chunks.append(chunk) + return b"".join(chunks) + data = response.content + if not isinstance(data, bytes): + raise TypeError("QQBot provider response content 必须是 bytes") + if len(data) > _MAX_ATTACHMENT_BYTES: + raise ValueError("QQBot 入站附件超过大小上限") + return data + + async def _await_task_after_cancellation(task: asyncio.Task[Any]) -> Any: """Finish critical cleanup before restoring caller cancellation.""" diff --git a/tests/test_plugin.py b/tests/test_plugin.py index b8e39fe..e2635f8 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import hashlib import importlib.util import json import sys @@ -11,6 +12,8 @@ import pytest from agent.plugin_composition.channels import ( + AttachmentKind, + AttachmentRef, AttachmentKind, AttachmentRef, ChannelDeliveryReceipt, @@ -144,6 +147,58 @@ def subscribe(self, callback) -> FakeSubscription: return self.subscription +class FakeAttachmentReadLease: + def __init__(self, ref: AttachmentRef, data: bytes) -> None: + self.ref = ref + self.data = data + self.closed = False + self.max_bytes: int | None = None + + async def read_bytes(self, *, max_bytes: int) -> bytes: + self.max_bytes = max_bytes + if len(self.data) > max_bytes: + raise ValueError("read exceeded bound") + return self.data + + async def aclose(self) -> None: + self.closed = True + + +class FakeAttachmentRead: + def __init__(self, values: dict[str, tuple[AttachmentRef, bytes]] | None = None) -> None: + self.values = values or {} + self.leases: list[FakeAttachmentReadLease] = [] + + async def acquire(self, ref: AttachmentRef) -> FakeAttachmentReadLease: + actual_ref, data = self.values.get(ref.artifact_id, (ref, b"")) + lease = FakeAttachmentReadLease(actual_ref, data) + self.leases.append(lease) + return lease + + +class FakeAttachmentImport: + def __init__(self) -> None: + self.calls: list[tuple[bytes, AttachmentKind, str | None, str | None]] = [] + + async def import_bytes( + self, + data: bytes, + *, + kind: AttachmentKind, + filename: str | None, + media_type: str | None, + ) -> AttachmentRef: + self.calls.append((data, kind, filename, media_type)) + return AttachmentRef( + artifact_id=f"imported-{len(self.calls)}", + kind=kind, + filename=filename, + media_type=media_type, + size_bytes=len(data), + sha256=hashlib.sha256(data).hexdigest(), + ) + + def _context( *, factory: FakeProviderFactory | None = None, @@ -151,6 +206,8 @@ def _context( identity: FakeIdentity | None = None, control: FakeControl | None = None, stream: FakeTurnStream | None = None, + attachment_read: FakeAttachmentRead | None = None, + attachment_import: FakeAttachmentImport | None = None, config: dict[str, object] | None = None, ) -> ChannelFactoryContext: return ChannelFactoryContext( @@ -165,6 +222,8 @@ def _context( provider_client_factory=factory or FakeProviderFactory(), ingress=ingress or FakeIngress(), identity=identity or FakeIdentity(), + attachment_import=attachment_import or FakeAttachmentImport(), + attachment_read=attachment_read or FakeAttachmentRead(), control=control or FakeControl(), turn_stream=stream or FakeTurnStream(), ) @@ -353,17 +412,27 @@ async def test_start_failure_closes_provider_resources_before_reraising() -> Non @pytest.mark.asyncio -async def test_attachment_is_rejected_before_provider_effect() -> None: - factory = FakeProviderFactory() - adapter = module.build_qqbot_channel(_context(factory=factory)) +async def test_attachment_delivery_reads_exact_bytes_and_preserves_text_file_order() -> None: + data = b"x" attachment = AttachmentRef( artifact_id="artifact-1", kind=AttachmentKind.FILE, filename="a.txt", media_type="text/plain", - size_bytes=1, - sha256="0" * 64, + size_bytes=len(data), + sha256=hashlib.sha256(data).hexdigest(), ) + read = FakeAttachmentRead({"artifact-1": (attachment, data)}) + adapter = module.build_qqbot_channel(_context(attachment_read=read)) + calls: list[tuple[str, dict[str, object]]] = [] + + async def request(method: str, path: str, body: dict[str, object] | None = None): + calls.append((path, body or {})) + if path.endswith("/files"): + return DeliveryStatus.DELIVERED, {"file_info": "file-info"}, None + return DeliveryStatus.DELIVERED, {"id": f"provider-{len(calls)}"}, None + + adapter._request_with_status = request receipt = await adapter.deliver( ProviderDeliveryRequest( "binding-1", @@ -373,8 +442,73 @@ async def test_attachment_is_rejected_before_provider_effect() -> None: (attachment,), ) ) + assert receipt.status is DeliveryStatus.DELIVERED + assert [path for path, _body in calls] == [ + "/v2/users/alice/messages", + "/v2/users/alice/files", + "/v2/users/alice/messages", + ] + assert calls[1][1]["file_data"] == "eA==" + assert calls[2][1] == {"msg_type": 7, "media": {"file_info": "file-info"}, "msg_seq": calls[2][1]["msg_seq"]} + assert read.leases[0].max_bytes == 1 + assert read.leases[0].closed + + +@pytest.mark.asyncio +async def test_attachment_upload_failure_is_rejected_without_rich_media_message() -> None: + data = b"x" + attachment = AttachmentRef( + artifact_id="artifact-failure", + kind=AttachmentKind.FILE, + filename="a.txt", + media_type="text/plain", + size_bytes=1, + sha256=hashlib.sha256(data).hexdigest(), + ) + adapter = module.build_qqbot_channel( + _context(attachment_read=FakeAttachmentRead({"artifact-failure": (attachment, data)})) + ) + paths: list[str] = [] + + async def request(method: str, path: str, body: dict[str, object] | None = None): + paths.append(path) + if path.endswith("/files"): + return DeliveryStatus.REJECTED, {}, "HTTP 400" + return DeliveryStatus.DELIVERED, {"id": "text-id"}, None + + adapter._request_with_status = request + receipt = await adapter.deliver( + ProviderDeliveryRequest("binding-1", "delivery-failure", "c2c:alice", "", (attachment,)) + ) assert receipt.status is DeliveryStatus.REJECTED - assert factory.create_calls == 0 + assert paths == ["/v2/users/alice/files"] + + +@pytest.mark.asyncio +async def test_attachment_delivery_propagates_cancel_and_closes_read_lease() -> None: + data = b"x" + attachment = AttachmentRef( + artifact_id="artifact-cancel", + kind=AttachmentKind.FILE, + filename="a.txt", + media_type="text/plain", + size_bytes=1, + sha256=hashlib.sha256(data).hexdigest(), + ) + read = FakeAttachmentRead({"artifact-cancel": (attachment, data)}) + adapter = module.build_qqbot_channel( + _context(attachment_read=read) + ) + + async def request(method: str, path: str, body: dict[str, object] | None = None): + raise asyncio.CancelledError + + adapter._request_with_status = request + with pytest.raises(asyncio.CancelledError): + await adapter.deliver( + ProviderDeliveryRequest("binding-1", "delivery-cancel", "c2c:alice", "", (attachment,)) + ) + assert read.leases[0].closed @pytest.mark.asyncio @@ -437,10 +571,23 @@ async def test_inbound_is_allowlisted_and_stop_uses_control_port() -> None: @pytest.mark.asyncio -async def test_inbound_attachment_is_rejected_without_admission() -> None: +async def test_inbound_attachment_downloads_and_imports_before_admission() -> None: ingress = FakeIngress() adapter = module.build_qqbot_channel(_context(ingress=ingress)) + class Response: + content = b"image-bytes" + + def raise_for_status(self) -> None: + return None + + class Client: + async def get(self, url: str) -> Response: + assert url == "https://example.test/image" + return Response() + + adapter._client = Client() + status = await adapter._handle_c2c( { "id": "media-1", @@ -450,8 +597,10 @@ async def test_inbound_attachment_is_rejected_without_admission() -> None: } ) - assert status is DeliveryStatus.REJECTED - assert ingress.raw == [] + assert status is DeliveryStatus.DELIVERED + assert len(ingress.raw) == 1 + assert ingress.raw[0].message.content == "image" + assert ingress.raw[0].message.attachments[0].size_bytes == len(b"image-bytes") @pytest.mark.asyncio From cd3945b656551ce634a7b1ecd7c0c0612b419973 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Thu, 20 Aug 2026 15:32:10 +0800 Subject: [PATCH 6/9] fix(qqbot): close v3 attachment and ingress boundaries --- channel.py | 288 ++++++++++++++++++++++++++++++++++++------- tests/test_plugin.py | 239 ++++++++++++++++++++++++++++++++++- 2 files changed, 480 insertions(+), 47 deletions(-) diff --git a/channel.py b/channel.py index 78dc8d5..e892113 100644 --- a/channel.py +++ b/channel.py @@ -9,6 +9,7 @@ import logging import mimetypes import time +from urllib.parse import urlsplit from collections.abc import AsyncIterable, Callable, Mapping from dataclasses import dataclass from datetime import datetime, timezone @@ -57,6 +58,10 @@ "client_secret": ("clientSecret", "client_secret"), } _MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024 +_MAX_ATTACHMENT_COUNT = 16 +_MAX_ATTACHMENT_BATCH_BYTES = 100 * 1024 * 1024 +_MAX_PROVIDER_SEGMENT_LENGTH = 256 +_QQ_MEDIA_HOSTS = frozenset({"multimedia.nt.qq.com.cn"}) @dataclass(slots=True) @@ -111,6 +116,9 @@ def __init__(self, context: ChannelFactoryContext) -> None: self._stopped = asyncio.Event() self._started = False self._stopping = False + self._admission_open = False + self._runtime: Any | None = None + self._inbound_tasks: set[asyncio.Task[DeliveryStatus]] = set() self._message_recipients: dict[str, str] = {} self._message_identities: dict[str, str] = {} @@ -134,6 +142,29 @@ def attach_presentation(self, ports: ChannelPresentationPorts) -> None: raise RuntimeError("QQBot v3 必须同时绑定 control 与 turn_stream") self._presentation = ports + def attach_runtime(self, runtime: Any) -> None: + """Bind the exact Host runtime lifecycle owner without replacing context ports.""" + + if self._runtime is not None: + raise RuntimeError("QQBot runtime 不能重复绑定") + if runtime is None: + raise TypeError("QQBot runtime 不能为空") + if getattr(runtime, "binding_token", None) != self._binding_token: + raise RuntimeError("QQBot runtime binding token 不匹配") + self._runtime = runtime + + def open_admission(self) -> None: + """Allow provider ingress only after Core has published this binding.""" + + if self._stopping: + raise RuntimeError("QQBot adapter 正在停止") + self._admission_open = True + + def close_admission(self) -> None: + """Reject new provider ingress while accepted gateway work drains.""" + + self._admission_open = False + async def start(self) -> ChannelReady: """Create formal provider resources and start the gateway closed.""" @@ -224,6 +255,7 @@ async def deliver(self, request: ProviderDeliveryRequest) -> ProviderDeliveryRec # 2. Send text first, then media in exact request order. provider_ids: list[str] = [] + delivered_any = False if request.body.strip(): status, provider_id, error = await self._send_text( request.recipient, @@ -231,8 +263,10 @@ async def deliver(self, request: ProviderDeliveryRequest) -> ProviderDeliveryRec ) if provider_id: provider_ids.append(provider_id) + if status is DeliveryStatus.DELIVERED: + delivered_any = True if status is not DeliveryStatus.DELIVERED: - if provider_ids and status is DeliveryStatus.REJECTED: + if delivered_any and status is DeliveryStatus.REJECTED: status = DeliveryStatus.UNKNOWN return ProviderDeliveryReceipt( request.delivery_id, @@ -248,7 +282,11 @@ async def deliver(self, request: ProviderDeliveryRequest) -> ProviderDeliveryRec ) if provider_id: provider_ids.append(provider_id) + if status is DeliveryStatus.DELIVERED: + delivered_any = True if status is not DeliveryStatus.DELIVERED: + if delivered_any and status is DeliveryStatus.REJECTED: + status = DeliveryStatus.UNKNOWN return ProviderDeliveryReceipt( request.delivery_id, status, @@ -269,10 +307,16 @@ async def _read_attachments( if not refs: return [] + if len(refs) > _MAX_ATTACHMENT_COUNT: + raise ValueError("QQBot 附件数量超过上限") + declared_total = sum(ref.size_bytes for ref in refs) + if declared_total > _MAX_ATTACHMENT_BATCH_BYTES: + raise ValueError("QQBot 附件批次超过总大小上限") attachment_read = self._context.attachment_read if attachment_read is None: raise RuntimeError("QQBot outbound 附件缺少 Core attachment_read") result: list[tuple[AttachmentRef, bytes]] = [] + actual_total = 0 for ref in refs: lease = await attachment_read.acquire(ref) try: @@ -287,6 +331,9 @@ async def _read_attachments( ) if hashlib.sha256(data).hexdigest() != ref.sha256: raise ValueError("附件 sha256 不匹配") + actual_total += len(data) + if actual_total > _MAX_ATTACHMENT_BATCH_BYTES: + raise ValueError("QQBot 附件批次超过总大小上限") result.append((ref, data)) finally: await _close_attachment_lease(lease) @@ -320,6 +367,10 @@ async def _send_attachment( file_info = str(upload_payload.get("file_info") or "").strip() if not file_info: return DeliveryStatus.UNKNOWN, None, "QQBot media upload response 缺少 file_info" + try: + _provider_segment(file_info, "QQBot file_info") + except ValueError as error: + return DeliveryStatus.UNKNOWN, None, str(error) send_status, send_payload, send_error = await self._request_with_status( "POST", f"/v2/users/{openid}/messages", @@ -335,6 +386,10 @@ async def _send_attachment( provider_id = str(send_payload.get("id") or "").strip() if not provider_id: return DeliveryStatus.UNKNOWN, None, "QQBot rich-media response 缺少 message id" + try: + _provider_segment(provider_id, "QQBot message_id") + except ValueError as error: + return DeliveryStatus.UNKNOWN, None, str(error) return DeliveryStatus.DELIVERED, provider_id, None async def stop(self) -> StopReceipt: @@ -353,6 +408,7 @@ async def _stop_impl(self) -> StopReceipt: """Close every owned resource and retain failed owners for retry.""" self._stopping = True + self._admission_open = False failures: list[ChannelCleanupFailure] = [] # 1. Close callback admission before provider resources. @@ -380,7 +436,18 @@ async def _stop_impl(self) -> StopReceipt: else: self._gateway_task = None - # 3. Release formal clients; failed owners remain for exact retry. + # 3. Let callbacks admitted before close settle before releasing Core ports. + tasks = tuple(self._inbound_tasks) + if tasks: + results = await asyncio.gather(*tasks, return_exceptions=True) + for result in results: + if isinstance(result, BaseException) and not isinstance( + result, asyncio.CancelledError + ): + failures.append(self._cleanup_failure("inbound-task", result)) + self._inbound_tasks.clear() + + # 4. Release formal clients; failed owners remain for exact retry. if self._client is not None: try: await self._client.aclose() @@ -397,6 +464,7 @@ async def _stop_impl(self) -> StopReceipt: self._provider_client = None self._token = None + self._admission_open = False if failures: return StopReceipt(self._binding_token, False, tuple(failures)) self._started = False @@ -479,11 +547,22 @@ async def _heartbeat( async def _handle_dispatch(self, event_type: str, data: dict[str, Any]) -> None: if event_type == "C2C_MESSAGE_CREATE": - await self._handle_c2c(data) + if not self._admission_open: + logger.warning("[qqbot] Core admission 尚未打开,拒绝 provider 入站") + return + task = asyncio.create_task( + self._handle_c2c(data), + name=f"qqbot-inbound:{self._context.generation_id}", + ) + self._inbound_tasks.add(task) + task.add_done_callback(self._inbound_tasks.discard) elif event_type.startswith("GROUP_"): logger.debug("[qqbot] 当前仅启用私聊模式,忽略群事件 event=%s", event_type) async def _handle_c2c(self, data: dict[str, Any]) -> DeliveryStatus: + if not self._admission_open: + logger.warning("[qqbot] Core admission 尚未打开,拒绝 provider 入站") + return DeliveryStatus.REJECTED if not isinstance(data, dict): logger.warning("[qqbot] 拒绝非 object 私聊 data") return DeliveryStatus.REJECTED @@ -500,7 +579,12 @@ async def _handle_c2c(self, data: dict[str, Any]) -> DeliveryStatus: if not isinstance(raw_openid, str): logger.warning("[qqbot] 拒绝非 string user_openid") return DeliveryStatus.REJECTED - openid = raw_openid.strip() + try: + _provider_segment(raw_openid, "QQBot user_openid") + except ValueError as error: + logger.warning("[qqbot] 拒绝非法 user_openid: %s", error) + return DeliveryStatus.REJECTED + openid = raw_openid raw_message_id = data.get("id") raw_content = data.get("content") if not isinstance(raw_message_id, str): @@ -509,7 +593,12 @@ async def _handle_c2c(self, data: dict[str, Any]) -> DeliveryStatus: if raw_content is not None and not isinstance(raw_content, str): logger.warning("[qqbot] 拒绝非 string content") return DeliveryStatus.REJECTED - message_id = raw_message_id.strip() + try: + _provider_segment(raw_message_id, "QQBot message_id") + except ValueError as error: + logger.warning("[qqbot] 拒绝非法 message_id: %s", error) + return DeliveryStatus.REJECTED + message_id = raw_message_id content = raw_content.strip() if isinstance(raw_content, str) else "" if not openid or not message_id: logger.warning("[qqbot] 拒绝缺少 identity/message 的私聊事件") @@ -521,10 +610,23 @@ async def _handle_c2c(self, data: dict[str, Any]) -> DeliveryStatus: provider_attachments = _provider_attachments(data) if provider_attachments is None: raise ValueError("QQBot attachments 字段格式非法") - attachments = await self._import_provider_attachments(provider_attachments) + # /stop is decided before any provider URL is fetched or imported. + if content == "/stop": + if provider_attachments: + logger.warning("[qqbot] 拒绝带附件的 /stop message_id=%s", message_id) + return DeliveryStatus.REJECTED + attachments: list[AttachmentRef] = [] + else: + attachments = await self._import_provider_attachments(provider_attachments) except asyncio.CancelledError: raise - except (httpx.HTTPStatusError, RuntimeError, TypeError, ValueError) as error: + except ( + httpx.HTTPStatusError, + httpx.RequestError, + RuntimeError, + TypeError, + ValueError, + ) as error: logger.warning( "[qqbot] 入站附件未能导入 message_id=%s err=%s", message_id, @@ -591,17 +693,37 @@ async def _import_provider_attachments( raise RuntimeError("QQBot 入站附件缺少 Core attachment_import") if self._client is None: raise RuntimeError("QQBot HTTP client 尚未 start") + if len(provider_attachments) > _MAX_ATTACHMENT_COUNT: + raise ValueError("QQBot 入站附件数量超过上限") + declared_total = 0 + for item in provider_attachments: + declared_size = item.get("size") + if isinstance(declared_size, bool): + raise ValueError("QQBot 入站附件 size 格式非法") + if isinstance(declared_size, int): + if declared_size < 0 or declared_size > _MAX_ATTACHMENT_BYTES: + raise ValueError("QQBot 入站附件 size 超过单文件上限") + declared_total += declared_size + if declared_total > _MAX_ATTACHMENT_BATCH_BYTES: + raise ValueError("QQBot 入站附件批次超过总大小上限") refs: list[AttachmentRef] = [] + downloaded: list[tuple[bytes, AttachmentKind, str, str]] = [] + actual_total = 0 for item in provider_attachments: url = item.get("url") or item.get("resolved_url") - if not isinstance(url, str) or not url.startswith(("https://", "http://")): - raise ValueError("QQBot 入站附件缺少安全下载 URL") - declared_size = item.get("size") - if isinstance(declared_size, int) and declared_size > _MAX_ATTACHMENT_BYTES: - raise ValueError("QQBot 入站附件超过大小上限") - response = await self._client.get(url) - response.raise_for_status() - data = await _bounded_response_bytes(response) + safe_url = _validate_media_url(url) + async with self._client.stream( + "GET", + safe_url, + follow_redirects=False, + ) as response: + if 300 <= response.status_code < 400: + raise ValueError("QQBot 入站附件禁止重定向") + response.raise_for_status() + data = await _bounded_response_bytes(response) + actual_total += len(data) + if actual_total > _MAX_ATTACHMENT_BATCH_BYTES: + raise ValueError("QQBot 入站附件批次超过总大小上限") filename = item.get("filename") filename = filename.strip() if isinstance(filename, str) and filename.strip() else "attachment" media_type = item.get("content_type") @@ -611,11 +733,10 @@ async def _import_provider_attachments( else mimetypes.guess_type(filename)[0] or "application/octet-stream" ) kind = AttachmentKind.IMAGE if media_type.startswith("image/") else AttachmentKind.FILE + downloaded.append((data, kind, filename, media_type)) + for data, kind, filename, media_type in downloaded: ref = await attachment_import.import_bytes( - data, - kind=kind, - filename=filename, - media_type=media_type, + data, kind=kind, filename=filename, media_type=media_type ) if not isinstance(ref, AttachmentRef): raise TypeError("QQBot attachment_import 必须返回 AttachmentRef") @@ -725,10 +846,15 @@ async def _send_preview( raise RuntimeError( f"QQBot preview 缺少 provider message id: {presentation_id}" ) - state = _LiveStreamState(openid, message_id, _next_msg_seq()) + state = _LiveStreamState( + openid, + _provider_segment(message_id, "QQBot message_id"), + _next_msg_seq(), + ) self._live_states[presentation_id] = state lock = self._live_locks.setdefault(presentation_id, asyncio.Lock()) async with lock: + error: str | None = None body: dict[str, Any] = { "input_mode": "replace", "input_state": 1, @@ -754,10 +880,20 @@ async def _send_preview( if status is DeliveryStatus.DELIVERED: remote_id = payload.get("id") if isinstance(remote_id, str) and remote_id.strip(): - state.stream_msg_id = remote_id.strip() - state.index += 1 - self._live_failures[presentation_id] = 0 - self._live_uncertain.discard(presentation_id) + try: + state.stream_msg_id = _provider_segment( + remote_id.strip(), "QQBot stream_message_id" + ) + except ValueError as validation_error: + status = DeliveryStatus.UNKNOWN + error = str(validation_error) + self._live_uncertain.add(presentation_id) + self._live_disabled.add(presentation_id) + remote_id = None + if remote_id is not None: + state.index += 1 + self._live_failures[presentation_id] = 0 + self._live_uncertain.discard(presentation_id) else: status = DeliveryStatus.UNKNOWN error = ( @@ -803,6 +939,8 @@ async def _finish_preview(self, presentation_id: str) -> PresentationReceipt: return PresentationReceipt(presentation_id, DeliveryStatus.DELIVERED) if not state.stream_msg_id: return PresentationReceipt(presentation_id, DeliveryStatus.DELIVERED) + _provider_segment(state.openid, "QQBot user_openid") + _provider_segment(state.stream_msg_id, "QQBot stream_message_id") try: status, _payload, error = await self._request_with_status( "DELETE", @@ -833,6 +971,7 @@ async def _send_input_notify( message_id: str, ) -> tuple[DeliveryStatus, str | None, str | None]: _, openid = _parse_recipient(recipient) + _provider_segment(message_id, "QQBot message_id") status, payload, error = await self._request_with_status( "POST", f"/v2/users/{openid}/messages", @@ -844,6 +983,11 @@ async def _send_input_notify( }, ) provider_id = str(payload.get("id") or "").strip() or None + if provider_id is not None: + try: + _provider_segment(provider_id, "QQBot message_id") + except ValueError as error: + return DeliveryStatus.UNKNOWN, None, str(error) return status, provider_id, error async def _send_text( @@ -864,6 +1008,11 @@ async def _send_text( provider_id = str(payload.get("id") or "").strip() or None if status is DeliveryStatus.DELIVERED and provider_id is None: return DeliveryStatus.UNKNOWN, None, "QQBot response 缺少 message id" + if provider_id is not None: + try: + _provider_segment(provider_id, "QQBot message_id") + except ValueError as error: + return DeliveryStatus.UNKNOWN, None, str(error) return status, provider_id, error async def _request_with_status( @@ -884,6 +1033,13 @@ async def _request_with_status( else DeliveryStatus.UNKNOWN ) return delivery, {}, f"HTTP {status}" + except httpx.RequestError as error: + delivery = ( + DeliveryStatus.REJECTED + if _is_pre_effect_request_error(error) + else DeliveryStatus.UNKNOWN + ) + return delivery, {}, str(error) or type(error).__name__ except Exception as error: return DeliveryStatus.UNKNOWN, {}, str(error) or type(error).__name__ return DeliveryStatus.DELIVERED, payload, None @@ -1029,18 +1185,69 @@ def _has_provider_attachments(data: Mapping[str, Any]) -> bool: return attachments is None or bool(attachments) +def _provider_segment(value: object, field_name: str) -> str: + """Validate an opaque QQ provider value before putting it in a URL path.""" + + if not isinstance(value, str) or not value: + raise ValueError(f"{field_name} 不能为空") + if value != value.strip(): + raise ValueError(f"{field_name} 不能包含首尾空白") + if len(value) > _MAX_PROVIDER_SEGMENT_LENGTH: + raise ValueError(f"{field_name} 超过长度上限") + if "/" in value or "\\" in value: + raise ValueError(f"{field_name} 不能包含路径分隔符") + if any(ord(char) < 32 or ord(char) == 127 for char in value): + raise ValueError(f"{field_name} 不能包含控制字符") + return value + + +def _validate_media_url(value: object) -> str: + """Allow only HTTPS QQ media URLs without redirects or user-controlled hosts.""" + + if not isinstance(value, str) or not value: + raise ValueError("QQBot 入站附件缺少安全下载 URL") + parsed = urlsplit(value) + if parsed.scheme.lower() != "https" or parsed.hostname not in _QQ_MEDIA_HOSTS: + raise ValueError("QQBot 入站附件 URL 必须是受限 QQ HTTPS 媒体域名") + if parsed.username is not None or parsed.password is not None: + raise ValueError("QQBot 入站附件 URL 禁止 userinfo") + try: + port = parsed.port + except ValueError as error: + raise ValueError("QQBot 入站附件 URL 端口非法") from error + if port not in (None, 443): + raise ValueError("QQBot 入站附件 URL 端口非法") + return value + + +def _is_pre_effect_request_error(error: httpx.RequestError) -> bool: + """Classify only connection setup failures as deterministic no-effect errors.""" + + return isinstance( + error, + ( + httpx.ConnectError, + httpx.ConnectTimeout, + httpx.ProxyError, + httpx.UnsupportedProtocol, + httpx.InvalidURL, + ), + ) + + def _parse_recipient(recipient: str) -> tuple[str, str]: - value = recipient.strip() + _provider_segment(recipient, "QQBot recipient") + value = recipient if value.startswith("qqbot:"): value = value[len("qqbot:") :] if not value: raise ValueError(f"无效的 QQBot recipient: {recipient!r}") if ":" not in value: - return "c2c", value + return "c2c", _provider_segment(value, "QQBot user_openid") kind, target = value.split(":", 1) if kind != "c2c" or not target: raise ValueError(f"无效的 QQBot recipient: {recipient!r}") - return kind, target + return kind, _provider_segment(target, "QQBot user_openid") def _next_msg_seq() -> int: @@ -1078,22 +1285,17 @@ async def _bounded_response_bytes(response: Any) -> bytes: chunks: list[bytes] = [] total = 0 aiter_bytes = getattr(response, "aiter_bytes", None) - if callable(aiter_bytes): - aiter_bytes = cast(Callable[[], AsyncIterable[bytes]], aiter_bytes) - async for chunk in aiter_bytes(): - if not isinstance(chunk, bytes): - raise TypeError("QQBot provider response chunk 必须是 bytes") - total += len(chunk) - if total > _MAX_ATTACHMENT_BYTES: - raise ValueError("QQBot 入站附件超过大小上限") - chunks.append(chunk) - return b"".join(chunks) - data = response.content - if not isinstance(data, bytes): - raise TypeError("QQBot provider response content 必须是 bytes") - if len(data) > _MAX_ATTACHMENT_BYTES: - raise ValueError("QQBot 入站附件超过大小上限") - return data + if not callable(aiter_bytes): + raise TypeError("QQBot provider response 必须提供 aiter_bytes") + aiter_bytes = cast(Callable[[], AsyncIterable[bytes]], aiter_bytes) + async for chunk in aiter_bytes(): + if not isinstance(chunk, bytes): + raise TypeError("QQBot provider response chunk 必须是 bytes") + total += len(chunk) + if total > _MAX_ATTACHMENT_BYTES: + raise ValueError("QQBot 入站附件超过大小上限") + chunks.append(chunk) + return b"".join(chunks) async def _await_task_after_cancellation(task: asyncio.Task[Any]) -> Any: diff --git a/tests/test_plugin.py b/tests/test_plugin.py index e2635f8..19103be 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -10,6 +10,7 @@ from types import SimpleNamespace import pytest +import httpx from agent.plugin_composition.channels import ( AttachmentKind, @@ -511,6 +512,67 @@ async def request(method: str, path: str, body: dict[str, object] | None = None) assert read.leases[0].closed +@pytest.mark.asyncio +async def test_delivery_after_prior_success_aggregates_later_rejection_as_unknown() -> None: + data = b"x" + attachment = AttachmentRef( + artifact_id="aggregate-1", + kind=AttachmentKind.FILE, + filename="a.txt", + media_type="text/plain", + size_bytes=1, + sha256=hashlib.sha256(data).hexdigest(), + ) + adapter = module.build_qqbot_channel( + _context(attachment_read=FakeAttachmentRead({"aggregate-1": (attachment, data)})) + ) + + async def read(_refs): + return [(attachment, data)] + + async def send_text(_recipient, _message): + return DeliveryStatus.DELIVERED, "text-id", None + + async def send_attachment(_recipient, _ref, _data): + return DeliveryStatus.REJECTED, None, "HTTP 400" + + adapter._read_attachments = read + adapter._send_text = send_text + adapter._send_attachment = send_attachment + receipt = await adapter.deliver( + ProviderDeliveryRequest( + "binding-1", "aggregate-delivery", "c2c:alice", "body", (attachment,) + ) + ) + assert receipt.status is DeliveryStatus.UNKNOWN + assert receipt.provider_ids == ("text-id",) + + +@pytest.mark.asyncio +async def test_outbound_attachment_count_limit_rejects_before_read( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(module.channel, "_MAX_ATTACHMENT_COUNT", 1) + data = b"x" + first = AttachmentRef( + "limit-1", AttachmentKind.FILE, "a.txt", "text/plain", 1, hashlib.sha256(data).hexdigest() + ) + second = AttachmentRef( + "limit-2", AttachmentKind.FILE, "b.txt", "text/plain", 1, hashlib.sha256(data).hexdigest() + ) + read = FakeAttachmentRead( + {"limit-1": (first, data), "limit-2": (second, data)} + ) + adapter = module.build_qqbot_channel(_context(attachment_read=read)) + receipt = await adapter.deliver( + ProviderDeliveryRequest( + "binding-1", "limit-delivery", "c2c:alice", "", (first, second) + ) + ) + assert receipt.status is DeliveryStatus.REJECTED + assert read.leases == [] + + @pytest.mark.asyncio @pytest.mark.parametrize("recipient", ["group:group-1"]) async def test_invalid_recipient_returns_rejected_without_provider_effect( @@ -543,6 +605,7 @@ async def test_inbound_is_allowlisted_and_stop_uses_control_port() -> None: _context(ingress=ingress, control=control, stream=stream) ) adapter.attach_presentation(ChannelPresentationPorts(control, stream)) + adapter.open_admission() await adapter._handle_c2c( {"id": "msg-1", "content": "hello", "author": {"user_openid": "allowed"}} ) @@ -574,25 +637,39 @@ async def test_inbound_is_allowlisted_and_stop_uses_control_port() -> None: async def test_inbound_attachment_downloads_and_imports_before_admission() -> None: ingress = FakeIngress() adapter = module.build_qqbot_channel(_context(ingress=ingress)) + adapter.open_admission() class Response: content = b"image-bytes" + status_code = 200 def raise_for_status(self) -> None: return None - class Client: - async def get(self, url: str) -> Response: - assert url == "https://example.test/image" + async def aiter_bytes(self): + yield self.content + + class Stream: + async def __aenter__(self) -> Response: return Response() + async def __aexit__(self, *_args) -> None: + return None + + class Client: + def stream(self, method: str, url: str, **kwargs) -> Stream: + assert method == "GET" + assert url == "https://multimedia.nt.qq.com.cn/image" + assert kwargs["follow_redirects"] is False + return Stream() + adapter._client = Client() status = await adapter._handle_c2c( { "id": "media-1", "content": "image", - "attachments": [{"url": "https://example.test/image"}], + "attachments": [{"url": "https://multimedia.nt.qq.com.cn/image"}], "author": {"user_openid": "allowed"}, } ) @@ -603,6 +680,160 @@ async def get(self, url: str) -> Response: assert ingress.raw[0].message.attachments[0].size_bytes == len(b"image-bytes") +@pytest.mark.asyncio +@pytest.mark.parametrize( + "url", + [ + "http://multimedia.nt.qq.com.cn/image", + "https://evil.example/image", + "https://multimedia.nt.qq.com.cn@127.0.0.1/image", + ], +) +async def test_inbound_attachment_url_is_restricted_before_http_request(url: str) -> None: + imported = FakeAttachmentImport() + adapter = module.build_qqbot_channel(_context(attachment_import=imported)) + adapter.open_admission() + + class Client: + async def get(self, *_args, **_kwargs): + raise AssertionError("unsafe URL must not reach HTTP client") + + adapter._client = Client() + status = await adapter._handle_c2c( + { + "id": "unsafe-url", + "content": "image", + "attachments": [{"url": url}], + "author": {"user_openid": "allowed"}, + } + ) + assert status is DeliveryStatus.REJECTED + assert imported.calls == [] + + +@pytest.mark.asyncio +async def test_inbound_redirect_and_batch_limit_create_no_artifact( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(module.channel, "_MAX_ATTACHMENT_BATCH_BYTES", 3) + imported = FakeAttachmentImport() + adapter = module.build_qqbot_channel(_context(attachment_import=imported)) + adapter.open_admission() + + class Response: + content = b"xx" + status_code = 200 + + def raise_for_status(self) -> None: + return None + + async def aiter_bytes(self): + yield self.content + + class Stream: + async def __aenter__(self) -> Response: + return Response() + + async def __aexit__(self, *_args) -> None: + return None + + class Client: + def stream(self, method: str, url: str, **kwargs) -> Stream: + assert method == "GET" + assert kwargs["follow_redirects"] is False + return Stream() + + adapter._client = Client() + status = await adapter._handle_c2c( + { + "id": "batch-limit", + "content": "images", + "attachments": [ + {"url": "https://multimedia.nt.qq.com.cn/one"}, + {"url": "https://multimedia.nt.qq.com.cn/two"}, + ], + "author": {"user_openid": "allowed"}, + } + ) + assert status is DeliveryStatus.REJECTED + assert imported.calls == [] + + +@pytest.mark.asyncio +async def test_stop_with_attachment_rejects_before_download_or_import() -> None: + imported = FakeAttachmentImport() + adapter = module.build_qqbot_channel(_context(attachment_import=imported)) + adapter.open_admission() + + class Client: + async def get(self, *_args, **_kwargs): + raise AssertionError("/stop with attachment must not download") + + adapter._client = Client() + status = await adapter._handle_c2c( + { + "id": "stop-media", + "content": "/stop", + "attachments": [{"url": "https://multimedia.nt.qq.com.cn/media"}], + "author": {"user_openid": "allowed"}, + } + ) + assert status is DeliveryStatus.REJECTED + assert imported.calls == [] + + +@pytest.mark.asyncio +async def test_connection_setup_request_error_is_rejected_without_gateway_exception() -> None: + adapter = module.build_qqbot_channel(_context()) + + async def request(_method: str, _path: str, _body=None): + raise httpx.ConnectError("connect failed") + + adapter._api_request = request + status, payload, error = await adapter._request_with_status("POST", "/v2/users/alice/messages") + assert status is DeliveryStatus.REJECTED + assert payload == {} + assert error == "connect failed" + + +@pytest.mark.asyncio +async def test_runtime_lifecycle_blocks_closed_dispatch_and_stop_drains_accepted_work() -> None: + adapter = module.build_qqbot_channel(_context()) + adapter.attach_runtime(SimpleNamespace(binding_token="binding-1")) + called = asyncio.Event() + released = asyncio.Event() + + async def accepted(_data) -> DeliveryStatus: + called.set() + await released.wait() + return DeliveryStatus.DELIVERED + + adapter._handle_c2c = accepted + await adapter._handle_dispatch("C2C_MESSAGE_CREATE", {}) + assert not called.is_set() + + adapter.open_admission() + await adapter._handle_dispatch("C2C_MESSAGE_CREATE", {}) + await called.wait() + adapter.close_admission() + stopping = asyncio.create_task(adapter.stop()) + await asyncio.sleep(0) + assert not stopping.done() + released.set() + assert (await stopping).resources_closed + + +@pytest.mark.asyncio +async def test_opaque_recipient_path_segment_is_rejected_before_provider_effect() -> None: + adapter = module.build_qqbot_channel(_context()) + receipt = await adapter.deliver( + ProviderDeliveryRequest( + "binding-1", "invalid-recipient", "c2c:alice/escape", "hello" + ) + ) + assert receipt.status is DeliveryStatus.REJECTED + + @pytest.mark.asyncio async def test_turn_started_uses_core_identity_when_local_recipient_is_missing() -> None: identity = FakeIdentity({"allowed": "c2c:resolved"}) From d4bf1ed0256068b56963621e8587ca66e7f6786f Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Thu, 20 Aug 2026 15:43:49 +0800 Subject: [PATCH 7/9] fix(qqbot): bound provider effect and batch reads --- channel.py | 36 ++++++++++++++++++++++++++---------- tests/test_plugin.py | 37 ++++++++++++++++++++++++++++--------- 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/channel.py b/channel.py index e892113..7b808e7 100644 --- a/channel.py +++ b/channel.py @@ -180,7 +180,7 @@ async def start(self) -> ChannelReady: # 2. Resolve only through the formal provider factory. self._provider_client = await self._provider_factory.create(self._credentials) - self._client = httpx.AsyncClient(timeout=30.0) + self._client = httpx.AsyncClient(timeout=30.0, follow_redirects=False) # 3. Attach the exact presentation callback before receiving provider input. turn_stream = self._presentation.turn_stream @@ -708,10 +708,16 @@ async def _import_provider_attachments( raise ValueError("QQBot 入站附件批次超过总大小上限") refs: list[AttachmentRef] = [] downloaded: list[tuple[bytes, AttachmentKind, str, str]] = [] - actual_total = 0 + remaining = _MAX_ATTACHMENT_BATCH_BYTES for item in provider_attachments: + if remaining <= 0: + raise ValueError("QQBot 入站附件批次超过总大小上限") url = item.get("url") or item.get("resolved_url") safe_url = _validate_media_url(url) + max_bytes = min(_MAX_ATTACHMENT_BYTES, remaining) + declared_size = item.get("size") + if isinstance(declared_size, int) and declared_size > max_bytes: + raise ValueError("QQBot 入站附件超过剩余批次额度") async with self._client.stream( "GET", safe_url, @@ -720,10 +726,8 @@ async def _import_provider_attachments( if 300 <= response.status_code < 400: raise ValueError("QQBot 入站附件禁止重定向") response.raise_for_status() - data = await _bounded_response_bytes(response) - actual_total += len(data) - if actual_total > _MAX_ATTACHMENT_BATCH_BYTES: - raise ValueError("QQBot 入站附件批次超过总大小上限") + data = await _bounded_response_bytes(response, max_bytes=max_bytes) + remaining -= len(data) filename = item.get("filename") filename = filename.strip() if isinstance(filename, str) and filename.strip() else "attachment" media_type = item.get("content_type") @@ -1024,7 +1028,7 @@ async def _request_with_status( try: payload = await self._api_request(method, path, body) except asyncio.CancelledError: - raise + return DeliveryStatus.UNKNOWN, {}, "QQBot provider request 被取消,效果未知" except httpx.HTTPStatusError as error: status = error.response.status_code delivery = ( @@ -1279,9 +1283,21 @@ async def _close_attachment_lease(lease: Any) -> None: return result -async def _bounded_response_bytes(response: Any) -> bytes: +async def _bounded_response_bytes(response: Any, *, max_bytes: int) -> bytes: """Collect a provider response without exceeding the attachment memory bound.""" + if max_bytes <= 0 or max_bytes > _MAX_ATTACHMENT_BYTES: + raise ValueError("QQBot 入站附件读取额度非法") + headers = getattr(response, "headers", None) + if headers is not None: + raw_length = headers.get("content-length") + if raw_length is not None: + try: + content_length = int(raw_length) + except (TypeError, ValueError) as error: + raise ValueError("QQBot provider Content-Length 非法") from error + if content_length < 0 or content_length > max_bytes: + raise ValueError("QQBot 入站附件超过剩余批次额度") chunks: list[bytes] = [] total = 0 aiter_bytes = getattr(response, "aiter_bytes", None) @@ -1292,8 +1308,8 @@ async def _bounded_response_bytes(response: Any) -> bytes: if not isinstance(chunk, bytes): raise TypeError("QQBot provider response chunk 必须是 bytes") total += len(chunk) - if total > _MAX_ATTACHMENT_BYTES: - raise ValueError("QQBot 入站附件超过大小上限") + if total > max_bytes: + raise ValueError("QQBot 入站附件超过剩余批次额度") chunks.append(chunk) return b"".join(chunks) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 19103be..641e236 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -486,7 +486,7 @@ async def request(method: str, path: str, body: dict[str, object] | None = None) @pytest.mark.asyncio -async def test_attachment_delivery_propagates_cancel_and_closes_read_lease() -> None: +async def test_attachment_delivery_cancel_settles_unknown_and_closes_read_lease() -> None: data = b"x" attachment = AttachmentRef( artifact_id="artifact-cancel", @@ -501,14 +501,22 @@ async def test_attachment_delivery_propagates_cancel_and_closes_read_lease() -> _context(attachment_read=read) ) + started = asyncio.Event() + async def request(method: str, path: str, body: dict[str, object] | None = None): - raise asyncio.CancelledError + started.set() + await asyncio.Event().wait() - adapter._request_with_status = request - with pytest.raises(asyncio.CancelledError): - await adapter.deliver( + adapter._api_request = request + task = asyncio.create_task( + adapter.deliver( ProviderDeliveryRequest("binding-1", "delivery-cancel", "c2c:alice", "", (attachment,)) ) + ) + await started.wait() + task.cancel() + receipt = await task + assert receipt.status is DeliveryStatus.UNKNOWN assert read.leases[0].closed @@ -719,20 +727,28 @@ async def test_inbound_redirect_and_batch_limit_create_no_artifact( imported = FakeAttachmentImport() adapter = module.build_qqbot_channel(_context(attachment_import=imported)) adapter.open_admission() + streamed: list[tuple[str, bool]] = [] class Response: - content = b"xx" status_code = 200 + def __init__(self, key: str) -> None: + self.key = key + self.headers = {"content-length": "2"} + def raise_for_status(self) -> None: return None async def aiter_bytes(self): - yield self.content + streamed.append((self.key, True)) + yield b"xx" class Stream: + def __init__(self, key: str) -> None: + self.key = key + async def __aenter__(self) -> Response: - return Response() + return Response(self.key) async def __aexit__(self, *_args) -> None: return None @@ -741,7 +757,9 @@ class Client: def stream(self, method: str, url: str, **kwargs) -> Stream: assert method == "GET" assert kwargs["follow_redirects"] is False - return Stream() + key = url.rsplit("/", 1)[-1] + streamed.append((key, False)) + return Stream(key) adapter._client = Client() status = await adapter._handle_c2c( @@ -756,6 +774,7 @@ def stream(self, method: str, url: str, **kwargs) -> Stream: } ) assert status is DeliveryStatus.REJECTED + assert streamed == [("one", False), ("one", True), ("two", False)] assert imported.calls == [] From 97f109eb2ccd907d1b4159e41328afcad58216f5 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sat, 22 Aug 2026 01:18:14 +0800 Subject: [PATCH 8/9] ci(plugin): align v3 gate with final core --- .github/workflows/plugin-api-v3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index b224e65..65a7b08 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: b97f919b1fd865d23d11095cbc63d2354803bad9 + ref: 3005f838bcd96e2cbc58616aede46e4f39df4523 path: .akashic-core - uses: actions/setup-python@v5 with: From 9f906e9a5cfcca00a83f977806de3705fc5bf429 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sat, 22 Aug 2026 01:22:29 +0800 Subject: [PATCH 9/9] test(plugin): type the v3 channel factory seam --- .github/workflows/plugin-api-v3.yml | 2 +- tests/test_manager_integration.py | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index 65a7b08..0d6b179 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -58,7 +58,7 @@ jobs: - name: Check v3 source types env: PYTHONPATH: .akashic-core - run: .venv/bin/pyright --level error plugin.py channel.py config.py tests + run: .venv/bin/pyright --pythonpath .venv/bin/python --level error plugin.py channel.py config.py tests - name: Compile Python sources run: python -m compileall -q plugin.py channel.py config.py tests - name: Check diff formatting diff --git a/tests/test_manager_integration.py b/tests/test_manager_integration.py index 88c011f..0449127 100644 --- a/tests/test_manager_integration.py +++ b/tests/test_manager_integration.py @@ -1,12 +1,15 @@ from __future__ import annotations import shutil +from collections.abc import Callable from pathlib import Path +from types import ModuleType from typing import Any, cast import pytest from agent.plugins import channel_generation_host +from agent.plugin_composition.channels import ChannelAdapter, ChannelFactoryContext from agent.plugins.manager import PluginManager from bus.event_bus import EventBus @@ -78,13 +81,16 @@ async def test_manager_formal_candidate_discard_promote_and_cleanup( factory = FakeProviderFactory() original_resolver = channel_generation_host._resolve_sync_factory - def resolve_factory(module: object, export: str) -> object: + def resolve_factory( + module: ModuleType, + export: str, + ) -> Callable[[ChannelFactoryContext], ChannelAdapter]: factory_callable = original_resolver(module, export) - def wrapped(context: object) -> object: + def wrapped(context: ChannelFactoryContext) -> ChannelAdapter: adapter = cast(Any, factory_callable(context)) adapter._gateway_loop = lambda: adapter._stopped.wait() - return adapter + return cast(ChannelAdapter, adapter) return wrapped