feat: Enhance API and Outbound Tracking Functionality
- Add test for fallback to bot account in platform IO route metadata when context message is absent. - Improve PlatformIOManager to avoid duplicate driver entries and streamline fallback driver handling. - Refactor OutboundTracker to support tracking by both internal message ID and driver ID, enhancing the uniqueness of pending records. - Introduce dynamic API capabilities in RuntimeComponent, allowing plugins to replace their dynamic API lists. - Update APIRegistry to manage dynamic APIs more effectively, including registration and toggling of API statuses. - Implement authorization checks for dynamic API capabilities to ensure proper permissions. - Restrict direct calls to certain host RPC methods from plugins for enhanced security. - Refactor send_service to ensure fallback to current platform account when no context message is available.
This commit is contained in:
@@ -82,7 +82,61 @@ async def test_platform_io_uses_legacy_driver_when_no_explicit_send_route(
|
||||
)
|
||||
|
||||
explicit_drivers = manager.resolve_drivers(RouteKey(platform="qq"))
|
||||
assert [driver.driver_id for driver in explicit_drivers] == ["plugin.qq.sender"]
|
||||
assert [driver.driver_id for driver in explicit_drivers] == ["plugin.qq.sender", "legacy.send.qq"]
|
||||
finally:
|
||||
await manager.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_platform_io_broadcasts_to_plugin_and_legacy_driver(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""同一路由命中插件驱动与 legacy driver 时,应同时广播发送。"""
|
||||
|
||||
manager = PlatformIOManager()
|
||||
legacy_calls: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(chat_utils, "get_all_bot_accounts", lambda: {"qq": "bot-qq"})
|
||||
|
||||
async def _fake_send_prepared_message_to_platform(message: Any, show_log: bool = True) -> bool:
|
||||
"""记录 legacy driver 调用。"""
|
||||
|
||||
legacy_calls.append({"message": message, "show_log": show_log})
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(
|
||||
uni_message_sender,
|
||||
"send_prepared_message_to_platform",
|
||||
_fake_send_prepared_message_to_platform,
|
||||
)
|
||||
|
||||
try:
|
||||
await manager.ensure_send_pipeline_ready()
|
||||
|
||||
plugin_driver = _PluginDriver(driver_id="plugin.qq.sender", platform="qq")
|
||||
await manager.add_driver(plugin_driver)
|
||||
manager.bind_send_route(
|
||||
RouteBinding(
|
||||
route_key=RouteKey(platform="qq"),
|
||||
driver_id=plugin_driver.driver_id,
|
||||
driver_kind=plugin_driver.descriptor.kind,
|
||||
)
|
||||
)
|
||||
|
||||
message = type("FakeMessage", (), {"message_id": "message-1"})()
|
||||
batch = await manager.send_message(
|
||||
message=message,
|
||||
route_key=RouteKey(platform="qq"),
|
||||
metadata={"show_log": False},
|
||||
)
|
||||
|
||||
assert sorted(receipt.driver_id for receipt in batch.sent_receipts) == [
|
||||
"legacy.send.qq",
|
||||
"plugin.qq.sender",
|
||||
]
|
||||
assert batch.failed_receipts == []
|
||||
assert len(legacy_calls) == 1
|
||||
assert legacy_calls[0]["message"] is message
|
||||
assert legacy_calls[0]["show_log"] is False
|
||||
finally:
|
||||
await manager.stop()
|
||||
|
||||
|
||||
@@ -292,3 +292,233 @@ async def test_api_list_and_component_toggle_use_dedicated_registry() -> None:
|
||||
)
|
||||
assert enable_result["success"] is True
|
||||
assert provider_supervisor.api_registry.get_api("provider", "public_api", enabled_only=True) is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_registry_supports_multiple_versions_with_distinct_handlers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""同名 API 不同版本应可并存,并按版本路由到不同处理器。"""
|
||||
|
||||
provider_supervisor = PluginSupervisor(plugin_dirs=[])
|
||||
consumer_supervisor = PluginSupervisor(plugin_dirs=[])
|
||||
await _register_plugin(
|
||||
provider_supervisor,
|
||||
"provider",
|
||||
[
|
||||
{
|
||||
"name": "render_html",
|
||||
"component_type": "API",
|
||||
"metadata": {
|
||||
"description": "渲染 HTML v1",
|
||||
"version": "1",
|
||||
"public": True,
|
||||
"handler_name": "handle_render_html_v1",
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "render_html",
|
||||
"component_type": "API",
|
||||
"metadata": {
|
||||
"description": "渲染 HTML v2",
|
||||
"version": "2",
|
||||
"public": True,
|
||||
"handler_name": "handle_render_html_v2",
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
await _register_plugin(consumer_supervisor, "consumer", [])
|
||||
|
||||
captured: Dict[str, Any] = {}
|
||||
|
||||
async def fake_invoke_api(
|
||||
plugin_id: str,
|
||||
component_name: str,
|
||||
args: Dict[str, Any] | None = None,
|
||||
timeout_ms: int = 30000,
|
||||
) -> Any:
|
||||
"""模拟多版本 API 调用。"""
|
||||
|
||||
captured["plugin_id"] = plugin_id
|
||||
captured["component_name"] = component_name
|
||||
captured["args"] = args or {}
|
||||
captured["timeout_ms"] = timeout_ms
|
||||
return SimpleNamespace(error=None, payload={"success": True, "result": {"image": "ok"}})
|
||||
|
||||
monkeypatch.setattr(provider_supervisor, "invoke_api", fake_invoke_api)
|
||||
manager = _build_manager(provider_supervisor, consumer_supervisor)
|
||||
|
||||
ambiguous_result = await manager._cap_api_call(
|
||||
"consumer",
|
||||
"api.call",
|
||||
{
|
||||
"api_name": "provider.render_html",
|
||||
"args": {"html": "<div>Hello</div>"},
|
||||
},
|
||||
)
|
||||
assert ambiguous_result["success"] is False
|
||||
assert "多个版本" in str(ambiguous_result["error"])
|
||||
|
||||
disable_ambiguous_result = await manager._cap_component_disable(
|
||||
"consumer",
|
||||
"component.disable",
|
||||
{
|
||||
"name": "provider.render_html",
|
||||
"component_type": "API",
|
||||
"scope": "global",
|
||||
"stream_id": "",
|
||||
},
|
||||
)
|
||||
assert disable_ambiguous_result["success"] is False
|
||||
assert "多个版本" in str(disable_ambiguous_result["error"])
|
||||
|
||||
disable_v1_result = await manager._cap_component_disable(
|
||||
"consumer",
|
||||
"component.disable",
|
||||
{
|
||||
"name": "provider.render_html",
|
||||
"component_type": "API",
|
||||
"scope": "global",
|
||||
"stream_id": "",
|
||||
"version": "1",
|
||||
},
|
||||
)
|
||||
assert disable_v1_result["success"] is True
|
||||
assert provider_supervisor.api_registry.get_api("provider", "render_html", version="1", enabled_only=True) is None
|
||||
assert provider_supervisor.api_registry.get_api("provider", "render_html", version="2", enabled_only=True) is not None
|
||||
|
||||
result = await manager._cap_api_call(
|
||||
"consumer",
|
||||
"api.call",
|
||||
{
|
||||
"api_name": "provider.render_html",
|
||||
"version": "2",
|
||||
"args": {"html": "<div>Hello</div>"},
|
||||
},
|
||||
)
|
||||
|
||||
assert result == {"success": True, "result": {"image": "ok"}}
|
||||
assert captured["plugin_id"] == "provider"
|
||||
assert captured["component_name"] == "handle_render_html_v2"
|
||||
assert captured["args"] == {"html": "<div>Hello</div>"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_replace_dynamic_can_offline_removed_entries(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""动态 API 替换后,被移除的 API 应返回明确下线错误。"""
|
||||
|
||||
supervisor = PluginSupervisor(plugin_dirs=[])
|
||||
await _register_plugin(supervisor, "provider", [])
|
||||
manager = _build_manager(supervisor)
|
||||
|
||||
captured: Dict[str, Any] = {}
|
||||
|
||||
async def fake_invoke_api(
|
||||
plugin_id: str,
|
||||
component_name: str,
|
||||
args: Dict[str, Any] | None = None,
|
||||
timeout_ms: int = 30000,
|
||||
) -> Any:
|
||||
"""模拟动态 API 调用。"""
|
||||
|
||||
captured["plugin_id"] = plugin_id
|
||||
captured["component_name"] = component_name
|
||||
captured["args"] = args or {}
|
||||
captured["timeout_ms"] = timeout_ms
|
||||
return SimpleNamespace(error=None, payload={"success": True, "result": {"ok": True}})
|
||||
|
||||
monkeypatch.setattr(supervisor, "invoke_api", fake_invoke_api)
|
||||
|
||||
replace_result = await manager._cap_api_replace_dynamic(
|
||||
"provider",
|
||||
"api.replace_dynamic",
|
||||
{
|
||||
"apis": [
|
||||
{
|
||||
"name": "mcp.search",
|
||||
"type": "API",
|
||||
"metadata": {
|
||||
"version": "1",
|
||||
"public": True,
|
||||
"handler_name": "dynamic_search",
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "mcp.read",
|
||||
"type": "API",
|
||||
"metadata": {
|
||||
"version": "1",
|
||||
"public": True,
|
||||
"handler_name": "dynamic_read",
|
||||
},
|
||||
},
|
||||
],
|
||||
"offline_reason": "MCP 服务器已关闭",
|
||||
},
|
||||
)
|
||||
|
||||
assert replace_result["success"] is True
|
||||
assert replace_result["count"] == 2
|
||||
list_result = await manager._cap_api_list("provider", "api.list", {"plugin_id": "provider"})
|
||||
assert {(item["name"], item["version"]) for item in list_result["apis"]} == {
|
||||
("mcp.read", "1"),
|
||||
("mcp.search", "1"),
|
||||
}
|
||||
|
||||
call_result = await manager._cap_api_call(
|
||||
"provider",
|
||||
"api.call",
|
||||
{
|
||||
"api_name": "provider.mcp.search",
|
||||
"version": "1",
|
||||
"args": {"query": "hello"},
|
||||
},
|
||||
)
|
||||
assert call_result == {"success": True, "result": {"ok": True}}
|
||||
assert captured["component_name"] == "dynamic_search"
|
||||
assert captured["args"]["query"] == "hello"
|
||||
assert captured["args"]["__maibot_api_name__"] == "mcp.search"
|
||||
assert captured["args"]["__maibot_api_version__"] == "1"
|
||||
|
||||
second_replace_result = await manager._cap_api_replace_dynamic(
|
||||
"provider",
|
||||
"api.replace_dynamic",
|
||||
{
|
||||
"apis": [
|
||||
{
|
||||
"name": "mcp.read",
|
||||
"type": "API",
|
||||
"metadata": {
|
||||
"version": "1",
|
||||
"public": True,
|
||||
"handler_name": "dynamic_read",
|
||||
},
|
||||
}
|
||||
],
|
||||
"offline_reason": "MCP 服务器已关闭",
|
||||
},
|
||||
)
|
||||
|
||||
assert second_replace_result["success"] is True
|
||||
assert second_replace_result["count"] == 1
|
||||
assert second_replace_result["offlined"] == 1
|
||||
|
||||
offlined_call_result = await manager._cap_api_call(
|
||||
"provider",
|
||||
"api.call",
|
||||
{
|
||||
"api_name": "provider.mcp.search",
|
||||
"version": "1",
|
||||
"args": {},
|
||||
},
|
||||
)
|
||||
assert offlined_call_result["success"] is False
|
||||
assert "MCP 服务器已关闭" in str(offlined_call_result["error"])
|
||||
|
||||
list_after_replace = await manager._cap_api_list("provider", "api.list", {"plugin_id": "provider"})
|
||||
assert {(item["name"], item["version"]) for item in list_after_replace["apis"]} == {
|
||||
("mcp.read", "1"),
|
||||
}
|
||||
|
||||
@@ -73,6 +73,19 @@ def _build_target_stream() -> BotChatSession:
|
||||
)
|
||||
|
||||
|
||||
def test_inherit_platform_io_route_metadata_falls_back_to_bot_account(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""没有上下文消息时,也应回填当前平台账号用于账号级路由命中。"""
|
||||
|
||||
monkeypatch.setattr(send_service, "get_bot_account", lambda platform: "bot-qq" if platform == "qq" else "")
|
||||
|
||||
metadata = send_service._inherit_platform_io_route_metadata(_build_target_stream())
|
||||
|
||||
assert metadata["platform_io_account_id"] == "bot-qq"
|
||||
assert metadata["platform_io_target_user_id"] == "target-user"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_to_stream_delegates_to_platform_io(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""send service 应将发送职责统一交给 Platform IO。"""
|
||||
|
||||
Reference in New Issue
Block a user