diff --git a/astrbot/core/tools/message_tools.py b/astrbot/core/tools/message_tools.py index c3f33a7e8b..e3159f9349 100644 --- a/astrbot/core/tools/message_tools.py +++ b/astrbot/core/tools/message_tools.py @@ -1,3 +1,4 @@ +import errno import json import os import shlex @@ -118,6 +119,15 @@ async def _resolve_path_from_sandbox( return path, False + def _require_existing_file(self, resolved_path: str, original_path: str) -> str: + if os.path.isfile(resolved_path): + return resolved_path + raise FileNotFoundError( + errno.ENOENT, + os.strerror(errno.ENOENT), + original_path, + ) + async def call( self, context: ContextWrapper[AstrAgentContext], **kwargs ) -> ToolExecResult: @@ -157,6 +167,7 @@ async def call( local_path, _ = await self._resolve_path_from_sandbox( context, path ) + local_path = self._require_existing_file(local_path, path) components.append(Comp.Image.fromFileSystem(path=local_path)) elif url: components.append(Comp.Image.fromURL(url=url)) @@ -169,6 +180,7 @@ async def call( local_path, _ = await self._resolve_path_from_sandbox( context, path ) + local_path = self._require_existing_file(local_path, path) components.append(Comp.Record.fromFileSystem(path=local_path)) elif url: components.append(Comp.Record.fromURL(url=url)) @@ -181,6 +193,7 @@ async def call( local_path, _ = await self._resolve_path_from_sandbox( context, path ) + local_path = self._require_existing_file(local_path, path) components.append(Comp.Video.fromFileSystem(path=local_path)) elif url: components.append(Comp.Video.fromURL(url=url)) @@ -199,6 +212,7 @@ async def call( local_path, _ = await self._resolve_path_from_sandbox( context, path ) + local_path = self._require_existing_file(local_path, path) components.append(Comp.File(name=name, file=local_path)) elif url: components.append(Comp.File(name=name, url=url)) @@ -244,10 +258,19 @@ async def call( else: return f"error: invalid session: {session}" - await context.context.context.send_message( - target_session, - MessageChain(chain=components), - ) + try: + await context.context.context.send_message( + target_session, + MessageChain(chain=components), + ) + except Exception as exc: + logger.warning( + "Failed to send proactive message to session %s: %s", + target_session, + exc, + exc_info=True, + ) + return f"error: {exc}" return f"Message sent to session {target_session}" diff --git a/tests/unit/test_message_tools.py b/tests/unit/test_message_tools.py index eedee80abb..77c593f0ff 100644 --- a/tests/unit/test_message_tools.py +++ b/tests/unit/test_message_tools.py @@ -1,10 +1,19 @@ """Tests for send_message_to_user session handling.""" +import sys from types import SimpleNamespace from unittest.mock import AsyncMock import pytest + +sys.modules.setdefault( + "python_ripgrep", + SimpleNamespace(search=lambda *args, **kwargs: []), +) + +from astrbot.core.agent.run_context import ContextWrapper +from astrbot.core.platform.message_session import MessageSession from astrbot.core.tools.message_tools import SendMessageToUserTool @@ -54,7 +63,6 @@ async def test_send_message_with_partial_session_id_fallback(): session="oc_abc", ) assert "Message sent to session" in result - # Verify the target session was reconstructed with current_session's platform/msg_type call_args = ctx.context.context.send_message.call_args target_session = call_args[0][0] assert target_session.platform_id == "feishu" @@ -97,19 +105,11 @@ async def test_send_message_partial_session_falls_back_to_current(): @pytest.mark.asyncio async def test_cron_context_current_session_is_target_session(): - """在 cron 场景中,current_session 就是 cron 任务的目标 session。 - - cron 是主动唤醒,没有用户消息触发,因此没有"正在聊天的 session"。 - event.unified_msg_origin 来自 CronMessageEvent.session, - 而 CronMessageEvent.session 来自 cron job payload.session, - 即用户在 cron 配置中填写的目标会话。 - """ + """在 cron 场景中,current_session 就是 cron 任务的目标 session。""" tool = SendMessageToUserTool() - # cron 任务的目标 session(用户配置的完整三段式) cron_target_session = "feishu:GroupMessage:oc_cron_target" ctx = _make_context(current_session=cron_target_session) - # LLM 在 cron 上下文中只传了 session_id 部分 result = await tool.call( ctx, messages=[{"type": "plain", "text": "cron message"}], @@ -118,7 +118,6 @@ async def test_cron_context_current_session_is_target_session(): assert "Message sent to session" in result call_args = ctx.context.context.send_message.call_args target_session = call_args[0][0] - # 补全后的 session 应与 cron 目标 session 完全一致 assert str(target_session) == cron_target_session assert target_session.platform_id == "feishu" assert target_session.message_type.value == "GroupMessage" @@ -133,3 +132,53 @@ async def test_send_message_empty_messages_returns_error(): result = await tool.call(ctx, messages=[], session="oc_xxx") assert "error:" in result assert "messages" in result.lower() + + +@pytest.mark.asyncio +async def test_send_message_to_user_returns_error_for_missing_local_image_path(): + ctx = _make_context(current_session="aiocqhttp:FriendMessage:123456") + + async def _send_message(_session, _message_chain): + raise AssertionError("send_message should not be called for a missing file") + + ctx.context.context.send_message = _send_message + tool = SendMessageToUserTool() + + result = await tool.call( + ctx, + messages=[ + { + "type": "image", + "path": "/data/asset/images/record_store_vinyl.jpg", + } + ], + ) + + assert result.startswith("error: failed to build messages[0] component:") + assert "No such file or directory" in result + assert "/data/asset/images/record_store_vinyl.jpg" in result + + +@pytest.mark.asyncio +async def test_send_message_to_user_returns_error_when_send_message_raises(): + ctx = _make_context(current_session="aiocqhttp:FriendMessage:123456") + + async def _send_message(_session, _message_chain): + raise FileNotFoundError( + 2, + "No such file or directory", + "/data/asset/images/record_store_vinyl.jpg", + ) + + ctx.context.context.send_message = _send_message + tool = SendMessageToUserTool() + + result = await tool.call( + ctx, + session=MessageSession.from_str("aiocqhttp:FriendMessage:123456"), + messages=[{"type": "plain", "text": "fallback please"}], + ) + + assert result.startswith("error:") + assert "No such file or directory" in result + assert "/data/asset/images/record_store_vinyl.jpg" in result