diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 706642224e..1f6c39cf2b 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -341,11 +341,73 @@ 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 + # 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. + 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 - 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 @@ -376,15 +438,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 +478,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 +500,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..772b80d057 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -353,25 +353,56 @@ async def _send_by_session_common( session.session_id.rsplit("_", 1)[-1], ) - message_chains = QQOfficialMessageEvent._split_message_chain_by_media( - 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 len(message_chains) > 1: - for split_message_chain in message_chains: - 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) + # 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 + 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 +430,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..f859ec3010 100644 --- a/tests/test_qqofficial_group_message_create.py +++ b/tests/test_qqofficial_group_message_create.py @@ -694,6 +694,167 @@ 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" + # 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_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" + # 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 async def test_friend_send_by_session_renders_markdown(): adapter = QQOfficialPlatformAdapter(