diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 49f7a9e8fe..2cf9683cc9 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -27,7 +27,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.platform.sources.qqofficial.qqofficial_chunked_upload import ( QQOFFICIAL_CHUNKED_UPLOAD_THRESHOLD, @@ -216,6 +216,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) + # QQ Official group bots cannot send @all mentions through this path. + 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] = [] @@ -322,9 +345,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, @@ -354,10 +378,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, @@ -365,10 +386,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, @@ -376,10 +394,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, @@ -388,10 +403,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 @@ -409,10 +421,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, @@ -420,10 +429,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, @@ -431,10 +437,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, @@ -443,10 +446,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( @@ -804,6 +804,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..d0627aa819 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -4,6 +4,7 @@ import logging import os import random +import re import time from pathlib import Path from types import SimpleNamespace @@ -379,7 +380,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 +406,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 +417,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 +428,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 +441,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 +452,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 +470,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 +479,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 +490,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 +502,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 @@ -674,7 +692,6 @@ def _parse_face_message(content: str) -> str: """ import base64 import json - import re def replace_face(match): face_tag = match.group(0) @@ -697,6 +714,15 @@ 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 "" + escaped_id = re.escape(mention_id) + markup_pattern = ( + rf'(?:|<@!?{escaped_id}>)' + ) + return re.sub(rf"(?:[ \t]*{markup_pattern}[ \t]*)+", " ", normalized) + @staticmethod async def _parse_from_qqofficial( message: botpy.message.Message @@ -781,12 +807,11 @@ 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 +848,9 @@ 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 14c8791429..1c64da1341 100644 --- a/tests/test_qqofficial_group_message_create.py +++ b/tests/test_qqofficial_group_message_create.py @@ -164,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}], ) ) @@ -188,6 +194,55 @@ async def test_parse_group_message_create_bot_mention_cleans_plain_text(): assert abm.group_id == "group-1" +@pytest.mark.parametrize( + ("content", "mention_id", "expected"), + [ + ("before<@bot-123>after", "bot-123", "before after"), + ("before <@bot-123><@!bot-123> after", "bot-123", "before after"), + ( + 'beforeafter', + "bot-123", + "before after", + ), + ("before<@other-user>after", "bot-123", "before<@other-user>after"), + ("before<@bot.123>after", "bot.123", "before after"), + ], +) +def test_strip_bot_mention_markup_preserves_word_boundaries( + content: str, + mention_id: str, + expected: str, +): + normalized = QQOfficialPlatformAdapter._strip_bot_mention_markup( + content, + mention_id, + ) + + assert normalized.strip() == expected + + +@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( @@ -310,6 +365,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_media_upload_propagates_qq_api_error(monkeypatch): """QQ upload errors propagate so callers cannot report a false success.""" @@ -317,9 +404,7 @@ async def test_media_upload_propagates_qq_api_error(monkeypatch): side_effect=botpy.errors.ServerError("413 Request Entity Too Large") ) send_helper = SimpleNamespace( - bot=SimpleNamespace( - api=SimpleNamespace(_http=SimpleNamespace(request=request)) - ) + bot=SimpleNamespace(api=SimpleNamespace(_http=SimpleNamespace(request=request))) ) monkeypatch.setattr( "astrbot.core.platform.sources.qqofficial.qqofficial_message_event._qqofficial_retry",