fix: 静默离线(无 KickedOffLine 通知)后自动恢复登录 (#2080)

服务端静默断开会话时不会下发 KickedOffLine,onSelfStatusChanged 仅把
selfInfo.online 置为 false 并打一行日志,没有任何消费者:进程与 WS 连接
继续存活,下游 OneBot 客户端长时间收不到任何事件。

- core 在 在线 -> 离线 的跳变上广播新的 SelfOffline 事件(重复的 status=20
  不重复触发,未登录时不触发)
- framework/shell 复用 #2030 已建立的下线恢复链路:同步 WebUI -> 重启 Worker
  -> 快速登录
- 静默离线可能是临时状态,重启前重新确认仍未恢复,避免误重启 Worker

Closes #2071
This commit is contained in:
Shxiao
2026-10-05 12:06:22 +08:00
committed by GitHub
parent 8371359677
commit 9a06abbbbd
5 changed files with 170 additions and 10 deletions
+6
View File
@@ -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;
}
@@ -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<AppEvents>();
+21 -4
View File
@@ -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 (
+18 -6
View File
@@ -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()
@@ -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);
});
});