Improve plugin status handling and dirname lookup

Enhanced setPluginStatus to support enabling/disabling plugins by both package name and dirname, improving robustness when plugins are not loaded. Also removed redundant directory name matching logic from findDirnameById in the web UI backend.

Register plugin after installation in PluginStore

Adds logic to immediately register a plugin with the plugin manager after installation, both in the standard and SSE install handlers. This ensures newly installed plugins are available without requiring a restart or manual reload.

Refactor plugin path handling in plugin manager

Simplifies plugin directory and data path resolution by using pluginPath from the plugin context instead of fileId. Streamlines plugin uninstall and reload logic, removing redundant file system scans and improving code clarity.

Refactor plugin API to use package id and improve UX

Standardized plugin management APIs and frontend to use 'id' (package name) instead of ambiguous 'name' or 'filename'. Added support for a 'plugin' display field in package.json and improved plugin store UI to show install/update status. Refactored backend and frontend logic for enabling, disabling, uninstalling, and configuring plugins to use consistent identifiers, and enhanced type definitions and documentation for better maintainability.
This commit is contained in:
手瓜一十雪 2026-01-29 16:14:16 +08:00
parent 7f05aee11d
commit 699b46acbd
10 changed files with 410 additions and 239 deletions

View File

@ -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<T extends OB11EmitEventContent = OB11EmitEventCont
export interface LoadedPlugin {
name: string;
dirname: string; // Actual directory name for path resolution
fileId: string; // 文件系统目录名,用于路径解析
version?: string;
pluginPath: string;
entryPath: string;
@ -122,14 +123,15 @@ export interface PluginStatusConfig {
export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
private readonly pluginPath: string;
private readonly configPath: string;
private loadedPlugins: Map<string, LoadedPlugin> = new Map();
// Track failed plugins: ID -> Error Message
/** 插件注册表: 包名(id) -> 插件数据 */
private pluginRegistry: Map<string, LoadedPlugin> = new Map();
/** 失败的插件: ID -> 错误信息 */
private failedPlugins: Map<string, string> = 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<PluginConfig> {
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<PluginConfig> {
}
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<PluginConfig> {
*/
public async loadDirectoryPlugin (dirname: string): Promise<void> {
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<PluginConfig> {
}
}
// 获取插件 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<PluginConfig> {
}
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<PluginConfig> {
return module && typeof module.plugin_init === 'function';
}
/**
*
*/
/**
*
*/
private async registerPlugin (plugin: LoadedPlugin): Promise<void> {
// 检查名称冲突
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<PluginConfig> {
}
// 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<PluginConfig> {
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<PluginConfig> {
);
// 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<PluginConfig> {
*
*/
private async unloadPlugin (pluginName: string): Promise<void> {
const plugin = this.loadedPlugins.get(pluginName);
const plugin = this.pluginRegistry.get(pluginName);
if (!plugin) {
return;
}
@ -419,7 +432,8 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
}
}
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<PluginConfig> {
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<PluginConfig> {
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<PluginConfig> {
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<PluginConfig> {
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<PluginConfig> {
*
*/
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<boolean> {
// 扫描文件系统查找 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<void> {
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<boolean> {
const plugin = this.loadedPlugins.get(pluginName);
public async reloadPlugin (pluginId: string): Promise<boolean> {
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');
}
}

View File

@ -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<PluginConfig> {
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
}
async onEvent<T extends OB11EmitEventContent>(event: T) {
async onEvent<T extends OB11EmitEventContent> (event: T) {
if (!this.isEnable) {
return;
}
@ -357,7 +358,7 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
// 重新加载插件
const isDirectory = fs.statSync(plugin.pluginPath).isDirectory() &&
plugin.pluginPath !== this.pluginPath;
plugin.pluginPath !== this.pluginPath;
if (isDirectory) {
const dirname = path.basename(plugin.pluginPath);

View File

@ -1,5 +1,6 @@
{
"name": "napcat-plugin-builtin",
"plugin": "内置插件",
"version": "1.0.0",
"type": "module",
"main": "index.mjs",

View File

@ -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<string, any>(); // Map ID -> Loaded Info
const loadedPluginMap = new Map<string, any>(); // 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) {

View File

@ -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,

View File

@ -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<void>;
installStatus?: InstallStatus;
installedVersion?: string;
}
const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
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<PluginStoreCardProps> = ({
onInstall().finally(() => setProcessing(false));
};
// 根据安装状态返回按钮配置
const getButtonConfig = () => {
switch (installStatus) {
case 'installed':
return {
text: '重新安装',
icon: <IoMdRefresh size={16} />,
color: 'default' as const,
};
case 'update-available':
return {
text: '更新',
icon: <IoMdDownload size={16} />,
color: 'success' as const,
};
default:
return {
text: '安装',
icon: <IoMdDownload size={16} />,
color: 'primary' as const,
};
}
};
const buttonConfig = getButtonConfig();
return (
<DisplayCardContainer
className='w-full max-w-[420px]'
title={name}
tag={
<Chip
className="ml-auto"
color="primary"
size="sm"
variant="flat"
>
v{version}
</Chip>
<div className="ml-auto flex items-center gap-1">
{installStatus === 'installed' && (
<Chip
color="success"
size="sm"
variant="flat"
startContent={<IoMdCheckmarkCircle size={14} />}
>
</Chip>
)}
{installStatus === 'update-available' && (
<Chip
color="warning"
size="sm"
variant="flat"
>
</Chip>
)}
<Chip
color="primary"
size="sm"
variant="flat"
>
v{version}
</Chip>
</div>
}
enableSwitch={undefined}
action={
@ -43,13 +95,13 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
fullWidth
radius='full'
size='sm'
color='primary'
startContent={<IoMdDownload size={16} />}
color={buttonConfig.color}
startContent={buttonConfig.icon}
onPress={handleInstall}
isLoading={processing}
isDisabled={processing}
>
{buttonConfig.text}
</Button>
}
>

View File

@ -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<string, unknown>;
}
/** 服务端响应 */
export interface ServerResponse<T> {
code: number;
message: string;
data: T;
}
/**
* API
*/
export default class PluginManager {
public static async getPluginList () {
/**
*
*/
public static async getPluginList (): Promise<PluginListResponse> {
const { data } = await serverRequest.get<ServerResponse<PluginListResponse>>('/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<ServerResponse<{ message: string; }>>('/Plugin/RegisterManager');
return data.data;
}
public static async setPluginStatus (name: string, enable: boolean, filename?: string) {
await serverRequest.post<ServerResponse<void>>('/Plugin/SetStatus', { name, enable, filename });
/**
* /
* @param id
* @param enable
*/
public static async setPluginStatus (id: string, enable: boolean): Promise<void> {
await serverRequest.post<ServerResponse<void>>('/Plugin/SetStatus', { id, enable });
}
public static async uninstallPlugin (name: string, filename?: string, cleanData?: boolean) {
await serverRequest.post<ServerResponse<void>>('/Plugin/Uninstall', { name, filename, cleanData });
/**
*
* @param id
* @param cleanData
*/
public static async uninstallPlugin (id: string, cleanData?: boolean): Promise<void> {
await serverRequest.post<ServerResponse<void>>('/Plugin/Uninstall', { id, cleanData });
}
// 插件商店相关方法
public static async getPluginStoreList () {
// ==================== 插件商店 ====================
/**
*
*/
public static async getPluginStoreList (): Promise<PluginStoreList> {
const { data } = await serverRequest.get<ServerResponse<PluginStoreList>>('/Plugin/Store/List');
return data.data;
}
public static async getPluginStoreDetail (id: string) {
/**
*
* @param id ID
*/
public static async getPluginStoreDetail (id: string): Promise<PluginStoreItem> {
const { data } = await serverRequest.get<ServerResponse<PluginStoreItem>>(`/Plugin/Store/Detail/${id}`);
return data.data;
}
public static async installPluginFromStore (id: string, mirror?: string) {
// 插件安装可能需要较长时间(下载+解压设置5分钟超时
await serverRequest.post<ServerResponse<void>>('/Plugin/Store/Install', { id, mirror }, {
timeout: 300000, // 5分钟
});
/**
*
* @param id ID
* @param mirror
*/
public static async installPluginFromStore (id: string, mirror?: string): Promise<void> {
await serverRequest.post<ServerResponse<void>>(
'/Plugin/Store/Install',
{ id, mirror },
{ timeout: 300000 } // 5分钟超时
);
}
// 插件配置相关方法
public static async getPluginConfig (name: string) {
const { data } = await serverRequest.get<ServerResponse<PluginConfigResponse>>('/Plugin/Config', { params: { name } });
// ==================== 插件配置 ====================
/**
*
* @param id
*/
public static async getPluginConfig (id: string): Promise<PluginConfigResponse> {
const { data } = await serverRequest.get<ServerResponse<PluginConfigResponse>>('/Plugin/Config', {
params: { id }
});
return data.data;
}
public static async setPluginConfig (name: string, config: any) {
await serverRequest.post<ServerResponse<void>>('/Plugin/Config', { name, config });
/**
*
* @param id
* @param config
*/
public static async setPluginConfig (id: string, config: Record<string, unknown>): Promise<void> {
await serverRequest.post<ServerResponse<void>>('/Plugin/Config', { id, config });
}
}

View File

@ -17,7 +17,7 @@ export default function PluginPage () {
const dialog = useDialog();
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const [currentPluginName, setCurrentPluginName] = useState<string>('');
const [currentPluginId, setCurrentPluginId] = useState<string>('');
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 () {
<PluginConfigModal
isOpen={isOpen}
onOpenChange={onOpenChange}
pluginName={currentPluginName}
pluginId={currentPluginId}
/>
<div className='flex mb-6 items-center gap-4'>
@ -145,7 +145,7 @@ export default function PluginPage () {
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4'>
{plugins.map(plugin => (
<PluginDisplayCard
key={plugin.name}
key={plugin.id}
data={plugin}
onToggleStatus={() => handleToggle(plugin)}
onUninstall={() => handleUninstall(plugin)}

View File

@ -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<PluginConfigSchemaItem[]>([]);
const [config, setConfig] = useState<any>({});
const [config, setConfig] = useState<Record<string, unknown>>({});
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) => (
<>
<ModalHeader className="flex flex-col gap-1">
Configuration: {pluginName}
: {pluginId}
</ModalHeader>
<ModalBody>
{loading ? (

View File

@ -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<EmptySectionProps> = ({ isEmpty }) => {
export default function PluginStorePage () {
const [plugins, setPlugins] = useState<PluginStoreItem[]>([]);
const [installedPlugins, setInstalledPlugins] = useState<PluginItem[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [activeTab, setActiveTab] = useState<string>('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 () {
>
<EmptySection isEmpty={!categorizedPlugins[tab.key]?.length} />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4">
{categorizedPlugins[tab.key]?.map((plugin) => (
<PluginStoreCard
key={plugin.id}
data={plugin}
onInstall={() => handleInstall(plugin)}
/>
))}
{categorizedPlugins[tab.key]?.map((plugin) => {
const installInfo = getPluginInstallInfo(plugin);
return (
<PluginStoreCard
key={plugin.id}
data={plugin}
installStatus={installInfo.status}
installedVersion={installInfo.installedVersion}
onInstall={() => handleInstall(plugin)}
/>
);
})}
</div>
</Tab>
))}