diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index e80c8296a5..abd4af6dd5 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 @@ -196,6 +196,28 @@ 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] = [] @@ -304,9 +326,10 @@ async def _post_send_one( ): plain_text = plain_text + "\n" - # 根据消息链的 use_markdown_ 标记决定发送模式 + # QQ only resolves <@openid> mentions in Markdown messages. + has_mention = self._has_mention(message_to_send) 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, @@ -336,10 +359,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, @@ -347,10 +367,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, @@ -358,10 +375,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, @@ -370,10 +384,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 @@ -391,10 +402,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, @@ -402,10 +410,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, @@ -413,10 +418,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, @@ -425,10 +427,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( @@ -741,6 +740,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 = 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: 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..a7656c98cc 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -379,7 +379,17 @@ async def _send_by_session_common( ) return - payload: dict[str, Any] = {"content": plain_text} + has_mention = QQOfficialMessageEvent._has_mention(message_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 @@ -395,8 +405,9 @@ async def _send_by_session_common( QQOfficialMessageEvent.IMAGE_FILE_TYPE, group_openid=session.session_id, ) - payload["media"] = media - payload["msg_type"] = 7 + QQOfficialMessageEvent._set_media_payload( + payload, media, plain_text + ) if record_file_path: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( send_helper, # type: ignore @@ -405,8 +416,9 @@ async def _send_by_session_common( group_openid=session.session_id, ) if media: - payload["media"] = media - payload["msg_type"] = 7 + QQOfficialMessageEvent._set_media_payload( + payload, media, plain_text + ) if video_file_source: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( send_helper, # type: ignore @@ -415,8 +427,9 @@ async def _send_by_session_common( group_openid=session.session_id, ) if media: - payload["media"] = media - payload["msg_type"] = 7 + QQOfficialMessageEvent._set_media_payload( + payload, media, plain_text + ) payload.pop("msg_id", None) if file_source: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( @@ -427,8 +440,9 @@ async def _send_by_session_common( group_openid=session.session_id, ) if media: - payload["media"] = media - payload["msg_type"] = 7 + 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, @@ -437,6 +451,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, @@ -454,8 +469,7 @@ async def _send_by_session_common( QQOfficialMessageEvent.IMAGE_FILE_TYPE, openid=session.session_id, ) - payload["media"] = media - payload["msg_type"] = 7 + QQOfficialMessageEvent._set_media_payload(payload, media, plain_text) if record_file_path: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( send_helper, # type: ignore @@ -464,8 +478,9 @@ async def _send_by_session_common( openid=session.session_id, ) if media: - payload["media"] = media - payload["msg_type"] = 7 + QQOfficialMessageEvent._set_media_payload( + payload, media, plain_text + ) if video_file_source: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( send_helper, # type: ignore @@ -474,8 +489,9 @@ async def _send_by_session_common( openid=session.session_id, ) if media: - payload["media"] = media - payload["msg_type"] = 7 + QQOfficialMessageEvent._set_media_payload( + payload, media, plain_text + ) if file_source: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( send_helper, # type: ignore @@ -485,8 +501,9 @@ async def _send_by_session_common( openid=session.session_id, ) if media: - payload["media"] = media - payload["msg_type"] = 7 + QQOfficialMessageEvent._set_media_payload( + payload, media, plain_text + ) ret = await QQOfficialMessageEvent.post_c2c_message( send_helper, # type: ignore @@ -697,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 @@ -781,12 +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"<@{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() @@ -823,9 +849,8 @@ async def _parse_from_qqofficial( abm.self_id = "" plain_content = QQOfficialPlatformAdapter._parse_face_message( - message.content.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 ee064646a9..43936e8b89 100644 --- a/tests/test_qqofficial_group_message_create.py +++ b/tests/test_qqofficial_group_message_create.py @@ -19,6 +19,9 @@ 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, @@ -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,28 @@ 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.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( @@ -307,6 +338,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(