"""插件配置相关 WebUI 路由。""" from pathlib import Path from typing import Any, Dict, Optional, cast import tomlkit from fastapi import APIRouter, Cookie, HTTPException from src.common.logger import get_logger from src.plugin_runtime.protocol.envelope import InspectPluginConfigResultPayload from src.webui.utils.toml_utils import save_toml_with_format from .schemas import UpdatePluginConfigRequest, UpdatePluginRawConfigRequest from .support import ( backup_file, find_plugin_path_by_id, normalize_dotted_keys, require_plugin_token, resolve_plugin_file_path, ) logger = get_logger("webui.plugin_routes") 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": { "name": plugin_id, "version": "", "description": "", "author": "", }, "sections": {}, "layout": {"type": "auto", "tabs": []}, "_note": "插件未加载,仅返回当前配置结构", } for section_name, section_data in current_config.items(): if not isinstance(section_data, dict): continue section_fields: Dict[str, Any] = {} for field_name, field_value in section_data.items(): field_type = type(field_value).__name__ ui_type = "text" item_type = None item_fields = None if isinstance(field_value, bool): ui_type = "switch" elif isinstance(field_value, (int, float)): ui_type = "number" elif isinstance(field_value, list): ui_type = "list" if field_value: first_item = field_value[0] if isinstance(first_item, dict): item_type = "object" item_fields = { key: { "type": "number" if isinstance(value, (int, float)) else "string", "label": key, "default": "" if isinstance(value, str) else 0, } for key, value in first_item.items() } elif isinstance(first_item, (int, float)): item_type = "number" else: item_type = "string" else: item_type = "string" elif isinstance(field_value, dict): ui_type = "json" section_fields[field_name] = { "name": field_name, "type": field_type, "default": field_value, "description": field_name, "label": field_name, "ui_type": ui_type, "required": False, "hidden": False, "disabled": False, "order": 0, "item_type": item_type, "item_fields": item_fields, "min_items": None, "max_items": None, "placeholder": None, "hint": None, "icon": None, "example": None, "choices": None, "min": None, "max": None, "step": None, "pattern": None, "max_length": None, "input_type": None, "rows": 3, "group": None, "depends_on": None, "depends_value": None, } schema["sections"][section_name] = { "name": section_name, "title": section_name, "description": None, "icon": None, "collapsed": False, "order": 0, "fields": section_fields, } 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)) def _load_plugin_config_from_disk(plugin_path: Path) -> Dict[str, Any]: """从磁盘读取插件配置。 Args: plugin_path: 插件目录。 Returns: Dict[str, Any]: 当前配置字典;文件不存在时返回空字典。 """ config_path = resolve_plugin_file_path(plugin_path, "config.toml") if not config_path.exists(): return {} with open(config_path, "r", encoding="utf-8") as file_obj: loaded_config = tomlkit.load(file_obj).unwrap() return loaded_config if isinstance(loaded_config, dict) else {} async def _inspect_plugin_config_via_runtime( plugin_id: str, config_data: Optional[Dict[str, Any]] = None, *, use_provided_config: bool = False, ) -> InspectPluginConfigResultPayload | None: """通过插件运行时解析配置元数据。 Args: plugin_id: 插件 ID。 config_data: 可选的配置内容。 use_provided_config: 是否优先使用传入配置而不是磁盘配置。 Returns: InspectPluginConfigResultPayload | None: 运行时可用时返回解析结果,否则返回 ``None``。 Raises: ValueError: 插件运行时明确拒绝解析请求时抛出。 """ from src.plugin_runtime.integration import get_plugin_runtime_manager runtime_manager = get_plugin_runtime_manager() return await runtime_manager.inspect_plugin_config( plugin_id, config_data, use_provided_config=use_provided_config, ) 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_path = find_plugin_path_by_id(plugin_id) if plugin_path is None: raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}") try: runtime_snapshot = await _inspect_plugin_config_via_runtime(plugin_id) except ValueError as exc: logger.warning(f"插件 {plugin_id} 配置 Schema 解析失败,将回退到弱推断: {exc}") runtime_snapshot = None if runtime_snapshot is not None and runtime_snapshot.config_schema: return {"success": True, "schema": dict(runtime_snapshot.config_schema)} current_config: Any = ( dict(runtime_snapshot.normalized_config) if runtime_snapshot is not None else _load_plugin_config_from_disk(plugin_path) ) return {"success": True, "schema": _build_schema_from_current_config(plugin_id, current_config)} except HTTPException: raise except Exception as e: logger.error(f"获取插件配置 Schema 失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e @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}") try: plugin_path = find_plugin_path_by_id(plugin_id) if plugin_path is None: raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}") config_path = resolve_plugin_file_path(plugin_path, "config.toml") if not config_path.exists(): return {"success": True, "config": "", "message": "配置文件不存在"} with open(config_path, "r", encoding="utf-8") as file_obj: return {"success": True, "config": file_obj.read()} except HTTPException: raise except Exception as e: logger.error(f"获取插件原始配置失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e @router.put("/config/{plugin_id}/raw") async def update_plugin_config_raw( plugin_id: str, 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}") try: plugin_path = find_plugin_path_by_id(plugin_id) if plugin_path is None: raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}") config_path = resolve_plugin_file_path(plugin_path, "config.toml") try: tomlkit.loads(request.config) except Exception as e: raise HTTPException(status_code=400, detail=f"TOML 格式错误: {str(e)}") from e backup_path = backup_file(config_path, "backup") if backup_path is not None: logger.info(f"已备份配置文件: {backup_path}") with open(config_path, "w", encoding="utf-8") as file_obj: file_obj.write(request.config) logger.info(f"已更新插件原始配置: {plugin_id}") return {"success": True, "message": "配置已保存", "note": "配置更改将自动热更新到对应插件"} except HTTPException: raise except Exception as e: logger.error(f"更新插件原始配置失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e @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}") try: plugin_path = find_plugin_path_by_id(plugin_id) if plugin_path is None: raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}") config_path = resolve_plugin_file_path(plugin_path, "config.toml") try: runtime_snapshot = await _inspect_plugin_config_via_runtime(plugin_id) except ValueError as exc: logger.warning(f"插件 {plugin_id} 配置读取失败,将回退到磁盘内容: {exc}") runtime_snapshot = None if runtime_snapshot is not None: message = "配置文件不存在,已返回默认配置" if not config_path.exists() else "" return { "success": True, "config": dict(runtime_snapshot.normalized_config), "message": message, } if not config_path.exists(): return {"success": True, "config": {}, "message": "配置文件不存在"} return {"success": True, "config": _load_plugin_config_from_disk(plugin_path)} except HTTPException: raise except Exception as e: logger.error(f"获取插件配置失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e @router.put("/config/{plugin_id}") async def update_plugin_config( plugin_id: str, 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_path = find_plugin_path_by_id(plugin_id) if plugin_path is None: raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}") config_data = request.config or {} if isinstance(config_data, dict): config_data = normalize_dotted_keys(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: runtime_snapshot = await _inspect_plugin_config_via_runtime( plugin_id, config_data, use_provided_config=True, ) if runtime_snapshot is not None and runtime_snapshot.config_schema: _coerce_config_by_plugin_schema(dict(runtime_snapshot.config_schema), config_data) config_path = resolve_plugin_file_path(plugin_path, "config.toml") backup_path = backup_file(config_path, "backup") if backup_path is not None: logger.info(f"已备份配置文件: {backup_path}") 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: logger.error(f"更新插件配置失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e @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}") try: plugin_path = find_plugin_path_by_id(plugin_id) if plugin_path is None: raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}") config_path = resolve_plugin_file_path(plugin_path, "config.toml") if not config_path.exists(): return {"success": True, "message": "配置文件不存在,无需重置"} backup_path = backup_file(config_path, "reset", move_file=True) logger.info(f"已重置插件配置: {plugin_id},备份: {backup_path}") return {"success": True, "message": "配置已重置,运行时将自动刷新为默认配置", "backup": str(backup_path)} except HTTPException: raise except Exception as e: logger.error(f"重置插件配置失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e @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}") try: plugin_path = find_plugin_path_by_id(plugin_id) if plugin_path is None: raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}") config_path = resolve_plugin_file_path(plugin_path, "config.toml") try: runtime_snapshot = await _inspect_plugin_config_via_runtime(plugin_id) except ValueError as exc: logger.warning(f"插件 {plugin_id} 状态切换前配置解析失败,将回退到磁盘内容: {exc}") runtime_snapshot = None current_config = ( dict(runtime_snapshot.normalized_config) if runtime_snapshot is not None else _load_plugin_config_from_disk(plugin_path) ) config = _build_toml_document(current_config) plugin_section = config.get("plugin") if plugin_section is None or not hasattr(plugin_section, "get"): config["plugin"] = tomlkit.table() plugin_config = cast(Any, config["plugin"]) current_enabled = ( bool(runtime_snapshot.enabled) if runtime_snapshot is not None else bool(plugin_config.get("enabled", True)) ) new_enabled = not current_enabled plugin_config["enabled"] = new_enabled save_toml_with_format(config, str(config_path)) status = "启用" if new_enabled else "禁用" logger.info(f"已{status}插件: {plugin_id}") return { "success": True, "enabled": new_enabled, "message": f"插件已{status}", "note": "状态更改将自动热更新到对应插件", } except HTTPException: raise except Exception as e: logger.error(f"切换插件状态失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e