Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 180 additions & 1 deletion astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -108,6 +108,182 @@ def _id_to_sid(self, dingtalk_id: str | None) -> str:
return dingtalk_id[len(prefix) :]
return dingtalk_id or "unknown"

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,
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 = {}
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"):
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)
elif (item.get("msgType") or item.get("type")) == "picture":
parts.append("[Image]")
quoted_text = "".join(parts).strip()

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)
# DingTalk reports createdAt in milliseconds.
if quote_time > 1_000_000_000_000:
quote_time //= 1000
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=reply_chain,
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,
Expand Down Expand Up @@ -315,6 +491,9 @@ async def convert_msg(
file_name = Path(f_path).name
abm.message.append(File(name=file_name, file=f_path))

if reply := await self._parse_reply(message, robot_code):
abm.message.insert(0, reply)

await self._remember_sender_binding(message, abm)
return abm # 别忘了返回转换后的消息对象

Expand Down
139 changes: 138 additions & 1 deletion tests/test_dingtalk_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import dingtalk_stream
import pytest

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 MessageChain
from astrbot.core.platform.sources.dingtalk import dingtalk_adapter
from astrbot.core.platform.sources.dingtalk.dingtalk_adapter import (
Expand Down Expand Up @@ -219,3 +219,140 @@ 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",
"createdAt": 1_700_000_000,
"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"
assert result.message[0].time == 1_700_000_000


@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": "看一下引用",
"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]第二段"
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")]
Loading