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
21 changes: 21 additions & 0 deletions astrbot/core/tools/message_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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": (
Expand Down Expand Up @@ -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."
Expand Down
71 changes: 71 additions & 0 deletions tests/unit/test_message_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading