From 7b3c12ba0255728dff2d6e4db265f29d234cb6a3 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Wed, 1 Apr 2026 19:39:55 +0800 Subject: [PATCH] feat: add runtime validation for plugin configurations - Introduced a new method `validate_plugin_config` in `PluginRuntimeManager` to validate plugin configurations at runtime. - Implemented the `_normalize_plugin_config` method in `PluginRunner` to normalize and persist plugin configurations. - Enhanced the `PluginRunner` to handle configuration validation requests and return normalized configurations. - Updated the WebUI routes to utilize runtime validation for plugin configurations, ensuring that configurations are validated and normalized before saving. - Added tests for runtime configuration validation and normalization processes, including handling of invalid configurations. --- pytests/test_plugin_config_runtime.py | 253 ++++++++++++++++++ src/plugin_runtime/capabilities/components.py | 47 ++++ src/plugin_runtime/capabilities/registry.py | 1 + src/plugin_runtime/component_query.py | 50 ++++ src/plugin_runtime/host/supervisor.py | 50 +++- src/plugin_runtime/integration.py | 79 ++++-- src/plugin_runtime/protocol/envelope.py | 84 +++++- src/plugin_runtime/runner/runner_main.py | 233 ++++++++++++++-- src/webui/routers/plugin/config_routes.py | 214 ++++++++++++++- 9 files changed, 946 insertions(+), 65 deletions(-) create mode 100644 pytests/test_plugin_config_runtime.py diff --git a/pytests/test_plugin_config_runtime.py b/pytests/test_plugin_config_runtime.py new file mode 100644 index 00000000..df03d343 --- /dev/null +++ b/pytests/test_plugin_config_runtime.py @@ -0,0 +1,253 @@ +"""插件配置运行时测试。""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Dict, Mapping, Optional, Tuple, cast + +import tomllib + +import pytest + +from src.plugin_runtime.component_query import component_query_service +from src.plugin_runtime.protocol.envelope import ( + Envelope, + MessageType, + RegisterPluginPayload, + ValidatePluginConfigPayload, +) +from src.plugin_runtime.runner.runner_main import PluginRunner +from src.webui.routers.plugin.config_routes import update_plugin_config +from src.webui.routers.plugin.schemas import UpdatePluginConfigRequest + + +class _DemoConfigPlugin: + """用于测试 Runner 配置归一化流程的伪插件。""" + + def __init__(self) -> None: + """初始化测试插件状态。""" + + self.received_config: Dict[str, Any] = {} + + def normalize_plugin_config(self, config_data: Optional[Mapping[str, Any]]) -> Tuple[Dict[str, Any], bool]: + """补齐测试插件的默认配置。 + + Args: + config_data: 原始配置数据。 + + Returns: + Tuple[Dict[str, Any], bool]: 补齐后的配置,以及是否发生变更。 + """ + + current_config = dict(config_data or {}) + plugin_section = dict(current_config.get("plugin", {})) + changed = "retry_count" not in plugin_section + plugin_section.setdefault("enabled", True) + plugin_section.setdefault("retry_count", 3) + return {"plugin": plugin_section}, changed + + def set_plugin_config(self, config: Dict[str, Any]) -> None: + """记录 Runner 注入的配置内容。 + + Args: + config: 当前最新配置。 + """ + + self.received_config = config + + +class _StrictConfigPlugin: + """用于测试配置校验错误的伪插件。""" + + def normalize_plugin_config(self, config_data: Optional[Mapping[str, Any]]) -> Tuple[Dict[str, Any], bool]: + """校验重试次数不能为负数。 + + Args: + config_data: 原始配置数据。 + + Returns: + Tuple[Dict[str, Any], bool]: 规范化配置结果。 + + Raises: + ValueError: 当重试次数为负数时抛出。 + """ + + current_config = dict(config_data or {}) + plugin_section = dict(current_config.get("plugin", {})) + retry_count = int(plugin_section.get("retry_count", 0)) + if retry_count < 0: + raise ValueError("重试次数不能小于 0") + plugin_section.setdefault("enabled", True) + return {"plugin": plugin_section}, False + + def set_plugin_config(self, config: Dict[str, Any]) -> None: + """兼容 Runner 配置注入接口。 + + Args: + config: 当前配置字典。 + """ + + del config + + +def test_runner_apply_plugin_config_generates_config_file(tmp_path: Path) -> None: + """Runner 注入配置时应自动补齐并落盘 config.toml。""" + + plugin = _DemoConfigPlugin() + runner = PluginRunner( + host_address="ipc://unused", + session_token="session-token", + plugin_dirs=[], + ) + meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir=str(tmp_path), instance=plugin) + + runner._apply_plugin_config(cast(Any, meta), config_data={"plugin": {"enabled": False}}) + + config_path = tmp_path / "config.toml" + assert config_path.exists() + assert plugin.received_config == {"plugin": {"enabled": False, "retry_count": 3}} + + with config_path.open("rb") as handle: + saved_config = tomllib.load(handle) + assert saved_config == {"plugin": {"enabled": False, "retry_count": 3}} + + +def test_component_query_service_returns_plugin_config_schema(monkeypatch: Any) -> None: + """组件查询服务应支持按插件 ID 返回配置 Schema。""" + + payload = RegisterPluginPayload( + plugin_id="demo.plugin", + plugin_version="1.0.0", + default_config={"plugin": {"enabled": True}}, + config_schema={ + "plugin_id": "demo.plugin", + "plugin_info": { + "name": "Demo", + "version": "1.0.0", + "description": "", + "author": "", + }, + "sections": {"plugin": {"fields": {}}}, + "layout": {"type": "auto", "tabs": []}, + }, + ) + fake_supervisor = SimpleNamespace(_registered_plugins={"demo.plugin": payload}) + fake_manager = SimpleNamespace(_get_supervisor_for_plugin=lambda plugin_id: fake_supervisor) + + monkeypatch.setattr( + type(component_query_service), + "_get_runtime_manager", + staticmethod(lambda: fake_manager), + ) + + assert component_query_service.get_plugin_config_schema("demo.plugin") == payload.config_schema + assert component_query_service.get_plugin_default_config("demo.plugin") == payload.default_config + + +@pytest.mark.asyncio +async def test_runner_validate_plugin_config_handler_returns_normalized_config(monkeypatch: pytest.MonkeyPatch) -> None: + """Runner 应返回插件模型归一化后的配置。""" + + plugin = _DemoConfigPlugin() + runner = PluginRunner( + host_address="ipc://unused", + session_token="session-token", + plugin_dirs=[], + ) + meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir="", instance=plugin) + monkeypatch.setattr(runner._loader, "get_plugin", lambda plugin_id: meta if plugin_id == "demo.plugin" else None) + + envelope = Envelope( + request_id=1, + message_type=MessageType.REQUEST, + method="plugin.validate_config", + plugin_id="demo.plugin", + payload=ValidatePluginConfigPayload(config_data={"plugin": {"enabled": False}}).model_dump(), + ) + + response = await runner._handle_validate_plugin_config(envelope) + + assert response.error is None + assert response.payload["success"] is True + assert response.payload["normalized_config"] == {"plugin": {"enabled": False, "retry_count": 3}} + + +@pytest.mark.asyncio +async def test_runner_validate_plugin_config_handler_returns_error_on_invalid_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Runner 应在插件拒绝配置时返回错误响应。""" + + plugin = _StrictConfigPlugin() + runner = PluginRunner( + host_address="ipc://unused", + session_token="session-token", + plugin_dirs=[], + ) + meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir="", instance=plugin) + monkeypatch.setattr(runner._loader, "get_plugin", lambda plugin_id: meta if plugin_id == "demo.plugin" else None) + + envelope = Envelope( + request_id=1, + message_type=MessageType.REQUEST, + method="plugin.validate_config", + plugin_id="demo.plugin", + payload=ValidatePluginConfigPayload(config_data={"plugin": {"retry_count": -1}}).model_dump(), + ) + + response = await runner._handle_validate_plugin_config(envelope) + + assert response.error is not None + assert response.error["message"] == "重试次数不能小于 0" + + +@pytest.mark.asyncio +async def test_update_plugin_config_prefers_runtime_validation( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """WebUI 保存插件配置时应优先使用运行时校验结果。""" + + config_path = tmp_path / "config.toml" + + async def _mock_validate_plugin_config(plugin_id: str, config_data: Dict[str, Any]) -> Dict[str, Any] | None: + """返回运行时归一化后的配置。 + + Args: + plugin_id: 插件 ID。 + config_data: 原始配置。 + + Returns: + Dict[str, Any] | None: 归一化后的配置。 + """ + + assert plugin_id == "demo.plugin" + assert config_data == {"plugin": {"enabled": False}} + return {"plugin": {"enabled": False, "retry_count": 3}} + + fake_runtime_manager = SimpleNamespace(validate_plugin_config=_mock_validate_plugin_config) + + monkeypatch.setattr( + "src.webui.routers.plugin.config_routes.require_plugin_token", + lambda session: session or "session-token", + ) + monkeypatch.setattr( + "src.webui.routers.plugin.config_routes.find_plugin_path_by_id", + lambda plugin_id: tmp_path if plugin_id == "demo.plugin" else None, + ) + monkeypatch.setattr( + "src.plugin_runtime.integration.get_plugin_runtime_manager", + lambda: fake_runtime_manager, + ) + + response = await update_plugin_config( + "demo.plugin", + UpdatePluginConfigRequest(config={"plugin.enabled": False}), + maibot_session="session-token", + ) + + assert response["success"] is True + with config_path.open("rb") as handle: + saved_config = tomllib.load(handle) + assert saved_config == {"plugin": {"enabled": False, "retry_count": 3}} diff --git a/src/plugin_runtime/capabilities/components.py b/src/plugin_runtime/capabilities/components.py index 1e1827cf..a3caebf9 100644 --- a/src/plugin_runtime/capabilities/components.py +++ b/src/plugin_runtime/capabilities/components.py @@ -458,6 +458,17 @@ class RuntimeComponentCapabilityMixin: async def _cap_component_get_plugin_info( self: _RuntimeComponentManagerProtocol, plugin_id: str, capability: str, args: Dict[str, Any] ) -> Any: + """获取指定插件的基础信息。 + + Args: + plugin_id: 当前调用方插件 ID。 + capability: 当前能力名称。 + args: 能力调用参数。 + + Returns: + Any: 插件基础信息响应。 + """ + plugin_name: str = args.get("plugin_name", plugin_id) try: sv = self._get_supervisor_for_plugin(plugin_name) @@ -473,10 +484,46 @@ class RuntimeComponentCapabilityMixin: "description": "", "author": "", "enabled": True, + "default_config": reg.default_config, + "config_schema": reg.config_schema, }, } return {"success": False, "error": f"未找到插件: {plugin_name}"} + async def _cap_component_get_plugin_config_schema( + self: _RuntimeComponentManagerProtocol, plugin_id: str, capability: str, args: Dict[str, Any] + ) -> Any: + """获取指定插件注册时上报的配置 Schema。 + + Args: + plugin_id: 当前调用方插件 ID。 + capability: 当前能力名称。 + args: 能力调用参数。 + + Returns: + Any: 包含配置 Schema 与默认配置的响应。 + """ + + plugin_name: str = args.get("plugin_name", plugin_id) + try: + sv = self._get_supervisor_for_plugin(plugin_name) + except RuntimeError as exc: + return {"success": False, "error": str(exc)} + + if sv is None: + return {"success": False, "error": f"未找到插件: {plugin_name}"} + + registration = sv._registered_plugins.get(plugin_name) + if registration is None: + return {"success": False, "error": f"未找到插件: {plugin_name}"} + + return { + "success": True, + "plugin_id": plugin_name, + "schema": registration.config_schema, + "default_config": registration.default_config, + } + async def _cap_component_list_loaded_plugins( self: _RuntimeComponentManagerProtocol, plugin_id: str, capability: str, args: Dict[str, Any] ) -> Any: diff --git a/src/plugin_runtime/capabilities/registry.py b/src/plugin_runtime/capabilities/registry.py index 7f87604d..a4aed9bd 100644 --- a/src/plugin_runtime/capabilities/registry.py +++ b/src/plugin_runtime/capabilities/registry.py @@ -81,6 +81,7 @@ def register_capability_impls(manager: "PluginRuntimeManager", supervisor: Plugi _register("component.get_all_plugins", manager._cap_component_get_all_plugins) _register("component.get_plugin_info", manager._cap_component_get_plugin_info) + _register("component.get_plugin_config_schema", manager._cap_component_get_plugin_config_schema) _register("component.list_loaded_plugins", manager._cap_component_list_loaded_plugins) _register("component.list_registered_plugins", manager._cap_component_list_registered_plugins) _register("component.enable", manager._cap_component_enable) diff --git a/src/plugin_runtime/component_query.py b/src/plugin_runtime/component_query.py index e2ba7366..366c3c0f 100644 --- a/src/plugin_runtime/component_query.py +++ b/src/plugin_runtime/component_query.py @@ -858,5 +858,55 @@ class ComponentQueryService: logger.error(f"读取插件 {plugin_name} 配置失败: {exc}", exc_info=True) return None + def get_plugin_default_config(self, plugin_name: str) -> Optional[dict]: + """获取指定插件注册时上报的默认配置。 + + Args: + plugin_name: 插件名称。 + + Returns: + Optional[dict]: 默认配置字典;未找到时返回 ``None``。 + """ + + runtime_manager = self._get_runtime_manager() + try: + supervisor = runtime_manager._get_supervisor_for_plugin(plugin_name) + except RuntimeError as exc: + logger.error(f"读取插件默认配置失败: {exc}") + return None + + if supervisor is None: + return None + + registration = supervisor._registered_plugins.get(plugin_name) + if registration is None: + return None + return dict(registration.default_config) + + def get_plugin_config_schema(self, plugin_name: str) -> Optional[dict]: + """获取指定插件注册时上报的配置 Schema。 + + Args: + plugin_name: 插件名称。 + + Returns: + Optional[dict]: 配置 Schema;未找到时返回 ``None``。 + """ + + runtime_manager = self._get_runtime_manager() + try: + supervisor = runtime_manager._get_supervisor_for_plugin(plugin_name) + except RuntimeError as exc: + logger.error(f"读取插件配置 Schema 失败: {exc}") + return None + + if supervisor is None: + return None + + registration = supervisor._registered_plugins.get(plugin_name) + if registration is None: + return None + return dict(registration.config_schema) + component_query_service = ComponentQueryService() diff --git a/src/plugin_runtime/host/supervisor.py b/src/plugin_runtime/host/supervisor.py index c94fcb3f..7a023167 100644 --- a/src/plugin_runtime/host/supervisor.py +++ b/src/plugin_runtime/host/supervisor.py @@ -39,6 +39,8 @@ from src.plugin_runtime.protocol.envelope import ( RunnerReadyPayload, ShutdownPayload, UnregisterPluginPayload, + ValidatePluginConfigPayload, + ValidatePluginConfigResultPayload, ) from src.plugin_runtime.protocol.codec import MsgPackCodec from src.plugin_runtime.protocol.errors import ErrorCode, RPCError @@ -59,6 +61,7 @@ if TYPE_CHECKING: logger = get_logger("plugin_runtime.host.runner_manager") + @dataclass(slots=True) class _MessageGatewayRuntimeState: """保存消息网关当前的运行时连接状态。""" @@ -100,9 +103,7 @@ class PluginRunnerSupervisor: self._group_name: str = str(group_name or "third_party").strip() or "third_party" self._plugin_dirs: List[Path] = plugin_dirs or [] self._health_interval: float = health_check_interval_sec or runtime_config.health_check_interval_sec or 30.0 - self._runner_spawn_timeout: float = ( - runner_spawn_timeout_sec or runtime_config.runner_spawn_timeout_sec or 30.0 - ) + self._runner_spawn_timeout: float = runner_spawn_timeout_sec or runtime_config.runner_spawn_timeout_sec or 30.0 self._max_restart_attempts: int = max_restart_attempts or runtime_config.max_restart_attempts or 3 self._transport = create_transport_server(socket_path=socket_path) @@ -200,10 +201,7 @@ class PluginRunnerSupervisor: Returns: Dict[str, str]: 已注册插件版本映射,键为插件 ID,值为插件版本。 """ - return { - plugin_id: registration.plugin_version - for plugin_id, registration in self._registered_plugins.items() - } + return {plugin_id: registration.plugin_version for plugin_id, registration in self._registered_plugins.items()} @staticmethod def _normalize_reload_plugin_ids(plugin_ids: Optional[List[str] | str]) -> List[str]: @@ -550,6 +548,39 @@ class PluginRunnerSupervisor: return bool(response.payload.get("acknowledged", False)) + async def validate_plugin_config(self, plugin_id: str, config_data: Dict[str, Any]) -> Dict[str, Any]: + """请求 Runner 使用插件自身配置模型校验配置。 + + Args: + plugin_id: 目标插件 ID。 + config_data: 待校验的配置内容。 + + Returns: + Dict[str, Any]: 插件模型归一化后的配置字典。 + + Raises: + ValueError: 插件拒绝该配置或校验失败时抛出。 + """ + + payload = ValidatePluginConfigPayload(config_data=config_data) + try: + response = await self._rpc_server.send_request( + "plugin.validate_config", + plugin_id=plugin_id, + payload=payload.model_dump(), + timeout_ms=10000, + ) + except Exception as exc: + raise ValueError(f"插件配置校验请求失败: {exc}") from exc + + if response.error: + raise ValueError(str(response.error.get("message", "插件配置校验失败"))) + + result = ValidatePluginConfigResultPayload.model_validate(response.payload) + if not result.success: + raise ValueError("插件配置校验失败") + return dict(result.normalized_config) + def get_config_reload_subscribers(self, scope: str) -> List[str]: """返回订阅指定全局配置广播的插件列表。 @@ -608,6 +639,7 @@ class PluginRunnerSupervisor: Raises: TimeoutError: 在超时时间内 Runner 未完成初始化。 """ + async def wait_for_ready() -> RunnerReadyPayload: """轮询等待 Runner 上报就绪。""" while True: @@ -1058,7 +1090,9 @@ class PluginRunnerSupervisor: route_key = RouteKey(platform=platform) route_account_id, route_scope = RouteKeyFactory.extract_components(route_metadata) - account_id = route_key.account_id or route_account_id or runtime_state.account_id or gateway_entry.account_id or None + account_id = ( + route_key.account_id or route_account_id or runtime_state.account_id or gateway_entry.account_id or None + ) scope = route_key.scope or route_scope or runtime_state.scope or gateway_entry.scope or None return RouteKey( platform=platform, diff --git a/src/plugin_runtime/integration.py b/src/plugin_runtime/integration.py index 264c8ed2..deecaba8 100644 --- a/src/plugin_runtime/integration.py +++ b/src/plugin_runtime/integration.py @@ -9,7 +9,20 @@ """ from pathlib import Path -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, Dict, Iterable, List, Optional, Sequence, Set, Tuple +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + Coroutine, + Dict, + Iterable, + List, + Optional, + Sequence, + Set, + Tuple, +) import asyncio @@ -364,9 +377,7 @@ class PluginRuntimeManager( """构建当前已注册插件到所属 Supervisor 的映射。""" return { - plugin_id: supervisor - for supervisor in self.supervisors - for plugin_id in supervisor.get_loaded_plugin_ids() + plugin_id: supervisor for supervisor in self.supervisors for plugin_id in supervisor.get_loaded_plugin_ids() } def _build_external_available_plugins_for_supervisor(self, target_supervisor: "PluginSupervisor") -> Dict[str, str]: @@ -411,9 +422,7 @@ class PluginRuntimeManager( local_plugin_ids = set(supervisor.get_loaded_plugin_ids()) local_dependency_map = { plugin_id: { - dependency - for dependency in dependency_map.get(plugin_id, set()) - if dependency in local_plugin_ids + dependency for dependency in dependency_map.get(plugin_id, set()) if dependency in local_plugin_ids } for plugin_id in local_plugin_ids } @@ -440,9 +449,7 @@ class PluginRuntimeManager( """ normalized_plugin_ids = [ - normalized_plugin_id - for plugin_id in plugin_ids - if (normalized_plugin_id := str(plugin_id or "").strip()) + normalized_plugin_id for plugin_id in plugin_ids if (normalized_plugin_id := str(plugin_id or "").strip()) ] if not normalized_plugin_ids: return True @@ -518,9 +525,7 @@ class PluginRuntimeManager( return False config_payload = ( - config_data - if config_data is not None - else self._load_plugin_config_for_supervisor(sv, plugin_id) + config_data if config_data is not None else self._load_plugin_config_for_supervisor(sv, plugin_id) ) return await sv.notify_plugin_config_updated( plugin_id=plugin_id, @@ -529,6 +534,41 @@ class PluginRuntimeManager( config_scope=config_scope, ) + async def validate_plugin_config(self, plugin_id: str, config_data: Dict[str, Any]) -> Dict[str, Any] | None: + """请求运行时按插件自身配置模型校验配置。 + + Args: + plugin_id: 目标插件 ID。 + config_data: 待校验的配置内容。 + + Returns: + Dict[str, Any] | None: 校验成功时返回规范化后的配置;若插件当前未加载 + 或运行时不可用,则返回 ``None`` 以便调用方回退到静态 Schema 方案。 + + Raises: + ValueError: 插件已加载,但配置校验失败时抛出。 + """ + + if not self._started: + return None + + try: + supervisor = self._get_supervisor_for_plugin(plugin_id) + except RuntimeError as exc: + logger.warning(f"插件 {plugin_id} 配置校验路由失败,将回退到静态 Schema: {exc}") + return None + + if supervisor is None: + return None + + try: + return await supervisor.validate_plugin_config(plugin_id, config_data) + except ValueError: + raise + except Exception as exc: + logger.warning(f"插件 {plugin_id} 运行时配置校验不可用,将回退到静态 Schema: {exc}") + return None + @staticmethod def _normalize_config_reload_scopes(changed_scopes: Sequence[str]) -> tuple[str, ...]: """规范化配置热重载范围列表。 @@ -869,7 +909,9 @@ class PluginRuntimeManager( if self._plugin_dir_matches(cached_path, Path(plugin_dir)): return cached_path - for candidate_plugin_id, plugin_path in self._iter_discovered_plugin_paths(getattr(supervisor, "_plugin_dirs", [])): + for candidate_plugin_id, plugin_path in self._iter_discovered_plugin_paths( + getattr(supervisor, "_plugin_dirs", []) + ): if candidate_plugin_id != plugin_id: continue self._plugin_path_cache[plugin_id] = plugin_path @@ -908,9 +950,7 @@ class PluginRuntimeManager( ) self._plugin_config_watcher_subscriptions[plugin_id] = (config_path, subscription_id) - def _build_plugin_config_change_callback( - self, plugin_id: str - ) -> Callable[[Sequence[FileChange]], Awaitable[None]]: + def _build_plugin_config_change_callback(self, plugin_id: str) -> Callable[[Sequence[FileChange]], Awaitable[None]]: """为指定插件生成配置文件变更回调。""" async def _callback(changes: Sequence[FileChange]) -> None: @@ -1018,7 +1058,10 @@ class PluginRuntimeManager( return plugin_id for plugin_id, plugin_path in self._plugin_path_cache.items(): - if not any(self._plugin_dir_matches(plugin_path, Path(plugin_dir)) for plugin_dir in getattr(supervisor, "_plugin_dirs", [])): + if not any( + self._plugin_dir_matches(plugin_path, Path(plugin_dir)) + for plugin_dir in getattr(supervisor, "_plugin_dirs", []) + ): continue if resolved_path == plugin_path or resolved_path.is_relative_to(plugin_path): return plugin_id diff --git a/src/plugin_runtime/protocol/envelope.py b/src/plugin_runtime/protocol/envelope.py index e738d019..88c5c7df 100644 --- a/src/plugin_runtime/protocol/envelope.py +++ b/src/plugin_runtime/protocol/envelope.py @@ -1,7 +1,7 @@ -"""RPC Envelope 消息模型 +"""RPC Envelope 消息模型。 定义 Host 与 Runner 之间所有 RPC 消息的统一信封格式。 -使用 Pydantic 进行 schema 定义与校验。 +使用 Pydantic 进行 Schema 定义与校验。 """ from enum import Enum @@ -39,12 +39,23 @@ class ConfigReloadScope(str, Enum): # ====== 请求 ID 生成器 ====== class RequestIdGenerator: - """单调递增 int64 请求 ID 生成器""" + """单调递增 int64 请求 ID 生成器。""" def __init__(self, start: int = 1) -> None: + """初始化请求 ID 生成器。 + + Args: + start: 起始请求 ID。 + """ self._counter = start async def next(self) -> int: + """返回下一个请求 ID。 + + Returns: + int: 下一个可用的请求 ID。 + """ + current = self._counter self._counter += 1 return current @@ -52,7 +63,7 @@ class RequestIdGenerator: # ====== Envelope 模型 ====== class Envelope(BaseModel): - """RPC 统一消息封装 + """RPC 统一消息封装。 所有 Host <-> Runner 消息均封装为此格式。 序列化流程:Envelope -> .model_dump() -> MsgPack encode @@ -79,18 +90,44 @@ class Envelope(BaseModel): """错误信息 (仅 response)""" def is_request(self) -> bool: + """判断当前信封是否为请求消息。 + + Returns: + bool: 当前消息类型是否为 ``REQUEST``。 + """ + return self.message_type == MessageType.REQUEST def is_response(self) -> bool: + """判断当前信封是否为响应消息。 + + Returns: + bool: 当前消息类型是否为 ``RESPONSE``。 + """ + return self.message_type == MessageType.RESPONSE def is_broadcast(self) -> bool: + """判断当前信封是否为广播消息。 + + Returns: + bool: 当前消息类型是否为 ``BROADCAST``。 + """ + return self.message_type == MessageType.BROADCAST def make_response( self, payload: Optional[Dict[str, Any]] = None, error: Optional[Dict[str, Any]] = None ) -> "Envelope": - """基于当前请求创建对应的响应信封""" + """基于当前请求创建对应的响应信封。 + + Args: + payload: 响应业务载荷。 + error: 响应错误信息。 + + Returns: + Envelope: 对应的响应信封。 + """ return Envelope( protocol_version=self.protocol_version, request_id=self.request_id, @@ -102,7 +139,16 @@ class Envelope(BaseModel): ) def make_error_response(self, code: str, message: str = "", details: Optional[Dict[str, Any]] = None) -> "Envelope": - """基于当前请求创建错误响应""" + """基于当前请求创建错误响应。 + + Args: + code: 错误码。 + message: 错误描述。 + details: 详细错误信息。 + + Returns: + Envelope: 错误响应信封。 + """ return self.make_response( error={ "code": code, @@ -141,9 +187,7 @@ class ComponentDeclaration(BaseModel): name: str = Field(description="组件名称") """组件名称""" - component_type: str = Field( - description="组件类型:action/command/tool/event_handler/hook_handler/message_gateway" - ) + component_type: str = Field(description="组件类型:action/command/tool/event_handler/hook_handler/message_gateway") """组件类型:`action`/`command`/`tool`/`event_handler`/`hook_handler`/`message_gateway`""" plugin_id: str = Field(description="所属插件 ID") """所属插件 ID""" @@ -170,6 +214,10 @@ class RegisterPluginPayload(BaseModel): """插件级依赖插件 ID 列表""" config_reload_subscriptions: List[str] = Field(default_factory=list, description="订阅的全局配置热重载范围") """订阅的全局配置热重载范围""" + default_config: Dict[str, Any] = Field(default_factory=dict, description="插件默认配置") + """插件默认配置""" + config_schema: Dict[str, Any] = Field(default_factory=dict, description="插件配置 Schema") + """插件配置 Schema""" class BootstrapPluginPayload(BaseModel): @@ -256,6 +304,24 @@ class ConfigUpdatedPayload(BaseModel): """配置内容""" +class ValidatePluginConfigPayload(BaseModel): + """plugin.validate_config 请求 payload。""" + + config_data: Dict[str, Any] = Field(default_factory=dict, description="待校验的配置内容") + """待校验的配置内容""" + + +class ValidatePluginConfigResultPayload(BaseModel): + """plugin.validate_config 响应 payload。""" + + success: bool = Field(description="是否校验成功") + """是否校验成功""" + normalized_config: Dict[str, Any] = Field(default_factory=dict, description="校验后的规范化配置") + """校验后的规范化配置""" + changed: bool = Field(default=False, description="是否在校验过程中自动补齐或归一化") + """是否在校验过程中自动补齐或归一化""" + + # ====== 关停 ====== class ShutdownPayload(BaseModel): """plugin.shutdown / plugin.prepare_shutdown payload""" diff --git a/src/plugin_runtime/runner/runner_main.py b/src/plugin_runtime/runner/runner_main.py index a1dc56fa..55c53d8d 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, cast +from typing import Any, Callable, Dict, List, Mapping, Optional, Protocol, Set, Tuple, cast import asyncio import contextlib @@ -23,6 +23,8 @@ import sys import time import tomllib +import tomlkit + from src.common.logger import get_console_handler, get_logger, initialize_logging from src.plugin_runtime import ( ENV_EXTERNAL_PLUGIN_IDS, @@ -46,6 +48,8 @@ from src.plugin_runtime.protocol.envelope import ( ReloadPluginsResultPayload, RunnerReadyPayload, UnregisterPluginPayload, + ValidatePluginConfigPayload, + ValidatePluginConfigResultPayload, ) from src.plugin_runtime.protocol.errors import ErrorCode from src.plugin_runtime.runner.log_handler import RunnerIPCLogHandler @@ -79,6 +83,64 @@ class _ContextAwarePlugin(Protocol): """ +class _ConfigAwarePlugin(Protocol): + """支持声明式插件配置能力的插件协议。""" + + def normalize_plugin_config(self, config_data: Optional[Mapping[str, Any]]) -> Tuple[Dict[str, Any], bool]: + """对插件配置进行归一化与补齐。 + + Args: + config_data: 原始配置数据。 + + Returns: + Tuple[Dict[str, Any], bool]: 归一化后的配置,以及是否发生自动变更。 + """ + + ... + + def set_plugin_config(self, config: Dict[str, Any]) -> None: + """注入插件当前配置。 + + Args: + config: 当前最新插件配置。 + """ + + ... + + def get_default_config(self) -> Dict[str, Any]: + """返回插件默认配置。 + + Returns: + Dict[str, Any]: 默认配置字典。 + """ + + ... + + def get_webui_config_schema( + self, + *, + plugin_id: str = "", + plugin_name: str = "", + plugin_version: str = "", + plugin_description: str = "", + plugin_author: str = "", + ) -> Dict[str, Any]: + """返回插件配置 Schema。 + + Args: + plugin_id: 插件 ID。 + plugin_name: 插件名称。 + plugin_version: 插件版本。 + plugin_description: 插件描述。 + plugin_author: 插件作者。 + + Returns: + Dict[str, Any]: WebUI 配置 Schema。 + """ + + ... + + def _install_shutdown_signal_handlers( mark_runner_shutting_down: Callable[[], None], loop: Optional[asyncio.AbstractEventLoop] = None, @@ -271,14 +333,11 @@ class PluginRunner: 始终绑定为当前插件实例,避免伪造其他插件身份申请能力。 """ if plugin_id and plugin_id != bound_plugin_id: - logger.warning( - f"插件 {bound_plugin_id} 尝试以 {plugin_id} 身份发起 RPC,已强制绑定回自身身份" - ) + logger.warning(f"插件 {bound_plugin_id} 尝试以 {plugin_id} 身份发起 RPC,已强制绑定回自身身份") normalized_method = str(method or "").strip() if normalized_method not in _PLUGIN_ALLOWED_RAW_HOST_METHODS: raise PermissionError( - f"插件 {bound_plugin_id} 不允许直接调用 Host 原始 RPC 方法: " - f"{normalized_method or ''}" + f"插件 {bound_plugin_id} 不允许直接调用 Host 原始 RPC 方法: {normalized_method or ''}" ) resp = await rpc_client.send_request( method=normalized_method, @@ -294,17 +353,72 @@ class PluginRunner: logger.debug(f"已为插件 {plugin_id} 注入 PluginContext") def _apply_plugin_config(self, meta: PluginMeta, config_data: Optional[Dict[str, Any]] = None) -> None: - """在 Runner 侧为插件实例注入当前插件配置。""" + """在 Runner 侧为插件实例注入当前插件配置。 + + Args: + meta: 插件元数据。 + config_data: 可选的配置数据;留空时自动从插件目录读取。 + """ instance = meta.instance if not hasattr(instance, "set_plugin_config"): return - plugin_config = config_data if config_data is not None else self._load_plugin_config(meta.plugin_dir) + raw_config = config_data if config_data is not None else self._load_plugin_config(meta.plugin_dir) + plugin_config, should_persist = self._normalize_plugin_config(instance, raw_config) + config_path = Path(meta.plugin_dir) / "config.toml" + default_config = self._get_plugin_default_config(instance) + should_initialize_file = not config_path.exists() and bool(default_config) + if should_persist or should_initialize_file: + self._save_plugin_config(meta.plugin_dir, plugin_config) try: - instance.set_plugin_config(plugin_config) + cast(_ConfigAwarePlugin, instance).set_plugin_config(plugin_config) except Exception as exc: logger.warning(f"插件 {meta.plugin_id} 配置注入失败: {exc}") + def _normalize_plugin_config( + self, + instance: object, + config_data: Optional[Dict[str, Any]], + *, + suppress_errors: bool = True, + ) -> Tuple[Dict[str, Any], bool]: + """对插件配置做统一归一化处理。 + + Args: + instance: 插件实例。 + config_data: 原始配置数据。 + suppress_errors: 是否在归一化失败时吞掉异常并回退原始配置。 + + Returns: + Tuple[Dict[str, Any], bool]: 归一化后的配置,以及是否需要回写文件。 + """ + + normalized_config = dict(config_data or {}) + if not hasattr(instance, "normalize_plugin_config"): + return normalized_config, False + + try: + return cast(_ConfigAwarePlugin, instance).normalize_plugin_config(normalized_config) + except Exception as exc: + if not suppress_errors: + raise + logger.warning(f"插件配置归一化失败,将回退为原始配置: {exc}") + return normalized_config, False + + @staticmethod + def _save_plugin_config(plugin_dir: str, config_data: Dict[str, Any]) -> None: + """将插件配置写回到 ``config.toml``。 + + Args: + plugin_dir: 插件目录。 + config_data: 需要写回的配置字典。 + """ + + config_path = Path(plugin_dir) / "config.toml" + config_path.parent.mkdir(parents=True, exist_ok=True) + with config_path.open("w", encoding="utf-8") as handle: + handle.write(tomlkit.dumps(config_data)) + @staticmethod def _load_plugin_config(plugin_dir: str) -> Dict[str, Any]: """从插件目录读取 config.toml。""" @@ -334,6 +448,7 @@ class PluginRunner: self._rpc_client.register_method("plugin.prepare_shutdown", self._handle_prepare_shutdown) self._rpc_client.register_method("plugin.shutdown", self._handle_shutdown) self._rpc_client.register_method("plugin.config_updated", self._handle_config_updated) + self._rpc_client.register_method("plugin.validate_config", self._handle_validate_plugin_config) self._rpc_client.register_method("plugin.reload", self._handle_reload_plugin) self._rpc_client.register_method("plugin.reload_batch", self._handle_reload_plugins) @@ -451,6 +566,8 @@ class PluginRunner: capabilities_required=meta.capabilities_required, dependencies=meta.dependencies, config_reload_subscriptions=config_reload_subscriptions, + default_config=self._get_plugin_default_config(instance), + config_schema=self._get_plugin_config_schema(meta), ) try: @@ -468,6 +585,53 @@ class PluginRunner: logger.error(f"插件 {meta.plugin_id} 注册失败: {e}") return False + @staticmethod + def _get_plugin_default_config(instance: object) -> Dict[str, Any]: + """获取插件默认配置。 + + Args: + instance: 插件实例。 + + Returns: + Dict[str, Any]: 默认配置;插件未声明时返回空字典。 + """ + + if not hasattr(instance, "get_default_config"): + return {} + try: + default_config = cast(_ConfigAwarePlugin, instance).get_default_config() + except Exception as exc: + logger.warning(f"读取插件默认配置失败: {exc}") + return {} + return default_config if isinstance(default_config, dict) else {} + + @staticmethod + def _get_plugin_config_schema(meta: PluginMeta) -> Dict[str, Any]: + """获取插件 WebUI 配置 Schema。 + + Args: + meta: 插件元数据。 + + Returns: + Dict[str, Any]: 插件配置 Schema;插件未声明时返回空字典。 + """ + + instance = meta.instance + if not hasattr(instance, "get_webui_config_schema"): + return {} + try: + schema = cast(_ConfigAwarePlugin, instance).get_webui_config_schema( + plugin_id=meta.plugin_id, + plugin_name=meta.manifest.name, + plugin_version=meta.version, + plugin_description=meta.manifest.description, + plugin_author=meta.manifest.author.name, + ) + except Exception as exc: + logger.warning(f"构造插件配置 Schema 失败: {exc}") + return {} + return schema if isinstance(schema, dict) else {} + async def _unregister_plugin(self, plugin_id: str, reason: str) -> None: """通知 Host 注销指定插件。 @@ -631,7 +795,9 @@ class PluginRunner: continue dependency_graph[plugin_id] = {dependency for dependency in meta.dependencies if dependency in plugin_ids} - indegree: Dict[str, int] = {plugin_id: len(dependencies) for plugin_id, dependencies in dependency_graph.items()} + indegree: Dict[str, int] = { + plugin_id: len(dependencies) for plugin_id, dependencies in dependency_graph.items() + } reverse_graph: Dict[str, Set[str]] = {plugin_id: set() for plugin_id in dependency_graph} for plugin_id, dependencies in dependency_graph.items(): @@ -677,9 +843,7 @@ class PluginRunner: for failed_plugin_id, failure_reason in failed_plugins.items(): rollback_failure = rollback_failures.get(failed_plugin_id) if rollback_failure: - finalized_failures[failed_plugin_id] = ( - f"{failure_reason};且旧版本恢复失败: {rollback_failure}" - ) + finalized_failures[failed_plugin_id] = f"{failure_reason};且旧版本恢复失败: {rollback_failure}" else: finalized_failures[failed_plugin_id] = f"{failure_reason}(已恢复旧版本)" @@ -761,9 +925,7 @@ class PluginRunner: failed_plugins=failed_plugins, ) - target_plugin_ids: Set[str] = { - plugin_id for plugin_id in reload_root_ids if plugin_id not in loaded_plugin_ids - } + target_plugin_ids: Set[str] = {plugin_id for plugin_id in reload_root_ids if plugin_id not in loaded_plugin_ids} if loaded_root_plugin_ids := reload_root_ids & loaded_plugin_ids: target_plugin_ids.update(self._collect_reverse_dependents_for_roots(loaded_root_plugin_ids)) @@ -1127,6 +1289,42 @@ class PluginRunner: return envelope.make_error_response(ErrorCode.E_UNKNOWN.value, str(e)) return envelope.make_response(payload={"acknowledged": True}) + async def _handle_validate_plugin_config(self, envelope: Envelope) -> Envelope: + """处理插件配置校验请求。 + + Args: + envelope: RPC 请求信封。 + + Returns: + Envelope: RPC 响应信封。 + """ + + try: + payload = ValidatePluginConfigPayload.model_validate(envelope.payload) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + + plugin_id = envelope.plugin_id + meta = self._loader.get_plugin(plugin_id) + if meta is None: + return envelope.make_error_response(ErrorCode.E_PLUGIN_NOT_FOUND.value, f"未找到插件: {plugin_id}") + + try: + normalized_config, changed = self._normalize_plugin_config( + meta.instance, + payload.config_data, + suppress_errors=False, + ) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + + result = ValidatePluginConfigResultPayload( + success=True, + normalized_config=normalized_config, + changed=changed, + ) + return envelope.make_response(payload=result.model_dump()) + async def _handle_reload_plugin(self, envelope: Envelope) -> Envelope: """处理按插件 ID 的精确重载请求。 @@ -1212,8 +1410,7 @@ async def _async_main() -> None: session_token, plugin_dirs, external_available_plugins={ - str(plugin_id): str(plugin_version) - for plugin_id, plugin_version in external_plugin_ids.items() + str(plugin_id): str(plugin_version) for plugin_id, plugin_version in external_plugin_ids.items() }, ) diff --git a/src/webui/routers/plugin/config_routes.py b/src/webui/routers/plugin/config_routes.py index 128e86a8..3a24503e 100644 --- a/src/webui/routers/plugin/config_routes.py +++ b/src/webui/routers/plugin/config_routes.py @@ -1,3 +1,5 @@ +"""插件配置相关 WebUI 路由。""" + import json from typing import Any, Dict, Optional, cast @@ -5,13 +7,12 @@ import tomlkit from fastapi import APIRouter, Cookie, HTTPException from src.common.logger import get_logger +from src.plugin_runtime.component_query import component_query_service from src.webui.utils.toml_utils import save_toml_with_format from .schemas import UpdatePluginConfigRequest, UpdatePluginRawConfigRequest from .support import ( backup_file, - coerce_types, - find_plugin_instance, find_plugin_path_by_id, normalize_dotted_keys, require_plugin_token, @@ -24,6 +25,16 @@ router = APIRouter() def _build_schema_from_current_config(plugin_id: str, current_config: Any) -> Dict[str, Any]: + """根据当前配置内容自动推断一个兜底 Schema。 + + Args: + plugin_id: 插件 ID。 + current_config: 当前配置对象。 + + Returns: + Dict[str, Any]: 可供前端渲染的兜底 Schema。 + """ + schema: Dict[str, Any] = { "plugin_id": plugin_id, "plugin_info": { @@ -119,15 +130,123 @@ def _build_schema_from_current_config(plugin_id: str, current_config: Any) -> Di return schema +def _coerce_scalar_value(field_schema: Dict[str, Any], value: Any) -> Any: + """根据字段 Schema 规范化单个字段值。 + + Args: + field_schema: 单个字段 Schema。 + value: 当前字段值。 + + Returns: + Any: 规范化后的字段值。 + """ + + field_type = str(field_schema.get("type", "") or "").lower() + if field_type == "boolean" and isinstance(value, str): + normalized_value = value.strip().lower() + if normalized_value in {"1", "true", "yes", "on"}: + return True + if normalized_value in {"0", "false", "no", "off"}: + return False + if field_type == "integer" and isinstance(value, str): + try: + return int(value) + except ValueError: + return value + if field_type == "number" and isinstance(value, str): + try: + return float(value) + except ValueError: + return value + if field_type == "array" and isinstance(value, str): + return [item.strip() for item in value.split(",") if item.strip()] + return value + + +def _coerce_config_by_plugin_schema(schema: Dict[str, Any], config_data: Dict[str, Any]) -> None: + """根据插件配置 Schema 就地规范化配置值类型。 + + Args: + schema: 插件配置 Schema。 + config_data: 待规范化的配置字典。 + """ + + sections = schema.get("sections") + if not isinstance(sections, dict): + return + + for section_name, section_schema in sections.items(): + if not isinstance(section_schema, dict): + continue + if section_name not in config_data or not isinstance(config_data[section_name], dict): + continue + + section_fields = section_schema.get("fields") + if not isinstance(section_fields, dict): + continue + + section_config = cast(Dict[str, Any], config_data[section_name]) + for field_name, field_schema in section_fields.items(): + if field_name not in section_config or not isinstance(field_schema, dict): + continue + section_config[field_name] = _coerce_scalar_value(field_schema, section_config[field_name]) + + +def _build_toml_document(config_data: Dict[str, Any]) -> tomlkit.TOMLDocument: + """将普通字典转换为 TOML 文档对象。 + + Args: + config_data: 原始配置字典。 + + Returns: + tomlkit.TOMLDocument: 解析后的 TOML 文档。 + """ + + if not config_data: + return tomlkit.document() + return tomlkit.parse(tomlkit.dumps(config_data)) + + +async def _validate_plugin_config_via_runtime(plugin_id: str, config_data: Dict[str, Any]) -> Dict[str, Any] | None: + """通过插件运行时对配置进行校验。 + + Args: + plugin_id: 插件 ID。 + config_data: 待校验的配置内容。 + + Returns: + Dict[str, Any] | None: 校验成功时返回规范化后的配置;若运行时不可用则返回 + ``None``,由调用方自行回退到静态 Schema 方案。 + + Raises: + ValueError: 插件运行时明确判定配置非法时抛出。 + """ + + from src.plugin_runtime.integration import get_plugin_runtime_manager + + runtime_manager = get_plugin_runtime_manager() + return await runtime_manager.validate_plugin_config(plugin_id, config_data) + + @router.get("/config/{plugin_id}/schema") async def get_plugin_config_schema(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]: + """按插件 ID 返回配置 Schema。 + + Args: + plugin_id: 插件 ID。 + maibot_session: 当前会话令牌。 + + Returns: + Dict[str, Any]: 包含 Schema 的响应字典。 + """ + require_plugin_token(maibot_session) logger.info(f"获取插件配置 Schema: {plugin_id}") try: - plugin_instance = find_plugin_instance(plugin_id) - if plugin_instance and hasattr(plugin_instance, "get_webui_config_schema"): - return {"success": True, "schema": plugin_instance.get_webui_config_schema()} + registration_schema = component_query_service.get_plugin_config_schema(plugin_id) + if isinstance(registration_schema, dict) and registration_schema: + return {"success": True, "schema": registration_schema} plugin_path = find_plugin_path_by_id(plugin_id) if plugin_path is None: @@ -141,7 +260,7 @@ async def get_plugin_config_schema(plugin_id: str, maibot_session: Optional[str] except Exception as e: logger.warning(f"读取 config_schema.json 失败,回退到自动推断: {e}") - current_config: Any = {} + current_config: Any = component_query_service.get_plugin_default_config(plugin_id) or {} config_path = resolve_plugin_file_path(plugin_path, "config.toml") if config_path.exists(): with open(config_path, "r", encoding="utf-8") as file_obj: @@ -157,6 +276,16 @@ async def get_plugin_config_schema(plugin_id: str, maibot_session: Optional[str] @router.get("/config/{plugin_id}/raw") async def get_plugin_config_raw(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]: + """获取插件原始 TOML 配置内容。 + + Args: + plugin_id: 插件 ID。 + maibot_session: 当前会话令牌。 + + Returns: + Dict[str, Any]: 包含原始配置文本的响应字典。 + """ + require_plugin_token(maibot_session) logger.info(f"获取插件原始配置: {plugin_id}") @@ -184,6 +313,17 @@ async def update_plugin_config_raw( request: UpdatePluginRawConfigRequest, maibot_session: Optional[str] = Cookie(None), ) -> Dict[str, Any]: + """更新插件原始 TOML 配置内容。 + + Args: + plugin_id: 插件 ID。 + request: 原始配置更新请求。 + maibot_session: 当前会话令牌。 + + Returns: + Dict[str, Any]: 更新结果。 + """ + require_plugin_token(maibot_session) logger.info(f"更新插件原始配置: {plugin_id}") @@ -216,6 +356,16 @@ async def update_plugin_config_raw( @router.get("/config/{plugin_id}") async def get_plugin_config(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]: + """获取插件配置字典。 + + Args: + plugin_id: 插件 ID。 + maibot_session: 当前会话令牌。 + + Returns: + Dict[str, Any]: 当前配置响应。 + """ + require_plugin_token(maibot_session) logger.info(f"获取插件配置: {plugin_id}") @@ -226,6 +376,9 @@ async def get_plugin_config(plugin_id: str, maibot_session: Optional[str] = Cook config_path = resolve_plugin_file_path(plugin_path, "config.toml") if not config_path.exists(): + default_config = component_query_service.get_plugin_default_config(plugin_id) + if isinstance(default_config, dict): + return {"success": True, "config": default_config, "message": "配置文件不存在,已返回默认配置"} return {"success": True, "config": {}, "message": "配置文件不存在"} with open(config_path, "r", encoding="utf-8") as file_obj: @@ -244,17 +397,31 @@ async def update_plugin_config( request: UpdatePluginConfigRequest, maibot_session: Optional[str] = Cookie(None), ) -> Dict[str, Any]: + """更新插件结构化配置。 + + Args: + plugin_id: 插件 ID。 + request: 结构化配置更新请求。 + maibot_session: 当前会话令牌。 + + Returns: + Dict[str, Any]: 更新结果。 + """ + require_plugin_token(maibot_session) logger.info(f"更新插件配置: {plugin_id}") try: - plugin_instance = find_plugin_instance(plugin_id) config_data = request.config or {} - if plugin_instance and isinstance(config_data, dict): + if isinstance(config_data, dict): config_data = normalize_dotted_keys(config_data) - if isinstance(plugin_instance.config_schema, dict): - coerce_types(plugin_instance.config_schema, config_data) - + runtime_validated_config = await _validate_plugin_config_via_runtime(plugin_id, config_data) + if isinstance(runtime_validated_config, dict): + config_data = runtime_validated_config + else: + plugin_schema = component_query_service.get_plugin_config_schema(plugin_id) + if isinstance(plugin_schema, dict) and plugin_schema: + _coerce_config_by_plugin_schema(plugin_schema, config_data) plugin_path = find_plugin_path_by_id(plugin_id) if plugin_path is None: raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}") @@ -267,6 +434,8 @@ async def update_plugin_config( save_toml_with_format(config_data, str(config_path)) logger.info(f"已更新插件配置: {plugin_id}") return {"success": True, "message": "配置已保存", "note": "配置更改将自动热更新到对应插件"} + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc except HTTPException: raise except Exception as e: @@ -276,6 +445,16 @@ async def update_plugin_config( @router.post("/config/{plugin_id}/reset") async def reset_plugin_config(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]: + """重置插件配置文件。 + + Args: + plugin_id: 插件 ID。 + maibot_session: 当前会话令牌。 + + Returns: + Dict[str, Any]: 重置结果。 + """ + require_plugin_token(maibot_session) logger.info(f"重置插件配置: {plugin_id}") @@ -300,6 +479,16 @@ async def reset_plugin_config(plugin_id: str, maibot_session: Optional[str] = Co @router.post("/config/{plugin_id}/toggle") async def toggle_plugin(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]: + """切换插件启用状态。 + + Args: + plugin_id: 插件 ID。 + maibot_session: 当前会话令牌。 + + Returns: + Dict[str, Any]: 切换结果。 + """ + require_plugin_token(maibot_session) logger.info(f"切换插件状态: {plugin_id}") @@ -309,7 +498,8 @@ async def toggle_plugin(plugin_id: str, maibot_session: Optional[str] = Cookie(N raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}") config_path = resolve_plugin_file_path(plugin_path, "config.toml") - config = tomlkit.document() + default_config = component_query_service.get_plugin_default_config(plugin_id) + config = _build_toml_document(default_config if isinstance(default_config, dict) else {}) if config_path.exists(): with open(config_path, "r", encoding="utf-8") as file_obj: config = tomlkit.load(file_obj)