diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 62679d3adb..ee48ca7aa1 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,13 +640,30 @@ async def _send_with_markdown_fallback( 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: - 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 @@ -916,6 +939,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 +991,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 +1006,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 46cd25366a..bc59e0bc7c 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 59ed22e0f5..95bef3d749 100644 --- a/tests/test_qqofficial_group_message_create.py +++ b/tests/test_qqofficial_group_message_create.py @@ -251,6 +251,47 @@ 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( @@ -757,7 +798,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 +820,140 @@ 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_friend_send_by_session_renders_markdown(): adapter = QQOfficialPlatformAdapter(