fix(dingtalk): handle command errors and rich-text mentions (#9389)

This commit is contained in:
Baolin Zhu
2026-07-26 16:15:42 +08:00
committed by GitHub
parent f9c6129b9e
commit b4459953a6
5 changed files with 211 additions and 12 deletions
+4 -2
View File
@@ -189,9 +189,11 @@ class WakingCheckStage(Stage):
break
except Exception as e:
await event.send(
MessageEventResult().message(
MessageEventResult()
.message(
f"插件 {star_map[handler.handler_module_path].name}: {e}",
),
)
.use_markdown(False),
)
event.stop_event()
passed = False
@@ -182,12 +182,15 @@ class DingtalkPlatformAdapter(Platform):
abm.message_id = cast(str, message.message_id)
abm.raw_message = message
leading_at_is_self = False
if abm.type == MessageType.GROUP_MESSAGE:
# 处理所有被 @ 的用户(包括机器人自己,因 at_users 已包含)
if message.at_users:
for user in message.at_users:
for index, user in enumerate(message.at_users):
if id := self._id_to_sid(user.dingtalk_id):
abm.message.append(At(qq=id))
if index == 0 and id == abm.self_id:
leading_at_is_self = True
abm.group_id = message.conversation_id
abm.session_id = abm.group_id
else:
@@ -232,10 +235,18 @@ class DingtalkPlatformAdapter(Platform):
)
contents: list[dict] = cast(list[dict], rtc.rich_text_list)
plain_parts: list[str] = []
for content in contents:
for index, content in enumerate(contents):
if "text" in content:
plain_text = cast(str, content.get("text") or "")
if plain_text:
# HarmonyOS repeats the leading bot mention as a text
# segment even though atUsers already represents it.
if (
index == 0
and leading_at_is_self
and plain_text.lstrip().startswith("@")
):
continue
plain_parts.append(plain_text)
abm.message.append(Plain(plain_text))
elif "type" in content and content["type"] == "picture":
@@ -577,13 +588,17 @@ class DingtalkPlatformAdapter(Platform):
text = segment.text.strip()
if not text and not at_str:
continue
await send_message(
msg_key="sampleMarkdown",
msg_param={
"title": "AstrBot",
"text": f"{at_str} {text}".strip(),
},
)
text = f"{at_str} {text}".strip()
if message_chain.use_markdown_ is False:
await send_message(
msg_key="sampleText",
msg_param={"content": text},
)
else:
await send_message(
msg_key="sampleMarkdown",
msg_param={"title": "AstrBot", "text": text},
)
elif isinstance(segment, Image):
photo_url = segment.file or segment.url or ""
if photo_url.startswith(("http://", "https://")):
+1 -1
View File
@@ -65,7 +65,7 @@ class CommandFilter(HandlerFilter):
def init_handler_md(self, handle_md: StarHandlerMetadata) -> None:
self.handler_md = handle_md
signature = inspect.signature(self.handler_md.handler)
signature = inspect.signature(self.handler_md.handler, eval_str=True)
self.handler_params = {} # 参数名 -> 参数类型,如果有默认值则为默认值
idx = 0
for k, v in signature.parameters.items():
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from astrbot.core.star.filter.command import CommandFilter
async def postponed_annotations_handler(
self,
event,
machine: str,
retries: int = 1,
) -> None:
pass
def test_command_filter_resolves_postponed_annotations():
command_filter = CommandFilter(
"probe",
handler_md=SimpleNamespace(handler=postponed_annotations_handler),
)
assert command_filter.handler_params == {"machine": str, "retries": 1}
assert command_filter.validate_and_convert_params(
["server-1", "2"],
command_filter.handler_params,
) == {"machine": "server-1", "retries": 2}
def test_command_filter_rejects_missing_postponed_required_param():
command_filter = CommandFilter(
"probe",
handler_md=SimpleNamespace(handler=postponed_annotations_handler),
)
with pytest.raises(ValueError, match="必要参数缺失"):
command_filter.validate_and_convert_params([], command_filter.handler_params)
+143
View File
@@ -1,8 +1,11 @@
import asyncio
import threading
import dingtalk_stream
import pytest
from astrbot.api.message_components import At, Plain
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 (
DINGTALK_RECONNECT_INITIAL_DELAY,
@@ -12,6 +15,29 @@ from astrbot.core.platform.sources.dingtalk.dingtalk_adapter import (
)
def _dingtalk_group_message(**payload) -> dingtalk_stream.ChatbotMessage:
"""Build a DingTalk group callback message for adapter tests.
Args:
**payload: Callback fields that vary between test cases.
Returns:
A parsed DingTalk chatbot message.
"""
return dingtalk_stream.ChatbotMessage.from_dict(
{
"conversationId": "conversation",
"conversationType": "2",
"createAt": 1_700_000_000_000,
"msgId": "message",
"senderId": "sender",
"senderNick": "sender",
"chatbotUserId": "bot",
**payload,
}
)
def test_dingtalk_reconnect_delay_uses_exponential_backoff():
assert [_dingtalk_reconnect_delay(i) for i in range(1, 5)] == [
10,
@@ -76,3 +102,120 @@ async def test_dingtalk_reconnect_delay_wakes_on_terminate(monkeypatch):
await adapter.terminate()
run_task.cancel()
await asyncio.gather(run_task, return_exceptions=True)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("use_markdown", "expected_key", "expected_param"),
[
(None, "sampleMarkdown", {"title": "AstrBot", "text": "first\nsecond"}),
(False, "sampleText", {"content": "first\nsecond"}),
],
)
async def test_dingtalk_text_respects_markdown_mode(
use_markdown,
expected_key,
expected_param,
):
sent = []
adapter = DingtalkPlatformAdapter.__new__(DingtalkPlatformAdapter)
async def capture_message(open_conversation_id, robot_code, msg_key, msg_param):
sent.append((open_conversation_id, robot_code, msg_key, msg_param))
adapter._send_group_message = capture_message
chain = MessageChain().message("first\nsecond").use_markdown(use_markdown)
await adapter._send_message_chain("group", "conversation", "robot", chain)
assert sent == [("conversation", "robot", expected_key, expected_param)]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"payload",
[
{
"atUsers": [{"dingtalkId": "bot"}],
"isInAtList": True,
"msgtype": "text",
"text": {"content": " /server"},
},
{
"atUsers": [{"dingtalkId": "bot"}],
"isInAtList": True,
"msgtype": "richText",
"content": {
"richText": [
{"text": "@ExampleBot"},
{"text": "/server"},
]
},
},
],
)
async def test_dingtalk_self_mention_produces_consistent_command_text(payload):
adapter = DingtalkPlatformAdapter.__new__(DingtalkPlatformAdapter)
result = await adapter.convert_msg(_dingtalk_group_message(**payload))
assert result.message_str == "/server"
assert len(result.message) == 2
assert isinstance(result.message[0], At)
assert result.message[0].qq == "bot"
assert isinstance(result.message[1], Plain)
assert result.message[1].text == "/server"
@pytest.mark.asyncio
async def test_dingtalk_rich_text_preserves_non_self_mention_text():
adapter = DingtalkPlatformAdapter.__new__(DingtalkPlatformAdapter)
message = _dingtalk_group_message(
atUsers=[{"dingtalkId": "another-user"}],
isInAtList=False,
msgtype="richText",
content={
"richText": [
{"text": "@AnotherUser"},
{"text": "/server"},
]
},
)
result = await adapter.convert_msg(message)
assert result.message_str == "@AnotherUser/server"
assert len(result.message) == 3
assert isinstance(result.message[0], At)
assert result.message[0].qq == "another-user"
assert isinstance(result.message[1], Plain)
assert result.message[1].text == "@AnotherUser"
assert isinstance(result.message[2], Plain)
assert result.message[2].text == "/server"
@pytest.mark.asyncio
async def test_dingtalk_rich_text_preserves_other_leading_mention():
adapter = DingtalkPlatformAdapter.__new__(DingtalkPlatformAdapter)
message = _dingtalk_group_message(
atUsers=[{"dingtalkId": "another-user"}, {"dingtalkId": "bot"}],
isInAtList=True,
msgtype="richText",
content={
"richText": [
{"text": "@AnotherUser"},
{"text": "@ExampleBot"},
{"text": "/server"},
]
},
)
result = await adapter.convert_msg(message)
assert result.message_str == "@AnotherUser@ExampleBot/server"
assert isinstance(result.message[0], At)
assert result.message[0].qq == "another-user"
assert isinstance(result.message[1], At)
assert result.message[1].qq == "bot"
assert isinstance(result.message[2], Plain)
assert result.message[2].text == "@AnotherUser"