Files
abot/admin/dashboard/blueprints/plugin_routes.py
2026-05-01 12:12:55 +08:00

429 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
from loguru import logger
import os
import toml
from flask import Blueprint, request, jsonify, render_template, current_app
from admin.dashboard.blueprints.auth import login_required
from utils.robot_cmd.robot_command import GroupBotManager, PermissionStatus
from plugins.robot_menu.menu_render_tool import RobotMenuRenderTool
# 创建蓝图
plugin_routes = Blueprint('plugin_routes', __name__)
LOG = logger
# 后台命令索引页只复用“命令目录生成”能力,不需要图片渲染,
# 因此这里固定使用轻量 text 配置创建一个工具实例即可。
_command_catalog_tool = RobotMenuRenderTool(
output_mode="text",
image_fallback_to_text=True,
image_render_timeout_seconds=30,
image_render_retries=1,
image_template_path="plugins/robot_menu/templates/menu_cards.html",
log=LOG,
)
def _build_group_options(server) -> list:
"""构建后台命令索引页的群组选项列表。"""
group_ids = sorted(set(GroupBotManager.get_group_list() or []))
options = []
for group_id in group_ids:
group_name = ""
try:
group_name = server.contact_manager.get_nickname(group_id) or ""
except Exception:
group_name = ""
options.append(
{
"group_id": group_id,
"group_name": str(group_name or group_id),
}
)
return options
# 机器人管理页面
@plugin_routes.route('/plugins_manage')
@login_required
def robot_management():
return render_template('plugins_manage.html')
@plugin_routes.route('/command_catalog')
@login_required
def command_catalog_page():
"""后台命令索引页面。"""
return render_template('command_catalog.html')
@plugin_routes.route('/api/plugins', methods=['GET'])
@login_required
def get_plugins():
"""获取所有插件列表"""
try:
server = current_app.dashboard_server
# 统一改为消费 PluginManager 的标准治理快照:
# 1. 这样既能覆盖“已加载插件”,也能覆盖“发现但加载失败/配置禁用”的模块;
# 2. 后台不必重复拼装版本、命令、依赖、配置健康等字段;
# 3. 后续继续补错误统计、性能排名时,也只需要在快照层扩展。
plugin_list = server.plugin_manager.get_plugin_snapshots()
return jsonify({"success": True, "data": plugin_list})
except Exception as e:
LOG.error(f"获取插件列表失败: {str(e)}", exc_info=True)
return jsonify({"success": False, "message": f"获取插件列表失败: {str(e)}"})
@plugin_routes.route('/api/plugins/command_catalog', methods=['GET'])
@login_required
def get_command_catalog():
"""获取后台命令索引数据。"""
try:
server = current_app.dashboard_server
group_id = str(request.args.get('group_id') or '').strip()
# 后台命令索引默认站在“管理员”视角,
# 这样既能看到当前可用命令,也能看到未启用能力和管理指令。
catalog = _command_catalog_tool.build_command_catalog_data(
group_id=group_id,
requester_id="dashboard_admin",
force_admin=True,
)
data = {
**catalog,
"group_options": _build_group_options(server),
"summary": {
"available_manual_count": len(catalog.get("available_manual", []) or []),
"available_auto_count": len(catalog.get("available_auto", []) or []),
"unavailable_manual_count": len(catalog.get("unavailable_manual", []) or []),
"admin_command_count": len(catalog.get("admin_commands", []) or []),
},
}
return jsonify({"success": True, "data": data})
except Exception as e:
LOG.error(f"获取命令索引失败: {str(e)}", exc_info=True)
return jsonify({"success": False, "message": f"获取命令索引失败: {str(e)}"})
@plugin_routes.route('/api/plugins/group_status', methods=['GET'])
@login_required
def get_plugin_group_status():
"""获取单个插件在各群的启用状态(已启用/未启用)"""
try:
server = current_app.dashboard_server
plugin_name = request.args.get('plugin_name')
if not plugin_name:
return jsonify({"success": False, "message": "缺少插件名称参数"})
# 通过现有插件管理器查找插件实例,兼容传入模块名或展示名两种形式。
display_name, plugin = server.plugin_manager.find_plugin_by_name(plugin_name)
if not plugin:
return jsonify({"success": False, "message": f"未找到插件: {plugin_name}"})
# 统一构建群列表:优先使用通讯录中的群(覆盖面更全),并补齐机器人管理列表中的群。
group_contacts = {}
try:
group_contacts = (server.contact_manager.get_group_contacts() or {})
except Exception:
# 这里降级为空字典,后续仍会使用 GroupBotManager 群列表,不中断主流程。
group_contacts = {}
# 从群机器人管理缓存中取群,确保“已托管但通讯录暂缺”的群也会展示出来。
managed_groups = set(GroupBotManager.get_group_list() or [])
contact_groups = set(group_contacts.keys())
all_group_ids = sorted(contact_groups | managed_groups)
# 再做一次兜底:极端情况下如果前两者都为空,尝试读取本地缓存中的群集合。
if not all_group_ids and hasattr(GroupBotManager, "local_cache"):
all_group_ids = sorted(GroupBotManager.local_cache.get("group_list", set()) or [])
# 插件是否支持“按群开关”依赖于插件是否注册了 feature多数消息插件会注册
plugin_feature = getattr(plugin, "feature", None)
supports_group_switch = plugin_feature is not None
feature_key = getattr(plugin_feature, "name", "") if plugin_feature else ""
feature_description = getattr(plugin_feature, "description", "") if plugin_feature else ""
enabled_groups = []
disabled_groups = []
for group_id in all_group_ids:
# 群名优先从通讯录获取拿不到时降级为群ID保证页面总能显示。
group_name = group_contacts.get(group_id) or server.contact_manager.get_nickname(group_id) or group_id
group_item = {
"group_id": group_id,
"group_name": group_name,
}
# 支持群开关的插件:按 feature 的真实权限划分已开启/未开启。
if supports_group_switch:
permission_status = GroupBotManager.get_group_permission(group_id, plugin_feature)
if permission_status == PermissionStatus.ENABLED:
enabled_groups.append(group_item)
else:
disabled_groups.append(group_item)
else:
# 不支持群级开关的插件默认归入“未开启”列表,并通过 supports_group_switch 让前端给提示。
disabled_groups.append(group_item)
return jsonify({
"success": True,
"data": {
"plugin_name": plugin.name,
"module_name": plugin_name,
"supports_group_switch": supports_group_switch,
"feature_key": feature_key,
"feature_description": feature_description,
"enabled_count": len(enabled_groups),
"disabled_count": len(disabled_groups),
"total_group_count": len(all_group_ids),
"enabled_groups": enabled_groups,
"disabled_groups": disabled_groups,
}
})
except Exception as e:
LOG.error(f"获取插件群状态失败: {str(e)}", exc_info=True)
return jsonify({"success": False, "message": f"获取插件群状态失败: {str(e)}"})
@plugin_routes.route('/api/plugins/group_status/toggle', methods=['POST'])
@login_required
def toggle_plugin_group_status():
"""切换插件在指定群的启用状态(开启/关闭)"""
try:
server = current_app.dashboard_server
data = request.get_json() or {}
plugin_name = (data.get('plugin_name') or '').strip()
group_id = (data.get('group_id') or '').strip()
status = (data.get('status') or '').strip().lower()
# 参数校验:保证前端传入完整且合法的切换目标。
if not plugin_name:
return jsonify({"success": False, "message": "缺少插件名称参数"})
if not group_id:
return jsonify({"success": False, "message": "缺少群ID参数"})
if status not in ('enabled', 'disabled'):
return jsonify({"success": False, "message": "状态参数非法,仅支持 enabled / disabled"})
# 查找插件实例,沿用现有查找逻辑,兼容模块名与展示名。
display_name, plugin = server.plugin_manager.find_plugin_by_name(plugin_name)
if not plugin:
return jsonify({"success": False, "message": f"未找到插件: {plugin_name}"})
# 插件必须注册了 feature 才能进行群级开关;否则无法映射到权限系统。
plugin_feature = getattr(plugin, "feature", None)
if plugin_feature is None:
return jsonify({"success": False, "message": "该插件未接入群级开关能力,无法直接切换"})
# 将字符串状态映射到统一权限枚举然后写入群权限缓存与Redis。
target_status = PermissionStatus.ENABLED if status == 'enabled' else PermissionStatus.DISABLED
GroupBotManager.set_group_permission(group_id, plugin_feature, target_status)
# 返回简洁确认信息,方便前端即时提示与二次渲染。
return jsonify({
"success": True,
"message": f"已将群 {group_id} 的插件“{plugin.name}”设置为{'开启' if target_status == PermissionStatus.ENABLED else '关闭'}",
"data": {
"plugin_name": plugin.name,
"group_id": group_id,
"status": target_status.value,
"feature_key": getattr(plugin_feature, "name", ""),
}
})
except Exception as e:
LOG.error(f"切换插件群状态失败: {str(e)}", exc_info=True)
return jsonify({"success": False, "message": f"切换插件群状态失败: {str(e)}"})
@plugin_routes.route('/api/plugins/info', methods=['GET'])
@login_required
def get_plugin_info():
"""获取插件详细信息"""
try:
server = current_app.dashboard_server
plugin_name = request.args.get('plugin_name')
if not plugin_name:
return jsonify({"success": False, "message": "缺少插件名称参数"})
plugin_info = server.plugin_manager.get_plugin_snapshot(plugin_name)
if not plugin_info:
return jsonify({"success": False, "message": f"未找到插件: {plugin_name}"})
return jsonify({"success": True, "data": plugin_info})
except Exception as e:
LOG.error(f"获取插件详情失败: {str(e)}", exc_info=True)
return jsonify({"success": False, "message": f"获取插件详情失败: {str(e)}"})
@plugin_routes.route('/api/plugins/enable', methods=['POST'])
@login_required
def enable_plugin():
"""启用插件"""
try:
server = current_app.dashboard_server
data = request.get_json()
plugin_name = data.get('plugin_name')
if not plugin_name:
return jsonify({"success": False, "message": "缺少插件名称参数"})
# 已加载插件直接启动;尚未加载的插件则先尝试加载,再进入启动流程。
display_name, plugin = server.plugin_manager.find_plugin_by_name(plugin_name)
if not plugin:
plugin = server.plugin_manager.load_plugin(plugin_name)
if plugin:
display_name = plugin.name
if plugin and server.plugin_manager.start_plugin(display_name or plugin_name):
return jsonify({"success": True, "message": f"插件 {plugin_name} 启用成功"})
else:
return jsonify({"success": False, "message": f"插件 {plugin_name} 启用失败"})
except Exception as e:
LOG.error(f"启用插件失败: {str(e)}", exc_info=True)
return jsonify({"success": False, "message": f"启用插件失败: {str(e)}"})
@plugin_routes.route('/api/plugins/disable', methods=['POST'])
@login_required
def disable_plugin():
"""禁用插件"""
try:
server = current_app.dashboard_server
data = request.get_json()
plugin_name = data.get('plugin_name')
if not plugin_name:
return jsonify({"success": False, "message": "缺少插件名称参数"})
# 禁用插件
if server.plugin_manager.stop_plugin(plugin_name):
return jsonify({"success": True, "message": f"插件 {plugin_name} 禁用成功"})
else:
return jsonify({"success": False, "message": f"插件 {plugin_name} 禁用失败"})
except Exception as e:
LOG.error(f"禁用插件失败: {str(e)}", exc_info=True)
return jsonify({"success": False, "message": f"禁用插件失败: {str(e)}"})
@plugin_routes.route('/api/plugins/reload', methods=['POST'])
@login_required
def reload_plugin():
"""重载插件"""
try:
server = current_app.dashboard_server
data = request.get_json()
plugin_name = data.get('plugin_name')
if not plugin_name:
return jsonify({"success": False, "message": "缺少插件名称参数"})
# 已加载插件优先走重载;若当前未加载,则退化为“重新尝试加载并启动”。
display_name, plugin = server.plugin_manager.find_plugin_by_name(plugin_name)
if plugin:
reloaded_plugin = server.plugin_manager.reload_plugin(plugin_name)
else:
reloaded_plugin = server.plugin_manager.load_plugin(plugin_name)
if reloaded_plugin:
server.plugin_manager.start_plugin(reloaded_plugin.name)
if reloaded_plugin:
return jsonify({"success": True, "message": f"插件 {plugin_name} 重载成功"})
else:
return jsonify({"success": False, "message": f"插件 {plugin_name} 重载失败"})
except Exception as e:
LOG.error(f"重载插件失败: {str(e)}", exc_info=True)
return jsonify({"success": False, "message": f"重载插件失败: {str(e)}"})
@plugin_routes.route('/api/plugins/config/raw', methods=['GET'])
@login_required
def get_raw_plugin_config():
"""获取插件原始配置文件内容"""
try:
server = current_app.dashboard_server
plugin_name = request.args.get('plugin_name')
if not plugin_name:
return jsonify({"success": False, "message": "缺少插件名称参数"})
plugin_snapshot = server.plugin_manager.get_plugin_snapshot(plugin_name)
if not plugin_snapshot:
return jsonify({"success": False, "message": f"未找到插件: {plugin_name}"})
config_path = str(plugin_snapshot.get("config_path", "") or "").strip()
if not os.path.exists(config_path):
return jsonify({"success": False, "message": f"配置文件不存在: {config_path}"})
# 读取配置文件内容
with open(config_path, 'r', encoding='utf-8') as f:
config_text = f.read()
# 确定配置文件格式
format_type = 'toml'
if config_path.endswith('.json'):
format_type = 'json'
elif config_path.endswith('.yaml') or config_path.endswith('.yml'):
format_type = 'yaml'
return jsonify({
"success": True,
"data": config_text,
"format": format_type
})
except Exception as e:
LOG.error(f"获取插件配置文件失败: {str(e)}", exc_info=True)
return jsonify({"success": False, "message": f"获取配置文件失败: {str(e)}"})
@plugin_routes.route('/api/plugins/config/update', methods=['POST'])
@login_required
def update_plugin_config():
"""更新插件配置"""
try:
server = current_app.dashboard_server
data = request.json
plugin_name = data.get('plugin_name')
config_text = data.get('config_text')
format_type = data.get('format', 'toml')
if not plugin_name or config_text is None:
return jsonify({"success": False, "message": "缺少必要参数"})
display_name, plugin = server.plugin_manager.find_plugin_by_name(plugin_name)
plugin_snapshot = server.plugin_manager.get_plugin_snapshot(plugin_name)
if not plugin_snapshot:
return jsonify({"success": False, "message": f"未找到插件: {plugin_name}"})
config_path = str(plugin_snapshot.get("config_path", "") or "").strip()
if not config_path:
return jsonify({"success": False, "message": "插件未声明配置路径,暂不支持在线编辑"})
# 保存前先做格式校验:
# 1. 避免把坏 TOML 先写回磁盘,再让插件进入“文件已坏但提示成功”的状态;
# 2. 校验通过后再真正落盘,失败则保留线上旧配置;
# 3. 这也是插件治理中心第一阶段的“配置校验底座”。
try:
if format_type == 'toml':
config_obj = toml.loads(config_text)
elif format_type == 'json':
config_obj = json.loads(config_text)
else:
return jsonify({"success": False, "message": f"不支持的配置格式: {format_type}"})
except Exception as parse_error:
LOG.error(f"解析配置失败: {str(parse_error)}", exc_info=True)
return jsonify({"success": False, "message": f"配置格式校验失败: {str(parse_error)}"})
# 确保配置目录存在
os.makedirs(os.path.dirname(config_path), exist_ok=True)
# 写入配置文件
with open(config_path, 'w', encoding='utf-8') as f:
f.write(config_text)
# 若插件当前已加载,则同步刷新内存中的配置镜像,减少“保存后详情弹窗仍是旧配置”的困惑。
if plugin:
plugin._config = config_obj
return jsonify({"success": True, "message": "配置已保存并通过格式校验"})
except Exception as e:
LOG.error(f"更新插件配置失败: {str(e)}", exc_info=True)
return jsonify({"success": False, "message": f"更新配置失败: {str(e)}"})