From d26e7c2fa9e7f19fabfa5edf9ed80aa377bf7dd4 Mon Sep 17 00:00:00 2001 From: OMSociety Date: Wed, 2 Sep 2026 02:05:29 +0800 Subject: [PATCH 1/3] fix(qqofficial): render markdown for proactive send_by_session messages --- .../qqofficial/qqofficial_platform_adapter.py | 28 +++- tests/test_qqofficial_group_message_create.py | 143 +++++++++++++++++- 2 files changed, 167 insertions(+), 4 deletions(-) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index c8b6230dbb..b980acb24e 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,6 +460,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) ret = await self.client.api.post_group_message( group_openid=session.session_id, @@ -454,6 +470,8 @@ async def _send_by_session_common( else: if image_path: payload["file_image"] = image_path + # Guild text-channel send API does not use the QQ v2 msg_type field. + payload.pop("msg_type", None) ret = await self.client.api.post_message( channel_id=session.session_id, **payload, @@ -473,6 +491,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 +503,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 +515,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,6 +528,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 ret = await QQOfficialMessageEvent.post_c2c_message( send_helper, # type: ignore diff --git a/tests/test_qqofficial_group_message_create.py b/tests/test_qqofficial_group_message_create.py index 338a92af0d..6a24192a21 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,134 @@ 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 From c28a8031953a17bdfb43723af478a35c0baedf1b Mon Sep 17 00:00:00 2001 From: OMSociety Date: Wed, 2 Sep 2026 02:47:17 +0800 Subject: [PATCH 2/3] fix(qqofficial): preserve use_markdown_ when splitting media chains --- .../qqofficial/qqofficial_message_event.py | 16 ++-------------- tests/test_qqofficial_group_message_create.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 98780230a8..8d08524012 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 diff --git a/tests/test_qqofficial_group_message_create.py b/tests/test_qqofficial_group_message_create.py index 6a24192a21..42a058f2e4 100644 --- a/tests/test_qqofficial_group_message_create.py +++ b/tests/test_qqofficial_group_message_create.py @@ -751,3 +751,21 @@ async def test_guild_channel_send_by_session_drops_msg_type(): 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) From 9a83e07b3a69762fda28fa615feb1a1b64be3fea Mon Sep 17 00:00:00 2001 From: OMSociety Date: Wed, 2 Sep 2026 03:06:34 +0800 Subject: [PATCH 3/3] fix(qqofficial): fall back to content when markdown payload is rejected --- .../qqofficial/qqofficial_message_event.py | 8 ++-- .../qqofficial/qqofficial_platform_adapter.py | 32 +++++++++----- tests/test_qqofficial_group_message_create.py | 43 +++++++++++++++++++ 3 files changed, 70 insertions(+), 13 deletions(-) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 8d08524012..706642224e 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -599,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, @@ -626,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") @@ -645,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 b980acb24e..e7c9c55b9f 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -463,18 +463,26 @@ async def _send_by_session_common( 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 # Guild text-channel send API does not use the QQ v2 msg_type field. payload.pop("msg_type", None) - ret = await self.client.api.post_message( - channel_id=session.session_id, - **payload, + 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: @@ -531,10 +539,14 @@ async def _send_by_session_common( 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 42a058f2e4..f5b6ea5ed5 100644 --- a/tests/test_qqofficial_group_message_create.py +++ b/tests/test_qqofficial_group_message_create.py @@ -769,3 +769,46 @@ def test_split_message_chain_by_media_preserves_use_markdown(): 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