fix(qqofficial): render @ mentions in group messages (#9285)

* 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

* 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

* style(qqofficial): apply Ruff formatting
This commit is contained in:
智商局局长
2026-07-18 16:01:17 +08:00
committed by GitHub
parent 0ac2e5c703
commit 77340bc509
3 changed files with 154 additions and 63 deletions

View File

@@ -25,7 +25,7 @@ from tenacity import (
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 @@ class QQOfficialMessageEvent(AstrMessageEvent):
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 @@ class QQOfficialMessageEvent(AstrMessageEvent):
):
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 @@ class QQOfficialMessageEvent(AstrMessageEvent):
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 @@ class QQOfficialMessageEvent(AstrMessageEvent):
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 @@ class QQOfficialMessageEvent(AstrMessageEvent):
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 @@ class QQOfficialMessageEvent(AstrMessageEvent):
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 @@ class QQOfficialMessageEvent(AstrMessageEvent):
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 @@ class QQOfficialMessageEvent(AstrMessageEvent):
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 @@ class QQOfficialMessageEvent(AstrMessageEvent):
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 @@ class QQOfficialMessageEvent(AstrMessageEvent):
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 @@ class QQOfficialMessageEvent(AstrMessageEvent):
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")

View File

@@ -379,7 +379,17 @@ class QQOfficialPlatformAdapter(Platform):
)
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 @@ class QQOfficialPlatformAdapter(Platform):
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 @@ class QQOfficialPlatformAdapter(Platform):
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 @@ class QQOfficialPlatformAdapter(Platform):
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 @@ class QQOfficialPlatformAdapter(Platform):
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 @@ class QQOfficialPlatformAdapter(Platform):
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 @@ class QQOfficialPlatformAdapter(Platform):
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 @@ class QQOfficialPlatformAdapter(Platform):
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 @@ class QQOfficialPlatformAdapter(Platform):
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 @@ class QQOfficialPlatformAdapter(Platform):
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 @@ class QQOfficialPlatformAdapter(Platform):
# Match face tags: <faceType=...>
return re.sub(r"<faceType=\d+[^>]*>", replace_face, content)
@staticmethod
def _strip_bot_mention_markup(content: str | None, mention_id: str) -> str:
normalized = content or ""
for markup in (
f'<qqbot-at-user id="{mention_id}" />',
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 @@ class QQOfficialPlatformAdapter(Platform):
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 @@ class QQOfficialPlatformAdapter(Platform):
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()
)

View File

@@ -19,6 +19,9 @@ from astrbot.core.pipeline.respond.stage import RespondStage
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>", '<qqbot-at-user id="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(