mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
* fix(message_tools): 路径不存在时抛出异常并阻止消息发送 - _resolve_path_from_sandbox 在所有解析路径均失败时改为抛出 FileNotFoundError,而非静默返回原始路径,避免将无效路径传递给下游组件 - 新增 component_type 关键字参数,使错误信息能明确指出是 image/record/video/file 哪类资源路径缺失 - 在 call 方法中捕获 FileNotFoundError 并提前返回错误字符串,确保路径无效时不会继续构建或发送任何消息组件 - 补充单元测试,验证缺失图片路径场景下 send_message 不会被调用 * Update tests/unit/test_message_tools.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * fix(tools): propagate sandbox error instead of masking as FileNotFoundError --------- Co-authored-by: Ruochen Pan <badbatch0x01@gmail.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: RC-CHN <1051989940@qq.com>
164 lines
5.9 KiB
Python
164 lines
5.9 KiB
Python
"""Tests for send_message_to_user session handling."""
|
||
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock
|
||
|
||
import pytest
|
||
|
||
from astrbot.core.tools.message_tools import SendMessageToUserTool
|
||
|
||
|
||
def _make_context(
|
||
current_session="feishu:GroupMessage:oc_xxx",
|
||
role="admin",
|
||
require_admin=True,
|
||
):
|
||
"""Build a minimal ContextWrapper for SendMessageToUserTool."""
|
||
cfg = {"provider_settings": {"computer_use_require_admin": require_admin}}
|
||
return SimpleNamespace(
|
||
context=SimpleNamespace(
|
||
event=SimpleNamespace(
|
||
unified_msg_origin=current_session,
|
||
role=role,
|
||
get_sender_id=lambda: "user-1",
|
||
),
|
||
context=SimpleNamespace(
|
||
get_config=lambda umo: cfg,
|
||
send_message=AsyncMock(),
|
||
),
|
||
)
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_message_with_full_three_part_session():
|
||
"""LLM passes a complete three-part session string."""
|
||
tool = SendMessageToUserTool()
|
||
ctx = _make_context(current_session="feishu:GroupMessage:oc_aaa")
|
||
result = await tool.call(
|
||
ctx,
|
||
messages=[{"type": "plain", "text": "hello"}],
|
||
session="feishu:GroupMessage:oc_aaa",
|
||
)
|
||
assert "Message sent to session" in result
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_message_with_partial_session_id_fallback():
|
||
"""LLM passes only session_id (no colons) — fallback to current_session's prefix."""
|
||
tool = SendMessageToUserTool()
|
||
ctx = _make_context(current_session="feishu:GroupMessage:oc_abc")
|
||
result = await tool.call(
|
||
ctx,
|
||
messages=[{"type": "plain", "text": "hello"}],
|
||
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"
|
||
assert target_session.message_type.value == "GroupMessage"
|
||
assert target_session.session_id == "oc_abc"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_message_defaults_to_current_session():
|
||
"""LLM does not pass session — uses current_session directly."""
|
||
tool = SendMessageToUserTool()
|
||
ctx = _make_context(current_session="feishu:GroupMessage:oc_xxx")
|
||
result = await tool.call(
|
||
ctx,
|
||
messages=[{"type": "plain", "text": "hello"}],
|
||
)
|
||
assert "Message sent to session" in result
|
||
call_args = ctx.context.context.send_message.call_args
|
||
target_session = call_args[0][0]
|
||
assert str(target_session) == "feishu:GroupMessage:oc_xxx"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_message_partial_session_falls_back_to_current():
|
||
"""LLM passes session_id matching current_session's id — same session, just incomplete format."""
|
||
tool = SendMessageToUserTool()
|
||
ctx = _make_context(current_session="qq_official:GroupMessage:g123")
|
||
result = await tool.call(
|
||
ctx,
|
||
messages=[{"type": "plain", "text": "world"}],
|
||
session="g123",
|
||
)
|
||
assert "Message sent to session" in result
|
||
call_args = ctx.context.context.send_message.call_args
|
||
target_session = call_args[0][0]
|
||
assert target_session.platform_id == "qq_official"
|
||
assert target_session.message_type.value == "GroupMessage"
|
||
assert target_session.session_id == "g123"
|
||
|
||
|
||
@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 配置中填写的目标会话。
|
||
"""
|
||
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"}],
|
||
session="oc_cron_target",
|
||
)
|
||
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"
|
||
assert target_session.session_id == "oc_cron_target"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_message_empty_messages_returns_error():
|
||
"""Empty or missing messages returns error before session resolution."""
|
||
tool = SendMessageToUserTool()
|
||
ctx = _make_context()
|
||
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_missing_image_path_stops_before_send(tmp_path, monkeypatch):
|
||
"""Missing image paths fail before sending any message components."""
|
||
tool = SendMessageToUserTool()
|
||
ctx = _make_context()
|
||
missing_image_path = tmp_path / "missing.png"
|
||
|
||
async def mock_get_booter(*args, **kwargs):
|
||
del args, kwargs
|
||
raise RuntimeError("sandbox unavailable")
|
||
|
||
monkeypatch.setattr(
|
||
"astrbot.core.tools.message_tools.get_booter",
|
||
mock_get_booter,
|
||
)
|
||
|
||
result = await tool.call(
|
||
ctx,
|
||
messages=[
|
||
{"type": "plain", "text": "before image"},
|
||
{"type": "image", "path": str(missing_image_path)},
|
||
],
|
||
)
|
||
|
||
assert "error: failed to build messages[1] component: sandbox unavailable" in result
|
||
ctx.context.context.send_message.assert_not_called()
|