NapCatQQ/packages/napcat-webui-backend/src/api/Plugin.ts

599 lines
19 KiB
TypeScript
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 { RequestHandler } from 'express';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger';
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
import path from 'path';
import fs from 'fs';
import compressing from 'compressing';
// Helper to get the plugin manager adapter
const getPluginManager = (): OB11PluginMangerAdapter | null => {
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
if (!ob11) return null;
return ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter;
};
// Helper to get OneBot context
const getOneBotContext = (): NapCatOneBot11Adapter | null => {
return WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
};
/**
* 手动注册插件管理器到 NetworkManager
*/
export const RegisterPluginManagerHandler: RequestHandler = async (_req, res) => {
const ob11 = getOneBotContext();
if (!ob11) {
return sendError(res, 'OneBot context not found');
}
// 检查是否已经注册
const existingManager = ob11.networkManager.findSomeAdapter('plugin_manager');
if (existingManager) {
return sendError(res, '插件管理器已经注册');
}
try {
// 确保插件目录存在
const pluginPath = webUiPathWrapper.pluginPath;
if (!fs.existsSync(pluginPath)) {
fs.mkdirSync(pluginPath, { recursive: true });
}
// 创建并注册插件管理器
const pluginManager = new OB11PluginMangerAdapter(
'plugin_manager',
ob11.core,
ob11,
ob11.actions
);
await ob11.networkManager.registerAdapterAndOpen(pluginManager);
return sendSuccess(res, { message: '插件管理器注册成功' });
} catch (e: any) {
return sendError(res, '注册插件管理器失败: ' + e.message);
}
};
export const GetPluginListHandler: RequestHandler = async (_req, res) => {
const pluginManager = getPluginManager();
if (!pluginManager) {
// 返回成功但带特殊标记
return sendSuccess(res, { plugins: [], pluginManagerNotFound: true, extensionPages: [] });
}
const loadedPlugins = pluginManager.getAllPlugins();
const AllPlugins: Array<{
name: string;
id: string;
version: string;
description: string;
author: string;
homepage?: string;
status: string;
hasConfig: boolean;
hasPages: boolean;
}> = new Array();
// 收集所有插件的扩展页面
const extensionPages: Array<{
pluginId: string;
pluginName: string;
path: string;
title: string;
icon?: string;
description?: string;
}> = [];
// 1. 整理已加载的插件
for (const p of loadedPlugins) {
// 根据插件状态确定 status
let status: string;
if (!p.enable) {
status = 'disabled';
} else if (p.loaded) {
status = 'active';
} else {
status = 'stopped'; // 启用但未加载(可能加载失败)
}
// 检查插件是否有注册页面
const pluginRouter = pluginManager.getPluginRouter(p.id);
const hasPages = pluginRouter?.hasPages() ?? false;
AllPlugins.push({
name: p.packageJson?.plugin || p.name || '', // 优先显示 package.json 的 plugin 字段
id: p.id, // 包名,用于 API 操作
version: p.version || '0.0.0',
description: p.packageJson?.description || '',
author: p.packageJson?.author || '',
homepage: p.packageJson?.homepage,
status,
hasConfig: !!(p.runtime.module?.plugin_config_schema || p.runtime.module?.plugin_config_ui),
hasPages
});
// 收集插件的扩展页面
if (hasPages && pluginRouter) {
const pages = pluginRouter.getPages();
for (const page of pages) {
extensionPages.push({
pluginId: p.id,
pluginName: p.packageJson?.plugin || p.name || p.id,
path: page.path,
title: page.title,
icon: page.icon,
description: page.description
});
}
}
}
return sendSuccess(res, { plugins: AllPlugins, pluginManagerNotFound: false, extensionPages });
};
export const SetPluginStatusHandler: RequestHandler = async (req, res) => {
const { enable, id } = req.body;
if (!id) return sendError(res, 'Plugin id is required');
const pluginManager = getPluginManager();
if (!pluginManager) {
return sendError(res, 'Plugin Manager not found');
}
try {
// 设置插件状态(需要 await因为内部会加载/卸载插件)
await pluginManager.setPluginStatus(id, enable);
// 如果启用,检查插件是否加载成功
if (enable) {
const plugin = pluginManager.getPluginInfo(id);
if (!plugin || !plugin.loaded) {
return sendError(res, 'Plugin load failed: ' + id);
}
}
return sendSuccess(res, { message: 'Status updated successfully' });
} catch (e: any) {
return sendError(res, 'Failed to update status: ' + e.message);
}
};
export const UninstallPluginHandler: RequestHandler = async (req, res) => {
const { id, cleanData } = req.body;
if (!id) return sendError(res, 'Plugin id is required');
const pluginManager = getPluginManager();
if (!pluginManager) {
return sendError(res, 'Plugin Manager not found');
}
try {
await pluginManager.uninstallPlugin(id, cleanData);
return sendSuccess(res, { message: 'Uninstalled successfully' });
} catch (e: any) {
return sendError(res, 'Failed to uninstall: ' + e.message);
}
};
export const GetPluginConfigHandler: RequestHandler = async (req, res) => {
const id = req.query['id'] as string;
if (!id) return sendError(res, 'Plugin id is required');
const pluginManager = getPluginManager();
if (!pluginManager) return sendError(res, 'Plugin Manager not found');
const plugin = pluginManager.getPluginInfo(id);
if (!plugin) return sendError(res, 'Plugin not loaded');
// 获取配置值
let config: unknown = {};
if (plugin.runtime.module?.plugin_get_config && plugin.runtime.context) {
try {
config = await plugin.runtime.module?.plugin_get_config(plugin.runtime.context);
} catch (e) { }
} else {
// Default behavior: read from default config path
try {
const configPath = plugin.runtime.context?.configPath || pluginManager.getPluginConfigPath(id);
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
} catch (e) { }
}
// 获取静态 schema
const schema = plugin.runtime.module?.plugin_config_schema || plugin.runtime.module?.plugin_config_ui || [];
// 检查是否支持动态控制
const supportReactive = !!(plugin.runtime.module?.plugin_config_controller || plugin.runtime.module?.plugin_on_config_change);
return sendSuccess(res, { schema, config, supportReactive });
};
/** 活跃的 SSE 连接 */
const activeConfigSessions = new Map<string, {
res: any;
cleanup?: () => void;
currentConfig: Record<string, any>;
}>();
/**
* 插件配置 SSE 连接 - 用于动态更新配置界面
*/
export const PluginConfigSSEHandler: RequestHandler = (req, res): void => {
const id = req.query['id'] as string;
const initialConfigStr = req.query['config'] as string;
if (!id) {
res.status(400).json({ error: 'Plugin id is required' });
return;
}
const pluginManager = getPluginManager();
if (!pluginManager) {
res.status(400).json({ error: 'Plugin Manager not found' });
return;
}
const plugin = pluginManager.getPluginInfo(id);
if (!plugin) {
res.status(400).json({ error: 'Plugin not loaded' });
return;
}
// 设置 SSE 头
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
// 生成会话 ID
const sessionId = `${id}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
// 解析初始配置
let currentConfig: Record<string, any> = {};
if (initialConfigStr) {
try {
currentConfig = JSON.parse(initialConfigStr);
} catch (e) { }
}
// 发送 SSE 消息的辅助函数
const sendSSE = (event: string, data: any) => {
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
// 创建 UI 控制器
const uiController = {
updateSchema: (schema: any[]) => {
sendSSE('schema', { type: 'full', schema });
},
updateField: (key: string, field: any) => {
sendSSE('schema', { type: 'updateField', key, field });
},
removeField: (key: string) => {
sendSSE('schema', { type: 'removeField', key });
},
addField: (field: any, afterKey?: string) => {
sendSSE('schema', { type: 'addField', field, afterKey });
},
showField: (key: string) => {
sendSSE('schema', { type: 'showField', key });
},
hideField: (key: string) => {
sendSSE('schema', { type: 'hideField', key });
},
getCurrentConfig: () => currentConfig
};
// 存储会话
activeConfigSessions.set(sessionId, { res, currentConfig });
// 发送连接成功消息
sendSSE('connected', { sessionId });
// 调用插件的控制器初始化(异步处理)
(async () => {
let cleanup: (() => void) | undefined;
if (plugin.runtime.module?.plugin_config_controller && plugin.runtime.context) {
try {
const result = await plugin.runtime.module.plugin_config_controller(
plugin.runtime.context,
uiController,
currentConfig
);
if (typeof result === 'function') {
cleanup = result;
}
} catch (e: any) {
sendSSE('error', { message: e.message });
}
}
// 更新会话的 cleanup
const session = activeConfigSessions.get(sessionId);
if (session) {
session.cleanup = cleanup;
}
})();
// 心跳保持连接
const heartbeat = setInterval(() => {
sendSSE('ping', { time: Date.now() });
}, 30000);
// 连接关闭时清理
req.on('close', () => {
clearInterval(heartbeat);
const session = activeConfigSessions.get(sessionId);
if (session?.cleanup) {
try {
session.cleanup();
} catch (e) { }
}
activeConfigSessions.delete(sessionId);
});
};
/**
* 插件配置字段变化通知
*/
export const PluginConfigChangeHandler: RequestHandler = async (req, res) => {
const { id, sessionId, key, value, currentConfig } = req.body;
if (!id || !sessionId || !key) {
return sendError(res, 'Missing required parameters');
}
const pluginManager = getPluginManager();
if (!pluginManager) return sendError(res, 'Plugin Manager not found');
const plugin = pluginManager.getPluginInfo(id);
if (!plugin) return sendError(res, 'Plugin not loaded');
// 获取会话
const session = activeConfigSessions.get(sessionId);
if (!session) {
return sendError(res, 'Session not found');
}
// 更新会话中的当前配置
session.currentConfig = currentConfig || {};
// 如果插件有响应式处理器,调用它
if (plugin.runtime.module?.plugin_on_config_change) {
const uiController = {
updateSchema: (schema: any[]) => {
session.res.write(`event: schema\n`);
session.res.write(`data: ${JSON.stringify({ type: 'full', schema })}\n\n`);
},
updateField: (fieldKey: string, field: any) => {
session.res.write(`event: schema\n`);
session.res.write(`data: ${JSON.stringify({ type: 'updateField', key: fieldKey, field })}\n\n`);
},
removeField: (fieldKey: string) => {
session.res.write(`event: schema\n`);
session.res.write(`data: ${JSON.stringify({ type: 'removeField', key: fieldKey })}\n\n`);
},
addField: (field: any, afterKey?: string) => {
session.res.write(`event: schema\n`);
session.res.write(`data: ${JSON.stringify({ type: 'addField', field, afterKey })}\n\n`);
},
showField: (fieldKey: string) => {
session.res.write(`event: schema\n`);
session.res.write(`data: ${JSON.stringify({ type: 'showField', key: fieldKey })}\n\n`);
},
hideField: (fieldKey: string) => {
session.res.write(`event: schema\n`);
session.res.write(`data: ${JSON.stringify({ type: 'hideField', key: fieldKey })}\n\n`);
},
getCurrentConfig: () => session.currentConfig
};
try {
if (plugin.runtime.context) {
await plugin.runtime.module.plugin_on_config_change(
plugin.runtime.context,
uiController,
key,
value,
currentConfig || {}
);
}
} catch (e: any) {
session.res.write(`event: error\n`);
session.res.write(`data: ${JSON.stringify({ message: e.message })}\n\n`);
}
}
return sendSuccess(res, { message: 'Change processed' });
};
export const SetPluginConfigHandler: RequestHandler = async (req, res) => {
const { id, config } = req.body;
if (!id || !config) return sendError(res, 'Plugin id and config required');
const pluginManager = getPluginManager();
if (!pluginManager) return sendError(res, 'Plugin Manager not found');
const plugin = pluginManager.getPluginInfo(id);
if (!plugin) return sendError(res, 'Plugin not loaded');
if (plugin.runtime.module?.plugin_set_config && plugin.runtime.context) {
try {
await plugin.runtime.module.plugin_set_config(plugin.runtime.context, config);
return sendSuccess(res, { message: 'Config updated' });
} catch (e: any) {
return sendError(res, 'Error updating config: ' + e.message);
}
} else if (plugin.runtime.module?.plugin_config_schema || plugin.runtime.module?.plugin_config_ui || plugin.runtime.module?.plugin_config_controller) {
// Default behavior: write to default config path
try {
const configPath = plugin.runtime.context?.configPath || pluginManager.getPluginConfigPath(id);
const configDir = path.dirname(configPath);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
// Auto-Reload plugin to apply changes
await pluginManager.reloadPlugin(id);
return sendSuccess(res, { message: 'Config saved and plugin reloaded' });
} catch (e: any) {
return sendError(res, 'Error saving config: ' + e.message);
}
} else {
return sendError(res, 'Plugin does not support config update');
}
};
/**
* 导入本地插件包(支持 .zip 文件)
*/
export const ImportLocalPluginHandler: RequestHandler = async (req, res) => {
const pluginManager = getPluginManager();
if (!pluginManager) {
return sendError(res, 'Plugin Manager not found');
}
// multer 会将文件信息放在 req.file 中
const file = req.file;
if (!file) {
return sendError(res, 'No file uploaded');
}
const PLUGINS_DIR = webUiPathWrapper.pluginPath;
// 确保插件目录存在
if (!fs.existsSync(PLUGINS_DIR)) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
}
const tempZipPath = file.path;
try {
// 创建临时解压目录
const tempExtractDir = path.join(PLUGINS_DIR, `_temp_extract_${Date.now()}`);
fs.mkdirSync(tempExtractDir, { recursive: true });
// 解压到临时目录
await compressing.zip.uncompress(tempZipPath, tempExtractDir);
// 检查解压后的内容
const extractedItems = fs.readdirSync(tempExtractDir);
let pluginSourceDir: string;
let pluginId: string;
// 判断解压结构:可能是直接的插件文件,或者包含一个子目录
const hasPackageJson = extractedItems.includes('package.json');
const hasIndexFile = extractedItems.some(item =>
['index.js', 'index.mjs', 'main.js', 'main.mjs'].includes(item)
);
if (hasPackageJson || hasIndexFile) {
// 直接是插件文件
pluginSourceDir = tempExtractDir;
// 尝试从 package.json 获取插件 ID
const packageJsonPath = path.join(tempExtractDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
pluginId = pkg.name || path.basename(file.originalname, '.zip');
} catch {
pluginId = path.basename(file.originalname, '.zip');
}
} else {
pluginId = path.basename(file.originalname, '.zip');
}
} else if (extractedItems.length === 1 && fs.statSync(path.join(tempExtractDir, extractedItems[0]!)).isDirectory()) {
// 包含一个子目录
const subDir = extractedItems[0]!;
pluginSourceDir = path.join(tempExtractDir, subDir);
// 尝试从子目录的 package.json 获取插件 ID
const packageJsonPath = path.join(pluginSourceDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
pluginId = pkg.name || subDir;
} catch {
pluginId = subDir;
}
} else {
pluginId = subDir;
}
} else {
// 清理临时文件
fs.rmSync(tempExtractDir, { recursive: true, force: true });
fs.unlinkSync(tempZipPath);
return sendError(res, 'Invalid plugin package structure');
}
// 目标插件目录
const targetPluginDir = path.join(PLUGINS_DIR, pluginId);
// 如果目标目录已存在,先删除
if (fs.existsSync(targetPluginDir)) {
// 先卸载已存在的插件
const existingPlugin = pluginManager.getPluginInfo(pluginId);
if (existingPlugin && existingPlugin.loaded) {
await pluginManager.unregisterPlugin(pluginId);
}
fs.rmSync(targetPluginDir, { recursive: true, force: true });
}
// 移动插件文件到目标目录
if (pluginSourceDir === tempExtractDir) {
// 直接重命名临时目录
fs.renameSync(tempExtractDir, targetPluginDir);
} else {
// 移动子目录内容
fs.renameSync(pluginSourceDir, targetPluginDir);
// 清理临时目录
fs.rmSync(tempExtractDir, { recursive: true, force: true });
}
// 删除上传的临时文件
if (fs.existsSync(tempZipPath)) {
fs.unlinkSync(tempZipPath);
}
// 加载插件
const loaded = await pluginManager.loadPluginById(pluginId);
if (loaded) {
return sendSuccess(res, {
message: 'Plugin imported and loaded successfully',
pluginId,
installPath: targetPluginDir,
});
} else {
return sendSuccess(res, {
message: 'Plugin imported but failed to load (check plugin structure)',
pluginId,
installPath: targetPluginDir,
});
}
} catch (e: any) {
// 清理临时文件
if (fs.existsSync(tempZipPath)) {
fs.unlinkSync(tempZipPath);
}
return sendError(res, 'Failed to import plugin: ' + e.message);
}
};