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:
@@ -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>();
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user