diff --git a/astrbot/core/pipeline/waking_check/stage.py b/astrbot/core/pipeline/waking_check/stage.py index be792384e..fd4692354 100644 --- a/astrbot/core/pipeline/waking_check/stage.py +++ b/astrbot/core/pipeline/waking_check/stage.py @@ -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 diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py index 9a07608c3..bae98a807 100644 --- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py @@ -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://")): diff --git a/astrbot/core/star/filter/command.py b/astrbot/core/star/filter/command.py index 31949b674..abf339fda 100755 --- a/astrbot/core/star/filter/command.py +++ b/astrbot/core/star/filter/command.py @@ -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(): diff --git a/tests/test_command_filter.py b/tests/test_command_filter.py new file mode 100644 index 000000000..afd37a7c4 --- /dev/null +++ b/tests/test_command_filter.py @@ -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) diff --git a/tests/test_dingtalk_adapter.py b/tests/test_dingtalk_adapter.py index aa1e638c8..818ebd115 100644 --- a/tests/test_dingtalk_adapter.py +++ b/tests/test_dingtalk_adapter.py @@ -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"