Support memory static files and plugin APIs

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.
This commit is contained in:
手瓜一十雪 2026-02-02 15:01:26 +08:00
parent 01a6594707
commit 94f07ab98b
7 changed files with 230 additions and 11 deletions

View File

@ -28,6 +28,7 @@ export { PluginModule } from './plugin/types';
export { PluginStatusConfig } from './plugin/types'; export { PluginStatusConfig } from './plugin/types';
export { PluginRouterRegistry, PluginRequestHandler, PluginApiRouteDefinition, PluginPageDefinition, HttpMethod } from './plugin/types'; export { PluginRouterRegistry, PluginRequestHandler, PluginApiRouteDefinition, PluginPageDefinition, HttpMethod } from './plugin/types';
export { PluginHttpRequest, PluginHttpResponse, PluginNextFunction } from './plugin/types'; export { PluginHttpRequest, PluginHttpResponse, PluginNextFunction } from './plugin/types';
export { MemoryStaticFile, MemoryFileGenerator } from './plugin/types';
export { PluginRouterRegistryImpl } from './plugin/router-registry'; export { PluginRouterRegistryImpl } from './plugin/router-registry';
export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> implements IPluginManager { export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> implements IPluginManager {
private readonly pluginPath: string; private readonly pluginPath: string;
@ -214,6 +215,15 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
// 保存到路由注册表 // 保存到路由注册表
this.pluginRouters.set(entry.id, routerRegistry); this.pluginRouters.set(entry.id, routerRegistry);
// 创建获取其他插件导出的方法
const getPluginExports = <T = any>(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 { return {
core: this.core, core: this.core,
oneBot: this.obContext, oneBot: this.obContext,
@ -227,6 +237,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
pluginManager: this, pluginManager: this,
logger: pluginLogger, logger: pluginLogger,
router: routerRegistry, router: routerRegistry,
getPluginExports,
}; };
} }

View File

@ -194,6 +194,15 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
this.pluginRouters.set(entry.id, router); this.pluginRouters.set(entry.id, router);
} }
// 创建获取其他插件导出的方法
const getPluginExports = <T = any>(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 { return {
core: this.core, core: this.core,
oneBot: this.obContext, oneBot: this.obContext,
@ -207,6 +216,7 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
pluginManager: this, pluginManager: this,
logger: pluginLogger, logger: pluginLogger,
router, router,
getPluginExports,
}; };
} }

View File

@ -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 path from 'path';
import { import {
PluginRouterRegistry, PluginRouterRegistry,
@ -8,6 +8,7 @@ import {
PluginHttpRequest, PluginHttpRequest,
PluginHttpResponse, PluginHttpResponse,
HttpMethod, HttpMethod,
MemoryStaticFile,
} from './types'; } from './types';
/** /**
@ -59,10 +60,17 @@ function wrapResponse (res: Response): PluginHttpResponse {
* *
* *
*/ */
/** 内存静态路由定义 */
interface MemoryStaticRoute {
urlPath: string;
files: MemoryStaticFile[];
}
export class PluginRouterRegistryImpl implements PluginRouterRegistry { export class PluginRouterRegistryImpl implements PluginRouterRegistry {
private apiRoutes: PluginApiRouteDefinition[] = []; private apiRoutes: PluginApiRouteDefinition[] = [];
private pageDefinitions: PluginPageDefinition[] = []; private pageDefinitions: PluginPageDefinition[] = [];
private staticRoutes: Array<{ urlPath: string; localPath: string; }> = []; private staticRoutes: Array<{ urlPath: string; localPath: string; }> = [];
private memoryStaticRoutes: MemoryStaticRoute[] = [];
constructor ( constructor (
private readonly pluginId: string, private readonly pluginId: string,
@ -111,19 +119,19 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
this.staticRoutes.push({ urlPath, localPath: absolutePath }); this.staticRoutes.push({ urlPath, localPath: absolutePath });
} }
staticOnMem (urlPath: string, files: MemoryStaticFile[]): void {
this.memoryStaticRoutes.push({ urlPath, files });
}
// ==================== 构建路由 ==================== // ==================== 构建路由 ====================
/** /**
* Express Router API * Express Router API
* webui-backend
*/ */
buildApiRouter (): Router { buildApiRouter (): Router {
const router = Router(); const router = Router();
// 注册静态文件路由
for (const { urlPath, localPath } of this.staticRoutes) {
router.use(urlPath, expressStatic(localPath));
}
// 注册 API 路由 // 注册 API 路由
for (const route of this.apiRoutes) { for (const route of this.apiRoutes) {
const handler = this.wrapHandler(route.handler); const handler = this.wrapHandler(route.handler);
@ -179,7 +187,14 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
* API * API
*/ */
hasApiRoutes (): boolean { 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; 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.apiRoutes = [];
this.pageDefinitions = []; this.pageDefinitions = [];
this.staticRoutes = []; this.staticRoutes = [];
this.memoryStaticRoutes = [];
} }
} }

View File

@ -125,6 +125,19 @@ export interface PluginPageDefinition {
description?: string; description?: string;
} }
/** 内存文件生成器 - 用于动态生成静态文件内容 */
export type MemoryFileGenerator = () => string | Buffer | Promise<string | Buffer>;
/** 内存静态文件定义 */
export interface MemoryStaticFile {
/** 文件路径(相对于 urlPath */
path: string;
/** 文件内容或生成器 */
content: string | Buffer | MemoryFileGenerator;
/** 可选的 MIME 类型 */
contentType?: string;
}
/** 插件路由注册器 */ /** 插件路由注册器 */
export interface PluginRouterRegistry { export interface PluginRouterRegistry {
// ==================== API 路由注册 ==================== // ==================== API 路由注册 ====================
@ -167,6 +180,13 @@ export interface PluginRouterRegistry {
* @param localPath * @param localPath
*/ */
static (urlPath: string, localPath: string): void; 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 * WebUI
* HTTP API /api/Plugin/ext/{pluginId}/ * HTTP API /api/Plugin/ext/{pluginId}/
* /plugin/{pluginId}/files/{urlPath}/
*/ */
router: PluginRouterRegistry; router: PluginRouterRegistry;
/**
*
* @param pluginId ID
* @returns undefined
*/
getPluginExports: <T = PluginModule>(pluginId: string) => T | undefined;
} }
// ==================== 插件模块接口 ==================== // ==================== 插件模块接口 ====================

View File

@ -65,10 +65,32 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
// ==================== 注册 WebUI 路由示例 ==================== // ==================== 注册 WebUI 路由示例 ====================
// 注册静态资源目录webui 目录下的文件可通过 /api/Plugin/ext/{pluginId}/static/ 访问) // 注册静态资源目录
// 静态资源可通过 /plugin/{pluginId}/files/static/ 访问(无需鉴权)
ctx.router.static('/static', 'webui'); 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) => { ctx.router.get('/status', (_req, res) => {
const uptime = Date.now() - startTime; const uptime = Date.now() - startTime;
res.json({ 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<PluginModule>(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({ ctx.router.page({
path: 'dashboard', path: 'dashboard',
@ -116,7 +169,10 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
description: '查看内置插件的运行状态和配置' 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 () => { export const plugin_get_config: PluginModule['plugin_get_config'] = async () => {

View File

@ -27,6 +27,8 @@ import compression from 'compression';
import { napCatVersion } from 'napcat-common/src/version'; import { napCatVersion } from 'napcat-common/src/version';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path'; 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 __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@ -294,6 +296,72 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
app.use('/webui', express.static(pathWrapper.staticPath, { app.use('/webui', express.static(pathWrapper.staticPath, {
maxAge: '1d', 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服务器 // 初始化WebSocket服务器
const sslCerts = await checkCertificates(logger); const sslCerts = await checkCertificates(logger);
const isHttps = !!sslCerts; const isHttps = !!sslCerts;

View File

@ -86,6 +86,14 @@ export default function ExtensionPage () {
setIframeLoading(false); 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 ( return (
<> <>
<title> - NapCat WebUI</title> <title> - NapCat WebUI</title>
@ -125,7 +133,16 @@ export default function ExtensionPage () {
title={ title={
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
{tab.icon && <span>{tab.icon}</span>} {tab.icon && <span>{tab.icon}</span>}
<span>{tab.title}</span> <span
className='cursor-pointer hover:underline'
title='点击在新窗口打开'
onClick={(e) => {
e.stopPropagation();
openInNewWindow(tab.pluginId, tab.path);
}}
>
{tab.title}
</span>
<span className='text-xs text-default-400'>({tab.pluginName})</span> <span className='text-xs text-default-400'>({tab.pluginName})</span>
</div> </div>
} }