From 21ccd08bc43006bc59bdd77ff0fb671b05af7476 Mon Sep 17 00:00:00 2001 From: adityaanikam Date: Sat, 3 Oct 2026 00:58:59 +0530 Subject: [PATCH] fix(plugin): honor plugin_blacklist and plugin_whitelist when loading The CLI commands "plugin enable" and "plugin disable" edit plugin_blacklist and plugin_whitelist in the config, but PluginLoader never read them, so a disabled plugin was still loaded at startup. Check both lists in PluginLoader.load_plugin, which load_all, load_selected and hot reload all go through. A blacklisted plugin is skipped, and when the whitelist is not empty only plugins in it are loaded. The blacklist wins if a name is in both, which matches the status shown by "ncatbot plugin list". Built-in plugins are not affected. Fixes #58 --- ncatbot/plugin/loader/core.py | 12 +++++++ tests/README.md | 4 +-- tests/unit/plugin/test_plugin_loader.py | 44 ++++++++++++++++++++++++- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/ncatbot/plugin/loader/core.py b/ncatbot/plugin/loader/core.py index 92b00ff7..9a010454 100644 --- a/ncatbot/plugin/loader/core.py +++ b/ncatbot/plugin/loader/core.py @@ -174,6 +174,10 @@ async def run_startup(self) -> None: async def load_plugin(self, name: str) -> Optional[BasePlugin]: """加载单个插件(必须已索引)。""" + if not self._is_plugin_enabled(name): + LOG.info("跳过插件 %s(已在配置中禁用)", name) + return None + manifest = self._indexer.get(name) if manifest is None: LOG.error("插件 %s 未索引,无法加载", name) @@ -410,6 +414,14 @@ async def load_builtin_plugins(self) -> None: # 内部方法 # ------------------------------------------------------------------ + def _is_plugin_enabled(self, name: str) -> bool: + """按配置中的黑白名单判断插件是否允许加载(黑名单优先)。""" + plugin_config = get_config_manager().plugin + if name in plugin_config.plugin_blacklist: + return False + whitelist = plugin_config.plugin_whitelist + return not whitelist or name in whitelist + async def _check_pip_deps_batch(self, manifests: Dict[str, PluginManifest]) -> set: """批量检查所有插件的 pip 依赖,返回应跳过的插件名集合。""" config = get_config_manager() diff --git a/tests/README.md b/tests/README.md index 558919a0..f09080c9 100644 --- a/tests/README.md +++ b/tests/README.md @@ -12,7 +12,7 @@ tests/ │ ├── api/ # API 客户端 + 错误层级 + Sugar (A-01 ~ A-02, AE-01 ~ AE-07, SG-01 ~ SG-06, FL-01 ~ FL-06) │ ├── core/ # 核心分发与注册 + 谓词 (D-01 ~ D-09, K-01 ~ K-21, H-01 ~ H-11, R-01 ~ R-09, PR-01 ~ PR-06, CSE-01 ~ CSE-02) │ ├── service/ # 服务管理 + RBAC + 调度 (SM-01 ~ SM-08, SC-01 ~ SC-12, TS-01 ~ TS-06) -│ ├── plugin/ # 插件 Mixin + 导入去重 + Loader (M-01 ~ M-41, ID-01 ~ ID-02, LD-01 ~ LD-05) +│ ├── plugin/ # 插件 Mixin + 导入去重 + Loader (M-01 ~ M-41, ID-01 ~ ID-02, LD-01 ~ LD-09) │ ├── adapter/ # 适配器解析 + 注册表 + 真实数据 + 事件日志格式 (P-01 ~ P-07, RF-01 ~ RF-08, AR-01 ~ AR-05, SL-01 ~ SL-04, GM-01 ~ GM-05, BL-01 ~ BL-25, GH-01 ~ GH-11, LK-01 ~ LK-09, LKP-01 ~ LKP-10, ELS-01 ~ ELS-17) │ ├── config/ # 配置迁移 + 安全 + 分层 + 事件日志格式 (CF-01 ~ CF-05, CS-01 ~ CS-05, CE-01 ~ CE-05, BQ-01 ~ BQ-11, AI-03 ~ AI-31, ELF-01 ~ ELF-06) │ ├── cli/ # CLI 冒烟 (CX-01 ~ CX-22) @@ -89,7 +89,7 @@ python tests/e2e/napcat/run.py | SC | RBAC 权限系统 | SC-01 ~ SC-12 | | PR | Predicate 谓词 DSL | PR-01 ~ PR-06 | | TS | TimeTaskParser 调度解析 | TS-01 ~ TS-06 | -| LD | PluginLoader 生命周期 | LD-01 ~ LD-08 | +| LD | PluginLoader 生命周期 | LD-01 ~ LD-09 | | CS | Config 安全检查 | CS-01 ~ CS-05 | | SG | QQ Sugar 便捷方法 | SG-01 ~ SG-06 | | MA | MessageArray 容器 | MA-01 ~ MA-04 | diff --git a/tests/unit/plugin/test_plugin_loader.py b/tests/unit/plugin/test_plugin_loader.py index f58842a7..9af575a5 100644 --- a/tests/unit/plugin/test_plugin_loader.py +++ b/tests/unit/plugin/test_plugin_loader.py @@ -1,7 +1,7 @@ """ Plugin Loader 生命周期单元测试 -LD-01 ~ LD-08:覆盖 load/unload/load_all/热重载消费 的核心路径。 +LD-01 ~ LD-09:覆盖 load/unload/load_all/热重载消费 的核心路径。 """ import asyncio @@ -347,3 +347,45 @@ async def mock_load(name): task.cancel() with pytest.raises(asyncio.CancelledError): await task + + +class TestPluginEnableConfig: + """LD-09: plugin_blacklist / plugin_whitelist 控制插件是否加载""" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("blacklist", "whitelist", "expected"), + [ + (["b"], [], {"a", "c"}), + ([], ["a", "c"], {"a", "c"}), + (["a"], ["a", "b"], {"b"}), + ], + ) + async def test_ld09_blacklist_whitelist_skip_load( + self, loader: PluginLoader, blacklist, whitelist, expected + ): + """LD-09: load_all 跳过黑名单中的插件,白名单非空时只加载白名单内的插件""" + manifests = {name: _make_manifest(name) for name in ("a", "b", "c")} + loader._indexer._manifests = manifests + + config = MagicMock() + config.plugin.plugin_blacklist = blacklist + config.plugin.plugin_whitelist = whitelist + + with ( + patch("ncatbot.plugin.loader.core.get_config_manager", return_value=config), + patch.object(loader._indexer, "scan", return_value=manifests), + patch.object(loader._importer, "add_plugin_root"), + patch.object(loader._importer, "load_module") as mock_load, + patch.object( + loader._importer, "find_plugin_class", return_value=_StubPlugin + ), + patch.object(loader, "_check_pip_deps_batch", return_value=set()), + patch("ncatbot.plugin.loader.core.flush_pending"), + patch("ncatbot.plugin.loader.core.clear_pending"), + ): + loaded = await loader.load_all(Path("plugins")) + + assert set(loaded) == expected + assert set(loader.plugins) == expected + assert {call.args[0].name for call in mock_load.call_args_list} == expected