diff --git a/packages/napcat-onebot/index.ts b/packages/napcat-onebot/index.ts index 255c6d6a..d4aee8e6 100644 --- a/packages/napcat-onebot/index.ts +++ b/packages/napcat-onebot/index.ts @@ -49,10 +49,11 @@ import { OneBotConfigSchema, } from './config/config'; import { OB11Message } from './types'; +import { existsSync } from 'node:fs'; import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter'; import { OB11HttpSSEServerAdapter } from './network/http-server-sse'; import { OB11PluginMangerAdapter } from './network/plugin-manger'; -import { existsSync } from 'node:fs'; + import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler'; import { OneBotFileApi } from './api/file'; @@ -160,6 +161,7 @@ export class NapCatOneBot11Adapter { // this.networkManager.registerAdapter( // new OB11PluginAdapter('myPlugin', this.core, this,this.actions) // ); + // 检查插件目录是否存在,不存在则不加载插件管理器 if (existsSync(this.context.pathWrapper.pluginPath)) { this.context.logger.log('[Plugins] 插件目录存在,开始加载插件'); this.networkManager.registerAdapter( diff --git a/packages/napcat-onebot/network/plugin-manger.ts b/packages/napcat-onebot/network/plugin-manger.ts index 56c096ed..b5797680 100644 --- a/packages/napcat-onebot/network/plugin-manger.ts +++ b/packages/napcat-onebot/network/plugin-manger.ts @@ -11,6 +11,8 @@ export interface PluginPackageJson { name?: string; version?: string; main?: string; + description?: string; + author?: string; } export interface PluginModule { @@ -85,7 +87,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { /** * 加载单文件插件 (.mjs, .js) */ - private async loadFilePlugin (filename: string): Promise { + public async loadFilePlugin (filename: string): Promise { // 只处理支持的文件类型 if (!this.isSupportedFile(filename)) { return; @@ -117,7 +119,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { /** * 加载目录插件 */ - private async loadDirectoryPlugin (dirname: string): Promise { + public async loadDirectoryPlugin (dirname: string): Promise { const pluginDir = path.join(this.pluginPath, dirname); try { @@ -255,6 +257,14 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`); } + public async unregisterPlugin (pluginName: string): Promise { + return this.unloadPlugin(pluginName); + } + + public getPluginPath (): string { + return this.pluginPath; + } + async onEvent (event: T) { if (!this.isEnable) { return; diff --git a/packages/napcat-plugin-builtin/index.ts b/packages/napcat-plugin-builtin/index.ts index 71a60562..916c6219 100644 --- a/packages/napcat-plugin-builtin/index.ts +++ b/packages/napcat-plugin-builtin/index.ts @@ -81,4 +81,4 @@ async function sendMessage (event: OB11Message, message: string, adapter: string } } -export { plugin_init, plugin_onmessage, actions }; +export { plugin_init, plugin_onmessage }; diff --git a/packages/napcat-webui-backend/src/api/Plugin.ts b/packages/napcat-webui-backend/src/api/Plugin.ts new file mode 100644 index 00000000..738e5cea --- /dev/null +++ b/packages/napcat-webui-backend/src/api/Plugin.ts @@ -0,0 +1,199 @@ +import { RequestHandler } from 'express'; +import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data'; +import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response'; +import { NapCatOneBot11Adapter } from '@/napcat-onebot/index'; +import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger'; +import path from 'path'; +import fs from 'fs'; + +// 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; +}; + +export const GetPluginListHandler: RequestHandler = async (_req, res) => { + const pluginManager = getPluginManager(); + if (!pluginManager) { + return sendError(res, 'Plugin Manager not found'); + } + + const loadedPlugins = pluginManager.getLoadedPlugins().map(p => ({ + name: p.name, + version: p.version || '0.0.0', + description: p.packageJson?.description || '', + author: p.packageJson?.author || '', + status: 'active', + })); + + // Find disabled plugins (those with .disabled extension) + const pluginPath = pluginManager.getPluginPath(); + const disabledPlugins: any[] = []; + if (fs.existsSync(pluginPath)) { + const items = fs.readdirSync(pluginPath, { withFileTypes: true }); + for (const item of items) { + if (item.name.endsWith('.disabled')) { + const originalName = item.name.replace(/\.disabled$/, ''); + const isDirectory = item.isDirectory(); + let version = '0.0.0'; + let description = ''; + let author = ''; + let name = originalName; + + try { + if (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; + name = pkg.name || name; + } + } + } catch (e) { } + + disabledPlugins.push({ + name: name, + version, + description, + author, + status: 'disabled', + filename: item.name // Store real filename for operations + }); + } + } + } + + return sendSuccess(res, [...loadedPlugins, ...disabledPlugins]); +}; + +export const ReloadPluginHandler: RequestHandler = async (req, res) => { + const { name } = req.body; + if (!name) return sendError(res, 'Plugin Name is required'); + + const pluginManager = getPluginManager(); + if (!pluginManager) { + return sendError(res, 'Plugin Manager not found'); + } + + const success = await pluginManager.reloadPlugin(name); + if (success) { + return sendSuccess(res, { message: 'Reloaded successfully' }); + } else { + return sendError(res, 'Failed to reload plugin'); + } +}; + +export const SetPluginStatusHandler: RequestHandler = async (req, res) => { + const { name, enable, filename } = req.body; // filename required for enabling + if (!name) return sendError(res, 'Plugin Name is required'); + + const pluginManager = getPluginManager(); + if (!pluginManager) { + return sendError(res, 'Plugin Manager not found'); + } + + const pluginPath = pluginManager.getPluginPath(); + + if (enable) { + // Enable: Rename back from .disabled + // We need the filename since we can't guess if it was a dir or file easily without scanning or passing it + if (!filename) return sendError(res, 'Filename is required to enable'); + + const disabledPath = path.join(pluginPath, filename); + const enabledPath = path.join(pluginPath, filename.replace(/\.disabled$/, '')); + + if (!fs.existsSync(disabledPath)) { + return sendError(res, 'Disabled plugin not found'); + } + + try { + fs.renameSync(disabledPath, enabledPath); + // After rename, we need to load it + const isDirectory = fs.statSync(enabledPath).isDirectory(); + if (isDirectory) { + await pluginManager.loadDirectoryPlugin(path.basename(enabledPath)); + } else { + await pluginManager.loadFilePlugin(path.basename(enabledPath)); + } + return sendSuccess(res, { message: 'Enabled successfully' }); + } catch (e: any) { + return sendError(res, 'Failed to enable: ' + e.message); + } + + } else { + // Disable: Unload and rename to .disabled + const plugin = pluginManager.getPluginInfo(name); + if (!plugin) return sendError(res, 'Plugin not found/loaded'); + + try { + await pluginManager.unregisterPlugin(name); + // Determine the file/dir key in the fs + + // plugin.pluginPath is the directory for dir plugins, and the directory containing the file for file plugins?? + // Let's check LoadedPlugin definition again. + // pluginPath: this.pluginPath (for file plugins), pluginDir (for dir plugins) + + // Wait, for file plugins: pluginPath = this.pluginPath, entryPath = filePath + // For dir plugins: pluginPath = pluginDir, entryPath = join(pluginDir, entryFile) + + let fsPath = ''; + // We need to rename the whole thing that constitutes the plugin. + if (plugin.pluginPath === pluginManager.getPluginPath()) { + // It's a file plugin + fsPath = plugin.entryPath; + } else { + // It's a directory plugin + fsPath = plugin.pluginPath; + } + + const disabledPath = fsPath + '.disabled'; + fs.renameSync(fsPath, disabledPath); + return sendSuccess(res, { message: 'Disabled successfully' }); + } catch (e: any) { + return sendError(res, 'Failed to disable: ' + e.message); + } + } +}; + +export const UninstallPluginHandler: RequestHandler = async (req, res) => { + const { name, filename } = req.body; + // If it's loaded, we use name. If it's disabled, we might use filename. + + 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 }); + } + return sendSuccess(res, { message: 'Uninstalled successfully' }); + } catch (e: any) { + return sendError(res, 'Failed to uninstall: ' + e.message); + } +}; diff --git a/packages/napcat-webui-backend/src/router/Plugin.ts b/packages/napcat-webui-backend/src/router/Plugin.ts new file mode 100644 index 00000000..bd62a7a0 --- /dev/null +++ b/packages/napcat-webui-backend/src/router/Plugin.ts @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import { GetPluginListHandler, ReloadPluginHandler, SetPluginStatusHandler, UninstallPluginHandler } from '@/napcat-webui-backend/src/api/Plugin'; + +const router = Router(); + +router.get('/List', GetPluginListHandler); +router.post('/Reload', ReloadPluginHandler); +router.post('/SetStatus', SetPluginStatusHandler); +router.post('/Uninstall', UninstallPluginHandler); + +export { router as PluginRouter }; diff --git a/packages/napcat-webui-backend/src/router/index.ts b/packages/napcat-webui-backend/src/router/index.ts index 31b7cbad..06e81abc 100644 --- a/packages/napcat-webui-backend/src/router/index.ts +++ b/packages/napcat-webui-backend/src/router/index.ts @@ -17,6 +17,7 @@ import { WebUIConfigRouter } from './WebUIConfig'; import { UpdateNapCatRouter } from './UpdateNapCat'; import DebugRouter from '@/napcat-webui-backend/src/api/Debug'; import { ProcessRouter } from './Process'; +import { PluginRouter } from './Plugin'; const router = Router(); @@ -47,5 +48,7 @@ router.use('/UpdateNapCat', UpdateNapCatRouter); router.use('/Debug', DebugRouter); // router:进程管理相关路由 router.use('/Process', ProcessRouter); +// router:插件管理相关路由 +router.use('/Plugin', PluginRouter); export { router as ALLRouter }; diff --git a/packages/napcat-webui-frontend/src/App.tsx b/packages/napcat-webui-frontend/src/App.tsx index e21a60ca..796ed195 100644 --- a/packages/napcat-webui-frontend/src/App.tsx +++ b/packages/napcat-webui-frontend/src/App.tsx @@ -25,6 +25,7 @@ const FileManagerPage = lazy(() => import('@/pages/dashboard/file_manager')); const LogsPage = lazy(() => import('@/pages/dashboard/logs')); const NetworkPage = lazy(() => import('@/pages/dashboard/network')); const TerminalPage = lazy(() => import('@/pages/dashboard/terminal')); +const PluginPage = lazy(() => import('@/pages/dashboard/plugin')); function App () { return ( @@ -42,7 +43,7 @@ function App () { ); } -function AuthChecker ({ children }: { children: React.ReactNode }) { +function AuthChecker ({ children }: { children: React.ReactNode; }) { const { isAuth } = useAuth(); const navigate = useNavigate(); @@ -76,6 +77,7 @@ function AppRoutes () { } /> } /> + } /> } /> } /> diff --git a/packages/napcat-webui-frontend/src/components/display_card/plugin_card.tsx b/packages/napcat-webui-frontend/src/components/display_card/plugin_card.tsx new file mode 100644 index 00000000..c39f0f62 --- /dev/null +++ b/packages/napcat-webui-frontend/src/components/display_card/plugin_card.tsx @@ -0,0 +1,116 @@ +import { Button } from '@heroui/button'; +import { Switch } from '@heroui/switch'; +import clsx from 'clsx'; +import { useState } from 'react'; +import { MdDeleteForever, MdPublishedWithChanges } from 'react-icons/md'; + +import DisplayCardContainer from './container'; +import { PluginItem } from '@/controllers/plugin_manager'; + +export interface PluginDisplayCardProps { + data: PluginItem; + onReload: () => Promise; + onToggleStatus: () => Promise; + onUninstall: () => Promise; +} + +const PluginDisplayCard: React.FC = ({ + data, + onReload, + onToggleStatus, + onUninstall, +}) => { + const { name, version, author, description, status } = data; + const isEnabled = status === 'active'; + const [processing, setProcessing] = useState(false); + + const handleToggle = () => { + setProcessing(true); + onToggleStatus().finally(() => setProcessing(false)); + }; + + const handleReload = () => { + setProcessing(true); + onReload().finally(() => setProcessing(false)); + }; + + const handleUninstall = () => { + setProcessing(true); + onUninstall().finally(() => setProcessing(false)); + }; + + return ( + + + + + + } + enableSwitch={ + + } + title={name} + > +
+
+ + 版本 + +
+ {version} +
+
+
+ + 作者 + +
+ {author || '未知'} +
+
+
+ + 描述 + +
+ {description || '暂无描述'} +
+
+
+
+ ); +}; + +export default PluginDisplayCard; diff --git a/packages/napcat-webui-frontend/src/config/site.tsx b/packages/napcat-webui-frontend/src/config/site.tsx index d0ced77d..05a06e8e 100644 --- a/packages/napcat-webui-frontend/src/config/site.tsx +++ b/packages/napcat-webui-frontend/src/config/site.tsx @@ -8,6 +8,7 @@ import { LuSignal, LuTerminal, LuZap, + LuPackage, } from 'react-icons/lu'; export type SiteConfig = typeof siteConfig; @@ -59,6 +60,11 @@ export const siteConfig = { icon: , href: '/file_manager', }, + { + label: '插件管理', + icon: , + href: '/plugins', + }, { label: '系统终端', icon: , diff --git a/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts b/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts new file mode 100644 index 00000000..97b65ac1 --- /dev/null +++ b/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts @@ -0,0 +1,35 @@ +import { serverRequest } from '@/utils/request'; + +export interface PluginItem { + name: string; + version: string; + description: string; + author: string; + status: 'active' | 'disabled'; + filename?: string; +} + +export interface ServerResponse { + code: number; + message: string; + data: T; +} + +export default class PluginManager { + public static async getPluginList () { + const { data } = await serverRequest.get>('/Plugin/List'); + return data.data; + } + + public static async reloadPlugin (name: string) { + await serverRequest.post>('/Plugin/Reload', { name }); + } + + public static async setPluginStatus (name: string, enable: boolean, filename?: string) { + await serverRequest.post>('/Plugin/SetStatus', { name, enable, filename }); + } + + public static async uninstallPlugin (name: string, filename?: string) { + await serverRequest.post>('/Plugin/Uninstall', { name, filename }); + } +} diff --git a/packages/napcat-webui-frontend/src/pages/dashboard/plugin.tsx b/packages/napcat-webui-frontend/src/pages/dashboard/plugin.tsx new file mode 100644 index 00000000..e5dada94 --- /dev/null +++ b/packages/napcat-webui-frontend/src/pages/dashboard/plugin.tsx @@ -0,0 +1,115 @@ +import { Button } from '@heroui/button'; +import { useEffect, useState } from 'react'; +import toast from 'react-hot-toast'; +import { IoMdRefresh } from 'react-icons/io'; + +import PageLoading from '@/components/page_loading'; +import PluginDisplayCard from '@/components/display_card/plugin_card'; +import PluginManager, { PluginItem } from '@/controllers/plugin_manager'; +import useDialog from '@/hooks/use-dialog'; + +export default function PluginPage () { + const [plugins, setPlugins] = useState([]); + const [loading, setLoading] = useState(false); + const dialog = useDialog(); + + const loadPlugins = async () => { + setLoading(true); + try { + const data = await PluginManager.getPluginList(); + setPlugins(data); + } catch (e: any) { + toast.error(e.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadPlugins(); + }, []); + + const handleReload = async (name: string) => { + const loadingToast = toast.loading('重载中...'); + try { + await PluginManager.reloadPlugin(name); + toast.success('重载成功', { id: loadingToast }); + loadPlugins(); + } catch (e: any) { + toast.error(e.message, { id: loadingToast }); + } + }; + + const handleToggle = async (plugin: PluginItem) => { + const isEnable = plugin.status !== 'active'; + const actionText = isEnable ? '启用' : '禁用'; + const loadingToast = toast.loading(`${actionText}中...`); + try { + await PluginManager.setPluginStatus(plugin.name, isEnable, plugin.filename); + toast.success(`${actionText}成功`, { id: loadingToast }); + loadPlugins(); + } catch (e: any) { + toast.error(e.message, { id: loadingToast }); + } + }; + + const handleUninstall = async (plugin: PluginItem) => { + return new Promise((resolve, reject) => { + dialog.confirm({ + title: '卸载插件', + content: `确定要卸载插件「${plugin.name}」吗? 此操作不可恢复。`, + onConfirm: async () => { + const loadingToast = toast.loading('卸载中...'); + try { + await PluginManager.uninstallPlugin(plugin.name, plugin.filename); + toast.success('卸载成功', { id: loadingToast }); + loadPlugins(); + resolve(); + } catch (e: any) { + toast.error(e.message, { id: loadingToast }); + reject(e); + } + }, + onCancel: () => { + resolve(); + } + }); + }); + }; + + return ( + <> + 插件管理 - NapCat WebUI +
+ +
+

插件管理

+ +
+ + {plugins.length === 0 ? ( +
暂时没有安装插件
+ ) : ( +
+ {plugins.map(plugin => ( + handleReload(plugin.name)} + onToggleStatus={() => handleToggle(plugin)} + onUninstall={() => handleUninstall(plugin)} + /> + ))} +
+ )} +
+ + ); +}