diff --git a/packages/napcat-onebot/network/plugin-manger.ts b/packages/napcat-onebot/network/plugin-manger.ts index 502cf1f6..76fdcb9a 100644 --- a/packages/napcat-onebot/network/plugin-manger.ts +++ b/packages/napcat-onebot/network/plugin-manger.ts @@ -9,6 +9,7 @@ import path from 'path'; export interface PluginPackageJson { name?: string; + plugin?: string; version?: string; main?: string; description?: string; @@ -106,7 +107,7 @@ export interface PluginModule { private readonly pluginPath: string; private readonly configPath: string; - private loadedPlugins: Map = new Map(); - // Track failed plugins: ID -> Error Message + /** 插件注册表: 包名(id) -> 插件数据 */ + private pluginRegistry: Map = new Map(); + /** 失败的插件: ID -> 错误信息 */ private failedPlugins: Map = new Map(); declare config: PluginConfig; public NapCatConfig = NapCatConfig; override get isActive (): boolean { - return this.isEnable && this.loadedPlugins.size > 0; + return this.isEnable && this.pluginRegistry.size > 0; } constructor ( @@ -192,9 +194,19 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { continue; } - const pluginId = item.name; + // 先读取 package.json 获取包名(id) + const packageJsonPath = path.join(this.pluginPath, item.name, 'package.json'); + let pluginId = item.name; // 默认使用目录名 + if (fs.existsSync(packageJsonPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + if (pkg.name) { + pluginId = pkg.name; + } + } catch (e) { } + } - // Check if plugin is disabled in config + // Check if plugin is disabled in config (by id) if (pluginConfig[pluginId] === false) { this.logger.log(`[Plugin Adapter] Plugin ${pluginId} is disabled in config, skipping`); continue; @@ -205,7 +217,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { } this.logger.log( - `[Plugin Adapter] Loaded ${this.loadedPlugins.size} plugins` + `[Plugin Adapter] Loaded ${this.pluginRegistry.size} plugins` ); } catch (error) { this.logger.logError('[Plugin Adapter] Error loading plugins:', error); @@ -219,13 +231,6 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { */ public async loadDirectoryPlugin (dirname: string): Promise { const pluginDir = path.join(this.pluginPath, dirname); - const pluginConfig = this.loadPluginConfig(); - const pluginId = dirname; // Use directory name as unique ID - - if (pluginConfig[pluginId] === false) { - this.logger.log(`[Plugin Adapter] Plugin ${pluginId} is disabled by user`); - return; - } try { // 尝试读取 package.json @@ -244,6 +249,16 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { } } + // 获取插件 id(包名) + const pluginId = packageJson?.name || dirname; + + // 检查插件是否被禁用 (by id) + const pluginConfig = this.loadPluginConfig(); + if (pluginConfig[pluginId] === false) { + this.logger.log(`[Plugin Adapter] Plugin ${pluginId} is disabled by user`); + return; + } + // 确定入口文件 const entryFile = this.findEntryFile(pluginDir, packageJson); if (!entryFile) { @@ -264,8 +279,8 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { } const plugin: LoadedPlugin = { - name: packageJson?.name || pluginId, // Use package.json name for API lookups, fallback to dir name - dirname: pluginId, // Keep track of actual directory name for path resolution + name: pluginId, // 使用包名作为 id + fileId: dirname, // 保留目录名用于路径解析 version: packageJson?.version, pluginPath: pluginDir, entryPath, @@ -329,15 +344,12 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { return module && typeof module.plugin_init === 'function'; } - /** - * 注册插件 - */ /** * 注册插件 */ private async registerPlugin (plugin: LoadedPlugin): Promise { // 检查名称冲突 - if (this.loadedPlugins.has(plugin.name)) { + if (this.pluginRegistry.has(plugin.name)) { this.logger.logWarn( `[Plugin Adapter] Plugin name conflict: ${plugin.name}, skipping...` ); @@ -345,8 +357,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { } // Create Context - // Use dirname for path resolution, name for identification - const dataPath = path.join(this.pluginPath, plugin.dirname, 'data'); + const dataPath = path.join(plugin.pluginPath, 'data'); const configPath = path.join(dataPath, 'config.json'); // Create plugin-specific logger with prefix @@ -376,7 +387,9 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { plugin.context = context; // Store context on plugin object - this.loadedPlugins.set(plugin.name, plugin); + // 注册到映射表 + this.pluginRegistry.set(plugin.name, plugin); + this.logger.log( `[Plugin Adapter] Registered plugin: ${plugin.name}${plugin.version ? ` v${plugin.version}` : '' }` @@ -393,7 +406,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { ); // Mark as failed this.failedPlugins.set(plugin.name, error.message || 'Initialization failed'); - this.loadedPlugins.delete(plugin.name); + this.pluginRegistry.delete(plugin.name); } } @@ -401,7 +414,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { * 卸载插件 */ private async unloadPlugin (pluginName: string): Promise { - const plugin = this.loadedPlugins.get(pluginName); + const plugin = this.pluginRegistry.get(pluginName); if (!plugin) { return; } @@ -419,7 +432,8 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { } } - this.loadedPlugins.delete(pluginName); + // 从映射表中移除 + this.pluginRegistry.delete(pluginName); this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`); } @@ -435,19 +449,22 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { return this.loadPluginConfig(); } - public setPluginStatus (pluginName: string, enable: boolean): void { - // Try to find plugin by package name first - const plugin = this.loadedPlugins.get(pluginName); - // Use dirname for config storage (if plugin is loaded), otherwise assume pluginName is dirname - const configKey = plugin?.dirname || pluginName; - + /** + * 设置插件状态(启用/禁用) + * @param pluginId 插件包名(id) + * @param enable 是否启用 + */ + public setPluginStatus (pluginId: string, enable: boolean): void { const config = this.loadPluginConfig(); - config[configKey] = enable; + config[pluginId] = enable; this.savePluginConfig(config); - if (!enable && plugin) { - // Unload by plugin.name (package name, which is the key in loadedPlugins) - this.unloadPlugin(plugin.name).catch(e => this.logger.logError('Error unloading', e)); + // 如果禁用且插件已加载,则卸载 + if (!enable) { + const plugin = this.pluginRegistry.get(pluginId); + if (plugin) { + this.unloadPlugin(pluginId).catch(e => this.logger.logError('Error unloading', e)); + } } } @@ -458,7 +475,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { try { await Promise.allSettled( - Array.from(this.loadedPlugins.values()).map((plugin) => + Array.from(this.pluginRegistry.values()).map((plugin) => this.callPluginEventHandler(plugin, event) ) ); @@ -513,7 +530,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { await this.loadPlugins(); this.logger.log( - `[Plugin Adapter] Plugin adapter opened with ${this.loadedPlugins.size} plugins loaded` + `[Plugin Adapter] Plugin adapter opened with ${this.pluginRegistry.size} plugins loaded` ); } @@ -526,7 +543,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { this.isEnable = false; // 卸载所有插件 - const pluginNames = Array.from(this.loadedPlugins.keys()); + const pluginNames = Array.from(this.pluginRegistry.keys()); for (const pluginName of pluginNames) { await this.unloadPlugin(pluginName); } @@ -549,55 +566,119 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { * 获取已加载的插件列表 */ public getLoadedPlugins (): LoadedPlugin[] { - return Array.from(this.loadedPlugins.values()); + return Array.from(this.pluginRegistry.values()); } /** - * 获取插件信息 + * 通过包名(id)获取插件信息 */ - public getPluginInfo (pluginName: string): LoadedPlugin | undefined { - return this.loadedPlugins.get(pluginName); + public getPluginInfo (pluginId: string): LoadedPlugin | undefined { + return this.pluginRegistry.get(pluginId); + } + + /** + * 通过 id 加载插件 + */ + public async loadPluginById (id: string): Promise { + // 扫描文件系统查找 fileId + if (!fs.existsSync(this.pluginPath)) { + this.logger.logWarn(`[Plugin Adapter] Plugin ${id} not found in filesystem`); + return false; + } + + const items = fs.readdirSync(this.pluginPath, { withFileTypes: true }); + for (const item of items) { + if (!item.isDirectory()) continue; + + const packageJsonPath = path.join(this.pluginPath, item.name, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + if (pkg.name === id) { + await this.loadDirectoryPlugin(item.name); + return this.pluginRegistry.has(id); + } + } catch (e) { } + } + } + + this.logger.logWarn(`[Plugin Adapter] Plugin ${id} not found in filesystem`); + return false; + } + + /** + * 卸载并删除插件 + */ + public async uninstallPlugin (id: string, cleanData: boolean = false): Promise { + const plugin = this.pluginRegistry.get(id); + if (!plugin) { + throw new Error(`Plugin ${id} not found or not loaded`); + } + + const pluginPath = plugin.context.pluginPath; + const dataPath = plugin.context.dataPath; + + // 先卸载插件 + await this.unloadPlugin(id); + + // 删除插件目录 + if (fs.existsSync(pluginPath)) { + fs.rmSync(pluginPath, { recursive: true, force: true }); + } + + // 清理数据 + if (cleanData && fs.existsSync(dataPath)) { + fs.rmSync(dataPath, { recursive: true, force: true }); + } } /** * 重载指定插件 */ - public async reloadPlugin (pluginName: string): Promise { - const plugin = this.loadedPlugins.get(pluginName); + public async reloadPlugin (pluginId: string): Promise { + const plugin = this.pluginRegistry.get(pluginId); if (!plugin) { - this.logger.logWarn(`[Plugin Adapter] Plugin ${pluginName} not found`); + this.logger.logWarn(`[Plugin Adapter] Plugin ${pluginId} not found`); return false; } - const dirname = plugin.dirname; + const dirname = path.basename(plugin.pluginPath); try { // 卸载插件 - await this.unloadPlugin(pluginName); + await this.unloadPlugin(pluginId); - // 重新加载插件 - use dirname for directory loading + // 重新加载插件 await this.loadDirectoryPlugin(dirname); this.logger.log( - `[Plugin Adapter] Plugin ${pluginName} reloaded successfully` + `[Plugin Adapter] Plugin ${pluginId} reloaded successfully` ); return true; } catch (error) { this.logger.logError( - `[Plugin Adapter] Error reloading plugin ${pluginName}:`, + `[Plugin Adapter] Error reloading plugin ${pluginId}:`, error ); return false; } } - public getPluginDataPath (pluginName: string): string { - // Lookup plugin by name (package name) and use dirname for path - const plugin = this.loadedPlugins.get(pluginName); - const dirname = plugin?.dirname || pluginName; // fallback to pluginName if not found - return path.join(this.pluginPath, dirname, 'data'); + + /** + * 获取插件数据目录路径 + */ + public getPluginDataPath (pluginId: string): string { + const plugin = this.pluginRegistry.get(pluginId); + if (!plugin) { + throw new Error(`Plugin ${pluginId} not found`); + } + return plugin.context.dataPath; } - public getPluginConfigPath (pluginName: string): string { - return path.join(this.getPluginDataPath(pluginName), 'config.json'); + /** + * 获取插件配置文件路径 + */ + public getPluginConfigPath (pluginId: string): string { + return path.join(this.getPluginDataPath(pluginId), 'config.json'); } } diff --git a/packages/napcat-onebot/network/plugin.ts b/packages/napcat-onebot/network/plugin.ts index f3508510..a63323d1 100644 --- a/packages/napcat-onebot/network/plugin.ts +++ b/packages/napcat-onebot/network/plugin.ts @@ -9,6 +9,7 @@ import path from 'path'; export interface PluginPackageJson { name?: string; + plugin?: string; version?: string; main?: string; } @@ -255,7 +256,7 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter { this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`); } - async onEvent(event: T) { + async onEvent (event: T) { if (!this.isEnable) { return; } @@ -357,7 +358,7 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter { // 重新加载插件 const isDirectory = fs.statSync(plugin.pluginPath).isDirectory() && - plugin.pluginPath !== this.pluginPath; + plugin.pluginPath !== this.pluginPath; if (isDirectory) { const dirname = path.basename(plugin.pluginPath); diff --git a/packages/napcat-plugin-builtin/package.json b/packages/napcat-plugin-builtin/package.json index 1b967b61..8c5dde77 100644 --- a/packages/napcat-plugin-builtin/package.json +++ b/packages/napcat-plugin-builtin/package.json @@ -1,5 +1,6 @@ { "name": "napcat-plugin-builtin", + "plugin": "内置插件", "version": "1.0.0", "type": "module", "main": "index.mjs", diff --git a/packages/napcat-webui-backend/src/api/Plugin.ts b/packages/napcat-webui-backend/src/api/Plugin.ts index 3e1a7ede..622fa091 100644 --- a/packages/napcat-webui-backend/src/api/Plugin.ts +++ b/packages/napcat-webui-backend/src/api/Plugin.ts @@ -64,32 +64,18 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => { return sendSuccess(res, { plugins: [], pluginManagerNotFound: true }); } - // 辅助函数:根据文件名/路径生成唯一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 + const loadedPluginMap = new Map(); // Map id -> Loaded Info // 1. 整理已加载的插件 for (const p of loadedPlugins) { - // Use dirname for map key (matches filesystem scan) - const id = p.dirname; - const fsName = p.dirname; // dirname is the actual filesystem directory name - - loadedPluginMap.set(id, { - name: p.name, // This is now package name (from packageJson.name || dirname) - id: id, + loadedPluginMap.set(p.name, { + name: p.packageJson?.plugin || p.name, // 优先显示 package.json 的 plugin 字段 + id: p.name, // 包名,用于 API 操作 version: p.version || '0.0.0', description: p.packageJson?.description || '', author: p.packageJson?.author || '', status: 'active', - filename: fsName, // 真实文件/目录名 - loadedName: p.name, // 运行时注册的名称,用于重载/卸载 (package name) hasConfig: !!(p.module.plugin_config_schema || p.module.plugin_config_ui) }); } @@ -103,15 +89,25 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => { const items = fs.readdirSync(pluginPath, { withFileTypes: true }); for (const item of items) { - let id = ''; + if (!item.isDirectory()) continue; - if (item.isFile()) { - if (!['.js', '.mjs'].includes(path.extname(item.name))) continue; - id = getPluginId(item.name, true); - } else if (item.isDirectory()) { - id = getPluginId(item.name, false); - } else { - continue; + // 读取 package.json 获取插件信息 + let id = item.name; + let name = item.name; + let version = '0.0.0'; + let description = ''; + let author = ''; + + const packageJsonPath = path.join(pluginPath, item.name, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + id = pkg.name || id; + name = pkg.plugin || pkg.name || name; + version = pkg.version || version; + description = pkg.description || description; + author = pkg.author || author; + } catch (e) { } } const isActiveConfig = pluginConfig[id] !== false; // 默认为 true @@ -121,37 +117,14 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => { const loadedInfo = loadedPluginMap.get(id); allPlugins.push(loadedInfo); } else { - // 未加载 (可能是被禁用,或者加载失败,或者新增未运行) - let version = '0.0.0'; - let description = ''; - let author = ''; - // 默认显示名称为 ID (文件名/目录名) - let name = id; - - try { - // 尝试读取 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) { } - allPlugins.push({ - name: name, - id: id, + name, + id, version, description, author, // 如果配置是 false,则为 disabled;否则是 stopped (应启动但未启动) - status: isActiveConfig ? 'stopped' : 'disabled', - filename: item.name + status: isActiveConfig ? 'stopped' : 'disabled' }); } } @@ -160,43 +133,27 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => { return sendSuccess(res, { plugins: allPlugins, pluginManagerNotFound: false }); }; -// ReloadPluginHandler removed - export const SetPluginStatusHandler: RequestHandler = async (req, res) => { - const { enable, filename, name } = req.body; - // filename is the directory name (used for fs checks) - // name is the package name (used for plugin manager API, if provided) - // We need to determine: which to use for setPluginStatus call + const { enable, id } = req.body; - if (!filename && !name) return sendError(res, 'Plugin Filename or Name is required'); + if (!id) return sendError(res, 'Plugin id is required'); const pluginManager = getPluginManager(); if (!pluginManager) { return sendError(res, 'Plugin Manager not found'); } - // Determine which ID to use - // If 'name' (package name) is provided, use it for pluginManager calls - // But 'filename' (dirname) is needed for filesystem operations - const dirname = filename || name; // fallback - const pluginName = name || filename; // fallback - try { - // setPluginStatus now handles both package name and dirname lookup internally - pluginManager.setPluginStatus(pluginName, enable); + // 设置插件状态 + pluginManager.setPluginStatus(id, enable); - // If enabling, trigger load + // 如果启用,需要加载插件 if (enable) { - const pluginPath = pluginManager.getPluginPath(); - const fullPath = path.join(pluginPath, dirname); - - if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) { - await pluginManager.loadDirectoryPlugin(dirname); - } else { - return sendError(res, 'Plugin directory not found: ' + dirname); + const loaded = await pluginManager.loadPluginById(id); + if (!loaded) { + return sendError(res, 'Plugin not found: ' + id); } } - // Disabling is handled by setPluginStatus return sendSuccess(res, { message: 'Status updated successfully' }); } catch (e: any) { @@ -205,47 +162,17 @@ export const SetPluginStatusHandler: RequestHandler = async (req, res) => { }; export const UninstallPluginHandler: RequestHandler = async (req, res) => { - const { name, filename, cleanData } = req.body; - // If it's loaded, we use name. If it's disabled, we might use filename. + 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'); } - // Check if loaded - const plugin = pluginManager.getPluginInfo(name); - let fsPath = ''; - - if (plugin) { - // Active plugin - await pluginManager.unregisterPlugin(name); - if (plugin.pluginPath === pluginManager.getPluginPath()) { - fsPath = plugin.entryPath; - } else { - fsPath = plugin.pluginPath; - } - } else { - // Disabled or not loaded - if (filename) { - fsPath = path.join(pluginManager.getPluginPath(), filename); - } else { - return sendError(res, 'Plugin not found, provide filename if disabled'); - } - } - try { - if (fs.existsSync(fsPath)) { - fs.rmSync(fsPath, { recursive: true, force: true }); - } - - if (cleanData && name) { - const dataPath = pluginManager.getPluginDataPath(name); - if (fs.existsSync(dataPath)) { - fs.rmSync(dataPath, { recursive: true, force: true }); - } - } - + await pluginManager.uninstallPlugin(id, cleanData); return sendSuccess(res, { message: 'Uninstalled successfully' }); } catch (e: any) { return sendError(res, 'Failed to uninstall: ' + e.message); @@ -253,13 +180,13 @@ export const UninstallPluginHandler: RequestHandler = async (req, res) => { }; export const GetPluginConfigHandler: RequestHandler = async (req, res) => { - const name = req.query['name'] as string; - if (!name) return sendError(res, 'Plugin Name is required'); + 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(name); + const plugin = pluginManager.getPluginInfo(id); if (!plugin) return sendError(res, 'Plugin not loaded'); // Support legacy schema or new API @@ -274,7 +201,7 @@ export const GetPluginConfigHandler: RequestHandler = async (req, res) => { // Default behavior: read from default config path try { // Use context configPath if available - const configPath = plugin.context?.configPath || pluginManager.getPluginConfigPath(name); + const configPath = plugin.context?.configPath || pluginManager.getPluginConfigPath(id); if (fs.existsSync(configPath)) { config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); } @@ -285,13 +212,13 @@ export const GetPluginConfigHandler: RequestHandler = async (req, res) => { }; export const SetPluginConfigHandler: RequestHandler = async (req, res) => { - const { name, config } = req.body; - if (!name || !config) return sendError(res, 'Name and Config required'); + 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(name); + const plugin = pluginManager.getPluginInfo(id); if (!plugin) return sendError(res, 'Plugin not loaded'); if (plugin.module.plugin_set_config) { @@ -304,7 +231,7 @@ export const SetPluginConfigHandler: RequestHandler = async (req, res) => { } else if (plugin.module.plugin_config_schema || plugin.module.plugin_config_ui) { // Default behavior: write to default config path try { - const configPath = plugin.context?.configPath || pluginManager.getPluginConfigPath(name); + const configPath = plugin.context?.configPath || pluginManager.getPluginConfigPath(id); const configDir = path.dirname(configPath); if (!fs.existsSync(configDir)) { @@ -313,7 +240,7 @@ export const SetPluginConfigHandler: RequestHandler = async (req, res) => { fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); // Auto-Reload plugin to apply changes - await pluginManager.reloadPlugin(name); + await pluginManager.reloadPlugin(id); return sendSuccess(res, { message: 'Config saved and plugin reloaded' }); } catch (e: any) { diff --git a/packages/napcat-webui-backend/src/api/PluginStore.ts b/packages/napcat-webui-backend/src/api/PluginStore.ts index 0a347308..45a02657 100644 --- a/packages/napcat-webui-backend/src/api/PluginStore.ts +++ b/packages/napcat-webui-backend/src/api/PluginStore.ts @@ -8,6 +8,16 @@ import { createWriteStream } from 'fs'; import compressing from 'compressing'; import { findAvailableDownloadUrl, GITHUB_RAW_MIRRORS } from 'napcat-common/src/mirror'; import { webUiPathWrapper } from '@/napcat-webui-backend/index'; +import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data'; +import { NapCatOneBot11Adapter } from '@/napcat-onebot/index'; +import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger'; + +// 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; +}; // 插件商店源配置 const PLUGIN_STORE_SOURCES = [ @@ -242,6 +252,15 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => // 删除临时文件 fs.unlinkSync(tempZipPath); + // 如果 pluginManager 存在,立即注册插件 + const pluginManager = getPluginManager(); + if (pluginManager) { + // 检查是否已注册,避免重复注册 + if (!pluginManager.getPluginInfo(id)) { + await pluginManager.loadPluginById(id); + } + } + return sendSuccess(res, { message: 'Plugin installed successfully', plugin: plugin, @@ -315,6 +334,16 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res) sendProgress('解压完成,正在清理...', 90); fs.unlinkSync(tempZipPath); + // 如果 pluginManager 存在,立即注册插件 + const pluginManager = getPluginManager(); + if (pluginManager) { + // 检查是否已注册,避免重复注册 + if (!pluginManager.getPluginInfo(id)) { + sendProgress('正在注册插件...', 95); + await pluginManager.loadPluginById(id); + } + } + sendProgress('安装成功!', 100); res.write(`data: ${JSON.stringify({ success: true, diff --git a/packages/napcat-webui-frontend/src/components/display_card/plugin_store_card.tsx b/packages/napcat-webui-frontend/src/components/display_card/plugin_store_card.tsx index d76e0161..ec5242d3 100644 --- a/packages/napcat-webui-frontend/src/components/display_card/plugin_store_card.tsx +++ b/packages/napcat-webui-frontend/src/components/display_card/plugin_store_card.tsx @@ -1,19 +1,25 @@ import { Button } from '@heroui/button'; import { Chip } from '@heroui/chip'; import { useState } from 'react'; -import { IoMdDownload } from 'react-icons/io'; +import { IoMdDownload, IoMdRefresh, IoMdCheckmarkCircle } from 'react-icons/io'; import DisplayCardContainer from './container'; import { PluginStoreItem } from '@/types/plugin-store'; +export type InstallStatus = 'not-installed' | 'installed' | 'update-available'; + export interface PluginStoreCardProps { data: PluginStoreItem; onInstall: () => Promise; + installStatus?: InstallStatus; + installedVersion?: string; } const PluginStoreCard: React.FC = ({ data, onInstall, + installStatus = 'not-installed', + installedVersion, }) => { const { name, version, author, description, tags, id } = data; const [processing, setProcessing] = useState(false); @@ -23,19 +29,65 @@ const PluginStoreCard: React.FC = ({ onInstall().finally(() => setProcessing(false)); }; + // 根据安装状态返回按钮配置 + const getButtonConfig = () => { + switch (installStatus) { + case 'installed': + return { + text: '重新安装', + icon: , + color: 'default' as const, + }; + case 'update-available': + return { + text: '更新', + icon: , + color: 'success' as const, + }; + default: + return { + text: '安装', + icon: , + color: 'primary' as const, + }; + } + }; + + const buttonConfig = getButtonConfig(); + return ( - v{version} - +
+ {installStatus === 'installed' && ( + } + > + 已安装 + + )} + {installStatus === 'update-available' && ( + + 可更新 + + )} + + v{version} + +
} enableSwitch={undefined} action={ @@ -43,13 +95,13 @@ const PluginStoreCard: React.FC = ({ fullWidth radius='full' size='sm' - color='primary' - startContent={} + color={buttonConfig.color} + startContent={buttonConfig.icon} onPress={handleInstall} isLoading={processing} isDisabled={processing} > - 安装 + {buttonConfig.text} } > diff --git a/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts b/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts index aad26b1e..36265007 100644 --- a/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts +++ b/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts @@ -1,21 +1,34 @@ import { serverRequest } from '@/utils/request'; import { PluginStoreList, PluginStoreItem } from '@/types/plugin-store'; +/** 插件状态 */ +export type PluginStatus = 'active' | 'disabled' | 'stopped'; + +/** 插件信息 */ export interface PluginItem { + /** 显示名称 (优先 package.json 的 plugin 字段) */ name: string; + /** 包名 (package name),用于 API 操作 */ + id: string; + /** 版本号 */ version: string; + /** 描述 */ description: string; + /** 作者 */ author: string; - status: 'active' | 'disabled' | 'stopped'; - filename?: string; + /** 状态: active-运行中, disabled-已禁用, stopped-已停止 */ + status: PluginStatus; + /** 是否有配置项 */ hasConfig?: boolean; } +/** 插件列表响应 */ export interface PluginListResponse { plugins: PluginItem[]; pluginManagerNotFound: boolean; } +/** 插件配置项定义 */ export interface PluginConfigSchemaItem { key: string; type: 'string' | 'number' | 'boolean' | 'select' | 'multi-select' | 'html' | 'text'; @@ -26,19 +39,27 @@ export interface PluginConfigSchemaItem { placeholder?: string; } +/** 插件配置响应 */ export interface PluginConfigResponse { schema: PluginConfigSchemaItem[]; - config: any; + config: Record; } +/** 服务端响应 */ export interface ServerResponse { code: number; message: string; data: T; } +/** + * 插件管理器 API + */ export default class PluginManager { - public static async getPluginList () { + /** + * 获取插件列表 + */ + public static async getPluginList (): Promise { const { data } = await serverRequest.get>('/Plugin/List'); return data.data; } @@ -46,46 +67,80 @@ export default class PluginManager { /** * 手动注册插件管理器到 NetworkManager */ - public static async registerPluginManager () { + public static async registerPluginManager (): Promise<{ message: string; }> { const { data } = await serverRequest.post>('/Plugin/RegisterManager'); return data.data; } - - - public static async setPluginStatus (name: string, enable: boolean, filename?: string) { - await serverRequest.post>('/Plugin/SetStatus', { name, enable, filename }); + /** + * 设置插件状态(启用/禁用) + * @param id 插件包名 + * @param enable 是否启用 + */ + public static async setPluginStatus (id: string, enable: boolean): Promise { + await serverRequest.post>('/Plugin/SetStatus', { id, enable }); } - public static async uninstallPlugin (name: string, filename?: string, cleanData?: boolean) { - await serverRequest.post>('/Plugin/Uninstall', { name, filename, cleanData }); + /** + * 卸载插件 + * @param id 插件包名 + * @param cleanData 是否清理数据 + */ + public static async uninstallPlugin (id: string, cleanData?: boolean): Promise { + await serverRequest.post>('/Plugin/Uninstall', { id, cleanData }); } - // 插件商店相关方法 - public static async getPluginStoreList () { + // ==================== 插件商店 ==================== + + /** + * 获取插件商店列表 + */ + public static async getPluginStoreList (): Promise { const { data } = await serverRequest.get>('/Plugin/Store/List'); return data.data; } - public static async getPluginStoreDetail (id: string) { + /** + * 获取插件商店详情 + * @param id 插件 ID + */ + public static async getPluginStoreDetail (id: string): Promise { const { data } = await serverRequest.get>(`/Plugin/Store/Detail/${id}`); return data.data; } - public static async installPluginFromStore (id: string, mirror?: string) { - // 插件安装可能需要较长时间(下载+解压),设置5分钟超时 - await serverRequest.post>('/Plugin/Store/Install', { id, mirror }, { - timeout: 300000, // 5分钟 - }); + /** + * 从商店安装插件 + * @param id 插件 ID + * @param mirror 镜像源 + */ + public static async installPluginFromStore (id: string, mirror?: string): Promise { + await serverRequest.post>( + '/Plugin/Store/Install', + { id, mirror }, + { timeout: 300000 } // 5分钟超时 + ); } - // 插件配置相关方法 - public static async getPluginConfig (name: string) { - const { data } = await serverRequest.get>('/Plugin/Config', { params: { name } }); + // ==================== 插件配置 ==================== + + /** + * 获取插件配置 + * @param id 插件包名 + */ + public static async getPluginConfig (id: string): Promise { + const { data } = await serverRequest.get>('/Plugin/Config', { + params: { id } + }); return data.data; } - public static async setPluginConfig (name: string, config: any) { - await serverRequest.post>('/Plugin/Config', { name, config }); + /** + * 设置插件配置 + * @param id 插件包名 + * @param config 配置内容 + */ + public static async setPluginConfig (id: string, config: Record): Promise { + await serverRequest.post>('/Plugin/Config', { id, config }); } } diff --git a/packages/napcat-webui-frontend/src/pages/dashboard/plugin.tsx b/packages/napcat-webui-frontend/src/pages/dashboard/plugin.tsx index 0232a209..96f24f52 100644 --- a/packages/napcat-webui-frontend/src/pages/dashboard/plugin.tsx +++ b/packages/napcat-webui-frontend/src/pages/dashboard/plugin.tsx @@ -17,7 +17,7 @@ export default function PluginPage () { const dialog = useDialog(); const { isOpen, onOpen, onOpenChange } = useDisclosure(); - const [currentPluginName, setCurrentPluginName] = useState(''); + const [currentPluginId, setCurrentPluginId] = useState(''); const loadPlugins = async () => { setLoading(true); @@ -49,7 +49,7 @@ export default function PluginPage () { const actionText = isEnable ? '启用' : '禁用'; const loadingToast = toast.loading(`${actionText}中...`); try { - await PluginManager.setPluginStatus(plugin.name, isEnable, plugin.filename); + await PluginManager.setPluginStatus(plugin.id, isEnable); toast.success(`${actionText}成功`, { id: loadingToast }); loadPlugins(); } catch (e: any) { @@ -85,7 +85,7 @@ export default function PluginPage () { const loadingToast = toast.loading('卸载中...'); try { - await PluginManager.uninstallPlugin(plugin.name, plugin.filename, cleanData); + await PluginManager.uninstallPlugin(plugin.id, cleanData); toast.success('卸载成功', { id: loadingToast }); loadPlugins(); resolve(); @@ -102,7 +102,7 @@ export default function PluginPage () { }; const handleConfig = (plugin: PluginItem) => { - setCurrentPluginName(plugin.name); // Use Loaded Name for config lookup + setCurrentPluginId(plugin.id); onOpen(); }; @@ -114,7 +114,7 @@ export default function PluginPage () {
@@ -145,7 +145,7 @@ export default function PluginPage () {
{plugins.map(plugin => ( handleToggle(plugin)} onUninstall={() => handleUninstall(plugin)} diff --git a/packages/napcat-webui-frontend/src/pages/dashboard/plugin_config_modal.tsx b/packages/napcat-webui-frontend/src/pages/dashboard/plugin_config_modal.tsx index bffee022..ad70373d 100644 --- a/packages/napcat-webui-frontend/src/pages/dashboard/plugin_config_modal.tsx +++ b/packages/napcat-webui-frontend/src/pages/dashboard/plugin_config_modal.tsx @@ -10,31 +10,32 @@ import PluginManager, { PluginConfigSchemaItem } from '@/controllers/plugin_mana interface Props { isOpen: boolean; onOpenChange: () => void; - pluginName: string; + /** 插件包名 (id) */ + pluginId: string; } -export default function PluginConfigModal ({ isOpen, onOpenChange, pluginName }: Props) { +export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: Props) { const [loading, setLoading] = useState(false); const [schema, setSchema] = useState([]); - const [config, setConfig] = useState({}); + const [config, setConfig] = useState>({}); const [saving, setSaving] = useState(false); useEffect(() => { - if (isOpen && pluginName) { + if (isOpen && pluginId) { loadConfig(); } - }, [isOpen, pluginName]); + }, [isOpen, pluginId]); const loadConfig = async () => { setLoading(true); setSchema([]); setConfig({}); try { - const data = await PluginManager.getPluginConfig(pluginName); + const data = await PluginManager.getPluginConfig(pluginId); setSchema(data.schema || []); setConfig(data.config || {}); } catch (e: any) { - toast.error('Load config failed: ' + e.message); + toast.error('加载配置失败: ' + e.message); } finally { setLoading(false); } @@ -43,7 +44,7 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginName }: const handleSave = async () => { setSaving(true); try { - await PluginManager.setPluginConfig(pluginName, config); + await PluginManager.setPluginConfig(pluginId, config); toast.success('Configuration saved'); onOpenChange(); } catch (e: any) { @@ -177,7 +178,7 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginName }: {(onClose) => ( <> - Configuration: {pluginName} + 插件配置: {pluginId} {loading ? ( diff --git a/packages/napcat-webui-frontend/src/pages/dashboard/plugin_store.tsx b/packages/napcat-webui-frontend/src/pages/dashboard/plugin_store.tsx index 352ee127..f4d4c59c 100644 --- a/packages/napcat-webui-frontend/src/pages/dashboard/plugin_store.tsx +++ b/packages/napcat-webui-frontend/src/pages/dashboard/plugin_store.tsx @@ -10,8 +10,8 @@ import { useRequest } from 'ahooks'; import { EventSourcePolyfill } from 'event-source-polyfill'; import PageLoading from '@/components/page_loading'; -import PluginStoreCard from '@/components/display_card/plugin_store_card'; -import PluginManager from '@/controllers/plugin_manager'; +import PluginStoreCard, { InstallStatus } from '@/components/display_card/plugin_store_card'; +import PluginManager, { PluginItem } from '@/controllers/plugin_manager'; import WebUIManager from '@/controllers/webui_manager'; import { PluginStoreItem } from '@/types/plugin-store'; import useDialog from '@/hooks/use-dialog'; @@ -35,6 +35,7 @@ const EmptySection: React.FC = ({ isEmpty }) => { export default function PluginStorePage () { const [plugins, setPlugins] = useState([]); + const [installedPlugins, setInstalledPlugins] = useState([]); const [loading, setLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [activeTab, setActiveTab] = useState('all'); @@ -57,6 +58,7 @@ export default function PluginStorePage () { // 检查插件管理器是否已加载 const listResult = await PluginManager.getPluginList(); setPluginManagerNotFound(listResult.pluginManagerNotFound); + setInstalledPlugins(listResult.plugins || []); } catch (e: any) { toast.error(e.message); } finally { @@ -95,6 +97,23 @@ export default function PluginStorePage () { return categories; }, [plugins, searchQuery]); + // 获取插件的安装状态和已安装版本 + const getPluginInstallInfo = (plugin: PluginStoreItem): { status: InstallStatus; installedVersion?: string; } => { + // 通过 id (包名) 或 name 匹配已安装的插件 + const installed = installedPlugins.find(p => p.id === plugin.id); + + if (!installed) { + return { status: 'not-installed' }; + } + + // 使用不等于判断:版本不同就显示更新 + if (installed.version !== plugin.version) { + return { status: 'update-available', installedVersion: installed.version }; + } + + return { status: 'installed', installedVersion: installed.version }; + }; + const tabs = useMemo(() => { return [ { key: 'all', title: '全部', count: categorizedPlugins.all?.length || 0 }, @@ -293,13 +312,18 @@ export default function PluginStorePage () { >
- {categorizedPlugins[tab.key]?.map((plugin) => ( - handleInstall(plugin)} - /> - ))} + {categorizedPlugins[tab.key]?.map((plugin) => { + const installInfo = getPluginInstallInfo(plugin); + return ( + handleInstall(plugin)} + /> + ); + })}
))}