Ctrl K
ComunidadGuías…

scout/plugins/api.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""插件管理 API 路由"""

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import List, Optional
from pathlib import Path
import logging

from scout.plugins.manager import PluginManager

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/plugins", tags=["plugins"])


def _require_password_confirmation(password: str) -> None:
    """插件写码操作需要密码二次确认.

    防止 JWT 泄露后通过插件上传持久化后门(token 有有效期,插件无)。
    未初始化凭证(本地单用户模式)时跳过,该场景已由全局鉴权中间件 401 拦截。
    """
    from scout.security.auth import AuthManager

    # 与全局鉴权中间件保持一致:登录认证关闭时(本地单用户模式)无需密码确认,
    # 否则 auth_enabled=False 的用户(无 token)会被空密码 401 卡住无法创建插件。
    try:
        from scout.config.manager import ConfigManager
        _cfg = ConfigManager().load()
        if not getattr(_cfg, "auth_enabled", False):
            return
    except Exception:
        pass

    mgr = AuthManager()
    if not mgr.has_credentials():
        return
    if not mgr.verify(mgr.get_username(), password):
        raise HTTPException(status_code=401, detail="密码验证失败,插件变更已拒绝")

# 统一使用 scout.plugins.manager 的全局单例(避免多套 PluginManager 状态不一致)
from scout.plugins.manager import get_plugin_manager  # noqa: F401


def set_plugin_manager(manager: PluginManager):
    """设置插件管理器实例(用于注入)"""
    global _plugin_manager
    _plugin_manager = manager


# ── 数据模型 ──

class PluginInfo(BaseModel):
    name: str
    version: str
    author: str
    description: str
    enabled: bool
    priority: int


class PluginToggleRequest(BaseModel):
    enabled: bool


class PluginCreateRequest(BaseModel):
    name: str
    code: str
    description: Optional[str] = "通过可视化构建器创建的插件"
    password: str = ""  # 密码二次确认(已设置凭证时必须匹配)


class PluginConfigRequest(BaseModel):
    config: dict


class PluginResponse(BaseModel):
    success: bool
    message: str
    plugin: Optional[PluginInfo] = None


# ── API 路由 ──

@router.post("/create", response_model=PluginResponse)
async def create_plugin(request: PluginCreateRequest):
    """通过可视化构建器创建新插件"""
    import re

    # 密码二次确认:防止 token 泄露后植入持久化后门
    _require_password_confirmation(request.password)

    manager = get_plugin_manager()
    name = request.name.strip()
    
    # 验证插件名
    if not re.match(r'^[a-z][a-z0-9_]*$', name):
        raise HTTPException(
            status_code=400, 
            detail="插件名只能包含小写字母、数字和下划线,且必须以字母开头"
        )
    
    # 检查插件是否已存在
    if manager.get_plugin(name):
        raise HTTPException(
            status_code=400,
            detail=f"插件 {name} 已存在"
        )
    
    # 处理代码中的转义字符(将 \n 转换为真正的换行符)
    code = request.code
    # 如果代码中包含字面量 \n(反斜杠+n),需要转换为真正的换行符
    if '\\n' in code and '\n' not in code:
        code = code.replace('\\n', '\n')
    
    # 创建插件目录
    plugin_dir = manager.plugins_dir / name
    try:
        plugin_dir.mkdir(parents=True, exist_ok=True)
        
        # 写入 __init__.py
        init_file = plugin_dir / "__init__.py"
        init_file.write_text(code, encoding="utf-8")
        
        # 创建空的 config.json
        config_file = plugin_dir / "config.json"
        config_file.write_text("{}", encoding="utf-8")
        
        # 加载插件:加载失败必须报错(清理目录),不能静默返回"创建成功"
        manager.discover_plugins()
        if not manager.load_plugin(name):
            raise RuntimeError(f"插件 {name} 加载失败,请检查代码是否有语法错误或导入问题")
        
        return PluginResponse(
            success=True,
            message=f"插件 {name} 创建成功"
        )
        
    except Exception as e:
        logger.error(f"创建插件失败: {e}")
        # 清理已创建的文件
        if plugin_dir.exists():
            import shutil
            shutil.rmtree(plugin_dir)
        
        raise HTTPException(
            status_code=500,
            detail=f"创建插件失败: {str(e)}"
        )


@router.get("/", response_model=List[PluginInfo])
async def list_plugins():
    """列出所有已加载的插件"""
    manager = get_plugin_manager()
    plugins = manager.list_plugins()
    return [PluginInfo(**p) for p in plugins]


@router.get("/{name}", response_model=PluginInfo)
async def get_plugin(name: str):
    """获取单个插件信息"""
    manager = get_plugin_manager()
    plugin = manager.get_plugin(name)
    if not plugin:
        raise HTTPException(status_code=404, detail=f"插件 {name} 未找到")
    
    plugins = manager.list_plugins()
    for p in plugins:
        if p["name"] == name:
            return PluginInfo(**p)
    
    raise HTTPException(status_code=404, detail=f"插件 {name} 未找到")


@router.post("/{name}/enable", response_model=PluginResponse)
async def enable_plugin(name: str):
    """启用插件"""
    manager = get_plugin_manager()
    
    if not manager.get_plugin(name):
        raise HTTPException(status_code=404, detail=f"插件 {name} 未找到")
    
    success = manager.enable_plugin(name)
    if success:
        plugins = manager.list_plugins()
        plugin_info = None
        for p in plugins:
            if p["name"] == name:
                plugin_info = PluginInfo(**p)
                break
        
        return PluginResponse(
            success=True,
            message=f"插件 {name} 已启用",
            plugin=plugin_info
        )
    else:
        return PluginResponse(
            success=False,
            message=f"启用插件 {name} 失败"
        )


@router.post("/{name}/disable", response_model=PluginResponse)
async def disable_plugin(name: str):
    """禁用插件"""
    manager = get_plugin_manager()
    
    if not manager.get_plugin(name):
        raise HTTPException(status_code=404, detail=f"插件 {name} 未找到")
    
    success = manager.disable_plugin(name)
    if success:
        plugins = manager.list_plugins()
        plugin_info = None
        for p in plugins:
            if p["name"] == name:
                plugin_info = PluginInfo(**p)
                break
        
        return PluginResponse(
            success=True,
            message=f"插件 {name} 已禁用",
            plugin=plugin_info
        )
    else:
        return PluginResponse(
            success=False,
            message=f"禁用插件 {name} 失败"
        )


@router.post("/{name}/toggle", response_model=PluginResponse)
async def toggle_plugin(name: str, request: PluginToggleRequest):
    """切换插件启用/禁用状态"""
    manager = get_plugin_manager()
    
    if not manager.get_plugin(name):
        raise HTTPException(status_code=404, detail=f"插件 {name} 未找到")
    
    if request.enabled:
        success = manager.enable_plugin(name)
        action = "启用"
    else:
        success = manager.disable_plugin(name)
        action = "禁用"
    
    if success:
        plugins = manager.list_plugins()
        plugin_info = None
        for p in plugins:
            if p["name"] == name:
                plugin_info = PluginInfo(**p)
                break
        
        return PluginResponse(
            success=True,
            message=f"插件 {name} 已{action}",
            plugin=plugin_info
        )
    else:
        return PluginResponse(
            success=False,
            message=f"{action}插件 {name} 失败"
        )


@router.post("/{name}/reload", response_model=PluginResponse)
async def reload_plugin(name: str):
    """重新加载插件"""
    manager = get_plugin_manager()
    
    if not manager.get_plugin(name):
        raise HTTPException(status_code=404, detail=f"插件 {name} 未找到")
    
    # 先卸载再加载
    success = manager.unload_plugin(name)
    if not success:
        return PluginResponse(
            success=False,
            message=f"卸载插件 {name} 失败"
        )
    
    success = manager.load_plugin(name)
    if success:
        plugins = manager.list_plugins()
        plugin_info = None
        for p in plugins:
            if p["name"] == name:
                plugin_info = PluginInfo(**p)
                break
        
        return PluginResponse(
            success=True,
            message=f"插件 {name} 已重新加载",
            plugin=plugin_info
        )
    else:
        return PluginResponse(
            success=False,
            message=f"重新加载插件 {name} 失败"
        )


@router.post("/reload-all", response_model=PluginResponse)
async def reload_all_plugins():
    """重新加载所有插件"""
    manager = get_plugin_manager()
    
    # 卸载所有插件
    for name in list(manager._plugins.keys()):
        manager.unload_plugin(name)
    
    # 重新加载
    count = manager.load_all_plugins()
    
    return PluginResponse(
        success=True,
        message=f"已重新加载 {count} 个插件"
    )


@router.get("/{name}/config")
async def get_plugin_config(name: str):
    """获取插件配置"""
    manager = get_plugin_manager()
    plugin = manager.get_plugin(name)
    
    if not plugin:
        raise HTTPException(status_code=404, detail=f"插件 {name} 未找到")
    
    return {
        "name": name,
        "config": plugin.config
    }


@router.post("/{name}/config")
async def update_plugin_config(name: str, request: PluginConfigRequest):
    """更新插件配置"""
    manager = get_plugin_manager()
    plugin = manager.get_plugin(name)
    
    if not plugin:
        raise HTTPException(status_code=404, detail=f"插件 {name} 未找到")
    
    try:
        plugin.config = request.config
        plugin.save_config()
        
        return {
            "success": True,
            "message": f"插件 {name} 配置已更新"
        }
    except Exception as e:
        logger.error(f"更新插件 {name} 配置失败: {e}")
        raise HTTPException(status_code=500, detail=f"更新配置失败: {str(e)}")