From 9a06abbbbd33254bd3195325783bfc55a9e448e8 Mon Sep 17 00:00:00 2001 From: Shxiao Date: Mon, 5 Oct 2026 13:06:22 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=9D=99=E9=BB=98=E7=A6=BB=E7=BA=BF?= =?UTF-8?q?=EF=BC=88=E6=97=A0=20KickedOffLine=20=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=EF=BC=89=E5=90=8E=E8=87=AA=E5=8A=A8=E6=81=A2=E5=A4=8D=E7=99=BB?= =?UTF-8?q?=E5=BD=95=20(#2080)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务端静默断开会话时不会下发 KickedOffLine,onSelfStatusChanged 仅把 selfInfo.online 置为 false 并打一行日志,没有任何消费者:进程与 WS 连接 继续存活,下游 OneBot 客户端长时间收不到任何事件。 - core 在 在线 -> 离线 的跳变上广播新的 SelfOffline 事件(重复的 status=20 不重复触发,未登录时不触发) - framework/shell 复用 #2030 已建立的下线恢复链路:同步 WebUI -> 重启 Worker -> 快速登录 - 静默离线可能是临时状态,重启前重新确认仍未恢复,避免误重启 Worker Closes #2071 --- packages/napcat-core/index.ts | 6 + .../napcat-core/packet/handler/eventList.ts | 2 + packages/napcat-framework/napcat.ts | 25 +++- packages/napcat-shell/base.ts | 24 +++- .../napcat-test/offlineStatusEvent.test.ts | 123 ++++++++++++++++++ 5 files changed, 170 insertions(+), 10 deletions(-) create mode 100644 packages/napcat-test/offlineStatusEvent.test.ts diff --git a/packages/napcat-core/index.ts b/packages/napcat-core/index.ts index ee3128ec..fef9718f 100644 --- a/packages/napcat-core/index.ts +++ b/packages/napcat-core/index.ts @@ -261,8 +261,14 @@ export class NapCatCore { }; profileListener.onSelfStatusChanged = (Info: SelfStatusInfo) => { if (Info.status === 20) { + // 静默离线:服务端判定会话失效,但不会下发 KickedOffLine 通知。 + // 仅在 在线 -> 离线 的跳变上广播一次,供上层复用下线恢复链路。 + const wasOnline = this.selfInfo.online === true; this.selfInfo.online = false; this.context.logger.log('账号状态变更为离线'); + if (wasOnline) { + this.event.emit('SelfOffline', '[SelfOffline] 账号状态变更为离线(未收到 KickedOffLine 通知)'); + } } else { this.selfInfo.online = true; } diff --git a/packages/napcat-core/packet/handler/eventList.ts b/packages/napcat-core/packet/handler/eventList.ts index b43c3db7..e152b9e6 100644 --- a/packages/napcat-core/packet/handler/eventList.ts +++ b/packages/napcat-core/packet/handler/eventList.ts @@ -3,5 +3,7 @@ import { TypedEventEmitter } from './typeEvent'; export interface AppEvents { 'event:emoji_like': { groupId: string; senderUin: string; emojiId: string, msgSeq: string, isAdd: boolean, count: number; }; KickedOffLine: string; + // 静默离线:服务端会话已失效,但没有下发 KickedOffLine 通知 + SelfOffline: string; } export const appEvent = new TypedEventEmitter(); diff --git a/packages/napcat-framework/napcat.ts b/packages/napcat-framework/napcat.ts index 1a7d11c9..f4faa175 100644 --- a/packages/napcat-framework/napcat.ts +++ b/packages/napcat-framework/napcat.ts @@ -213,18 +213,33 @@ export async function NCoreInitFramework ( } } - // 监听下线通知并同步到 WebUI(兜底保障,防止 WebUI 状态不同步) + // 监听下线并同步到 WebUI(兜底保障,防止 WebUI 状态不同步) + // KickedOffLine:账号被踢下线;SelfOffline:静默离线(服务端会话语义失效但无通知)。 + // 两者复用同一条恢复链路:同步 WebUI -> 重启 Worker -> 快速登录。 let restartRequested = false; - loaderObject.core.event.on('KickedOffLine', (tips: string) => { + const handleOffline = (tips: string, allowSelfRecovery: boolean) => { WebUiDataRuntime.setQQLoginStatus(false); + // 下线后的二维码已失效,绝不能继续交给页面展示或扫码。 WebUiDataRuntime.setQQLoginQrcodeURL(''); WebUiDataRuntime.setQQLoginError(tips); WebUiDataRuntime.setQQLoginPhase('reconnecting'); + if (restartRequested) return; restartRequested = true; + // 不从 RefreshQRcode 请求中重启 Worker;否则请求会在返回前被中断, + // 浏览器只能得到 ERR_EMPTY_RESPONSE。下线是独立事件, + // 在此异步调度可让前端先观察到“二维码已清空、正在恢复”的状态。 setTimeout(() => { + if (allowSelfRecovery && loaderObject.core.selfInfo.online === true) { + // 静默离线可能只是临时状态且已自行恢复,此时不应重启 Worker。 + restartRequested = false; + WebUiDataRuntime.setQQLoginStatus(true); + WebUiDataRuntime.setQQLoginError(''); + WebUiDataRuntime.setQQLoginPhase('ready'); + return; + } WebUiDataRuntime.requestRestartProcess().then((restartResult) => { - logger.logWarn('[NapCat] [Framework] 账号被踢下线,正在重启 Worker 以重新创建 QQ 登录服务', restartResult); + logger.logWarn('[NapCat] [Framework] 账号已离线,正在重启 Worker 以重新创建 QQ 登录服务', restartResult); if (!restartResult.result) { restartRequested = false; WebUiDataRuntime.setQQLoginPhase('offline'); @@ -236,7 +251,9 @@ export async function NCoreInitFramework ( WebUiDataRuntime.setQQLoginError(`${tips}\n登录服务重启失败:${(error as Error).message}`); }); }, 3_500); - }); + }; + loaderObject.core.event.on('KickedOffLine', (tips: string) => handleOffline(tips, false)); + loaderObject.core.event.on('SelfOffline', (tips: string) => handleOffline(tips, true)); } function registerWebUiLoginCallbacks ( diff --git a/packages/napcat-shell/base.ts b/packages/napcat-shell/base.ts index b125b907..bbabd082 100644 --- a/packages/napcat-shell/base.ts +++ b/packages/napcat-shell/base.ts @@ -839,11 +839,13 @@ export class NapCatShell { // } // } - // 监听下线通知并同步到 WebUI + // 监听下线并同步到 WebUI + // KickedOffLine:账号被踢下线;SelfOffline:静默离线(服务端会话语义失效但无通知)。 + // 两者复用同一条恢复链路:同步 WebUI -> 重启 Worker -> 快速登录。 let restartRequested = false; - this.core.event.on('KickedOffLine', (tips: string) => { + const handleOffline = (tips: string, allowSelfRecovery: boolean) => { WebUiDataRuntime.setQQLoginStatus(false); - // 被踢后的二维码已失效,绝不能继续交给页面展示或扫码。 + // 下线后的二维码已失效,绝不能继续交给页面展示或扫码。 WebUiDataRuntime.setQQLoginQrcodeURL(''); WebUiDataRuntime.setQQLoginError(tips); WebUiDataRuntime.setQQLoginPhase('reconnecting'); @@ -851,11 +853,19 @@ export class NapCatShell { if (restartRequested) return; restartRequested = true; // 不从 RefreshQRcode 请求中重启 Worker;否则请求会在返回前被中断, - // 浏览器只能得到 ERR_EMPTY_RESPONSE。KickedOffLine 是独立事件, + // 浏览器只能得到 ERR_EMPTY_RESPONSE。下线是独立事件, // 在此异步调度可让前端先观察到“二维码已清空、正在恢复”的状态。 setTimeout(() => { + if (allowSelfRecovery && this.core.selfInfo.online === true) { + // 静默离线可能只是临时状态且已自行恢复,此时不应重启 Worker。 + restartRequested = false; + WebUiDataRuntime.setQQLoginStatus(true); + WebUiDataRuntime.setQQLoginError(''); + WebUiDataRuntime.setQQLoginPhase('ready'); + return; + } WebUiDataRuntime.requestRestartProcess().then((restartResult) => { - this.context.logger.logWarn('[Core] [Login] 账号被踢下线,正在重启 Worker 以重新创建 QQ 登录服务', restartResult); + this.context.logger.logWarn('[Core] [Login] 账号已离线,正在重启 Worker 以重新创建 QQ 登录服务', restartResult); if (!restartResult.result) { restartRequested = false; WebUiDataRuntime.setQQLoginPhase('offline'); @@ -868,7 +878,9 @@ export class NapCatShell { }); // 常规状态轮询为 3 秒;留出一个轮询周期,确保浏览器先清除旧二维码。 }, 3_500); - }); + }; + this.core.event.on('KickedOffLine', (tips: string) => handleOffline(tips, false)); + this.core.event.on('SelfOffline', (tips: string) => handleOffline(tips, true)); // 使用 NapCatAdapterManager 统一管理协议适配器 const adapterManager = new NapCatAdapterManager(this.core, this.context, this.context.pathWrapper); await adapterManager.initAdapters() diff --git a/packages/napcat-test/offlineStatusEvent.test.ts b/packages/napcat-test/offlineStatusEvent.test.ts new file mode 100644 index 00000000..1187089c --- /dev/null +++ b/packages/napcat-test/offlineStatusEvent.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { NapCatCore } from '../napcat-core/index'; +import type { SelfStatusInfo, SelfInfo } from '../napcat-core/types'; + +interface FakeProfileListener { + onSelfStatusChanged: (info: SelfStatusInfo) => void; +} + +interface FakeMessageListener { + onKickedOffLine: (info: { tipsTitle: string; tipsDesc: string; }) => void; +} + +function status (value: number): SelfStatusInfo { + return { + uid: 'self-uid', + status: value, + extStatus: 0, + termType: 0, + netType: 0, + iconType: 0, + customStatus: undefined, + setTime: '0', + }; +} + +function createCore (online: boolean | undefined) { + let profileListener: FakeProfileListener | undefined; + let messageListener: FakeMessageListener | undefined; + const session = { + getMsgService: () => ({ + addKernelMsgListener: vi.fn((listener: FakeMessageListener) => { + messageListener = listener; + }), + }), + getProfileService: () => ({ + addKernelProfileListener: vi.fn((listener: FakeProfileListener) => { + profileListener = listener; + }), + }), + }; + const logger = { + log: vi.fn(), + logError: vi.fn(), + logWarn: vi.fn(), + logDebug: vi.fn(), + }; + const emit = vi.fn(); + // NapCatCore's constructor loads the native wrapper, which is unavailable in tests. + // Only the listener wiring is under test, so the instance is built field-by-field. + const core = Object.create(NapCatCore.prototype) as NapCatCore; + Object.assign(core, { + context: { logger, session }, + selfInfo: { uid: 'self-uid', uin: '1', online } as SelfInfo, + event: { emit }, + }); + return { + core, + emit, + profileListener: () => profileListener, + messageListener: () => messageListener, + }; +} + +describe('silent offline (SelfOffline) event', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('emits SelfOffline when the account transitions online -> offline', async () => { + const { core, emit, profileListener } = createCore(true); + await core.initNapCatCoreListeners(); + + profileListener()!.onSelfStatusChanged(status(20)); + + expect(emit).toHaveBeenCalledTimes(1); + expect(emit).toHaveBeenCalledWith('SelfOffline', expect.stringContaining('账号状态变更为离线')); + expect(core.selfInfo.online).toBe(false); + }); + + test('does not emit again while the account stays offline', async () => { + const { core, emit, profileListener } = createCore(true); + await core.initNapCatCoreListeners(); + + profileListener()!.onSelfStatusChanged(status(20)); + profileListener()!.onSelfStatusChanged(status(20)); + profileListener()!.onSelfStatusChanged(status(20)); + + expect(emit).toHaveBeenCalledTimes(1); + }); + + test('emits again after the account recovered and went offline once more', async () => { + const { core, emit, profileListener } = createCore(true); + await core.initNapCatCoreListeners(); + + profileListener()!.onSelfStatusChanged(status(20)); + profileListener()!.onSelfStatusChanged(status(10)); + profileListener()!.onSelfStatusChanged(status(20)); + + expect(emit).toHaveBeenCalledTimes(2); + expect(core.selfInfo.online).toBe(false); + }); + + test('does not emit during startup when the account was never online', async () => { + const { core, emit, profileListener } = createCore(undefined); + await core.initNapCatCoreListeners(); + + profileListener()!.onSelfStatusChanged(status(20)); + + expect(emit).not.toHaveBeenCalled(); + expect(core.selfInfo.online).toBe(false); + }); + + test('keeps emitting KickedOffLine as before', async () => { + const { core, emit, messageListener } = createCore(true); + await core.initNapCatCoreListeners(); + + messageListener()!.onKickedOffLine({ tipsTitle: '下线通知', tipsDesc: '当前账号已在其它设备登录' }); + + expect(emit).toHaveBeenCalledTimes(1); + expect(emit).toHaveBeenCalledWith('KickedOffLine', expect.stringContaining('当前账号已在其它设备登录')); + expect(core.selfInfo.online).toBe(false); + }); +});