From 1752f857dcb1ee293783dc71df08c07c56d88424 Mon Sep 17 00:00:00 2001 From: chinatsu <23171419+chinatsu1124@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:41:33 +0800 Subject: [PATCH 1/3] fix(dingtalk): preserve inbound quoted message context --- .../sources/dingtalk/dingtalk_adapter.py | 114 +++++++++++++++++- tests/test_dingtalk_adapter.py | 89 +++++++++++++- 2 files changed, 201 insertions(+), 2 deletions(-) diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py index bae98a8076..26027b2981 100644 --- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py @@ -13,7 +13,7 @@ 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, @@ -108,6 +108,115 @@ def _id_to_sid(self, dingtalk_id: str | None) -> str: return dingtalk_id[len(prefix) :] return dingtalk_id or "unknown" + def _parse_reply( + self, + message: dingtalk_stream.ChatbotMessage, + ) -> Reply | None: + """Convert DingTalk quote metadata into an AstrBot reply component. + + Args: + message: Parsed DingTalk chatbot callback message. + + Returns: + A reply component when the callback contains a usable quote, + otherwise None. + """ + text_content = getattr(message, "text", None) + text_extensions = getattr(text_content, "extensions", None) + if not isinstance(text_extensions, dict): + text_extensions = {} + message_extensions = getattr(message, "extensions", None) + if not isinstance(message_extensions, dict): + message_extensions = {} + + replied_message = text_extensions.get("repliedMsg") + legacy_quote = message_extensions.get("quoteMessage") + quote = None + if text_extensions.get("isReplyMsg") and isinstance(replied_message, dict): + quote = replied_message + elif isinstance(legacy_quote, dict): + quote = legacy_quote + if quote is None: + return None + + message_type = str( + quote.get("msgType") or quote.get("msgtype") or "text" + ).strip() + content = quote.get("content") + if not isinstance(content, (dict, str)): + content = quote.get("text") + if isinstance(content, str): + content = {"text": content} + if not isinstance(content, dict): + content = {} + + quoted_text = "" + for key in ("text", "content"): + value = content.get(key) + if isinstance(value, str) and value.strip(): + quoted_text = value.strip() + break + + if not quoted_text and message_type == "richText": + parts = [] + rich_text = content.get("richText") + if isinstance(rich_text, list): + for item in rich_text: + if not isinstance(item, dict): + continue + item_text = item.get("content") or item.get("text") + if isinstance(item_text, str) and item_text.strip(): + parts.append(item_text.strip()) + elif (item.get("msgType") or item.get("type")) == "picture": + parts.append("[Image]") + quoted_text = "".join(parts) + + if not quoted_text: + placeholders = { + "picture": "[Image]", + "audio": "[Audio]", + "voice": "[Audio]", + "video": "[Video]", + "interactiveCard": "[Card]", + "chatRecord": "[Chat history]", + } + if message_type == "file": + file_name = content.get("fileName") + quoted_text = ( + f"[File: {file_name}]" + if isinstance(file_name, str) and file_name + else "[File]" + ) + else: + quoted_text = placeholders.get(message_type, "") + + quote_id = str( + quote.get("msgId") or message_extensions.get("originalMsgId") or "" + ).strip() + if not quote_id and not quoted_text: + return None + + sender_id = str(quote.get("senderId") or "") + if sender_id: + sender_id = self._id_to_sid(sender_id) + created_at = quote.get("createdAt") or 0 + try: + quote_time = int(created_at) + if quote_time > 10_000_000_000: + quote_time //= 1000 + except (TypeError, ValueError): + quote_time = 0 + + return Reply( + id=quote_id, + chain=[Plain(quoted_text)] if quoted_text else [], + sender_id=sender_id, + sender_nickname=str(quote.get("senderNick") or ""), + time=quote_time, + message_str=quoted_text, + text=quoted_text, + ) + async def send_by_session( self, session: MessageSesion, @@ -315,6 +424,9 @@ async def convert_msg( file_name = Path(f_path).name abm.message.append(File(name=file_name, file=f_path)) + if reply := self._parse_reply(message): + abm.message.insert(0, reply) + await self._remember_sender_binding(message, abm) return abm # 别忘了返回转换后的消息对象 diff --git a/tests/test_dingtalk_adapter.py b/tests/test_dingtalk_adapter.py index 818ebd1156..536dfc5184 100644 --- a/tests/test_dingtalk_adapter.py +++ b/tests/test_dingtalk_adapter.py @@ -4,7 +4,7 @@ import dingtalk_stream import pytest -from astrbot.api.message_components import At, Plain +from astrbot.api.message_components import At, Plain, Reply from astrbot.core.message.message_event_result import MessageChain from astrbot.core.platform.sources.dingtalk import dingtalk_adapter from astrbot.core.platform.sources.dingtalk.dingtalk_adapter import ( @@ -219,3 +219,90 @@ async def test_dingtalk_rich_text_preserves_other_leading_mention(): assert result.message[1].qq == "bot" assert isinstance(result.message[2], Plain) assert result.message[2].text == "@AnotherUser" + + +@pytest.mark.asyncio +async def test_dingtalk_text_reply_preserves_quoted_message(): + adapter = DingtalkPlatformAdapter.__new__(DingtalkPlatformAdapter) + message = _dingtalk_group_message( + atUsers=[{"dingtalkId": "bot"}], + isInAtList=True, + msgtype="text", + text={ + "content": "你能回答这个问题么", + "isReplyMsg": True, + "repliedMsg": { + "msgType": "text", + "msgId": "quoted-message", + "senderId": "$:LWCP_v1:$quoted-sender", + "senderNick": "Quoted User", + "createdAt": 1_700_000_000_000, + "content": {"text": "这个产品目前接入了哪些模型?"}, + }, + }, + ) + + result = await adapter.convert_msg(message) + + assert result.message_str == "你能回答这个问题么" + assert isinstance(result.message[0], Reply) + assert result.message[0].id == "quoted-message" + assert result.message[0].sender_id == "quoted-sender" + assert result.message[0].sender_nickname == "Quoted User" + assert result.message[0].time == 1_700_000_000 + assert result.message[0].message_str == "这个产品目前接入了哪些模型?" + assert len(result.message[0].chain) == 1 + assert isinstance(result.message[0].chain[0], Plain) + assert isinstance(result.message[1], At) + assert isinstance(result.message[2], Plain) + + +@pytest.mark.asyncio +async def test_dingtalk_legacy_quote_message_is_supported(): + adapter = DingtalkPlatformAdapter.__new__(DingtalkPlatformAdapter) + message = _dingtalk_group_message( + msgtype="text", + text={"content": "继续说"}, + quoteMessage={ + "msgId": "legacy-quoted-message", + "msgtype": "text", + "senderId": "legacy-sender", + "senderNick": "Legacy User", + "text": {"content": "旧格式引用内容"}, + }, + ) + + result = await adapter.convert_msg(message) + + assert isinstance(result.message[0], Reply) + assert result.message[0].id == "legacy-quoted-message" + assert result.message[0].message_str == "旧格式引用内容" + assert result.message[0].sender_nickname == "Legacy User" + + +@pytest.mark.asyncio +async def test_dingtalk_rich_text_reply_builds_readable_quote(): + adapter = DingtalkPlatformAdapter.__new__(DingtalkPlatformAdapter) + message = _dingtalk_group_message( + msgtype="text", + text={ + "content": "看一下引用", + "isReplyMsg": True, + "repliedMsg": { + "msgType": "richText", + "msgId": "rich-quoted-message", + "content": { + "richText": [ + {"msgType": "text", "content": "第一段"}, + {"msgType": "picture", "downloadCode": "image-code"}, + {"type": "text", "text": "第二段"}, + ] + }, + }, + }, + ) + + result = await adapter.convert_msg(message) + + assert isinstance(result.message[0], Reply) + assert result.message[0].message_str == "第一段[Image]第二段" From ea21ee197c30e987f2957c80bf0ccb64d8ca2dff Mon Sep 17 00:00:00 2001 From: chinatsu <23171419+chinatsu1124@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:56:06 +0800 Subject: [PATCH 2/3] fix(dingtalk): preserve quoted rich text spacing --- astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py | 7 ++++--- tests/test_dingtalk_adapter.py | 6 ++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py index 26027b2981..e48f7c5410 100644 --- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py @@ -166,10 +166,10 @@ def _parse_reply( continue item_text = item.get("content") or item.get("text") if isinstance(item_text, str) and item_text.strip(): - parts.append(item_text.strip()) + parts.append(item_text) elif (item.get("msgType") or item.get("type")) == "picture": parts.append("[Image]") - quoted_text = "".join(parts) + quoted_text = "".join(parts).strip() if not quoted_text: placeholders = { @@ -202,7 +202,8 @@ def _parse_reply( created_at = quote.get("createdAt") or 0 try: quote_time = int(created_at) - if quote_time > 10_000_000_000: + # DingTalk reports createdAt in milliseconds. + if quote_time > 1_000_000_000_000: quote_time //= 1000 except (TypeError, ValueError): quote_time = 0 diff --git a/tests/test_dingtalk_adapter.py b/tests/test_dingtalk_adapter.py index 536dfc5184..62c05d8d7f 100644 --- a/tests/test_dingtalk_adapter.py +++ b/tests/test_dingtalk_adapter.py @@ -268,6 +268,7 @@ async def test_dingtalk_legacy_quote_message_is_supported(): "msgtype": "text", "senderId": "legacy-sender", "senderNick": "Legacy User", + "createdAt": 1_700_000_000, "text": {"content": "旧格式引用内容"}, }, ) @@ -278,6 +279,7 @@ async def test_dingtalk_legacy_quote_message_is_supported(): assert result.message[0].id == "legacy-quoted-message" assert result.message[0].message_str == "旧格式引用内容" assert result.message[0].sender_nickname == "Legacy User" + assert result.message[0].time == 1_700_000_000 @pytest.mark.asyncio @@ -293,7 +295,7 @@ async def test_dingtalk_rich_text_reply_builds_readable_quote(): "msgId": "rich-quoted-message", "content": { "richText": [ - {"msgType": "text", "content": "第一段"}, + {"msgType": "text", "content": "第一段 "}, {"msgType": "picture", "downloadCode": "image-code"}, {"type": "text", "text": "第二段"}, ] @@ -305,4 +307,4 @@ async def test_dingtalk_rich_text_reply_builds_readable_quote(): result = await adapter.convert_msg(message) assert isinstance(result.message[0], Reply) - assert result.message[0].message_str == "第一段[Image]第二段" + assert result.message[0].message_str == "第一段 [Image]第二段" From b73abf2bbc2a1a34b03710f614d21be53aef5fa3 Mon Sep 17 00:00:00 2001 From: chinatsu <23171419+chinatsu1124@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:03:03 +0800 Subject: [PATCH 3/3] fix(dingtalk): resolve images in quoted messages --- .../sources/dingtalk/dingtalk_adapter.py | 72 ++++++++++++++++++- tests/test_dingtalk_adapter.py | 50 ++++++++++++- 2 files changed, 118 insertions(+), 4 deletions(-) diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py index e48f7c5410..90ade3763b 100644 --- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py @@ -108,14 +108,16 @@ def _id_to_sid(self, dingtalk_id: str | None) -> str: return dingtalk_id[len(prefix) :] return dingtalk_id or "unknown" - def _parse_reply( + async def _parse_reply( self, message: dingtalk_stream.ChatbotMessage, + robot_code: str, ) -> Reply | None: """Convert DingTalk quote metadata into an AstrBot reply component. Args: message: Parsed DingTalk chatbot callback message. + robot_code: Robot code used to download quoted media. Returns: A reply component when the callback contains a usable quote, @@ -149,6 +151,11 @@ def _parse_reply( content = {"text": content} if not isinstance(content, dict): content = {} + else: + content = content.copy() + for key in ("downloadCode", "download_code", "pictureDownloadCode"): + if key not in content and quote.get(key): + content[key] = quote[key] quoted_text = "" for key in ("text", "content"): @@ -208,9 +215,68 @@ def _parse_reply( except (TypeError, ValueError): quote_time = 0 + reply_chain: list[Plain | Image] = [] + if message_type == "picture": + nested_picture = content.get("picture") + if not isinstance(nested_picture, dict): + nested_picture = {} + download_code = str( + content.get("downloadCode") + or content.get("download_code") + or content.get("pictureDownloadCode") + or nested_picture.get("downloadCode") + or "" + ).strip() + if download_code and robot_code: + image_path = await self.download_ding_file( + download_code, + robot_code, + "jpg", + ) + if image_path: + reply_chain.append(Image.fromFileSystem(image_path)) + if not reply_chain and quoted_text: + reply_chain.append(Plain(quoted_text)) + elif message_type == "richText": + rich_text = content.get("richText") + if isinstance(rich_text, list): + for item in rich_text: + if not isinstance(item, dict): + continue + item_type = item.get("msgType") or item.get("type") + if item_type == "picture": + nested_picture = item.get("picture") + if not isinstance(nested_picture, dict): + nested_picture = {} + download_code = str( + item.get("downloadCode") + or item.get("download_code") + or item.get("pictureDownloadCode") + or nested_picture.get("downloadCode") + or "" + ).strip() + if download_code and robot_code: + image_path = await self.download_ding_file( + download_code, + robot_code, + "jpg", + ) + if image_path: + reply_chain.append(Image.fromFileSystem(image_path)) + continue + reply_chain.append(Plain("[Image]")) + continue + item_text = item.get("content") or item.get("text") + if isinstance(item_text, str) and item_text: + reply_chain.append(Plain(item_text)) + if not reply_chain and quoted_text: + reply_chain.append(Plain(quoted_text)) + elif quoted_text: + reply_chain.append(Plain(quoted_text)) + return Reply( id=quote_id, - chain=[Plain(quoted_text)] if quoted_text else [], + chain=reply_chain, sender_id=sender_id, sender_nickname=str(quote.get("senderNick") or ""), time=quote_time, @@ -425,7 +491,7 @@ async def convert_msg( file_name = Path(f_path).name abm.message.append(File(name=file_name, file=f_path)) - if reply := self._parse_reply(message): + if reply := await self._parse_reply(message, robot_code): abm.message.insert(0, reply) await self._remember_sender_binding(message, abm) diff --git a/tests/test_dingtalk_adapter.py b/tests/test_dingtalk_adapter.py index 62c05d8d7f..4ac45a7679 100644 --- a/tests/test_dingtalk_adapter.py +++ b/tests/test_dingtalk_adapter.py @@ -4,7 +4,7 @@ import dingtalk_stream import pytest -from astrbot.api.message_components import At, Plain, Reply +from astrbot.api.message_components import At, Image, Plain, Reply from astrbot.core.message.message_event_result import MessageChain from astrbot.core.platform.sources.dingtalk import dingtalk_adapter from astrbot.core.platform.sources.dingtalk.dingtalk_adapter import ( @@ -285,7 +285,15 @@ async def test_dingtalk_legacy_quote_message_is_supported(): @pytest.mark.asyncio async def test_dingtalk_rich_text_reply_builds_readable_quote(): adapter = DingtalkPlatformAdapter.__new__(DingtalkPlatformAdapter) + downloads = [] + + async def fake_download(download_code, robot_code, ext): + downloads.append((download_code, robot_code, ext)) + return "/tmp/quoted-rich-image.jpg" + + adapter.download_ding_file = fake_download message = _dingtalk_group_message( + robotCode="robot", msgtype="text", text={ "content": "看一下引用", @@ -308,3 +316,43 @@ async def test_dingtalk_rich_text_reply_builds_readable_quote(): assert isinstance(result.message[0], Reply) assert result.message[0].message_str == "第一段 [Image]第二段" + assert [type(item) for item in result.message[0].chain] == [ + Plain, + Image, + Plain, + ] + assert downloads == [("image-code", "robot", "jpg")] + + +@pytest.mark.asyncio +async def test_dingtalk_picture_reply_downloads_quoted_image(): + adapter = DingtalkPlatformAdapter.__new__(DingtalkPlatformAdapter) + downloads = [] + + async def fake_download(download_code, robot_code, ext): + downloads.append((download_code, robot_code, ext)) + return "/tmp/quoted-picture.jpg" + + adapter.download_ding_file = fake_download + message = _dingtalk_group_message( + robotCode="robot", + msgtype="text", + text={ + "content": "What does this image show?", + "isReplyMsg": True, + "repliedMsg": { + "msgType": "picture", + "msgId": "quoted-picture-message", + "content": {"downloadCode": "quoted-picture-code"}, + }, + }, + ) + + result = await adapter.convert_msg(message) + + reply = result.message[0] + assert isinstance(reply, Reply) + assert reply.message_str == "[Image]" + assert len(reply.chain) == 1 + assert isinstance(reply.chain[0], Image) + assert downloads == [("quoted-picture-code", "robot", "jpg")]