diff --git a/plugins/ChatFrequency/_manifest.json b/plugins/ChatFrequency/_manifest.json index 241242ed..56417665 100644 --- a/plugins/ChatFrequency/_manifest.json +++ b/plugins/ChatFrequency/_manifest.json @@ -1,58 +1,40 @@ { - "manifest_version": 1, - "name": "发言频率控制插件|BetterFrequency Plugin", + "manifest_version": 2, "version": "2.0.0", - "description": "控制聊天频率,支持设置focus_value和talk_frequency调整值,提供命令", + "name": "发言频率控制插件|BetterFrequency Plugin", + "description": "控制聊天频率,支持设置 focus_value 和 talk_frequency 调整值,并提供命令入口。", "author": { "name": "SengokuCola", "url": "https://github.com/MaiM-with-u" }, "license": "GPL-v3.0-or-later", - "host_application": { - "min_version": "1.0.0" + "urls": { + "repository": "https://github.com/SengokuCola/BetterFrequency", + "homepage": "https://github.com/SengokuCola/BetterFrequency", + "documentation": "https://github.com/SengokuCola/BetterFrequency", + "issues": "https://github.com/SengokuCola/BetterFrequency/issues" }, - "homepage_url": "https://github.com/SengokuCola/BetterFrequency", - "repository_url": "https://github.com/SengokuCola/BetterFrequency", - "keywords": [ - "frequency", - "control", - "talk_frequency", - "plugin", - "shortcut" + "host_application": { + "min_version": "1.0.0", + "max_version": "1.0.0" + }, + "sdk": { + "min_version": "2.0.0", + "max_version": "2.99.99" + }, + "dependencies": [], + "capabilities": [ + "send.text", + "frequency.set_adjust", + "frequency.get_current_talk_value", + "frequency.get_adjust" ], - "categories": [ - "Chat", - "Frequency", - "Control" - ], - "default_locale": "zh-CN", - "locales_path": "_locales", - "plugin_info": { - "is_built_in": false, - "plugin_type": "frequency", - "components": [ - { - "type": "command", - "name": "set_talk_frequency", - "description": "设置当前聊天的talk_frequency调整值", - "pattern": "/chat talk_frequency <数字> 或 /chat t <数字>" - }, - { - "type": "command", - "name": "show_frequency", - "description": "显示当前聊天的频率控制状态", - "pattern": "/chat show 或 /chat s" - } - ], - "features": [ - "设置talk_frequency调整值", - "调整当前聊天的发言频率", - "显示当前频率控制状态", - "实时频率控制调整", - "命令执行反馈(不保存消息)", - "支持完整命令和简化命令", - "快速操作支持" + "i18n": { + "default_locale": "zh-CN", + "locales_path": "_locales", + "supported_locales": [ + "zh-CN" ] }, - "id": "SengokuCola.BetterFrequency" -} \ No newline at end of file + "id": "sengokucola.betterfrequency" +} diff --git a/plugins/MaiBot_MCPBridgePlugin/_manifest.json b/plugins/MaiBot_MCPBridgePlugin/_manifest.json index 85225a43..d2e08ab4 100644 --- a/plugins/MaiBot_MCPBridgePlugin/_manifest.json +++ b/plugins/MaiBot_MCPBridgePlugin/_manifest.json @@ -1,67 +1,42 @@ { - "manifest_version": 1, - "name": "MCP桥接插件", + "manifest_version": 2, "version": "2.0.0", - "description": "将 MCP (Model Context Protocol) 服务器的工具桥接到 MaiBot,使麦麦能够调用外部 MCP 工具", + "name": "MCP桥接插件", + "description": "将 MCP (Model Context Protocol) 服务器的工具桥接到 MaiBot,使麦麦能够调用外部 MCP 工具。", "author": { "name": "CharTyr", "url": "https://github.com/CharTyr" }, "license": "AGPL-3.0", - "host_application": { - "min_version": "0.11.6" + "urls": { + "repository": "https://github.com/CharTyr/MaiBot_MCPBridgePlugin", + "homepage": "https://github.com/CharTyr/MaiBot_MCPBridgePlugin", + "documentation": "https://github.com/CharTyr/MaiBot_MCPBridgePlugin", + "issues": "https://github.com/CharTyr/MaiBot_MCPBridgePlugin/issues" }, - "homepage_url": "https://github.com/CharTyr/MaiBot_MCPBridgePlugin", - "repository_url": "https://github.com/CharTyr/MaiBot_MCPBridgePlugin", - "keywords": [ - "mcp", - "bridge", - "tool", - "integration", - "resources", - "prompts", - "post-process", - "cache", - "trace", - "permissions", - "import", - "export", - "claude-desktop", - "workflow", - "react", - "agent" + "host_application": { + "min_version": "0.11.6", + "max_version": "1.0.0" + }, + "sdk": { + "min_version": "2.0.0", + "max_version": "2.99.99" + }, + "dependencies": [ + { + "type": "python_package", + "name": "mcp", + "version_spec": ">=0.0.0" + } ], - "categories": [ - "工具扩展", - "外部集成" + "capabilities": [ + "send.text" ], - "default_locale": "zh-CN", - "plugin_info": { - "is_built_in": false, - "components": [], - "features": [ - "支持多个 MCP 服务器", - "自动发现并注册 MCP 工具", - "支持 stdio、SSE、HTTP、Streamable HTTP 四种传输方式", - "工具参数自动转换", - "心跳检测与自动重连", - "调用统计(次数、成功率、耗时)", - "WebUI 配置支持", - "Resources 支持(实验性)", - "Prompts 支持(实验性)", - "结果后处理(LLM 摘要提炼)", - "工具禁用管理", - "调用链路追踪", - "工具调用缓存(LRU)", - "工具权限控制(群/用户级别)", - "配置导入导出(Claude Desktop mcpServers)", - "断路器模式(故障快速失败)", - "状态实时刷新", - "Workflow 硬流程(顺序执行多个工具)", - "Workflow 快速添加(表单式配置)", - "ReAct 软流程(LLM 自主多轮调用)", - "双轨制架构(软流程 + 硬流程)" + "i18n": { + "default_locale": "zh-CN", + "supported_locales": [ + "zh-CN" ] }, - "id": "MaiBot Community.MCPBridgePlugin" + "id": "chartyr.mcpbridge-plugin" } diff --git a/plugins/emoji_manage_plugin/_manifest.json b/plugins/emoji_manage_plugin/_manifest.json index 3af69023..998cb7da 100644 --- a/plugins/emoji_manage_plugin/_manifest.json +++ b/plugins/emoji_manage_plugin/_manifest.json @@ -1,68 +1,44 @@ { - "manifest_version": 1, - "name": "BetterEmoji", + "manifest_version": 2, "version": "2.0.0", + "name": "BetterEmoji", "description": "更好的表情包管理插件", "author": { "name": "SengokuCola", "url": "https://github.com/SengokuCola" }, "license": "GPL-v3.0-or-later", - "host_application": { - "min_version": "1.0.0" + "urls": { + "repository": "https://github.com/SengokuCola/BetterEmoji", + "homepage": "https://github.com/SengokuCola/BetterEmoji", + "documentation": "https://github.com/SengokuCola/BetterEmoji", + "issues": "https://github.com/SengokuCola/BetterEmoji/issues" }, - "homepage_url": "https://github.com/SengokuCola/BetterEmoji", - "repository_url": "https://github.com/SengokuCola/BetterEmoji", - "keywords": [ - "emoji", - "manage", - "plugin" + "host_application": { + "min_version": "1.0.0", + "max_version": "1.0.0" + }, + "sdk": { + "min_version": "2.0.0", + "max_version": "2.99.99" + }, + "dependencies": [], + "capabilities": [ + "emoji.get_random", + "emoji.get_count", + "emoji.get_info", + "emoji.get_all", + "emoji.register_emoji", + "emoji.delete_emoji", + "send.text", + "send.forward" ], - "categories": [ - "Emoji", - "Management" - ], - "default_locale": "zh-CN", - "locales_path": "_locales", - "plugin_info": { - "is_built_in": false, - "plugin_type": "emoji_manage", - "capabilities": [ - "emoji.get_random", - "emoji.get_count", - "emoji.get_info", - "emoji.get_all", - "emoji.register_emoji", - "emoji.delete_emoji", - "send.text", - "send.forward" - ], - "components": [ - { - "type": "command", - "name": "add_emoji", - "description": "添加表情包", - "pattern": "/emoji add" - }, - { - "type": "command", - "name": "emoji_list", - "description": "列表表情包", - "pattern": "/emoji list" - }, - { - "type": "command", - "name": "delete_emoji", - "description": "删除表情包", - "pattern": "/emoji delete" - }, - { - "type": "command", - "name": "random_emojis", - "description": "发送多张随机表情包", - "pattern": "/random_emojis" - } + "i18n": { + "default_locale": "zh-CN", + "locales_path": "_locales", + "supported_locales": [ + "zh-CN" ] }, - "id": "SengokuCola.BetterEmoji" -} \ No newline at end of file + "id": "sengokucola.betteremoji" +} diff --git a/plugins/hello_world_plugin/_manifest.json b/plugins/hello_world_plugin/_manifest.json index dc9fc474..e2bc694d 100644 --- a/plugins/hello_world_plugin/_manifest.json +++ b/plugins/hello_world_plugin/_manifest.json @@ -1,88 +1,41 @@ { - "manifest_version": 1, - "name": "Hello World 示例插件 (Hello World Plugin)", + "manifest_version": 2, "version": "2.0.0", - "description": "我的第一个MaiCore插件,包含问候功能和时间查询等基础示例", + "name": "Hello World 示例插件 (Hello World Plugin)", + "description": "我的第一个 MaiCore 插件,包含问候功能和时间查询等基础示例", "author": { "name": "MaiBot开发团队", "url": "https://github.com/MaiM-with-u" }, "license": "GPL-v3.0-or-later", - "host_application": { - "min_version": "1.0.0" + "urls": { + "repository": "https://github.com/MaiM-with-u/maibot", + "homepage": "https://github.com/MaiM-with-u/maibot", + "documentation": "https://github.com/MaiM-with-u/maibot", + "issues": "https://github.com/MaiM-with-u/maibot/issues" }, - "homepage_url": "https://github.com/MaiM-with-u/maibot", - "repository_url": "https://github.com/MaiM-with-u/maibot", - "keywords": [ - "demo", - "example", - "hello", - "greeting", - "tutorial" + "host_application": { + "min_version": "1.0.0", + "max_version": "1.0.0" + }, + "sdk": { + "min_version": "2.0.0", + "max_version": "2.99.99" + }, + "dependencies": [], + "capabilities": [ + "send.text", + "send.forward", + "send.hybrid", + "emoji.get_random", + "config.get" ], - "categories": [ - "Examples", - "Tutorial" - ], - "default_locale": "zh-CN", - "locales_path": "_locales", - "plugin_info": { - "is_built_in": false, - "plugin_type": "example", - "capabilities": [ - "send.text", - "send.forward", - "send.hybrid", - "emoji.get_random", - "config.get" - ], - "components": [ - { - "type": "tool", - "name": "compare_numbers", - "description": "比较两个数的大小" - }, - { - "type": "action", - "name": "hello_greeting", - "description": "向用户发送问候消息" - }, - { - "type": "action", - "name": "bye_greeting", - "description": "向用户发送告别消息", - "activation_modes": ["keyword"], - "keywords": ["再见", "bye", "88", "拜拜"] - }, - { - "type": "command", - "name": "time", - "description": "查询当前时间", - "pattern": "/time" - }, - { - "type": "command", - "name": "random_emojis", - "description": "发送多张随机表情包", - "pattern": "/random_emojis" - }, - { - "type": "command", - "name": "test", - "description": "测试命令", - "pattern": "/test" - }, - { - "type": "event_handler", - "name": "print_message_handler", - "description": "打印接收到的消息" - }, - { - "type": "event_handler", - "name": "forward_messages_handler", - "description": "把接收到的消息转发到指定聊天ID" - } + "i18n": { + "default_locale": "zh-CN", + "locales_path": "_locales", + "supported_locales": [ + "zh-CN" ] }, - "id": "MaiBot开发团队.maibot" -} \ No newline at end of file + "id": "maibot-team.hello-world-plugin" +} diff --git a/pytests/test_plugin_runtime.py b/pytests/test_plugin_runtime.py index e094d85b..1d93ae24 100644 --- a/pytests/test_plugin_runtime.py +++ b/pytests/test_plugin_runtime.py @@ -19,6 +19,104 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "packages", "maibot-plugin-sdk")) +def build_test_manifest( + plugin_id: str, + *, + version: str = "1.0.0", + name: str = "测试插件", + description: str = "测试插件描述", + dependencies: list[dict[str, str]] | None = None, + capabilities: list[str] | None = None, + host_min_version: str = "0.12.0", + host_max_version: str = "1.0.0", + sdk_min_version: str = "2.0.0", + sdk_max_version: str = "2.99.99", +) -> dict[str, object]: + """构造一个合法的 Manifest v2 测试样例。 + + Args: + plugin_id: 插件 ID。 + version: 插件版本。 + name: 展示名称。 + description: 插件描述。 + dependencies: 依赖声明列表。 + capabilities: 能力声明列表。 + host_min_version: Host 最低支持版本。 + host_max_version: Host 最高支持版本。 + sdk_min_version: SDK 最低支持版本。 + sdk_max_version: SDK 最高支持版本。 + + Returns: + dict[str, object]: 可直接序列化为 ``_manifest.json`` 的字典。 + """ + return { + "manifest_version": 2, + "version": version, + "name": name, + "description": description, + "author": { + "name": "tester", + "url": "https://example.com/tester", + }, + "license": "MIT", + "urls": { + "repository": f"https://example.com/{plugin_id}", + }, + "host_application": { + "min_version": host_min_version, + "max_version": host_max_version, + }, + "sdk": { + "min_version": sdk_min_version, + "max_version": sdk_max_version, + }, + "dependencies": dependencies or [], + "capabilities": capabilities or [], + "i18n": { + "default_locale": "zh-CN", + "supported_locales": ["zh-CN"], + }, + "id": plugin_id, + } + + +def build_test_manifest_model( + plugin_id: str, + *, + version: str = "1.0.0", + dependencies: list[dict[str, str]] | None = None, + capabilities: list[str] | None = None, + host_version: str = "1.0.0", + sdk_version: str = "2.0.1", +) -> object: + """构造一个已经通过校验的强类型 Manifest 测试对象。 + + Args: + plugin_id: 插件 ID。 + version: 插件版本。 + dependencies: 依赖声明列表。 + capabilities: 能力声明列表。 + host_version: 当前测试使用的 Host 版本。 + sdk_version: 当前测试使用的 SDK 版本。 + + Returns: + object: ``PluginManifest`` 实例。 + """ + from src.plugin_runtime.runner.manifest_validator import ManifestValidator + + validator = ManifestValidator(host_version=host_version, sdk_version=sdk_version) + manifest = validator.parse_manifest( + build_test_manifest( + plugin_id, + version=version, + dependencies=dependencies, + capabilities=capabilities, + ) + ) + assert manifest is not None + return manifest + + # ─── 协议层测试 ─────────────────────────────────────────── @@ -759,65 +857,77 @@ class TestManifestValidator: def test_valid_manifest(self): from src.plugin_runtime.runner.manifest_validator import ManifestValidator - validator = ManifestValidator() - manifest = { - "manifest_version": 1, - "name": "test_plugin", - "version": "1.0.0", - "description": "测试插件", - "author": "test", - } + validator = ManifestValidator(host_version="1.0.0", sdk_version="2.0.1") + manifest = build_test_manifest("test.valid-plugin", capabilities=["send.text"]) assert validator.validate(manifest) is True assert len(validator.errors) == 0 + assert validator.warnings == [] def test_missing_required_fields(self): from src.plugin_runtime.runner.manifest_validator import ManifestValidator - validator = ManifestValidator() - manifest = {"manifest_version": 1} + validator = ManifestValidator(host_version="1.0.0", sdk_version="2.0.1") + manifest = {"manifest_version": 2} assert validator.validate(manifest) is False - assert len(validator.errors) >= 4 # name, version, description, author + assert len(validator.errors) >= 6 + assert any("缺少必需字段" in error for error in validator.errors) def test_unsupported_manifest_version(self): from src.plugin_runtime.runner.manifest_validator import ManifestValidator - validator = ManifestValidator() - manifest = { - "manifest_version": 999, - "name": "test", - "version": "1.0", - "description": "d", - "author": "a", - } + validator = ManifestValidator(host_version="1.0.0", sdk_version="2.0.1") + manifest = build_test_manifest("test.invalid-version") + manifest["manifest_version"] = 999 assert validator.validate(manifest) is False assert any("manifest_version" in e for e in validator.errors) def test_host_version_compatibility(self): from src.plugin_runtime.runner.manifest_validator import ManifestValidator - validator = ManifestValidator(host_version="0.8.5") - manifest = { - "name": "test", - "version": "1.0", - "description": "d", - "author": "a", - "host_application": {"min_version": "0.9.0"}, - } + validator = ManifestValidator(host_version="0.8.5", sdk_version="2.0.1") + manifest = build_test_manifest( + "test.host-check", + host_min_version="0.9.0", + host_max_version="1.0.0", + ) assert validator.validate(manifest) is False assert any("Host 版本不兼容" in e for e in validator.errors) - def test_recommended_fields_warning(self): + def test_sdk_version_compatibility(self): from src.plugin_runtime.runner.manifest_validator import ManifestValidator - validator = ManifestValidator() - manifest = { - "name": "test", - "version": "1.0", - "description": "d", - "author": "a", - } - validator.validate(manifest) - assert len(validator.warnings) >= 3 # license, keywords, categories + validator = ManifestValidator(host_version="1.0.0", sdk_version="1.9.9") + manifest = build_test_manifest("test.sdk-check") + assert validator.validate(manifest) is False + assert any("SDK 版本不兼容" in e for e in validator.errors) + + def test_extra_fields_are_rejected(self): + from src.plugin_runtime.runner.manifest_validator import ManifestValidator + + validator = ManifestValidator(host_version="1.0.0", sdk_version="2.0.1") + manifest = build_test_manifest("test.extra-field") + manifest["unexpected"] = True + + assert validator.validate(manifest) is False + assert any("存在未声明字段" in error for error in validator.errors) + + def test_python_package_conflict_rejects_manifest(self): + from src.plugin_runtime.runner.manifest_validator import ManifestValidator + + validator = ManifestValidator(host_version="1.0.0", sdk_version="2.0.1") + manifest = build_test_manifest( + "test.numpy-conflict", + dependencies=[ + { + "type": "python_package", + "name": "numpy", + "version_spec": ">=999.0.0", + } + ], + ) + + assert validator.validate(manifest) is False + assert any("Python 包依赖冲突" in error for error in validator.errors) class TestVersionComparator: @@ -859,59 +969,83 @@ class TestDependencyResolution: loader = PluginLoader() candidates = { - "core": ("dir_core", {"name": "core", "version": "1.0", "description": "d", "author": "a"}, "plugin.py"), - "auth": ( - "dir_auth", - {"name": "auth", "version": "1.0", "description": "d", "author": "a", "dependencies": ["core"]}, + "test.core": ( + "dir_core", + build_test_manifest_model("test.core"), "plugin.py", ), - "api": ( + "test.auth": ( + "dir_auth", + build_test_manifest_model( + "test.auth", + dependencies=[ + {"type": "plugin", "id": "test.core", "version_spec": ">=1.0.0,<2.0.0"}, + ], + ), + "plugin.py", + ), + "test.api": ( "dir_api", - {"name": "api", "version": "1.0", "description": "d", "author": "a", "dependencies": ["core", "auth"]}, + build_test_manifest_model( + "test.api", + dependencies=[ + {"type": "plugin", "id": "test.core", "version_spec": ">=1.0.0,<2.0.0"}, + {"type": "plugin", "id": "test.auth", "version_spec": ">=1.0.0,<2.0.0"}, + ], + ), "plugin.py", ), } order, failed = loader._resolve_dependencies(candidates) assert len(failed) == 0 - assert order.index("core") < order.index("auth") - assert order.index("auth") < order.index("api") + assert order.index("test.core") < order.index("test.auth") + assert order.index("test.auth") < order.index("test.api") def test_missing_dependency(self): from src.plugin_runtime.runner.plugin_loader import PluginLoader loader = PluginLoader() candidates = { - "plugin_a": ( + "test.plugin-a": ( "dir_a", - { - "name": "plugin_a", - "version": "1.0", - "description": "d", - "author": "a", - "dependencies": ["nonexistent"], - }, + build_test_manifest_model( + "test.plugin-a", + dependencies=[ + {"type": "plugin", "id": "test.nonexistent", "version_spec": ">=1.0.0,<2.0.0"}, + ], + ), "plugin.py", ), } order, failed = loader._resolve_dependencies(candidates) - assert "plugin_a" in failed - assert "缺少依赖" in failed["plugin_a"] + assert "test.plugin-a" in failed + assert "依赖未满足" in failed["test.plugin-a"] def test_circular_dependency(self): from src.plugin_runtime.runner.plugin_loader import PluginLoader loader = PluginLoader() candidates = { - "a": ( + "test.a": ( "dir_a", - {"name": "a", "version": "1.0", "description": "d", "author": "x", "dependencies": ["b"]}, + build_test_manifest_model( + "test.a", + dependencies=[ + {"type": "plugin", "id": "test.b", "version_spec": ">=1.0.0,<2.0.0"}, + ], + ), "p.py", ), - "b": ( + "test.b": ( "dir_b", - {"name": "b", "version": "1.0", "description": "d", "author": "x", "dependencies": ["a"]}, + build_test_manifest_model( + "test.b", + dependencies=[ + {"type": "plugin", "id": "test.a", "version_spec": ">=1.0.0,<2.0.0"}, + ], + ), "p.py", ), } @@ -929,12 +1063,11 @@ class TestDependencyResolution: (plugin_dir / "_manifest.json").write_text( json.dumps( - { - "name": "grok_search_plugin", - "version": "1.0.0", - "description": "demo", - "author": "tester", - } + build_test_manifest( + "test.grok-search-plugin", + name="grok_search_plugin", + description="demo", + ) ), encoding="utf-8", ) @@ -954,7 +1087,7 @@ class TestDependencyResolution: loader = PluginLoader() loaded = loader.discover_and_load([str(plugin_root)]) - assert [meta.plugin_id for meta in loaded] == ["grok_search_plugin"] + assert [meta.plugin_id for meta in loaded] == ["test.grok-search-plugin"] assert loader.failed_plugins == {} assert loaded[0].instance.answer() == 42 @@ -968,12 +1101,11 @@ class TestDependencyResolution: (plugin_dir / "_manifest.json").write_text( json.dumps( - { - "name": "demo_plugin", - "version": "1.0.0", - "description": "demo", - "author": "tester", - } + build_test_manifest( + "test.demo-plugin", + name="demo_plugin", + description="demo", + ) ), encoding="utf-8", ) @@ -993,8 +1125,8 @@ class TestDependencyResolution: loaded = loader.discover_and_load([str(plugin_root)]) assert loaded == [] - assert "demo_plugin" in loader.failed_plugins - assert "on_config_update" in loader.failed_plugins["demo_plugin"] + assert "test.demo-plugin" in loader.failed_plugins + assert "on_config_update" in loader.failed_plugins["test.demo-plugin"] def test_loader_requires_sdk_plugin_to_override_on_load(self, tmp_path): from src.plugin_runtime.runner.plugin_loader import PluginLoader @@ -1006,12 +1138,11 @@ class TestDependencyResolution: (plugin_dir / "_manifest.json").write_text( json.dumps( - { - "name": "demo_plugin", - "version": "1.0.0", - "description": "demo", - "author": "tester", - } + build_test_manifest( + "test.demo-plugin", + name="demo_plugin", + description="demo", + ) ), encoding="utf-8", ) @@ -1031,8 +1162,8 @@ class TestDependencyResolution: loaded = loader.discover_and_load([str(plugin_root)]) assert loaded == [] - assert "demo_plugin" in loader.failed_plugins - assert "on_load" in loader.failed_plugins["demo_plugin"] + assert "test.demo-plugin" in loader.failed_plugins + assert "on_load" in loader.failed_plugins["test.demo-plugin"] def test_loader_requires_sdk_plugin_to_override_on_unload(self, tmp_path): from src.plugin_runtime.runner.plugin_loader import PluginLoader @@ -1044,12 +1175,11 @@ class TestDependencyResolution: (plugin_dir / "_manifest.json").write_text( json.dumps( - { - "name": "demo_plugin", - "version": "1.0.0", - "description": "demo", - "author": "tester", - } + build_test_manifest( + "test.demo-plugin", + name="demo_plugin", + description="demo", + ) ), encoding="utf-8", ) @@ -1069,8 +1199,8 @@ class TestDependencyResolution: loaded = loader.discover_and_load([str(plugin_root)]) assert loaded == [] - assert "demo_plugin" in loader.failed_plugins - assert "on_unload" in loader.failed_plugins["demo_plugin"] + assert "test.demo-plugin" in loader.failed_plugins + assert "on_unload" in loader.failed_plugins["test.demo-plugin"] def test_isolate_sys_path_preserves_plugin_dirs(self): from src.plugin_runtime.runner import runner_main @@ -2374,16 +2504,19 @@ class TestIntegration: def __init__(self, plugin_dirs=None, socket_path=None): self._plugin_dirs = plugin_dirs or [] self.capability_service = FakeCapabilityService() - self.external_plugin_ids = [] + self.external_plugin_versions = {} self.stopped = False instances.append(self) - def set_external_available_plugin_ids(self, plugin_ids): - self.external_plugin_ids = list(plugin_ids) + def set_external_available_plugins(self, plugin_versions): + self.external_plugin_versions = dict(plugin_versions) def get_loaded_plugin_ids(self): return [] + def get_loaded_plugin_versions(self): + return {} + async def start(self): if len(instances) == 2 and self is instances[1]: raise RuntimeError("boom") @@ -2425,8 +2558,8 @@ class TestIntegration: (beta_dir / "config.toml").write_text("enabled = false\n", encoding="utf-8") (alpha_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8") (beta_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8") - (alpha_dir / "_manifest.json").write_text(json.dumps({"name": "alpha"}), encoding="utf-8") - (beta_dir / "_manifest.json").write_text(json.dumps({"name": "beta"}), encoding="utf-8") + (alpha_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.alpha")), encoding="utf-8") + (beta_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.beta")), encoding="utf-8") monkeypatch.chdir(tmp_path) @@ -2440,8 +2573,11 @@ class TestIntegration: def get_loaded_plugin_ids(self): return sorted(self._registered_plugins.keys()) + def get_loaded_plugin_versions(self): + return {plugin_id: "1.0.0" for plugin_id in self._registered_plugins} + async def reload_plugins(self, plugin_ids=None, reason="manual", external_available_plugins=None): - self.reload_reasons.append((plugin_ids, reason, external_available_plugins or [])) + self.reload_reasons.append((plugin_ids, reason, external_available_plugins or {})) async def notify_plugin_config_updated(self, plugin_id, config_data, config_version=""): self.config_updates.append((plugin_id, config_data, config_version)) @@ -2449,8 +2585,8 @@ class TestIntegration: manager = integration_module.PluginRuntimeManager() manager._started = True - manager._builtin_supervisor = FakeSupervisor([builtin_root], {"alpha": object()}) - manager._third_party_supervisor = FakeSupervisor([thirdparty_root], {"beta": object()}) + manager._builtin_supervisor = FakeSupervisor([builtin_root], {"test.alpha": object()}) + manager._third_party_supervisor = FakeSupervisor([thirdparty_root], {"test.beta": object()}) changes = [ FileChange(change_type=1, path=beta_dir / "plugin.py"), @@ -2466,7 +2602,9 @@ class TestIntegration: await manager._handle_plugin_source_changes(changes) assert manager._builtin_supervisor.reload_reasons == [] - assert manager._third_party_supervisor.reload_reasons == [(["beta"], "file_watcher", ["alpha"])] + assert manager._third_party_supervisor.reload_reasons == [ + (["test.beta"], "file_watcher", {"test.alpha": "1.0.0"}) + ] assert manager._builtin_supervisor.config_updates == [] assert manager._third_party_supervisor.config_updates == [] assert refresh_calls == [True] @@ -2487,15 +2625,18 @@ class TestIntegration: def get_loaded_plugin_ids(self): return sorted(self._registered_plugins.keys()) + def get_loaded_plugin_versions(self): + return {plugin_id: "1.0.0" for plugin_id in self._registered_plugins} + async def reload_plugins(self, plugin_ids=None, reason="manual", external_available_plugins=None): - self.reload_calls.append((plugin_ids, reason, sorted(external_available_plugins or []))) + self.reload_calls.append((plugin_ids, reason, dict(sorted((external_available_plugins or {}).items())))) return True - builtin_supervisor = FakeSupervisor({"alpha": FakeRegistration([])}) + builtin_supervisor = FakeSupervisor({"test.alpha": FakeRegistration([])}) third_party_supervisor = FakeSupervisor( { - "beta": FakeRegistration(["alpha"]), - "gamma": FakeRegistration(["beta"]), + "test.beta": FakeRegistration(["test.alpha"]), + "test.gamma": FakeRegistration(["test.beta"]), } ) @@ -2510,13 +2651,15 @@ class TestIntegration: lambda message: warning_messages.append(message), ) - reloaded = await manager.reload_plugins_globally(["alpha"], reason="manual") + reloaded = await manager.reload_plugins_globally(["test.alpha"], reason="manual") assert reloaded is True - assert builtin_supervisor.reload_calls == [(["alpha"], "manual", ["beta", "gamma"])] + assert builtin_supervisor.reload_calls == [ + (["test.alpha"], "manual", {"test.beta": "1.0.0", "test.gamma": "1.0.0"}) + ] assert third_party_supervisor.reload_calls == [] assert len(warning_messages) == 1 - assert "beta, gamma" in warning_messages[0] + assert "test.beta, test.gamma" in warning_messages[0] assert "跨 Supervisor API 调用仍然可用" in warning_messages[0] @pytest.mark.asyncio @@ -2535,8 +2678,8 @@ class TestIntegration: (beta_dir / "config.toml").write_text("enabled = false\n", encoding="utf-8") (alpha_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8") (beta_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8") - (alpha_dir / "_manifest.json").write_text(json.dumps({"name": "alpha"}), encoding="utf-8") - (beta_dir / "_manifest.json").write_text(json.dumps({"name": "beta"}), encoding="utf-8") + (alpha_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.alpha")), encoding="utf-8") + (beta_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.beta")), encoding="utf-8") monkeypatch.chdir(tmp_path) @@ -2558,15 +2701,15 @@ class TestIntegration: manager = integration_module.PluginRuntimeManager() manager._started = True - manager._builtin_supervisor = FakeSupervisor([builtin_root], ["alpha"]) - manager._third_party_supervisor = FakeSupervisor([thirdparty_root], ["beta"]) + manager._builtin_supervisor = FakeSupervisor([builtin_root], ["test.alpha"]) + manager._third_party_supervisor = FakeSupervisor([thirdparty_root], ["test.beta"]) await manager._handle_plugin_config_changes( - "alpha", + "test.alpha", [FileChange(change_type=1, path=alpha_dir / "config.toml")], ) - assert manager._builtin_supervisor.config_updates == [("alpha", {"enabled": True}, "", "self")] + assert manager._builtin_supervisor.config_updates == [("test.alpha", {"enabled": True}, "", "self")] assert manager._third_party_supervisor.config_updates == [] @pytest.mark.asyncio @@ -2615,23 +2758,23 @@ class TestIntegration: manager._started = True manager._builtin_supervisor = FakeSupervisor( { - "alpha": FakeRegistration(["bot"]), - "beta": FakeRegistration([]), + "test.alpha": FakeRegistration(["bot"]), + "test.beta": FakeRegistration([]), } ) manager._third_party_supervisor = FakeSupervisor( { - "gamma": FakeRegistration(["model"]), + "test.gamma": FakeRegistration(["model"]), } ) await manager._handle_main_config_reload(["bot", "model"]) assert manager._builtin_supervisor.config_updates == [ - ("alpha", {"bot": {"name": "MaiBot"}}, "", "bot") + ("test.alpha", {"bot": {"name": "MaiBot"}}, "", "bot") ] assert manager._third_party_supervisor.config_updates == [ - ("gamma", {"models": [{"name": "demo"}]}, "", "model") + ("test.gamma", {"models": [{"name": "demo"}]}, "", "model") ] def test_refresh_plugin_config_watch_subscriptions_registers_per_plugin(self, tmp_path): @@ -2646,8 +2789,8 @@ class TestIntegration: beta_dir.mkdir(parents=True) (alpha_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8") (beta_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8") - (alpha_dir / "_manifest.json").write_text(json.dumps({"name": "alpha"}), encoding="utf-8") - (beta_dir / "_manifest.json").write_text(json.dumps({"name": "beta"}), encoding="utf-8") + (alpha_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.alpha")), encoding="utf-8") + (beta_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.beta")), encoding="utf-8") class FakeWatcher: def __init__(self): @@ -2670,12 +2813,12 @@ class TestIntegration: manager = integration_module.PluginRuntimeManager() manager._plugin_file_watcher = FakeWatcher() - manager._builtin_supervisor = FakeSupervisor([builtin_root], ["alpha"]) - manager._third_party_supervisor = FakeSupervisor([thirdparty_root], ["beta"]) + manager._builtin_supervisor = FakeSupervisor([builtin_root], ["test.alpha"]) + manager._third_party_supervisor = FakeSupervisor([thirdparty_root], ["test.beta"]) manager._refresh_plugin_config_watch_subscriptions() - assert set(manager._plugin_config_watcher_subscriptions.keys()) == {"alpha", "beta"} + assert set(manager._plugin_config_watcher_subscriptions.keys()) == {"test.alpha", "test.beta"} assert { subscription["paths"][0] for subscription in manager._plugin_file_watcher.subscriptions } == {alpha_dir / "config.toml", beta_dir / "config.toml"} diff --git a/src/plugin_runtime/__init__.py b/src/plugin_runtime/__init__.py index 704ce514..7f2d789f 100644 --- a/src/plugin_runtime/__init__.py +++ b/src/plugin_runtime/__init__.py @@ -18,7 +18,7 @@ ENV_HOST_VERSION = "MAIBOT_HOST_VERSION" """Runner 读取的 Host 应用版本号,用于 manifest 兼容性校验""" ENV_EXTERNAL_PLUGIN_IDS = "MAIBOT_EXTERNAL_PLUGIN_IDS" -"""Runner 启动时可视为已满足的外部插件依赖列表(JSON 数组)""" +"""Runner 启动时可视为已满足的外部插件依赖版本映射(JSON 对象)""" ENV_GLOBAL_CONFIG_SNAPSHOT = "MAIBOT_GLOBAL_CONFIG_SNAPSHOT" """Runner 启动时注入的全局配置快照(JSON 对象)""" diff --git a/src/plugin_runtime/capabilities/components.py b/src/plugin_runtime/capabilities/components.py index 2e4c111c..33b54c64 100644 --- a/src/plugin_runtime/capabilities/components.py +++ b/src/plugin_runtime/capabilities/components.py @@ -191,7 +191,7 @@ class RuntimeComponentCapabilityMixin: return None, None, "缺少必要参数 api_name" if "." in normalized_api_name: - target_plugin_id, target_api_name = normalized_api_name.split(".", 1) + target_plugin_id, target_api_name = normalized_api_name.rsplit(".", 1) try: supervisor = self._get_supervisor_for_plugin(target_plugin_id) except RuntimeError as exc: @@ -282,7 +282,7 @@ class RuntimeComponentCapabilityMixin: return None, None, "缺少必要参数 name" if "." in normalized_name: - plugin_id, api_name = normalized_name.split(".", 1) + plugin_id, api_name = normalized_name.rsplit(".", 1) try: supervisor = self._get_supervisor_for_plugin(plugin_id) except RuntimeError as exc: diff --git a/src/plugin_runtime/host/supervisor.py b/src/plugin_runtime/host/supervisor.py index 693eae51..ac953bb3 100644 --- a/src/plugin_runtime/host/supervisor.py +++ b/src/plugin_runtime/host/supervisor.py @@ -116,7 +116,7 @@ class PluginRunnerSupervisor: self._runner_process: Optional[asyncio.subprocess.Process] = None self._registered_plugins: Dict[str, RegisterPluginPayload] = {} self._message_gateway_states: Dict[str, Dict[str, _MessageGatewayRuntimeState]] = {} - self._external_available_plugin_ids: List[str] = [] + self._external_available_plugins: Dict[str, str] = {} self._runner_ready_events: asyncio.Event = asyncio.Event() self._runner_ready_payloads: RunnerReadyPayload = RunnerReadyPayload() self._health_task: Optional[asyncio.Task[None]] = None @@ -166,21 +166,34 @@ class PluginRunnerSupervisor: """返回底层 RPC 服务端。""" return self._rpc_server - def set_external_available_plugin_ids(self, plugin_ids: List[str]) -> None: - """设置当前 Runner 启动/重载时可视为已满足的外部依赖列表。""" + def set_external_available_plugins(self, plugin_versions: Dict[str, str]) -> None: + """设置当前 Runner 启动/重载时可视为已满足的外部依赖版本映射。 - normalized_plugin_ids = { - str(plugin_id or "").strip() - for plugin_id in plugin_ids - if str(plugin_id or "").strip() + Args: + plugin_versions: 外部插件版本映射,键为插件 ID,值为插件版本。 + """ + self._external_available_plugins = { + str(plugin_id or "").strip(): str(plugin_version or "").strip() + for plugin_id, plugin_version in plugin_versions.items() + if str(plugin_id or "").strip() and str(plugin_version or "").strip() } - self._external_available_plugin_ids = sorted(normalized_plugin_ids) def get_loaded_plugin_ids(self) -> List[str]: """返回当前 Supervisor 已注册的插件 ID 列表。""" return sorted(self._registered_plugins.keys()) + def get_loaded_plugin_versions(self) -> Dict[str, str]: + """返回当前 Supervisor 已注册插件的版本映射。 + + Returns: + Dict[str, str]: 已注册插件版本映射,键为插件 ID,值为插件版本。 + """ + return { + plugin_id: registration.plugin_version + for plugin_id, registration in self._registered_plugins.items() + } + async def dispatch_event( self, event_type: str, @@ -373,14 +386,14 @@ class PluginRunnerSupervisor: self, plugin_id: str, reason: str = "manual", - external_available_plugins: Optional[List[str]] = None, + external_available_plugins: Optional[Dict[str, str]] = None, ) -> bool: """按插件 ID 触发精确重载。 Args: plugin_id: 目标插件 ID。 reason: 重载原因。 - external_available_plugins: 视为已满足的外部依赖插件 ID 列表。 + external_available_plugins: 视为已满足的外部依赖插件版本映射。 Returns: bool: 是否重载成功。 @@ -392,7 +405,7 @@ class PluginRunnerSupervisor: payload={ "plugin_id": plugin_id, "reason": reason, - "external_available_plugins": external_available_plugins or self._external_available_plugin_ids, + "external_available_plugins": external_available_plugins or self._external_available_plugins, }, timeout_ms=max(int(self._runner_spawn_timeout * 1000), 10000), ) @@ -409,14 +422,14 @@ class PluginRunnerSupervisor: self, plugin_ids: Optional[List[str]] = None, reason: str = "manual", - external_available_plugins: Optional[List[str]] = None, + external_available_plugins: Optional[Dict[str, str]] = None, ) -> bool: """批量重载插件。 Args: plugin_ids: 目标插件 ID 列表;为空时重载当前已注册的全部插件。 reason: 重载原因。 - external_available_plugins: 视为已满足的外部依赖插件 ID 列表。 + external_available_plugins: 视为已满足的外部依赖插件版本映射。 Returns: bool: 是否全部重载成功。 @@ -1136,7 +1149,7 @@ class PluginRunnerSupervisor: global_config_snapshot = config_manager.get_global_config().model_dump() global_config_snapshot["model"] = config_manager.get_model_config().model_dump() return { - ENV_EXTERNAL_PLUGIN_IDS: json.dumps(self._external_available_plugin_ids, ensure_ascii=False), + ENV_EXTERNAL_PLUGIN_IDS: json.dumps(self._external_available_plugins, ensure_ascii=False), ENV_GLOBAL_CONFIG_SNAPSHOT: json.dumps(global_config_snapshot, ensure_ascii=False), ENV_HOST_VERSION: PROTOCOL_VERSION, ENV_IPC_ADDRESS: self._transport.get_address(), diff --git a/src/plugin_runtime/integration.py b/src/plugin_runtime/integration.py index d48260e5..092b9597 100644 --- a/src/plugin_runtime/integration.py +++ b/src/plugin_runtime/integration.py @@ -11,7 +11,6 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, Dict, Iterable, List, Optional, Sequence, Set, Tuple import asyncio -import json import tomlkit @@ -26,6 +25,7 @@ from src.plugin_runtime.capabilities import ( ) from src.plugin_runtime.capabilities.registry import register_capability_impls from src.plugin_runtime.host.message_utils import MessageDict, PluginMessageUtils +from src.plugin_runtime.runner.manifest_validator import ManifestValidator if TYPE_CHECKING: from src.chat.message_receive.message import SessionMessage @@ -69,6 +69,7 @@ class PluginRuntimeManager( self._plugin_source_watcher_subscription_id: Optional[str] = None self._plugin_config_watcher_subscriptions: Dict[str, Tuple[Path, str]] = {} self._plugin_path_cache: Dict[str, Path] = {} + self._manifest_validator: ManifestValidator = ManifestValidator() self._config_reload_callback: Callable[[Sequence[str]], Awaitable[None]] = self._handle_main_config_reload self._config_reload_callback_registered: bool = False @@ -102,46 +103,11 @@ class PluginRuntimeManager( candidate = Path("plugins").resolve() return [candidate] if candidate.is_dir() else [] - @staticmethod - def _extract_manifest_dependencies(manifest: Dict[str, Any]) -> List[str]: - """从插件 manifest 中提取规范化后的依赖插件 ID 列表。""" - - dependencies: List[str] = [] - for dependency in manifest.get("dependencies", []): - if isinstance(dependency, str): - normalized_dependency = dependency.strip() - elif isinstance(dependency, dict): - normalized_dependency = str(dependency.get("name", "") or "").strip() - else: - normalized_dependency = "" - - if normalized_dependency: - dependencies.append(normalized_dependency) - return dependencies - @classmethod def _discover_plugin_dependency_map(cls, plugin_dirs: Iterable[Path]) -> Dict[str, List[str]]: """扫描指定插件目录集合,返回 ``plugin_id -> dependencies`` 映射。""" - - dependency_map: Dict[str, List[str]] = {} - for plugin_dir in cls._iter_candidate_plugin_paths(plugin_dirs): - manifest_path = plugin_dir / "_manifest.json" - entrypoint_path = plugin_dir / "plugin.py" - if not manifest_path.is_file() or not entrypoint_path.is_file(): - continue - - try: - with manifest_path.open("r", encoding="utf-8") as manifest_file: - manifest = json.load(manifest_file) - except Exception: - continue - - if not isinstance(manifest, dict): - continue - - plugin_id = str(manifest.get("name", plugin_dir.name) or "").strip() or plugin_dir.name - dependency_map[plugin_id] = cls._extract_manifest_dependencies(manifest) - return dependency_map + validator = ManifestValidator() + return validator.build_plugin_dependency_map(plugin_dirs) @classmethod def _build_group_start_order( @@ -243,12 +209,12 @@ class PluginRuntimeManager( if supervisor is None: continue - external_plugin_ids = [ - plugin_id + external_plugin_versions = { + plugin_id: plugin_version for started_supervisor in started_supervisors - for plugin_id in started_supervisor.get_loaded_plugin_ids() - ] - supervisor.set_external_available_plugin_ids(external_plugin_ids) + for plugin_id, plugin_version in started_supervisor.get_loaded_plugin_versions().items() + } + supervisor.set_external_available_plugins(external_plugin_versions) await supervisor.start() started_supervisors.append(supervisor) @@ -366,23 +332,22 @@ class PluginRuntimeManager( for plugin_id in supervisor.get_loaded_plugin_ids() } - def _build_external_available_plugins_for_supervisor(self, target_supervisor: "PluginSupervisor") -> List[str]: - """收集某个 Supervisor 可用的外部插件 ID 列表。""" + def _build_external_available_plugins_for_supervisor(self, target_supervisor: "PluginSupervisor") -> Dict[str, str]: + """收集某个 Supervisor 可用的外部插件版本映射。""" - external_plugin_ids: Set[str] = set() + external_plugin_versions: Dict[str, str] = {} for supervisor in self.supervisors: if supervisor is target_supervisor: continue - external_plugin_ids.update(supervisor.get_loaded_plugin_ids()) - return sorted(external_plugin_ids) + external_plugin_versions.update(supervisor.get_loaded_plugin_versions()) + return external_plugin_versions def _find_supervisor_by_plugin_directory(self, plugin_id: str) -> Optional["PluginSupervisor"]: """根据插件目录推断应负责该插件重载的 Supervisor。""" for supervisor in self.supervisors: - for plugin_dir in supervisor._plugin_dirs: - if (Path(plugin_dir) / plugin_id).is_dir(): - return supervisor + if self._get_plugin_path_for_supervisor(supervisor, plugin_id) is not None: + return supervisor return None def _warn_skipped_cross_supervisor_reload( @@ -740,30 +705,13 @@ class PluginRuntimeManager( external_available_plugins=self._build_external_available_plugins_for_supervisor(supervisor), ) - @staticmethod - def _find_duplicate_plugin_ids(plugin_dirs: List[Path]) -> Dict[str, List[Path]]: + @classmethod + def _find_duplicate_plugin_ids(cls, plugin_dirs: List[Path]) -> Dict[str, List[Path]]: """扫描插件目录,找出被多个目录重复声明的插件 ID。""" plugin_locations: Dict[str, List[Path]] = {} - for base_dir in plugin_dirs: - if not base_dir.is_dir(): - continue - for entry in base_dir.iterdir(): - if not entry.is_dir(): - continue - manifest_path = entry / "_manifest.json" - plugin_path = entry / "plugin.py" - if not manifest_path.exists() or not plugin_path.exists(): - continue - - plugin_id = entry.name - try: - with open(manifest_path, "r", encoding="utf-8") as manifest_file: - manifest = json.load(manifest_file) - plugin_id = str(manifest.get("name", entry.name)).strip() or entry.name - except Exception: - continue - - plugin_locations.setdefault(plugin_id, []).append(entry) + validator = ManifestValidator() + for plugin_path, manifest in validator.iter_plugin_manifests(plugin_dirs): + plugin_locations.setdefault(manifest.id, []).append(plugin_path) return { plugin_id: sorted(dict.fromkeys(paths), key=lambda p: str(p)) @@ -831,8 +779,7 @@ class PluginRuntimeManager( if entry.is_dir(): yield entry.resolve() - @staticmethod - def _read_plugin_id_from_plugin_path(plugin_path: Path) -> Optional[str]: + def _read_plugin_id_from_plugin_path(self, plugin_path: Path) -> Optional[str]: """从单个插件目录中读取 manifest 声明的插件 ID。 Args: @@ -841,22 +788,7 @@ class PluginRuntimeManager( Returns: Optional[str]: 解析成功时返回插件 ID,否则返回 ``None``。 """ - manifest_path = plugin_path / "_manifest.json" - entrypoint_path = plugin_path / "plugin.py" - if not manifest_path.is_file() or not entrypoint_path.is_file(): - return None - - try: - with open(manifest_path, "r", encoding="utf-8") as manifest_file: - manifest = json.load(manifest_file) - except Exception: - return None - - if not isinstance(manifest, dict): - return None - - plugin_id = str(manifest.get("name", plugin_path.name)).strip() or plugin_path.name - return plugin_id or None + return self._manifest_validator.read_plugin_id_from_plugin_path(plugin_path) def _iter_discovered_plugin_paths(self, plugin_dirs: Iterable[Path]) -> Iterable[Tuple[str, Path]]: """迭代目录中可解析到的插件 ID 与实际目录路径。 diff --git a/src/plugin_runtime/protocol/envelope.py b/src/plugin_runtime/protocol/envelope.py index ce40d855..c2c89a0f 100644 --- a/src/plugin_runtime/protocol/envelope.py +++ b/src/plugin_runtime/protocol/envelope.py @@ -282,8 +282,11 @@ class ReloadPluginPayload(BaseModel): """目标插件 ID""" reason: str = Field(default="manual", description="重载原因") """重载原因""" - external_available_plugins: List[str] = Field(default_factory=list, description="可视为已满足的外部依赖插件 ID") - """可视为已满足的外部依赖插件 ID""" + external_available_plugins: Dict[str, str] = Field( + default_factory=dict, + description="可视为已满足的外部依赖插件版本映射", + ) + """可视为已满足的外部依赖插件版本映射""" class ReloadPluginResultPayload(BaseModel): diff --git a/src/plugin_runtime/runner/manifest_validator.py b/src/plugin_runtime/runner/manifest_validator.py index 32429e01..33c2b1e5 100644 --- a/src/plugin_runtime/runner/manifest_validator.py +++ b/src/plugin_runtime/runner/manifest_validator.py @@ -1,20 +1,36 @@ -"""Manifest 校验与版本兼容性 +"""Manifest 校验与解析。 -从旧系统的 ManifestValidator / VersionComparator 对齐移植, -适配新 plugin_runtime 的 _manifest.json 格式。 +集中负责插件 ``_manifest.json`` 的读取、结构校验、运行时兼容性判断, +以及插件依赖/Python 包依赖的解析逻辑。 """ -from typing import Any, Dict, List, Tuple +from functools import lru_cache +from importlib import metadata as importlib_metadata +from pathlib import Path +from typing import Annotated, Any, Dict, Iterable, List, Literal, Optional, Tuple, Union +import json import re +import tomllib + +from packaging.requirements import InvalidRequirement, Requirement +from packaging.specifiers import InvalidSpecifier, SpecifierSet +from packaging.utils import canonicalize_name +from packaging.version import InvalidVersion, Version +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator from src.common.logger import get_logger logger = get_logger("plugin_runtime.runner.manifest_validator") +_SEMVER_PATTERN = re.compile(r"^\d+\.\d+\.\d+$") +_PLUGIN_ID_PATTERN = re.compile(r"^[a-z0-9]+(?:[.-][a-z0-9]+)+$") +_PACKAGE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +_HTTP_URL_PATTERN = re.compile(r"^https?://.+$") + class VersionComparator: - """语义化版本号比较器""" + """语义化版本号比较器。""" @staticmethod def normalize_version(version: str) -> str: @@ -25,13 +41,15 @@ class VersionComparator: Returns: str: 规范化后的 ``major.minor.patch`` 形式版本号。 - 当输入为空或格式非法时返回 ``0.0.0``。 + 当输入为空或格式非法时返回 ``0.0.0``。 """ if not version: return "0.0.0" - normalized = re.sub(r"-snapshot\.\d+", "", version.strip()) + + normalized = re.sub(r"-snapshot\.\d+", "", str(version).strip()) if not re.match(r"^\d+(\.\d+){0,2}$", normalized): return "0.0.0" + parts = normalized.split(".") while len(parts) < 3: parts.append("0") @@ -46,7 +64,7 @@ class VersionComparator: Returns: Tuple[int, int, int]: 三段式版本号对应的整数元组。 - 当解析失败时返回 ``(0, 0, 0)``。 + 当解析失败时返回 ``(0, 0, 0)``。 """ normalized = VersionComparator.normalize_version(version) try: @@ -65,13 +83,13 @@ class VersionComparator: Returns: int: ``-1`` 表示 ``v1 < v2``,``1`` 表示 ``v1 > v2``, - ``0`` 表示两者相等。 + ``0`` 表示两者相等。 """ t1 = VersionComparator.parse_version(v1) t2 = VersionComparator.parse_version(v2) if t1 < t2: return -1 - elif t1 > t2: + if t1 > t2: return 1 return 0 @@ -86,120 +104,1043 @@ class VersionComparator: Returns: Tuple[bool, str]: 第一项表示是否满足要求,第二项为失败原因; - 当校验通过时第二项为空字符串。 + 当校验通过时第二项为空字符串。 """ if not min_version and not max_version: return True, "" - vn = VersionComparator.normalize_version(version) + + normalized_version = VersionComparator.normalize_version(version) if min_version: - mn = VersionComparator.normalize_version(min_version) - if VersionComparator.compare(vn, mn) < 0: - return False, f"版本 {vn} 低于最小要求 {mn}" + normalized_min_version = VersionComparator.normalize_version(min_version) + if VersionComparator.compare(normalized_version, normalized_min_version) < 0: + return False, f"版本 {normalized_version} 低于最小要求 {normalized_min_version}" if max_version: - mx = VersionComparator.normalize_version(max_version) - if VersionComparator.compare(vn, mx) > 0: - return False, f"版本 {vn} 高于最大支持 {mx}" + normalized_max_version = VersionComparator.normalize_version(max_version) + if VersionComparator.compare(normalized_version, normalized_max_version) > 0: + return False, f"版本 {normalized_version} 高于最大支持 {normalized_max_version}" return True, "" + @staticmethod + def is_valid_semver(version: str) -> bool: + """判断字符串是否为严格三段式语义版本号。 + + Args: + version: 待检查的版本号字符串。 + + Returns: + bool: 是否满足 ``X.Y.Z`` 格式。 + """ + return bool(_SEMVER_PATTERN.fullmatch(str(version or "").strip())) + + +class _StrictManifestModel(BaseModel): + """Manifest 解析使用的严格基类模型。""" + + model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True) + + +class ManifestAuthor(_StrictManifestModel): + """插件作者信息。""" + + name: str = Field(description="作者名称") + url: str = Field(description="作者主页地址") + + @field_validator("name") + @classmethod + def _validate_name(cls, value: str) -> str: + """校验作者名称。 + + Args: + value: 原始作者名称。 + + Returns: + str: 规范化后的作者名称。 + + Raises: + ValueError: 当字段为空时抛出。 + """ + if not value: + raise ValueError("不能为空") + return value + + @field_validator("url") + @classmethod + def _validate_url(cls, value: str) -> str: + """校验作者主页地址。 + + Args: + value: 原始主页地址。 + + Returns: + str: 规范化后的主页地址。 + + Raises: + ValueError: 当字段为空或不是 HTTP/HTTPS URL 时抛出。 + """ + if not value: + raise ValueError("不能为空") + if not _HTTP_URL_PATTERN.fullmatch(value): + raise ValueError("必须为 http:// 或 https:// 开头的 URL") + return value + + +class ManifestUrls(_StrictManifestModel): + """插件相关链接集合。""" + + repository: str = Field(description="插件仓库地址") + homepage: Optional[str] = Field(default=None, description="插件主页地址") + documentation: Optional[str] = Field(default=None, description="插件文档地址") + issues: Optional[str] = Field(default=None, description="插件问题反馈地址") + + @field_validator("repository") + @classmethod + def _validate_repository(cls, value: str) -> str: + """校验仓库地址。 + + Args: + value: 原始仓库地址。 + + Returns: + str: 规范化后的仓库地址。 + + Raises: + ValueError: 当字段为空或不是 HTTP/HTTPS URL 时抛出。 + """ + if not value: + raise ValueError("不能为空") + if not _HTTP_URL_PATTERN.fullmatch(value): + raise ValueError("必须为 http:// 或 https:// 开头的 URL") + return value + + @field_validator("homepage", "documentation", "issues") + @classmethod + def _validate_optional_url(cls, value: Optional[str]) -> Optional[str]: + """校验可选链接字段。 + + Args: + value: 原始链接值。 + + Returns: + Optional[str]: 合法的链接值。 + + Raises: + ValueError: 当提供的值不是 HTTP/HTTPS URL 时抛出。 + """ + if value is None: + return None + if not value: + raise ValueError("不能为空字符串") + if not _HTTP_URL_PATTERN.fullmatch(value): + raise ValueError("必须为 http:// 或 https:// 开头的 URL") + return value + + +class ManifestVersionRange(_StrictManifestModel): + """版本闭区间声明。""" + + min_version: str = Field(description="最小版本,闭区间") + max_version: str = Field(description="最大版本,闭区间") + + @field_validator("min_version", "max_version") + @classmethod + def _validate_version(cls, value: str) -> str: + """校验版本号格式。 + + Args: + value: 原始版本号。 + + Returns: + str: 合法的版本号。 + + Raises: + ValueError: 当版本号不是严格三段式语义版本时抛出。 + """ + if not VersionComparator.is_valid_semver(value): + raise ValueError("必须为严格三段式版本号,例如 1.0.0") + return value + + @model_validator(mode="after") + def _validate_range(self) -> "ManifestVersionRange": + """校验版本区间上下界关系。 + + Returns: + ManifestVersionRange: 当前对象本身。 + + Raises: + ValueError: 当最小版本大于最大版本时抛出。 + """ + if VersionComparator.compare(self.min_version, self.max_version) > 0: + raise ValueError("min_version 不能大于 max_version") + return self + + +class ManifestI18n(_StrictManifestModel): + """国际化配置。""" + + default_locale: str = Field(description="默认语言") + locales_path: Optional[str] = Field(default=None, description="语言资源目录") + supported_locales: List[str] = Field(default_factory=list, description="支持的语言列表") + + @field_validator("default_locale") + @classmethod + def _validate_default_locale(cls, value: str) -> str: + """校验默认语言。 + + Args: + value: 原始默认语言。 + + Returns: + str: 规范化后的默认语言。 + + Raises: + ValueError: 当字段为空时抛出。 + """ + if not value: + raise ValueError("不能为空") + return value + + @field_validator("locales_path") + @classmethod + def _validate_locales_path(cls, value: Optional[str]) -> Optional[str]: + """校验语言资源目录。 + + Args: + value: 原始语言资源目录。 + + Returns: + Optional[str]: 合法的目录值。 + + Raises: + ValueError: 当值为空字符串时抛出。 + """ + if value is None: + return None + if not value: + raise ValueError("不能为空字符串") + return value + + @field_validator("supported_locales") + @classmethod + def _validate_supported_locales(cls, value: List[str]) -> List[str]: + """校验支持语言列表。 + + Args: + value: 原始语言列表。 + + Returns: + List[str]: 去重后的语言列表。 + + Raises: + ValueError: 当列表项为空时抛出。 + """ + normalized_locales: List[str] = [] + for locale in value: + normalized_locale = str(locale or "").strip() + if not normalized_locale: + raise ValueError("语言列表中存在空值") + if normalized_locale not in normalized_locales: + normalized_locales.append(normalized_locale) + return normalized_locales + + @model_validator(mode="after") + def _validate_default_locale_membership(self) -> "ManifestI18n": + """校验默认语言是否位于支持列表中。 + + Returns: + ManifestI18n: 当前对象本身。 + + Raises: + ValueError: 当 ``supported_locales`` 非空但未包含 ``default_locale`` 时抛出。 + """ + if self.supported_locales and self.default_locale not in self.supported_locales: + raise ValueError("default_locale 必须包含在 supported_locales 中") + return self + + +class PluginDependencyDefinition(_StrictManifestModel): + """插件级依赖声明。""" + + type: Literal["plugin"] = Field(description="依赖类型") + id: str = Field(description="依赖插件 ID") + version_spec: str = Field(description="版本约束表达式") + + @field_validator("id") + @classmethod + def _validate_id(cls, value: str) -> str: + """校验依赖插件 ID。 + + Args: + value: 原始依赖插件 ID。 + + Returns: + str: 合法的依赖插件 ID。 + + Raises: + ValueError: 当 ID 不符合规则时抛出。 + """ + if not _PLUGIN_ID_PATTERN.fullmatch(value): + raise ValueError("必须使用小写字母/数字,并以点号或横线分隔,例如 github.author.plugin") + return value + + @field_validator("version_spec") + @classmethod + def _validate_version_spec(cls, value: str) -> str: + """校验插件依赖版本约束。 + + Args: + value: 原始版本约束表达式。 + + Returns: + str: 合法的版本约束表达式。 + + Raises: + ValueError: 当表达式无效时抛出。 + """ + if not value: + raise ValueError("不能为空") + try: + SpecifierSet(value) + except InvalidSpecifier as exc: + raise ValueError(f"无效的版本约束: {exc}") from exc + return value + + +class PythonPackageDependencyDefinition(_StrictManifestModel): + """Python 包依赖声明。""" + + type: Literal["python_package"] = Field(description="依赖类型") + name: str = Field(description="Python 包名") + version_spec: str = Field(description="版本约束表达式") + + @field_validator("name") + @classmethod + def _validate_name(cls, value: str) -> str: + """校验 Python 包名。 + + Args: + value: 原始包名。 + + Returns: + str: 合法的包名。 + + Raises: + ValueError: 当包名不合法时抛出。 + """ + if not _PACKAGE_NAME_PATTERN.fullmatch(value): + raise ValueError("包名只能包含字母、数字、点号、下划线和横线") + return value + + @field_validator("version_spec") + @classmethod + def _validate_version_spec(cls, value: str) -> str: + """校验 Python 包版本约束。 + + Args: + value: 原始版本约束表达式。 + + Returns: + str: 合法的版本约束表达式。 + + Raises: + ValueError: 当表达式无效时抛出。 + """ + if not value: + raise ValueError("不能为空") + try: + Requirement(f"placeholder{value}") + except InvalidRequirement as exc: + raise ValueError(f"无效的版本约束: {exc}") from exc + return value + + +ManifestDependencyDefinition = Annotated[ + Union[PluginDependencyDefinition, PythonPackageDependencyDefinition], + Field(discriminator="type"), +] + + +class PluginManifest(_StrictManifestModel): + """插件 Manifest v2 强类型模型。""" + + manifest_version: Literal[2] = Field(description="Manifest 协议版本") + version: str = Field(description="插件版本") + name: str = Field(description="插件展示名称") + description: str = Field(description="插件描述") + author: ManifestAuthor = Field(description="插件作者信息") + license: str = Field(description="插件协议") + urls: ManifestUrls = Field(description="插件相关链接") + host_application: ManifestVersionRange = Field(description="Host 兼容区间") + sdk: ManifestVersionRange = Field(description="SDK 兼容区间") + dependencies: List[ManifestDependencyDefinition] = Field(default_factory=list, description="依赖声明") + capabilities: List[str] = Field(description="插件声明的能力请求") + i18n: ManifestI18n = Field(description="国际化配置") + id: str = Field(description="稳定插件 ID") + + @field_validator("version") + @classmethod + def _validate_version(cls, value: str) -> str: + """校验插件版本号格式。 + + Args: + value: 原始插件版本号。 + + Returns: + str: 合法的插件版本号。 + + Raises: + ValueError: 当版本号不是严格三段式语义版本时抛出。 + """ + if not VersionComparator.is_valid_semver(value): + raise ValueError("必须为严格三段式版本号,例如 1.0.0") + return value + + @field_validator("name", "description", "license", "id") + @classmethod + def _validate_required_string(cls, value: str, info: Any) -> str: + """校验必填字符串字段。 + + Args: + value: 原始字段值。 + info: Pydantic 字段上下文。 + + Returns: + str: 合法的字段值。 + + Raises: + ValueError: 当字段为空或格式不合法时抛出。 + """ + if not value: + raise ValueError("不能为空") + if info.field_name == "id" and not _PLUGIN_ID_PATTERN.fullmatch(value): + raise ValueError("必须使用小写字母/数字,并以点号或横线分隔,例如 github.author.plugin") + return value + + @field_validator("capabilities") + @classmethod + def _validate_capabilities(cls, value: List[str]) -> List[str]: + """校验能力声明列表。 + + Args: + value: 原始能力声明列表。 + + Returns: + List[str]: 去重后的能力列表。 + + Raises: + ValueError: 当列表为空项或能力名为空时抛出。 + """ + normalized_capabilities: List[str] = [] + for capability in value: + normalized_capability = str(capability or "").strip() + if not normalized_capability: + raise ValueError("capabilities 中存在空能力名") + if normalized_capability not in normalized_capabilities: + normalized_capabilities.append(normalized_capability) + return normalized_capabilities + + @model_validator(mode="after") + def _validate_dependencies(self) -> "PluginManifest": + """校验依赖声明集合。 + + Returns: + PluginManifest: 当前对象本身。 + + Raises: + ValueError: 当依赖项重复或插件依赖自身时抛出。 + """ + plugin_dependency_ids: set[str] = set() + python_package_names: set[str] = set() + + for dependency in self.dependencies: + if isinstance(dependency, PluginDependencyDefinition): + if dependency.id == self.id: + raise ValueError("dependencies 中的插件依赖不能依赖自身") + if dependency.id in plugin_dependency_ids: + raise ValueError(f"存在重复的插件依赖声明: {dependency.id}") + plugin_dependency_ids.add(dependency.id) + continue + + normalized_package_name = canonicalize_name(dependency.name) + if normalized_package_name in python_package_names: + raise ValueError(f"存在重复的 Python 包依赖声明: {dependency.name}") + python_package_names.add(normalized_package_name) + + return self + + @property + def plugin_dependencies(self) -> List[PluginDependencyDefinition]: + """返回插件级依赖列表。 + + Returns: + List[PluginDependencyDefinition]: 所有 ``type=plugin`` 的依赖项。 + """ + return [dependency for dependency in self.dependencies if isinstance(dependency, PluginDependencyDefinition)] + + @property + def python_package_dependencies(self) -> List[PythonPackageDependencyDefinition]: + """返回 Python 包依赖列表。 + + Returns: + List[PythonPackageDependencyDefinition]: 所有 ``type=python_package`` 的依赖项。 + """ + return [ + dependency + for dependency in self.dependencies + if isinstance(dependency, PythonPackageDependencyDefinition) + ] + + @property + def plugin_dependency_ids(self) -> List[str]: + """返回插件级依赖的插件 ID 列表。 + + Returns: + List[str]: 所有插件级依赖的插件 ID。 + """ + return [dependency.id for dependency in self.plugin_dependencies] + class ManifestValidator: - """_manifest.json 校验器""" + """严格的插件 Manifest v2 校验器。""" - REQUIRED_FIELDS = ["name", "version", "description", "author"] - RECOMMENDED_FIELDS = ["license", "keywords", "categories"] - SUPPORTED_MANIFEST_VERSIONS = [1, 2] + SUPPORTED_MANIFEST_VERSIONS = [2] - def __init__(self, host_version: str = "") -> None: + def __init__( + self, + host_version: str = "", + sdk_version: str = "", + project_root: Optional[Path] = None, + ) -> None: """初始化 Manifest 校验器。 Args: - host_version: 当前 Host 版本号,用于校验插件声明的兼容区间。 + host_version: 当前 Host 版本号;留空时自动从主程序 ``pyproject.toml`` 读取。 + sdk_version: 当前 SDK 版本号;留空时自动从运行环境中探测。 + project_root: 项目根目录;留空时自动推断。 """ - self._host_version = host_version + self._project_root: Path = project_root or self._resolve_project_root() + self._host_version: str = host_version or self._detect_default_host_version(self._project_root) + self._sdk_version: str = sdk_version or self._detect_default_sdk_version(self._project_root) self.errors: List[str] = [] self.warnings: List[str] = [] def validate(self, manifest: Dict[str, Any]) -> bool: - """校验 manifest 数据,返回是否通过(errors 为空即通过)。""" + """校验 manifest 数据,返回是否通过。 + + Args: + manifest: 待校验的 Manifest 原始字典。 + + Returns: + bool: 校验是否通过。 + """ + return self.parse_manifest(manifest) is not None + + def parse_manifest(self, manifest: Dict[str, Any]) -> Optional[PluginManifest]: + """解析并校验 manifest 字典。 + + Args: + manifest: 待解析的 Manifest 原始字典。 + + Returns: + Optional[PluginManifest]: 解析成功时返回强类型 Manifest;失败时返回 ``None``。 + """ self.errors.clear() self.warnings.clear() - self._check_required_fields(manifest) - self._check_manifest_version(manifest) - self._check_author(manifest) - self._check_host_compatibility(manifest) - self._check_recommended(manifest) + try: + parsed_manifest = PluginManifest.model_validate(manifest) + except ValidationError as exc: + self.errors.extend(self._format_validation_errors(exc)) + self._log_errors() + return None + self._validate_runtime_compatibility(parsed_manifest) if self.errors: - for e in self.errors: - logger.error(f"Manifest 校验失败: {e}") - if self.warnings: - for w in self.warnings: - logger.warning(f"Manifest 警告: {w}") + self._log_errors() + return None - return len(self.errors) == 0 + return parsed_manifest - def _check_required_fields(self, manifest: Dict[str, Any]) -> None: - """检查 Manifest 中的必填字段是否存在且非空。 + def load_from_plugin_path(self, plugin_path: Path, require_entrypoint: bool = True) -> Optional[PluginManifest]: + """从插件目录读取并解析 manifest。 Args: - manifest: 待校验的 Manifest 数据。 - """ - for field in self.REQUIRED_FIELDS: - if field not in manifest: - self.errors.append(f"缺少必需字段: {field}") - elif not manifest[field]: - self.errors.append(f"必需字段不能为空: {field}") + plugin_path: 单个插件目录路径。 + require_entrypoint: 是否要求目录内存在 ``plugin.py`` 入口文件。 - def _check_manifest_version(self, manifest: Dict[str, Any]) -> None: - """检查 Manifest 版本号是否在当前 Runner 支持范围内。 + Returns: + Optional[PluginManifest]: 解析成功时返回强类型 Manifest;失败时返回 ``None``。 + """ + self.errors.clear() + self.warnings.clear() + + manifest_path = plugin_path / "_manifest.json" + entrypoint_path = plugin_path / "plugin.py" + + if not manifest_path.is_file(): + self.errors.append("缺少 _manifest.json") + return None + if require_entrypoint and not entrypoint_path.is_file(): + self.errors.append("缺少 plugin.py") + return None + + try: + with manifest_path.open("r", encoding="utf-8") as manifest_file: + manifest_data = json.load(manifest_file) + except Exception as exc: + self.errors.append(f"manifest 解析失败: {exc}") + self._log_errors() + return None + + if not isinstance(manifest_data, dict): + self.errors.append("manifest 顶层必须为 JSON 对象") + self._log_errors() + return None + + return self.parse_manifest(manifest_data) + + def iter_plugin_manifests( + self, + plugin_dirs: Iterable[Path], + require_entrypoint: bool = True, + ) -> Iterable[Tuple[Path, PluginManifest]]: + """扫描插件根目录并迭代所有可成功解析的 Manifest。 Args: - manifest: 待校验的 Manifest 数据。 - """ - mv = manifest.get("manifest_version") - if mv is not None and mv not in self.SUPPORTED_MANIFEST_VERSIONS: - self.errors.append(f"不支持的 manifest_version: {mv},支持: {self.SUPPORTED_MANIFEST_VERSIONS}") + plugin_dirs: 一个或多个插件根目录。 + require_entrypoint: 是否要求每个插件目录内存在 ``plugin.py``。 - def _check_author(self, manifest: Dict[str, Any]) -> None: - """校验 ``author`` 字段的结构与内容。 + Yields: + Tuple[Path, PluginManifest]: ``(插件目录路径, 解析结果)`` 二元组。 + """ + for plugin_root in plugin_dirs: + normalized_root = Path(plugin_root).resolve() + if not normalized_root.is_dir(): + continue + + for candidate_path in sorted(entry.resolve() for entry in normalized_root.iterdir() if entry.is_dir()): + parsed_manifest = self.load_from_plugin_path(candidate_path, require_entrypoint=require_entrypoint) + if parsed_manifest is None: + continue + yield candidate_path, parsed_manifest + + def build_plugin_dependency_map( + self, + plugin_dirs: Iterable[Path], + require_entrypoint: bool = True, + ) -> Dict[str, List[str]]: + """扫描目录并构建 ``plugin_id -> 依赖插件 ID 列表`` 映射。 Args: - manifest: 待校验的 Manifest 数据。 - """ - author = manifest.get("author") - if author is None: - return - if isinstance(author, dict): - if "name" not in author or not author["name"]: - self.errors.append("author 对象缺少 name 字段") - elif isinstance(author, str): - if not author.strip(): - self.errors.append("author 不能为空") - else: - self.errors.append("author 应为字符串或 {name, url} 对象") + plugin_dirs: 一个或多个插件根目录。 + require_entrypoint: 是否要求每个插件目录内存在 ``plugin.py``。 - def _check_host_compatibility(self, manifest: Dict[str, Any]) -> None: - """检查插件声明的 Host 兼容范围是否包含当前 Host 版本。 + Returns: + Dict[str, List[str]]: 所有成功解析到的插件依赖映射。 + """ + dependency_map: Dict[str, List[str]] = {} + for _plugin_path, manifest in self.iter_plugin_manifests(plugin_dirs, require_entrypoint=require_entrypoint): + dependency_map[manifest.id] = manifest.plugin_dependency_ids + return dependency_map + + def read_plugin_id_from_plugin_path(self, plugin_path: Path, require_entrypoint: bool = True) -> Optional[str]: + """从单个插件目录中读取规范化后的插件 ID。 Args: - manifest: 待校验的 Manifest 数据。 - """ - host_app = manifest.get("host_application") - if not isinstance(host_app, dict) or not self._host_version: - return - min_v = host_app.get("min_version", "") - max_v = host_app.get("max_version", "") - ok, msg = VersionComparator.is_in_range(self._host_version, min_v, max_v) - if not ok: - self.errors.append(f"Host 版本不兼容: {msg} (当前 Host: {self._host_version})") + plugin_path: 单个插件目录路径。 + require_entrypoint: 是否要求目录内存在 ``plugin.py``。 - def _check_recommended(self, manifest: Dict[str, Any]) -> None: - """检查推荐字段是否齐备,并记录为警告而非错误。 + Returns: + Optional[str]: 解析成功时返回插件 ID,否则返回 ``None``。 + """ + manifest = self.load_from_plugin_path(plugin_path, require_entrypoint=require_entrypoint) + if manifest is None: + return None + return manifest.id + + def get_unsatisfied_plugin_dependencies( + self, + manifest: PluginManifest, + available_plugin_versions: Dict[str, str], + ) -> List[str]: + """返回当前 Manifest 尚未满足的插件依赖项。 Args: - manifest: 待校验的 Manifest 数据。 + manifest: 目标插件的强类型 Manifest。 + available_plugin_versions: 当前可用插件版本映射,键为插件 ID,值为插件版本。 + + Returns: + List[str]: 未满足依赖的错误描述列表。 """ - for field in self.RECOMMENDED_FIELDS: - if field not in manifest or not manifest[field]: - self.warnings.append(f"建议填写字段: {field}") + unsatisfied_dependencies: List[str] = [] + for dependency in manifest.plugin_dependencies: + dependency_version = available_plugin_versions.get(dependency.id) + if not dependency_version: + unsatisfied_dependencies.append(f"{dependency.id} (未找到依赖插件)") + continue + + if not self._version_matches_specifier(dependency_version, dependency.version_spec): + unsatisfied_dependencies.append( + f"{dependency.id} (需要 {dependency.version_spec},当前 {dependency_version})" + ) + + return unsatisfied_dependencies + + def is_plugin_dependency_satisfied( + self, + dependency: PluginDependencyDefinition, + plugin_version: str, + ) -> bool: + """判断单个插件依赖是否被指定版本满足。 + + Args: + dependency: 插件级依赖声明。 + plugin_version: 当前可用的插件版本号。 + + Returns: + bool: 是否满足版本约束。 + """ + return self._version_matches_specifier(plugin_version, dependency.version_spec) + + def _validate_runtime_compatibility(self, manifest: PluginManifest) -> None: + """校验运行时版本兼容性与 Python 包依赖。 + + Args: + manifest: 已通过结构校验的强类型 Manifest。 + """ + host_ok, host_message = VersionComparator.is_in_range( + self._host_version, + manifest.host_application.min_version, + manifest.host_application.max_version, + ) + if not host_ok: + self.errors.append(f"Host 版本不兼容: {host_message} (当前 Host: {self._host_version})") + + sdk_ok, sdk_message = VersionComparator.is_in_range( + self._sdk_version, + manifest.sdk.min_version, + manifest.sdk.max_version, + ) + if not sdk_ok: + self.errors.append(f"SDK 版本不兼容: {sdk_message} (当前 SDK: {self._sdk_version})") + + self._validate_python_package_dependencies(manifest) + + def _validate_python_package_dependencies(self, manifest: PluginManifest) -> None: + """校验 Python 包依赖与主程序运行环境是否冲突。 + + Args: + manifest: 已通过结构校验的强类型 Manifest。 + """ + host_requirements = self._load_host_dependency_requirements(self._project_root) + + for dependency in manifest.python_package_dependencies: + normalized_package_name = canonicalize_name(dependency.name) + package_specifier = self._build_specifier_set(dependency.version_spec) + if package_specifier is None: + self.errors.append( + f"Python 包依赖 {dependency.name} 的版本约束无效: {dependency.version_spec}" + ) + continue + + installed_version = self._get_installed_package_version(dependency.name) + host_requirement = host_requirements.get(normalized_package_name) + + if installed_version is not None and not self._version_matches_specifier( + installed_version, + dependency.version_spec, + ): + self.errors.append( + f"Python 包依赖冲突: {dependency.name} 需要 {dependency.version_spec}," + f"当前运行环境为 {installed_version}" + ) + continue + + if host_requirement is None: + continue + + if not self._requirements_may_overlap(host_requirement.specifier, package_specifier): + host_specifier = str(host_requirement.specifier or "") + self.errors.append( + f"Python 包依赖冲突: {dependency.name} 需要 {dependency.version_spec}," + f"主程序依赖约束为 {host_specifier or '任意版本'}" + ) + + def _log_errors(self) -> None: + """输出当前累计的 Manifest 校验错误。""" + for error_message in self.errors: + logger.error(f"Manifest 校验失败: {error_message}") + + @classmethod + def _resolve_project_root(cls) -> Path: + """推断当前项目根目录。 + + Returns: + Path: 项目根目录路径。 + """ + return Path(__file__).resolve().parents[3] + + @classmethod + @lru_cache(maxsize=None) + def _detect_default_host_version(cls, project_root: Path) -> str: + """从主程序 ``pyproject.toml`` 探测 Host 版本号。 + + Args: + project_root: 项目根目录。 + + Returns: + str: 探测到的 Host 版本号;失败时返回空字符串。 + """ + pyproject_path = project_root / "pyproject.toml" + try: + with pyproject_path.open("rb") as pyproject_file: + pyproject_data = tomllib.load(pyproject_file) + except Exception: + return "" + + project_data = pyproject_data.get("project", {}) + if not isinstance(project_data, dict): + return "" + + raw_version = str(project_data.get("version", "") or "").strip() + if VersionComparator.is_valid_semver(raw_version): + return raw_version + return "" + + @classmethod + @lru_cache(maxsize=None) + def _detect_default_sdk_version(cls, project_root: Path) -> str: + """探测当前运行环境中的 SDK 版本号。 + + Args: + project_root: 项目根目录。 + + Returns: + str: 探测到的 SDK 版本号;失败时返回空字符串。 + """ + try: + raw_version = importlib_metadata.version("maibot-plugin-sdk") + if VersionComparator.is_valid_semver(raw_version): + return raw_version + except importlib_metadata.PackageNotFoundError: + pass + + sdk_pyproject_path = project_root / "packages" / "maibot-plugin-sdk" / "pyproject.toml" + try: + with sdk_pyproject_path.open("rb") as pyproject_file: + pyproject_data = tomllib.load(pyproject_file) + except Exception: + return "" + + project_data = pyproject_data.get("project", {}) + if not isinstance(project_data, dict): + return "" + + raw_version = str(project_data.get("version", "") or "").strip() + if VersionComparator.is_valid_semver(raw_version): + return raw_version + return "" + + @classmethod + @lru_cache(maxsize=None) + def _load_host_dependency_requirements(cls, project_root: Path) -> Dict[str, Requirement]: + """加载主程序 ``pyproject.toml`` 中声明的依赖约束。 + + Args: + project_root: 项目根目录。 + + Returns: + Dict[str, Requirement]: 以规范化包名为键的 Requirement 映射。 + """ + pyproject_path = project_root / "pyproject.toml" + try: + with pyproject_path.open("rb") as pyproject_file: + pyproject_data = tomllib.load(pyproject_file) + except Exception: + return {} + + project_data = pyproject_data.get("project", {}) + if not isinstance(project_data, dict): + return {} + + raw_dependencies = project_data.get("dependencies", []) + if not isinstance(raw_dependencies, list): + return {} + + requirements: Dict[str, Requirement] = {} + for raw_dependency in raw_dependencies: + dependency_text = str(raw_dependency or "").strip() + if not dependency_text: + continue + + try: + requirement = Requirement(dependency_text) + except InvalidRequirement: + continue + + requirements[canonicalize_name(requirement.name)] = requirement + + return requirements + + @staticmethod + def _get_installed_package_version(package_name: str) -> Optional[str]: + """获取当前运行环境中指定 Python 包的安装版本。 + + Args: + package_name: 待查询的包名。 + + Returns: + Optional[str]: 已安装版本号;未安装时返回 ``None``。 + """ + try: + return importlib_metadata.version(package_name) + except importlib_metadata.PackageNotFoundError: + return None + + @staticmethod + def _build_specifier_set(version_spec: str) -> Optional[SpecifierSet]: + """构造版本约束对象。 + + Args: + version_spec: 版本约束字符串。 + + Returns: + Optional[SpecifierSet]: 构造成功时返回约束对象,否则返回 ``None``。 + """ + try: + return SpecifierSet(version_spec) + except InvalidSpecifier: + return None + + @staticmethod + def _version_matches_specifier(version: str, version_spec: str) -> bool: + """判断版本是否满足给定约束。 + + Args: + version: 待判断的版本号。 + version_spec: 版本约束表达式。 + + Returns: + bool: 是否满足约束。 + """ + try: + normalized_version = Version(version) + specifier_set = SpecifierSet(version_spec) + except (InvalidVersion, InvalidSpecifier): + return False + return specifier_set.contains(normalized_version, prereleases=True) + + @classmethod + def _requirements_may_overlap(cls, left: SpecifierSet, right: SpecifierSet) -> bool: + """粗略判断两个版本约束是否存在交集。 + + Args: + left: 左侧版本约束。 + right: 右侧版本约束。 + + Returns: + bool: 若可能存在交集则返回 ``True``,否则返回 ``False``。 + """ + candidate_versions = cls._build_candidate_versions(left, right) + for candidate_version in candidate_versions: + if left.contains(candidate_version, prereleases=True) and right.contains(candidate_version, prereleases=True): + return True + return False + + @classmethod + def _build_candidate_versions(cls, left: SpecifierSet, right: SpecifierSet) -> List[Version]: + """为两个版本约束构造一组用于交集探测的候选版本。 + + Args: + left: 左侧版本约束。 + right: 右侧版本约束。 + + Returns: + List[Version]: 去重后的候选版本列表。 + """ + candidate_versions: List[Version] = [Version("0.0.0")] + for specifier in tuple(left) + tuple(right): + for candidate_version in cls._expand_candidate_versions(specifier.version): + if candidate_version not in candidate_versions: + candidate_versions.append(candidate_version) + return candidate_versions + + @staticmethod + def _expand_candidate_versions(raw_version: str) -> List[Version]: + """根据边界版本扩展出一组邻近候选版本。 + + Args: + raw_version: 约束中出现的边界版本字符串。 + + Returns: + List[Version]: 可用于交集探测的候选版本列表。 + """ + normalized_text = raw_version.replace("*", "0") + try: + boundary_version = Version(normalized_text) + except InvalidVersion: + return [] + + release_parts = list(boundary_version.release[:3]) + while len(release_parts) < 3: + release_parts.append(0) + major, minor, patch = release_parts[:3] + + candidates = { + Version(f"{major}.{minor}.{patch}"), + Version(f"{major}.{minor}.{patch + 1}"), + } + if patch > 0: + candidates.add(Version(f"{major}.{minor}.{patch - 1}")) + elif minor > 0: + candidates.add(Version(f"{major}.{minor - 1}.999")) + elif major > 0: + candidates.add(Version(f"{major - 1}.999.999")) + + return sorted(candidates) + + @classmethod + def _format_validation_errors(cls, exc: ValidationError) -> List[str]: + """将 Pydantic 校验错误转换为中文错误列表。 + + Args: + exc: Pydantic 抛出的校验异常。 + + Returns: + List[str]: 中文错误描述列表。 + """ + error_messages: List[str] = [] + for error in exc.errors(): + location = cls._format_error_location(error.get("loc", ())) + error_type = str(error.get("type", "")) + error_input = error.get("input") + error_context = error.get("ctx", {}) or {} + + if error_type == "missing": + error_messages.append(f"缺少必需字段: {location}") + elif error_type == "extra_forbidden": + error_messages.append(f"存在未声明字段: {location}") + elif error_type == "literal_error": + expected_values = error_context.get("expected") + error_messages.append(f"字段 {location} 的值不合法,必须为 {expected_values}") + elif error_type == "model_type": + error_messages.append(f"字段 {location} 必须为对象") + elif error_type.endswith("_type"): + error_messages.append(f"字段 {location} 的类型不正确") + elif error_type == "value_error": + error_messages.append(f"字段 {location} 校验失败: {error_context.get('error')}") + else: + error_messages.append(f"字段 {location} 校验失败: {error.get('msg', error_input)}") + + return error_messages + + @staticmethod + def _format_error_location(location: Tuple[Any, ...]) -> str: + """格式化校验错误字段路径。 + + Args: + location: Pydantic 提供的字段路径元组。 + + Returns: + str: 点号连接后的字段路径。 + """ + return ".".join(str(item) for item in location) if location else "" diff --git a/src/plugin_runtime/runner/plugin_loader.py b/src/plugin_runtime/runner/plugin_loader.py index f07eb593..3eaf9f23 100644 --- a/src/plugin_runtime/runner/plugin_loader.py +++ b/src/plugin_runtime/runner/plugin_loader.py @@ -13,16 +13,16 @@ from typing import Any, Dict, Iterator, List, Optional, Set, Tuple import contextlib import importlib import importlib.util -import json import os +import re import sys from src.common.logger import get_logger -from src.plugin_runtime.runner.manifest_validator import ManifestValidator +from src.plugin_runtime.runner.manifest_validator import ManifestValidator, PluginManifest logger = get_logger("plugin_runtime.runner.plugin_loader") -PluginCandidate = Tuple[Path, Dict[str, Any], Path] +PluginCandidate = Tuple[Path, PluginManifest, Path] class PluginMeta: @@ -34,7 +34,7 @@ class PluginMeta: plugin_dir: str, module_name: str, plugin_instance: Any, - manifest: Dict[str, Any], + manifest: PluginManifest, ) -> None: """初始化插件元数据。 @@ -43,36 +43,16 @@ class PluginMeta: plugin_dir: 插件目录绝对路径。 module_name: 插件入口模块名。 plugin_instance: 插件实例对象。 - manifest: 解析后的 manifest 内容。 + manifest: 解析后的强类型 Manifest。 """ self.plugin_id = plugin_id self.plugin_dir = plugin_dir self.module_name = module_name self.instance = plugin_instance self.manifest = manifest - self.version = manifest.get("version", "1.0.0") - self.capabilities_required = manifest.get("capabilities", []) - self.dependencies: List[str] = self._extract_dependencies(manifest) - - @staticmethod - def _extract_dependencies(manifest: Dict[str, Any]) -> List[str]: - """从 manifest 中提取依赖列表。 - - Args: - manifest: 插件 manifest。 - - Returns: - List[str]: 规范化后的依赖插件 ID 列表。 - """ - raw = manifest.get("dependencies", []) - result: List[str] = [] - for dep in raw: - if isinstance(dep, str): - result.append(dep.strip()) - elif isinstance(dep, dict): - if name := str(dep.get("name", "")).strip(): - result.append(name) - return result + self.version = manifest.version + self.capabilities_required = list(manifest.capabilities) + self.dependencies: List[str] = list(manifest.plugin_dependency_ids) class PluginLoader: @@ -98,13 +78,13 @@ class PluginLoader: def discover_and_load( self, plugin_dirs: List[str], - extra_available: Optional[Set[str]] = None, + extra_available: Optional[Dict[str, str]] = None, ) -> List[PluginMeta]: """扫描多个目录并加载所有插件。 Args: plugin_dirs: 插件目录列表。 - extra_available: 额外视为已满足的外部依赖插件 ID 集合。 + extra_available: 额外视为已满足的外部依赖插件版本映射。 Returns: List[PluginMeta]: 成功加载的插件元数据列表,按依赖顺序排列。 @@ -164,26 +144,17 @@ class PluginLoader: def _discover_single_candidate(self, plugin_dir: Path) -> Optional[Tuple[str, PluginCandidate]]: """发现并校验单个插件目录。""" - manifest_path = plugin_dir / "_manifest.json" plugin_path = plugin_dir / "plugin.py" - - if not manifest_path.exists() or not plugin_path.exists(): + if not plugin_path.exists(): return None - try: - with manifest_path.open("r", encoding="utf-8") as manifest_file: - manifest: Dict[str, Any] = json.load(manifest_file) - except Exception as e: - self._failed_plugins[plugin_dir.name] = f"manifest 解析失败: {e}" - logger.error(f"插件 {plugin_dir.name} manifest 解析失败: {e}") - return None - - if not self._manifest_validator.validate(manifest): + manifest = self._manifest_validator.load_from_plugin_path(plugin_dir) + if manifest is None: errors = "; ".join(self._manifest_validator.errors) self._failed_plugins[plugin_dir.name] = f"manifest 校验失败: {errors}" return None - plugin_id = str(manifest.get("name", plugin_dir.name)).strip() or plugin_dir.name + plugin_id = manifest.id return plugin_id, (plugin_dir, manifest, plugin_path) def _record_duplicate_candidates(self, duplicate_candidates: Dict[str, List[Path]]) -> None: @@ -253,7 +224,7 @@ class PluginLoader: """ removed_modules: List[str] = [] plugin_path = Path(plugin_dir).resolve() - synthetic_module_name = f"_maibot_plugin_{plugin_id}" + synthetic_module_name = self._build_safe_module_name(plugin_id) for module_name, module in list(sys.modules.items()): if module_name == synthetic_module_name: @@ -277,6 +248,21 @@ class PluginLoader: importlib.invalidate_caches() return removed_modules + @staticmethod + def _build_safe_module_name(plugin_id: str) -> str: + """将插件 ID 转换为可用于动态导入的安全模块名。 + + Args: + plugin_id: 原始插件 ID。 + + Returns: + str: 仅包含字母、数字和下划线的合成模块名。 + """ + normalized_plugin_id = re.sub(r"[^0-9A-Za-z_]", "_", str(plugin_id or "").strip()) + if normalized_plugin_id and normalized_plugin_id[0].isdigit(): + normalized_plugin_id = f"_{normalized_plugin_id}" + return f"_maibot_plugin_{normalized_plugin_id or 'plugin'}" + def list_plugins(self) -> List[str]: """列出所有已加载的插件 ID""" return list(self._loaded_plugins.keys()) @@ -286,18 +272,27 @@ class PluginLoader: """返回当前记录的失败插件原因映射。""" return dict(self._failed_plugins) + @property + def manifest_validator(self) -> ManifestValidator: + """返回当前加载器持有的 Manifest 校验器。 + + Returns: + ManifestValidator: 当前使用的 Manifest 校验器实例。 + """ + return self._manifest_validator + # ──── 依赖解析 ──────────────────────────────────────────── def resolve_dependencies( self, candidates: Dict[str, PluginCandidate], - extra_available: Optional[Set[str]] = None, + extra_available: Optional[Dict[str, str]] = None, ) -> Tuple[List[str], Dict[str, str]]: """解析候选插件的依赖顺序。 Args: candidates: 待加载的候选插件集合。 - extra_available: 视为已满足的外部依赖插件 ID 集合。 + extra_available: 视为已满足的外部依赖插件版本映射。 Returns: Tuple[List[str], Dict[str, str]]: 可加载顺序和失败原因映射。 @@ -320,36 +315,71 @@ class PluginLoader: def _resolve_dependencies( self, candidates: Dict[str, PluginCandidate], - extra_available: Optional[Set[str]] = None, + extra_available: Optional[Dict[str, str]] = None, ) -> Tuple[List[str], Dict[str, str]]: """拓扑排序解析加载顺序,返回 (有序列表, 失败项 {id: reason})。""" available = set(candidates.keys()) - satisfied_dependencies = set(extra_available or set()) + satisfied_dependencies = { + str(plugin_id or "").strip(): str(plugin_version or "").strip() + for plugin_id, plugin_version in (extra_available or {}).items() + if str(plugin_id or "").strip() and str(plugin_version or "").strip() + } dep_graph: Dict[str, Set[str]] = {} failed: Dict[str, str] = {} for pid, (_, manifest, _) in candidates.items(): - raw_deps = manifest.get("dependencies", []) resolved: Set[str] = set() - missing: List[str] = [] - for dep in raw_deps: - dep_name = dep if isinstance(dep, str) else str(dep.get("name", "")) - dep_name = dep_name.strip() - if not dep_name or dep_name == pid: + missing_or_incompatible: List[str] = [] + + for dependency in manifest.plugin_dependencies: + dependency_id = dependency.id + if dependency_id in available: + dependency_manifest = candidates[dependency_id][1] + if not self._manifest_validator.is_plugin_dependency_satisfied( + dependency, + dependency_manifest.version, + ): + missing_or_incompatible.append( + f"{dependency_id} (需要 {dependency.version_spec},当前 {dependency_manifest.version})" + ) + continue + resolved.add(dependency_id) continue - if dep_name in available: - resolved.add(dep_name) - elif dep_name in satisfied_dependencies: + + external_dependency_version = satisfied_dependencies.get(dependency_id) + if external_dependency_version is None: + missing_or_incompatible.append(f"{dependency_id} (未找到依赖插件)") continue - else: - missing.append(dep_name) - if missing: - failed[pid] = f"缺少依赖: {', '.join(missing)}" + + if not self._manifest_validator.is_plugin_dependency_satisfied( + dependency, + external_dependency_version, + ): + missing_or_incompatible.append( + f"{dependency_id} (需要 {dependency.version_spec},当前 {external_dependency_version})" + ) + + if missing_or_incompatible: + failed[pid] = f"依赖未满足: {', '.join(missing_or_incompatible)}" dep_graph[pid] = resolved - # 移除失败项 - for pid in failed: - dep_graph.pop(pid, None) + # 迭代传播“依赖自身加载失败”到上游依赖方,避免误报为循环依赖 + changed = True + while changed: + changed = False + failed_plugin_ids = set(failed) + for pid, dependencies in list(dep_graph.items()): + if pid in failed: + dep_graph.pop(pid, None) + continue + + failed_dependencies = sorted(dependency for dependency in dependencies if dependency in failed_plugin_ids) + if not failed_dependencies: + continue + + failed[pid] = f"依赖未满足: {', '.join(f'{dependency} (依赖插件加载失败)' for dependency in failed_dependencies)}" + dep_graph.pop(pid, None) + changed = True # Kahn 拓扑排序 indegree = {pid: len(deps) for pid, deps in dep_graph.items()} @@ -382,7 +412,7 @@ class PluginLoader: self, plugin_id: str, plugin_dir: Path, - manifest: Dict[str, Any], + manifest: PluginManifest, plugin_path: Path, ) -> Optional[PluginMeta]: """加载单个插件""" @@ -390,7 +420,7 @@ class PluginLoader: self._ensure_compat_hook() # 动态导入插件模块 - module_name = f"_maibot_plugin_{plugin_id}" + module_name = self._build_safe_module_name(plugin_id) spec = importlib.util.spec_from_file_location(module_name, str(plugin_path)) if spec is None or spec.loader is None: logger.error(f"无法创建模块 spec: {plugin_path}") @@ -409,7 +439,7 @@ class PluginLoader: if create_plugin is not None: instance = create_plugin() self._validate_sdk_plugin_contract(plugin_id, instance) - logger.info(f"插件 {plugin_id} v{manifest.get('version', '?')} 加载成功") + logger.info(f"插件 {plugin_id} v{manifest.version} 加载成功") return PluginMeta( plugin_id=plugin_id, plugin_dir=str(plugin_dir), @@ -422,7 +452,7 @@ class PluginLoader: instance = self._try_load_legacy_plugin(module, plugin_id) if instance is not None: logger.info( - f"插件 {plugin_id} v{manifest.get('version', '?')} 通过旧版兼容层加载成功(请尽快迁移到 maibot_sdk)" + f"插件 {plugin_id} v{manifest.version} 通过旧版兼容层加载成功(请尽快迁移到 maibot_sdk)" ) return PluginMeta( plugin_id=plugin_id, diff --git a/src/plugin_runtime/runner/runner_main.py b/src/plugin_runtime/runner/runner_main.py index c0f5e771..f5c32f7e 100644 --- a/src/plugin_runtime/runner/runner_main.py +++ b/src/plugin_runtime/runner/runner_main.py @@ -10,7 +10,7 @@ """ from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Protocol, Set, Tuple, cast +from typing import Any, Callable, Dict, List, Optional, Protocol, Set, cast import asyncio import contextlib @@ -47,7 +47,7 @@ from src.plugin_runtime.protocol.envelope import ( ) from src.plugin_runtime.protocol.errors import ErrorCode from src.plugin_runtime.runner.log_handler import RunnerIPCLogHandler -from src.plugin_runtime.runner.plugin_loader import PluginLoader, PluginMeta +from src.plugin_runtime.runner.plugin_loader import PluginCandidate, PluginLoader, PluginMeta from src.plugin_runtime.runner.rpc_client import RPCClient logger = get_logger("plugin_runtime.runner.main") @@ -119,7 +119,7 @@ class PluginRunner: host_address: str, session_token: str, plugin_dirs: List[str], - external_available_plugin_ids: Optional[List[str]] = None, + external_available_plugins: Optional[Dict[str, str]] = None, ) -> None: """初始化 Runner。 @@ -127,15 +127,15 @@ class PluginRunner: host_address: Host 的 IPC 地址。 session_token: 握手用会话令牌。 plugin_dirs: 当前 Runner 负责扫描的插件目录列表。 - external_available_plugin_ids: 视为已满足的外部依赖插件 ID 列表。 + external_available_plugins: 视为已满足的外部依赖插件版本映射。 """ self._host_address: str = host_address self._session_token: str = session_token self._plugin_dirs: List[str] = plugin_dirs - self._external_available_plugin_ids: Set[str] = { - str(plugin_id or "").strip() - for plugin_id in (external_available_plugin_ids or []) - if str(plugin_id or "").strip() + self._external_available_plugins: Dict[str, str] = { + str(plugin_id or "").strip(): str(plugin_version or "").strip() + for plugin_id, plugin_version in (external_available_plugins or {}).items() + if str(plugin_id or "").strip() and str(plugin_version or "").strip() } self._rpc_client: RPCClient = RPCClient(host_address, session_token) @@ -166,7 +166,7 @@ class PluginRunner: # 3. 加载插件 plugins = self._loader.discover_and_load( self._plugin_dirs, - extra_available=self._external_available_plugin_ids, + extra_available=self._external_available_plugins, ) logger.info(f"已加载 {len(plugins)} 个插件") @@ -611,14 +611,14 @@ class PluginRunner: self, plugin_id: str, reason: str, - external_available_plugins: Optional[Set[str]] = None, + external_available_plugins: Optional[Dict[str, str]] = None, ) -> ReloadPluginResultPayload: """按插件 ID 在 Runner 进程内执行精确重载。 Args: plugin_id: 目标插件 ID。 reason: 重载原因。 - external_available_plugins: 视为已满足的外部依赖插件 ID 集合。 + external_available_plugins: 视为已满足的外部依赖插件版本映射。 Returns: ReloadPluginResultPayload: 结构化重载结果。 @@ -626,9 +626,9 @@ class PluginRunner: candidates, duplicate_candidates = self._loader.discover_candidates(self._plugin_dirs) failed_plugins: Dict[str, str] = {} normalized_external_available = { - str(candidate_plugin_id or "").strip() - for candidate_plugin_id in (external_available_plugins or set()) - if str(candidate_plugin_id or "").strip() + str(candidate_plugin_id or "").strip(): str(candidate_plugin_version or "").strip() + for candidate_plugin_id, candidate_plugin_version in (external_available_plugins or {}).items() + if str(candidate_plugin_id or "").strip() and str(candidate_plugin_version or "").strip() } if plugin_id in duplicate_candidates: @@ -668,7 +668,7 @@ class PluginRunner: self._loader.purge_plugin_modules(unload_plugin_id, meta.plugin_dir) unloaded_plugins.append(unload_plugin_id) - reload_candidates: Dict[str, Tuple[Path, Dict[str, Any], Path]] = {} + reload_candidates: Dict[str, PluginCandidate] = {} for target_plugin_id in target_plugin_ids: candidate = candidates.get(target_plugin_id) if candidate is None: @@ -678,11 +678,25 @@ class PluginRunner: load_order, dependency_failures = self._loader.resolve_dependencies( reload_candidates, - extra_available=retained_plugin_ids | normalized_external_available, + extra_available={ + **normalized_external_available, + **{ + retained_plugin_id: retained_meta.version + for retained_plugin_id in retained_plugin_ids + if (retained_meta := self._loader.get_plugin(retained_plugin_id)) is not None + }, + }, ) failed_plugins.update(dependency_failures) - available_plugins = set(retained_plugin_ids) | normalized_external_available + available_plugins = { + **normalized_external_available, + **{ + retained_plugin_id: retained_meta.version + for retained_plugin_id in retained_plugin_ids + if (retained_meta := self._loader.get_plugin(retained_plugin_id)) is not None + }, + } reloaded_plugins: List[str] = [] for load_plugin_id in load_order: @@ -694,10 +708,12 @@ class PluginRunner: continue _, manifest, _ = candidate - dependencies = PluginMeta._extract_dependencies(manifest) - missing_dependencies = [dependency for dependency in dependencies if dependency not in available_plugins] - if missing_dependencies: - failed_plugins[load_plugin_id] = f"依赖未满足: {', '.join(missing_dependencies)}" + unsatisfied_dependencies = self._loader.manifest_validator.get_unsatisfied_plugin_dependencies( + manifest, + available_plugin_versions=available_plugins, + ) + if unsatisfied_dependencies: + failed_plugins[load_plugin_id] = f"依赖未满足: {', '.join(unsatisfied_dependencies)}" continue meta = self._loader.load_candidate(load_plugin_id, candidate) @@ -710,7 +726,7 @@ class PluginRunner: failed_plugins[load_plugin_id] = "插件初始化失败" continue - available_plugins.add(load_plugin_id) + available_plugins[load_plugin_id] = meta.version reloaded_plugins.append(load_plugin_id) if failed_plugins: @@ -1079,7 +1095,7 @@ class PluginRunner: result = await self._reload_plugin_by_id( payload.plugin_id, payload.reason, - external_available_plugins=set(payload.external_available_plugins), + external_available_plugins=dict(payload.external_available_plugins), ) return envelope.make_response(payload=result.model_dump()) @@ -1185,13 +1201,13 @@ async def _async_main() -> None: plugin_dirs = [d for d in plugin_dirs_str.split(os.pathsep) if d] try: - external_plugin_ids = json.loads(external_plugin_ids_raw) if external_plugin_ids_raw else [] + external_plugin_ids = json.loads(external_plugin_ids_raw) if external_plugin_ids_raw else {} except json.JSONDecodeError: - logger.warning("解析外部依赖插件列表失败,已回退为空列表") - external_plugin_ids = [] - if not isinstance(external_plugin_ids, list): - logger.warning("外部依赖插件列表格式非法,已回退为空列表") - external_plugin_ids = [] + logger.warning("解析外部依赖插件版本映射失败,已回退为空映射") + external_plugin_ids = {} + if not isinstance(external_plugin_ids, dict): + logger.warning("外部依赖插件版本映射格式非法,已回退为空映射") + external_plugin_ids = {} # sys.path 隔离: 只保留标准库、SDK 包、插件目录 _isolate_sys_path(plugin_dirs) @@ -1200,7 +1216,10 @@ async def _async_main() -> None: host_address, session_token, plugin_dirs, - external_available_plugin_ids=[str(plugin_id) for plugin_id in external_plugin_ids], + external_available_plugins={ + str(plugin_id): str(plugin_version) + for plugin_id, plugin_version in external_plugin_ids.items() + }, ) # 注册信号处理 diff --git a/src/plugins/built_in/emoji_plugin/_manifest.json b/src/plugins/built_in/emoji_plugin/_manifest.json index d4d262e7..5b53abad 100644 --- a/src/plugins/built_in/emoji_plugin/_manifest.json +++ b/src/plugins/built_in/emoji_plugin/_manifest.json @@ -1,32 +1,28 @@ { - "manifest_version": 1, - "name": "Emoji插件 (Emoji Actions)", + "manifest_version": 2, "version": "2.0.0", - "description": "可以发送和管理Emoji", + "name": "Emoji插件 (Emoji Actions)", + "description": "可以发送和管理 Emoji", "author": { "name": "SengokuCola", "url": "https://github.com/MaiM-with-u" }, "license": "GPL-v3.0-or-later", + "urls": { + "repository": "https://github.com/MaiM-with-u/maibot", + "homepage": "https://github.com/MaiM-with-u/maibot", + "documentation": "https://github.com/MaiM-with-u/maibot", + "issues": "https://github.com/MaiM-with-u/maibot/issues" + }, "host_application": { - "min_version": "1.0.0" + "min_version": "1.0.0", + "max_version": "1.0.0" }, - "homepage_url": "https://github.com/MaiM-with-u/maibot", - "repository_url": "https://github.com/MaiM-with-u/maibot", - "keywords": ["emoji", "action", "built-in"], - "categories": ["Emoji"], - "default_locale": "zh-CN", - "plugin_info": { - "is_built_in": true, - "plugin_type": "action_provider", - "components": [ - { - "type": "action", - "name": "emoji", - "description": "发送表情包辅助表达情绪" - } - ] + "sdk": { + "min_version": "2.0.0", + "max_version": "2.99.99" }, + "dependencies": [], "capabilities": [ "emoji.get_random", "message.get_recent", @@ -34,5 +30,12 @@ "llm.generate", "send.emoji", "config.get" - ] + ], + "i18n": { + "default_locale": "zh-CN", + "supported_locales": [ + "zh-CN" + ] + }, + "id": "builtin.emoji-plugin" } diff --git a/src/plugins/built_in/plugin_management/_manifest.json b/src/plugins/built_in/plugin_management/_manifest.json index a5b52835..a2bfa9ce 100644 --- a/src/plugins/built_in/plugin_management/_manifest.json +++ b/src/plugins/built_in/plugin_management/_manifest.json @@ -1,51 +1,46 @@ { - "manifest_version": 1, - "name": "插件和组件管理 (Plugin and Component Management)", + "manifest_version": 2, "version": "2.0.0", - "description": "通过系统API管理插件和组件的生命周期,包括加载、卸载、启用和禁用等操作。", + "name": "插件和组件管理 (Plugin and Component Management)", + "description": "通过系统 API 管理插件和组件的生命周期,包括加载、卸载、启用和禁用等操作。", "author": { "name": "MaiBot团队", "url": "https://github.com/MaiM-with-u" }, "license": "GPL-v3.0-or-later", - "host_application": { - "min_version": "1.0.0" + "urls": { + "repository": "https://github.com/MaiM-with-u/maibot", + "homepage": "https://github.com/MaiM-with-u/maibot", + "documentation": "https://github.com/MaiM-with-u/maibot", + "issues": "https://github.com/MaiM-with-u/maibot/issues" }, - "homepage_url": "https://github.com/MaiM-with-u/maibot", - "repository_url": "https://github.com/MaiM-with-u/maibot", - "keywords": [ - "plugins", - "components", - "management", - "built-in" + "host_application": { + "min_version": "1.0.0", + "max_version": "1.0.0" + }, + "sdk": { + "min_version": "2.0.0", + "max_version": "2.99.99" + }, + "dependencies": [], + "capabilities": [ + "component.get_all_plugins", + "component.list_loaded_plugins", + "component.list_registered_plugins", + "component.enable", + "component.disable", + "component.load_plugin", + "component.unload_plugin", + "component.reload_plugin", + "send.text", + "config.get" ], - "categories": [ - "Core System", - "Plugin Management" - ], - "default_locale": "zh-CN", - "locales_path": "_locales", - "plugin_info": { - "is_built_in": true, - "plugin_type": "plugin_management", - "capabilities": [ - "component.get_all_plugins", - "component.list_loaded_plugins", - "component.list_registered_plugins", - "component.enable", - "component.disable", - "component.load_plugin", - "component.unload_plugin", - "component.reload_plugin", - "send.text", - "config.get" - ], - "components": [ - { - "type": "command", - "name": "management", - "description": "管理插件和组件的生命周期,包括加载、卸载、启用和禁用等操作。" - } + "i18n": { + "default_locale": "zh-CN", + "locales_path": "_locales", + "supported_locales": [ + "zh-CN" ] - } -} \ No newline at end of file + }, + "id": "builtin.plugin-management" +}