diff --git a/packages/napcat-core/external/appid.json b/packages/napcat-core/external/appid.json index bf0e1d60..a0d31ba9 100644 --- a/packages/napcat-core/external/appid.json +++ b/packages/napcat-core/external/appid.json @@ -513,7 +513,10 @@ }, "9.9.26-44498": { "appid": 537337416, - "offset": "0x1809C2810", "qua": "V1_WIN_NQ_9.9.26_44498_GW_B" + }, + "9.9.26-44725": { + "appid": 537337569, + "qua": "V1_WIN_NQ_9.9.26_44725_GW_B" } } \ No newline at end of file diff --git a/packages/napcat-core/external/napi2native.json b/packages/napcat-core/external/napi2native.json index 9fe3a696..6349796d 100644 --- a/packages/napcat-core/external/napi2native.json +++ b/packages/napcat-core/external/napi2native.json @@ -87,6 +87,10 @@ "send": "23B0330", "recv": "0957648" }, + "3.2.21-42086-arm64": { + "send": "3D6D98C", + "recv": "14797C8" + }, "3.2.21-42086-x64": { "send": "5B42CF0", "recv": "2FDA6F0" @@ -146,5 +150,9 @@ "9.9.26-44498-x64": { "send": "0A1051C", "recv": "1D3BC0D" + }, + "9.9.26-44725-x64": { + "send": "0A18D0C", + "recv": "1D4BF0D" } } \ No newline at end of file diff --git a/packages/napcat-core/external/packet.json b/packages/napcat-core/external/packet.json index d5c618f9..a409e15b 100644 --- a/packages/napcat-core/external/packet.json +++ b/packages/napcat-core/external/packet.json @@ -658,5 +658,9 @@ "9.9.26-44498-x64": { "send": "2CDAE40", "recv": "2CDE3C0" + }, + "9.9.26-44725-x64": { + "send": "2CEBB20", + "recv": "2CEF0A0" } } \ No newline at end of file diff --git a/packages/napcat-core/listeners/NodeIKernelLoginListener.ts b/packages/napcat-core/listeners/NodeIKernelLoginListener.ts index 20af3732..3dc63be2 100644 --- a/packages/napcat-core/listeners/NodeIKernelLoginListener.ts +++ b/packages/napcat-core/listeners/NodeIKernelLoginListener.ts @@ -53,6 +53,8 @@ export class NodeIKernelLoginListener { onLoginState (..._args: any[]): any { } + onLoginRecordUpdate (..._args: any[]): any { + } } export interface QRCodeLoginSucceedResult { diff --git a/packages/napcat-onebot/config/config.ts b/packages/napcat-onebot/config/config.ts index b43c2c49..189d61ad 100644 --- a/packages/napcat-onebot/config/config.ts +++ b/packages/napcat-onebot/config/config.ts @@ -6,7 +6,7 @@ const HttpServerConfigSchema = Type.Object({ port: Type.Number({ default: 3000 }), host: Type.String({ default: '127.0.0.1' }), enableCors: Type.Boolean({ default: true }), - enableWebsocket: Type.Boolean({ default: true }), + enableWebsocket: Type.Boolean({ default: false }), messagePostFormat: Type.String({ default: 'array' }), token: Type.String({ default: '' }), debug: Type.Boolean({ default: false }), @@ -18,7 +18,7 @@ const HttpSseServerConfigSchema = Type.Object({ port: Type.Number({ default: 3000 }), host: Type.String({ default: '127.0.0.1' }), enableCors: Type.Boolean({ default: true }), - enableWebsocket: Type.Boolean({ default: true }), + enableWebsocket: Type.Boolean({ default: false }), messagePostFormat: Type.String({ default: 'array' }), token: Type.String({ default: '' }), debug: Type.Boolean({ default: false }), diff --git a/packages/napcat-onebot/index.ts b/packages/napcat-onebot/index.ts index 50283285..255c6d6a 100644 --- a/packages/napcat-onebot/index.ts +++ b/packages/napcat-onebot/index.ts @@ -305,6 +305,9 @@ export class NapCatOneBot11Adapter { }; msgListener.onRecvMsg = async (msg) => { + if (!this.networkManager.hasActiveAdapters()) { + return; + } for (const m of msg) { if (this.bootTime > parseInt(m.msgTime)) { this.context.logger.logDebug(`消息时间${m.msgTime}早于启动时间${this.bootTime},忽略上报`); @@ -518,15 +521,14 @@ export class NapCatOneBot11Adapter { } private async emitMsg (message: RawMessage) { - const network = await this.networkManager.getAllConfig(); this.context.logger.logDebug('收到新消息 RawMessage', message); await Promise.allSettled([ - this.handleMsg(message, network), + this.handleMsg(message), message.chatType === ChatType.KCHATTYPEGROUP ? this.handleGroupEvent(message) : this.handlePrivateMsgEvent(message), ]); } - private async handleMsg (message: RawMessage, network: Array) { + private async handleMsg (message: RawMessage) { // 过滤无效消息 if (message.msgType === NTMsgType.KMSGTYPENULL) { return; @@ -536,10 +538,36 @@ export class NapCatOneBot11Adapter { if (ob11Msg) { const isSelfMsg = this.isSelfMessage(ob11Msg); this.context.logger.logDebug('转化为 OB11Message', ob11Msg); - const msgMap = this.createMsgMap(network, ob11Msg, isSelfMsg, message); - this.handleDebugNetwork(network, msgMap, message); - this.handleNotReportSelfNetwork(network, msgMap, isSelfMsg); - this.networkManager.emitEventByNames(msgMap); + if (isSelfMsg || message.chatType !== ChatType.KCHATTYPEGROUP) { + const targetId = parseInt(message.peerUin); + ob11Msg.stringMsg.target_id = targetId; + ob11Msg.arrayMsg.target_id = targetId; + } + + const msgMap = new Map(); + + for (const adapter of this.networkManager.adapters.values()) { + if (!adapter.isActive) continue; + const config = adapter.config; + if (isSelfMsg) { + if (!('reportSelfMessage' in config) || !config.reportSelfMessage) { + continue; + } + } + const msgData = config.messagePostFormat === 'string' ? ob11Msg.stringMsg : ob11Msg.arrayMsg; + if (config.debug) { + const clone = structuredClone(msgData); + clone.raw = message; + msgMap.set(adapter.name, clone); + } else { + msgMap.set(adapter.name, msgData); + } + } + if (msgMap.size > 0) { + this.networkManager.emitEventByNames(msgMap); + } else if (this.networkManager.hasActiveAdapters()) { + this.context.logger.logDebug('没有可用的网络适配器发送消息,消息内容:', message); + } } } catch (e) { this.context.logger.logError('constructMessage error: ', e); @@ -554,48 +582,6 @@ export class NapCatOneBot11Adapter { ob11Msg.arrayMsg.user_id.toString() === this.core.selfInfo.uin; } - private createMsgMap (network: Array, ob11Msg: { - stringMsg: OB11Message; - arrayMsg: OB11Message; - }, isSelfMsg: boolean, message: RawMessage): Map { - const msgMap: Map = new Map(); - network.filter(e => e.enable).forEach(e => { - if (isSelfMsg || message.chatType !== ChatType.KCHATTYPEGROUP) { - ob11Msg.stringMsg.target_id = parseInt(message.peerUin); - ob11Msg.arrayMsg.target_id = parseInt(message.peerUin); - } - if ('messagePostFormat' in e && e.messagePostFormat === 'string') { - msgMap.set(e.name, structuredClone(ob11Msg.stringMsg)); - } else { - msgMap.set(e.name, structuredClone(ob11Msg.arrayMsg)); - } - }); - return msgMap; - } - - private handleDebugNetwork (network: Array, msgMap: Map, message: RawMessage) { - const debugNetwork = network.filter(e => e.enable && e.debug); - if (debugNetwork.length > 0) { - debugNetwork.forEach(adapter => { - const msg = msgMap.get(adapter.name); - if (msg) { - msg.raw = message; - } - }); - } else if (msgMap.size === 0) { - this.context.logger.logDebug('没有可用的网络适配器发送消息,消息内容:', message); - } - } - - private handleNotReportSelfNetwork (network: Array, msgMap: Map, isSelfMsg: boolean) { - if (isSelfMsg) { - const notReportSelfNetwork = network.filter(e => e.enable && (('reportSelfMessage' in e && !e.reportSelfMessage) || !('reportSelfMessage' in e))); - notReportSelfNetwork.forEach(adapter => { - msgMap.delete(adapter.name); - }); - } - } - private async handleGroupEvent (message: RawMessage) { try { // 群名片修改事件解析 任何都该判断 diff --git a/packages/napcat-onebot/network/adapter.ts b/packages/napcat-onebot/network/adapter.ts index 63b90e9f..a4f1c1f8 100644 --- a/packages/napcat-onebot/network/adapter.ts +++ b/packages/napcat-onebot/network/adapter.ts @@ -23,11 +23,15 @@ export abstract class IOB11NetworkAdapter { this.logger = core.context.logger; } - abstract onEvent(event: T): Promise; + abstract onEvent (event: T): Promise; abstract open (): void | Promise; abstract close (): void | Promise; abstract reload (config: unknown): OB11NetworkReloadType | Promise; + + get isActive (): boolean { + return this.isEnable; + } } diff --git a/packages/napcat-onebot/network/http-server-sse.ts b/packages/napcat-onebot/network/http-server-sse.ts index 9ae3ec8c..db5d5aa1 100644 --- a/packages/napcat-onebot/network/http-server-sse.ts +++ b/packages/napcat-onebot/network/http-server-sse.ts @@ -5,6 +5,10 @@ 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); @@ -25,7 +29,8 @@ export class OB11HttpSSEServerAdapter extends OB11HttpServerAdapter { }); } - override async onEvent(event: T) { + override async onEvent (event: T) { + super.onEvent(event); const promises: Promise[] = []; this.sseClients.forEach((res) => { promises.push(new Promise((resolve, reject) => { diff --git a/packages/napcat-onebot/network/http-server.ts b/packages/napcat-onebot/network/http-server.ts index 9a06e924..a2b19260 100644 --- a/packages/napcat-onebot/network/http-server.ts +++ b/packages/napcat-onebot/network/http-server.ts @@ -1,6 +1,6 @@ import { OB11EmitEventContent, OB11NetworkReloadType } from './index'; import express, { Express, NextFunction, Request, Response } from 'express'; -import http from 'http'; +import http, { IncomingMessage } from 'http'; import { OB11Response } from '@/napcat-onebot/action/OneBotAction'; import cors from 'cors'; import { HttpServerConfig } from '@/napcat-onebot/config/config'; @@ -8,13 +8,41 @@ 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 { Mutex } from 'async-mutex'; export class OB11HttpServerAdapter extends IOB11NetworkAdapter { private app: Express | undefined; private server: http.Server | undefined; + private wsServer?: WebSocketServer; + private wsClients: WebSocket[] = []; + private wsClientsMutex = new Mutex(); + private heartbeatIntervalId: NodeJS.Timeout | null = null; + private wsClientWithEvent: WebSocket[] = []; - override async onEvent (_event: T) { + override get isActive (): boolean { + return this.isEnable && this.wsClientWithEvent.length > 0; + } + + override async onEvent (event: T) { // http server is passive, no need to emit event + this.wsClientsMutex.runExclusive(async () => { + const promises = this.wsClientWithEvent.map((wsClient) => { + return new Promise((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 () { @@ -36,11 +64,24 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter this.isEnable = false; this.server?.close(); this.app = undefined; + this.stopHeartbeat(); + await this.wsClientsMutex.runExclusive(async () => { + 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' })); @@ -93,6 +134,137 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter } } + 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('ping', () => { + wsClient.pong(); + }); + wsClient.on('pong', () => { + // this.logger.logDebug('[OneBot] [HTTP WebSocket] Pong received'); + }); + wsClient.once('close', () => { + this.wsClientsMutex.runExclusive(async () => { + 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(); + } + }); + }); + await this.wsClientsMutex.runExclusive(async () => { + 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(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.wsClientsMutex.runExclusive(async () => { + 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 (data: T, wsClient: WebSocket) { + return await new Promise((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(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(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({ ...OB11Response.ok(data, echo ?? '', true) }, wsClient); + }, + }); + await this.checkStateAndReply({ ...retdata }, wsClient); + } + async httpApiRequest (req: Request, res: Response, request_sse: boolean = false) { let payload = req.body; if (req.method === 'get') { @@ -152,6 +324,7 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter async reload (newConfig: HttpServerConfig) { const wasEnabled = this.isEnable; const oldPort = this.config.port; + const oldEnableWebsocket = this.config.enableWebsocket; this.config = newConfig; if (newConfig.enable && !wasEnabled) { @@ -162,7 +335,7 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter return OB11NetworkReloadType.NetWorkClose; } - if (oldPort !== newConfig.port) { + if (oldPort !== newConfig.port || oldEnableWebsocket !== newConfig.enableWebsocket) { this.close(); if (newConfig.enable) { this.open(); diff --git a/packages/napcat-onebot/network/index.ts b/packages/napcat-onebot/network/index.ts index 1eda2dbb..80a49c3d 100644 --- a/packages/napcat-onebot/network/index.ts +++ b/packages/napcat-onebot/network/index.ts @@ -21,7 +21,7 @@ export class OB11NetworkManager { async emitEvent (event: OB11EmitEventContent) { return Promise.all(Array.from(this.adapters.values()).map(async adapter => { - if (adapter.isEnable) { + if (adapter.isActive) { return await adapter.onEvent(event); } })); @@ -34,7 +34,7 @@ export class OB11NetworkManager { async emitEventByName (names: string[], event: OB11EmitEventContent) { return Promise.all(names.map(async name => { const adapter = this.adapters.get(name); - if (adapter && adapter.isEnable) { + if (adapter && adapter.isActive) { return await adapter.onEvent(event); } })); @@ -43,29 +43,29 @@ export class OB11NetworkManager { async emitEventByNames (map: Map) { return Promise.all(Array.from(map.entries()).map(async ([name, event]) => { const adapter = this.adapters.get(name); - if (adapter && adapter.isEnable) { + if (adapter && adapter.isActive) { return await adapter.onEvent(event); } })); } - registerAdapter(adapter: IOB11NetworkAdapter) { + registerAdapter (adapter: IOB11NetworkAdapter) { this.adapters.set(adapter.name, adapter); } - async registerAdapterAndOpen(adapter: IOB11NetworkAdapter) { + async registerAdapterAndOpen (adapter: IOB11NetworkAdapter) { this.registerAdapter(adapter); await adapter.open(); } - async closeSomeAdapters(adaptersToClose: IOB11NetworkAdapter[]) { + async closeSomeAdapters (adaptersToClose: IOB11NetworkAdapter[]) { for (const adapter of adaptersToClose) { this.adapters.delete(adapter.name); await adapter.close(); } } - async closeSomeAdaterWhenOpen(adaptersToClose: IOB11NetworkAdapter[]) { + async closeSomeAdaterWhenOpen (adaptersToClose: IOB11NetworkAdapter[]) { for (const adapter of adaptersToClose) { this.adapters.delete(adapter.name); if (adapter.isEnable) { @@ -88,17 +88,21 @@ export class OB11NetworkManager { this.adapters.clear(); } - async readloadAdapter(name: string, config: T) { + async readloadAdapter (name: string, config: T) { const adapter = this.adapters.get(name); if (adapter) { await adapter.reload(config); } } - async readloadSomeAdapters(configMap: Map) { + async readloadSomeAdapters (configMap: Map) { 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); } diff --git a/packages/napcat-onebot/network/plugin-manger.ts b/packages/napcat-onebot/network/plugin-manger.ts index 9334854b..56c096ed 100644 --- a/packages/napcat-onebot/network/plugin-manger.ts +++ b/packages/napcat-onebot/network/plugin-manger.ts @@ -33,6 +33,10 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { private readonly pluginPath: string; private loadedPlugins: Map = new Map(); declare config: PluginConfig; + override get isActive (): boolean { + return this.isEnable && this.loadedPlugins.size > 0; + } + constructor ( name: string, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap ) { @@ -251,7 +255,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`); } - async onEvent(event: T) { + async onEvent (event: T) { if (!this.isEnable) { return; } @@ -359,7 +363,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { // 重新加载插件 const isDirectory = fs.statSync(plugin.pluginPath).isDirectory() && - plugin.pluginPath !== this.pluginPath; + plugin.pluginPath !== this.pluginPath; if (isDirectory) { const dirname = path.basename(plugin.pluginPath); diff --git a/packages/napcat-onebot/network/plugin.ts b/packages/napcat-onebot/network/plugin.ts index bf5c842f..f3508510 100644 --- a/packages/napcat-onebot/network/plugin.ts +++ b/packages/napcat-onebot/network/plugin.ts @@ -33,6 +33,10 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter { private readonly pluginPath: string; private loadedPlugins: Map = new Map(); declare config: PluginConfig; + override get isActive (): boolean { + return this.isEnable && this.loadedPlugins.size > 0; + } + constructor ( name: string, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap ) { diff --git a/packages/napcat-onebot/network/websocket-client.ts b/packages/napcat-onebot/network/websocket-client.ts index 67a014aa..b40e668f 100644 --- a/packages/napcat-onebot/network/websocket-client.ts +++ b/packages/napcat-onebot/network/websocket-client.ts @@ -13,6 +13,10 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter (event: T) { if (this.connection && this.connection.readyState === WebSocket.OPEN) { this.connection.send(JSON.stringify(event)); diff --git a/packages/napcat-onebot/network/websocket-server.ts b/packages/napcat-onebot/network/websocket-server.ts index 18f9cc49..d5a9affe 100644 --- a/packages/napcat-onebot/network/websocket-server.ts +++ b/packages/napcat-onebot/network/websocket-server.ts @@ -21,6 +21,10 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter 0; + } + constructor ( name: string, config: WebsocketServerConfig, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap ) { @@ -70,6 +74,9 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter { @@ -77,6 +84,9 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter 0) { + this.startHeartbeat(); + } }); }).on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Server Error:', err.message)); } @@ -114,9 +124,6 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter 0) { - this.registerHeartBeat(); - } } async close () { @@ -128,10 +135,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter { this.wsClients.forEach((wsClient) => { wsClient.close(); @@ -141,7 +145,8 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter { this.wsClientsMutex.runExclusive(async () => { this.wsClientWithEvent.forEach((wsClient) => { @@ -153,6 +158,13 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter 0 && this.isEnable) { - this.registerHeartBeat(); + this.stopHeartbeat(); + if (newConfig.heartInterval > 0 && this.isEnable && this.wsClientWithEvent.length > 0) { + this.startHeartbeat(); } return OB11NetworkReloadType.NetWorkReload; } diff --git a/packages/napcat-shell/base.ts b/packages/napcat-shell/base.ts index 069ff4d7..95456b22 100644 --- a/packages/napcat-shell/base.ts +++ b/packages/napcat-shell/base.ts @@ -343,9 +343,9 @@ export async function NCoreInitShell () { // 初始化 FFmpeg 服务 await FFmpegService.init(pathWrapper.binaryPath, logger); - if (process.env['NAPCAT_DISABLE_PIPE'] !== '1') { - await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e)); - } + // if (process.env['NAPCAT_DISABLE_PIPE'] !== '1') { + // await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e)); + // } const basicInfoWrapper = new QQBasicInfoWrapper({ logger }); const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion()); const nativePacketHandler = new NativePacketHandler({ logger }); // 初始化 NativePacketHandler 用于后续使用 diff --git a/packages/napcat-shell/napcat.ts b/packages/napcat-shell/napcat.ts index c65d65d6..5a759926 100644 --- a/packages/napcat-shell/napcat.ts +++ b/packages/napcat-shell/napcat.ts @@ -1,2 +1,240 @@ import { NCoreInitShell } from './base'; -NCoreInitShell(); +import { NapCatPathWrapper } from '@/napcat-common/src/path'; +import { LogWrapper } from '@/napcat-core/helper/log'; +import { connectToNamedPipe } from './pipe'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// ES 模块中获取 __dirname +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// 扩展 Process 类型以支持 parentPort +declare global { + namespace NodeJS { + interface Process { + parentPort?: { + on (event: 'message', listener: (e: { data: any; }) => void): void; + postMessage (message: any): void; + }; + } + } +} + +// 判断是否为子进程(通过环境变量) +const isWorkerProcess = process.env['NAPCAT_WORKER_PROCESS'] === '1'; + +// 只在主进程中导入 utilityProcess +let utilityProcess: any; +if (!isWorkerProcess) { + // @ts-ignore - electron 运行时存在但类型声明可能缺失 + const electron = await import('electron'); + utilityProcess = electron.utilityProcess; +} + +const pathWrapper = new NapCatPathWrapper(); +const logger = new LogWrapper(pathWrapper.logsPath); + +// 存储当前的 worker 进程引用 +let currentWorker: any = null; + +// 重启 worker 进程的函数 +export async function restartWorker () { + logger.log('[NapCat] [UtilityProcess] 正在重启Worker进程...'); + + if (currentWorker) { + const workerPid = currentWorker.pid; + logger.log(`[NapCat] [UtilityProcess] 准备关闭Worker进程,PID: ${workerPid}`); + + // 发送关闭信号 + currentWorker.postMessage({ type: 'shutdown' }); + + // 等待进程退出,最多等待 3 秒 + await new Promise((resolve) => { + const timeout = setTimeout(() => { + logger.logWarn('[NapCat] [UtilityProcess] Worker进程未在 3 秒内退出,尝试强制终止'); + currentWorker.kill(); + resolve(); + }, 3000); + + currentWorker.once('exit', () => { + clearTimeout(timeout); + logger.log('[NapCat] [UtilityProcess] Worker进程已正常退出'); + resolve(); + }); + }); + + // 检查进程是否真的被杀掉了 + if (workerPid) { + logger.log(`[NapCat] [UtilityProcess] 检查进程 ${workerPid} 是否已终止...`); + try { + // 尝试发送信号 0 来检查进程是否存在 + process.kill(workerPid, 0); + // 如果没有抛出异常,说明进程还在运行 + logger.logWarn(`[NapCat] [UtilityProcess] 进程 ${workerPid} 仍在运行,强制杀掉`); + try { + // Windows 使用 taskkill,Unix 使用 SIGKILL + if (process.platform === 'win32') { + const { execSync } = await import('child_process'); + execSync(`taskkill /F /PID ${workerPid} /T`, { stdio: 'ignore' }); + } else { + process.kill(workerPid, 'SIGKILL'); + } + logger.log(`[NapCat] [UtilityProcess] 已强制终止进程 ${workerPid}`); + } catch (killError) { + logger.logError(`[NapCat] [UtilityProcess] 强制终止进程失败:`, killError); + } + } catch (e) { + // 抛出异常说明进程不存在,已经被成功杀掉 + logger.log(`[NapCat] [UtilityProcess] 进程 ${workerPid} 已确认终止`); + } + } + + // 进程结束后等待 3 秒再启动新进程 + logger.log('[NapCat] [UtilityProcess] Worker进程已关闭,等待 3 秒后启动新进程...'); + await new Promise(resolve => setTimeout(resolve, 3000)); + } + + // 启动新的 worker 进程 + await startWorker(); + logger.log('[NapCat] [UtilityProcess] Worker进程重启完成'); +} + +async function startWorker () { + // 创建 utility 进程 + // 根据实际构建产物确定文件扩展名 + const workerScript = __filename.endsWith('.mjs') + ? path.join(__dirname, 'napcat.mjs') + : path.join(__dirname, 'napcat.js'); + + const child = utilityProcess.fork(workerScript, [], { + env: { + ...process.env, + NAPCAT_WORKER_PROCESS: '1', + }, + stdio: 'pipe', + }); + + currentWorker = child; + logger.log('[NapCat] [UtilityProcess] 已创建Worker进程,PID:', child.pid); + + // 监听子进程标准输出 - 直接原始输出 + if (child.stdout) { + child.stdout.on('data', (data: Buffer) => { + process.stdout.write(data); + }); + } + + // 监听子进程标准错误 - 直接原始输出 + if (child.stderr) { + child.stderr.on('data', (data: Buffer) => { + process.stderr.write(data); + }); + } + + // 监听子进程消息 + child.on('message', (msg: any) => { + logger.log('[NapCat] [UtilityProcess] 收到Worker消息:', msg); + + // 处理重启请求 + if (msg?.type === 'restart') { + logger.log('[NapCat] [UtilityProcess] 收到重启请求,正在重启Worker进程...'); + restartWorker().catch(e => { + logger.logError('[NapCat] [UtilityProcess] 重启Worker进程失败:', e); + }); + } + }); + + // 监听子进程退出 + child.on('exit', (code: number) => { + if (code !== 0) { + logger.logError(`[NapCat] [UtilityProcess] Worker进程退出,退出码: ${code}`); + } else { + logger.log('[NapCat] [UtilityProcess] Worker进程正常退出'); + } + + // 可选:自动重启工作进程 + // logger.log('[NapCat] [UtilityProcess] 正在重启Worker进程...'); + // setTimeout(() => restartWorker(), 1000); + }); + + // 监听子进程生成 + child.on('spawn', () => { + logger.log('[NapCat] [UtilityProcess] Worker进程已生成'); + }); +} + +async function startMasterProcess () { + logger.log('[NapCat] [UtilityProcess] Master进程启动,PID:', process.pid); + + // 连接命名管道,用于输出子进程内容 + await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e)); + + // 启动 worker 进程 + await startWorker(); + + // 优雅关闭处理 + const shutdown = (signal: string) => { + logger.log(`[NapCat] [UtilityProcess] 收到${signal}信号,正在关闭...`); + if (currentWorker) { + currentWorker.postMessage({ type: 'shutdown' }); + setTimeout(() => { + currentWorker.kill(); + process.exit(0); + }, 1000); + } else { + process.exit(0); + } + }; + + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); +} + +async function startWorkerProcess () { + logger.log('[NapCat] [UtilityProcess] Worker进程启动,PID:', process.pid); + + // 监听来自父进程的消息 + process.parentPort?.on('message', (e: { data: any; }) => { + const msg = e.data; + if (msg?.type === 'shutdown') { + logger.log('[NapCat] [UtilityProcess] 收到关闭信号,正在退出...'); + process.exit(0); + } + }); + + // 注册重启进程函数到 WebUI(在 Worker 进程中) + const { WebUiDataRuntime } = await import('@/napcat-webui-backend/src/helper/Data'); + WebUiDataRuntime.setRestartProcessCall(async () => { + try { + // 向父进程发送重启请求 + if (process.parentPort) { + process.parentPort.postMessage({ type: 'restart' }); + return { result: true, message: '进程重启请求已发送' }; + } else { + return { result: false, message: '无法与主进程通信' }; + } + } catch (e) { + logger.logError('[NapCat] [UtilityProcess] 发送重启请求失败:', e); + return { result: false, message: '发送重启请求失败: ' + (e as Error).message }; + } + }); + + // 在子进程中启动NapCat核心 + await NCoreInitShell(); +} + +// 主入口 +if (isWorkerProcess) { + // Worker进程 + startWorkerProcess().catch((e: Error) => { + logger.logError('[NapCat] [UtilityProcess] Worker进程启动失败:', e); + process.exit(1); + }); +} else { + // Master进程 + startMasterProcess().catch((e: Error) => { + logger.logError('[NapCat] [UtilityProcess] Master进程启动失败:', e); + process.exit(1); + }); +} diff --git a/packages/napcat-shell/vite.config.ts b/packages/napcat-shell/vite.config.ts index e97bc597..e46f6b0e 100644 --- a/packages/napcat-shell/vite.config.ts +++ b/packages/napcat-shell/vite.config.ts @@ -11,6 +11,7 @@ import react from '@vitejs/plugin-react-swc'; const external = [ 'ws', 'express', + 'electron' ]; const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat(); diff --git a/packages/napcat-webui-backend/src/api/Process.ts b/packages/napcat-webui-backend/src/api/Process.ts new file mode 100644 index 00000000..c0128164 --- /dev/null +++ b/packages/napcat-webui-backend/src/api/Process.ts @@ -0,0 +1,21 @@ +import type { Request, Response } from 'express'; +import { WebUiDataRuntime } from '../helper/Data'; +import { sendError, sendSuccess } from '../utils/response'; + +/** + * 重启进程处理器 + * POST /api/Process/Restart + */ +export async function RestartProcessHandler (_req: Request, res: Response) { + try { + const result = await WebUiDataRuntime.requestRestartProcess(); + + if (result.result) { + return sendSuccess(res, { message: result.message || '进程重启请求已发送' }); + } else { + return sendError(res, result.message || '进程重启失败', 500); + } + } catch (e) { + return sendError(res, '重启进程时发生错误: ' + (e as Error).message, 500); + } +} diff --git a/packages/napcat-webui-backend/src/helper/Data.ts b/packages/napcat-webui-backend/src/helper/Data.ts index 438d59b0..d5d8c09c 100644 --- a/packages/napcat-webui-backend/src/helper/Data.ts +++ b/packages/napcat-webui-backend/src/helper/Data.ts @@ -33,6 +33,9 @@ const LoginRuntime: LoginRuntimeType = { onQuickLoginRequested: async () => { return { result: false, message: '' }; }, + onRestartProcessRequested: async () => { + return { result: false, message: '重启功能未初始化' }; + }, QQLoginList: [], NewQQLoginList: [], }, @@ -168,21 +171,11 @@ export const WebUiDataRuntime = { return LoginRuntime.OneBotContext; }, - setQQLoginError (error: string): void { - LoginRuntime.QQLoginError = error; + setRestartProcessCall (func: () => Promise<{ result: boolean; message: string; }>): void { + LoginRuntime.NapCatHelper.onRestartProcessRequested = func; }, - getQQLoginError (): string { - return LoginRuntime.QQLoginError; - }, - - setRefreshQRCodeCallback (func: () => Promise): void { - LoginRuntime.onRefreshQRCode = func; - }, - - async refreshQRCode (): Promise { - // 清除错误信息 - LoginRuntime.QQLoginError = ''; - await LoginRuntime.onRefreshQRCode(); + requestRestartProcess: async function () { + return await LoginRuntime.NapCatHelper.onRestartProcessRequested(); }, }; diff --git a/packages/napcat-webui-backend/src/router/Process.ts b/packages/napcat-webui-backend/src/router/Process.ts new file mode 100644 index 00000000..7931a5c7 --- /dev/null +++ b/packages/napcat-webui-backend/src/router/Process.ts @@ -0,0 +1,9 @@ +import { Router } from 'express'; +import { RestartProcessHandler } from '../api/Process'; + +const router = Router(); + +// POST /api/Process/Restart - 重启进程 +router.post('/Restart', RestartProcessHandler); + +export { router as ProcessRouter }; diff --git a/packages/napcat-webui-backend/src/router/index.ts b/packages/napcat-webui-backend/src/router/index.ts index 768f3e61..31b7cbad 100644 --- a/packages/napcat-webui-backend/src/router/index.ts +++ b/packages/napcat-webui-backend/src/router/index.ts @@ -16,6 +16,7 @@ import { FileRouter } from './File'; import { WebUIConfigRouter } from './WebUIConfig'; import { UpdateNapCatRouter } from './UpdateNapCat'; import DebugRouter from '@/napcat-webui-backend/src/api/Debug'; +import { ProcessRouter } from './Process'; const router = Router(); @@ -44,5 +45,7 @@ router.use('/WebUIConfig', WebUIConfigRouter); router.use('/UpdateNapCat', UpdateNapCatRouter); // router:调试相关路由 router.use('/Debug', DebugRouter); +// router:进程管理相关路由 +router.use('/Process', ProcessRouter); export { router as ALLRouter }; diff --git a/packages/napcat-webui-backend/src/types/index.ts b/packages/napcat-webui-backend/src/types/index.ts index 5ac0ea39..6008dcd3 100644 --- a/packages/napcat-webui-backend/src/types/index.ts +++ b/packages/napcat-webui-backend/src/types/index.ts @@ -53,6 +53,7 @@ export interface LoginRuntimeType { NapCatHelper: { onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>; onOB11ConfigChanged: (ob11: OneBotConfig) => Promise; + onRestartProcessRequested: () => Promise<{ result: boolean; message: string; }>; QQLoginList: string[]; NewQQLoginList: LoginListItem[]; }; diff --git a/packages/napcat-webui-frontend/src/components/network_edit/http_server.tsx b/packages/napcat-webui-frontend/src/components/network_edit/http_server.tsx index 7fb74883..3df93844 100644 --- a/packages/napcat-webui-frontend/src/components/network_edit/http_server.tsx +++ b/packages/napcat-webui-frontend/src/components/network_edit/http_server.tsx @@ -2,9 +2,9 @@ import GenericForm, { random_token } from './generic_form'; import type { Field } from './generic_form'; export interface HTTPServerFormProps { - data?: OneBotConfig['network']['httpServers'][0] - onClose: () => void - onSubmit: (data: OneBotConfig['network']['httpServers'][0]) => Promise + data?: OneBotConfig['network']['httpServers'][0]; + onClose: () => void; + onSubmit: (data: OneBotConfig['network']['httpServers'][0]) => Promise; } type HTTPServerFormType = OneBotConfig['network']['httpServers']; @@ -20,7 +20,7 @@ const HTTPServerForm: React.FC = ({ host: '127.0.0.1', port: 3000, enableCors: true, - enableWebsocket: true, + enableWebsocket: false, messagePostFormat: 'array', token: random_token(16), debug: false, diff --git a/packages/napcat-webui-frontend/src/components/network_edit/http_sse.tsx b/packages/napcat-webui-frontend/src/components/network_edit/http_sse.tsx index 06dbbf09..18f23cc3 100644 --- a/packages/napcat-webui-frontend/src/components/network_edit/http_sse.tsx +++ b/packages/napcat-webui-frontend/src/components/network_edit/http_sse.tsx @@ -2,11 +2,11 @@ import GenericForm, { random_token } from './generic_form'; import type { Field } from './generic_form'; export interface HTTPServerSSEFormProps { - data?: OneBotConfig['network']['httpSseServers'][0] - onClose: () => void + data?: OneBotConfig['network']['httpSseServers'][0]; + onClose: () => void; onSubmit: ( data: OneBotConfig['network']['httpSseServers'][0] - ) => Promise + ) => Promise; } type HTTPServerSSEFormType = OneBotConfig['network']['httpSseServers']; @@ -22,7 +22,7 @@ const HTTPServerSSEForm: React.FC = ({ host: '127.0.0.1', port: 3000, enableCors: true, - enableWebsocket: true, + enableWebsocket: false, messagePostFormat: 'array', token: random_token(16), debug: false, diff --git a/packages/napcat-webui-frontend/src/components/network_edit/modal.tsx b/packages/napcat-webui-frontend/src/components/network_edit/modal.tsx index dab5b78e..409c89f5 100644 --- a/packages/napcat-webui-frontend/src/components/network_edit/modal.tsx +++ b/packages/napcat-webui-frontend/src/components/network_edit/modal.tsx @@ -2,6 +2,7 @@ import { Modal, ModalContent, ModalHeader } from '@heroui/modal'; import toast from 'react-hot-toast'; import useConfig from '@/hooks/use-config'; +import useDialog from '@/hooks/use-dialog'; import HTTPClientForm from './http_client'; import HTTPServerForm from './http_server'; @@ -31,23 +32,57 @@ const NetworkFormModal = ( ) => { const { isOpen, onOpenChange, field, data } = props; const { createNetworkConfig, updateNetworkConfig } = useConfig(); + const dialog = useDialog(); const isCreate = !data; const onSubmit = async (data: OneBotConfig['network'][typeof field][0]) => { - try { - if (isCreate) { - await createNetworkConfig(field, data); - } else { - await updateNetworkConfig(field, data); + const saveData = async (dataToSave: OneBotConfig['network'][typeof field][0]) => { + try { + if (isCreate) { + await createNetworkConfig(field, dataToSave); + } else { + await updateNetworkConfig(field, dataToSave); + } + toast.success('保存配置成功'); + } catch (error) { + const msg = (error as Error).message; + + toast.error(`保存配置失败: ${msg}`); + + throw error; } - toast.success('保存配置成功'); - } catch (error) { - const msg = (error as Error).message; + }; - toast.error(`保存配置失败: ${msg}`); - - throw error; + if (['httpServers', 'httpSseServers', 'websocketServers'].includes(field)) { + const serverData = data as any; + if (!serverData.token) { + await new Promise((resolve, reject) => { + dialog.confirm({ + title: '安全警告', + content: ( +
+

检测到未配置Token,这可能导致安全风险。确认要继续吗?

+

(未配置Token时,Host将被强制限制为 127.0.0.1)

+
+ ), + onConfirm: async () => { + serverData.host = '127.0.0.1'; + try { + await saveData(serverData); + resolve(); + } catch (e) { + reject(e); + } + }, + onCancel: () => { + reject(new Error('Cancelled')); + }, + }); + }); + return; + } } + await saveData(data); }; const renderFormComponent = (onClose: () => void) => { diff --git a/packages/napcat-webui-frontend/src/controllers/process_manager.ts b/packages/napcat-webui-frontend/src/controllers/process_manager.ts new file mode 100644 index 00000000..a451a425 --- /dev/null +++ b/packages/napcat-webui-frontend/src/controllers/process_manager.ts @@ -0,0 +1,14 @@ +import { serverRequest } from '@/utils/request'; + +export default class ProcessManager { + /** + * 重启进程 + */ + public static async restartProcess () { + const data = await serverRequest.post>( + '/Process/Restart' + ); + + return data.data.data; + } +} diff --git a/packages/napcat-webui-frontend/src/pages/dashboard/config/login.tsx b/packages/napcat-webui-frontend/src/pages/dashboard/config/login.tsx index e0fa6303..c9f7b49a 100644 --- a/packages/napcat-webui-frontend/src/pages/dashboard/config/login.tsx +++ b/packages/napcat-webui-frontend/src/pages/dashboard/config/login.tsx @@ -1,6 +1,7 @@ import { Input } from '@heroui/input'; +import { Button } from '@heroui/button'; import { useRequest } from 'ahooks'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import toast from 'react-hot-toast'; @@ -8,8 +9,10 @@ import SaveButtons from '@/components/button/save_buttons'; import PageLoading from '@/components/page_loading'; import QQManager from '@/controllers/qq_manager'; +import ProcessManager from '@/controllers/process_manager'; const LoginConfigCard = () => { + const [isRestarting, setIsRestarting] = useState(false); const { data: quickLoginData, loading: quickLoginLoading, @@ -53,6 +56,22 @@ const LoginConfigCard = () => { } }; + const onRestartProcess = async () => { + setIsRestarting(true); + try { + const result = await ProcessManager.restartProcess(); + toast.success(result.message || '进程重启成功'); + // 等待 5 秒后刷新页面 + setTimeout(() => { + window.location.reload(); + }, 5000); + } catch (error) { + const msg = (error as Error).message; + toast.error(`进程重启失败: ${msg}`); + setIsRestarting(false); + } + }; + useEffect(() => { reset(); }, [quickLoginData]); @@ -82,6 +101,22 @@ const LoginConfigCard = () => { isSubmitting={isSubmitting || quickLoginLoading} refresh={onRefresh} /> +
+
进程管理
+ +
+ 重启进程将关闭当前 Worker 进程,等待 3 秒后启动新进程,页面将在 5 秒后自动刷新 +
+
); };