Refactor plugin loader and runner to support enhanced manifest structure

- Updated the PluginMeta class to utilize a strongly typed PluginManifest, improving type safety and clarity.
- Refactored dependency extraction logic to streamline the handling of plugin dependencies.
- Modified the PluginLoader to accommodate new manifest versioning and validation processes.
- Enhanced the PluginRunner to work with a dictionary for external available plugins, allowing for version mapping.
- Updated built-in plugins' manifest files to version 2, adding URLs and SDK versioning for better integration and documentation.
- Improved error handling and logging for plugin loading and dependency resolution processes.
This commit is contained in:
DrSmoothl
2026-03-23 22:59:01 +08:00
parent 0c508995dd
commit 1f02171a63
15 changed files with 1676 additions and 711 deletions

View File

@@ -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"
}
"id": "sengokucola.betterfrequency"
}

View File

@@ -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"
}

View File

@@ -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"
}
"id": "sengokucola.betteremoji"
}

View File

@@ -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"
}
"id": "maibot-team.hello-world-plugin"
}

View File

@@ -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"}

View File

@@ -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 对象)"""

View File

@@ -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:

View File

@@ -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(),

View File

@@ -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 与实际目录路径。

View File

@@ -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):

File diff suppressed because it is too large Load Diff

View File

@@ -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,

View File

@@ -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()
},
)
# 注册信号处理

View File

@@ -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"
}

View File

@@ -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"
]
}
}
},
"id": "builtin.plugin-management"
}