Merge branch 'main' into feature/relogin-enhancement

This commit is contained in:
时瑾 2026-01-17 14:48:40 +08:00
commit de33ab10e5
No known key found for this signature in database
GPG Key ID: 023F70A1B8F8C196
27 changed files with 680 additions and 120 deletions

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -658,5 +658,9 @@
"9.9.26-44498-x64": {
"send": "2CDAE40",
"recv": "2CDE3C0"
},
"9.9.26-44725-x64": {
"send": "2CEBB20",
"recv": "2CEF0A0"
}
}

View File

@ -53,6 +53,8 @@ export class NodeIKernelLoginListener {
onLoginState (..._args: any[]): any {
}
onLoginRecordUpdate (..._args: any[]): any {
}
}
export interface QRCodeLoginSucceedResult {

View File

@ -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 }),

View File

@ -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<NetworkAdapterConfig>) {
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<string, OB11Message>();
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<NetworkAdapterConfig>, ob11Msg: {
stringMsg: OB11Message;
arrayMsg: OB11Message;
}, isSelfMsg: boolean, message: RawMessage): Map<string, OB11Message> {
const msgMap: Map<string, OB11Message> = 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<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, 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<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, 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 {
// 群名片修改事件解析 任何都该判断

View File

@ -23,11 +23,15 @@ export abstract class IOB11NetworkAdapter<CT extends NetworkAdapterConfig> {
this.logger = core.context.logger;
}
abstract onEvent<T extends OB11EmitEventContent>(event: T): Promise<void>;
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;
}
}

View File

@ -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<T extends OB11EmitEventContent>(event: T) {
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) => {

View File

@ -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<HttpServerConfig> {
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<T extends OB11EmitEventContent> (_event: T) {
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
this.wsClientsMutex.runExclusive(async () => {
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 () {
@ -36,11 +64,24 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
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<HttpServerConfig>
}
}
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<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.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<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') {
@ -152,6 +324,7 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
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<HttpServerConfig>
return OB11NetworkReloadType.NetWorkClose;
}
if (oldPort !== newConfig.port) {
if (oldPort !== newConfig.port || oldEnableWebsocket !== newConfig.enableWebsocket) {
this.close();
if (newConfig.enable) {
this.open();

View File

@ -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<string, OB11EmitEventContent>) {
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<CT extends NetworkAdapterConfig>(adapter: IOB11NetworkAdapter<CT>) {
registerAdapter<CT extends NetworkAdapterConfig> (adapter: IOB11NetworkAdapter<CT>) {
this.adapters.set(adapter.name, adapter);
}
async registerAdapterAndOpen<CT extends NetworkAdapterConfig>(adapter: IOB11NetworkAdapter<CT>) {
async registerAdapterAndOpen<CT extends NetworkAdapterConfig> (adapter: IOB11NetworkAdapter<CT>) {
this.registerAdapter(adapter);
await adapter.open();
}
async closeSomeAdapters<CT extends NetworkAdapterConfig>(adaptersToClose: IOB11NetworkAdapter<CT>[]) {
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>[]) {
async closeSomeAdaterWhenOpen<CT extends NetworkAdapterConfig> (adaptersToClose: IOB11NetworkAdapter<CT>[]) {
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<T>(name: string, config: T) {
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>) {
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);
}

View File

@ -33,6 +33,10 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
private readonly pluginPath: string;
private loadedPlugins: Map<string, LoadedPlugin> = 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<PluginConfig> {
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
}
async onEvent<T extends OB11EmitEventContent>(event: T) {
async onEvent<T extends OB11EmitEventContent> (event: T) {
if (!this.isEnable) {
return;
}
@ -359,7 +363,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
// 重新加载插件
const isDirectory = fs.statSync(plugin.pluginPath).isDirectory() &&
plugin.pluginPath !== this.pluginPath;
plugin.pluginPath !== this.pluginPath;
if (isDirectory) {
const dirname = path.basename(plugin.pluginPath);

View File

@ -33,6 +33,10 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
private readonly pluginPath: string;
private loadedPlugins: Map<string, LoadedPlugin> = 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
) {

View File

@ -13,6 +13,10 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
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));

View File

@ -21,6 +21,10 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
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
) {
@ -70,6 +74,9 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
if (EventIndex !== -1) {
this.wsClientWithEvent.splice(EventIndex, 1);
}
if (this.wsClientWithEvent.length === 0) {
this.stopHeartbeat();
}
});
});
await this.wsClientsMutex.runExclusive(async () => {
@ -77,6 +84,9 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
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));
}
@ -114,9 +124,6 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
this.logger.log('[OneBot] [WebSocket Server] Server Started', typeof (addressInfo) === 'string' ? addressInfo : addressInfo?.address + ':' + addressInfo?.port);
this.isEnable = true;
if (this.config.heartInterval > 0) {
this.registerHeartBeat();
}
}
async close () {
@ -128,10 +135,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
this.logger.log('[OneBot] [WebSocket Server] Server Closed');
}
});
if (this.heartbeatIntervalId) {
clearInterval(this.heartbeatIntervalId);
this.heartbeatIntervalId = null;
}
this.stopHeartbeat();
await this.wsClientsMutex.runExclusive(async () => {
this.wsClients.forEach((wsClient) => {
wsClient.close();
@ -141,7 +145,8 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
});
}
private registerHeartBeat () {
private startHeartbeat () {
if (this.heartbeatIntervalId || this.config.heartInterval <= 0) return;
this.heartbeatIntervalId = setInterval(() => {
this.wsClientsMutex.runExclusive(async () => {
this.wsClientWithEvent.forEach((wsClient) => {
@ -153,6 +158,13 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
}, 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}`);
@ -235,12 +247,9 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
}
if (oldHeartbeatInterval !== newConfig.heartInterval) {
if (this.heartbeatIntervalId) {
clearInterval(this.heartbeatIntervalId);
this.heartbeatIntervalId = null;
}
if (newConfig.heartInterval > 0 && this.isEnable) {
this.registerHeartBeat();
this.stopHeartbeat();
if (newConfig.heartInterval > 0 && this.isEnable && this.wsClientWithEvent.length > 0) {
this.startHeartbeat();
}
return OB11NetworkReloadType.NetWorkReload;
}

View File

@ -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 用于后续使用

View File

@ -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<void>((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 使用 taskkillUnix 使用 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);
});
}

View File

@ -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();

View File

@ -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);
}
}

View File

@ -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>): void {
LoginRuntime.onRefreshQRCode = func;
},
async refreshQRCode (): Promise<void> {
// 清除错误信息
LoginRuntime.QQLoginError = '';
await LoginRuntime.onRefreshQRCode();
requestRestartProcess: async function () {
return await LoginRuntime.NapCatHelper.onRestartProcessRequested();
},
};

View File

@ -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 };

View File

@ -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 };

View File

@ -53,6 +53,7 @@ export interface LoginRuntimeType {
NapCatHelper: {
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
onRestartProcessRequested: () => Promise<{ result: boolean; message: string; }>;
QQLoginList: string[];
NewQQLoginList: LoginListItem[];
};

View File

@ -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<void>
data?: OneBotConfig['network']['httpServers'][0];
onClose: () => void;
onSubmit: (data: OneBotConfig['network']['httpServers'][0]) => Promise<void>;
}
type HTTPServerFormType = OneBotConfig['network']['httpServers'];
@ -20,7 +20,7 @@ const HTTPServerForm: React.FC<HTTPServerFormProps> = ({
host: '127.0.0.1',
port: 3000,
enableCors: true,
enableWebsocket: true,
enableWebsocket: false,
messagePostFormat: 'array',
token: random_token(16),
debug: false,

View File

@ -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<void>
) => Promise<void>;
}
type HTTPServerSSEFormType = OneBotConfig['network']['httpSseServers'];
@ -22,7 +22,7 @@ const HTTPServerSSEForm: React.FC<HTTPServerSSEFormProps> = ({
host: '127.0.0.1',
port: 3000,
enableCors: true,
enableWebsocket: true,
enableWebsocket: false,
messagePostFormat: 'array',
token: random_token(16),
debug: false,

View File

@ -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 = <T extends keyof OneBotConfig['network']> (
) => {
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<void>((resolve, reject) => {
dialog.confirm({
title: '安全警告',
content: (
<div>
<p>Token</p>
<p className='text-sm text-gray-500 mt-2'>(Token时Host将被强制限制为 127.0.0.1)</p>
</div>
),
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) => {

View File

@ -0,0 +1,14 @@
import { serverRequest } from '@/utils/request';
export default class ProcessManager {
/**
*
*/
public static async restartProcess () {
const data = await serverRequest.post<ServerResponse<{ message: string; }>>(
'/Process/Restart'
);
return data.data.data;
}
}

View File

@ -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}
/>
<div className='flex-shrink-0 w-full mt-6 pt-6 border-t border-divider'>
<div className='mb-3 text-sm text-default-600'></div>
<Button
color='warning'
variant='flat'
onPress={onRestartProcess}
isLoading={isRestarting}
isDisabled={isRestarting}
fullWidth
>
{isRestarting ? '正在重启进程...' : '重启进程'}
</Button>
<div className='mt-2 text-xs text-default-500'>
Worker 3 5
</div>
</div>
</>
);
};