From 9825f67f51568f7ef47de1bc18741c2c55eef430 Mon Sep 17 00:00:00 2001 From: IQ-Director <2498726461@qq.com> Date: Wed, 15 Jul 2026 03:58:14 +0800 Subject: [PATCH 1/3] fix(qqofficial): render @ mentions in group messages - serialize At components as <@openid> markup - send mention-bearing replies and proactive messages as Markdown - preserve payload compatibility for media and guild channel messages - support legacy and current incoming mention formats - add regression tests for QQ Official @ mentions --- .../qqofficial/qqofficial_message_event.py | 14 +++-- .../qqofficial/qqofficial_platform_adapter.py | 41 +++++++++++++- tests/test_qqofficial_group_message_create.py | 54 ++++++++++++++++++- 3 files changed, 103 insertions(+), 6 deletions(-) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index e80c8296a5..899c57696e 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -25,7 +25,7 @@ from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain -from astrbot.api.message_components import File, Image, Plain, Record, Video +from astrbot.api.message_components import At, File, Image, Plain, Record, Video from astrbot.api.platform import AstrBotMessage, PlatformMetadata from astrbot.core.utils.media_utils import MediaResolver, file_uri_to_path, is_file_uri @@ -304,9 +304,13 @@ async def _post_send_one( ): plain_text = plain_text + "\n" - # 根据消息链的 use_markdown_ 标记决定发送模式 + # QQ only resolves <@openid> mentions in Markdown messages. + has_mention = any( + isinstance(item, At) and str(item.qq) != "all" + for item in message_to_send.chain + ) use_md = getattr(self.send_buffer, "use_markdown_", None) - if use_md is False: + if use_md is False and not has_mention: payload: dict = { "content": plain_text, "msg_type": 0, @@ -741,6 +745,10 @@ async def _parse_to_qqofficial(message: MessageChain): for i in message.chain: if isinstance(i, Plain): plain_text += i.text + elif isinstance(i, At): + qq_id = str(getattr(i, "qq", "")) + if qq_id and qq_id != "all": + plain_text += f"<@{qq_id}>" elif isinstance(i, Image) and not image_base64: if not i.file: raise ValueError("Unsupported image file format") diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index 66d2fc3472..364ff21e05 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -379,7 +379,20 @@ async def _send_by_session_common( ) return - payload: dict[str, Any] = {"content": plain_text} + has_mention = any( + isinstance(item, At) and str(item.qq) != "all" + for item in message_chain.chain + ) + use_markdown = ( + has_mention or getattr(message_chain, "use_markdown_", None) is True + ) + if use_markdown and plain_text: + payload: dict[str, Any] = { + "markdown": botpy.types.message.MarkdownPayload(content=plain_text), + "msg_type": 2, + } + else: + payload = {"content": plain_text, "msg_type": 0} if msg_id and not allow_group_proactive_send: payload["msg_id"] = msg_id ret: Any = None @@ -397,6 +410,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 @@ -407,6 +422,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 @@ -417,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 payload.pop("msg_id", None) if file_source: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( @@ -429,6 +448,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, @@ -437,6 +458,7 @@ async def _send_by_session_common( else: if image_path: payload["file_image"] = image_path + payload.pop("msg_type", None) ret = await self.client.api.post_message( channel_id=session.session_id, **payload, @@ -456,6 +478,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 @@ -466,6 +490,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 @@ -476,6 +502,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 @@ -487,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 ret = await QQOfficialMessageEvent.post_c2c_message( send_helper, # type: ignore @@ -782,6 +812,9 @@ async def _parse_from_qqofficial( plain_content_raw = message.content or "" for mention_id in bot_mention_ids: plain_content_raw = plain_content_raw.replace( + f'', + "", + ).replace( f"<@{mention_id}>", "", ).replace( @@ -824,6 +857,12 @@ async def _parse_from_qqofficial( plain_content = QQOfficialPlatformAdapter._parse_face_message( message.content.replace( + f'', + "", + ).replace( + "<@" + str(abm.self_id) + ">", + "", + ).replace( "<@!" + str(abm.self_id) + ">", "", ).strip() diff --git a/tests/test_qqofficial_group_message_create.py b/tests/test_qqofficial_group_message_create.py index ee064646a9..54cd1d1dfd 100644 --- a/tests/test_qqofficial_group_message_create.py +++ b/tests/test_qqofficial_group_message_create.py @@ -23,6 +23,9 @@ QQOfficialPlatformAdapter, _ensure_group_message_create_parser, ) +from astrbot.core.platform.sources.qqofficial.qqofficial_message_event import ( + QQOfficialMessageEvent, +) from astrbot.core.platform.sources.qqofficial.qqofficial_platform_adapter import ( botClient as QQOfficialBotClient, ) @@ -161,11 +164,17 @@ async def test_parse_group_message_create_quoted_context(): ][-1] == "answer" +@pytest.mark.parametrize( + "mention_markup", + ["<@bot-123>", "<@!bot-123>", ''], +) @pytest.mark.asyncio -async def test_parse_group_message_create_bot_mention_cleans_plain_text(): +async def test_parse_group_message_create_bot_mention_cleans_plain_text( + mention_markup: str, +): _, message = _dispatch_group_message( _make_group_payload( - content="<@!bot-123> hello there", + content=f"{mention_markup} hello there", mentions=[{"id": "bot-123", "is_you": True}], ) ) @@ -185,6 +194,15 @@ async def test_parse_group_message_create_bot_mention_cleans_plain_text(): assert abm.group_id == "group-1" +@pytest.mark.asyncio +async def test_parse_to_qqofficial_preserves_at_component_order(): + parsed = await QQOfficialMessageEvent._parse_to_qqofficial( + MessageChain(chain=[At(qq="member-1"), Plain(" hello"), At(qq="all")]) + ) + + assert parsed[0] == "<@member-1> hello" + + @pytest.mark.asyncio async def test_legacy_group_at_path_forces_bot_mention_when_mentions_missing(): message = botpy.message.GroupMessage( @@ -307,6 +325,38 @@ async def test_ws_group_send_by_session_with_cached_msg_id_still_omits_msg_id(): assert "msg_seq" in kwargs +@pytest.mark.asyncio +async def test_ws_group_send_by_session_with_at_uses_markdown(): + 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-at"}), + post_message=AsyncMock(), + ) + adapter._session_scene["group-1"] = "group" + chain = MessageChain(chain=[At(qq="member-1"), Plain(" hello")]) + chain.use_markdown(False) + + await adapter.send_by_session( + MessageSession("qq_official", MessageType.GROUP_MESSAGE, "group-1"), + chain, + ) + + kwargs = adapter.client.api.post_group_message.await_args.kwargs + assert kwargs["msg_type"] == 2 + assert kwargs["markdown"]["content"] == "<@member-1> hello" + assert "content" not in kwargs + + @pytest.mark.asyncio async def test_webhook_group_send_by_session_without_cached_msg_id_omits_msg_id(): adapter = QQOfficialWebhookPlatformAdapter( From 7f2f05c13d728f941df69db783d27baf9e06e5e8 Mon Sep 17 00:00:00 2001 From: IQ-Director <2498726461@qq.com> Date: Wed, 15 Jul 2026 04:52:00 +0800 Subject: [PATCH 2/3] fix(qqofficial): address review feedback - validate mention IDs before serialization - centralize mention detection and media payload normalization - consolidate incoming mention markup cleanup - add regression tests for invalid mention IDs --- .../qqofficial/qqofficial_message_event.py | 72 +++++++------- .../qqofficial/qqofficial_platform_adapter.py | 94 ++++++++----------- tests/test_qqofficial_group_message_create.py | 19 +++- 3 files changed, 90 insertions(+), 95 deletions(-) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 899c57696e..68439f703d 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -196,6 +196,29 @@ def _extract_response_message_id(ret) -> str | None: ret_id = getattr(ret, "id", None) return str(ret_id) if ret_id is not None else None + @staticmethod + def _get_mention_id(component: At) -> str | None: + qq = getattr(component, "qq", None) + if not qq: + return None + qq_id = str(qq) + return qq_id if qq_id != "all" else None + + @classmethod + def _has_mention(cls, message: MessageChain) -> bool: + return any( + isinstance(component, At) + and cls._get_mention_id(component) is not None + for component in message.chain + ) + + @staticmethod + def _set_media_payload(payload: dict, media: Media, plain_text: str) -> None: + payload["media"] = media + payload["msg_type"] = 7 + payload.pop("markdown", None) + payload["content"] = plain_text or None + @staticmethod def _split_message_chain_by_media(message: MessageChain) -> list[MessageChain]: chunks: list[MessageChain] = [] @@ -305,10 +328,7 @@ async def _post_send_one( plain_text = plain_text + "\n" # QQ only resolves <@openid> mentions in Markdown messages. - has_mention = any( - isinstance(item, At) and str(item.qq) != "all" - for item in message_to_send.chain - ) + has_mention = self._has_mention(message_to_send) use_md = getattr(self.send_buffer, "use_markdown_", None) if use_md is False and not has_mention: payload: dict = { @@ -340,10 +360,7 @@ async def _post_send_one( self.IMAGE_FILE_TYPE, group_openid=source.group_openid, ) - payload["media"] = media - payload["msg_type"] = 7 - payload.pop("markdown", None) - payload["content"] = plain_text or None + self._set_media_payload(payload, media, plain_text) if record_file_path: # group record msg media = await self.upload_group_and_c2c_media( record_file_path, @@ -351,10 +368,7 @@ async def _post_send_one( group_openid=source.group_openid, ) if media: - payload["media"] = media - payload["msg_type"] = 7 - payload.pop("markdown", None) - payload["content"] = plain_text or None + self._set_media_payload(payload, media, plain_text) if video_file_source: media = await self.upload_group_and_c2c_media( video_file_source, @@ -362,10 +376,7 @@ async def _post_send_one( group_openid=source.group_openid, ) if media: - payload["media"] = media - payload["msg_type"] = 7 - payload.pop("markdown", None) - payload["content"] = plain_text or None + self._set_media_payload(payload, media, plain_text) if file_source: media = await self.upload_group_and_c2c_media( file_source, @@ -374,10 +385,7 @@ async def _post_send_one( group_openid=source.group_openid, ) if media: - payload["media"] = media - payload["msg_type"] = 7 - payload.pop("markdown", None) - payload["content"] = plain_text or None + self._set_media_payload(payload, media, plain_text) ret = await self._send_with_markdown_fallback( send_func=lambda retry_payload: self.bot.api.post_group_message( group_openid=source.group_openid, # type: ignore @@ -395,10 +403,7 @@ async def _post_send_one( self.IMAGE_FILE_TYPE, openid=source.author.user_openid, ) - payload["media"] = media - payload["msg_type"] = 7 - payload.pop("markdown", None) - payload["content"] = plain_text or None + self._set_media_payload(payload, media, plain_text) if record_file_path: # c2c record media = await self.upload_group_and_c2c_media( record_file_path, @@ -406,10 +411,7 @@ async def _post_send_one( openid=source.author.user_openid, ) if media: - payload["media"] = media - payload["msg_type"] = 7 - payload.pop("markdown", None) - payload["content"] = plain_text or None + self._set_media_payload(payload, media, plain_text) if video_file_source: media = await self.upload_group_and_c2c_media( video_file_source, @@ -417,10 +419,7 @@ async def _post_send_one( openid=source.author.user_openid, ) if media: - payload["media"] = media - payload["msg_type"] = 7 - payload.pop("markdown", None) - payload["content"] = plain_text or None + self._set_media_payload(payload, media, plain_text) if file_source: media = await self.upload_group_and_c2c_media( file_source, @@ -429,10 +428,7 @@ async def _post_send_one( openid=source.author.user_openid, ) if media: - payload["media"] = media - payload["msg_type"] = 7 - payload.pop("markdown", None) - payload["content"] = plain_text or None + self._set_media_payload(payload, media, plain_text) if stream: ret = await self._send_with_markdown_fallback( send_func=lambda retry_payload: self.post_c2c_message( @@ -746,8 +742,8 @@ async def _parse_to_qqofficial(message: MessageChain): if isinstance(i, Plain): plain_text += i.text elif isinstance(i, At): - qq_id = str(getattr(i, "qq", "")) - if qq_id and qq_id != "all": + qq_id = QQOfficialMessageEvent._get_mention_id(i) + if qq_id: plain_text += f"<@{qq_id}>" elif isinstance(i, Image) and not image_base64: if not i.file: diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index 364ff21e05..a7656c98cc 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -379,10 +379,7 @@ async def _send_by_session_common( ) return - has_mention = any( - isinstance(item, At) and str(item.qq) != "all" - for item in message_chain.chain - ) + has_mention = QQOfficialMessageEvent._has_mention(message_chain) use_markdown = ( has_mention or getattr(message_chain, "use_markdown_", None) is True ) @@ -408,10 +405,9 @@ async def _send_by_session_common( QQOfficialMessageEvent.IMAGE_FILE_TYPE, group_openid=session.session_id, ) - payload["media"] = media - payload["msg_type"] = 7 - payload.pop("markdown", None) - payload["content"] = plain_text or None + QQOfficialMessageEvent._set_media_payload( + payload, media, plain_text + ) if record_file_path: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( send_helper, # type: ignore @@ -420,10 +416,9 @@ async def _send_by_session_common( group_openid=session.session_id, ) if media: - payload["media"] = media - payload["msg_type"] = 7 - payload.pop("markdown", None) - payload["content"] = plain_text or None + QQOfficialMessageEvent._set_media_payload( + payload, media, plain_text + ) if video_file_source: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( send_helper, # type: ignore @@ -432,10 +427,9 @@ async def _send_by_session_common( group_openid=session.session_id, ) if media: - payload["media"] = media - payload["msg_type"] = 7 - payload.pop("markdown", None) - payload["content"] = plain_text or None + QQOfficialMessageEvent._set_media_payload( + payload, media, plain_text + ) payload.pop("msg_id", None) if file_source: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( @@ -446,10 +440,9 @@ async def _send_by_session_common( group_openid=session.session_id, ) if media: - payload["media"] = media - payload["msg_type"] = 7 - payload.pop("markdown", None) - payload["content"] = plain_text or None + QQOfficialMessageEvent._set_media_payload( + payload, media, plain_text + ) payload.pop("msg_id", None) ret = await self.client.api.post_group_message( group_openid=session.session_id, @@ -476,10 +469,7 @@ async def _send_by_session_common( QQOfficialMessageEvent.IMAGE_FILE_TYPE, openid=session.session_id, ) - payload["media"] = media - payload["msg_type"] = 7 - payload.pop("markdown", None) - payload["content"] = plain_text or None + QQOfficialMessageEvent._set_media_payload(payload, media, plain_text) if record_file_path: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( send_helper, # type: ignore @@ -488,10 +478,9 @@ async def _send_by_session_common( openid=session.session_id, ) if media: - payload["media"] = media - payload["msg_type"] = 7 - payload.pop("markdown", None) - payload["content"] = plain_text or None + QQOfficialMessageEvent._set_media_payload( + payload, media, plain_text + ) if video_file_source: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( send_helper, # type: ignore @@ -500,10 +489,9 @@ async def _send_by_session_common( openid=session.session_id, ) if media: - payload["media"] = media - payload["msg_type"] = 7 - payload.pop("markdown", None) - payload["content"] = plain_text or None + QQOfficialMessageEvent._set_media_payload( + payload, media, plain_text + ) if file_source: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( send_helper, # type: ignore @@ -513,10 +501,9 @@ async def _send_by_session_common( openid=session.session_id, ) if media: - payload["media"] = media - payload["msg_type"] = 7 - payload.pop("markdown", None) - payload["content"] = plain_text or None + QQOfficialMessageEvent._set_media_payload( + payload, media, plain_text + ) ret = await QQOfficialMessageEvent.post_c2c_message( send_helper, # type: ignore @@ -727,6 +714,17 @@ def replace_face(match): # Match face tags: return re.sub(r"]*>", replace_face, content) + @staticmethod + def _strip_bot_mention_markup(content: str | None, mention_id: str) -> str: + normalized = content or "" + for markup in ( + f'', + f"<@{mention_id}>", + f"<@!{mention_id}>", + ): + normalized = normalized.replace(markup, "") + return normalized + @staticmethod async def _parse_from_qqofficial( message: botpy.message.Message @@ -811,15 +809,10 @@ async def _parse_from_qqofficial( group_mentioned = bool(bot_mention_ids) or force_group_mention plain_content_raw = message.content or "" for mention_id in bot_mention_ids: - plain_content_raw = plain_content_raw.replace( - f'', - "", - ).replace( - f"<@{mention_id}>", - "", - ).replace( - f"<@!{mention_id}>", - "", + plain_content_raw = ( + QQOfficialPlatformAdapter._strip_bot_mention_markup( + plain_content_raw, mention_id + ) ) abm.message_str = QQOfficialPlatformAdapter._parse_face_message( plain_content_raw.strip() @@ -856,15 +849,8 @@ async def _parse_from_qqofficial( abm.self_id = "" plain_content = QQOfficialPlatformAdapter._parse_face_message( - message.content.replace( - f'', - "", - ).replace( - "<@" + str(abm.self_id) + ">", - "", - ).replace( - "<@!" + str(abm.self_id) + ">", - "", + QQOfficialPlatformAdapter._strip_bot_mention_markup( + message.content, str(abm.self_id) ).strip() ) diff --git a/tests/test_qqofficial_group_message_create.py b/tests/test_qqofficial_group_message_create.py index 54cd1d1dfd..43936e8b89 100644 --- a/tests/test_qqofficial_group_message_create.py +++ b/tests/test_qqofficial_group_message_create.py @@ -19,13 +19,13 @@ from astrbot.core.pipeline.result_decorate.stage import ResultDecorateStage from astrbot.core.platform.message_session import MessageSession from astrbot.core.platform.message_type import MessageType +from astrbot.core.platform.sources.qqofficial.qqofficial_message_event import ( + QQOfficialMessageEvent, +) from astrbot.core.platform.sources.qqofficial.qqofficial_platform_adapter import ( QQOfficialPlatformAdapter, _ensure_group_message_create_parser, ) -from astrbot.core.platform.sources.qqofficial.qqofficial_message_event import ( - QQOfficialMessageEvent, -) from astrbot.core.platform.sources.qqofficial.qqofficial_platform_adapter import ( botClient as QQOfficialBotClient, ) @@ -203,6 +203,19 @@ async def test_parse_to_qqofficial_preserves_at_component_order(): assert parsed[0] == "<@member-1> hello" +@pytest.mark.parametrize("qq", [None, ""]) +@pytest.mark.asyncio +async def test_parse_to_qqofficial_ignores_empty_at_component(qq: str | None): + mention = At(qq="placeholder") + mention.qq = cast(Any, qq) + chain = MessageChain(chain=[mention, Plain("hello")]) + + parsed = await QQOfficialMessageEvent._parse_to_qqofficial(chain) + + assert parsed[0] == "hello" + assert QQOfficialMessageEvent._has_mention(chain) is False + + @pytest.mark.asyncio async def test_legacy_group_at_path_forces_bot_mention_when_mentions_missing(): message = botpy.message.GroupMessage( From f642a60c8fc42d9169bbcb745bf7285b5f07ef01 Mon Sep 17 00:00:00 2001 From: IQ-Director <2498726461@qq.com> Date: Wed, 15 Jul 2026 16:32:51 +0800 Subject: [PATCH 3/3] style(qqofficial): apply Ruff formatting --- .../platform/sources/qqofficial/qqofficial_message_event.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 68439f703d..abd4af6dd5 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -207,8 +207,7 @@ def _get_mention_id(component: At) -> str | None: @classmethod def _has_mention(cls, message: MessageChain) -> bool: return any( - isinstance(component, At) - and cls._get_mention_id(component) is not None + isinstance(component, At) and cls._get_mention_id(component) is not None for component in message.chain )