fix: preserve qq official quoted message context

This commit is contained in:
Soulter
2026-07-06 21:59:33 +08:00
parent ab2502c174
commit ab83662b5a
2 changed files with 231 additions and 27 deletions

View File

@@ -17,7 +17,7 @@ from botpy.gateway import BotWebSocket
from astrbot import logger
from astrbot.api.event import MessageChain
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
from astrbot.api.message_components import At, File, Image, Plain, Record, Reply, Video
from astrbot.api.platform import (
AstrBotMessage,
MessageMember,
@@ -37,7 +37,73 @@ for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
def _set_raw_message_fields(message: Any, data: dict[str, Any]) -> None:
"""Preserve QQ message fields that qq-botpy does not expose.
Args:
message: Patched qq-botpy message object.
data: Raw message payload from QQ.
Returns:
None.
"""
message.raw_data = data
message.message_type = data.get("message_type")
msg_elements = data.get("msg_elements")
message.msg_elements = msg_elements if isinstance(msg_elements, list) else []
class PatchedMessage(botpy.message.Message):
__slots__ = ("raw_data", "message_type", "msg_elements")
def __init__(
self,
api: Any,
event_id: str | None,
data: dict[str, Any],
) -> None:
super().__init__(api, event_id, data) # type: ignore
_set_raw_message_fields(self, data)
class PatchedDirectMessage(botpy.message.DirectMessage):
__slots__ = ("raw_data", "message_type", "msg_elements")
def __init__(
self,
api: Any,
event_id: str | None,
data: dict[str, Any],
) -> None:
super().__init__(api, event_id, data) # type: ignore
_set_raw_message_fields(self, data)
class PatchedC2CMessage(botpy.message.C2CMessage):
__slots__ = ("raw_data", "message_type", "msg_elements")
def __init__(
self,
api: Any,
event_id: str | None,
data: dict[str, Any],
) -> None:
super().__init__(api, event_id, data) # type: ignore
_set_raw_message_fields(self, data)
class PatchedGroupMessage(botpy.message.GroupMessage):
__slots__ = ("raw_data", "message_type", "msg_elements")
def __init__(
self,
api: Any,
event_id: str | None,
data: dict[str, Any],
) -> None:
super().__init__(api, event_id, data) # type: ignore
_set_raw_message_fields(self, data)
class _User:
def __init__(self, data: dict[str, Any]) -> None:
self.id = data.get("id", None)
@@ -53,21 +119,43 @@ class PatchedGroupMessage(botpy.message.GroupMessage):
def _ensure_group_message_create_parser() -> None:
"""Register the missing qq-botpy parser for GROUP_MESSAGE_CREATE."""
"""Register qq-botpy message parsers with QQ quote payload preservation."""
if hasattr(ConnectionState, "parse_group_message_create"):
return
def build_parser(event_name: str, message_cls: type) -> Any:
"""Build a ConnectionState parser for one QQ message event.
def parse_group_message_create(self, payload: dict[str, Any]) -> None:
group_message = PatchedGroupMessage(
self.api,
payload.get("id", None),
payload.get("d", {}),
Args:
event_name: botpy dispatch event name.
message_cls: Patched message class used to retain raw fields.
Returns:
Parser function bound by qq-botpy's ConnectionState.
"""
def parse_message(self, payload: dict[str, Any]) -> None:
qq_message = message_cls(
self.api,
payload.get("id", None),
payload.get("d", {}),
)
self._dispatch(event_name, qq_message)
return parse_message
parser_specs = {
"message_create": ("message_create", PatchedMessage),
"at_message_create": ("at_message_create", PatchedMessage),
"direct_message_create": ("direct_message_create", PatchedDirectMessage),
"group_at_message_create": ("group_at_message_create", PatchedGroupMessage),
"c2c_message_create": ("c2c_message_create", PatchedC2CMessage),
"group_message_create": ("group_message_create", PatchedGroupMessage),
}
for parser_name, (event_name, message_cls) in parser_specs.items():
setattr(
ConnectionState,
f"parse_{parser_name}",
build_parser(event_name, message_cls),
)
logger.debug("[QQOfficial] Received group message: %s", group_message)
self._dispatch("group_message_create", group_message)
setattr(ConnectionState, "parse_group_message_create", parse_group_message_create)
class ManagedBotWebSocket(BotWebSocket):
@@ -491,25 +579,41 @@ class QQOfficialPlatformAdapter(Platform):
return
for attachment in attachments:
content_type = cast(
str,
getattr(attachment, "content_type", "") or "",
).lower()
url = QQOfficialPlatformAdapter._normalize_attachment_url(
cast(str | None, getattr(attachment, "url", None))
)
if not url:
continue
if content_type.startswith("image"):
msg.append(Image.fromURL(url))
if isinstance(attachment, dict):
content_type = str(
attachment.get("content_type")
or attachment.get("contentType")
or "",
).lower()
url = QQOfficialPlatformAdapter._normalize_attachment_url(
cast(str | None, attachment.get("url"))
)
filename = cast(
str,
attachment.get("filename")
or attachment.get("name")
or "attachment",
)
else:
content_type = cast(
str,
getattr(attachment, "content_type", "") or "",
).lower()
url = QQOfficialPlatformAdapter._normalize_attachment_url(
cast(str | None, getattr(attachment, "url", None))
)
filename = cast(
str,
getattr(attachment, "filename", None)
or getattr(attachment, "name", None)
or "attachment",
)
if not url:
continue
if content_type.startswith("image"):
msg.append(Image.fromURL(url))
else:
ext = Path(filename).suffix.lower()
image_exts = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
audio_exts = {
@@ -607,6 +711,53 @@ class QQOfficialPlatformAdapter(Platform):
abm.message_id = message.id
# abm.tag = "qq_official"
msg: list[BaseMessageComponent] = []
message_reference = getattr(message, "message_reference", None)
quoted_message_id = getattr(message_reference, "message_id", None)
raw_message_type = getattr(message, "message_type", None)
try:
is_quoted_message = int(raw_message_type or 0) == 103
except (TypeError, ValueError):
is_quoted_message = False
msg_elements = getattr(message, "msg_elements", None)
quoted_message_str = ""
quoted_element_message_id = ""
quoted_chain: list[BaseMessageComponent] = []
if is_quoted_message and isinstance(msg_elements, list) and msg_elements:
quoted_element = msg_elements[0]
if isinstance(quoted_element, dict):
quoted_content = quoted_element.get("content")
quoted_attachments = quoted_element.get("attachments")
quoted_element_message_id = str(
quoted_element.get("id") or quoted_element.get("message_id") or "",
)
else:
quoted_content = getattr(quoted_element, "content", None)
quoted_attachments = getattr(quoted_element, "attachments", None)
quoted_element_message_id = str(
getattr(quoted_element, "id", None)
or getattr(quoted_element, "message_id", None)
or "",
)
quoted_message_str = QQOfficialPlatformAdapter._parse_face_message(
str(quoted_content or "").strip()
)
if quoted_message_str:
quoted_chain.append(Plain(quoted_message_str))
if isinstance(quoted_attachments, list):
await QQOfficialPlatformAdapter._append_attachments(
quoted_chain,
quoted_attachments,
)
if quoted_message_id or quoted_element_message_id or quoted_chain:
msg.append(
Reply(
id=str(quoted_message_id or quoted_element_message_id or ""),
chain=quoted_chain,
message_str=quoted_message_str,
text=quoted_message_str,
)
)
if isinstance(message, botpy.message.GroupMessage) or isinstance(
message,

View File

@@ -10,7 +10,7 @@ import pytest
from botpy import ConnectionSession
from astrbot.api.event import MessageChain
from astrbot.api.message_components import At, Plain
from astrbot.api.message_components import At, Image, Plain, Reply
from astrbot.core.message.message_event_result import (
MessageEventResult,
ResultContentType,
@@ -38,8 +38,11 @@ def _make_group_payload(
mentions: list[dict] | None = None,
member_openid: str = "member-1",
group_openid: str = "group-1",
message_type: int | None = None,
msg_elements: list[dict] | None = None,
message_reference: dict | None = None,
) -> dict:
return {
data = {
"id": f"event-{message_id}",
"d": {
"id": message_id,
@@ -50,6 +53,13 @@ def _make_group_payload(
"attachments": [],
},
}
if message_type is not None:
data["d"]["message_type"] = message_type
if msg_elements is not None:
data["d"]["msg_elements"] = msg_elements
if message_reference is not None:
data["d"]["message_reference"] = message_reference
return data
def _dispatch_group_message(payload: dict) -> tuple[str, botpy.message.GroupMessage]:
@@ -108,6 +118,49 @@ async def test_parse_group_message_create_plain_message_has_no_at_component():
] == ["plain group message"]
@pytest.mark.asyncio
async def test_parse_group_message_create_quoted_context():
_, message = _dispatch_group_message(
_make_group_payload(
content="answer",
message_type=103,
message_reference={"message_id": "quoted-1"},
msg_elements=[
{
"content": "quoted text",
"attachments": [
{
"content_type": "image/png",
"filename": "quoted.png",
"url": "img.example.com/quoted.png",
}
],
}
],
)
)
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.GROUP_MESSAGE,
)
assert getattr(message, "message_type") == 103
assert getattr(message, "msg_elements")[0]["content"] == "quoted text"
reply = abm.message[0]
assert isinstance(reply, Reply)
assert reply.id == "quoted-1"
assert reply.message_str == "quoted text"
assert isinstance(reply.chain[0], Plain)
assert reply.chain[0].text == "quoted text"
assert isinstance(reply.chain[1], Image)
assert reply.chain[1].file == "https://img.example.com/quoted.png"
assert abm.message_str == "answer"
assert [
component.text for component in abm.message if isinstance(component, Plain)
][-1] == "answer"
@pytest.mark.asyncio
async def test_parse_group_message_create_bot_mention_cleans_plain_text():
_, message = _dispatch_group_message(