Files
mai-bot/src/webui/routers/plugin/config_routes.py
DrSmoothl 7d0d429640 feat: Enhance plugin runtime configuration and hook management
- Added `inactive_plugins` field to `RunnerReadyPayload` and `ReloadPluginResultPayload` to track plugins that are not activated due to being disabled or unmet dependencies.
- Introduced `InspectPluginConfigPayload` and `InspectPluginConfigResultPayload` for inspecting plugin configuration metadata.
- Implemented `PluginActivationStatus` enum to better represent plugin activation states.
- Updated `_activate_plugin` method to return activation status and handle inactive plugins accordingly.
- Added hooks for send service to allow modification of messages before and after sending.
- Created new runtime routes for listing hook specifications in the WebUI.
- Refactored plugin configuration handling to utilize runtime inspection for better accuracy and flexibility.
- Enhanced error handling and logging for plugin configuration operations.
2026-04-02 21:16:31 +08:00

601 lines
21 KiB
Python

"""插件配置相关 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