- 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.
601 lines
21 KiB
Python
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
|