diff --git a/astrbot/core/tools/message_tools.py b/astrbot/core/tools/message_tools.py index ca6f21e9b5..6f44a1647b 100644 --- a/astrbot/core/tools/message_tools.py +++ b/astrbot/core/tools/message_tools.py @@ -83,6 +83,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]): "Send message to the user. " "Supports various message types including `plain`, `image`, `record`, `video`, `file`, and `mention_user`. " "Use this tool to send media files (`image`, `record`, `video`, `file`), " + "to reply to a specific earlier message using `reply_to_message_id`, " "or when you need to proactively message the user(such as cron job). For other normal text replies, you can output directly and no need to use this tool." ) parameters: dict = Field( @@ -122,6 +123,15 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]): "required": ["type"], }, }, + "reply_to_message_id": { + "type": "string", + "description": ( + "Optional platform message ID to quote in the target session, " + "on platforms that support replies. Use only a real message ID " + "from that session, never invent one or use a history database row ID. " + "Omit to send without an explicit quote." + ), + }, "session": { "type": "string", "description": ( @@ -221,7 +231,18 @@ async def call( if not isinstance(messages, list) or not messages: return "error: messages parameter is empty or invalid." + reply_to_message_id = kwargs.get("reply_to_message_id") + if reply_to_message_id is not None: + if ( + not isinstance(reply_to_message_id, str) + or not reply_to_message_id.strip() + ): + return "error: reply_to_message_id must be a non-empty string." + reply_to_message_id = reply_to_message_id.strip() + components: list[Comp.BaseMessageComponent] = [] + if reply_to_message_id is not None: + components.append(Comp.Reply(id=reply_to_message_id)) for idx, msg in enumerate(messages): if not isinstance(msg, dict): return f"error: messages[{idx}] should be an object." diff --git a/tests/unit/test_message_tools.py b/tests/unit/test_message_tools.py index b12a26d8ad..7858d81472 100644 --- a/tests/unit/test_message_tools.py +++ b/tests/unit/test_message_tools.py @@ -472,3 +472,74 @@ async def mock_get_booter(*args, **kwargs): sent_chain = ctx.context.context.send_message.await_args.args[1] sent_file = sent_chain.chain[0] assert sent_file.name == "export" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("session", [None, "test:GroupMessage:other-group"]) +async def test_send_message_quotes_explicit_message_in_target_session(session): + """Keep the requested quote before text and mentions in the target session.""" + from astrbot.core.message.components import At, Plain, Reply + from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import ( + AiocqhttpMessageEvent, + ) + + ctx = _make_context(current_session="test:GroupMessage:current-group") + result = await SendMessageToUserTool().call( + ctx, + session=session, + reply_to_message_id=" -123456789 ", + messages=[ + {"type": "plain", "text": "About that earlier message"}, + {"type": "mention_user", "mention_user_id": "42"}, + ], + ) + + target, chain = ctx.context.context.send_message.await_args.args + assert str(target) == (session or ctx.context.event.unified_msg_origin) + assert result.startswith("Message sent") + assert [type(comp) for comp in chain.chain] == [Reply, Plain, At] + assert chain.chain[0].id == "-123456789" + segments = await AiocqhttpMessageEvent._parse_onebot_json(chain) + assert segments[0] == {"type": "reply", "data": {"id": "-123456789"}} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("reply_id", ["", " ", 123, True, [], {}]) +async def test_send_message_rejects_invalid_reply_id_before_sending(reply_id): + """Invalid quote arguments must not send a partial message.""" + ctx = _make_context() + result = await SendMessageToUserTool().call( + ctx, + reply_to_message_id=reply_id, + messages=[{"type": "plain", "text": "hello"}], + ) + assert result == "error: reply_to_message_id must be a non-empty string." + ctx.context.context.send_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_send_message_without_reply_id_keeps_plain_chain(): + """Omitting the quote argument preserves ordinary message sending.""" + from astrbot.core.message.components import Plain + + ctx = _make_context() + await SendMessageToUserTool().call( + ctx, messages=[{"type": "plain", "text": "hello"}] + ) + chain = ctx.context.context.send_message.await_args.args[1] + assert len(chain.chain) == 1 + assert isinstance(chain.chain[0], Plain) + + +@pytest.mark.asyncio +async def test_send_message_reply_does_not_bypass_cross_session_permission(): + """Quoting a message keeps the existing cross-session permission check.""" + ctx = _make_context(role="member") + result = await SendMessageToUserTool().call( + ctx, + session="test:GroupMessage:other-group", + reply_to_message_id="123", + messages=[{"type": "plain", "text": "hello"}], + ) + assert "error" in result.lower() + ctx.context.context.send_message.assert_not_awaited()