feat:可开启原生at功能

This commit is contained in:
SengokuCola
2026-04-23 15:56:27 +08:00
parent 2255592bd2
commit 35ff91d134
20 changed files with 258 additions and 404 deletions

View File

@@ -1,6 +1,7 @@
from src.common.data_models.message_component_data_model import ImageComponent, MessageSequence, TextComponent
from src.llm_models.payload_content.message import RoleType
from src.maisaka.context_messages import _build_message_from_sequence
from src.maisaka.message_adapter import build_visible_text_from_sequence
def test_image_only_message_keeps_placeholder_in_text_fallback() -> None:
@@ -20,3 +21,35 @@ def test_image_only_message_keeps_placeholder_in_text_fallback() -> None:
assert message is not None
assert "[发言内容]" in message.get_text_content()
assert "[图片]" in message.get_text_content()
def test_whitespace_image_content_uses_placeholder_in_text_fallback() -> None:
message_sequence = MessageSequence(
[
TextComponent("[发言内容]"),
ImageComponent(binary_hash="hash", content=" ", binary_data=None),
]
)
message = _build_message_from_sequence(
RoleType.User,
message_sequence,
"[发言内容][图片]",
enable_visual_message=False,
)
assert message is not None
assert message.get_text_content() == "[发言内容][图片]"
def test_visible_text_uses_image_placeholder_for_whitespace_content() -> None:
visible_text = build_visible_text_from_sequence(
MessageSequence(
[
TextComponent("看这个"),
ImageComponent(binary_hash="hash", content=" ", binary_data=None),
]
)
)
assert visible_text == "看这个[图片]"

View File

@@ -1,176 +0,0 @@
from importlib import util
from pathlib import Path
from types import ModuleType, SimpleNamespace
from typing import Any
import sys
import pytest
from src.common.data_models.message_component_data_model import AtComponent, TextComponent
from src.core.tooling import ToolExecutionResult, ToolInvocation
_MISSING_MODULE = object()
_module_overrides: dict[str, object] = {}
def _override_module(module_name: str, module: ModuleType) -> None:
_module_overrides[module_name] = sys.modules.get(module_name, _MISSING_MODULE)
sys.modules[module_name] = module
def _restore_overridden_modules() -> None:
for module_name, previous_module in reversed(_module_overrides.items()):
if previous_module is _MISSING_MODULE:
sys.modules.pop(module_name, None)
else:
sys.modules[module_name] = previous_module
_module_overrides.clear()
fake_cli_sender_module = ModuleType("src.cli.maisaka_cli_sender")
fake_cli_sender_module.CLI_PLATFORM_NAME = "cli"
fake_cli_sender_module.render_cli_message = lambda text: text
fake_cli_module = ModuleType("src.cli")
fake_cli_module.maisaka_cli_sender = fake_cli_sender_module
fake_send_service_module = ModuleType("src.services.send_service")
fake_send_service_module._send_to_target_with_message = None
fake_services_module = ModuleType("src.services")
fake_services_module.send_service = fake_send_service_module
_override_module("src.cli", fake_cli_module)
_override_module("src.cli.maisaka_cli_sender", fake_cli_sender_module)
_override_module("src.services", fake_services_module)
_override_module("src.services.send_service", fake_send_service_module)
AT_TOOL_PATH = Path(__file__).resolve().parents[1] / "src" / "maisaka" / "builtin_tool" / "at.py"
at_tool_spec = util.spec_from_file_location("_test_maisaka_builtin_at_tool", AT_TOOL_PATH)
assert at_tool_spec is not None and at_tool_spec.loader is not None
at_tool = util.module_from_spec(at_tool_spec)
sys.modules["_test_maisaka_builtin_at_tool"] = at_tool
try:
at_tool_spec.loader.exec_module(at_tool)
finally:
_restore_overridden_modules()
class _ToolCtx:
def __init__(self, runtime: SimpleNamespace) -> None:
self.runtime = runtime
@staticmethod
def build_success_result(
tool_name: str,
content: str = "",
structured_content: Any = None,
metadata: dict[str, Any] | None = None,
) -> ToolExecutionResult:
return ToolExecutionResult(
tool_name=tool_name,
success=True,
content=content,
structured_content=structured_content,
metadata=dict(metadata or {}),
)
@staticmethod
def build_failure_result(
tool_name: str,
error_message: str,
structured_content: Any = None,
metadata: dict[str, Any] | None = None,
) -> ToolExecutionResult:
return ToolExecutionResult(
tool_name=tool_name,
success=False,
error_message=error_message,
structured_content=structured_content,
metadata=dict(metadata or {}),
)
def append_guided_reply_to_chat_history(self, reply_text: str) -> None:
self.runtime._chat_history.append(reply_text)
def _build_tool_ctx(*, group_id: str = "group-1") -> _ToolCtx:
target_message = SimpleNamespace(
message_info=SimpleNamespace(
user_info=SimpleNamespace(
user_id="target-user-1",
user_nickname="目标昵称",
user_cardname="群名片",
)
)
)
runtime = SimpleNamespace(
_source_messages_by_id={"msg-1": target_message},
chat_stream=SimpleNamespace(platform="qq", group_id=group_id),
session_id="session-1",
log_prefix="[test-at]",
_record_reply_sent=lambda: None,
_chat_history=[],
)
return _ToolCtx(runtime=runtime)
def test_at_tool_spec_does_not_embed_visibility_metadata() -> None:
tool_spec = at_tool.get_tool_spec()
assert tool_spec.name == "at"
assert "deferred" not in tool_spec.metadata
assert "visibility" not in tool_spec.metadata
@pytest.mark.asyncio
async def test_at_tool_sends_at_component_by_msg_id(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, Any] = {}
async def fake_send_to_target_with_message(**kwargs: Any) -> object:
captured.update(kwargs)
return SimpleNamespace(message_id="sent-msg-1")
monkeypatch.setattr(at_tool.send_service, "_send_to_target_with_message", fake_send_to_target_with_message)
result = await at_tool.handle_tool(
_build_tool_ctx(),
ToolInvocation(tool_name="at", arguments={"msg_id": "msg-1", "text": "看这里"}),
)
assert result.success is True
assert result.structured_content["target_user_id"] == "target-user-1"
assert result.structured_content["target_user_name"] == "群名片"
assert captured["stream_id"] == "session-1"
assert captured["display_message"] == "@群名片 看这里"
assert captured["sync_to_maisaka_history"] is True
assert captured["maisaka_source_kind"] == "guided_reply"
components = captured["message_sequence"].components
assert isinstance(components[0], AtComponent)
assert components[0].target_user_id == "target-user-1"
assert components[0].target_user_nickname == "目标昵称"
assert components[0].target_user_cardname == "群名片"
assert isinstance(components[1], TextComponent)
assert components[1].text == " 看这里"
@pytest.mark.asyncio
async def test_at_tool_rejects_private_chat() -> None:
result = await at_tool.handle_tool(
_build_tool_ctx(group_id=""),
ToolInvocation(tool_name="at", arguments={"msg_id": "msg-1"}),
)
assert result.success is False
assert "群聊" in result.error_message
@pytest.mark.asyncio
async def test_at_tool_rejects_unknown_msg_id() -> None:
result = await at_tool.handle_tool(
_build_tool_ctx(),
ToolInvocation(tool_name="at", arguments={"msg_id": "missing-msg"}),
)
assert result.success is False
assert result.structured_content == {"msg_id": "missing-msg"}

View File

@@ -3,7 +3,8 @@ from types import SimpleNamespace
from src.chat.message_receive.message import SessionMessage
from src.common.data_models.mai_message_data_model import MessageInfo, UserInfo
from src.common.data_models.message_component_data_model import MessageSequence, ReplyComponent, TextComponent
from src.common.data_models.message_component_data_model import AtComponent, MessageSequence, ReplyComponent, TextComponent
from src.config.config import global_config
from src.maisaka.builtin_tool.context import BuiltinToolRuntimeContext
@@ -45,3 +46,53 @@ def test_append_sent_message_to_chat_history_keeps_message_id() -> None:
assert history_message.message_id == "real-message-id"
assert "[msg_id]real-message-id\n" in history_message.raw_message.components[0].text
assert "[msg_id:real-message-id]" in history_message.visible_text
def test_post_process_reply_message_sequences_converts_at_marker_before_bracket_cleanup(monkeypatch) -> None:
monkeypatch.setattr(global_config.chat, "enable_at", True)
monkeypatch.setattr(
"src.maisaka.builtin_tool.context.process_llm_response",
lambda text: [text.strip()] if text.strip() else [],
)
target_message = SimpleNamespace(
message_info=SimpleNamespace(
user_info=SimpleNamespace(
user_id="target-user",
user_nickname="目标昵称",
user_cardname="群名片",
)
)
)
runtime = SimpleNamespace(_source_messages_by_id={"12160142": target_message})
engine = SimpleNamespace(_get_runtime_manager=lambda: None)
tool_ctx = BuiltinToolRuntimeContext(engine=engine, runtime=runtime)
sequences = tool_ctx.post_process_reply_message_sequences("at[12160142] 就这个群")
assert len(sequences) == 1
components = sequences[0].components
assert isinstance(components[0], AtComponent)
assert components[0].target_user_id == "target-user"
assert components[0].target_user_nickname == "目标昵称"
assert components[0].target_user_cardname == "群名片"
assert isinstance(components[1], TextComponent)
assert components[1].text == " 就这个群"
def test_post_process_reply_message_sequences_ignores_at_marker_when_disabled(monkeypatch) -> None:
monkeypatch.setattr(global_config.chat, "enable_at", False)
monkeypatch.setattr(
"src.maisaka.builtin_tool.context.process_llm_response",
lambda text: [text.strip()] if text.strip() else [],
)
runtime = SimpleNamespace(_source_messages_by_id={})
engine = SimpleNamespace(_get_runtime_manager=lambda: None)
tool_ctx = BuiltinToolRuntimeContext(engine=engine, runtime=runtime)
sequences = tool_ctx.post_process_reply_message_sequences("at[12160142] 就这个群")
assert len(sequences) == 1
components = sequences[0].components
assert len(components) == 1
assert isinstance(components[0], TextComponent)
assert components[0].text == "at[12160142] 就这个群"

View File

@@ -12,7 +12,7 @@ from src.plugin_runtime.host.component_registry import ComponentRegistry
@pytest.mark.asyncio
async def test_builtin_at_is_exposed_only_in_group_chats() -> None:
async def test_builtin_at_tool_is_not_exposed() -> None:
registry = ToolRegistry()
registry.register_provider(MaisakaBuiltinToolProvider())
@@ -20,9 +20,9 @@ async def test_builtin_at_is_exposed_only_in_group_chats() -> None:
private_specs = await registry.list_tools(ToolAvailabilityContext(session_id="private-1", is_group_chat=False))
default_specs = await registry.list_tools()
assert "at" in {tool_spec.name for tool_spec in group_specs}
assert "at" not in {tool_spec.name for tool_spec in group_specs}
assert "at" not in {tool_spec.name for tool_spec in private_specs}
assert "at" in {tool_spec.name for tool_spec in default_specs}
assert "at" not in {tool_spec.name for tool_spec in default_specs}
def test_plugin_tool_chat_scope_uses_component_field(monkeypatch: pytest.MonkeyPatch) -> None: