From 0a89b870118d7ddd1e05ce0c6105ffda4bb6c05a Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:56:26 +0800 Subject: [PATCH 1/6] fix(qqofficial): fill message_reference when sending Reply components Inbound quotes already work: the adapter reads message_reference off incoming messages and builds a Reply component from it. The outbound half was never wired up, so a plugin quoting a message with Comp.Reply(id=...) produced plain text with no quote bubble. _parse_to_qqofficial() now returns the first Reply component's message id alongside the media fields, and both send paths (_post_send_one and _send_by_session_common) attach it as message_reference. All four send APIs used here (v2 groups, v2 C2C, guild channel, guild DM) accept that field. The active-send fallback drops the reference together with msg_id, so a stale quote id degrades to plain text instead of blocking the message. --- .../qqofficial/qqofficial_message_event.py | 17 ++- .../qqofficial/qqofficial_platform_adapter.py | 3 + tests/test_qqofficial_group_message_create.py | 116 +++++++++++++++++- 3 files changed, 134 insertions(+), 2 deletions(-) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 62679d3adbe..79af2dc02d1 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -27,7 +27,7 @@ from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain -from astrbot.api.message_components import At, File, Image, Plain, Record, Video +from astrbot.api.message_components import At, File, Image, Plain, Record, Reply, Video from astrbot.api.platform import AstrBotMessage, Group, PlatformMetadata from astrbot.core.platform.sources.qqofficial.qqofficial_chunked_upload import ( QQOFFICIAL_CHUNKED_UPLOAD_THRESHOLD, @@ -405,6 +405,7 @@ async def _post_send_one( video_file_source, file_source, file_name, + reply_message_id, ) = await QQOfficialMessageEvent._parse_to_qqofficial(message_to_send) # C2C 流式仅用于文本分片,富媒体时降级为普通发送,避免平台侧流式校验报错。 @@ -453,6 +454,11 @@ async def _post_send_one( if not isinstance(source, botpy.message.Message | botpy.message.DirectMessage): payload["msg_seq"] = random.randint(1, 10000) + # 插件通过 Reply 组件给出的引用 id,四个发送接口(群/C2C/频道/频道私信) + # 都以 message_reference 接受;拿不到 id 时保持原样,不影响正文发送 + if reply_message_id: + payload["message_reference"] = {"message_id": reply_message_id} + ret = None match source: @@ -634,6 +640,8 @@ async def _send_with_markdown_fallback( if payload.get("msg_id"): fallback_payload = payload.copy() fallback_payload.pop("msg_id", None) + # 主动发送兜底同样去掉引用:引用 id 失效不应阻断正文送达 + fallback_payload.pop("message_reference", None) try: ret = await send_func(fallback_payload) logger.info("[QQOfficial] 使用主动发送接口发送成功。") @@ -916,6 +924,7 @@ async def _parse_to_qqofficial(message: MessageChain): video_file_source = None file_source = None file_name = None + reply_message_id = None for i in message.chain: if isinstance(i, Plain): plain_text += i.text @@ -967,6 +976,11 @@ async def _parse_to_qqofficial(message: MessageChain): file_source = file_path elif i.url: file_source = i.url + elif isinstance(i, Reply) and not reply_message_id: + # 出站引用回复:取第一个带被引用消息 id 的 Reply 组件 + quoted_id = str(i.id).strip() + if quoted_id: + reply_message_id = quoted_id else: logger.debug(f"qq_official 忽略 {i.type}") return ( @@ -977,4 +991,5 @@ async def _parse_to_qqofficial(message: MessageChain): video_file_source, file_source, file_name, + reply_message_id, ) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index 46cd25366af..bc59e0bc7c6 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -369,6 +369,7 @@ async def _send_by_session_common( video_file_source, file_source, file_name, + reply_message_id, ) = await QQOfficialMessageEvent._parse_to_qqofficial(message_chain) if ( not plain_text @@ -409,6 +410,8 @@ async def _send_by_session_common( } if msg_id and not allow_group_proactive_send: payload["msg_id"] = msg_id + if reply_message_id: + payload["message_reference"] = {"message_id": reply_message_id} ret: Any = None send_helper = SimpleNamespace(bot=self.client) diff --git a/tests/test_qqofficial_group_message_create.py b/tests/test_qqofficial_group_message_create.py index 59ed22e0f5d..9a5565f2591 100644 --- a/tests/test_qqofficial_group_message_create.py +++ b/tests/test_qqofficial_group_message_create.py @@ -251,6 +251,38 @@ async def test_parse_to_qqofficial_ignores_empty_at_component(qq: str | None): assert parsed[0] == "hello" +@pytest.mark.asyncio +async def test_parse_to_qqofficial_extracts_reply_reference(): + parsed = await QQOfficialMessageEvent._parse_to_qqofficial( + MessageChain(chain=[Reply(id="quoted-1"), Plain("hello")]) + ) + + assert parsed[7] == "quoted-1" + + +@pytest.mark.parametrize( + "chain", + [ + MessageChain(chain=[Plain("hello")]), + MessageChain(chain=[Reply(id=""), Plain("hello")]), + ], +) +@pytest.mark.asyncio +async def test_parse_to_qqofficial_reply_reference_defaults_to_none(chain): + parsed = await QQOfficialMessageEvent._parse_to_qqofficial(chain) + + assert parsed[7] is None + + +@pytest.mark.asyncio +async def test_parse_to_qqofficial_keeps_first_reply_reference(): + parsed = await QQOfficialMessageEvent._parse_to_qqofficial( + MessageChain(chain=[Reply(id="quoted-1"), Reply(id="quoted-2")]) + ) + + assert parsed[7] == "quoted-1" + + @pytest.mark.asyncio async def test_legacy_group_at_path_forces_bot_mention_when_mentions_missing(): message = botpy.message.GroupMessage( @@ -757,7 +789,7 @@ async def test_ws_group_send_by_session_with_media_uses_msg_type_7(monkeypatch): adapter._session_scene["group-1"] = "group" async def fake_parse(message_chain): - return ("caption", "fake-base64", None, None, None, None, None) + return ("caption", "fake-base64", None, None, None, None, None, None) async def fake_upload_image(self_, image_base64, file_type, **kwargs): return {"file_uuid": "u-1", "file_info": "i-1", "ttl": 0} @@ -779,6 +811,88 @@ async def fake_upload_image(self_, image_base64, file_type, **kwargs): assert kwargs["media"]["file_uuid"] == "u-1" +@pytest.mark.asyncio +async def test_ws_group_send_by_session_attaches_message_reference(): + adapter = QQOfficialPlatformAdapter( + { + "id": "qq-official-test", + "appid": "123", + "secret": "secret", + "enable_group_c2c": True, + "enable_guild_direct_message": False, + }, + {}, + asyncio.Queue(), + ) + adapter.client.api = SimpleNamespace( + post_group_message=AsyncMock(return_value={"id": "sent-quote"}), + post_message=AsyncMock(), + ) + adapter._session_scene["group-1"] = "group" + + await adapter.send_by_session( + MessageSession("qq_official", MessageType.GROUP_MESSAGE, "group-1"), + MessageChain(chain=[Reply(id="quoted-1"), Plain("reply text")]), + ) + + kwargs = adapter.client.api.post_group_message.await_args.kwargs + assert kwargs["message_reference"] == {"message_id": "quoted-1"} + + +@pytest.mark.asyncio +async def test_friend_send_by_session_attaches_message_reference(): + adapter = QQOfficialPlatformAdapter( + { + "id": "qq-official-test", + "appid": "123", + "secret": "secret", + "enable_group_c2c": True, + "enable_guild_direct_message": False, + }, + {}, + asyncio.Queue(), + ) + request = AsyncMock(return_value={"id": "sent-c2c-quote"}) + adapter.client.api = SimpleNamespace(_http=SimpleNamespace(request=request)) + + await adapter.send_by_session( + MessageSession("qq_official", MessageType.FRIEND_MESSAGE, "user-1"), + MessageChain(chain=[Reply(id="quoted-2"), Plain("reply text")]), + ) + + request.assert_awaited_once() + json_payload = request.await_args.kwargs["json"] + assert json_payload["message_reference"] == {"message_id": "quoted-2"} + + +@pytest.mark.asyncio +async def test_send_by_session_without_reply_adds_no_message_reference(): + adapter = QQOfficialPlatformAdapter( + { + "id": "qq-official-test", + "appid": "123", + "secret": "secret", + "enable_group_c2c": True, + "enable_guild_direct_message": False, + }, + {}, + asyncio.Queue(), + ) + adapter.client.api = SimpleNamespace( + post_group_message=AsyncMock(return_value={"id": "sent-plain"}), + post_message=AsyncMock(), + ) + adapter._session_scene["group-1"] = "group" + + await adapter.send_by_session( + MessageSession("qq_official", MessageType.GROUP_MESSAGE, "group-1"), + MessageChain(chain=[Plain("plain text")]), + ) + + kwargs = adapter.client.api.post_group_message.await_args.kwargs + assert "message_reference" not in kwargs + + @pytest.mark.asyncio async def test_friend_send_by_session_renders_markdown(): adapter = QQOfficialPlatformAdapter( From 117240d81c4af0fa101e257aa83f7b6718ccb99f Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:33:18 +0800 Subject: [PATCH 2/6] fix(qqofficial): keep a valid message_reference through the active-send fallback Address review findings: - The passive-to-active fallback stripped message_reference together with msg_id on any failure, so an unrelated send error (markdown rejection, transient server error) lost a perfectly valid quote context. The active retry now keeps the reference first and only drops it when the retry with the reference also fails, since a dead reference id must not block the message body either. - The Reply extraction tests only covered Reply at the chain start. Added a Plain-before-Reply parser case and two fallback-behavior cases. --- .../qqofficial/qqofficial_message_event.py | 23 +++++-- tests/test_qqofficial_group_message_create.py | 61 +++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 79af2dc02d1..ee48ca7aa1b 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -640,15 +640,30 @@ async def _send_with_markdown_fallback( if payload.get("msg_id"): fallback_payload = payload.copy() fallback_payload.pop("msg_id", None) - # 主动发送兜底同样去掉引用:引用 id 失效不应阻断正文送达 - fallback_payload.pop("message_reference", None) + # 转主动发送时先保留引用:失败若与引用无关(如 markdown 校验), + # 引用上下文仍然有效,不应一并丢弃 try: ret = await send_func(fallback_payload) logger.info("[QQOfficial] 使用主动发送接口发送成功。") return ret except _QQOFFICIAL_SEND_API_ERRORS as fallback_err: - err = fallback_err - payload = fallback_payload + if not fallback_payload.get("message_reference"): + err = fallback_err + payload = fallback_payload + else: + # 主动发送仍失败,引用 id 可能已失效;去掉引用最后重试一次, + # 失效引用不应阻断正文送达 + retry_payload = fallback_payload.copy() + retry_payload.pop("message_reference", None) + try: + ret = await send_func(retry_payload) + logger.info( + "[QQOfficial] 引用已失效,去掉引用后主动发送成功。" + ) + return ret + except _QQOFFICIAL_SEND_API_ERRORS as retry_err: + err = retry_err + payload = retry_payload if not isinstance(err, botpy.errors.ServerError): raise diff --git a/tests/test_qqofficial_group_message_create.py b/tests/test_qqofficial_group_message_create.py index 9a5565f2591..95bef3d7491 100644 --- a/tests/test_qqofficial_group_message_create.py +++ b/tests/test_qqofficial_group_message_create.py @@ -260,6 +260,15 @@ async def test_parse_to_qqofficial_extracts_reply_reference(): assert parsed[7] == "quoted-1" +@pytest.mark.asyncio +async def test_parse_to_qqofficial_extracts_reply_reference_after_text(): + parsed = await QQOfficialMessageEvent._parse_to_qqofficial( + MessageChain(chain=[Plain("hello"), Reply(id="quoted-1")]) + ) + + assert parsed[7] == "quoted-1" + + @pytest.mark.parametrize( "chain", [ @@ -893,6 +902,58 @@ async def test_send_by_session_without_reply_adds_no_message_reference(): assert "message_reference" not in kwargs +@pytest.mark.asyncio +async def test_active_send_fallback_keeps_valid_message_reference(): + calls = [] + + async def fake_send(payload): + calls.append(dict(payload)) + if len(calls) == 1: + raise botpy.errors.ServerError("passive msg_id expired") + return {"id": "sent-active"} + + payload = { + "content": "reply text", + "msg_type": 0, + "msg_id": "passive-1", + "message_reference": {"message_id": "quoted-1"}, + } + ret = await QQOfficialMessageEvent._send_with_markdown_fallback( + fake_send, payload, "reply text" + ) + + assert ret == {"id": "sent-active"} + assert len(calls) == 2 + assert "msg_id" not in calls[1] + assert calls[1]["message_reference"] == {"message_id": "quoted-1"} + + +@pytest.mark.asyncio +async def test_active_send_fallback_strips_reference_only_when_retry_fails(): + calls = [] + + async def fake_send(payload): + calls.append(dict(payload)) + if len(calls) < 3: + raise botpy.errors.ServerError("reference rejected") + return {"id": "sent-no-ref"} + + payload = { + "content": "reply text", + "msg_type": 0, + "msg_id": "passive-1", + "message_reference": {"message_id": "quoted-1"}, + } + ret = await QQOfficialMessageEvent._send_with_markdown_fallback( + fake_send, payload, "reply text" + ) + + assert ret == {"id": "sent-no-ref"} + assert len(calls) == 3 + assert calls[1]["message_reference"] == {"message_id": "quoted-1"} + assert "message_reference" not in calls[2] + + @pytest.mark.asyncio async def test_friend_send_by_session_renders_markdown(): adapter = QQOfficialPlatformAdapter( From e2e0299c781b33d0b5e27bedb837606975662459 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:25:26 +0800 Subject: [PATCH 3/6] fix(qqofficial): keep markdown-rejected sends as passive replies When the QQ official API refuses a native markdown payload, the fallback used to drop msg_id and resend through the active-message interface, which runs into the 304049 active-message rate limit and loses the reply. Reorder the fallback so a rejected markdown degrades to a plain content reply that keeps msg_id (passive reply, msg_type reset from 2 to 0, stream trailing newline restored). The stream newline repair retry also keeps msg_id. Only errors that are not markdown rejections take the active-send fallback, which keeps the message_reference two-stage retry from the previous fix. Fallback raises are chained so the original rejection stays visible in logs. --- .../qqofficial/qqofficial_message_event.py | 102 +++++++++--------- tests/test_qqofficial_group_message_create.py | 83 ++++++++++++++ 2 files changed, 134 insertions(+), 51 deletions(-) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index ee48ca7aa1b..16eaf4fe35f 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -636,42 +636,36 @@ async def _send_with_markdown_fallback( try: return await send_func(payload) except _QQOFFICIAL_SEND_API_ERRORS as err: - logger.info("[QQOfficial] 回复消息失败: %s, 尝试使用主动发送接口。", err) - if payload.get("msg_id"): + # markdown 被服务端拒绝(50056)时,保留 msg_id 退化为纯 content + # 被动回复。先去掉 msg_id 会把被动回复变成主动消息,既烧主动 + # 配额也可能直接失败(如 304049 主动消息限频)。 + if ( + isinstance(err, botpy.errors.ServerError) + and QQOfficialMessageEvent.MARKDOWN_NOT_ALLOWED_ERROR in str(err) + and payload.get("markdown") + and plain_text + ): + logger.warning( + "[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。" + ) fallback_payload = payload.copy() - fallback_payload.pop("msg_id", None) - # 转主动发送时先保留引用:失败若与引用无关(如 markdown 校验), - # 引用上下文仍然有效,不应一并丢弃 - try: - ret = await send_func(fallback_payload) - logger.info("[QQOfficial] 使用主动发送接口发送成功。") - return ret - except _QQOFFICIAL_SEND_API_ERRORS as fallback_err: - if not fallback_payload.get("message_reference"): - err = fallback_err - payload = fallback_payload - else: - # 主动发送仍失败,引用 id 可能已失效;去掉引用最后重试一次, - # 失效引用不应阻断正文送达 - retry_payload = fallback_payload.copy() - retry_payload.pop("message_reference", None) - try: - ret = await send_func(retry_payload) - logger.info( - "[QQOfficial] 引用已失效,去掉引用后主动发送成功。" - ) - return ret - except _QQOFFICIAL_SEND_API_ERRORS as retry_err: - err = retry_err - payload = retry_payload - - if not isinstance(err, botpy.errors.ServerError): - raise + fallback_payload.pop("markdown", None) + fallback_payload["content"] = plain_text + if fallback_payload.get("msg_type") == 2: + fallback_payload["msg_type"] = 0 + if stream: + fallback_content = cast(str, fallback_payload.get("content") or "") + if fallback_content and not fallback_content.endswith("\n"): + fallback_payload["content"] = fallback_content + "\n" + return await send_func(fallback_payload) # QQ 流式 markdown 分片校验:内容必须以换行结尾。 # 某些边界场景服务端仍可能判定失败,这里做一次修正重试。 - if stream and QQOfficialMessageEvent.STREAM_MARKDOWN_NEWLINE_ERROR in str( - err + # 与消息 id 无关,保留 msg_id 继续走被动回复。 + if ( + stream + and isinstance(err, botpy.errors.ServerError) + and QQOfficialMessageEvent.STREAM_MARKDOWN_NEWLINE_ERROR in str(err) ): retry_payload = payload.copy() @@ -690,26 +684,32 @@ async def _send_with_markdown_fallback( ) return await send_func(retry_payload) - if ( - QQOfficialMessageEvent.MARKDOWN_NOT_ALLOWED_ERROR not in str(err) - or not payload.get("markdown") - or not plain_text - ): - raise + # 其余发送错误(如 msg_id 过期)才去 msg_id 走主动接口兜底。 + logger.info("[QQOfficial] 回复消息失败: %s, 尝试使用主动发送接口。", err) + if payload.get("msg_id"): + fallback_payload = payload.copy() + fallback_payload.pop("msg_id", None) + # 转主动发送时先保留引用:失败若与引用无关(如 markdown 校验), + # 引用上下文仍然有效,不应一并丢弃 + try: + ret = await send_func(fallback_payload) + logger.info("[QQOfficial] 使用主动发送接口发送成功。") + return ret + except _QQOFFICIAL_SEND_API_ERRORS as fallback_err: + if not fallback_payload.get("message_reference"): + raise fallback_err from err + # 主动发送仍失败,引用 id 可能已失效;去掉引用最后重试一次, + # 失效引用不应阻断正文送达 + retry_payload = fallback_payload.copy() + retry_payload.pop("message_reference", None) + try: + ret = await send_func(retry_payload) + logger.info("[QQOfficial] 引用已失效,去掉引用后主动发送成功。") + return ret + except _QQOFFICIAL_SEND_API_ERRORS as retry_err: + raise retry_err from fallback_err - logger.warning( - "[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。" - ) - fallback_payload = payload.copy() - fallback_payload.pop("markdown", None) - fallback_payload["content"] = plain_text - if fallback_payload.get("msg_type") == 2: - fallback_payload["msg_type"] = 0 - if stream: - fallback_content = cast(str, fallback_payload.get("content") or "") - if fallback_content and not fallback_content.endswith("\n"): - fallback_payload["content"] = fallback_content + "\n" - return await send_func(fallback_payload) + raise async def upload_group_and_c2c_image( self, diff --git a/tests/test_qqofficial_group_message_create.py b/tests/test_qqofficial_group_message_create.py index 95bef3d7491..3c10786ee86 100644 --- a/tests/test_qqofficial_group_message_create.py +++ b/tests/test_qqofficial_group_message_create.py @@ -954,6 +954,89 @@ async def fake_send(payload): assert "message_reference" not in calls[2] +@pytest.mark.asyncio +async def test_markdown_rejected_keeps_msg_id_and_degrades_to_content(): + # Regression test for #10420: a markdown rejection (50056) must degrade to + # a plain-content passive reply with msg_id intact, not drop msg_id and + # become a proactive message (which hits the proactive quota, e.g. 304049). + calls = [] + + async def fake_send(payload): + calls.append(dict(payload)) + if len(calls) == 1: + raise botpy.errors.ServerError("不允许发送原生 markdown") + return {"id": "sent-content"} + + payload = { + "markdown": {"content": "**bold** text"}, + "msg_type": 2, + "msg_id": "passive-1", + } + ret = await QQOfficialMessageEvent._send_with_markdown_fallback( + fake_send, payload, "**bold** text" + ) + + assert ret == {"id": "sent-content"} + assert len(calls) == 2 + assert calls[1]["msg_id"] == "passive-1" + assert "markdown" not in calls[1] + assert calls[1]["content"] == "**bold** text" + assert calls[1]["msg_type"] == 0 + + +@pytest.mark.asyncio +async def test_non_markdown_error_still_falls_back_to_active_send(): + # Errors unrelated to the payload content (stale msg_id and the like) + # keep the old behavior: retry once without msg_id as a proactive message. + calls = [] + + async def fake_send(payload): + calls.append(dict(payload)) + if len(calls) == 1: + raise botpy.errors.NotFoundError("msg_id expired") + return {"id": "sent-active"} + + payload = { + "markdown": {"content": "**bold** text"}, + "msg_type": 2, + "msg_id": "passive-1", + } + ret = await QQOfficialMessageEvent._send_with_markdown_fallback( + fake_send, payload, "**bold** text" + ) + + assert ret == {"id": "sent-active"} + assert len(calls) == 2 + assert "msg_id" not in calls[1] + + +@pytest.mark.asyncio +async def test_stream_newline_retry_keeps_msg_id(): + # The stream markdown newline fix is a content correction, not an id + # problem, so the retry must stay a passive reply with msg_id. + calls = [] + + async def fake_send(payload): + calls.append(dict(payload)) + if len(calls) == 1: + raise botpy.errors.ServerError("流式消息md分片需要\\n结束") + return {"id": "sent-newline"} + + payload = { + "markdown": {"content": "partial chunk"}, + "msg_type": 2, + "msg_id": "passive-1", + } + ret = await QQOfficialMessageEvent._send_with_markdown_fallback( + fake_send, payload, "partial chunk", stream={"msg_seq": 1} + ) + + assert ret == {"id": "sent-newline"} + assert len(calls) == 2 + assert calls[1]["msg_id"] == "passive-1" + assert calls[1]["markdown"]["content"] == "partial chunk\n" + + @pytest.mark.asyncio async def test_friend_send_by_session_renders_markdown(): adapter = QQOfficialPlatformAdapter( From 26dfbbae5cfd7a1bc87a2b0aa0c160958f07aaca Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:19:38 +0800 Subject: [PATCH 4/6] fix(agent): degrade tool-result images for text-only providers When a plugin tool returns ImageContent and the provider does not explicitly declare image support, the cached image was still appended to the context as an ImageURLPart, causing a 400 that aborted the entire turn. Now only append cached images when the provider config explicitly lists 'image' in modalities. The text description in the tool result already tells the LLM the image exists and where it is cached. Fixes #10702 --- astrbot/core/agent/runners/tool_loop_agent_runner.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index ed5bb3df628..507b3d4ba53 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -1107,9 +1107,15 @@ async def step(self): # append a user message with images so LLM can see them if cached_images: modalities = self.provider.provider_config.get("modalities", []) - supports_image = ( - not modalities or "image" in modalities - ) # Empty list is treated as unconfigured for backward compatibility + # Unlike the request-assembly path above, tool-result images + # are new multimodal content the provider never asked for. + # Be conservative: only append them when the provider config + # explicitly declares image support. An empty or missing + # modalities list means capabilities are unknown, and sending + # images to a text-only model produces a 400 that aborts the + # entire turn. The text description in result_parts already + # tells the LLM the image exists and where it is cached. + supports_image = "image" in modalities if supports_image: # Build user message with images for LLM to review image_parts = [] From 3533fecb2fc3b029000f0aac2fefd97a11838b67 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:53:00 +0800 Subject: [PATCH 5/6] fix(agent): keep image degradation focused on providers --- .../qqofficial/qqofficial_message_event.py | 100 +++---- .../qqofficial/qqofficial_platform_adapter.py | 3 - tests/test_qqofficial_group_message_create.py | 260 +----------------- 3 files changed, 36 insertions(+), 327 deletions(-) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 16eaf4fe35f..62679d3adbe 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -27,7 +27,7 @@ from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain -from astrbot.api.message_components import At, File, Image, Plain, Record, Reply, Video +from astrbot.api.message_components import At, File, Image, Plain, Record, Video from astrbot.api.platform import AstrBotMessage, Group, PlatformMetadata from astrbot.core.platform.sources.qqofficial.qqofficial_chunked_upload import ( QQOFFICIAL_CHUNKED_UPLOAD_THRESHOLD, @@ -405,7 +405,6 @@ async def _post_send_one( video_file_source, file_source, file_name, - reply_message_id, ) = await QQOfficialMessageEvent._parse_to_qqofficial(message_to_send) # C2C 流式仅用于文本分片,富媒体时降级为普通发送,避免平台侧流式校验报错。 @@ -454,11 +453,6 @@ async def _post_send_one( if not isinstance(source, botpy.message.Message | botpy.message.DirectMessage): payload["msg_seq"] = random.randint(1, 10000) - # 插件通过 Reply 组件给出的引用 id,四个发送接口(群/C2C/频道/频道私信) - # 都以 message_reference 接受;拿不到 id 时保持原样,不影响正文发送 - if reply_message_id: - payload["message_reference"] = {"message_id": reply_message_id} - ret = None match source: @@ -636,36 +630,25 @@ async def _send_with_markdown_fallback( try: return await send_func(payload) except _QQOFFICIAL_SEND_API_ERRORS as err: - # markdown 被服务端拒绝(50056)时,保留 msg_id 退化为纯 content - # 被动回复。先去掉 msg_id 会把被动回复变成主动消息,既烧主动 - # 配额也可能直接失败(如 304049 主动消息限频)。 - if ( - isinstance(err, botpy.errors.ServerError) - and QQOfficialMessageEvent.MARKDOWN_NOT_ALLOWED_ERROR in str(err) - and payload.get("markdown") - and plain_text - ): - logger.warning( - "[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。" - ) + logger.info("[QQOfficial] 回复消息失败: %s, 尝试使用主动发送接口。", err) + if payload.get("msg_id"): fallback_payload = payload.copy() - fallback_payload.pop("markdown", None) - fallback_payload["content"] = plain_text - if fallback_payload.get("msg_type") == 2: - fallback_payload["msg_type"] = 0 - if stream: - fallback_content = cast(str, fallback_payload.get("content") or "") - if fallback_content and not fallback_content.endswith("\n"): - fallback_payload["content"] = fallback_content + "\n" - return await send_func(fallback_payload) + fallback_payload.pop("msg_id", None) + try: + ret = await send_func(fallback_payload) + logger.info("[QQOfficial] 使用主动发送接口发送成功。") + return ret + except _QQOFFICIAL_SEND_API_ERRORS as fallback_err: + err = fallback_err + payload = fallback_payload + + if not isinstance(err, botpy.errors.ServerError): + raise # QQ 流式 markdown 分片校验:内容必须以换行结尾。 # 某些边界场景服务端仍可能判定失败,这里做一次修正重试。 - # 与消息 id 无关,保留 msg_id 继续走被动回复。 - if ( - stream - and isinstance(err, botpy.errors.ServerError) - and QQOfficialMessageEvent.STREAM_MARKDOWN_NEWLINE_ERROR in str(err) + if stream and QQOfficialMessageEvent.STREAM_MARKDOWN_NEWLINE_ERROR in str( + err ): retry_payload = payload.copy() @@ -684,32 +667,26 @@ async def _send_with_markdown_fallback( ) return await send_func(retry_payload) - # 其余发送错误(如 msg_id 过期)才去 msg_id 走主动接口兜底。 - logger.info("[QQOfficial] 回复消息失败: %s, 尝试使用主动发送接口。", err) - if payload.get("msg_id"): - fallback_payload = payload.copy() - fallback_payload.pop("msg_id", None) - # 转主动发送时先保留引用:失败若与引用无关(如 markdown 校验), - # 引用上下文仍然有效,不应一并丢弃 - try: - ret = await send_func(fallback_payload) - logger.info("[QQOfficial] 使用主动发送接口发送成功。") - return ret - except _QQOFFICIAL_SEND_API_ERRORS as fallback_err: - if not fallback_payload.get("message_reference"): - raise fallback_err from err - # 主动发送仍失败,引用 id 可能已失效;去掉引用最后重试一次, - # 失效引用不应阻断正文送达 - retry_payload = fallback_payload.copy() - retry_payload.pop("message_reference", None) - try: - ret = await send_func(retry_payload) - logger.info("[QQOfficial] 引用已失效,去掉引用后主动发送成功。") - return ret - except _QQOFFICIAL_SEND_API_ERRORS as retry_err: - raise retry_err from fallback_err + if ( + QQOfficialMessageEvent.MARKDOWN_NOT_ALLOWED_ERROR not in str(err) + or not payload.get("markdown") + or not plain_text + ): + raise - raise + logger.warning( + "[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。" + ) + fallback_payload = payload.copy() + fallback_payload.pop("markdown", None) + fallback_payload["content"] = plain_text + if fallback_payload.get("msg_type") == 2: + fallback_payload["msg_type"] = 0 + if stream: + fallback_content = cast(str, fallback_payload.get("content") or "") + if fallback_content and not fallback_content.endswith("\n"): + fallback_payload["content"] = fallback_content + "\n" + return await send_func(fallback_payload) async def upload_group_and_c2c_image( self, @@ -939,7 +916,6 @@ async def _parse_to_qqofficial(message: MessageChain): video_file_source = None file_source = None file_name = None - reply_message_id = None for i in message.chain: if isinstance(i, Plain): plain_text += i.text @@ -991,11 +967,6 @@ async def _parse_to_qqofficial(message: MessageChain): file_source = file_path elif i.url: file_source = i.url - elif isinstance(i, Reply) and not reply_message_id: - # 出站引用回复:取第一个带被引用消息 id 的 Reply 组件 - quoted_id = str(i.id).strip() - if quoted_id: - reply_message_id = quoted_id else: logger.debug(f"qq_official 忽略 {i.type}") return ( @@ -1006,5 +977,4 @@ async def _parse_to_qqofficial(message: MessageChain): video_file_source, file_source, file_name, - reply_message_id, ) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index bc59e0bc7c6..46cd25366af 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -369,7 +369,6 @@ async def _send_by_session_common( video_file_source, file_source, file_name, - reply_message_id, ) = await QQOfficialMessageEvent._parse_to_qqofficial(message_chain) if ( not plain_text @@ -410,8 +409,6 @@ async def _send_by_session_common( } if msg_id and not allow_group_proactive_send: payload["msg_id"] = msg_id - if reply_message_id: - payload["message_reference"] = {"message_id": reply_message_id} ret: Any = None send_helper = SimpleNamespace(bot=self.client) diff --git a/tests/test_qqofficial_group_message_create.py b/tests/test_qqofficial_group_message_create.py index 3c10786ee86..59ed22e0f5d 100644 --- a/tests/test_qqofficial_group_message_create.py +++ b/tests/test_qqofficial_group_message_create.py @@ -251,47 +251,6 @@ async def test_parse_to_qqofficial_ignores_empty_at_component(qq: str | None): assert parsed[0] == "hello" -@pytest.mark.asyncio -async def test_parse_to_qqofficial_extracts_reply_reference(): - parsed = await QQOfficialMessageEvent._parse_to_qqofficial( - MessageChain(chain=[Reply(id="quoted-1"), Plain("hello")]) - ) - - assert parsed[7] == "quoted-1" - - -@pytest.mark.asyncio -async def test_parse_to_qqofficial_extracts_reply_reference_after_text(): - parsed = await QQOfficialMessageEvent._parse_to_qqofficial( - MessageChain(chain=[Plain("hello"), Reply(id="quoted-1")]) - ) - - assert parsed[7] == "quoted-1" - - -@pytest.mark.parametrize( - "chain", - [ - MessageChain(chain=[Plain("hello")]), - MessageChain(chain=[Reply(id=""), Plain("hello")]), - ], -) -@pytest.mark.asyncio -async def test_parse_to_qqofficial_reply_reference_defaults_to_none(chain): - parsed = await QQOfficialMessageEvent._parse_to_qqofficial(chain) - - assert parsed[7] is None - - -@pytest.mark.asyncio -async def test_parse_to_qqofficial_keeps_first_reply_reference(): - parsed = await QQOfficialMessageEvent._parse_to_qqofficial( - MessageChain(chain=[Reply(id="quoted-1"), Reply(id="quoted-2")]) - ) - - assert parsed[7] == "quoted-1" - - @pytest.mark.asyncio async def test_legacy_group_at_path_forces_bot_mention_when_mentions_missing(): message = botpy.message.GroupMessage( @@ -798,7 +757,7 @@ async def test_ws_group_send_by_session_with_media_uses_msg_type_7(monkeypatch): adapter._session_scene["group-1"] = "group" async def fake_parse(message_chain): - return ("caption", "fake-base64", None, None, None, None, None, None) + return ("caption", "fake-base64", None, None, None, None, None) async def fake_upload_image(self_, image_base64, file_type, **kwargs): return {"file_uuid": "u-1", "file_info": "i-1", "ttl": 0} @@ -820,223 +779,6 @@ async def fake_upload_image(self_, image_base64, file_type, **kwargs): assert kwargs["media"]["file_uuid"] == "u-1" -@pytest.mark.asyncio -async def test_ws_group_send_by_session_attaches_message_reference(): - adapter = QQOfficialPlatformAdapter( - { - "id": "qq-official-test", - "appid": "123", - "secret": "secret", - "enable_group_c2c": True, - "enable_guild_direct_message": False, - }, - {}, - asyncio.Queue(), - ) - adapter.client.api = SimpleNamespace( - post_group_message=AsyncMock(return_value={"id": "sent-quote"}), - post_message=AsyncMock(), - ) - adapter._session_scene["group-1"] = "group" - - await adapter.send_by_session( - MessageSession("qq_official", MessageType.GROUP_MESSAGE, "group-1"), - MessageChain(chain=[Reply(id="quoted-1"), Plain("reply text")]), - ) - - kwargs = adapter.client.api.post_group_message.await_args.kwargs - assert kwargs["message_reference"] == {"message_id": "quoted-1"} - - -@pytest.mark.asyncio -async def test_friend_send_by_session_attaches_message_reference(): - adapter = QQOfficialPlatformAdapter( - { - "id": "qq-official-test", - "appid": "123", - "secret": "secret", - "enable_group_c2c": True, - "enable_guild_direct_message": False, - }, - {}, - asyncio.Queue(), - ) - request = AsyncMock(return_value={"id": "sent-c2c-quote"}) - adapter.client.api = SimpleNamespace(_http=SimpleNamespace(request=request)) - - await adapter.send_by_session( - MessageSession("qq_official", MessageType.FRIEND_MESSAGE, "user-1"), - MessageChain(chain=[Reply(id="quoted-2"), Plain("reply text")]), - ) - - request.assert_awaited_once() - json_payload = request.await_args.kwargs["json"] - assert json_payload["message_reference"] == {"message_id": "quoted-2"} - - -@pytest.mark.asyncio -async def test_send_by_session_without_reply_adds_no_message_reference(): - adapter = QQOfficialPlatformAdapter( - { - "id": "qq-official-test", - "appid": "123", - "secret": "secret", - "enable_group_c2c": True, - "enable_guild_direct_message": False, - }, - {}, - asyncio.Queue(), - ) - adapter.client.api = SimpleNamespace( - post_group_message=AsyncMock(return_value={"id": "sent-plain"}), - post_message=AsyncMock(), - ) - adapter._session_scene["group-1"] = "group" - - await adapter.send_by_session( - MessageSession("qq_official", MessageType.GROUP_MESSAGE, "group-1"), - MessageChain(chain=[Plain("plain text")]), - ) - - kwargs = adapter.client.api.post_group_message.await_args.kwargs - assert "message_reference" not in kwargs - - -@pytest.mark.asyncio -async def test_active_send_fallback_keeps_valid_message_reference(): - calls = [] - - async def fake_send(payload): - calls.append(dict(payload)) - if len(calls) == 1: - raise botpy.errors.ServerError("passive msg_id expired") - return {"id": "sent-active"} - - payload = { - "content": "reply text", - "msg_type": 0, - "msg_id": "passive-1", - "message_reference": {"message_id": "quoted-1"}, - } - ret = await QQOfficialMessageEvent._send_with_markdown_fallback( - fake_send, payload, "reply text" - ) - - assert ret == {"id": "sent-active"} - assert len(calls) == 2 - assert "msg_id" not in calls[1] - assert calls[1]["message_reference"] == {"message_id": "quoted-1"} - - -@pytest.mark.asyncio -async def test_active_send_fallback_strips_reference_only_when_retry_fails(): - calls = [] - - async def fake_send(payload): - calls.append(dict(payload)) - if len(calls) < 3: - raise botpy.errors.ServerError("reference rejected") - return {"id": "sent-no-ref"} - - payload = { - "content": "reply text", - "msg_type": 0, - "msg_id": "passive-1", - "message_reference": {"message_id": "quoted-1"}, - } - ret = await QQOfficialMessageEvent._send_with_markdown_fallback( - fake_send, payload, "reply text" - ) - - assert ret == {"id": "sent-no-ref"} - assert len(calls) == 3 - assert calls[1]["message_reference"] == {"message_id": "quoted-1"} - assert "message_reference" not in calls[2] - - -@pytest.mark.asyncio -async def test_markdown_rejected_keeps_msg_id_and_degrades_to_content(): - # Regression test for #10420: a markdown rejection (50056) must degrade to - # a plain-content passive reply with msg_id intact, not drop msg_id and - # become a proactive message (which hits the proactive quota, e.g. 304049). - calls = [] - - async def fake_send(payload): - calls.append(dict(payload)) - if len(calls) == 1: - raise botpy.errors.ServerError("不允许发送原生 markdown") - return {"id": "sent-content"} - - payload = { - "markdown": {"content": "**bold** text"}, - "msg_type": 2, - "msg_id": "passive-1", - } - ret = await QQOfficialMessageEvent._send_with_markdown_fallback( - fake_send, payload, "**bold** text" - ) - - assert ret == {"id": "sent-content"} - assert len(calls) == 2 - assert calls[1]["msg_id"] == "passive-1" - assert "markdown" not in calls[1] - assert calls[1]["content"] == "**bold** text" - assert calls[1]["msg_type"] == 0 - - -@pytest.mark.asyncio -async def test_non_markdown_error_still_falls_back_to_active_send(): - # Errors unrelated to the payload content (stale msg_id and the like) - # keep the old behavior: retry once without msg_id as a proactive message. - calls = [] - - async def fake_send(payload): - calls.append(dict(payload)) - if len(calls) == 1: - raise botpy.errors.NotFoundError("msg_id expired") - return {"id": "sent-active"} - - payload = { - "markdown": {"content": "**bold** text"}, - "msg_type": 2, - "msg_id": "passive-1", - } - ret = await QQOfficialMessageEvent._send_with_markdown_fallback( - fake_send, payload, "**bold** text" - ) - - assert ret == {"id": "sent-active"} - assert len(calls) == 2 - assert "msg_id" not in calls[1] - - -@pytest.mark.asyncio -async def test_stream_newline_retry_keeps_msg_id(): - # The stream markdown newline fix is a content correction, not an id - # problem, so the retry must stay a passive reply with msg_id. - calls = [] - - async def fake_send(payload): - calls.append(dict(payload)) - if len(calls) == 1: - raise botpy.errors.ServerError("流式消息md分片需要\\n结束") - return {"id": "sent-newline"} - - payload = { - "markdown": {"content": "partial chunk"}, - "msg_type": 2, - "msg_id": "passive-1", - } - ret = await QQOfficialMessageEvent._send_with_markdown_fallback( - fake_send, payload, "partial chunk", stream={"msg_seq": 1} - ) - - assert ret == {"id": "sent-newline"} - assert len(calls) == 2 - assert calls[1]["msg_id"] == "passive-1" - assert calls[1]["markdown"]["content"] == "partial chunk\n" - - @pytest.mark.asyncio async def test_friend_send_by_session_renders_markdown(): adapter = QQOfficialPlatformAdapter( From 3f6d3cc756766cf5e323c6bd7c00c8bdf3d8e728 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:02:14 +0800 Subject: [PATCH 6/6] fix(agent): handle unknown tool-result image modalities Cover empty, null, text-only, and explicitly image-capable providers through the real tool-loop runner. Providers without explicit image support keep the cached-image text without loading or appending image data. --- .../agent/runners/tool_loop_agent_runner.py | 6 +- tests/test_tool_loop_agent_runner.py | 62 +++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index e7d180893ef..29712ce46a7 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -1107,12 +1107,12 @@ async def step(self): # If there are cached images and the model supports image input, # append a user message with images so LLM can see them if cached_images: - modalities = self.provider.provider_config.get("modalities", []) + modalities = self.provider.provider_config.get("modalities") or [] # Unlike the request-assembly path above, tool-result images # are new multimodal content the provider never asked for. # Be conservative: only append them when the provider config - # explicitly declares image support. An empty or missing - # modalities list means capabilities are unknown, and sending + # explicitly declares image support. An empty, null, or missing + # modalities value means capabilities are unknown, and sending # images to a text-only model produces a 400 that aborts the # entire turn. The text description in result_parts already # tells the LLM the image exists and where it is cached. diff --git a/tests/test_tool_loop_agent_runner.py b/tests/test_tool_loop_agent_runner.py index ef8e818bb5c..dc7ecfd4391 100644 --- a/tests/test_tool_loop_agent_runner.py +++ b/tests/test_tool_loop_agent_runner.py @@ -974,6 +974,68 @@ def fake_save_image( ] +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("modalities", "expect_image"), + [ + ([], False), + (None, False), + (["text", "tool_use"], False), + (["image", "tool_use"], True), + ], +) +async def test_tool_result_images_require_explicit_image_modality( + runner, provider_request, mock_hooks, monkeypatch, modalities, expect_image +): + from astrbot.core.agent.tool_image_cache import tool_image_cache + + provider = SingleToolThenFinalProvider("test_tool") + provider.provider_config["modalities"] = modalities + image_reads = [] + + def fake_save_image( + base64_data, tool_call_id, tool_name, index=0, mime_type="image/png" + ): + return SimpleNamespace( + file_path="call_123_0.png", mime_type=mime_type, tool_name=tool_name + ) + + def fake_read_image(file_path, mime_type): + image_reads.append((file_path, mime_type)) + return "dGVzdA==", mime_type + + monkeypatch.setattr(tool_image_cache, "save_image", fake_save_image) + monkeypatch.setattr(tool_image_cache, "get_image_base64_by_path", fake_read_image) + + await runner.reset( + provider=provider, + request=provider_request, + run_context=ContextWrapper(context=None), + tool_executor=MockMixedContentToolExecutor, + agent_hooks=mock_hooks, + streaming=False, + ) + + async for _ in runner.step_until_done(3): + pass + + image_messages = [ + message + for message in runner.run_context.messages + if isinstance(message.content, list) + and any(isinstance(part, ImageURLPart) for part in message.content) + ] + assert len(image_messages) == int(expect_image) + assert image_reads == ([("call_123_0.png", "image/png")] if expect_image else []) + + tool_messages = [ + message for message in runner.run_context.messages if message.role == "tool" + ] + assert "Image returned and cached at path='call_123_0.png'." in str( + tool_messages[0].content + ) + + @pytest.mark.asyncio async def test_runner_replaces_runtime_image_context_before_provider_call( runner, provider_request, mock_hooks