diff --git a/packages/napcat-onebot/network/plugin-manger.ts b/packages/napcat-onebot/network/plugin-manger.ts index b5797680..1a13ba8d 100644 --- a/packages/napcat-onebot/network/plugin-manger.ts +++ b/packages/napcat-onebot/network/plugin-manger.ts @@ -1,9 +1,9 @@ -import { OB11EmitEventContent, OB11NetworkReloadType } from './index'; -import { NapCatOneBot11Adapter, OB11Message } from '@/napcat-onebot/index'; -import { NapCatCore } from 'napcat-core'; -import { PluginConfig } from '../config/config'; import { ActionMap } from '../action'; +import { NapCatCore } from 'napcat-core'; +import { NapCatOneBot11Adapter, OB11Message } from '@/napcat-onebot/index'; +import { OB11EmitEventContent, OB11NetworkReloadType } from './index'; import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter'; +import { PluginConfig } from '../config/config'; import fs from 'fs'; import path from 'path'; @@ -16,10 +16,34 @@ export interface PluginPackageJson { } export interface PluginModule { - plugin_init: (core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise; - plugin_onmessage?: (adapter: string, core: NapCatCore, obCtx: NapCatOneBot11Adapter, event: OB11Message, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise; - plugin_onevent?: (adapter: string, core: NapCatCore, obCtx: NapCatOneBot11Adapter, event: T, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise; - plugin_cleanup?: (core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise; + plugin_init: ( + core: NapCatCore, + obContext: NapCatOneBot11Adapter, + actions: ActionMap, + instance: OB11PluginMangerAdapter + ) => void | Promise; + plugin_onmessage?: ( + adapter: string, + core: NapCatCore, + obCtx: NapCatOneBot11Adapter, + event: OB11Message, + actions: ActionMap, + instance: OB11PluginMangerAdapter + ) => void | Promise; + plugin_onevent?: ( + adapter: string, + core: NapCatCore, + obCtx: NapCatOneBot11Adapter, + event: T, + actions: ActionMap, + instance: OB11PluginMangerAdapter + ) => void | Promise; + plugin_cleanup?: ( + core: NapCatCore, + obContext: NapCatOneBot11Adapter, + actions: ActionMap, + instance: OB11PluginMangerAdapter + ) => void | Promise; } export interface LoadedPlugin { @@ -31,16 +55,25 @@ export interface LoadedPlugin { module: PluginModule; } +export interface PluginStatusConfig { + [key: string]: boolean; // key: pluginName, value: enabled +} + export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { private readonly pluginPath: string; + private readonly configPath: string; private loadedPlugins: Map = new Map(); declare config: PluginConfig; + override get isActive (): boolean { return this.isEnable && this.loadedPlugins.size > 0; } constructor ( - name: string, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap + name: string, + core: NapCatCore, + obContext: NapCatOneBot11Adapter, + actions: ActionMap ) { const config = { name, @@ -51,24 +84,60 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { }; super(name, config, core, obContext, actions); this.pluginPath = this.core.context.pathWrapper.pluginPath; + this.configPath = path.join(this.core.context.pathWrapper.configPath, 'plugins.json'); + } + + private loadPluginConfig (): PluginStatusConfig { + if (fs.existsSync(this.configPath)) { + try { + return JSON.parse(fs.readFileSync(this.configPath, 'utf-8')); + } catch (e) { + this.logger.logWarn('[Plugin Adapter] Error parsing plugins.json', e); + } + } + return {}; + } + + private savePluginConfig (config: PluginStatusConfig) { + try { + fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), 'utf-8'); + } catch (e) { + this.logger.logError('[Plugin Adapter] Error saving plugins.json', e); + } } /** - * 扫描并加载插件 - */ + * 扫描并加载插件 + */ private async loadPlugins (): Promise { try { // 确保插件目录存在 if (!fs.existsSync(this.pluginPath)) { - this.logger.logWarn(`[Plugin Adapter] Plugin directory does not exist: ${this.pluginPath}`); + this.logger.logWarn( + `[Plugin Adapter] Plugin directory does not exist: ${this.pluginPath}` + ); fs.mkdirSync(this.pluginPath, { recursive: true }); return; } const items = fs.readdirSync(this.pluginPath, { withFileTypes: true }); + const pluginConfig = this.loadPluginConfig(); // 扫描文件和目录 for (const item of items) { + let pluginName = ''; + if (item.isFile()) { + pluginName = path.parse(item.name).name; + } else if (item.isDirectory()) { + pluginName = item.name; + } + + // Check if plugin is disabled in config + if (pluginConfig[pluginName] === false) { + this.logger.log(`[Plugin Adapter] Plugin ${pluginName} is disabled in config, skipping`); + continue; + } + if (item.isFile()) { // 处理单文件插件 await this.loadFilePlugin(item.name); @@ -78,15 +147,17 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { } } - this.logger.log(`[Plugin Adapter] Loaded ${this.loadedPlugins.size} plugins`); + this.logger.log( + `[Plugin Adapter] Loaded ${this.loadedPlugins.size} plugins` + ); } catch (error) { this.logger.logError('[Plugin Adapter] Error loading plugins:', error); } } /** - * 加载单文件插件 (.mjs, .js) - */ + * 加载单文件插件 (.mjs, .js) + */ public async loadFilePlugin (filename: string): Promise { // 只处理支持的文件类型 if (!this.isSupportedFile(filename)) { @@ -95,11 +166,20 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { const filePath = path.join(this.pluginPath, filename); const pluginName = path.parse(filename).name; + const pluginConfig = this.loadPluginConfig(); + + // Check if plugin is disabled in config + if (pluginConfig[pluginName] === false) { + this.logger.log(`[Plugin Adapter] Plugin ${pluginName} is disabled by user`); + return; + } try { const module = await this.importModule(filePath); if (!this.isValidPluginModule(module)) { - this.logger.logWarn(`[Plugin Adapter] File ${filename} is not a valid plugin (missing plugin methods)`); + this.logger.logWarn( + `[Plugin Adapter] File ${filename} is not a valid plugin (missing plugin methods)` + ); return; } @@ -112,15 +192,31 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { await this.registerPlugin(plugin); } catch (error) { - this.logger.logError(`[Plugin Adapter] Error loading file plugin ${filename}:`, error); + this.logger.logError( + `[Plugin Adapter] Error loading file plugin ${filename}:`, + error + ); } } /** - * 加载目录插件 - */ + * 加载目录插件 + */ public async loadDirectoryPlugin (dirname: string): Promise { const pluginDir = path.join(this.pluginPath, dirname); + const pluginConfig = this.loadPluginConfig(); + + // Ideally we'd get the name from package.json first, but we can use dirname as a fallback identifier initially. + // However, the list scan uses item.name (dirname) as the key. Let's stick to using dirname/filename as the config key for simplicity and consistency. + // Wait, package.json name might override. But for management, consistent ID is better. + // Let's check config after parsing package.json? + // User expects to disable 'plugin-name'. But if multiple folders have same name? Not handled. + // Let's use dirname as the key for config to be consistent with file system. + + if (pluginConfig[dirname] === false) { + this.logger.log(`[Plugin Adapter] Plugin ${dirname} is disabled by user`); + return; + } try { // 尝试读取 package.json @@ -132,14 +228,22 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { const packageContent = fs.readFileSync(packageJsonPath, 'utf-8'); packageJson = JSON.parse(packageContent); } catch (error) { - this.logger.logWarn(`[Plugin Adapter] Invalid package.json in ${dirname}:`, error); + this.logger.logWarn( + `[Plugin Adapter] Invalid package.json in ${dirname}:`, + error + ); } } + // Check if disabled by package name IF package.json exists? + // No, file system name is more reliable ID for resource management here. + // 确定入口文件 const entryFile = this.findEntryFile(pluginDir, packageJson); if (!entryFile) { - this.logger.logWarn(`[Plugin Adapter] No valid entry file found for plugin directory: ${dirname}`); + this.logger.logWarn( + `[Plugin Adapter] No valid entry file found for plugin directory: ${dirname}` + ); return; } @@ -147,7 +251,9 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { const module = await this.importModule(entryPath); if (!this.isValidPluginModule(module)) { - this.logger.logWarn(`[Plugin Adapter] Directory ${dirname} does not contain a valid plugin`); + this.logger.logWarn( + `[Plugin Adapter] Directory ${dirname} does not contain a valid plugin` + ); return; } @@ -162,14 +268,20 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { await this.registerPlugin(plugin); } catch (error) { - this.logger.logError(`[Plugin Adapter] Error loading directory plugin ${dirname}:`, error); + this.logger.logError( + `[Plugin Adapter] Error loading directory plugin ${dirname}:`, + error + ); } } /** - * 查找插件目录的入口文件 - */ - private findEntryFile (pluginDir: string, packageJson?: PluginPackageJson): string | null { + * 查找插件目录的入口文件 + */ + private findEntryFile ( + pluginDir: string, + packageJson?: PluginPackageJson + ): string | null { // 优先级:package.json main > 默认文件名 const possibleEntries = [ packageJson?.main, @@ -190,53 +302,69 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { } /** - * 检查是否为支持的文件类型 - */ + * 检查是否为支持的文件类型 + */ private isSupportedFile (filename: string): boolean { const ext = path.extname(filename).toLowerCase(); return ['.mjs', '.js'].includes(ext); } /** - * 动态导入模块 - */ + * 动态导入模块 + */ private async importModule (filePath: string): Promise { const fileUrl = `file://${filePath.replace(/\\/g, '/')}`; - return await import(fileUrl); + // Add timestamp to force reload cache if supported or just import + // Note: dynamic import caching is tricky in ESM. Adding query param might help? + const fileUrlWithQuery = `${fileUrl}?t=${Date.now()}`; + return await import(fileUrlWithQuery); } /** - * 检查模块是否为有效的插件模块 - */ + * 检查模块是否为有效的插件模块 + */ private isValidPluginModule (module: any): module is PluginModule { return module && typeof module.plugin_init === 'function'; } /** - * 注册插件 - */ + * 注册插件 + */ private async registerPlugin (plugin: LoadedPlugin): Promise { // 检查名称冲突 if (this.loadedPlugins.has(plugin.name)) { - this.logger.logWarn(`[Plugin Adapter] Plugin name conflict: ${plugin.name}, skipping...`); + this.logger.logWarn( + `[Plugin Adapter] Plugin name conflict: ${plugin.name}, skipping...` + ); return; } this.loadedPlugins.set(plugin.name, plugin); - this.logger.log(`[Plugin Adapter] Registered plugin: ${plugin.name}${plugin.version ? ` v${plugin.version}` : ''}`); + this.logger.log( + `[Plugin Adapter] Registered plugin: ${plugin.name}${plugin.version ? ` v${plugin.version}` : '' + }` + ); // 调用插件初始化方法(必须存在) try { - await plugin.module.plugin_init(this.core, this.obContext, this.actions, this); + await plugin.module.plugin_init( + this.core, + this.obContext, + this.actions, + this + ); this.logger.log(`[Plugin Adapter] Initialized plugin: ${plugin.name}`); } catch (error) { - this.logger.logError(`[Plugin Adapter] Error initializing plugin ${plugin.name}:`, error); + this.logger.logError( + `[Plugin Adapter] Error initializing plugin ${plugin.name}:`, + error + ); } } /** - * 卸载插件 - */ + * 卸载插件 + */ private async unloadPlugin (pluginName: string): Promise { const plugin = this.loadedPlugins.get(pluginName); if (!plugin) { @@ -246,10 +374,18 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { // 调用插件清理方法 if (typeof plugin.module.plugin_cleanup === 'function') { try { - await plugin.module.plugin_cleanup(this.core, this.obContext, this.actions, this); + await plugin.module.plugin_cleanup( + this.core, + this.obContext, + this.actions, + this + ); this.logger.log(`[Plugin Adapter] Cleaned up plugin: ${pluginName}`); } catch (error) { - this.logger.logError(`[Plugin Adapter] Error cleaning up plugin ${pluginName}:`, error); + this.logger.logError( + `[Plugin Adapter] Error cleaning up plugin ${pluginName}:`, + error + ); } } @@ -265,6 +401,61 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { return this.pluginPath; } + public getPluginConfig (): PluginStatusConfig { + return this.loadPluginConfig(); + } + + public setPluginStatus (pluginName: string, enable: boolean): void { + const config = this.loadPluginConfig(); + config[pluginName] = enable; + this.savePluginConfig(config); + + // If disabling, unload immediately if loaded + if (!enable) { + // Note: pluginName passed here might be the package name or the filename/dirname + // But our registerPlugin uses plugin.name which comes from package.json or dirname. + // This mismatch is tricky. + // Ideally, we should use a consistent ID. + // Let's assume pluginName passed here effectively matches the ID used in loadedPlugins. + // But wait, loadDirectoryPlugin logic: name = packageJson.name || dirname. + // config key = dirname. + // If packageJson.name != dirname, we have a problem. + // To fix this properly: + // 1. We need to know which LoadedPlugin corresponds to the enabled/disabled item. + // 2. Or we iterate loadedPlugins and find match. + + for (const [_, loaded] of this.loadedPlugins.entries()) { + const dirOrFile = path.basename(loaded.pluginPath === this.pluginPath ? loaded.entryPath : loaded.pluginPath); + const ext = path.extname(dirOrFile); + const simpleName = ext ? path.parse(dirOrFile).name : dirOrFile; // filename without ext + + // But wait, config key is the FILENAME (with ext for files?). + // In Scan loop: + // pluginName = path.parse(item.name).name (for file) + // pluginName = item.name (for dir) + // config[pluginName] check. + + // So if file is "test.js", pluginName is "test". Config key "test". + // If dir is "test-plugin", pluginName is "test-plugin". Config key "test-plugin". + + // loadedPlugin.name might be distinct. + // So we need to match loadedPlugin back to its fs source to unload it? + + // loadedPlugin.entryPath or pluginPath helps. + // If it's a file plugin: loaded.entryPath ends with pluginName + ext. + // If it's a dir plugin: loaded.pluginPath ends with pluginName. + + if (pluginName === simpleName) { + this.unloadPlugin(loaded.name).catch(e => this.logger.logError('Error unloading', e)); + } + } + } + // If enabling, we need to load it. + // But we can just rely on the API handler to call loadFile/DirectoryPlugin which now checks config. + // Wait, if I call loadFilePlugin("test.js") and config says enable=true, it loads. + // API handler needs to change to pass filename/dirname. + } + async onEvent (event: T) { if (!this.isEnable) { return; @@ -283,21 +474,44 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { } /** - * 调用插件的事件处理方法 - */ - private async callPluginEventHandler (plugin: LoadedPlugin, event: OB11EmitEventContent): Promise { + * 调用插件的事件处理方法 + */ + private async callPluginEventHandler ( + plugin: LoadedPlugin, + event: OB11EmitEventContent + ): Promise { try { // 优先使用 plugin_onevent 方法 if (typeof plugin.module.plugin_onevent === 'function') { - await plugin.module.plugin_onevent(this.name, this.core, this.obContext, event, this.actions, this); + await plugin.module.plugin_onevent( + this.name, + this.core, + this.obContext, + event, + this.actions, + this + ); } // 如果是消息事件并且插件有 plugin_onmessage 方法,也调用 - if ((event as any).message_type && typeof plugin.module.plugin_onmessage === 'function') { - await plugin.module.plugin_onmessage(this.name, this.core, this.obContext, event as OB11Message, this.actions, this); + if ( + (event as any).message_type && + typeof plugin.module.plugin_onmessage === 'function' + ) { + await plugin.module.plugin_onmessage( + this.name, + this.core, + this.obContext, + event as OB11Message, + this.actions, + this + ); } } catch (error) { - this.logger.logError(`[Plugin Adapter] Error calling plugin ${plugin.name} event handler:`, error); + this.logger.logError( + `[Plugin Adapter] Error calling plugin ${plugin.name} event handler:`, + error + ); } } @@ -312,7 +526,9 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { // 加载所有插件 await this.loadPlugins(); - this.logger.log(`[Plugin Adapter] Plugin adapter opened with ${this.loadedPlugins.size} plugins loaded`); + this.logger.log( + `[Plugin Adapter] Plugin adapter opened with ${this.loadedPlugins.size} plugins loaded` + ); } async close () { @@ -344,22 +560,22 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { } /** - * 获取已加载的插件列表 - */ + * 获取已加载的插件列表 + */ public getLoadedPlugins (): LoadedPlugin[] { return Array.from(this.loadedPlugins.values()); } /** - * 获取插件信息 - */ + * 获取插件信息 + */ public getPluginInfo (pluginName: string): LoadedPlugin | undefined { return this.loadedPlugins.get(pluginName); } /** - * 重载指定插件 - */ + * 重载指定插件 + */ public async reloadPlugin (pluginName: string): Promise { const plugin = this.loadedPlugins.get(pluginName); if (!plugin) { @@ -372,8 +588,10 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { await this.unloadPlugin(pluginName); // 重新加载插件 - const isDirectory = fs.statSync(plugin.pluginPath).isDirectory() && - plugin.pluginPath !== this.pluginPath; + // Use logic to re-determine if it is directory or file based on original paths + // Note: we can't fully trust fs status if it's gone. + const isDirectory = + plugin.pluginPath !== this.pluginPath; // Simple check: if path is nested, it's a dir plugin if (isDirectory) { const dirname = path.basename(plugin.pluginPath); @@ -383,10 +601,15 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { await this.loadFilePlugin(filename); } - this.logger.log(`[Plugin Adapter] Plugin ${pluginName} reloaded successfully`); + this.logger.log( + `[Plugin Adapter] Plugin ${pluginName} reloaded successfully` + ); return true; } catch (error) { - this.logger.logError(`[Plugin Adapter] Error reloading plugin ${pluginName}:`, error); + this.logger.logError( + `[Plugin Adapter] Error reloading plugin ${pluginName}:`, + error + ); return false; } } diff --git a/packages/napcat-plugin-builtin/package.json b/packages/napcat-plugin-builtin/package.json index 68120f79..2f4a5bfe 100644 --- a/packages/napcat-plugin-builtin/package.json +++ b/packages/napcat-plugin-builtin/package.json @@ -4,6 +4,7 @@ "type": "module", "main": "index.mjs", "description": "NapCat 内置插件", + "author": "NapNeko", "dependencies": { "napcat-onebot": "workspace:*" }, diff --git a/packages/napcat-webui-backend/src/api/Plugin.ts b/packages/napcat-webui-backend/src/api/Plugin.ts index 738e5cea..ab6b7651 100644 --- a/packages/napcat-webui-backend/src/api/Plugin.ts +++ b/packages/napcat-webui-backend/src/api/Plugin.ts @@ -19,58 +19,109 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => { return sendError(res, 'Plugin Manager not found'); } - const loadedPlugins = pluginManager.getLoadedPlugins().map(p => ({ - name: p.name, - version: p.version || '0.0.0', - description: p.packageJson?.description || '', - author: p.packageJson?.author || '', - status: 'active', - })); + // 辅助函数:根据文件名/路径生成唯一ID(作为配置键) + const getPluginId = (fsName: string, isFile: boolean): string => { + if (isFile) { + return path.parse(fsName).name; + } + return fsName; + }; + + const loadedPlugins = pluginManager.getLoadedPlugins(); + const loadedPluginMap = new Map(); // Map ID -> Loaded Info + + // 1. 整理已加载的插件 + for (const p of loadedPlugins) { + // 计算 ID:需要回溯到加载时的入口信息 + // 对于已加载的插件,我们通过判断 pluginPath 是否等于根 pluginPath 来判断它是单文件还是目录 + const isFilePlugin = p.pluginPath === pluginManager.getPluginPath(); + const fsName = isFilePlugin ? path.basename(p.entryPath) : path.basename(p.pluginPath); + const id = getPluginId(fsName, isFilePlugin); + + loadedPluginMap.set(id, { + name: p.packageJson?.name || p.name, // 优先使用 package.json 的 name + id: id, + version: p.version || '0.0.0', + description: p.packageJson?.description || '', + author: p.packageJson?.author || '', + status: 'active', + filename: fsName, // 真实文件/目录名 + loadedName: p.name // 运行时注册的名称,用于重载/卸载 + }); + } - // Find disabled plugins (those with .disabled extension) const pluginPath = pluginManager.getPluginPath(); - const disabledPlugins: any[] = []; + const pluginConfig = pluginManager.getPluginConfig(); + const allPlugins: any[] = []; + + // 2. 扫描文件系统,合并状态 if (fs.existsSync(pluginPath)) { const items = fs.readdirSync(pluginPath, { withFileTypes: true }); + for (const item of items) { - if (item.name.endsWith('.disabled')) { - const originalName = item.name.replace(/\.disabled$/, ''); - const isDirectory = item.isDirectory(); + let id = ''; + let isFile = false; + + if (item.isFile()) { + if (!['.js', '.mjs'].includes(path.extname(item.name))) continue; + isFile = true; + id = getPluginId(item.name, true); + } else if (item.isDirectory()) { + id = getPluginId(item.name, false); + } else { + continue; + } + + const isActiveConfig = pluginConfig[id] !== false; // 默认为 true + + if (loadedPluginMap.has(id)) { + // 已加载,使用加载的信息 + const loadedInfo = loadedPluginMap.get(id); + allPlugins.push(loadedInfo); + } else { + // 未加载 (可能是被禁用,或者加载失败,或者新增未运行) let version = '0.0.0'; let description = ''; let author = ''; - let name = originalName; + // 默认显示名称为 ID (文件名/目录名) + let name = id; try { - if (isDirectory) { + // 尝试读取 package.json 获取信息 + if (item.isDirectory()) { const packageJsonPath = path.join(pluginPath, item.name, 'package.json'); if (fs.existsSync(packageJsonPath)) { const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); version = pkg.version || version; description = pkg.description || description; author = pkg.author || author; + // 如果 package.json 有 name,优先使用 name = pkg.name || name; } } } catch (e) { } - disabledPlugins.push({ + allPlugins.push({ name: name, + id: id, version, description, author, - status: 'disabled', - filename: item.name // Store real filename for operations + // 如果配置是 false,则为 disabled;否则是 stopped (应启动但未启动) + status: isActiveConfig ? 'stopped' : 'disabled', + filename: item.name }); } } } - return sendSuccess(res, [...loadedPlugins, ...disabledPlugins]); + return sendSuccess(res, allPlugins); }; export const ReloadPluginHandler: RequestHandler = async (req, res) => { const { name } = req.body; + // Note: we should probably accept ID or Name. But ReloadPlugin uses valid loaded name. + // Let's stick to name for now, but be aware of ambiguity. if (!name) return sendError(res, 'Plugin Name is required'); const pluginManager = getPluginManager(); @@ -87,74 +138,51 @@ export const ReloadPluginHandler: RequestHandler = async (req, res) => { }; export const SetPluginStatusHandler: RequestHandler = async (req, res) => { - const { name, enable, filename } = req.body; // filename required for enabling - if (!name) return sendError(res, 'Plugin Name is required'); + const { enable, filename } = req.body; + // We Use filename / id to control config + // Front-end should pass the 'filename' or 'id' as the key identifier + + if (!filename) return sendError(res, 'Plugin Filename/ID is required'); const pluginManager = getPluginManager(); if (!pluginManager) { return sendError(res, 'Plugin Manager not found'); } - const pluginPath = pluginManager.getPluginPath(); + // Calculate ID from filename (remove ext if file) + // Or just use the logic consistent with loadPlugins + let id = filename; + // If it has extension .js/.mjs, remove it to get the ID used in config + if (filename.endsWith('.js') || filename.endsWith('.mjs')) { + id = path.parse(filename).name; + } - if (enable) { - // Enable: Rename back from .disabled - // We need the filename since we can't guess if it was a dir or file easily without scanning or passing it - if (!filename) return sendError(res, 'Filename is required to enable'); + try { + pluginManager.setPluginStatus(id, enable); - const disabledPath = path.join(pluginPath, filename); - const enabledPath = path.join(pluginPath, filename.replace(/\.disabled$/, '')); + // If enabling, trigger load + if (enable) { + const pluginPath = pluginManager.getPluginPath(); + const fullPath = path.join(pluginPath, filename); - if (!fs.existsSync(disabledPath)) { - return sendError(res, 'Disabled plugin not found'); - } - - try { - fs.renameSync(disabledPath, enabledPath); - // After rename, we need to load it - const isDirectory = fs.statSync(enabledPath).isDirectory(); - if (isDirectory) { - await pluginManager.loadDirectoryPlugin(path.basename(enabledPath)); + if (fs.statSync(fullPath).isDirectory()) { + await pluginManager.loadDirectoryPlugin(filename); } else { - await pluginManager.loadFilePlugin(path.basename(enabledPath)); + await pluginManager.loadFilePlugin(filename); } - return sendSuccess(res, { message: 'Enabled successfully' }); - } catch (e: any) { - return sendError(res, 'Failed to enable: ' + e.message); + } else { + // Disabling is handled inside setPluginStatus usually if implemented, + // OR we can explicitly unload here using the loaded name. + // The Manager's setPluginStatus implementation (if added) might logic this out. + // But our current Manager implementation just saves config. + // Wait, I updated Manager to try to unload. + // Let's rely on Manager's setPluginStatus or do it here? + // I implemented a basic unload loop in Manager.setPluginStatus. } - } else { - // Disable: Unload and rename to .disabled - const plugin = pluginManager.getPluginInfo(name); - if (!plugin) return sendError(res, 'Plugin not found/loaded'); - - try { - await pluginManager.unregisterPlugin(name); - // Determine the file/dir key in the fs - - // plugin.pluginPath is the directory for dir plugins, and the directory containing the file for file plugins?? - // Let's check LoadedPlugin definition again. - // pluginPath: this.pluginPath (for file plugins), pluginDir (for dir plugins) - - // Wait, for file plugins: pluginPath = this.pluginPath, entryPath = filePath - // For dir plugins: pluginPath = pluginDir, entryPath = join(pluginDir, entryFile) - - let fsPath = ''; - // We need to rename the whole thing that constitutes the plugin. - if (plugin.pluginPath === pluginManager.getPluginPath()) { - // It's a file plugin - fsPath = plugin.entryPath; - } else { - // It's a directory plugin - fsPath = plugin.pluginPath; - } - - const disabledPath = fsPath + '.disabled'; - fs.renameSync(fsPath, disabledPath); - return sendSuccess(res, { message: 'Disabled successfully' }); - } catch (e: any) { - return sendError(res, 'Failed to disable: ' + e.message); - } + return sendSuccess(res, { message: 'Status updated successfully' }); + } catch (e: any) { + return sendError(res, 'Failed to update status: ' + e.message); } }; diff --git a/packages/napcat-webui-frontend/src/components/display_card/plugin_card.tsx b/packages/napcat-webui-frontend/src/components/display_card/plugin_card.tsx index c39f0f62..7351d2d1 100644 --- a/packages/napcat-webui-frontend/src/components/display_card/plugin_card.tsx +++ b/packages/napcat-webui-frontend/src/components/display_card/plugin_card.tsx @@ -1,6 +1,7 @@ import { Button } from '@heroui/button'; import { Switch } from '@heroui/switch'; -import clsx from 'clsx'; +import { Chip } from '@heroui/chip'; + import { useState } from 'react'; import { MdDeleteForever, MdPublishedWithChanges } from 'react-icons/md'; @@ -21,7 +22,7 @@ const PluginDisplayCard: React.FC = ({ onUninstall, }) => { const { name, version, author, description, status } = data; - const isEnabled = status === 'active'; + const isEnabled = status !== 'disabled'; const [processing, setProcessing] = useState(false); const handleToggle = () => { @@ -82,6 +83,16 @@ const PluginDisplayCard: React.FC = ({ /> } title={name} + tag={ + + {status === 'active' ? '运行中' : status === 'stopped' ? '已停止' : '已禁用'} + + } >
diff --git a/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts b/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts index 97b65ac1..e27e1829 100644 --- a/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts +++ b/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts @@ -5,7 +5,7 @@ export interface PluginItem { version: string; description: string; author: string; - status: 'active' | 'disabled'; + status: 'active' | 'disabled' | 'stopped'; filename?: string; }