diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 98780230a8..706642224e 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -329,13 +329,7 @@ def _split_message_chain_by_media(message: MessageChain) -> list[MessageChain]: for component in message.chain: is_media = isinstance(component, Image | Record | Video | File) if is_media and current_has_media: - chunks.append( - MessageChain( - chain=current_chain, - use_t2i_=message.use_t2i_, - type=message.type, - ) - ) + chunks.append(message.derive(current_chain)) current_chain = [] current_has_media = False @@ -343,13 +337,7 @@ def _split_message_chain_by_media(message: MessageChain) -> list[MessageChain]: current_has_media = current_has_media or is_media if current_chain or not message.chain: - chunks.append( - MessageChain( - chain=current_chain, - use_t2i_=message.use_t2i_, - type=message.type, - ) - ) + chunks.append(message.derive(current_chain)) return chunks @@ -611,8 +599,8 @@ async def _post_send_one( return ret + @staticmethod async def _send_with_markdown_fallback( - self, send_func, payload: dict, plain_text: str, @@ -638,7 +626,9 @@ async def _send_with_markdown_fallback( # QQ 流式 markdown 分片校验:内容必须以换行结尾。 # 某些边界场景服务端仍可能判定失败,这里做一次修正重试。 - if stream and self.STREAM_MARKDOWN_NEWLINE_ERROR in str(err): + if stream and QQOfficialMessageEvent.STREAM_MARKDOWN_NEWLINE_ERROR in str( + err + ): retry_payload = payload.copy() markdown_payload = retry_payload.get("markdown") @@ -657,7 +647,7 @@ async def _send_with_markdown_fallback( return await send_func(retry_payload) if ( - self.MARKDOWN_NOT_ALLOWED_ERROR not in str(err) + QQOfficialMessageEvent.MARKDOWN_NOT_ALLOWED_ERROR not in str(err) or not payload.get("markdown") or not plain_text ): diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index c8b6230dbb..e7c9c55b9f 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -14,6 +14,7 @@ from botpy import Client from botpy.connection import ConnectionState from botpy.gateway import BotWebSocket +from botpy.types.message import MarkdownPayload from astrbot import logger from astrbot.api.event import MessageChain @@ -396,7 +397,14 @@ async def _send_by_session_common( ) return - payload: dict[str, Any] = {"content": plain_text} + use_md = getattr(message_chain, "use_markdown_", None) + if use_md is False: + payload: dict[str, Any] = {"content": plain_text} + else: + payload = { + "markdown": MarkdownPayload(content=plain_text) if plain_text else None, + "msg_type": 2, + } if msg_id and not allow_group_proactive_send: payload["msg_id"] = msg_id ret: Any = None @@ -414,6 +422,8 @@ async def _send_by_session_common( ) payload["media"] = media payload["msg_type"] = 7 + payload.pop("markdown", None) + payload["content"] = plain_text or None if record_file_path: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( send_helper, # type: ignore @@ -424,6 +434,8 @@ async def _send_by_session_common( if media: payload["media"] = media payload["msg_type"] = 7 + payload.pop("markdown", None) + payload["content"] = plain_text or None if video_file_source: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( send_helper, # type: ignore @@ -434,6 +446,8 @@ async def _send_by_session_common( if media: payload["media"] = media payload["msg_type"] = 7 + payload.pop("markdown", None) + payload["content"] = plain_text or None payload.pop("msg_id", None) if file_source: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( @@ -446,17 +460,29 @@ async def _send_by_session_common( if media: payload["media"] = media payload["msg_type"] = 7 + payload.pop("markdown", None) + payload["content"] = plain_text or None payload.pop("msg_id", None) - ret = await self.client.api.post_group_message( - group_openid=session.session_id, - **payload, + ret = await QQOfficialMessageEvent._send_with_markdown_fallback( + send_func=lambda retry_payload: self.client.api.post_group_message( + group_openid=session.session_id, + **retry_payload, + ), + payload=payload, + plain_text=plain_text, ) else: if image_path: payload["file_image"] = image_path - ret = await self.client.api.post_message( - channel_id=session.session_id, - **payload, + # Guild text-channel send API does not use the QQ v2 msg_type field. + payload.pop("msg_type", None) + ret = await QQOfficialMessageEvent._send_with_markdown_fallback( + send_func=lambda retry_payload: self.client.api.post_message( + channel_id=session.session_id, + **retry_payload, + ), + payload=payload, + plain_text=plain_text, ) elif session.message_type == MessageType.FRIEND_MESSAGE: @@ -473,6 +499,8 @@ async def _send_by_session_common( ) payload["media"] = media payload["msg_type"] = 7 + payload.pop("markdown", None) + payload["content"] = plain_text or None if record_file_path: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( send_helper, # type: ignore @@ -483,6 +511,8 @@ async def _send_by_session_common( if media: payload["media"] = media payload["msg_type"] = 7 + payload.pop("markdown", None) + payload["content"] = plain_text or None if video_file_source: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( send_helper, # type: ignore @@ -493,6 +523,8 @@ async def _send_by_session_common( if media: payload["media"] = media payload["msg_type"] = 7 + payload.pop("markdown", None) + payload["content"] = plain_text or None if file_source: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( send_helper, # type: ignore @@ -504,11 +536,17 @@ async def _send_by_session_common( if media: payload["media"] = media payload["msg_type"] = 7 + payload.pop("markdown", None) + payload["content"] = plain_text or None - ret = await QQOfficialMessageEvent.post_c2c_message( - send_helper, # type: ignore - openid=session.session_id, - **payload, + ret = await QQOfficialMessageEvent._send_with_markdown_fallback( + send_func=lambda retry_payload: QQOfficialMessageEvent.post_c2c_message( + send_helper, # type: ignore + openid=session.session_id, + **retry_payload, + ), + payload=payload, + plain_text=plain_text, ) else: logger.warning( diff --git a/tests/test_qqofficial_group_message_create.py b/tests/test_qqofficial_group_message_create.py index 338a92af0d..f5b6ea5ed5 100644 --- a/tests/test_qqofficial_group_message_create.py +++ b/tests/test_qqofficial_group_message_create.py @@ -428,7 +428,9 @@ async def test_ws_group_send_by_session_without_cached_msg_id_omits_msg_id(): adapter.client.api.post_group_message.assert_awaited_once() kwargs = adapter.client.api.post_group_message.await_args.kwargs assert kwargs["group_openid"] == "group-1" - assert kwargs["content"] == "proactive hello" + assert kwargs["markdown"]["content"] == "proactive hello" + assert kwargs["msg_type"] == 2 + assert "content" not in kwargs assert "msg_id" not in kwargs assert "msg_seq" in kwargs assert adapter._session_last_message_id["group-1"] == "sent-1" @@ -462,7 +464,9 @@ async def test_ws_group_send_by_session_with_cached_msg_id_still_omits_msg_id(): adapter.client.api.post_group_message.assert_awaited_once() kwargs = adapter.client.api.post_group_message.await_args.kwargs assert kwargs["group_openid"] == "group-1" - assert kwargs["content"] == "proactive with cache" + assert kwargs["markdown"]["content"] == "proactive with cache" + assert kwargs["msg_type"] == 2 + assert "content" not in kwargs assert "msg_id" not in kwargs assert "msg_seq" in kwargs @@ -515,7 +519,9 @@ async def test_webhook_group_send_by_session_without_cached_msg_id_omits_msg_id( adapter.client.api.post_group_message.assert_awaited_once() kwargs = adapter.client.api.post_group_message.await_args.kwargs assert kwargs["group_openid"] == "group-1" - assert kwargs["content"] == "webhook proactive hello" + assert kwargs["markdown"]["content"] == "webhook proactive hello" + assert kwargs["msg_type"] == 2 + assert "content" not in kwargs assert "msg_id" not in kwargs assert "msg_seq" in kwargs assert adapter._session_last_message_id["group-1"] == "sent-1" @@ -614,3 +620,195 @@ async def test_result_decorate_segments_qqofficial_ws_plain_result(): "第一段", "第二段", ] + + +@pytest.mark.asyncio +async def test_ws_group_send_by_session_use_markdown_false_sends_content(): + 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-1"}), + 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 content")], use_markdown_=False), + ) + + kwargs = adapter.client.api.post_group_message.await_args.kwargs + assert kwargs["content"] == "plain content" + assert "markdown" not in kwargs + assert "msg_type" not in kwargs + + +@pytest.mark.asyncio +async def test_ws_group_send_by_session_with_media_uses_msg_type_7(monkeypatch): + 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-media"}), + post_message=AsyncMock(), + ) + adapter._session_scene["group-1"] = "group" + + async def fake_parse(message_chain): + 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} + + monkeypatch.setattr(QQOfficialMessageEvent, "_parse_to_qqofficial", fake_parse) + monkeypatch.setattr( + QQOfficialMessageEvent, "upload_group_and_c2c_image", fake_upload_image + ) + + await adapter.send_by_session( + MessageSession("qq_official", MessageType.GROUP_MESSAGE, "group-1"), + MessageChain(chain=[Plain("caption")]), + ) + + kwargs = adapter.client.api.post_group_message.await_args.kwargs + assert kwargs["msg_type"] == 7 + assert "markdown" not in kwargs + assert kwargs["content"] == "caption" + assert kwargs["media"]["file_uuid"] == "u-1" + + +@pytest.mark.asyncio +async def test_friend_send_by_session_renders_markdown(): + 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"}) + adapter.client.api = SimpleNamespace(_http=SimpleNamespace(request=request)) + + await adapter.send_by_session( + MessageSession("qq_official", MessageType.FRIEND_MESSAGE, "user-1"), + MessageChain(chain=[Plain("hello friend")]), + ) + + request.assert_awaited_once() + json_payload = request.await_args.kwargs["json"] + assert json_payload["markdown"]["content"] == "hello friend" + assert json_payload["msg_type"] == 2 + + +@pytest.mark.asyncio +async def test_guild_channel_send_by_session_drops_msg_type(): + 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(), + post_message=AsyncMock(return_value={"id": "sent-guild"}), + ) + adapter._session_scene["guild-channel-1"] = "channel" + adapter._session_last_message_id["guild-channel-1"] = "cached-msg-id" + + await adapter.send_by_session( + MessageSession("qq_official", MessageType.GROUP_MESSAGE, "guild-channel-1"), + MessageChain(chain=[Plain("guild text")]), + ) + + adapter.client.api.post_message.assert_awaited_once() + kwargs = adapter.client.api.post_message.await_args.kwargs + assert kwargs["channel_id"] == "guild-channel-1" + assert kwargs["markdown"]["content"] == "guild text" + assert "msg_type" not in kwargs + + +def test_split_message_chain_by_media_preserves_use_markdown(): + # Splitting a mixed text/media chain must keep use_markdown_ on every chunk, + # otherwise _send_by_session_common would treat text chunks as Markdown even + # when markdown was explicitly disabled. Regression test for the sourcery review. + chain = MessageChain( + chain=[ + Plain("text before"), + Image(file="https://example.com/1.png"), + Image(file="https://example.com/2.png"), + ], + use_markdown_=False, + ) + chunks = QQOfficialMessageEvent._split_message_chain_by_media(chain) + assert len(chunks) == 2 + assert chunks[0].chain[0].text == "text before" + assert all(chunk.use_markdown_ is False for chunk in chunks) + + +@pytest.mark.asyncio +async def test_group_send_by_session_falls_back_to_content_when_markdown_rejected(): + # When QQ rejects a markdown payload, the proactive send must retry in content + # mode instead of propagating the exception. Regression test for the sourcery + # review of the proactive markdown payload. + adapter = QQOfficialPlatformAdapter( + { + "id": "qq-official-test", + "appid": "123", + "secret": "secret", + "enable_group_c2c": True, + "enable_guild_direct_message": False, + }, + {}, + asyncio.Queue(), + ) + posting = AsyncMock( + side_effect=[ + botpy.errors.ServerError("不允许发送原生 markdown"), + {"id": "sent-fallback"}, + ] + ) + adapter.client.api = SimpleNamespace( + post_group_message=posting, + 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("**bold** text")]), + ) + + assert posting.await_count == 2 + first = posting.await_args_list[0].kwargs + second = posting.await_args_list[1].kwargs + assert first["markdown"]["content"] == "**bold** text" + assert first["msg_type"] == 2 + assert "markdown" not in second + assert second["content"] == "**bold** text" + assert second["msg_type"] == 0