From 682beb14337881747641e6d117c209da05527f70 Mon Sep 17 00:00:00 2001 From: guts <2030746443@qq.com> Date: Thu, 10 Sep 2026 21:44:54 +0800 Subject: [PATCH 1/2] feat(qqofficial): inline public images in markdown messages (#10019) QQ renders rich-media messages (msg_type=7) with the image above the text, so a text-then-image MessageChain arrives visually reversed. Markdown keeps the original component order, but the adapter dropped markdown whenever the chain carried an image and fell back to msg_type=7. When the chain carries only publicly hosted images plus text, build the markdown body with the image inlined and keep msg_type=2, skipping both the image download and the rich-media upload. Chains with local or base64 images keep using the rich-media path, because markdown images must reference a publicly reachable URL that the platform fetches itself. Both send paths are updated: the session send path and the reply path. --- .../qqofficial/qqofficial_message_event.py | 97 ++++++++++++++-- .../qqofficial/qqofficial_platform_adapter.py | 58 +++++++--- tests/test_qqofficial_group_message_create.py | 107 ++++++++++++++++++ 3 files changed, 237 insertions(+), 25 deletions(-) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 706642224e..c72396cb9b 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -341,6 +341,51 @@ def _split_message_chain_by_media(message: MessageChain) -> list[MessageChain]: return chunks + @staticmethod + def _build_markdown_with_public_images( + message: MessageChain, + ) -> tuple[str, str] | None: + """Render a chain as markdown with publicly hosted images inlined. + + QQ renders rich-media messages (``msg_type=7``) with the image above the + text, which reverses a text-then-image chain. Markdown keeps the original + order, but its images must reference a publicly reachable URL because the + platform downloads and re-hosts them itself. Local paths and base64 + payloads therefore cannot take this path. + + Args: + message: The message chain to render. + + Returns: + A ``(markdown_content, text_only_content)`` tuple. The second value is + used when the platform rejects markdown and the adapter falls back to + a plain text message. Returns None when the chain cannot be rendered + as markdown, i.e. it carries no publicly hosted image or a component + that markdown cannot express. + """ + markdown_parts: list[str] = [] + text_parts: list[str] = [] + has_public_image = False + + for component in message.chain: + if isinstance(component, Plain): + markdown_parts.append(component.text) + text_parts.append(component.text) + elif isinstance(component, Image): + image_url = component.url or component.file or "" + if not image_url.startswith(("http://", "https://")): + return None + markdown_parts.append(f"![image]({image_url})") + has_public_image = True + else: + # At/Record/Video/File and friends have no markdown equivalent. + return None + + if not has_public_image: + return None + + return "".join(markdown_parts), "".join(text_parts) + async def _post_send(self, stream: dict | None = None): if not self.send_buffer: return None @@ -376,15 +421,37 @@ async def _post_send_one( logger.warning(f"[QQOfficial] 不支持的消息源类型: {type(source)}") return None - ( - plain_text, - image_base64, - image_path, - record_file_path, - video_file_source, - file_source, - file_name, - ) = await QQOfficialMessageEvent._parse_to_qqofficial(message_to_send) + # 图片是公网 URL 时优先内嵌进 Markdown,让 QQ 按原有图文顺序渲染; + # 走 msg_type=7 富媒体时 QQ 固定把图片渲染在文字上方。 + # 这条路径无需下载图片或上传富媒体,QQ 会自行下载转存该 URL。 + use_md = getattr(self.send_buffer, "use_markdown_", None) + markdown_with_images = ( + None + if use_md is False or stream is not None + else QQOfficialMessageEvent._build_markdown_with_public_images( + message_to_send + ) + ) + + if markdown_with_images is not None: + markdown_content, plain_text = markdown_with_images + image_base64 = None + image_path = None + record_file_path = None + video_file_source = None + file_source = None + file_name = None + else: + markdown_content = None + ( + plain_text, + image_base64, + image_path, + record_file_path, + video_file_source, + file_source, + file_name, + ) = await QQOfficialMessageEvent._parse_to_qqofficial(message_to_send) # C2C 流式仅用于文本分片,富媒体时降级为普通发送,避免平台侧流式校验报错。 if stream and ( @@ -394,7 +461,8 @@ async def _post_send_one( stream = None if ( - not plain_text + markdown_content is None + and not plain_text and not image_base64 and not image_path and not record_file_path @@ -415,9 +483,14 @@ async def _post_send_one( plain_text = plain_text + "\n" # 根据消息链的 use_markdown_ 标记决定发送模式 - use_md = getattr(self.send_buffer, "use_markdown_", None) - if use_md is False: + if markdown_content is not None: payload: dict = { + "markdown": MarkdownPayload(content=markdown_content), + "msg_type": 2, + "msg_id": self.message_obj.message_id, + } + elif use_md is False: + payload = { "content": plain_text, "msg_type": 0, "msg_id": self.message_obj.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 8f73ccae88..e66f2242b1 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -361,17 +361,45 @@ async def _send_by_session_common( await self._send_by_session_common(session, split_message_chain) return - ( - plain_text, - image_base64, - image_path, - record_file_path, - video_file_source, - file_source, - file_name, - ) = await QQOfficialMessageEvent._parse_to_qqofficial(message_chain) + use_md = getattr(message_chain, "use_markdown_", None) + markdown_disabled = use_md is False or ( + use_md is None and not self.use_markdown_default + ) + + # 图片是公网 URL 时优先内嵌进 Markdown,让 QQ 按原有图文顺序渲染; + # 走 msg_type=7 富媒体时 QQ 固定把图片渲染在文字上方。 + # 这条路径无需下载图片或上传富媒体,QQ 会自行下载转存该 URL。 + markdown_with_images = ( + None + if markdown_disabled + else QQOfficialMessageEvent._build_markdown_with_public_images( + message_chain + ) + ) + + if markdown_with_images is not None: + markdown_content, plain_text = markdown_with_images + image_base64 = None + image_path = None + record_file_path = None + video_file_source = None + file_source = None + file_name = None + else: + markdown_content = None + ( + plain_text, + image_base64, + image_path, + record_file_path, + video_file_source, + file_source, + file_name, + ) = await QQOfficialMessageEvent._parse_to_qqofficial(message_chain) + if ( - not plain_text + markdown_content is None + and not plain_text and not image_path and not image_base64 and not record_file_path @@ -399,9 +427,13 @@ async def _send_by_session_common( ) return - use_md = getattr(message_chain, "use_markdown_", None) - if use_md is False or (use_md is None and not self.use_markdown_default): - payload: dict[str, Any] = {"content": plain_text} + if markdown_content is not None: + payload: dict[str, Any] = { + "markdown": MarkdownPayload(content=markdown_content), + "msg_type": 2, + } + elif markdown_disabled: + payload = {"content": plain_text} else: payload = { "markdown": MarkdownPayload(content=plain_text) if plain_text else None, diff --git a/tests/test_qqofficial_group_message_create.py b/tests/test_qqofficial_group_message_create.py index 9e7f3047dd..64070112a6 100644 --- a/tests/test_qqofficial_group_message_create.py +++ b/tests/test_qqofficial_group_message_create.py @@ -694,6 +694,113 @@ 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_markdown_with_public_image_embeds_image(monkeypatch): + """A markdown-chain with a public image URL must keep markdown (#10019). + + QQ renders ``msg_type=7`` rich-media messages with the image above the text, + so a text-then-image chain arrives visually reversed. When the caller asks + for markdown, the image URL should instead be embedded in the markdown body + so the client keeps the original ordering. + """ + 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-md"}), + post_message=AsyncMock(), + ) + adapter._session_scene["group-1"] = "group" + + async def fake_parse(message_chain): + return ("caption", "ZmFrZS1iYXNlNjQ=", 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"), + Image.fromURL("https://example.com/a.png"), + ], + use_markdown_=True, + ), + ) + + kwargs = adapter.client.api.post_group_message.await_args.kwargs + assert kwargs["msg_type"] == 2, "expected a markdown message, not rich media" + assert "media" not in kwargs, "image must not be downgraded to rich media" + markdown_content = kwargs["markdown"]["content"] + assert "caption" in markdown_content + assert "https://example.com/a.png" in markdown_content + assert markdown_content.index("caption") < markdown_content.index( + "https://example.com/a.png" + ), "text must stay before the image" + + +@pytest.mark.asyncio +async def test_group_reply_markdown_with_public_image_embeds_image(): + """The reply path must inline public images as markdown too (#10019). + + ``_send_by_session_common`` and ``_post_send_one`` build the payload + separately, so both need coverage. + """ + _, message = _dispatch_group_message(_make_group_payload()) + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( + message, + MessageType.GROUP_MESSAGE, + ) + abm.session_id = abm.group_id + bot = SimpleNamespace( + api=SimpleNamespace( + post_group_message=AsyncMock(return_value={"id": "sent-md"}), + post_message=AsyncMock(), + ) + ) + event = QQOfficialMessageEvent( + abm.message_str, + abm, + SimpleNamespace(name="qq_official", id="qq-official-test"), + abm.session_id, + cast(Any, bot), + ) + + await event.send( + MessageChain( + chain=[ + Plain("caption"), + Image.fromURL("https://example.com/a.png"), + ], + use_markdown_=True, + ) + ) + + kwargs = bot.api.post_group_message.await_args.kwargs + assert kwargs["msg_type"] == 2, "expected a markdown message, not rich media" + assert "media" not in kwargs, "image must not be downgraded to rich media" + markdown_content = kwargs["markdown"]["content"] + assert "caption" in markdown_content + assert "https://example.com/a.png" in markdown_content + assert markdown_content.index("caption") < markdown_content.index( + "https://example.com/a.png" + ), "text must stay before the image" + + @pytest.mark.asyncio async def test_friend_send_by_session_renders_markdown(): adapter = QQOfficialPlatformAdapter( From 27d7ffdc1468cf2db989ff9a8116bb1bb67d2a64 Mon Sep 17 00:00:00 2001 From: guts <2030746443@qq.com> Date: Thu, 10 Sep 2026 23:00:57 +0800 Subject: [PATCH 2/2] fix(qqofficial): separate markdown images and keep multi-image chains intact Testing the markdown path against a real QQ bot surfaced two problems. 1. The generated markdown concatenated text and image with no line break, so QQ rendered the image inline and it overlapped the surrounding text. Each image now sits on its own block. 2. A chain carrying several public images was still split by media, producing one message per image. Markdown can carry several images in a single message, so markdown chains are no longer split. Tests assert the block separation and cover the multi-image case. --- .../qqofficial/qqofficial_message_event.py | 21 +++++++- .../qqofficial/qqofficial_platform_adapter.py | 19 ++++--- tests/test_qqofficial_group_message_create.py | 54 +++++++++++++++++++ 3 files changed, 84 insertions(+), 10 deletions(-) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index c72396cb9b..1f6c39cf2b 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -375,7 +375,10 @@ def _build_markdown_with_public_images( image_url = component.url or component.file or "" if not image_url.startswith(("http://", "https://")): return None - markdown_parts.append(f"![image]({image_url})") + # Keep the image on its own block: QQ renders an inline image so + # that it overlaps the surrounding text when no break separates + # them. + markdown_parts.append(f"\n![image]({image_url})\n") has_public_image = True else: # At/Record/Video/File and friends have no markdown equivalent. @@ -390,7 +393,21 @@ async def _post_send(self, stream: dict | None = None): if not self.send_buffer: return None - message_chains = self._split_message_chain_by_media(self.send_buffer) + # Markdown 能在同一条消息里承载多张图片,所以只有富媒体路径才需要按 + # 媒体拆分消息链。 + use_md = getattr(self.send_buffer, "use_markdown_", None) + markdown_with_images = ( + None + if use_md is False or stream is not None + else QQOfficialMessageEvent._build_markdown_with_public_images( + self.send_buffer + ) + ) + if markdown_with_images is not None: + message_chains = [self.send_buffer] + else: + message_chains = self._split_message_chain_by_media(self.send_buffer) + stream_for_chain = stream if len(message_chains) == 1 else None ret = None diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index e66f2242b1..772b80d057 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -353,14 +353,6 @@ async def _send_by_session_common( session.session_id.rsplit("_", 1)[-1], ) - message_chains = QQOfficialMessageEvent._split_message_chain_by_media( - message_chain - ) - if len(message_chains) > 1: - for split_message_chain in message_chains: - await self._send_by_session_common(session, split_message_chain) - return - use_md = getattr(message_chain, "use_markdown_", None) markdown_disabled = use_md is False or ( use_md is None and not self.use_markdown_default @@ -377,6 +369,17 @@ async def _send_by_session_common( ) ) + # Markdown 能在同一条消息里承载多张图片,所以只有富媒体路径才需要按 + # 媒体拆分消息链。 + if markdown_with_images is None: + message_chains = QQOfficialMessageEvent._split_message_chain_by_media( + message_chain + ) + if len(message_chains) > 1: + for split_message_chain in message_chains: + await self._send_by_session_common(session, split_message_chain) + return + if markdown_with_images is not None: markdown_content, plain_text = markdown_with_images image_base64 = None diff --git a/tests/test_qqofficial_group_message_create.py b/tests/test_qqofficial_group_message_create.py index 64070112a6..f859ec3010 100644 --- a/tests/test_qqofficial_group_message_create.py +++ b/tests/test_qqofficial_group_message_create.py @@ -751,6 +751,9 @@ async def fake_upload_image(self_, image_base64, file_type, **kwargs): assert markdown_content.index("caption") < markdown_content.index( "https://example.com/a.png" ), "text must stay before the image" + # The image must sit on its own block: QQ renders an inline image so that it + # overlaps the surrounding text when no line break separates them. + assert "\n![image](" in markdown_content @pytest.mark.asyncio @@ -799,6 +802,57 @@ async def test_group_reply_markdown_with_public_image_embeds_image(): assert markdown_content.index("caption") < markdown_content.index( "https://example.com/a.png" ), "text must stay before the image" + # The image must sit on its own block: QQ renders an inline image so that it + # overlaps the surrounding text when no line break separates them. + assert "\n![image](" in markdown_content + + +@pytest.mark.asyncio +async def test_ws_group_markdown_with_multiple_public_images_sends_one_message(): + """Markdown carries several images, so the chain must not be split by media.""" + 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-md"}), + 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("A"), + Image.fromURL("https://example.com/1.png"), + Plain("B"), + Image.fromURL("https://example.com/2.png"), + Plain("C"), + ], + use_markdown_=True, + ), + ) + + assert adapter.client.api.post_group_message.await_count == 1 + kwargs = adapter.client.api.post_group_message.await_args.kwargs + assert kwargs["msg_type"] == 2 + markdown_content = kwargs["markdown"]["content"] + positions = [ + markdown_content.index("A"), + markdown_content.index("https://example.com/1.png"), + markdown_content.index("B"), + markdown_content.index("https://example.com/2.png"), + markdown_content.index("C"), + ] + assert positions == sorted(positions), "component order must be preserved" @pytest.mark.asyncio