From 94f07ab98bb317a5e6a24aa9aaaa4f7ce0dbdf57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=8B=E7=93=9C=E4=B8=80=E5=8D=81=E9=9B=AA?= Date: Mon, 2 Feb 2026 15:01:26 +0800 Subject: [PATCH] Support memory static files and plugin APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce in-memory static file support and inter-plugin exports. Add MemoryStaticFile/MemoryFileGenerator types and expose staticOnMem in PluginRouterRegistry; router registry now tracks memory routes and exposes getters. Add getPluginExports to plugin manager adapters to allow plugins to call each other's exported modules. WebUI backend gains routes to serve /plugin/:pluginId/mem/* (memory files) and /plugin/:pluginId/files/* (plugin filesystem static) without auth. Update builtin plugin to demonstrate staticOnMem and inter-plugin call, and add frontend UI to open extension pages in a new window. Note: API router no longer mounts static filesystem routes — those are handled by webui-backend. --- .../napcat-onebot/network/plugin-manger.ts | 11 +++ .../napcat-onebot/network/plugin/manager.ts | 10 +++ .../network/plugin/router-registry.ts | 44 ++++++++++-- .../napcat-onebot/network/plugin/types.ts | 27 ++++++++ packages/napcat-plugin-builtin/index.ts | 62 ++++++++++++++++- packages/napcat-webui-backend/index.ts | 68 +++++++++++++++++++ .../src/pages/dashboard/extension.tsx | 19 +++++- 7 files changed, 230 insertions(+), 11 deletions(-) diff --git a/packages/napcat-onebot/network/plugin-manger.ts b/packages/napcat-onebot/network/plugin-manger.ts index be10ab89..69f5f198 100644 --- a/packages/napcat-onebot/network/plugin-manger.ts +++ b/packages/napcat-onebot/network/plugin-manger.ts @@ -28,6 +28,7 @@ export { PluginModule } from './plugin/types'; export { PluginStatusConfig } from './plugin/types'; export { PluginRouterRegistry, PluginRequestHandler, PluginApiRouteDefinition, PluginPageDefinition, HttpMethod } from './plugin/types'; export { PluginHttpRequest, PluginHttpResponse, PluginNextFunction } from './plugin/types'; +export { MemoryStaticFile, MemoryFileGenerator } from './plugin/types'; export { PluginRouterRegistryImpl } from './plugin/router-registry'; export class OB11PluginMangerAdapter extends IOB11NetworkAdapter implements IPluginManager { private readonly pluginPath: string; @@ -214,6 +215,15 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter i // 保存到路由注册表 this.pluginRouters.set(entry.id, routerRegistry); + // 创建获取其他插件导出的方法 + const getPluginExports = (pluginId: string): T | undefined => { + const targetEntry = this.plugins.get(pluginId); + if (!targetEntry || !targetEntry.loaded || targetEntry.runtime.status !== 'loaded') { + return undefined; + } + return targetEntry.runtime.module as T; + }; + return { core: this.core, oneBot: this.obContext, @@ -227,6 +237,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter i pluginManager: this, logger: pluginLogger, router: routerRegistry, + getPluginExports, }; } diff --git a/packages/napcat-onebot/network/plugin/manager.ts b/packages/napcat-onebot/network/plugin/manager.ts index b9b38fce..03fadbe3 100644 --- a/packages/napcat-onebot/network/plugin/manager.ts +++ b/packages/napcat-onebot/network/plugin/manager.ts @@ -194,6 +194,15 @@ export class OB11PluginManager extends IOB11NetworkAdapter impleme this.pluginRouters.set(entry.id, router); } + // 创建获取其他插件导出的方法 + const getPluginExports = (pluginId: string): T | undefined => { + const targetEntry = this.plugins.get(pluginId); + if (!targetEntry || !targetEntry.loaded || targetEntry.runtime.status !== 'loaded') { + return undefined; + } + return targetEntry.runtime.module as T; + }; + return { core: this.core, oneBot: this.obContext, @@ -207,6 +216,7 @@ export class OB11PluginManager extends IOB11NetworkAdapter impleme pluginManager: this, logger: pluginLogger, router, + getPluginExports, }; } diff --git a/packages/napcat-onebot/network/plugin/router-registry.ts b/packages/napcat-onebot/network/plugin/router-registry.ts index a0b250f9..2bf4b1af 100644 --- a/packages/napcat-onebot/network/plugin/router-registry.ts +++ b/packages/napcat-onebot/network/plugin/router-registry.ts @@ -1,4 +1,4 @@ -import { Router, static as expressStatic, Request, Response, NextFunction } from 'express'; +import { Router, Request, Response, NextFunction } from 'express'; import path from 'path'; import { PluginRouterRegistry, @@ -8,6 +8,7 @@ import { PluginHttpRequest, PluginHttpResponse, HttpMethod, + MemoryStaticFile, } from './types'; /** @@ -59,10 +60,17 @@ function wrapResponse (res: Response): PluginHttpResponse { * 插件路由注册器实现 * 为每个插件创建独立的路由注册器,收集路由定义 */ +/** 内存静态路由定义 */ +interface MemoryStaticRoute { + urlPath: string; + files: MemoryStaticFile[]; +} + export class PluginRouterRegistryImpl implements PluginRouterRegistry { private apiRoutes: PluginApiRouteDefinition[] = []; private pageDefinitions: PluginPageDefinition[] = []; private staticRoutes: Array<{ urlPath: string; localPath: string; }> = []; + private memoryStaticRoutes: MemoryStaticRoute[] = []; constructor ( private readonly pluginId: string, @@ -111,19 +119,19 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry { this.staticRoutes.push({ urlPath, localPath: absolutePath }); } + staticOnMem (urlPath: string, files: MemoryStaticFile[]): void { + this.memoryStaticRoutes.push({ urlPath, files }); + } + // ==================== 构建路由 ==================== /** * 构建 Express Router(用于 API 路由) + * 注意:静态资源路由不在此处挂载,由 webui-backend 直接在不需要鉴权的路径下处理 */ buildApiRouter (): Router { const router = Router(); - // 注册静态文件路由 - for (const { urlPath, localPath } of this.staticRoutes) { - router.use(urlPath, expressStatic(localPath)); - } - // 注册 API 路由 for (const route of this.apiRoutes) { const handler = this.wrapHandler(route.handler); @@ -179,7 +187,14 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry { * 检查是否有注册的 API 路由 */ hasApiRoutes (): boolean { - return this.apiRoutes.length > 0 || this.staticRoutes.length > 0; + return this.apiRoutes.length > 0; + } + + /** + * 检查是否有注册的静态资源路由 + */ + hasStaticRoutes (): boolean { + return this.staticRoutes.length > 0 || this.memoryStaticRoutes.length > 0; } /** @@ -210,6 +225,20 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry { return this.pluginPath; } + /** + * 获取所有注册的静态路由 + */ + getStaticRoutes (): Array<{ urlPath: string; localPath: string; }> { + return [...this.staticRoutes]; + } + + /** + * 获取所有注册的内存静态路由 + */ + getMemoryStaticRoutes (): MemoryStaticRoute[] { + return [...this.memoryStaticRoutes]; + } + /** * 清空路由(用于插件卸载) */ @@ -217,5 +246,6 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry { this.apiRoutes = []; this.pageDefinitions = []; this.staticRoutes = []; + this.memoryStaticRoutes = []; } } diff --git a/packages/napcat-onebot/network/plugin/types.ts b/packages/napcat-onebot/network/plugin/types.ts index 448b276b..c0290ff0 100644 --- a/packages/napcat-onebot/network/plugin/types.ts +++ b/packages/napcat-onebot/network/plugin/types.ts @@ -125,6 +125,19 @@ export interface PluginPageDefinition { description?: string; } +/** 内存文件生成器 - 用于动态生成静态文件内容 */ +export type MemoryFileGenerator = () => string | Buffer | Promise; + +/** 内存静态文件定义 */ +export interface MemoryStaticFile { + /** 文件路径(相对于 urlPath) */ + path: string; + /** 文件内容或生成器 */ + content: string | Buffer | MemoryFileGenerator; + /** 可选的 MIME 类型 */ + contentType?: string; +} + /** 插件路由注册器 */ export interface PluginRouterRegistry { // ==================== API 路由注册 ==================== @@ -167,6 +180,13 @@ export interface PluginRouterRegistry { * @param localPath 本地文件夹路径(相对于插件目录或绝对路径) */ static (urlPath: string, localPath: string): void; + + /** + * 提供内存生成的静态文件服务 + * @param urlPath URL 路径 + * @param files 内存文件列表 + */ + staticOnMem (urlPath: string, files: MemoryStaticFile[]): void; } // ==================== 插件管理器接口 ==================== @@ -247,8 +267,15 @@ export interface NapCatPluginContext { /** * WebUI 路由注册器 * 用于注册插件的 HTTP API 路由,路由将挂载到 /api/Plugin/ext/{pluginId}/ + * 静态资源将挂载到 /plugin/{pluginId}/files/{urlPath}/ */ router: PluginRouterRegistry; + /** + * 获取其他插件的导出模块 + * @param pluginId 目标插件 ID + * @returns 插件导出的模块,如果插件未加载则返回 undefined + */ + getPluginExports: (pluginId: string) => T | undefined; } // ==================== 插件模块接口 ==================== diff --git a/packages/napcat-plugin-builtin/index.ts b/packages/napcat-plugin-builtin/index.ts index 290f9b62..b438245f 100644 --- a/packages/napcat-plugin-builtin/index.ts +++ b/packages/napcat-plugin-builtin/index.ts @@ -65,10 +65,32 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => { // ==================== 注册 WebUI 路由示例 ==================== - // 注册静态资源目录(webui 目录下的文件可通过 /api/Plugin/ext/{pluginId}/static/ 访问) + // 注册静态资源目录 + // 静态资源可通过 /plugin/{pluginId}/files/static/ 访问(无需鉴权) ctx.router.static('/static', 'webui'); - // 注册 API 路由 + // 注册内存生成的静态资源(无需鉴权) + // 可通过 /plugin/{pluginId}/mem/dynamic/info.json 访问 + ctx.router.staticOnMem('/dynamic', [ + { + path: '/info.json', + contentType: 'application/json', + // 使用生成器函数动态生成内容 + content: () => JSON.stringify({ + pluginName: ctx.pluginName, + generatedAt: new Date().toISOString(), + uptime: Date.now() - startTime, + config: currentConfig + }, null, 2) + }, + { + path: '/readme.txt', + contentType: 'text/plain', + content: `NapCat Builtin Plugin\n=====================\nThis is a demonstration of the staticOnMem feature.\nPlugin: ${ctx.pluginName}\nPath: ${ctx.pluginPath}` + } + ]); + + // 注册 API 路由(需要鉴权,挂载到 /api/Plugin/ext/{pluginId}/) ctx.router.get('/status', (_req, res) => { const uptime = Date.now() - startTime; res.json({ @@ -107,6 +129,37 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => { } }); + // ==================== 插件互调用示例 ==================== + // 演示如何调用其他插件的导出方法 + ctx.router.get('/call-plugin/:pluginId', (req, res) => { + const { pluginId } = req.params; + + // 使用 getPluginExports 获取其他插件的导出模块 + const targetPlugin = ctx.getPluginExports(pluginId); + + if (!targetPlugin) { + res.status(404).json({ + code: -1, + message: `Plugin '${pluginId}' not found or not loaded` + }); + return; + } + + // 返回目标插件的信息 + res.json({ + code: 0, + data: { + pluginId, + hasInit: typeof targetPlugin.plugin_init === 'function', + hasOnMessage: typeof targetPlugin.plugin_onmessage === 'function', + hasOnEvent: typeof targetPlugin.plugin_onevent === 'function', + hasCleanup: typeof targetPlugin.plugin_cleanup === 'function', + hasConfigSchema: Array.isArray(targetPlugin.plugin_config_schema), + hasConfigUI: Array.isArray(targetPlugin.plugin_config_ui), + } + }); + }); + // 注册扩展页面 ctx.router.page({ path: 'dashboard', @@ -116,7 +169,10 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => { description: '查看内置插件的运行状态和配置' }); - logger.info('WebUI 路由已注册: /api/Plugin/ext/' + ctx.pluginName); + logger.info('WebUI 路由已注册:'); + logger.info(' - API 路由: /api/Plugin/ext/' + ctx.pluginName + '/'); + logger.info(' - 静态资源: /plugin/' + ctx.pluginName + '/files/static/'); + logger.info(' - 内存资源: /plugin/' + ctx.pluginName + '/mem/dynamic/'); }; export const plugin_get_config: PluginModule['plugin_get_config'] = async () => { diff --git a/packages/napcat-webui-backend/index.ts b/packages/napcat-webui-backend/index.ts index ea35561a..3cb7cb17 100644 --- a/packages/napcat-webui-backend/index.ts +++ b/packages/napcat-webui-backend/index.ts @@ -27,6 +27,8 @@ import compression from 'compression'; import { napCatVersion } from 'napcat-common/src/version'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; +import { NapCatOneBot11Adapter } from '@/napcat-onebot/index'; +import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -294,6 +296,72 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra app.use('/webui', express.static(pathWrapper.staticPath, { maxAge: '1d', })); + + // 插件内存静态资源路由(不需要鉴权) + // 路径格式: /plugin/:pluginId/mem/:urlPath/* + app.use('/plugin/:pluginId/mem', async (req, res) => { + const { pluginId } = req.params; + if (!pluginId) return res.status(400).json({ code: -1, message: 'Plugin ID is required' }); + + const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter | null; + if (!ob11) return res.status(503).json({ code: -1, message: 'OneBot context not available' }); + + const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter | undefined; + if (!pluginManager) return res.status(503).json({ code: -1, message: 'Plugin manager not available' }); + + const routerRegistry = pluginManager.getPluginRouter(pluginId); + const memoryRoutes = routerRegistry?.getMemoryStaticRoutes() || []; + + for (const { urlPath, files } of memoryRoutes) { + const prefix = urlPath.startsWith('/') ? urlPath : '/' + urlPath; + if (req.path.startsWith(prefix)) { + const filePath = '/' + (req.path.substring(prefix.length).replace(/^\//, '') || ''); + const memFile = files.find(f => ('/' + f.path.replace(/^\//, '')) === filePath); + if (memFile) { + try { + const content = typeof memFile.content === 'function' ? await memFile.content() : memFile.content; + res.setHeader('Content-Type', memFile.contentType || 'application/octet-stream'); + return res.send(content); + } catch (err) { + console.error(`[Plugin: ${pluginId}] Error serving memory file:`, err); + return res.status(500).json({ code: -1, message: 'Error serving memory file' }); + } + } + } + } + res.status(404).json({ code: -1, message: 'Memory file not found' }); + }); + + // 插件文件系统静态资源路由(不需要鉴权) + // 路径格式: /plugin/:pluginId/files/* + app.use('/plugin/:pluginId/files', (req, res, next) => { + const { pluginId } = req.params; + if (!pluginId) return res.status(400).json({ code: -1, message: 'Plugin ID is required' }); + + const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter | null; + if (!ob11) return res.status(503).json({ code: -1, message: 'OneBot context not available' }); + + const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter | undefined; + if (!pluginManager) return res.status(503).json({ code: -1, message: 'Plugin manager not available' }); + + const routerRegistry = pluginManager.getPluginRouter(pluginId); + const staticRoutes = routerRegistry?.getStaticRoutes() || []; + + for (const { urlPath, localPath } of staticRoutes) { + const prefix = urlPath.startsWith('/') ? urlPath : '/' + urlPath; + if (req.path.startsWith(prefix) || req.path === prefix.slice(0, -1)) { + const staticMiddleware = express.static(localPath, { maxAge: '1d' }); + const originalUrl = req.url; + req.url = '/' + (req.path.substring(prefix.length).replace(/^\//, '') || ''); + return staticMiddleware(req, res, (err) => { + req.url = originalUrl; + err ? next(err) : next(); + }); + } + } + res.status(404).json({ code: -1, message: 'Static resource not found' }); + }); + // 初始化WebSocket服务器 const sslCerts = await checkCertificates(logger); const isHttps = !!sslCerts; diff --git a/packages/napcat-webui-frontend/src/pages/dashboard/extension.tsx b/packages/napcat-webui-frontend/src/pages/dashboard/extension.tsx index 5e72eb18..9cbdf414 100644 --- a/packages/napcat-webui-frontend/src/pages/dashboard/extension.tsx +++ b/packages/napcat-webui-frontend/src/pages/dashboard/extension.tsx @@ -86,6 +86,14 @@ export default function ExtensionPage () { setIframeLoading(false); }; + // 在新窗口打开页面 + const openInNewWindow = (pluginId: string, path: string) => { + const cleanPath = path.replace(/^\//, ''); + const token = localStorage.getItem('token') || ''; + const url = `/api/Plugin/page/${pluginId}/${cleanPath}?webui_token=${token}`; + window.open(url, '_blank'); + }; + return ( <> 扩展页面 - NapCat WebUI @@ -125,7 +133,16 @@ export default function ExtensionPage () { title={
{tab.icon && {tab.icon}} - {tab.title} + { + e.stopPropagation(); + openInNewWindow(tab.pluginId, tab.path); + }} + > + {tab.title} + ({tab.pluginName})
}