mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-19 10:22:04 +08:00
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:
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user