Add plugin enable/disable config and status management

Introduces a persistent plugins.json config to track enabled/disabled status for plugins, updates the plugin manager to respect this config when loading plugins, and adds API and frontend support for toggling plugin status. The backend now reports plugin status as 'active', 'stopped', or 'disabled', and the frontend displays these states with appropriate labels. Also updates the built-in plugin package.json with author info.
This commit is contained in:
手瓜一十雪 2026-01-17 16:24:46 +08:00
parent ed1872a349
commit ec6762d916
5 changed files with 400 additions and 137 deletions

View File

@ -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 { 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 { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
import { PluginConfig } from '../config/config';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
@ -16,10 +16,34 @@ export interface PluginPackageJson {
} }
export interface PluginModule<T extends OB11EmitEventContent = OB11EmitEventContent> { export interface PluginModule<T extends OB11EmitEventContent = OB11EmitEventContent> {
plugin_init: (core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise<void>; plugin_init: (
plugin_onmessage?: (adapter: string, core: NapCatCore, obCtx: NapCatOneBot11Adapter, event: OB11Message, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise<void>; core: NapCatCore,
plugin_onevent?: (adapter: string, core: NapCatCore, obCtx: NapCatOneBot11Adapter, event: T, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise<void>; obContext: NapCatOneBot11Adapter,
plugin_cleanup?: (core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise<void>; actions: ActionMap,
instance: OB11PluginMangerAdapter
) => void | Promise<void>;
plugin_onmessage?: (
adapter: string,
core: NapCatCore,
obCtx: NapCatOneBot11Adapter,
event: OB11Message,
actions: ActionMap,
instance: OB11PluginMangerAdapter
) => void | Promise<void>;
plugin_onevent?: (
adapter: string,
core: NapCatCore,
obCtx: NapCatOneBot11Adapter,
event: T,
actions: ActionMap,
instance: OB11PluginMangerAdapter
) => void | Promise<void>;
plugin_cleanup?: (
core: NapCatCore,
obContext: NapCatOneBot11Adapter,
actions: ActionMap,
instance: OB11PluginMangerAdapter
) => void | Promise<void>;
} }
export interface LoadedPlugin { export interface LoadedPlugin {
@ -31,16 +55,25 @@ export interface LoadedPlugin {
module: PluginModule; module: PluginModule;
} }
export interface PluginStatusConfig {
[key: string]: boolean; // key: pluginName, value: enabled
}
export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> { export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
private readonly pluginPath: string; private readonly pluginPath: string;
private readonly configPath: string;
private loadedPlugins: Map<string, LoadedPlugin> = new Map(); private loadedPlugins: Map<string, LoadedPlugin> = new Map();
declare config: PluginConfig; declare config: PluginConfig;
override get isActive (): boolean { override get isActive (): boolean {
return this.isEnable && this.loadedPlugins.size > 0; return this.isEnable && this.loadedPlugins.size > 0;
} }
constructor ( constructor (
name: string, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap name: string,
core: NapCatCore,
obContext: NapCatOneBot11Adapter,
actions: ActionMap
) { ) {
const config = { const config = {
name, name,
@ -51,24 +84,60 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
}; };
super(name, config, core, obContext, actions); super(name, config, core, obContext, actions);
this.pluginPath = this.core.context.pathWrapper.pluginPath; 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<void> { private async loadPlugins (): Promise<void> {
try { try {
// 确保插件目录存在 // 确保插件目录存在
if (!fs.existsSync(this.pluginPath)) { 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 }); fs.mkdirSync(this.pluginPath, { recursive: true });
return; return;
} }
const items = fs.readdirSync(this.pluginPath, { withFileTypes: true }); const items = fs.readdirSync(this.pluginPath, { withFileTypes: true });
const pluginConfig = this.loadPluginConfig();
// 扫描文件和目录 // 扫描文件和目录
for (const item of items) { 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()) { if (item.isFile()) {
// 处理单文件插件 // 处理单文件插件
await this.loadFilePlugin(item.name); await this.loadFilePlugin(item.name);
@ -78,15 +147,17 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
} }
} }
this.logger.log(`[Plugin Adapter] Loaded ${this.loadedPlugins.size} plugins`); this.logger.log(
`[Plugin Adapter] Loaded ${this.loadedPlugins.size} plugins`
);
} catch (error) { } catch (error) {
this.logger.logError('[Plugin Adapter] Error loading plugins:', error); this.logger.logError('[Plugin Adapter] Error loading plugins:', error);
} }
} }
/** /**
* (.mjs, .js) * (.mjs, .js)
*/ */
public async loadFilePlugin (filename: string): Promise<void> { public async loadFilePlugin (filename: string): Promise<void> {
// 只处理支持的文件类型 // 只处理支持的文件类型
if (!this.isSupportedFile(filename)) { if (!this.isSupportedFile(filename)) {
@ -95,11 +166,20 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
const filePath = path.join(this.pluginPath, filename); const filePath = path.join(this.pluginPath, filename);
const pluginName = path.parse(filename).name; 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 { try {
const module = await this.importModule(filePath); const module = await this.importModule(filePath);
if (!this.isValidPluginModule(module)) { 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; return;
} }
@ -112,15 +192,31 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
await this.registerPlugin(plugin); await this.registerPlugin(plugin);
} catch (error) { } 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<void> { public async loadDirectoryPlugin (dirname: string): Promise<void> {
const pluginDir = path.join(this.pluginPath, dirname); 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 { try {
// 尝试读取 package.json // 尝试读取 package.json
@ -132,14 +228,22 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
const packageContent = fs.readFileSync(packageJsonPath, 'utf-8'); const packageContent = fs.readFileSync(packageJsonPath, 'utf-8');
packageJson = JSON.parse(packageContent); packageJson = JSON.parse(packageContent);
} catch (error) { } 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); const entryFile = this.findEntryFile(pluginDir, packageJson);
if (!entryFile) { 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; return;
} }
@ -147,7 +251,9 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
const module = await this.importModule(entryPath); const module = await this.importModule(entryPath);
if (!this.isValidPluginModule(module)) { 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; return;
} }
@ -162,14 +268,20 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
await this.registerPlugin(plugin); await this.registerPlugin(plugin);
} catch (error) { } 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 > 默认文件名 // 优先级package.json main > 默认文件名
const possibleEntries = [ const possibleEntries = [
packageJson?.main, packageJson?.main,
@ -190,53 +302,69 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
} }
/** /**
* *
*/ */
private isSupportedFile (filename: string): boolean { private isSupportedFile (filename: string): boolean {
const ext = path.extname(filename).toLowerCase(); const ext = path.extname(filename).toLowerCase();
return ['.mjs', '.js'].includes(ext); return ['.mjs', '.js'].includes(ext);
} }
/** /**
* *
*/ */
private async importModule (filePath: string): Promise<any> { private async importModule (filePath: string): Promise<any> {
const fileUrl = `file://${filePath.replace(/\\/g, '/')}`; 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 { private isValidPluginModule (module: any): module is PluginModule {
return module && typeof module.plugin_init === 'function'; return module && typeof module.plugin_init === 'function';
} }
/** /**
* *
*/ */
private async registerPlugin (plugin: LoadedPlugin): Promise<void> { private async registerPlugin (plugin: LoadedPlugin): Promise<void> {
// 检查名称冲突 // 检查名称冲突
if (this.loadedPlugins.has(plugin.name)) { 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; return;
} }
this.loadedPlugins.set(plugin.name, plugin); 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 { 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}`); this.logger.log(`[Plugin Adapter] Initialized plugin: ${plugin.name}`);
} catch (error) { } 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<void> { private async unloadPlugin (pluginName: string): Promise<void> {
const plugin = this.loadedPlugins.get(pluginName); const plugin = this.loadedPlugins.get(pluginName);
if (!plugin) { if (!plugin) {
@ -246,10 +374,18 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
// 调用插件清理方法 // 调用插件清理方法
if (typeof plugin.module.plugin_cleanup === 'function') { if (typeof plugin.module.plugin_cleanup === 'function') {
try { 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}`); this.logger.log(`[Plugin Adapter] Cleaned up plugin: ${pluginName}`);
} catch (error) { } 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<PluginConfig> {
return this.pluginPath; 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<T extends OB11EmitEventContent> (event: T) { async onEvent<T extends OB11EmitEventContent> (event: T) {
if (!this.isEnable) { if (!this.isEnable) {
return; return;
@ -283,21 +474,44 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
} }
/** /**
* *
*/ */
private async callPluginEventHandler (plugin: LoadedPlugin, event: OB11EmitEventContent): Promise<void> { private async callPluginEventHandler (
plugin: LoadedPlugin,
event: OB11EmitEventContent
): Promise<void> {
try { try {
// 优先使用 plugin_onevent 方法 // 优先使用 plugin_onevent 方法
if (typeof plugin.module.plugin_onevent === 'function') { 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 方法,也调用 // 如果是消息事件并且插件有 plugin_onmessage 方法,也调用
if ((event as any).message_type && typeof plugin.module.plugin_onmessage === 'function') { if (
await plugin.module.plugin_onmessage(this.name, this.core, this.obContext, event as OB11Message, this.actions, this); (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) { } 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<PluginConfig> {
// 加载所有插件 // 加载所有插件
await this.loadPlugins(); 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 () { async close () {
@ -344,22 +560,22 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
} }
/** /**
* *
*/ */
public getLoadedPlugins (): LoadedPlugin[] { public getLoadedPlugins (): LoadedPlugin[] {
return Array.from(this.loadedPlugins.values()); return Array.from(this.loadedPlugins.values());
} }
/** /**
* *
*/ */
public getPluginInfo (pluginName: string): LoadedPlugin | undefined { public getPluginInfo (pluginName: string): LoadedPlugin | undefined {
return this.loadedPlugins.get(pluginName); return this.loadedPlugins.get(pluginName);
} }
/** /**
* *
*/ */
public async reloadPlugin (pluginName: string): Promise<boolean> { public async reloadPlugin (pluginName: string): Promise<boolean> {
const plugin = this.loadedPlugins.get(pluginName); const plugin = this.loadedPlugins.get(pluginName);
if (!plugin) { if (!plugin) {
@ -372,8 +588,10 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
await this.unloadPlugin(pluginName); await this.unloadPlugin(pluginName);
// 重新加载插件 // 重新加载插件
const isDirectory = fs.statSync(plugin.pluginPath).isDirectory() && // Use logic to re-determine if it is directory or file based on original paths
plugin.pluginPath !== this.pluginPath; // 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) { if (isDirectory) {
const dirname = path.basename(plugin.pluginPath); const dirname = path.basename(plugin.pluginPath);
@ -383,10 +601,15 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
await this.loadFilePlugin(filename); await this.loadFilePlugin(filename);
} }
this.logger.log(`[Plugin Adapter] Plugin ${pluginName} reloaded successfully`); this.logger.log(
`[Plugin Adapter] Plugin ${pluginName} reloaded successfully`
);
return true; return true;
} catch (error) { } catch (error) {
this.logger.logError(`[Plugin Adapter] Error reloading plugin ${pluginName}:`, error); this.logger.logError(
`[Plugin Adapter] Error reloading plugin ${pluginName}:`,
error
);
return false; return false;
} }
} }

View File

@ -4,6 +4,7 @@
"type": "module", "type": "module",
"main": "index.mjs", "main": "index.mjs",
"description": "NapCat 内置插件", "description": "NapCat 内置插件",
"author": "NapNeko",
"dependencies": { "dependencies": {
"napcat-onebot": "workspace:*" "napcat-onebot": "workspace:*"
}, },

View File

@ -19,58 +19,109 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
return sendError(res, 'Plugin Manager not found'); return sendError(res, 'Plugin Manager not found');
} }
const loadedPlugins = pluginManager.getLoadedPlugins().map(p => ({ // 辅助函数:根据文件名/路径生成唯一ID作为配置键
name: p.name, const getPluginId = (fsName: string, isFile: boolean): string => {
version: p.version || '0.0.0', if (isFile) {
description: p.packageJson?.description || '', return path.parse(fsName).name;
author: p.packageJson?.author || '', }
status: 'active', return fsName;
})); };
const loadedPlugins = pluginManager.getLoadedPlugins();
const loadedPluginMap = new Map<string, any>(); // 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 pluginPath = pluginManager.getPluginPath();
const disabledPlugins: any[] = []; const pluginConfig = pluginManager.getPluginConfig();
const allPlugins: any[] = [];
// 2. 扫描文件系统,合并状态
if (fs.existsSync(pluginPath)) { if (fs.existsSync(pluginPath)) {
const items = fs.readdirSync(pluginPath, { withFileTypes: true }); const items = fs.readdirSync(pluginPath, { withFileTypes: true });
for (const item of items) { for (const item of items) {
if (item.name.endsWith('.disabled')) { let id = '';
const originalName = item.name.replace(/\.disabled$/, ''); let isFile = false;
const isDirectory = item.isDirectory();
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 version = '0.0.0';
let description = ''; let description = '';
let author = ''; let author = '';
let name = originalName; // 默认显示名称为 ID (文件名/目录名)
let name = id;
try { try {
if (isDirectory) { // 尝试读取 package.json 获取信息
if (item.isDirectory()) {
const packageJsonPath = path.join(pluginPath, item.name, 'package.json'); const packageJsonPath = path.join(pluginPath, item.name, 'package.json');
if (fs.existsSync(packageJsonPath)) { if (fs.existsSync(packageJsonPath)) {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
version = pkg.version || version; version = pkg.version || version;
description = pkg.description || description; description = pkg.description || description;
author = pkg.author || author; author = pkg.author || author;
// 如果 package.json 有 name优先使用
name = pkg.name || name; name = pkg.name || name;
} }
} }
} catch (e) { } } catch (e) { }
disabledPlugins.push({ allPlugins.push({
name: name, name: name,
id: id,
version, version,
description, description,
author, author,
status: 'disabled', // 如果配置是 false则为 disabled否则是 stopped (应启动但未启动)
filename: item.name // Store real filename for operations status: isActiveConfig ? 'stopped' : 'disabled',
filename: item.name
}); });
} }
} }
} }
return sendSuccess(res, [...loadedPlugins, ...disabledPlugins]); return sendSuccess(res, allPlugins);
}; };
export const ReloadPluginHandler: RequestHandler = async (req, res) => { export const ReloadPluginHandler: RequestHandler = async (req, res) => {
const { name } = req.body; 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'); if (!name) return sendError(res, 'Plugin Name is required');
const pluginManager = getPluginManager(); const pluginManager = getPluginManager();
@ -87,74 +138,51 @@ export const ReloadPluginHandler: RequestHandler = async (req, res) => {
}; };
export const SetPluginStatusHandler: RequestHandler = async (req, res) => { export const SetPluginStatusHandler: RequestHandler = async (req, res) => {
const { name, enable, filename } = req.body; // filename required for enabling const { enable, filename } = req.body;
if (!name) return sendError(res, 'Plugin Name is required'); // 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(); const pluginManager = getPluginManager();
if (!pluginManager) { if (!pluginManager) {
return sendError(res, 'Plugin Manager not found'); 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) { try {
// Enable: Rename back from .disabled pluginManager.setPluginStatus(id, enable);
// 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');
const disabledPath = path.join(pluginPath, filename); // If enabling, trigger load
const enabledPath = path.join(pluginPath, filename.replace(/\.disabled$/, '')); if (enable) {
const pluginPath = pluginManager.getPluginPath();
const fullPath = path.join(pluginPath, filename);
if (!fs.existsSync(disabledPath)) { if (fs.statSync(fullPath).isDirectory()) {
return sendError(res, 'Disabled plugin not found'); await pluginManager.loadDirectoryPlugin(filename);
}
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));
} else { } else {
await pluginManager.loadFilePlugin(path.basename(enabledPath)); await pluginManager.loadFilePlugin(filename);
} }
return sendSuccess(res, { message: 'Enabled successfully' }); } else {
} catch (e: any) { // Disabling is handled inside setPluginStatus usually if implemented,
return sendError(res, 'Failed to enable: ' + e.message); // 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 { return sendSuccess(res, { message: 'Status updated successfully' });
// Disable: Unload and rename to .disabled } catch (e: any) {
const plugin = pluginManager.getPluginInfo(name); return sendError(res, 'Failed to update status: ' + e.message);
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);
}
} }
}; };

View File

@ -1,6 +1,7 @@
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import { Switch } from '@heroui/switch'; import { Switch } from '@heroui/switch';
import clsx from 'clsx'; import { Chip } from '@heroui/chip';
import { useState } from 'react'; import { useState } from 'react';
import { MdDeleteForever, MdPublishedWithChanges } from 'react-icons/md'; import { MdDeleteForever, MdPublishedWithChanges } from 'react-icons/md';
@ -21,7 +22,7 @@ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
onUninstall, onUninstall,
}) => { }) => {
const { name, version, author, description, status } = data; const { name, version, author, description, status } = data;
const isEnabled = status === 'active'; const isEnabled = status !== 'disabled';
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const handleToggle = () => { const handleToggle = () => {
@ -82,6 +83,16 @@ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
/> />
} }
title={name} title={name}
tag={
<Chip
className="ml-auto"
color={status === 'active' ? 'success' : status === 'stopped' ? 'warning' : 'default'}
size="sm"
variant="flat"
>
{status === 'active' ? '运行中' : status === 'stopped' ? '已停止' : '已禁用'}
</Chip>
}
> >
<div className='grid grid-cols-2 gap-3'> <div className='grid grid-cols-2 gap-3'>
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'> <div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>

View File

@ -5,7 +5,7 @@ export interface PluginItem {
version: string; version: string;
description: string; description: string;
author: string; author: string;
status: 'active' | 'disabled'; status: 'active' | 'disabled' | 'stopped';
filename?: string; filename?: string;
} }