From ab83662b5a3ec75ff3d9eb2d822e0d754fac1272 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 6 Jul 2026 21:59:33 +0800 Subject: [PATCH] fix: preserve qq official quoted message context --- .../qqofficial/qqofficial_platform_adapter.py | 201 +++++++++++++++--- tests/test_qqofficial_group_message_create.py | 57 ++++- 2 files changed, 231 insertions(+), 27 deletions(-) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index fc96d4404..81dfc4e36 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -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, diff --git a/tests/test_qqofficial_group_message_create.py b/tests/test_qqofficial_group_message_create.py index 46d688d15..ee064646a 100644 --- a/tests/test_qqofficial_group_message_create.py +++ b/tests/test_qqofficial_group_message_create.py @@ -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(