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
31 changes: 27 additions & 4 deletions astrbot/core/tools/message_tools.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import errno
import json
import os
import shlex
Expand Down Expand Up @@ -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,
)
Comment on lines +122 to +129
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To reduce code duplication across the different message types, consider combining the path resolution and existence check into a single helper method. This aligns with the general rule of refactoring similar functionality into shared helpers. Additionally, ensure that this new attachment handling functionality is accompanied by corresponding unit tests.

Suggested change
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 _resolve_and_require_file(self, context: ContextWrapper[AstrAgentContext], path: str) -> str:
local_path, _ = await self._resolve_path_from_sandbox(context, path)
if os.path.isfile(local_path):
return local_path
raise FileNotFoundError(
errno.ENOENT,
os.strerror(errno.ENOENT),
path)
References
  1. When implementing similar functionality for different cases (e.g., direct vs. quoted attachments), refactor the logic into a shared helper function to avoid code duplication.
  2. New functionality, such as handling attachments, should be accompanied by corresponding unit tests.


async def call(
self, context: ContextWrapper[AstrAgentContext], **kwargs
) -> ToolExecResult:
Expand Down Expand Up @@ -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)
Comment on lines 167 to +170
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the suggested _resolve_and_require_file helper to simplify the code and reduce duplication, following the repository's refactoring guidelines.

                        local_path = await self._resolve_and_require_file(context, path)
References
  1. When implementing similar functionality for different cases (e.g., direct vs. quoted attachments), refactor the logic into a shared helper function to avoid code duplication.

components.append(Comp.Image.fromFileSystem(path=local_path))
elif url:
components.append(Comp.Image.fromURL(url=url))
Expand All @@ -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)
Comment on lines 180 to +183
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the suggested _resolve_and_require_file helper to simplify the code and reduce duplication, following the repository's refactoring guidelines.

                        local_path = await self._resolve_and_require_file(context, path)
References
  1. When implementing similar functionality for different cases (e.g., direct vs. quoted attachments), refactor the logic into a shared helper function to avoid code duplication.

components.append(Comp.Record.fromFileSystem(path=local_path))
elif url:
components.append(Comp.Record.fromURL(url=url))
Expand All @@ -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)
Comment on lines 193 to +196
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the suggested _resolve_and_require_file helper to simplify the code and reduce duplication, following the repository's refactoring guidelines.

                        local_path = await self._resolve_and_require_file(context, path)
References
  1. When implementing similar functionality for different cases (e.g., direct vs. quoted attachments), refactor the logic into a shared helper function to avoid code duplication.

components.append(Comp.Video.fromFileSystem(path=local_path))
elif url:
components.append(Comp.Video.fromURL(url=url))
Expand All @@ -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)
Comment on lines 212 to +215
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the suggested _resolve_and_require_file helper to simplify the code and reduce duplication, following the repository's refactoring guidelines.

                        local_path = await self._resolve_and_require_file(context, path)
References
  1. When implementing similar functionality for different cases (e.g., direct vs. quoted attachments), refactor the logic into a shared helper function to avoid code duplication.

components.append(Comp.File(name=name, file=local_path))
elif url:
components.append(Comp.File(name=name, url=url))
Expand Down Expand Up @@ -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}"


Expand Down
71 changes: 60 additions & 11 deletions tests/unit/test_message_tools.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"}],
Expand All @@ -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"
Expand All @@ -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
Loading