NapCatQQ source snapshot
This commit is contained in:
commit
f954252a34
1111 files changed
+139867
No files matched your search
@@ -0,0 +1,37 @@
|
||||
import { NetworkAdapterConfig } from '@/napcat-onebot/config/config';
|
||||
import { LogWrapper } from 'napcat-core/helper/log';
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
|
||||
import { ActionMap } from '@/napcat-onebot/action';
|
||||
import { OB11EmitEventContent, OB11NetworkReloadType } from '@/napcat-onebot/network/index';
|
||||
|
||||
export abstract class IOB11NetworkAdapter<CT extends NetworkAdapterConfig> {
|
||||
name: string;
|
||||
isEnable: boolean = false;
|
||||
config: CT;
|
||||
readonly logger: LogWrapper;
|
||||
readonly core: NapCatCore;
|
||||
readonly obContext: NapCatOneBot11Adapter;
|
||||
readonly actions: ActionMap;
|
||||
|
||||
constructor (name: string, config: CT, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap) {
|
||||
this.name = name;
|
||||
this.config = structuredClone(config);
|
||||
this.core = core;
|
||||
this.obContext = obContext;
|
||||
this.actions = actions;
|
||||
this.logger = core.context.logger;
|
||||
}
|
||||
|
||||
abstract onEvent<T extends OB11EmitEventContent> (event: T): Promise<void>;
|
||||
|
||||
abstract open (): void | Promise<void>;
|
||||
|
||||
abstract close (): void | Promise<void>;
|
||||
|
||||
abstract reload (config: unknown): OB11NetworkReloadType | Promise<OB11NetworkReloadType>;
|
||||
|
||||
get isActive (): boolean {
|
||||
return this.isEnable;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { OB11EmitEventContent, OB11NetworkReloadType } from '@/napcat-onebot/network/index';
|
||||
import { createHmac } from 'crypto';
|
||||
import { QuickAction, QuickActionEvent } from '@/napcat-onebot/types';
|
||||
import { RequestUtil } from 'napcat-common/src/request';
|
||||
import { HttpClientConfig } from '@/napcat-onebot/config/config';
|
||||
import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
|
||||
import json5 from 'json5';
|
||||
|
||||
export class OB11HttpClientAdapter extends IOB11NetworkAdapter<HttpClientConfig> {
|
||||
async onEvent<T extends OB11EmitEventContent> (event: T) {
|
||||
this.emitEventAsync(event).catch(e => this.logger.logError('[OneBot] [Http Client] 新消息事件HTTP上报返回快速操作失败', e));
|
||||
}
|
||||
|
||||
async emitEventAsync<T extends OB11EmitEventContent> (event: T) {
|
||||
if (!this.isEnable) return;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-self-id': this.core.selfInfo.uin,
|
||||
};
|
||||
|
||||
const msgStr = JSON.stringify(event);
|
||||
if (this.config.token) {
|
||||
const hmac = createHmac('sha1', this.config.token);
|
||||
hmac.update(msgStr);
|
||||
headers['x-signature'] = 'sha1=' + hmac.digest('hex');
|
||||
}
|
||||
|
||||
const data = await RequestUtil.HttpGetText(this.config.url, 'POST', msgStr, headers);
|
||||
const resJson: QuickAction = data ? json5.parse(data) : {};
|
||||
|
||||
await this.obContext.apis.QuickActionApi.handleQuickOperation(event as QuickActionEvent, resJson);
|
||||
}
|
||||
|
||||
open () {
|
||||
this.isEnable = true;
|
||||
}
|
||||
|
||||
close () {
|
||||
this.isEnable = false;
|
||||
}
|
||||
|
||||
async reload (newConfig: HttpClientConfig) {
|
||||
const wasEnabled = this.isEnable;
|
||||
const oldUrl = this.config.url;
|
||||
this.config = newConfig;
|
||||
|
||||
if (newConfig.enable && !wasEnabled) {
|
||||
this.open();
|
||||
return OB11NetworkReloadType.NetWorkOpen;
|
||||
} else if (!newConfig.enable && wasEnabled) {
|
||||
this.close();
|
||||
return OB11NetworkReloadType.NetWorkClose;
|
||||
}
|
||||
|
||||
if (oldUrl !== newConfig.url) {
|
||||
return OB11NetworkReloadType.NetWorkReload;
|
||||
}
|
||||
|
||||
return OB11NetworkReloadType.Normal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { OB11EmitEventContent } from './index';
|
||||
import { Request, Response } from 'express';
|
||||
import { OB11HttpServerAdapter } from './http-server';
|
||||
|
||||
export class OB11HttpSSEServerAdapter extends OB11HttpServerAdapter {
|
||||
private sseClients: Response[] = [];
|
||||
|
||||
override get isActive (): boolean {
|
||||
return this.isEnable && (this.sseClients.length > 0 || super.isActive);
|
||||
}
|
||||
|
||||
override async handleRequest (req: Request, res: Response) {
|
||||
if (req.path === '/_events') {
|
||||
this.createSseSupport(req, res);
|
||||
} else {
|
||||
super.httpApiRequest(req, res, true);
|
||||
}
|
||||
}
|
||||
|
||||
private async createSseSupport (req: Request, res: Response) {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
this.sseClients.push(res);
|
||||
req.on('close', () => {
|
||||
this.sseClients = this.sseClients.filter((client) => client !== res);
|
||||
});
|
||||
}
|
||||
|
||||
override async onEvent<T extends OB11EmitEventContent> (event: T) {
|
||||
super.onEvent(event);
|
||||
const promises: Promise<void>[] = [];
|
||||
this.sseClients.forEach((res) => {
|
||||
promises.push(new Promise<void>((resolve, reject) => {
|
||||
res.write(`data: ${JSON.stringify(event)}\n\n`, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}));
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
|
||||
import express, { Express, NextFunction, Request, Response } from 'express';
|
||||
import http, { IncomingMessage } from 'http';
|
||||
import { OB11Response } from '@/napcat-onebot/action/OneBotAction';
|
||||
import cors from 'cors';
|
||||
import { HttpServerConfig } from '@/napcat-onebot/config/config';
|
||||
import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
|
||||
import json5 from 'json5';
|
||||
import { isFinished } from 'on-finished';
|
||||
import typeis from 'type-is';
|
||||
import { WebSocket, WebSocketServer, RawData } from 'ws';
|
||||
import { URL } from 'url';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { OB11HeartbeatEvent } from '@/napcat-onebot/event/meta/OB11HeartbeatEvent';
|
||||
import { OB11LifeCycleEvent, LifeCycleSubType } from '@/napcat-onebot/event/meta/OB11LifeCycleEvent';
|
||||
import { QzonePublicAction } from '@/napcat-onebot/action/extends/qzone-public/common';
|
||||
import { decodeQzonePublicHttpParameters } from '@/napcat-onebot/action/extends/qzone-public/http-params';
|
||||
|
||||
export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig> {
|
||||
private app: Express | undefined;
|
||||
private server: http.Server | undefined;
|
||||
private wsServer?: WebSocketServer;
|
||||
private wsClients: WebSocket[] = [];
|
||||
private heartbeatIntervalId: NodeJS.Timeout | null = null;
|
||||
private wsClientWithEvent: WebSocket[] = [];
|
||||
|
||||
override get isActive (): boolean {
|
||||
return this.isEnable && this.wsClientWithEvent.length > 0;
|
||||
}
|
||||
|
||||
override async onEvent<T extends OB11EmitEventContent> (event: T) {
|
||||
// http server is passive, no need to emit event
|
||||
const promises = this.wsClientWithEvent.map((wsClient) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(event));
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('WebSocket is not open'));
|
||||
}
|
||||
});
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
open () {
|
||||
try {
|
||||
if (this.isEnable) {
|
||||
this.core.context.logger.logError('Cannot open a closed HTTP server');
|
||||
return;
|
||||
}
|
||||
if (!this.isEnable) {
|
||||
this.initializeServer();
|
||||
this.isEnable = true;
|
||||
}
|
||||
} catch (e) {
|
||||
this.core.context.logger.logError(`[OneBot] [HTTP Server Adapter] Boot Error: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async close () {
|
||||
this.isEnable = false;
|
||||
this.server?.close();
|
||||
this.app = undefined;
|
||||
this.stopHeartbeat();
|
||||
this.wsClients.forEach((wsClient) => wsClient.close());
|
||||
this.wsClients = [];
|
||||
this.wsClientWithEvent = [];
|
||||
this.wsServer?.close();
|
||||
}
|
||||
|
||||
private initializeServer () {
|
||||
this.app = express();
|
||||
this.server = http.createServer(this.app);
|
||||
if (this.config.enableWebsocket) {
|
||||
this.wsServer = new WebSocketServer({ server: this.server });
|
||||
this.createWSServer(this.wsServer);
|
||||
}
|
||||
|
||||
this.app.use(cors());
|
||||
this.app.use(express.urlencoded({ extended: true, limit: '5000mb' }));
|
||||
|
||||
this.app.use((req, res, next) => {
|
||||
if (isFinished(req)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (!typeis.hasBody(req)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
// 兼容处理没有带content-type的请求
|
||||
req.headers['content-type'] = 'application/json';
|
||||
let rawData = '';
|
||||
req.on('data', (chunk) => {
|
||||
rawData += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
try {
|
||||
req.body = { ...json5.parse(rawData || '{}'), ...req.body };
|
||||
next();
|
||||
} catch {
|
||||
res.status(400).send('Invalid JSON');
|
||||
}
|
||||
});
|
||||
req.on('error', () => {
|
||||
return res.status(400).send('Invalid JSON');
|
||||
});
|
||||
});
|
||||
this.app.use((req, res, next) => this.authorize(this.config.token, req, res, next));
|
||||
this.app.use(async (req, res) => {
|
||||
await this.handleRequest(req, res);
|
||||
});
|
||||
this.server.listen(this.config.port, this.config.host, () => {
|
||||
this.core.context.logger.log(`[OneBot] [HTTP Server Adapter] Start On ${this.config.host}:${this.config.port}`);
|
||||
});
|
||||
}
|
||||
|
||||
private authorize (token: string | undefined, req: Request, res: Response, next: NextFunction) {
|
||||
if (!token || token.length === 0) return next();// 客户端未设置密钥
|
||||
const HeaderClientToken = req.headers.authorization?.split('Bearer ').pop() || '';
|
||||
const QueryClientToken = req.query['access_token'];
|
||||
const ClientToken = typeof (QueryClientToken) === 'string' && QueryClientToken !== '' ? QueryClientToken : HeaderClientToken;
|
||||
if (ClientToken === token) {
|
||||
return next();
|
||||
} else {
|
||||
return res.status(403).send(JSON.stringify({ message: 'token verify failed!' }));
|
||||
}
|
||||
}
|
||||
|
||||
createWSServer (newServer: WebSocketServer) {
|
||||
newServer.on('connection', async (wsClient, wsReq) => {
|
||||
if (!this.isEnable) {
|
||||
wsClient.close();
|
||||
return;
|
||||
}
|
||||
if (!this.authorizeWS(this.config.token, wsClient, wsReq)) {
|
||||
return;
|
||||
}
|
||||
const paramUrl = wsReq.url?.indexOf('?') !== -1 ? wsReq.url?.substring(0, wsReq.url?.indexOf('?')) : wsReq.url;
|
||||
const isApiConnect = paramUrl === '/api' || paramUrl === '/api/';
|
||||
if (!isApiConnect) {
|
||||
this.connectEvent(this.core, wsClient);
|
||||
}
|
||||
|
||||
wsClient.on('error', (err) => this.logger.log('[OneBot] [HTTP WebSocket] Client Error:', err.message));
|
||||
wsClient.on('message', (message) => {
|
||||
this.handleWSMessage(wsClient, message).then().catch(e => this.logger.logError(e));
|
||||
});
|
||||
wsClient.on('pong', () => {
|
||||
// this.logger.logDebug('[OneBot] [HTTP WebSocket] Pong received');
|
||||
});
|
||||
wsClient.once('close', () => {
|
||||
const NormolIndex = this.wsClients.indexOf(wsClient);
|
||||
if (NormolIndex !== -1) {
|
||||
this.wsClients.splice(NormolIndex, 1);
|
||||
}
|
||||
const EventIndex = this.wsClientWithEvent.indexOf(wsClient);
|
||||
if (EventIndex !== -1) {
|
||||
this.wsClientWithEvent.splice(EventIndex, 1);
|
||||
}
|
||||
if (this.wsClientWithEvent.length === 0) {
|
||||
this.stopHeartbeat();
|
||||
}
|
||||
});
|
||||
if (!isApiConnect) {
|
||||
this.wsClientWithEvent.push(wsClient);
|
||||
}
|
||||
this.wsClients.push(wsClient);
|
||||
if (this.wsClientWithEvent.length > 0) {
|
||||
this.startHeartbeat();
|
||||
}
|
||||
}).on('error', (err) => this.logger.log('[OneBot] [HTTP WebSocket] Server Error:', err.message));
|
||||
}
|
||||
|
||||
connectEvent (core: any, wsClient: WebSocket) {
|
||||
try {
|
||||
this.checkStateAndReply<unknown>(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT), wsClient).catch(e => this.logger.logError('[OneBot] [HTTP WebSocket] 发送生命周期失败', e));
|
||||
} catch (e) {
|
||||
this.logger.logError('[OneBot] [HTTP WebSocket] 发送生命周期失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
private startHeartbeat () {
|
||||
if (this.heartbeatIntervalId) return;
|
||||
this.heartbeatIntervalId = setInterval(() => {
|
||||
this.wsClientWithEvent.forEach((wsClient) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(new OB11HeartbeatEvent(this.core, 30000, this.core.selfInfo.online ?? true, true)));
|
||||
}
|
||||
});
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
private stopHeartbeat () {
|
||||
if (this.heartbeatIntervalId) {
|
||||
clearInterval(this.heartbeatIntervalId);
|
||||
this.heartbeatIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private authorizeWS (token: string | undefined, wsClient: WebSocket, wsReq: IncomingMessage) {
|
||||
if (!token || token.length === 0) return true;
|
||||
const url = new URL(wsReq?.url || '', `http://${wsReq.headers.host}`);
|
||||
const QueryClientToken = url.searchParams.get('access_token');
|
||||
const HeaderClientToken = wsReq.headers.authorization?.split('Bearer ').pop() || '';
|
||||
const ClientToken = typeof (QueryClientToken) === 'string' && QueryClientToken !== '' ? QueryClientToken : HeaderClientToken;
|
||||
if (ClientToken === token) {
|
||||
return true;
|
||||
}
|
||||
wsClient.send(JSON.stringify(OB11Response.res(null, 'failed', 1403, 'token验证失败')));
|
||||
wsClient.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
private async checkStateAndReply<T> (data: T, wsClient: WebSocket) {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(data));
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('WebSocket is not open'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async handleWSMessage (wsClient: WebSocket, message: RawData) {
|
||||
let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any; } = { action: ActionName.Unknown, params: {} };
|
||||
let echo;
|
||||
try {
|
||||
receiveData = json5.parse(message.toString());
|
||||
echo = receiveData.echo;
|
||||
} catch {
|
||||
await this.checkStateAndReply<unknown>(OB11Response.error('json解析失败,请检查数据格式', 1400, echo), wsClient);
|
||||
return;
|
||||
}
|
||||
receiveData.params = (receiveData?.params) ? receiveData.params : {};
|
||||
|
||||
const action = this.actions.get(receiveData.action as any);
|
||||
if (!action) {
|
||||
this.logger.logError('[OneBot] [HTTP WebSocket] 发生错误', '不支持的API ' + receiveData.action);
|
||||
await this.checkStateAndReply<unknown>(OB11Response.error('不支持的API ' + receiveData.action, 1404, echo), wsClient);
|
||||
return;
|
||||
}
|
||||
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config, {
|
||||
send: async (data: object) => {
|
||||
await this.checkStateAndReply<unknown>({ ...OB11Response.ok(data, echo ?? '', true) }, wsClient);
|
||||
},
|
||||
});
|
||||
await this.checkStateAndReply<unknown>({ ...retdata }, wsClient);
|
||||
}
|
||||
|
||||
async httpApiRequest (req: Request, res: Response, request_sse: boolean = false) {
|
||||
let payload = req.body;
|
||||
if (req.method === 'GET') {
|
||||
payload = req.query;
|
||||
} else if (req.query) {
|
||||
payload = { ...req.body, ...req.query };
|
||||
}
|
||||
if (req.path === '' || req.path === '/') {
|
||||
const hello = OB11Response.ok({});
|
||||
hello.message = 'NapCat4 Is Running';
|
||||
return res.json(hello);
|
||||
}
|
||||
const actionName = req.path.split('/')[1];
|
||||
const action = this.actions.get(actionName as any);
|
||||
if (action instanceof QzonePublicAction) {
|
||||
const query = decodeQzonePublicHttpParameters(req.query);
|
||||
const body = req.is('application/x-www-form-urlencoded') ? decodeQzonePublicHttpParameters(req.body) : req.body;
|
||||
payload = req.method === 'GET' ? query : { ...body, ...query };
|
||||
}
|
||||
const payload_echo = payload['echo'];
|
||||
const real_echo = payload_echo ?? Math.random().toString(36).substring(2, 15);
|
||||
|
||||
if (action) {
|
||||
const useStream = action.useStream;
|
||||
try {
|
||||
const result = await action.handle(payload, this.name, this.config, {
|
||||
send: request_sse
|
||||
? async (data: object) => {
|
||||
await this.onEvent({ ...OB11Response.ok(data, real_echo, true) } as unknown as OB11EmitEventContent);
|
||||
}
|
||||
: async (data: object) => {
|
||||
const newPromise = new Promise<void>((resolve, _reject) => {
|
||||
res.write(JSON.stringify({ ...OB11Response.ok(data, real_echo, true) }) + '\r\n\r\n', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
return newPromise;
|
||||
},
|
||||
}, real_echo);
|
||||
if (useStream) {
|
||||
res.write(JSON.stringify({ ...result }) + '\r\n\r\n');
|
||||
return res.end();
|
||||
}
|
||||
return res.json(result);
|
||||
} catch (error: unknown) {
|
||||
return res.json(OB11Response.error((error as Error)?.stack?.toString() || (error as Error)?.message || 'Error Handle', 200, real_echo));
|
||||
}
|
||||
} else {
|
||||
return res.json(OB11Response.error('不支持的Api ' + actionName, 200, real_echo));
|
||||
}
|
||||
}
|
||||
|
||||
async handleRequest (req: Request, res: Response) {
|
||||
if (!this.isEnable) {
|
||||
this.core.context.logger.log('[OneBot] [HTTP Server Adapter] Server is closed');
|
||||
res.json(OB11Response.error('Server is closed', 200));
|
||||
return;
|
||||
}
|
||||
this.httpApiRequest(req, res);
|
||||
}
|
||||
|
||||
async reload (newConfig: HttpServerConfig) {
|
||||
const wasEnabled = this.isEnable;
|
||||
const oldPort = this.config.port;
|
||||
const oldHost = this.config.host;
|
||||
const oldEnableWebsocket = this.config.enableWebsocket;
|
||||
this.config = newConfig;
|
||||
|
||||
if (newConfig.enable && !wasEnabled) {
|
||||
this.open();
|
||||
return OB11NetworkReloadType.NetWorkOpen;
|
||||
} else if (!newConfig.enable && wasEnabled) {
|
||||
this.close();
|
||||
return OB11NetworkReloadType.NetWorkClose;
|
||||
}
|
||||
|
||||
if (oldPort !== newConfig.port || oldHost !== newConfig.host || oldEnableWebsocket !== newConfig.enableWebsocket) {
|
||||
this.close();
|
||||
if (newConfig.enable) {
|
||||
this.open();
|
||||
}
|
||||
return OB11NetworkReloadType.NetWorkReload;
|
||||
}
|
||||
|
||||
return OB11NetworkReloadType.Normal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { OneBotEvent } from '@/napcat-onebot/event/OneBotEvent';
|
||||
import { OB11Message } from '@/napcat-onebot/index';
|
||||
import { NetworkAdapterConfig } from '@/napcat-onebot/config/config';
|
||||
import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
|
||||
|
||||
export type OB11EmitEventContent = OneBotEvent | OB11Message;
|
||||
export enum OB11NetworkReloadType {
|
||||
Normal = 0,
|
||||
ConfigChange = 1,
|
||||
NetWorkReload = 2,
|
||||
NetWorkClose = 3,
|
||||
NetWorkOpen = 4,
|
||||
}
|
||||
|
||||
export class OB11NetworkManager {
|
||||
adapters: Map<string, IOB11NetworkAdapter<NetworkAdapterConfig>> = new Map();
|
||||
|
||||
async openAllAdapters () {
|
||||
return Promise.all(Array.from(this.adapters.values()).map(adapter => adapter.open()));
|
||||
}
|
||||
|
||||
async emitEvent (event: OB11EmitEventContent) {
|
||||
return Promise.all(Array.from(this.adapters.values()).map(async adapter => {
|
||||
if (adapter.isActive) {
|
||||
return await adapter.onEvent(event);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async emitEvents (events: OB11EmitEventContent[]) {
|
||||
return Promise.all(events.map(event => this.emitEvent(event)));
|
||||
}
|
||||
|
||||
async emitEventByName (names: string[], event: OB11EmitEventContent) {
|
||||
return Promise.all(names.map(async name => {
|
||||
const adapter = this.adapters.get(name);
|
||||
if (adapter && adapter.isActive) {
|
||||
return await adapter.onEvent(event);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async emitEventByNames (map: Map<string, OB11EmitEventContent>) {
|
||||
return Promise.all(Array.from(map.entries()).map(async ([name, event]) => {
|
||||
const adapter = this.adapters.get(name);
|
||||
if (adapter && adapter.isActive) {
|
||||
return await adapter.onEvent(event);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
registerAdapter<CT extends NetworkAdapterConfig> (adapter: IOB11NetworkAdapter<CT>) {
|
||||
this.adapters.set(adapter.name, adapter);
|
||||
}
|
||||
|
||||
async registerAdapterAndOpen<CT extends NetworkAdapterConfig> (adapter: IOB11NetworkAdapter<CT>) {
|
||||
this.registerAdapter(adapter);
|
||||
await adapter.open();
|
||||
}
|
||||
|
||||
async closeSomeAdapters<CT extends NetworkAdapterConfig> (adaptersToClose: IOB11NetworkAdapter<CT>[]) {
|
||||
for (const adapter of adaptersToClose) {
|
||||
this.adapters.delete(adapter.name);
|
||||
await adapter.close();
|
||||
}
|
||||
}
|
||||
|
||||
async closeSomeAdaterWhenOpen<CT extends NetworkAdapterConfig> (adaptersToClose: IOB11NetworkAdapter<CT>[]) {
|
||||
for (const adapter of adaptersToClose) {
|
||||
this.adapters.delete(adapter.name);
|
||||
if (adapter.isEnable) {
|
||||
await adapter.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findSomeAdapter (name: string) {
|
||||
return this.adapters.get(name);
|
||||
}
|
||||
|
||||
async closeAdapterByPredicate (closeFilter: (adapter: IOB11NetworkAdapter<NetworkAdapterConfig>) => boolean) {
|
||||
const adaptersToClose = Array.from(this.adapters.values()).filter(closeFilter);
|
||||
await this.closeSomeAdapters(adaptersToClose);
|
||||
}
|
||||
|
||||
async closeAllAdapters () {
|
||||
await Promise.all(Array.from(this.adapters.values()).map(adapter => adapter.close()));
|
||||
this.adapters.clear();
|
||||
}
|
||||
|
||||
async readloadAdapter<T> (name: string, config: T) {
|
||||
const adapter = this.adapters.get(name);
|
||||
if (adapter) {
|
||||
await adapter.reload(config);
|
||||
}
|
||||
}
|
||||
|
||||
async readloadSomeAdapters<T> (configMap: Map<string, T>) {
|
||||
await Promise.all(Array.from(configMap.entries()).map(([name, config]) => this.readloadAdapter(name, config)));
|
||||
}
|
||||
|
||||
hasActiveAdapters (): boolean {
|
||||
return Array.from(this.adapters.values()).some(adapter => adapter.isActive);
|
||||
}
|
||||
|
||||
async getAllConfig () {
|
||||
return Array.from(this.adapters.values()).map(adapter => adapter.config);
|
||||
}
|
||||
}
|
||||
|
||||
export * from './http-client';
|
||||
export * from './websocket-client';
|
||||
export * from './http-server';
|
||||
export * from './websocket-server';
|
||||
@@ -0,0 +1,551 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { ActionMap } from '@/napcat-onebot/action';
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatOneBot11Adapter, OB11Message } from '@/napcat-onebot/index';
|
||||
import { OB11EmitEventContent, OB11NetworkReloadType } from '@/napcat-onebot/network/index';
|
||||
import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
|
||||
import { PluginConfig } from '@/napcat-onebot/config/config';
|
||||
import { NapCatConfig } from './plugin/config';
|
||||
import { PluginLoader } from './plugin/loader';
|
||||
import {
|
||||
PluginEntry,
|
||||
PluginLogger,
|
||||
PluginStatusConfig,
|
||||
NapCatPluginContext,
|
||||
IPluginManager,
|
||||
} from './plugin/types';
|
||||
import { PluginRouterRegistryImpl } from './plugin/router-registry';
|
||||
|
||||
export { PluginPackageJson } from './plugin/types';
|
||||
export { PluginConfigItem } from './plugin/types';
|
||||
export { PluginConfigUIController } from './plugin/types';
|
||||
export { NapCatConfig } from './plugin/config';
|
||||
export { PluginConfigSchema } from './plugin/types';
|
||||
export { PluginLogger } from './plugin/types';
|
||||
export { NapCatPluginContext } from './plugin/types';
|
||||
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<PluginConfig> implements IPluginManager {
|
||||
private readonly pluginPath: string;
|
||||
private readonly configPath: string;
|
||||
private readonly loader: PluginLoader;
|
||||
|
||||
/** 插件注册表: ID -> 插件条目 */
|
||||
private plugins: Map<string, PluginEntry> = new Map();
|
||||
|
||||
/** 插件路由注册表: ID -> 路由注册器 */
|
||||
private pluginRouters: Map<string, PluginRouterRegistryImpl> = new Map();
|
||||
|
||||
declare config: PluginConfig;
|
||||
public NapCatConfig = NapCatConfig;
|
||||
|
||||
override get isActive (): boolean {
|
||||
return this.isEnable && this.getLoadedPlugins().length > 0;
|
||||
}
|
||||
|
||||
constructor (
|
||||
name: string,
|
||||
core: NapCatCore,
|
||||
obContext: NapCatOneBot11Adapter,
|
||||
actions: ActionMap
|
||||
) {
|
||||
const config = {
|
||||
name,
|
||||
messagePostFormat: 'array',
|
||||
reportSelfMessage: true,
|
||||
enable: true,
|
||||
debug: true,
|
||||
};
|
||||
super(name, config, core, obContext, actions);
|
||||
this.pluginPath = this.core.context.pathWrapper.pluginPath;
|
||||
this.configPath = path.join(this.core.context.pathWrapper.configPath, 'plugins.json');
|
||||
this.loader = new PluginLoader(this.pluginPath, this.configPath, this.logger);
|
||||
}
|
||||
|
||||
// ==================== 插件状态配置 ====================
|
||||
|
||||
public getPluginConfig (): PluginStatusConfig {
|
||||
return this.loader.loadPluginStatusConfig();
|
||||
}
|
||||
|
||||
private savePluginConfig (config: PluginStatusConfig): void {
|
||||
this.loader.savePluginStatusConfig(config);
|
||||
}
|
||||
|
||||
// ==================== 插件扫描与加载 ====================
|
||||
|
||||
/**
|
||||
* 扫描并加载所有插件
|
||||
*/
|
||||
private async scanAndLoadPlugins (): Promise<void> {
|
||||
// 扫描所有插件目录
|
||||
const entries = await this.loader.scanPlugins();
|
||||
|
||||
// 清空现有注册表
|
||||
this.plugins.clear();
|
||||
|
||||
// 注册所有插件条目
|
||||
for (const entry of entries) {
|
||||
this.plugins.set(entry.id, entry);
|
||||
}
|
||||
|
||||
this.logger.log(`[PluginManager] Scanned ${this.plugins.size} plugins`);
|
||||
|
||||
// 加载启用的插件
|
||||
for (const entry of this.plugins.values()) {
|
||||
if (entry.enable && entry.runtime.status !== 'error') {
|
||||
await this.loadPlugin(entry);
|
||||
}
|
||||
}
|
||||
|
||||
const loadedCount = this.getLoadedPlugins().length;
|
||||
this.logger.log(`[PluginManager] Loaded ${loadedCount} plugins`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载单个插件
|
||||
*/
|
||||
private async loadPlugin (entry: PluginEntry): Promise<boolean> {
|
||||
if (entry.loaded) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entry.runtime.status === 'error') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 加载模块
|
||||
const module = await this.loader.loadPluginModule(entry);
|
||||
if (!module) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建上下文
|
||||
const context = this.createPluginContext(entry);
|
||||
|
||||
// 初始化插件
|
||||
try {
|
||||
await module.plugin_init(context);
|
||||
|
||||
entry.loaded = true;
|
||||
entry.runtime = {
|
||||
status: 'loaded',
|
||||
module,
|
||||
context,
|
||||
};
|
||||
|
||||
this.logger.log(`[PluginManager] Initialized plugin: ${entry.id}${entry.version ? ` v${entry.version}` : ''}`);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
entry.loaded = false;
|
||||
entry.runtime = {
|
||||
status: 'error',
|
||||
error: error.message || 'Initialization failed',
|
||||
};
|
||||
|
||||
this.logger.logError(`[PluginManager] Error initializing plugin ${entry.id}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载单个插件
|
||||
*/
|
||||
private async unloadPlugin (entry: PluginEntry): Promise<void> {
|
||||
if (!entry.loaded || entry.runtime.status !== 'loaded') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { module, context } = entry.runtime;
|
||||
|
||||
// 调用清理方法
|
||||
if (module && context && typeof module.plugin_cleanup === 'function') {
|
||||
try {
|
||||
await module.plugin_cleanup(context);
|
||||
this.logger.log(`[PluginManager] Cleaned up plugin: ${entry.id}`);
|
||||
} catch (error) {
|
||||
this.logger.logError(`[PluginManager] Error cleaning up plugin ${entry.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理插件路由
|
||||
const routerRegistry = this.pluginRouters.get(entry.id);
|
||||
if (routerRegistry) {
|
||||
routerRegistry.clear();
|
||||
this.pluginRouters.delete(entry.id);
|
||||
}
|
||||
|
||||
// 清理模块缓存
|
||||
this.loader.clearCache(entry.pluginPath);
|
||||
|
||||
// 重置状态
|
||||
entry.loaded = false;
|
||||
entry.runtime = {
|
||||
status: 'unloaded',
|
||||
};
|
||||
|
||||
this.logger.log(`[PluginManager] Unloaded plugin: ${entry.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建插件上下文
|
||||
*/
|
||||
private createPluginContext (entry: PluginEntry): NapCatPluginContext {
|
||||
const dataPath = path.join(this.core.context.pathWrapper.configPath, 'plugins', entry.id);
|
||||
const configPath = path.join(dataPath, 'config.json');
|
||||
|
||||
// 确保插件配置目录存在
|
||||
if (!fs.existsSync(dataPath)) {
|
||||
fs.mkdirSync(dataPath, { recursive: true });
|
||||
}
|
||||
|
||||
// 创建插件专用日志器
|
||||
const pluginPrefix = `[Plugin: ${entry.id}]`;
|
||||
const coreLogger = this.logger;
|
||||
const pluginLogger: PluginLogger = {
|
||||
log: (...args: any[]) => coreLogger.log(pluginPrefix, ...args),
|
||||
debug: (...args: any[]) => coreLogger.logDebug(pluginPrefix, ...args),
|
||||
info: (...args: any[]) => coreLogger.log(pluginPrefix, ...args),
|
||||
warn: (...args: any[]) => coreLogger.logWarn(pluginPrefix, ...args),
|
||||
error: (...args: any[]) => coreLogger.logError(pluginPrefix, ...args),
|
||||
};
|
||||
|
||||
// 创建插件路由注册器
|
||||
const routerRegistry = new PluginRouterRegistryImpl(entry.id, entry.pluginPath);
|
||||
// 保存到路由注册表
|
||||
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 {
|
||||
core: this.core,
|
||||
oneBot: this.obContext,
|
||||
actions: this.actions,
|
||||
pluginName: entry.id,
|
||||
pluginPath: entry.pluginPath,
|
||||
dataPath,
|
||||
configPath,
|
||||
NapCatConfig,
|
||||
adapterName: this.name,
|
||||
pluginManager: this,
|
||||
logger: pluginLogger,
|
||||
router: routerRegistry,
|
||||
getPluginExports,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 公共 API ====================
|
||||
|
||||
/**
|
||||
* 获取插件目录路径
|
||||
*/
|
||||
public getPluginPath (): string {
|
||||
return this.pluginPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有插件条目
|
||||
*/
|
||||
public getAllPlugins (): PluginEntry[] {
|
||||
return Array.from(this.plugins.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已加载的插件列表
|
||||
*/
|
||||
public getLoadedPlugins (): PluginEntry[] {
|
||||
return Array.from(this.plugins.values()).filter(p => p.loaded);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 ID 获取插件信息
|
||||
*/
|
||||
public getPluginInfo (pluginId: string): PluginEntry | undefined {
|
||||
return this.plugins.get(pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件路由注册器
|
||||
*/
|
||||
public getPluginRouter (pluginId: string): PluginRouterRegistryImpl | undefined {
|
||||
return this.pluginRouters.get(pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有插件路由注册器
|
||||
*/
|
||||
public getAllPluginRouters (): Map<string, PluginRouterRegistryImpl> {
|
||||
return this.pluginRouters;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置插件状态(启用/禁用)
|
||||
*/
|
||||
public async setPluginStatus (pluginId: string, enable: boolean): Promise<void> {
|
||||
const config = this.getPluginConfig();
|
||||
config[pluginId] = enable;
|
||||
this.savePluginConfig(config);
|
||||
|
||||
const entry = this.plugins.get(pluginId);
|
||||
if (entry) {
|
||||
entry.enable = enable;
|
||||
|
||||
if (enable && !entry.loaded) {
|
||||
// 启用插件
|
||||
await this.loadPlugin(entry);
|
||||
} else if (!enable && entry.loaded) {
|
||||
// 禁用插件
|
||||
await this.unloadPlugin(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 ID 加载插件
|
||||
*/
|
||||
public async loadPluginById (pluginId: string): Promise<boolean> {
|
||||
let entry = this.plugins.get(pluginId);
|
||||
|
||||
if (!entry) {
|
||||
// 尝试查找并扫描
|
||||
const dirname = this.loader.findPluginDirById(pluginId);
|
||||
if (!dirname) {
|
||||
this.logger.logWarn(`[PluginManager] Plugin ${pluginId} not found in filesystem`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const newEntry = this.loader.rescanPlugin(dirname);
|
||||
if (!newEntry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.plugins.set(newEntry.id, newEntry);
|
||||
entry = newEntry;
|
||||
}
|
||||
|
||||
if (!entry.enable) {
|
||||
this.logger.log(`[PluginManager] Skipping loading disabled plugin: ${pluginId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return await this.loadPlugin(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载插件(仅从内存卸载)
|
||||
*/
|
||||
public async unregisterPlugin (pluginId: string): Promise<void> {
|
||||
const entry = this.plugins.get(pluginId);
|
||||
if (entry) {
|
||||
await this.unloadPlugin(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载并删除插件
|
||||
*/
|
||||
public async uninstallPlugin (pluginId: string, cleanData: boolean = false): Promise<void> {
|
||||
const entry = this.plugins.get(pluginId);
|
||||
if (!entry) {
|
||||
throw new Error(`Plugin ${pluginId} not found`);
|
||||
}
|
||||
|
||||
const pluginPath = entry.pluginPath;
|
||||
const dataPath = path.join(this.core.context.pathWrapper.configPath, 'plugins', pluginId);
|
||||
|
||||
if (entry.loaded) {
|
||||
await this.unloadPlugin(entry);
|
||||
}
|
||||
|
||||
// 从注册表移除
|
||||
this.plugins.delete(pluginId);
|
||||
|
||||
// 删除插件目录
|
||||
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 (pluginId: string): Promise<boolean> {
|
||||
const entry = this.plugins.get(pluginId);
|
||||
if (!entry) {
|
||||
this.logger.logWarn(`[PluginManager] Plugin ${pluginId} not found`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 卸载插件
|
||||
await this.unloadPlugin(entry);
|
||||
|
||||
// 重新扫描
|
||||
const newEntry = this.loader.rescanPlugin(entry.fileId);
|
||||
if (!newEntry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 更新注册表
|
||||
this.plugins.set(newEntry.id, newEntry);
|
||||
|
||||
// 重新加载
|
||||
if (newEntry.enable) {
|
||||
await this.loadPlugin(newEntry);
|
||||
}
|
||||
|
||||
this.logger.log(`[PluginManager] Plugin ${pluginId} reloaded successfully`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.logError(`[PluginManager] Error reloading plugin ${pluginId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载目录插件(用于新安装的插件)
|
||||
*/
|
||||
public async loadDirectoryPlugin (dirname: string): Promise<void> {
|
||||
const entry = this.loader.rescanPlugin(dirname);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已存在
|
||||
if (this.plugins.has(entry.id)) {
|
||||
this.logger.logWarn(`[PluginManager] Plugin ${entry.id} already exists`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.plugins.set(entry.id, entry);
|
||||
|
||||
if (entry.enable && entry.runtime.status !== 'error') {
|
||||
await this.loadPlugin(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件数据目录路径
|
||||
*/
|
||||
public getPluginDataPath (pluginId: string): string {
|
||||
return path.join(this.core.context.pathWrapper.configPath, 'plugins', pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件配置文件路径
|
||||
*/
|
||||
public getPluginConfigPath (pluginId: string): string {
|
||||
return path.join(this.getPluginDataPath(pluginId), 'config.json');
|
||||
}
|
||||
|
||||
// ==================== 事件处理 ====================
|
||||
|
||||
async onEvent<T extends OB11EmitEventContent> (event: T): Promise<void> {
|
||||
if (!this.isEnable) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
this.getLoadedPlugins().map((entry) =>
|
||||
this.callPluginEventHandler(entry, event)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.logError('[PluginManager] Error handling event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用插件的事件处理方法
|
||||
*/
|
||||
private async callPluginEventHandler (
|
||||
entry: PluginEntry,
|
||||
event: OB11EmitEventContent
|
||||
): Promise<void> {
|
||||
if (entry.runtime.status !== 'loaded' || !entry.runtime.module || !entry.runtime.context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { module, context } = entry.runtime;
|
||||
|
||||
try {
|
||||
// 优先使用 plugin_onevent 方法
|
||||
if (typeof module.plugin_onevent === 'function') {
|
||||
await module.plugin_onevent(context, event);
|
||||
}
|
||||
|
||||
// 如果是消息事件并且插件有 plugin_onmessage 方法,也调用
|
||||
if (
|
||||
(event as any).message_type &&
|
||||
typeof module.plugin_onmessage === 'function'
|
||||
) {
|
||||
await module.plugin_onmessage(context, event as OB11Message);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.logError(`[PluginManager] Error calling plugin ${entry.id} event handler:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
async open (): Promise<void> {
|
||||
if (this.isEnable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log('[PluginManager] Opening plugin manager...');
|
||||
this.isEnable = true;
|
||||
|
||||
// 扫描并加载所有插件
|
||||
await this.scanAndLoadPlugins();
|
||||
|
||||
this.logger.log(`[PluginManager] Plugin manager opened with ${this.getLoadedPlugins().length} plugins loaded`);
|
||||
}
|
||||
|
||||
async close (): Promise<void> {
|
||||
if (!this.isEnable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log('[PluginManager] Closing plugin manager...');
|
||||
this.isEnable = false;
|
||||
|
||||
// 卸载所有已加载的插件
|
||||
for (const entry of this.plugins.values()) {
|
||||
if (entry.loaded) {
|
||||
await this.unloadPlugin(entry);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log('[PluginManager] Plugin manager closed');
|
||||
}
|
||||
|
||||
async reload (): Promise<OB11NetworkReloadType> {
|
||||
this.logger.log('[PluginManager] Reloading plugin manager...');
|
||||
|
||||
// 先关闭然后重新打开
|
||||
await this.close();
|
||||
await this.open();
|
||||
|
||||
this.logger.log('[PluginManager] Plugin manager reloaded');
|
||||
return OB11NetworkReloadType.Normal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { PluginConfigItem, PluginConfigSchema } from './types';
|
||||
|
||||
/**
|
||||
* NapCat 插件配置构建器
|
||||
* 提供便捷的配置项创建方法
|
||||
*/
|
||||
export class NapCatConfig {
|
||||
static text (key: string, label: string, defaultValue?: string, description?: string, reactive?: boolean): PluginConfigItem {
|
||||
return { key, type: 'string', label, default: defaultValue, description, reactive };
|
||||
}
|
||||
|
||||
static number (key: string, label: string, defaultValue?: number, description?: string, reactive?: boolean): PluginConfigItem {
|
||||
return { key, type: 'number', label, default: defaultValue, description, reactive };
|
||||
}
|
||||
|
||||
static boolean (key: string, label: string, defaultValue?: boolean, description?: string, reactive?: boolean): PluginConfigItem {
|
||||
return { key, type: 'boolean', label, default: defaultValue, description, reactive };
|
||||
}
|
||||
|
||||
static select (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: string | number, description?: string, reactive?: boolean): PluginConfigItem {
|
||||
return { key, type: 'select', label, options, default: defaultValue, description, reactive };
|
||||
}
|
||||
|
||||
static multiSelect (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: (string | number)[], description?: string, reactive?: boolean): PluginConfigItem {
|
||||
return { key, type: 'multi-select', label, options, default: defaultValue, description, reactive };
|
||||
}
|
||||
|
||||
static html (content: string): PluginConfigItem {
|
||||
return { key: `_html_${Math.random().toString(36).slice(2)}`, type: 'html', label: '', default: content };
|
||||
}
|
||||
|
||||
static plainText (content: string): PluginConfigItem {
|
||||
return { key: `_text_${Math.random().toString(36).slice(2)}`, type: 'text', label: '', default: content };
|
||||
}
|
||||
|
||||
static combine (...items: PluginConfigItem[]): PluginConfigSchema {
|
||||
return items;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// 导出类型
|
||||
export type {
|
||||
PluginPackageJson,
|
||||
PluginConfigItem,
|
||||
PluginConfigSchema,
|
||||
INapCatConfigStatic,
|
||||
NapCatConfigClass,
|
||||
IPluginManager,
|
||||
PluginConfigUIController,
|
||||
PluginLogger,
|
||||
NapCatPluginContext,
|
||||
PluginModule,
|
||||
PluginRuntimeStatus,
|
||||
PluginRuntime,
|
||||
PluginEntry,
|
||||
PluginStatusConfig,
|
||||
} from './types';
|
||||
|
||||
// 导出配置构建器
|
||||
export { NapCatConfig } from './config';
|
||||
|
||||
// 导出加载器
|
||||
export { PluginLoader } from './loader';
|
||||
@@ -0,0 +1,429 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createRequire } from 'module';
|
||||
import { LogWrapper } from 'napcat-core/helper/log';
|
||||
import {
|
||||
PluginPackageJson,
|
||||
PluginModule,
|
||||
PluginEntry,
|
||||
PluginStatusConfig,
|
||||
} from './types';
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/** 允许加载的官方插件 ID 白名单。不在表内的插件一律拒绝加载。 */
|
||||
const OFFICIAL_PLUGIN_IDS = new Set<string>([
|
||||
'napcat-plugin-builtin',
|
||||
'napcat-plugin-cleaner',
|
||||
'napcat-plugin-ssqq',
|
||||
'napcat-plugin-qce',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 敏感词表:插件源码命中(黑灰产 / 支付发卡相关)即拒绝加载。
|
||||
* 仅作启发式威慑,混淆/编码可绕过,并非唯一防线。
|
||||
*/
|
||||
const SENSITIVE_KEYWORDS: string[] = [
|
||||
'发卡', '卡密', '卡网', '自动发卡', '寄售', '代刷', '刷单', '跑分',
|
||||
'四件套', '洗钱', '黑产', '灰产', '博彩', '赌博', '菠菜', '涉黄',
|
||||
'色情', '收款码', '代付', '担保交易', '第三方支付', 'USDT',
|
||||
];
|
||||
|
||||
/** 纳入敏感词检测的源码扩展名 */
|
||||
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts']);
|
||||
/** 跳过敏感词扫描的目录名 */
|
||||
const SCAN_SKIP_DIRS = new Set(['data', 'node_modules', '.git']);
|
||||
/** 单文件扫描大小上限,避免超大文件拖慢启动 */
|
||||
const SCAN_MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* 插件加载器
|
||||
* 负责扫描、加载和导入插件模块
|
||||
*/
|
||||
export class PluginLoader {
|
||||
constructor (
|
||||
private readonly pluginPath: string,
|
||||
private readonly configPath: string,
|
||||
private readonly logger: LogWrapper
|
||||
) { }
|
||||
|
||||
/**
|
||||
* 加载插件状态配置
|
||||
*/
|
||||
loadPluginStatusConfig (): PluginStatusConfig {
|
||||
if (fs.existsSync(this.configPath)) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(this.configPath, 'utf-8'));
|
||||
} catch (e) {
|
||||
this.logger.logWarn('[PluginLoader] Error parsing plugins.json', e);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存插件状态配置
|
||||
*/
|
||||
savePluginStatusConfig (config: PluginStatusConfig): void {
|
||||
try {
|
||||
fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
} catch (e) {
|
||||
this.logger.logError('[PluginLoader] Error saving plugins.json', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描插件目录,收集所有有效插件条目(异步版本,验证模块有效性)
|
||||
* 只有包含有效 plugin_init 函数的插件才会被收集
|
||||
*/
|
||||
async scanPlugins (): Promise<PluginEntry[]> {
|
||||
const entries: PluginEntry[] = [];
|
||||
|
||||
// 确保插件目录存在
|
||||
if (!fs.existsSync(this.pluginPath)) {
|
||||
this.logger.logWarn(`[PluginLoader] Plugin directory does not exist: ${this.pluginPath}`);
|
||||
fs.mkdirSync(this.pluginPath, { recursive: true });
|
||||
return entries;
|
||||
}
|
||||
|
||||
const items = fs.readdirSync(this.pluginPath, { withFileTypes: true });
|
||||
const statusConfig = this.loadPluginStatusConfig();
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry = this.scanDirectoryPlugin(item.name, statusConfig);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果没有入口文件,跳过
|
||||
if (!entry.entryPath) {
|
||||
this.logger.logWarn(`[PluginLoader] Skipping ${item.name}: no entry file found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果插件被禁用,跳过模块验证,直接添加到列表
|
||||
if (!entry.enable) {
|
||||
entries.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 验证模块有效性(仅对启用的插件)
|
||||
const validation = await this.validatePluginEntry(entry.entryPath);
|
||||
if (!validation.valid) {
|
||||
this.logger.logWarn(`[PluginLoader] Skipping ${item.name}: ${validation.error}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描单个目录插件
|
||||
*/
|
||||
private scanDirectoryPlugin (dirname: string, statusConfig: PluginStatusConfig): PluginEntry | null {
|
||||
const pluginDir = path.join(this.pluginPath, dirname);
|
||||
|
||||
try {
|
||||
// 尝试读取 package.json
|
||||
let packageJson: PluginPackageJson | undefined;
|
||||
const packageJsonPath = path.join(pluginDir, 'package.json');
|
||||
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const packageContent = fs.readFileSync(packageJsonPath, 'utf-8');
|
||||
packageJson = JSON.parse(packageContent);
|
||||
} catch (error) {
|
||||
this.logger.logWarn(`[PluginLoader] Invalid package.json in ${dirname}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取插件 ID(包名或目录名)
|
||||
const pluginId = packageJson?.name || dirname;
|
||||
|
||||
// 官方白名单 + 敏感词校验:被拒绝的插件视为不可见,
|
||||
// 不入列表、不加载、更不会被动态导入执行。
|
||||
// 该检查置于此处统一拦截 scanPlugins 与 rescanPlugin(安装/重载)两条路径。
|
||||
const rejectReason = this.getRejectReason(pluginId, pluginDir);
|
||||
if (rejectReason) {
|
||||
this.logger.logWarn(`[PluginLoader] Rejected ${pluginId} (${dirname}): ${rejectReason}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 确定入口文件
|
||||
const entryFile = this.findEntryFile(pluginDir, packageJson);
|
||||
const entryPath = entryFile ? path.join(pluginDir, entryFile) : undefined;
|
||||
|
||||
// 获取启用状态(默认禁用,内置插件除外)
|
||||
const enable = statusConfig[pluginId] ?? (pluginId === 'napcat-plugin-builtin');
|
||||
|
||||
// 创建插件条目
|
||||
const entry: PluginEntry = {
|
||||
id: pluginId,
|
||||
fileId: dirname,
|
||||
name: packageJson?.name,
|
||||
version: packageJson?.version,
|
||||
description: packageJson?.description,
|
||||
author: packageJson?.author,
|
||||
pluginPath: pluginDir,
|
||||
entryPath,
|
||||
packageJson,
|
||||
enable,
|
||||
loaded: false,
|
||||
runtime: {
|
||||
status: 'unloaded',
|
||||
},
|
||||
};
|
||||
|
||||
// 如果没有入口文件,标记为错误
|
||||
if (!entryPath) {
|
||||
entry.runtime = {
|
||||
status: 'error',
|
||||
error: `No valid entry file found for plugin directory: ${dirname}`,
|
||||
};
|
||||
}
|
||||
|
||||
return entry;
|
||||
} catch (error: any) {
|
||||
// 创建错误条目
|
||||
return {
|
||||
id: dirname, // 使用目录名作为 ID
|
||||
fileId: dirname,
|
||||
pluginPath: path.join(this.pluginPath, dirname),
|
||||
enable: statusConfig[dirname] ?? (dirname === 'napcat-plugin-builtin'),
|
||||
loaded: false,
|
||||
runtime: {
|
||||
status: 'error',
|
||||
error: error.message || 'Unknown error during scan',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找插件目录的入口文件
|
||||
*/
|
||||
private findEntryFile (pluginDir: string, packageJson?: PluginPackageJson): string | null {
|
||||
const possibleEntries = [
|
||||
packageJson?.main,
|
||||
'index.mjs',
|
||||
'index.js',
|
||||
'main.mjs',
|
||||
'main.js',
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
for (const entry of possibleEntries) {
|
||||
const root = path.resolve(pluginDir);
|
||||
const entryPath = path.resolve(pluginDir, entry);
|
||||
// 拒绝 package.json main 指向插件目录之外(防路径穿越)
|
||||
if (entryPath !== root && !entryPath.startsWith(root + path.sep)) {
|
||||
continue;
|
||||
}
|
||||
if (fs.existsSync(entryPath) && fs.statSync(entryPath).isFile()) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 是否为白名单内的官方插件 */
|
||||
isOfficialPlugin (pluginId: string): boolean {
|
||||
return OFFICIAL_PLUGIN_IDS.has(pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断插件是否应被拒绝(非官方 / 命中敏感词)。
|
||||
* @returns 拒绝原因;通过校验返回 null
|
||||
*/
|
||||
private getRejectReason (pluginId: string, pluginDir: string): string | null {
|
||||
const hit = this.scanSensitiveWords(pluginDir);
|
||||
if (this.isOfficialPlugin(pluginId)) {
|
||||
return null;
|
||||
}
|
||||
if (hit) {
|
||||
return `sensitive keyword "${hit}"`;
|
||||
}
|
||||
return 'not in official plugin whitelist';
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归扫描插件目录内源码是否命中敏感词(黑灰产 / 支付发卡)。
|
||||
* @returns 命中的敏感词;未命中返回 null
|
||||
*/
|
||||
scanSensitiveWords (pluginDir: string): string | null {
|
||||
const visit = (dir: string): string | null => {
|
||||
let items: fs.Dirent[];
|
||||
try {
|
||||
items = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const item of items) {
|
||||
if (item.isDirectory()) {
|
||||
if (SCAN_SKIP_DIRS.has(item.name)) {
|
||||
continue;
|
||||
}
|
||||
const hit = visit(path.join(dir, item.name));
|
||||
if (hit) {
|
||||
return hit;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!item.isFile()) {
|
||||
continue;
|
||||
}
|
||||
if (!SCAN_EXTS.has(path.extname(item.name).toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(dir, item.name);
|
||||
try {
|
||||
if (fs.statSync(filePath).size > SCAN_MAX_FILE_BYTES) {
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
for (const word of SENSITIVE_KEYWORDS) {
|
||||
if (content.includes(word)) {
|
||||
return word;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 读取失败忽略该文件
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return visit(pluginDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态导入模块
|
||||
*/
|
||||
async importModule (filePath: string): Promise<any> {
|
||||
const fileUrl = `file://${filePath.replace(/\\/g, '/')}`;
|
||||
const fileUrlWithQuery = `${fileUrl}?t=${Date.now()}`;
|
||||
return await import(fileUrlWithQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载插件模块
|
||||
*/
|
||||
async loadPluginModule (entry: PluginEntry): Promise<PluginModule | null> {
|
||||
if (!entry.entryPath) {
|
||||
entry.runtime = {
|
||||
status: 'error',
|
||||
error: 'No entry path specified',
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const module = await this.importModule(entry.entryPath);
|
||||
|
||||
if (!this.isValidPluginModule(module)) {
|
||||
entry.runtime = {
|
||||
status: 'error',
|
||||
error: 'Invalid plugin module: missing plugin_init function',
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
return module;
|
||||
} catch (error: any) {
|
||||
entry.runtime = {
|
||||
status: 'error',
|
||||
error: error.message || 'Failed to import module',
|
||||
};
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模块是否为有效的插件模块
|
||||
*/
|
||||
isValidPluginModule (module: any): module is PluginModule {
|
||||
return module && typeof module.plugin_init === 'function';
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证插件入口文件是否包含有效的 plugin_init 函数
|
||||
* 用于扫描阶段快速验证
|
||||
*/
|
||||
async validatePluginEntry (entryPath: string): Promise<{ valid: boolean; error?: string; }> {
|
||||
try {
|
||||
const module = await this.importModule(entryPath);
|
||||
if (this.isValidPluginModule(module)) {
|
||||
return { valid: true };
|
||||
}
|
||||
return { valid: false, error: 'Missing plugin_init function' };
|
||||
} catch (error: any) {
|
||||
return { valid: false, error: error.message || 'Failed to import module' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新扫描单个插件
|
||||
*/
|
||||
rescanPlugin (dirname: string): PluginEntry | null {
|
||||
const statusConfig = this.loadPluginStatusConfig();
|
||||
return this.scanDirectoryPlugin(dirname, statusConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 ID 查找插件目录名
|
||||
*/
|
||||
findPluginDirById (pluginId: string): string | null {
|
||||
if (!fs.existsSync(this.pluginPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 === pluginId) {
|
||||
return item.name;
|
||||
}
|
||||
} catch (_e) { }
|
||||
}
|
||||
|
||||
// 如果目录名就是 ID
|
||||
if (item.name === pluginId) {
|
||||
return item.name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除插件文件的 require 缓存
|
||||
* 用于确保卸载插件时清理 CJS 模块缓存
|
||||
*/
|
||||
clearCache (pluginPath: string): void {
|
||||
try {
|
||||
// 规范化路径以确保匹配正确
|
||||
const normalizedPluginPath = path.resolve(pluginPath);
|
||||
|
||||
// 遍历缓存并删除属于该插件目录的模块
|
||||
Object.keys(require.cache).forEach((id) => {
|
||||
if (id.startsWith(normalizedPluginPath)) {
|
||||
delete require.cache[id];
|
||||
this.logger.logDebug(`[PluginLoader] Cleared cache for: ${id}`);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.logError('[PluginLoader] Error clearing module cache:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import path from 'path';
|
||||
import {
|
||||
PluginRouterRegistry,
|
||||
PluginRequestHandler,
|
||||
PluginApiRouteDefinition,
|
||||
PluginPageDefinition,
|
||||
PluginHttpRequest,
|
||||
PluginHttpResponse,
|
||||
HttpMethod,
|
||||
MemoryStaticFile,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 包装 Express Request 为 PluginHttpRequest
|
||||
*/
|
||||
function wrapRequest (req: Request): PluginHttpRequest {
|
||||
return {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
query: req.query as Record<string, string | string[] | undefined>,
|
||||
body: req.body,
|
||||
headers: req.headers as Record<string, string | string[] | undefined>,
|
||||
params: req.params,
|
||||
raw: req,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装 Express Response 为 PluginHttpResponse
|
||||
*/
|
||||
function wrapResponse (res: Response): PluginHttpResponse {
|
||||
const wrapped: PluginHttpResponse = {
|
||||
status (code: number) {
|
||||
res.status(code);
|
||||
return wrapped;
|
||||
},
|
||||
json (data: unknown) {
|
||||
res.json(data);
|
||||
},
|
||||
send (data: string | Buffer) {
|
||||
res.send(data);
|
||||
},
|
||||
setHeader (name: string, value: string) {
|
||||
res.setHeader(name, value);
|
||||
return wrapped;
|
||||
},
|
||||
sendFile (filePath: string) {
|
||||
res.sendFile(filePath);
|
||||
},
|
||||
redirect (url: string) {
|
||||
res.redirect(url);
|
||||
},
|
||||
raw: res,
|
||||
};
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件路由注册器实现
|
||||
* 为每个插件创建独立的路由注册器,收集路由定义
|
||||
*/
|
||||
/** 内存静态路由定义 */
|
||||
interface MemoryStaticRoute {
|
||||
urlPath: string;
|
||||
files: MemoryStaticFile[];
|
||||
}
|
||||
|
||||
export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
||||
private apiRoutes: PluginApiRouteDefinition[] = [];
|
||||
private apiNoAuthRoutes: PluginApiRouteDefinition[] = [];
|
||||
private pageDefinitions: PluginPageDefinition[] = [];
|
||||
private staticRoutes: Array<{ urlPath: string; localPath: string; }> = [];
|
||||
private memoryStaticRoutes: MemoryStaticRoute[] = [];
|
||||
|
||||
constructor (
|
||||
private readonly pluginId: string,
|
||||
private readonly pluginPath: string
|
||||
) { }
|
||||
|
||||
// ==================== API 路由注册 ====================
|
||||
|
||||
api (method: HttpMethod, routePath: string, handler: PluginRequestHandler): void {
|
||||
this.apiRoutes.push({ method, path: routePath, handler });
|
||||
}
|
||||
|
||||
get (routePath: string, handler: PluginRequestHandler): void {
|
||||
this.api('get', routePath, handler);
|
||||
}
|
||||
|
||||
post (routePath: string, handler: PluginRequestHandler): void {
|
||||
this.api('post', routePath, handler);
|
||||
}
|
||||
|
||||
put (routePath: string, handler: PluginRequestHandler): void {
|
||||
this.api('put', routePath, handler);
|
||||
}
|
||||
|
||||
delete (routePath: string, handler: PluginRequestHandler): void {
|
||||
this.api('delete', routePath, handler);
|
||||
}
|
||||
|
||||
// ==================== 无认证 API 路由注册 ====================
|
||||
|
||||
apiNoAuth (method: HttpMethod, routePath: string, handler: PluginRequestHandler): void {
|
||||
this.apiNoAuthRoutes.push({ method, path: routePath, handler });
|
||||
}
|
||||
|
||||
getNoAuth (routePath: string, handler: PluginRequestHandler): void {
|
||||
this.apiNoAuth('get', routePath, handler);
|
||||
}
|
||||
|
||||
postNoAuth (routePath: string, handler: PluginRequestHandler): void {
|
||||
this.apiNoAuth('post', routePath, handler);
|
||||
}
|
||||
|
||||
putNoAuth (routePath: string, handler: PluginRequestHandler): void {
|
||||
this.apiNoAuth('put', routePath, handler);
|
||||
}
|
||||
|
||||
deleteNoAuth (routePath: string, handler: PluginRequestHandler): void {
|
||||
this.apiNoAuth('delete', routePath, handler);
|
||||
}
|
||||
|
||||
// ==================== 页面注册 ====================
|
||||
|
||||
page (pageDef: PluginPageDefinition): void {
|
||||
this.pageDefinitions.push(pageDef);
|
||||
}
|
||||
|
||||
pages (pageDefs: PluginPageDefinition[]): void {
|
||||
this.pageDefinitions.push(...pageDefs);
|
||||
}
|
||||
|
||||
// ==================== 静态资源 ====================
|
||||
|
||||
static (urlPath: string, localPath: string): void {
|
||||
// 如果是相对路径,则相对于插件目录
|
||||
const absolutePath = path.isAbsolute(localPath)
|
||||
? localPath
|
||||
: path.join(this.pluginPath, localPath);
|
||||
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();
|
||||
|
||||
// 注册 API 路由
|
||||
for (const route of this.apiRoutes) {
|
||||
const handler = this.wrapHandler(route.handler);
|
||||
switch (route.method) {
|
||||
case 'get':
|
||||
router.get(route.path, handler);
|
||||
break;
|
||||
case 'post':
|
||||
router.post(route.path, handler);
|
||||
break;
|
||||
case 'put':
|
||||
router.put(route.path, handler);
|
||||
break;
|
||||
case 'delete':
|
||||
router.delete(route.path, handler);
|
||||
break;
|
||||
case 'patch':
|
||||
router.patch(route.path, handler);
|
||||
break;
|
||||
case 'all':
|
||||
router.all(route.path, handler);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装处理器,添加错误处理和请求/响应包装
|
||||
*/
|
||||
private wrapHandler (handler: PluginRequestHandler): (req: Request, res: Response, next: NextFunction) => void {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const wrappedReq = wrapRequest(req);
|
||||
const wrappedRes = wrapResponse(res);
|
||||
await handler(wrappedReq, wrappedRes, next);
|
||||
} catch (error: any) {
|
||||
console.error(`[Plugin: ${this.pluginId}] Route error:`, error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
code: -1,
|
||||
message: `Plugin error: ${error.message || 'Unknown error'}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 查询方法 ====================
|
||||
|
||||
/**
|
||||
* 检查是否有注册的 API 路由(需要认证)
|
||||
*/
|
||||
hasApiRoutes (): boolean {
|
||||
return this.apiRoutes.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有注册的无认证 API 路由
|
||||
*/
|
||||
hasApiNoAuthRoutes (): boolean {
|
||||
return this.apiNoAuthRoutes.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建无认证 Express Router(用于 /plugin/{pluginId}/api/ 路径)
|
||||
*/
|
||||
buildApiNoAuthRouter (): Router {
|
||||
const router = Router();
|
||||
|
||||
for (const route of this.apiNoAuthRoutes) {
|
||||
const handler = this.wrapHandler(route.handler);
|
||||
switch (route.method) {
|
||||
case 'get':
|
||||
router.get(route.path, handler);
|
||||
break;
|
||||
case 'post':
|
||||
router.post(route.path, handler);
|
||||
break;
|
||||
case 'put':
|
||||
router.put(route.path, handler);
|
||||
break;
|
||||
case 'delete':
|
||||
router.delete(route.path, handler);
|
||||
break;
|
||||
case 'patch':
|
||||
router.patch(route.path, handler);
|
||||
break;
|
||||
case 'all':
|
||||
router.all(route.path, handler);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有注册的静态资源路由
|
||||
*/
|
||||
hasStaticRoutes (): boolean {
|
||||
return this.staticRoutes.length > 0 || this.memoryStaticRoutes.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有注册的页面
|
||||
*/
|
||||
hasPages (): boolean {
|
||||
return this.pageDefinitions.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有注册的页面定义
|
||||
*/
|
||||
getPages (): PluginPageDefinition[] {
|
||||
return [...this.pageDefinitions];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件 ID
|
||||
*/
|
||||
getPluginId (): string {
|
||||
return this.pluginId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件路径
|
||||
*/
|
||||
getPluginPath (): string {
|
||||
return this.pluginPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有注册的静态路由
|
||||
*/
|
||||
getStaticRoutes (): Array<{ urlPath: string; localPath: string; }> {
|
||||
return [...this.staticRoutes];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有注册的内存静态路由
|
||||
*/
|
||||
getMemoryStaticRoutes (): MemoryStaticRoute[] {
|
||||
return [...this.memoryStaticRoutes];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空路由(用于插件卸载)
|
||||
*/
|
||||
clear (): void {
|
||||
this.apiRoutes = [];
|
||||
this.apiNoAuthRoutes = [];
|
||||
this.pageDefinitions = [];
|
||||
this.staticRoutes = [];
|
||||
this.memoryStaticRoutes = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatOneBot11Adapter, OB11Message } from '@/napcat-onebot/index';
|
||||
import { ActionMap } from '@/napcat-onebot/action';
|
||||
import { OB11EmitEventContent } from '@/napcat-onebot/network/index';
|
||||
import { NetworkAdapterConfig } from '@/napcat-onebot/config/config';
|
||||
|
||||
// ==================== 插件包信息 ====================
|
||||
|
||||
export interface PluginPackageJson {
|
||||
name?: string;
|
||||
plugin?: string;
|
||||
version?: string;
|
||||
main?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
homepage?: string;
|
||||
repository?: string | { type: string; url: string; };
|
||||
icon?: string; // 插件图标文件路径(相对于插件目录),如 "icon.png"
|
||||
}
|
||||
|
||||
// ==================== 插件配置 Schema ====================
|
||||
|
||||
export interface PluginConfigItem {
|
||||
key: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'select' | 'multi-select' | 'html' | 'text';
|
||||
label: string;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
options?: { label: string; value: string | number; }[];
|
||||
placeholder?: string;
|
||||
/** 标记此字段为响应式:值变化时触发 schema 刷新 */
|
||||
reactive?: boolean;
|
||||
/** 是否隐藏此字段 */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export type PluginConfigSchema = PluginConfigItem[];
|
||||
|
||||
// ==================== NapCatConfig 静态接口 ====================
|
||||
|
||||
/** NapCatConfig 类的静态方法接口(用于 typeof NapCatConfig) */
|
||||
export interface INapCatConfigStatic {
|
||||
text (key: string, label: string, defaultValue?: string, description?: string, reactive?: boolean): PluginConfigItem;
|
||||
number (key: string, label: string, defaultValue?: number, description?: string, reactive?: boolean): PluginConfigItem;
|
||||
boolean (key: string, label: string, defaultValue?: boolean, description?: string, reactive?: boolean): PluginConfigItem;
|
||||
select (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: string | number, description?: string, reactive?: boolean): PluginConfigItem;
|
||||
multiSelect (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: (string | number)[], description?: string, reactive?: boolean): PluginConfigItem;
|
||||
html (content: string): PluginConfigItem;
|
||||
plainText (content: string): PluginConfigItem;
|
||||
combine (...items: PluginConfigItem[]): PluginConfigSchema;
|
||||
}
|
||||
|
||||
/** NapCatConfig 类型(包含静态方法) */
|
||||
export type NapCatConfigClass = INapCatConfigStatic;
|
||||
|
||||
// ==================== 插件路由相关类型(包装层,不直接依赖 express) ====================
|
||||
|
||||
/** HTTP 请求对象(包装类型) */
|
||||
export interface PluginHttpRequest {
|
||||
/** 请求路径 */
|
||||
path: string;
|
||||
/** 请求方法 */
|
||||
method: string;
|
||||
/** 查询参数 */
|
||||
query: Record<string, string | string[] | undefined>;
|
||||
/** 请求体 */
|
||||
body: unknown;
|
||||
/** 请求头 */
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
/** 路由参数 */
|
||||
params: Record<string, string>;
|
||||
/** 原始请求对象(用于高级用法) */
|
||||
raw: unknown;
|
||||
}
|
||||
|
||||
/** HTTP 响应对象(包装类型) */
|
||||
export interface PluginHttpResponse {
|
||||
/** 设置状态码 */
|
||||
status (code: number): PluginHttpResponse;
|
||||
/** 发送 JSON 响应 */
|
||||
json (data: unknown): void;
|
||||
/** 发送文本响应 */
|
||||
send (data: string | Buffer): void;
|
||||
/** 设置响应头 */
|
||||
setHeader (name: string, value: string): PluginHttpResponse;
|
||||
/** 发送文件 */
|
||||
sendFile (filePath: string): void;
|
||||
/** 重定向 */
|
||||
redirect (url: string): void;
|
||||
/** 原始响应对象(用于高级用法) */
|
||||
raw: unknown;
|
||||
}
|
||||
|
||||
/** 下一步函数类型 */
|
||||
export type PluginNextFunction = (err?: unknown) => void;
|
||||
|
||||
/** 插件请求处理器类型 */
|
||||
export type PluginRequestHandler = (
|
||||
req: PluginHttpRequest,
|
||||
res: PluginHttpResponse,
|
||||
next: PluginNextFunction
|
||||
) => void | Promise<void>;
|
||||
|
||||
/** HTTP 方法类型 */
|
||||
export type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'all';
|
||||
|
||||
/** 插件 API 路由定义 */
|
||||
export interface PluginApiRouteDefinition {
|
||||
/** HTTP 方法 */
|
||||
method: HttpMethod;
|
||||
/** 路由路径(相对于插件路由前缀) */
|
||||
path: string;
|
||||
/** 请求处理器 */
|
||||
handler: PluginRequestHandler;
|
||||
}
|
||||
|
||||
/** 插件页面定义 */
|
||||
export interface PluginPageDefinition {
|
||||
/** 页面路径(用于路由,如 'settings') */
|
||||
path: string;
|
||||
/** 页面标题(显示在 Tab 上) */
|
||||
title: string;
|
||||
/** 页面图标(可选,支持 emoji 或图标名) */
|
||||
icon?: string;
|
||||
/** 页面 HTML 文件路径(相对于插件目录) */
|
||||
htmlFile: 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 {
|
||||
// ==================== API 路由注册(需要认证) ====================
|
||||
|
||||
/**
|
||||
* 注册单个 API 路由(需要认证,挂载到 /api/Plugin/ext/{pluginId}/)
|
||||
* @param method HTTP 方法
|
||||
* @param path 路由路径
|
||||
* @param handler 请求处理器
|
||||
*/
|
||||
api (method: HttpMethod, path: string, handler: PluginRequestHandler): void;
|
||||
/** 注册 GET API(需要认证) */
|
||||
get (path: string, handler: PluginRequestHandler): void;
|
||||
/** 注册 POST API(需要认证) */
|
||||
post (path: string, handler: PluginRequestHandler): void;
|
||||
/** 注册 PUT API(需要认证) */
|
||||
put (path: string, handler: PluginRequestHandler): void;
|
||||
/** 注册 DELETE API(需要认证) */
|
||||
delete (path: string, handler: PluginRequestHandler): void;
|
||||
|
||||
// ==================== 无认证 API 路由注册 ====================
|
||||
|
||||
/**
|
||||
* 注册单个无认证 API 路由(挂载到 /plugin/{pluginId}/api/)
|
||||
* @param method HTTP 方法
|
||||
* @param path 路由路径
|
||||
* @param handler 请求处理器
|
||||
*/
|
||||
apiNoAuth (method: HttpMethod, path: string, handler: PluginRequestHandler): void;
|
||||
/** 注册 GET API(无认证) */
|
||||
getNoAuth (path: string, handler: PluginRequestHandler): void;
|
||||
/** 注册 POST API(无认证) */
|
||||
postNoAuth (path: string, handler: PluginRequestHandler): void;
|
||||
/** 注册 PUT API(无认证) */
|
||||
putNoAuth (path: string, handler: PluginRequestHandler): void;
|
||||
/** 注册 DELETE API(无认证) */
|
||||
deleteNoAuth (path: string, handler: PluginRequestHandler): void;
|
||||
|
||||
// ==================== 页面注册 ====================
|
||||
|
||||
/**
|
||||
* 注册插件页面
|
||||
* @param page 页面定义
|
||||
*/
|
||||
page (page: PluginPageDefinition): void;
|
||||
|
||||
/**
|
||||
* 注册多个插件页面
|
||||
* @param pages 页面定义数组
|
||||
*/
|
||||
pages (pages: PluginPageDefinition[]): void;
|
||||
|
||||
// ==================== 静态资源 ====================
|
||||
|
||||
/**
|
||||
* 提供静态文件服务
|
||||
* @param urlPath URL 路径
|
||||
* @param localPath 本地文件夹路径(相对于插件目录或绝对路径)
|
||||
*/
|
||||
static (urlPath: string, localPath: string): void;
|
||||
|
||||
/**
|
||||
* 提供内存生成的静态文件服务
|
||||
* @param urlPath URL 路径
|
||||
* @param files 内存文件列表
|
||||
*/
|
||||
staticOnMem (urlPath: string, files: MemoryStaticFile[]): void;
|
||||
}
|
||||
|
||||
// ==================== 插件管理器接口 ====================
|
||||
|
||||
/** 插件管理器公共接口 */
|
||||
export interface IPluginManager {
|
||||
readonly config: NetworkAdapterConfig;
|
||||
getPluginPath (): string;
|
||||
getPluginConfig (): PluginStatusConfig;
|
||||
getAllPlugins (): PluginEntry[];
|
||||
getLoadedPlugins (): PluginEntry[];
|
||||
getPluginInfo (pluginId: string): PluginEntry | undefined;
|
||||
setPluginStatus (pluginId: string, enable: boolean): Promise<void>;
|
||||
loadPluginById (pluginId: string): Promise<boolean>;
|
||||
unregisterPlugin (pluginId: string): Promise<void>;
|
||||
uninstallPlugin (pluginId: string, cleanData?: boolean): Promise<void>;
|
||||
reloadPlugin (pluginId: string): Promise<boolean>;
|
||||
loadDirectoryPlugin (dirname: string): Promise<void>;
|
||||
getPluginDataPath (pluginId: string): string;
|
||||
getPluginConfigPath (pluginId: string): string;
|
||||
}
|
||||
|
||||
// ==================== 插件配置 UI 控制器 ====================
|
||||
|
||||
/** 插件配置 UI 控制器 - 用于动态控制配置界面 */
|
||||
export interface PluginConfigUIController {
|
||||
/** 更新整个 schema */
|
||||
updateSchema: (schema: PluginConfigSchema) => void;
|
||||
/** 更新单个字段 */
|
||||
updateField: (key: string, field: Partial<PluginConfigItem>) => void;
|
||||
/** 移除字段 */
|
||||
removeField: (key: string) => void;
|
||||
/** 添加字段 */
|
||||
addField: (field: PluginConfigItem, afterKey?: string) => void;
|
||||
/** 显示字段 */
|
||||
showField: (key: string) => void;
|
||||
/** 隐藏字段 */
|
||||
hideField: (key: string) => void;
|
||||
/** 获取当前配置值 */
|
||||
getCurrentConfig: () => Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ==================== 插件日志接口 ====================
|
||||
|
||||
/**
|
||||
* 插件日志接口 - 简化的日志 API
|
||||
*/
|
||||
export interface PluginLogger {
|
||||
/** 普通日志 */
|
||||
log (...args: unknown[]): void;
|
||||
/** 调试日志 */
|
||||
debug (...args: unknown[]): void;
|
||||
/** 信息日志 */
|
||||
info (...args: unknown[]): void;
|
||||
/** 警告日志 */
|
||||
warn (...args: unknown[]): void;
|
||||
/** 错误日志 */
|
||||
error (...args: unknown[]): void;
|
||||
}
|
||||
|
||||
// ==================== 插件上下文 ====================
|
||||
|
||||
export interface NapCatPluginContext {
|
||||
core: NapCatCore;
|
||||
oneBot: NapCatOneBot11Adapter;
|
||||
actions: ActionMap;
|
||||
pluginName: string;
|
||||
pluginPath: string;
|
||||
configPath: string;
|
||||
dataPath: string;
|
||||
/** NapCatConfig 配置构建器 */
|
||||
NapCatConfig: NapCatConfigClass;
|
||||
adapterName: string;
|
||||
/** 插件管理器实例 */
|
||||
pluginManager: IPluginManager;
|
||||
/** 插件日志器 - 自动添加插件名称前缀 */
|
||||
logger: PluginLogger;
|
||||
/**
|
||||
* WebUI 路由注册器
|
||||
* 用于注册插件的 HTTP API 路由,路由将挂载到 /api/Plugin/ext/{pluginId}/
|
||||
* 静态资源将挂载到 /plugin/{pluginId}/files/{urlPath}/
|
||||
*/
|
||||
router: PluginRouterRegistry;
|
||||
/**
|
||||
* 获取其他插件的导出模块
|
||||
* @param pluginId 目标插件 ID
|
||||
* @returns 插件导出的模块,如果插件未加载则返回 undefined
|
||||
*/
|
||||
getPluginExports: <T = PluginModule>(pluginId: string) => T | undefined;
|
||||
}
|
||||
|
||||
// ==================== 插件模块接口 ====================
|
||||
|
||||
export interface PluginModule<T extends OB11EmitEventContent = OB11EmitEventContent, C = unknown> {
|
||||
plugin_init: (ctx: NapCatPluginContext) => void | Promise<void>;
|
||||
plugin_onmessage?: (
|
||||
ctx: NapCatPluginContext,
|
||||
event: OB11Message,
|
||||
) => void | Promise<void>;
|
||||
plugin_onevent?: (
|
||||
ctx: NapCatPluginContext,
|
||||
event: T,
|
||||
) => void | Promise<void>;
|
||||
plugin_cleanup?: (
|
||||
ctx: NapCatPluginContext
|
||||
) => void | Promise<void>;
|
||||
plugin_config_schema?: PluginConfigSchema;
|
||||
plugin_config_ui?: PluginConfigSchema;
|
||||
plugin_get_config?: (ctx: NapCatPluginContext) => C | Promise<C>;
|
||||
plugin_set_config?: (ctx: NapCatPluginContext, config: C) => void | Promise<void>;
|
||||
/**
|
||||
* 配置界面控制器 - 当配置界面打开时调用
|
||||
* 返回清理函数,在界面关闭时调用
|
||||
*/
|
||||
plugin_config_controller?: (
|
||||
ctx: NapCatPluginContext,
|
||||
ui: PluginConfigUIController,
|
||||
initialConfig: Record<string, unknown>
|
||||
) => void | (() => void) | Promise<void | (() => void)>;
|
||||
/**
|
||||
* 响应式字段变化回调 - 当标记为 reactive 的字段值变化时调用
|
||||
*/
|
||||
plugin_on_config_change?: (
|
||||
ctx: NapCatPluginContext,
|
||||
ui: PluginConfigUIController,
|
||||
key: string,
|
||||
value: unknown,
|
||||
currentConfig: Record<string, unknown>
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
// ==================== 插件运行时状态 ====================
|
||||
|
||||
export type PluginRuntimeStatus = 'loaded' | 'error' | 'unloaded';
|
||||
|
||||
export interface PluginRuntime {
|
||||
/** 运行时状态 */
|
||||
status: PluginRuntimeStatus;
|
||||
/** 错误信息(当 status 为 'error' 时) */
|
||||
error?: string;
|
||||
/** 插件模块(当 status 为 'loaded' 时) */
|
||||
module?: PluginModule;
|
||||
/** 插件上下文(当 status 为 'loaded' 时) */
|
||||
context?: NapCatPluginContext;
|
||||
}
|
||||
|
||||
// ==================== 插件条目(统一管理所有插件) ====================
|
||||
|
||||
export interface PluginEntry {
|
||||
// ===== 基础信息 =====
|
||||
/** 插件 ID(包名或目录名) */
|
||||
id: string;
|
||||
/** 文件系统目录名 */
|
||||
fileId: string;
|
||||
/** 显示名称 */
|
||||
name?: string;
|
||||
/** 版本号 */
|
||||
version?: string;
|
||||
/** 描述 */
|
||||
description?: string;
|
||||
/** 作者 */
|
||||
author?: string;
|
||||
/** 插件目录路径 */
|
||||
pluginPath: string;
|
||||
/** 入口文件路径 */
|
||||
entryPath?: string;
|
||||
/** package.json 内容 */
|
||||
packageJson?: PluginPackageJson;
|
||||
|
||||
// ===== 状态 =====
|
||||
/** 是否启用(用户配置) */
|
||||
enable: boolean;
|
||||
/** 运行时是否已加载 */
|
||||
loaded: boolean;
|
||||
|
||||
// ===== 运行时 =====
|
||||
/** 运行时信息 */
|
||||
runtime: PluginRuntime;
|
||||
}
|
||||
|
||||
// ==================== 插件状态配置(持久化) ====================
|
||||
|
||||
export interface PluginStatusConfig {
|
||||
[key: string]: boolean; // key: pluginId, value: enabled
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { OB11EmitEventContent, OB11NetworkReloadType } from '@/napcat-onebot/network/index';
|
||||
import { RawData, WebSocket } from 'ws';
|
||||
import { OB11HeartbeatEvent } from '@/napcat-onebot/event/meta/OB11HeartbeatEvent';
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { OB11Response } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { LifeCycleSubType, OB11LifeCycleEvent } from '@/napcat-onebot/event/meta/OB11LifeCycleEvent';
|
||||
import { WebsocketClientConfig } from '@/napcat-onebot/config/config';
|
||||
import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
|
||||
import json5 from 'json5';
|
||||
|
||||
export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketClientConfig> {
|
||||
private connection: WebSocket | null = null;
|
||||
private heartbeatRef: NodeJS.Timeout | null = null;
|
||||
|
||||
override get isActive (): boolean {
|
||||
return this.isEnable && !!this.connection && this.connection.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
async onEvent<T extends OB11EmitEventContent> (event: T) {
|
||||
if (this.connection && this.connection.readyState === WebSocket.OPEN) {
|
||||
this.connection.send(JSON.stringify(event));
|
||||
}
|
||||
}
|
||||
|
||||
async open () {
|
||||
if (this.connection) {
|
||||
return;
|
||||
}
|
||||
if (this.config.heartInterval > 0) {
|
||||
this.heartbeatRef = setInterval(() => {
|
||||
if (this.connection && this.connection.readyState === WebSocket.OPEN) {
|
||||
this.connection.send(JSON.stringify(new OB11HeartbeatEvent(this.core, this.config.heartInterval, this.core.selfInfo.online ?? true, true)));
|
||||
}
|
||||
}, this.config.heartInterval);
|
||||
}
|
||||
this.isEnable = true;
|
||||
try {
|
||||
await this.tryConnect();
|
||||
} catch (error) {
|
||||
this.logger.logError('[OneBot] [WebSocket Client] TryConnect Error , info -> ', error);
|
||||
}
|
||||
}
|
||||
|
||||
close () {
|
||||
if (!this.isEnable) {
|
||||
this.logger.logDebug('Cannot close a closed WebSocket connection');
|
||||
return;
|
||||
}
|
||||
this.isEnable = false;
|
||||
if (this.connection) {
|
||||
this.connection.close();
|
||||
this.connection = null;
|
||||
}
|
||||
if (this.heartbeatRef) {
|
||||
clearInterval(this.heartbeatRef);
|
||||
this.heartbeatRef = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async checkStateAndReply<T> (data: T) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (this.connection && this.connection.readyState === WebSocket.OPEN) {
|
||||
this.connection.send(JSON.stringify(data));
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('WebSocket is not open'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async tryConnect () {
|
||||
if (!this.connection && this.isEnable) {
|
||||
let isClosedByError = false;
|
||||
|
||||
const wsOptions: WebSocket.ClientOptions = {
|
||||
maxPayload: 50 * 1024 * 1024, // 50 MB
|
||||
handshakeTimeout: 2000,
|
||||
perMessageDeflate: false,
|
||||
headers: {
|
||||
'X-Self-ID': this.core.selfInfo.uin,
|
||||
Authorization: `Bearer ${this.config.token}`,
|
||||
'x-client-role': 'Universal', // 为koishi adpter适配
|
||||
'User-Agent': 'OneBot/11',
|
||||
},
|
||||
};
|
||||
|
||||
// 如果配置了 verifyCertificate,设置 rejectUnauthorized
|
||||
if (this.config.verifyCertificate !== undefined) {
|
||||
wsOptions.rejectUnauthorized = this.config.verifyCertificate;
|
||||
}
|
||||
|
||||
this.connection = new WebSocket(this.config.url, wsOptions);
|
||||
this.connection.on('pong', () => {
|
||||
// this.logger.logDebug('[OneBot] [WebSocket Client] 收到pong');
|
||||
});
|
||||
this.connection.on('open', () => {
|
||||
try {
|
||||
this.connectEvent(this.core).catch(e => this.logger.logError('[OneBot] [WebSocket Client] 发送连接生命周期失败', e));
|
||||
} catch (e) {
|
||||
this.logger.logError('[OneBot] [WebSocket Client] 发送连接生命周期失败', e);
|
||||
}
|
||||
});
|
||||
this.connection.on('message', (data) => {
|
||||
this.handleMessage(data);
|
||||
});
|
||||
this.connection.once('close', () => {
|
||||
if (!isClosedByError) {
|
||||
this.logger.logError(`[OneBot] [WebSocket Client] 反向WebSocket (${this.config.url}) 连接意外关闭`);
|
||||
this.logger.logError(`[OneBot] [WebSocket Client] 在 ${Math.floor(this.config.reconnectInterval / 1000)} 秒后尝试重新连接`);
|
||||
if (this.isEnable) {
|
||||
this.connection = null;
|
||||
setTimeout(() => this.tryConnect(), this.config.reconnectInterval);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.connection.on('error', (err) => {
|
||||
isClosedByError = true;
|
||||
this.logger.logError(`[OneBot] [WebSocket Client] 反向WebSocket (${this.config.url}) 连接错误`, err);
|
||||
this.logger.logError(`[OneBot] [WebSocket Client] 在 ${Math.floor(this.config.reconnectInterval / 1000)} 秒后尝试重新连接`);
|
||||
if (this.isEnable) {
|
||||
this.connection = null;
|
||||
setTimeout(() => this.tryConnect(), this.config.reconnectInterval);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async connectEvent (core: NapCatCore) {
|
||||
try {
|
||||
await this.checkStateAndReply<unknown>(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT));
|
||||
} catch (e) {
|
||||
this.logger.logError('[OneBot] [WebSocket Client] 发送生命周期失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMessage (message: RawData) {
|
||||
let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any; } = { action: ActionName.Unknown, params: {} };
|
||||
let echo;
|
||||
|
||||
try {
|
||||
receiveData = json5.parse(message.toString());
|
||||
echo = receiveData.echo;
|
||||
this.logger.logDebug('[OneBot] [WebSocket Client] 收到正向Websocket消息', receiveData);
|
||||
} catch {
|
||||
await this.checkStateAndReply<unknown>(OB11Response.error('json解析失败,请检查数据格式', 1400, echo));
|
||||
return;
|
||||
}
|
||||
receiveData.params = (receiveData?.params) ? receiveData.params : {};// 兼容类型验证
|
||||
|
||||
const action = this.actions.get(receiveData.action as any);
|
||||
if (!action) {
|
||||
this.logger.logError('[OneBot] [WebSocket Client] 发生错误', '不支持的Api ' + receiveData.action);
|
||||
await this.checkStateAndReply<unknown>(OB11Response.error('不支持的Api ' + receiveData.action, 1404, echo));
|
||||
return;
|
||||
}
|
||||
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config, {
|
||||
send: async (data: object) => {
|
||||
await this.checkStateAndReply<unknown>({ ...OB11Response.ok(data, echo ?? '', true) });
|
||||
},
|
||||
});
|
||||
await this.checkStateAndReply<unknown>({ ...retdata });
|
||||
}
|
||||
|
||||
async reload (newConfig: WebsocketClientConfig) {
|
||||
const wasEnabled = this.isEnable;
|
||||
const oldUrl = this.config.url;
|
||||
const oldHeartInterval = this.config.heartInterval;
|
||||
this.config = newConfig;
|
||||
|
||||
if (newConfig.enable && !wasEnabled) {
|
||||
this.open();
|
||||
return OB11NetworkReloadType.NetWorkOpen;
|
||||
} else if (!newConfig.enable && wasEnabled) {
|
||||
this.close();
|
||||
return OB11NetworkReloadType.NetWorkClose;
|
||||
}
|
||||
|
||||
if (oldUrl !== newConfig.url) {
|
||||
this.close();
|
||||
if (newConfig.enable) {
|
||||
this.open();
|
||||
}
|
||||
return OB11NetworkReloadType.NetWorkReload;
|
||||
}
|
||||
|
||||
if (oldHeartInterval !== newConfig.heartInterval) {
|
||||
if (this.heartbeatRef) {
|
||||
clearInterval(this.heartbeatRef);
|
||||
this.heartbeatRef = null;
|
||||
}
|
||||
if (newConfig.heartInterval > 0 && this.isEnable) {
|
||||
this.heartbeatRef = setInterval(() => {
|
||||
if (this.connection && this.connection.readyState === WebSocket.OPEN) {
|
||||
this.connection.send(JSON.stringify(new OB11HeartbeatEvent(this.core, newConfig.heartInterval, this.core.selfInfo.online ?? true, true)));
|
||||
}
|
||||
}, newConfig.heartInterval);
|
||||
}
|
||||
return OB11NetworkReloadType.NetWorkReload;
|
||||
}
|
||||
|
||||
return OB11NetworkReloadType.Normal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
|
||||
import { URL } from 'url';
|
||||
import { RawData, WebSocket, WebSocketServer } from 'ws';
|
||||
import { OB11Response } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { OB11HeartbeatEvent } from '@/napcat-onebot/event/meta/OB11HeartbeatEvent';
|
||||
import { IncomingMessage } from 'http';
|
||||
import { ActionMap } from '@/napcat-onebot/action';
|
||||
import { LifeCycleSubType, OB11LifeCycleEvent } from '@/napcat-onebot/event/meta/OB11LifeCycleEvent';
|
||||
import { WebsocketServerConfig } from '@/napcat-onebot/config/config';
|
||||
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
|
||||
import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
|
||||
import json5 from 'json5';
|
||||
|
||||
export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketServerConfig> {
|
||||
wsServer?: WebSocketServer;
|
||||
wsClients: WebSocket[] = [];
|
||||
private heartbeatIntervalId: NodeJS.Timeout | null = null;
|
||||
wsClientWithEvent: WebSocket[] = [];
|
||||
|
||||
override get isActive (): boolean {
|
||||
return this.isEnable && this.wsClientWithEvent.length > 0;
|
||||
}
|
||||
|
||||
constructor (
|
||||
name: string, config: WebsocketServerConfig, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap
|
||||
) {
|
||||
super(name, config, core, obContext, actions);
|
||||
this.wsServer = new WebSocketServer({
|
||||
port: this.config.port,
|
||||
host: this.config.host === '0.0.0.0' ? '' : this.config.host,
|
||||
maxPayload: 50 * 1024 * 1024, // 50 MB
|
||||
});
|
||||
this.createServer(this.wsServer);
|
||||
}
|
||||
|
||||
createServer (newServer: WebSocketServer) {
|
||||
newServer.on('connection', async (wsClient, wsReq) => {
|
||||
if (!this.isEnable) {
|
||||
wsClient.close();
|
||||
return;
|
||||
}
|
||||
// 鉴权 close 不会立刻销毁 当前返回可避免挂载message事件 close 并未立刻关闭 而是存在timer操作后关闭
|
||||
// 引发高危漏洞
|
||||
if (!this.authorize(this.config.token, wsClient, wsReq)) {
|
||||
return;
|
||||
}
|
||||
const paramUrl = wsReq.url?.indexOf('?') !== -1 ? wsReq.url?.substring(0, wsReq.url?.indexOf('?')) : wsReq.url;
|
||||
const isApiConnect = paramUrl === '/api' || paramUrl === '/api/';
|
||||
if (!isApiConnect) {
|
||||
this.connectEvent(this.core, wsClient);
|
||||
}
|
||||
|
||||
wsClient.on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Client Error:', err.message));
|
||||
wsClient.on('message', (message) => {
|
||||
this.handleMessage(wsClient, message).then().catch(e => this.logger.logError(e));
|
||||
});
|
||||
wsClient.on('pong', () => {
|
||||
// this.logger.logDebug('[OneBot] [WebSocket Server] Pong received');
|
||||
});
|
||||
wsClient.once('close', () => {
|
||||
const NormolIndex = this.wsClients.indexOf(wsClient);
|
||||
if (NormolIndex !== -1) {
|
||||
this.wsClients.splice(NormolIndex, 1);
|
||||
}
|
||||
const EventIndex = this.wsClientWithEvent.indexOf(wsClient);
|
||||
if (EventIndex !== -1) {
|
||||
this.wsClientWithEvent.splice(EventIndex, 1);
|
||||
}
|
||||
if (this.wsClientWithEvent.length === 0) {
|
||||
this.stopHeartbeat();
|
||||
}
|
||||
});
|
||||
if (!isApiConnect) {
|
||||
this.wsClientWithEvent.push(wsClient);
|
||||
}
|
||||
this.wsClients.push(wsClient);
|
||||
if (this.wsClientWithEvent.length > 0) {
|
||||
this.startHeartbeat();
|
||||
}
|
||||
}).on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Server Error:', err.message));
|
||||
}
|
||||
|
||||
connectEvent (core: NapCatCore, wsClient: WebSocket) {
|
||||
try {
|
||||
this.checkStateAndReply<unknown>(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT), wsClient).catch(e => this.logger.logError('[OneBot] [WebSocket Server] 发送生命周期失败', e));
|
||||
} catch (e) {
|
||||
this.logger.logError('[OneBot] [WebSocket Server] 发送生命周期失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
async onEvent<T extends OB11EmitEventContent> (event: T) {
|
||||
const promises = this.wsClientWithEvent.map((wsClient) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(event));
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('WebSocket is not open'));
|
||||
}
|
||||
});
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
open () {
|
||||
if (this.isEnable) {
|
||||
this.logger.logError('[OneBot] [WebSocket Server] Cannot open a opened WebSocket server');
|
||||
return;
|
||||
}
|
||||
const addressInfo = this.wsServer?.address();
|
||||
this.logger.log('[OneBot] [WebSocket Server] Server Started', typeof (addressInfo) === 'string' ? addressInfo : addressInfo?.address + ':' + addressInfo?.port);
|
||||
|
||||
this.isEnable = true;
|
||||
}
|
||||
|
||||
async close () {
|
||||
this.isEnable = false;
|
||||
this.wsServer?.close((err) => {
|
||||
if (err) {
|
||||
this.logger.logError('[OneBot] [WebSocket Server] Error closing server:', err.message);
|
||||
} else {
|
||||
this.logger.log('[OneBot] [WebSocket Server] Server Closed');
|
||||
}
|
||||
});
|
||||
this.stopHeartbeat();
|
||||
this.wsClients.forEach((wsClient) => wsClient.close());
|
||||
this.wsClients = [];
|
||||
this.wsClientWithEvent = [];
|
||||
}
|
||||
|
||||
private startHeartbeat () {
|
||||
if (this.heartbeatIntervalId || this.config.heartInterval <= 0) return;
|
||||
this.heartbeatIntervalId = setInterval(() => {
|
||||
this.wsClientWithEvent.forEach((wsClient) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(new OB11HeartbeatEvent(this.core, this.config.heartInterval, this.core.selfInfo.online ?? true, true)));
|
||||
}
|
||||
});
|
||||
}, this.config.heartInterval);
|
||||
}
|
||||
|
||||
private stopHeartbeat () {
|
||||
if (this.heartbeatIntervalId) {
|
||||
clearInterval(this.heartbeatIntervalId);
|
||||
this.heartbeatIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private authorize (token: string | undefined, wsClient: WebSocket, wsReq: IncomingMessage) {
|
||||
if (!token || token.length === 0) return true;// 客户端未设置密钥
|
||||
const url = new URL(wsReq?.url || '', `http://${wsReq.headers.host}`);
|
||||
const QueryClientToken = url.searchParams.get('access_token');
|
||||
const HeaderClientToken = wsReq.headers.authorization?.split('Bearer ').pop() || '';
|
||||
const ClientToken = typeof (QueryClientToken) === 'string' && QueryClientToken !== '' ? QueryClientToken : HeaderClientToken;
|
||||
if (ClientToken === token) {
|
||||
return true;
|
||||
}
|
||||
wsClient.send(JSON.stringify(OB11Response.res(null, 'failed', 1403, 'token验证失败')));
|
||||
wsClient.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
private async checkStateAndReply<T> (data: T, wsClient: WebSocket) {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(data));
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('WebSocket is not open'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async handleMessage (wsClient: WebSocket, message: RawData) {
|
||||
let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any; } = { action: ActionName.Unknown, params: {} };
|
||||
let echo;
|
||||
try {
|
||||
receiveData = json5.parse(message.toString());
|
||||
echo = receiveData.echo;
|
||||
// this.logger.logDebug('收到正向Websocket消息', receiveData);
|
||||
} catch {
|
||||
await this.checkStateAndReply<unknown>(OB11Response.error('json解析失败,请检查数据格式', 1400, echo), wsClient);
|
||||
return;
|
||||
}
|
||||
receiveData.params = (receiveData?.params) ? receiveData.params : {};// 兼容类型验证 不然类型校验爆炸
|
||||
|
||||
const action = this.actions.get(receiveData.action as any);
|
||||
if (!action) {
|
||||
this.logger.logError('[OneBot] [WebSocket Client] 发生错误', '不支持的API ' + receiveData.action);
|
||||
await this.checkStateAndReply<unknown>(OB11Response.error('不支持的API ' + receiveData.action, 1404, echo), wsClient);
|
||||
return;
|
||||
}
|
||||
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config, {
|
||||
send: async (data: object) => {
|
||||
await this.checkStateAndReply<unknown>({ ...OB11Response.ok(data, echo ?? '', true) }, wsClient);
|
||||
},
|
||||
});
|
||||
await this.checkStateAndReply<unknown>({ ...retdata }, wsClient);
|
||||
}
|
||||
|
||||
async reload (newConfig: WebsocketServerConfig) {
|
||||
const wasEnabled = this.isEnable;
|
||||
const oldPort = this.config.port;
|
||||
const oldHost = this.config.host;
|
||||
const oldHeartbeatInterval = this.config.heartInterval;
|
||||
this.config = newConfig;
|
||||
|
||||
if (newConfig.enable && !wasEnabled) {
|
||||
this.open();
|
||||
return OB11NetworkReloadType.NetWorkOpen;
|
||||
} else if (!newConfig.enable && wasEnabled) {
|
||||
this.close();
|
||||
return OB11NetworkReloadType.NetWorkClose;
|
||||
}
|
||||
|
||||
if (oldPort !== newConfig.port || oldHost !== newConfig.host) {
|
||||
this.close();
|
||||
this.wsServer = new WebSocketServer({
|
||||
port: newConfig.port,
|
||||
host: newConfig.host === '0.0.0.0' ? '' : newConfig.host,
|
||||
maxPayload: 50 * 1024 * 1024, // 50 MB
|
||||
});
|
||||
this.createServer(this.wsServer);
|
||||
if (newConfig.enable) {
|
||||
this.open();
|
||||
}
|
||||
return OB11NetworkReloadType.NetWorkReload;
|
||||
}
|
||||
|
||||
if (oldHeartbeatInterval !== newConfig.heartInterval) {
|
||||
this.stopHeartbeat();
|
||||
if (newConfig.heartInterval > 0 && this.isEnable && this.wsClientWithEvent.length > 0) {
|
||||
this.startHeartbeat();
|
||||
}
|
||||
return OB11NetworkReloadType.NetWorkReload;
|
||||
}
|
||||
|
||||
return OB11NetworkReloadType.Normal;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user