diff --git a/src/common/emailNotification.ts b/src/common/emailNotification.ts index 68806c151..8cef230e6 100644 --- a/src/common/emailNotification.ts +++ b/src/common/emailNotification.ts @@ -69,6 +69,11 @@ export class EmailNotificationService extends Service { this.onOffline(data.tipsDesc || data.tipsTitle) }) + this.ctx.on('qq/session-expired', (reason) => { + wasOffline = true + this.onOffline(reason) + }) + this.checkLoginStatus = setInterval(() => { if (wasOffline && selfInfo.online) { this.notificationSent = false diff --git a/src/main/qqProtocol/base.ts b/src/main/qqProtocol/base.ts index 1afcfac8a..ab1e2ba0c 100644 --- a/src/main/qqProtocol/base.ts +++ b/src/main/qqProtocol/base.ts @@ -23,6 +23,8 @@ declare module 'cordis' { 'qq/online': () => void /** 协议层断开(WS / TCP 掉了)。上层一般不需要卸插件,等协议层重连后会再次 emit `qq/online` */ 'protocol/disconnect': () => void + /** An online QQ session was rejected; notify consumers without applying KickNT recovery. */ + 'qq/session-expired': (reason: string) => void 'llbot/self-nick-changed': (info: { nick: string }) => void } } diff --git a/src/main/qqProtocol/direct-lib/client.ts b/src/main/qqProtocol/direct-lib/client.ts index 2e1983ed3..b7b12fc73 100644 --- a/src/main/qqProtocol/direct-lib/client.ts +++ b/src/main/qqProtocol/direct-lib/client.ts @@ -756,6 +756,7 @@ export class DirectProtocolClient extends EventEmitter { async sendCommand(cmd: string, payload: Buffer, encryptType?: EncryptType, timeout = 15000): Promise { const seq = this.nextSeq() + const session = this.session const ctx = this.getPacketContext() const enc = encryptType ?? (this.session ? EncryptType.EncryptD2Key : EncryptType.EncryptEmpty) @@ -784,6 +785,10 @@ export class DirectProtocolClient extends EventEmitter { } } + // Signing can still be in flight when another request invalidates the session. + if (this.session !== session) { + throw new Error('QQ session changed before the command could be sent') + } const packet = buildServicePacket(seq, cmd, ctx, payload, enc, signResult) // 调试用: 出网前 dump SSO frame, 跟真机抓包对照定位 sign 不一致的字节差异. @@ -825,6 +830,22 @@ export class DirectProtocolClient extends EventEmitter { this.emit('error', new Error('Failed to parse incoming packet')) return } + // Authentication failures can have seq=0 and no command, so handle them before request matching. + if (parsed.retCode === -10001 && this.session) { + const uin = this.session.uin + const error = new Error( + `QQ session authentication failed: retCode=${parsed.retCode}, ${parsed.extraMsg || 'Please log in again'}`, + ) + this.clearSession() + for (const pending of this.pendingPackets.values()) { + clearTimeout(pending.timeout) + pending.reject(error) + } + this.pendingPackets.clear() + this.frameArriveAt.clear() + this.emit('session-expired', uin, error) + return + } if (isDebugEnabled()) this.frameArriveAt.set(parsed.seq, tArrive) const pending = this.pendingPackets.get(parsed.seq) if (pending) { diff --git a/src/main/qqProtocol/direct-lib/login.ts b/src/main/qqProtocol/direct-lib/login.ts index 2b6f56e1b..51d5d74a9 100644 --- a/src/main/qqProtocol/direct-lib/login.ts +++ b/src/main/qqProtocol/direct-lib/login.ts @@ -209,6 +209,8 @@ export async function pollQrCode(client: DirectProtocolClient, sig: Buffer): Pro export async function loginWithQrResult( client: DirectProtocolClient, qrResult: QrPollResult, + // Checked after the network response, before installing session credentials. + isCurrent?: () => boolean, ): Promise { if (!qrResult.tempPassword || !qrResult.tgtgtKey || !qrResult.noPicSig || !qrResult.uin) { throw new Error('QR poll result incomplete') @@ -283,6 +285,8 @@ export async function loginWithQrResult( 15000, ) + // A superseded QR attempt must not install credentials into the shared client. + if (isCurrent && !isCurrent()) throw new Error('QR login attempt was superseded') const result = parseLoginResponse(resp.payload, client.getEcdhShareKey(), qrResult.tgtgtKey) if (result.success) { client.setSession({ diff --git a/src/main/qqProtocol/direct.ts b/src/main/qqProtocol/direct.ts index b8bc8a7f1..f9c848950 100644 --- a/src/main/qqProtocol/direct.ts +++ b/src/main/qqProtocol/direct.ts @@ -121,6 +121,7 @@ export class DirectQQProtocol extends QQProtocolBase { public async logout(): Promise { this.manualLogout = true + this.qrPollToken++ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null } this.directClient?.clearSession() this.directClient?.disconnect() @@ -253,13 +254,14 @@ export class DirectQQProtocol extends QQProtocolBase { private async doInitDirectClient(authToken: string): Promise { this.manualLogout = false // 换账号 / 重试时取消上一轮残留的 QR poll loop (qrPollToken 变 -> 旧 poll 循环下一 tick 自退) - this.qrPollToken++ + const myToken = ++this.qrPollToken // 换账号/重建 client: 清 base 展示缓存, 否则 TTL 内会复用上一账号的旧码 this.resetQrState() this.directQrResult = null // native sign 已 init 时热切换到最新 token; 未 init (首次) 时 no-op, token 由 new client 的 config 带入 await updateAuthToken(authToken).catch((e) => this.logger.warn('[Sign] updateAuthToken failed:', (e as Error).message)) + if (this.qrPollToken !== myToken) return // 先 loadSession 拿 (uin, guid). 运行时 override 优先于 argv -q. const specifiedUin = this.runtimeUinOverride || getSpecifiedUin() @@ -308,11 +310,13 @@ export class DirectQQProtocol extends QQProtocolBase { // uin 授权/绑定由服务端判 (登录时按配额自动绑, 满了才 403); 本地不预检 allowed_uins. this.logger.info('Found saved session for UIN %s (file: %s), attempting restore...', persisted.uin, getSessionFilePathForUin(persisted.uin)) if (!this.directClient.isConnected) await this.directClient.connect() + if (this.qrPollToken !== myToken) return const session = persistedToSessionInfo(persisted) this.directClient.setSession(session) try { await registerOnline(this.directClient) + if (this.qrPollToken !== myToken || this.directClient.getSession() !== session) return this.logger.info('[QQ Server] Online registered!') selfInfo.uin = persisted.uin selfInfo.uid = persisted.uid @@ -323,9 +327,12 @@ export class DirectQQProtocol extends QQProtocolBase { this.directStopHeartbeat = startHeartbeat(this.directClient) this.maybeEmitOnline() // 直连 session 恢复后 nick 可能为空; 异步补查 - if (!selfInfo.nick) this.scheduleFetchSelfNick() + if (!selfInfo.nick) { + this.scheduleFetchSelfNick(() => this.qrPollToken === myToken && this.directClient?.getSession() === session) + } return } catch (e) { + if (this.qrPollToken !== myToken || this.directClient.getSession() !== session) return // 恢复失败 (session 过期): 清 session, 但保留 TCP 连接复用给扫码 -- 不 disconnect, 否则会触发 // close 事件且 native sign relay 目标断链, 下面 fresh 分支直接用现连接拉码. this.logger.info('Saved session expired, will need QR login: %s', (e as Error).message) @@ -339,6 +346,34 @@ export class DirectQQProtocol extends QQProtocolBase { /** 给 client 挂事件 (error/connected/close/push). 只在首次建立 client 时调一次 -- 复用 client 不重挂. */ private bindDirectClientEvents(client: DirectProtocolClient): void { + client.on('session-expired', (uin: string, error: Error) => { + const wasOnline = this.onlineEmitted + selfInfo.online = false + this.onlineEmitted = false + authTokenStatus.loginError = error.message + this.logger.warn('QQ session expired:', error.message) + if (this.directStopHeartbeat) { + this.directStopHeartbeat() + this.directStopHeartbeat = null + } + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + // Remove only the rejected account's credentials; retain the device identity and native client. + deleteSession(uin) + this.runtimeUinOverride = null + this.qrPollToken++ + this.directQrResult = null + this.directPollResult = null + this.resetQrState() + setLoginState({ state: 'need_qrcode', qrcode_png_base64: undefined }) + if (wasOnline) { + this.ctx.parallel('protocol/disconnect') + this.ctx.parallel('qq/session-expired', error.message) + } + this.ensureQrLoop() + }) client.on('error', (err: Error) => { this.logger.warn('Direct client error:', err.message) }) @@ -363,8 +398,8 @@ export class DirectQQProtocol extends QQProtocolBase { } }) client.on('push', (packet: { cmd: string; payload: Buffer }) => { - // 收到包 = 连着; 顺带刷新 lastConnectedTime - this.lastConnectedTime = Date.now() + // Unauthenticated traffic must not postpone the disconnect notification. + if (client.isLoggedIn) this.lastConnectedTime = Date.now() this.ctx.parallel('qq/raw', { cmd: packet.cmd, payload: packet.payload }) }) } @@ -386,7 +421,7 @@ export class DirectQQProtocol extends QQProtocolBase { this.directPollResult = result if (result.state === QrCodeState.Confirmed) { - await this.completeDirectLogin() + await this.completeDirectLogin(myToken) return } @@ -402,6 +437,7 @@ export class DirectQQProtocol extends QQProtocolBase { return } } catch (e) { + if (this.qrPollToken !== myToken) return this.logger.warn('QR poll error:', (e as Error).message) } @@ -412,20 +448,25 @@ export class DirectQQProtocol extends QQProtocolBase { setTimeout(poll, 2000) } - private async completeDirectLogin() { - if (!this.directClient || !this.directPollResult || !this.directQrResult) return + private async completeDirectLogin(myToken: number) { + const client = this.directClient + const pollResult = this.directPollResult + const qrResult = this.directQrResult + if (this.qrPollToken !== myToken || !client || !pollResult || !qrResult) return this.manualLogout = false // Get UIN - const urlParams = new URL(this.directQrResult.url).searchParams + const urlParams = new URL(qrResult.url).searchParams const qrSig = urlParams.get('k') || '' const uin = await getCorrectUin(AppInfo.appId, qrSig) - this.directPollResult.uin = String(uin) + if (this.qrPollToken !== myToken) return + pollResult.uin = String(uin) // uin 授权/绑定由服务端判 (登录时按配额自动绑, 满了才 403); 本地不预检 allowed_uins. // wtlogin.login - const loginResult = await loginWithQrResult(this.directClient, this.directPollResult) + const loginResult = await loginWithQrResult(client, pollResult, () => this.qrPollToken === myToken) + if (this.qrPollToken !== myToken) return if (!loginResult.success) { this.logger.error(`Login failed: state=${loginResult.state} ${loginResult.tag} ${loginResult.message}`) // 登录失败原因回传 WebUI (如 auth_token 可用 QQ 数量已达上限) @@ -433,27 +474,30 @@ export class DirectQQProtocol extends QQProtocolBase { return } - this.logger.info(`Login successful! UID: ${loginResult.uid}, nick: "${loginResult.nick}"`) - - // Save session - const session = this.directClient.getSession()! - saveSession(session, this.directPollResult.tgtgtKey!, this.directClient.getGuid(), loginResult.tempPassword, loginResult.nick) + const session = client.getSession() + if (!session) return // Register online: 失败视为登录未完成, 不标记在线, 报错回 WebUI. 必须清掉半成品 session // (loginWithQrResult 已 setSession -> isLoggedIn=true), 否则扫码 loop 认为已登录会停, 不出新码, // 变成收不到 MsgPush 的"假在线". 连接保留复用, 下一轮 loop 直接拉新码. try { - await registerOnline(this.directClient) + await registerOnline(client) } catch (e) { + if (this.qrPollToken !== myToken || client.getSession() !== session) return const msg = (e as Error).message this.logger.error('Register online failed:', msg) authTokenStatus.loginError = `上线注册失败: ${msg}` - this.directClient.clearSession() + client.clearSession() return } + // A completed request may resume after invalidation or a newer login attempt. + if (this.qrPollToken !== myToken || client.getSession() !== session) return + + this.logger.info(`Login successful! UID: ${loginResult.uid}, nick: "${loginResult.nick}"`) + saveSession(session, pollResult.tgtgtKey!, client.getGuid(), loginResult.tempPassword, loginResult.nick) // Start heartbeat - this.directStopHeartbeat = startHeartbeat(this.directClient) + this.directStopHeartbeat = startHeartbeat(client) // Update global state selfInfo.uin = String(uin) @@ -463,6 +507,8 @@ export class DirectQQProtocol extends QQProtocolBase { // 记住已登录 uin: 断线重连走 initDirectClient() 时用它 loadSession 快速登录, 不退回扫码. this.runtimeUinOverride = String(uin) this.maybeEmitOnline() - if (!selfInfo.nick) this.scheduleFetchSelfNick() + if (!selfInfo.nick) { + this.scheduleFetchSelfNick(() => this.qrPollToken === myToken && client.getSession() === session) + } } } diff --git a/src/milky/adapter.ts b/src/milky/adapter.ts index cbc4704de..c7bc1e9e4 100644 --- a/src/milky/adapter.ts +++ b/src/milky/adapter.ts @@ -319,6 +319,10 @@ export class MilkyAdapter extends Service { this.emitEvent('bot_offline', eventData) } }) + + this.ctx.on('qq/session-expired', (reason) => { + this.emitEvent('bot_offline', { reason }) + }) } } diff --git a/test/unit/qqProtocol/directSession.test.ts b/test/unit/qqProtocol/directSession.test.ts new file mode 100644 index 000000000..fd31b08f0 --- /dev/null +++ b/test/unit/qqProtocol/directSession.test.ts @@ -0,0 +1,538 @@ +import { EventEmitter } from 'node:events' +import { Context } from 'cordis' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { authTokenStatus, selfInfo } from '@/common/globalVars' +import { DirectQQProtocol } from '../../../src/main/qqProtocol/direct' +import { DirectProtocolClient, type SessionInfo } from '../../../src/main/qqProtocol/direct-lib/client' +import { + fetchQrCode, + getCorrectUin, + loginWithQrResult, + pollQrCode, + type LoginResult, +} from '../../../src/main/qqProtocol/direct-lib/login' +import { deleteMachineGuid } from '../../../src/main/qqProtocol/direct-lib/machineGuid' +import { registerOnline, startHeartbeat } from '../../../src/main/qqProtocol/direct-lib/online' +import { + deleteSession, + loadSession, + persistedToSessionInfo, + saveSession, +} from '../../../src/main/qqProtocol/direct-lib/session' +import { requestSign, updateAuthToken } from '../../../src/main/qqProtocol/direct-lib/sign' +import { teaEncrypt } from '../../../src/main/qqProtocol/direct-lib/tea' +import { getCurrentLoginState } from '../../../src/main/llbot-ipc' + +vi.mock('@/common/globalVars', () => ({ + selfInfo: { uin: '123456', uid: 'test-uid', nick: 'TestBot', online: true }, + authTokenStatus: { loginError: '' }, + TEMP_DIR: '/tmp/test-data/temp', +})) + +vi.mock('@/main/config', () => ({ + authTokenUtil: { reload: vi.fn(() => 'test-auth-token') }, +})) + +vi.mock('@/main/qqProtocol/direct-lib/authTokenWatcher', () => ({ + startAuthTokenWatcher: vi.fn(), +})) + +vi.mock('@/main/qqProtocol/direct-lib/machineGuid', () => ({ + loadMachineGuidSync: vi.fn(() => Buffer.alloc(16)), + overwriteMachineGuid: vi.fn(), + deleteMachineGuid: vi.fn(), +})) + +vi.mock('@/main/qqProtocol/direct-lib/session', () => ({ + saveSession: vi.fn(), + loadSession: vi.fn(), + deleteSession: vi.fn(), + listAvailableSessions: vi.fn(() => []), + persistedToSessionInfo: vi.fn(), + getSpecifiedUin: vi.fn(() => '123456'), + getSessionFilePathForUin: vi.fn(), +})) + +vi.mock('@/main/qqProtocol/direct-lib/sign', () => ({ + requestSign: vi.fn(), + setupSign: vi.fn(), + setSignMachineGuid: vi.fn(), + acquireSignToken: vi.fn(async () => ({ token: 'test-token', ttlSecs: 3600 })), + updateAuthToken: vi.fn(), +})) + +vi.mock('@/main/qqProtocol/direct-lib/login', () => ({ + fetchQrCode: vi.fn(), + pollQrCode: vi.fn(), + loginWithQrResult: vi.fn(), + getCorrectUin: vi.fn(), + QrCodeState: { Confirmed: 0, WaitingForConfirm: 53, Expired: 17, Cancelled: 54 }, +})) + +vi.mock('@/main/qqProtocol/direct-lib/online', () => ({ + registerOnline: vi.fn(async () => 'ok'), + startHeartbeat: vi.fn(() => vi.fn()), +})) + +vi.mock('@/main/qqProtocol/direct-lib/connection', () => ({ + TcpConnection: class extends EventEmitter { + isConnected = true + send = vi.fn() + connect = vi.fn(async () => { + this.isConnected = true + }) + disconnect = vi.fn(() => { + this.isConnected = false + this.emit('close') + }) + }, +})) + +function createSession(): SessionInfo { + return { + uin: '123456', + uid: 'test-uid', + d2: Buffer.alloc(16), + d2Key: Buffer.alloc(16), + tgt: Buffer.alloc(16), + a2: Buffer.alloc(16), + a2Key: Buffer.alloc(16), + sKey: Buffer.alloc(0), + } +} + +function int32(value: number): Buffer { + const data = Buffer.alloc(4) + data.writeInt32BE(value) + return data +} + +// Synthetic protocol-12 responses exercise the real parser without account credentials or captured traffic. +function responseFrame(seq: number, retCode = 0, cmd = 'test.command', extraMsg = ''): Buffer { + const extra = Buffer.from(extraMsg) + const command = Buffer.from(cmd) + const head = Buffer.concat([ + int32(seq), + int32(retCode), + int32(extra.length + 4), + extra, + int32(command.length + 4), + command, + ]) + const body = Buffer.concat([int32(head.length + 4), head, int32(4)]) + return Buffer.concat([int32(12), Buffer.from([2, 0]), int32(4), Buffer.from(teaEncrypt(body, Buffer.alloc(16)))]) +} + +const authMessage = '身份验证失败,请你重新登录。(s20)' + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +const loginSuccess: LoginResult = { + success: true, + ...createSession(), + tempPassword: Buffer.alloc(16), + nick: 'TestBot', + age: 0, + gender: 0, +} + +describe('direct session authentication failures', () => { + let client: DirectProtocolClient + + beforeEach(() => { + vi.useFakeTimers() + client = new DirectProtocolClient() + client.setSession(createSession()) + selfInfo.online = true + authTokenStatus.loginError = '' + }) + + afterEach(() => { + client.disconnect() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it.each(['unmatched', 'matched'])('invalidates the session for an %s authentication failure', async (kind) => { + const expired = vi.fn() + const pushed = vi.fn() + client.on('session-expired', expired) + client.on('push', pushed) + const seq = client['seq'] + const requests = Promise.allSettled([ + client.sendCommand('test.command', Buffer.alloc(0)), + client.sendCommand('test.other', Buffer.alloc(0)), + ]) + + client['conn'].emit('packet', responseFrame(kind === 'matched' ? seq : 0, -10001, '', authMessage)) + + const results = await requests + for (const result of results) { + expect(result.status).toBe('rejected') + if (result.status === 'rejected') expect(result.reason.message).toContain(authMessage) + } + expect(client.isLoggedIn).toBe(false) + expect(expired).toHaveBeenCalledExactlyOnceWith('123456', expect.any(Error)) + expect(pushed).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it('invalidates an idle session even when there are no pending requests', () => { + const expired = vi.fn() + client.on('session-expired', expired) + client['conn'].emit('packet', responseFrame(0, -10001, '', authMessage)) + client['conn'].emit('packet', responseFrame(0, -10001, '', authMessage)) + expect(expired).toHaveBeenCalledTimes(1) + expect(client.isLoggedIn).toBe(false) + }) + + it('keeps ordinary request errors scoped to the matching request', async () => { + const expired = vi.fn() + client.on('session-expired', expired) + const seq = client['seq'] + const first = client.sendCommand('test.command', Buffer.alloc(0)) + const rejected = expect(first).rejects.toThrow('retCode=-2') + const second = client.sendCommand('test.other', Buffer.alloc(0)) + + client['conn'].emit('packet', responseFrame(seq, -2, 'test.command', 'Request failed')) + client['conn'].emit('packet', responseFrame(seq + 1, 0, 'test.other')) + + await rejected + await expect(second).resolves.toMatchObject({ cmd: 'test.other', retCode: 0 }) + expect(client.isLoggedIn).toBe(true) + expect(expired).not.toHaveBeenCalled() + }) + + it('continues forwarding ordinary unsolicited pushes', () => { + const pushed = vi.fn() + client.on('push', pushed) + client['conn'].emit('packet', responseFrame(0, 0, 'trpc.msg.olpush.OlPushService.MsgPush')) + expect(pushed).toHaveBeenCalledWith(expect.objectContaining({ cmd: 'trpc.msg.olpush.OlPushService.MsgPush' })) + expect(client.isLoggedIn).toBe(true) + }) + + it('does not send a command whose signing finishes after session invalidation', async () => { + client.setAuthToken('test-auth-token') + const session = client.getSession()! + session.signToken12B = 'test-token' + session.signTokenExpiresAt = Date.now() + 60_000 + let finishSign!: (result: { sign: Buffer; token: Buffer; extra: Buffer }) => void + vi.mocked(requestSign).mockImplementationOnce( + () => + new Promise((resolve) => { + finishSign = resolve + }), + ) + const request = client.sendCommand('MessageSvc.PbSendMsg', Buffer.alloc(0)) + const rejected = expect(request).rejects.toThrow('QQ session changed') + await vi.waitFor(() => expect(requestSign).toHaveBeenCalled()) + + client['conn'].emit('packet', responseFrame(0, -10001, '', authMessage)) + finishSign({ sign: Buffer.alloc(0), token: Buffer.from('test-token'), extra: Buffer.alloc(0) }) + + await rejected + expect(client['conn'].send).not.toHaveBeenCalled() + }) + + it.each([true, false])('exposes a fresh QR login after invalidation (already online: %s)', async (alreadyOnline) => { + const ctx = new Context() + const protocol = new DirectQQProtocol(ctx) + protocol['directClient'] = client + protocol['onlineEmitted'] = alreadyOnline + selfInfo.online = alreadyOnline + protocol['runtimeUinOverride'] = '123456' + protocol['qrResult'] = { qrcodeUrl: 'stale', pngBase64: '', expireTimeSec: 180, sig: 'stale' } + protocol['qrFetchedAt'] = Date.now() + const stopHeartbeat = vi.fn() + protocol['directStopHeartbeat'] = stopHeartbeat + const reconnect = vi.fn() + protocol['reconnectTimer'] = setTimeout(reconnect, 5000) as unknown as NodeJS.Timeout + const disconnect = vi.fn() + ctx.on('protocol/disconnect', disconnect) + const offline = vi.fn() + ctx.on('qq/session-expired', offline) + const qrLoop = vi + .spyOn(protocol as unknown as { ensureQrLoop(): void }, 'ensureQrLoop') + .mockImplementation(() => {}) + protocol['bindDirectClientEvents'](client) + const seq = client['seq'] + const pending = protocol.sendPB('test.command', Buffer.alloc(0)) + const rejected = expect(pending).rejects.toThrow(authMessage) + + client['conn'].emit('packet', responseFrame(0, -10001, '', authMessage)) + + await rejected + expect(selfInfo.online).toBe(false) + expect(protocol.get_is_connected()).toBe(false) + expect(authTokenStatus.loginError).toContain(authMessage) + expect(deleteSession).toHaveBeenCalledExactlyOnceWith('123456') + expect(deleteMachineGuid).not.toHaveBeenCalled() + expect(stopHeartbeat).toHaveBeenCalledTimes(1) + expect(disconnect).toHaveBeenCalledTimes(alreadyOnline ? 1 : 0) + expect(offline).toHaveBeenCalledTimes(alreadyOnline ? 1 : 0) + if (alreadyOnline) expect(offline).toHaveBeenCalledWith(expect.stringContaining(authMessage)) + expect(qrLoop).toHaveBeenCalledTimes(1) + expect(getCurrentLoginState()).toMatchObject({ state: 'need_qrcode' }) + expect(client['conn'].disconnect).not.toHaveBeenCalled() + await expect(protocol.sendPB('test.command', Buffer.alloc(0))).rejects.toThrow('not logged in') + + // A late reply must not restore the expired session or report the account online. + client['conn'].emit('packet', responseFrame(seq)) + expect(selfInfo.online).toBe(false) + vi.mocked(fetchQrCode).mockResolvedValue({ + url: 'fresh-qr', + image: Buffer.alloc(0), + sig: Buffer.alloc(16), + tgtgtKey: Buffer.alloc(16), + }) + await expect(protocol.getLoginQrCode()).resolves.toMatchObject({ qrcodeUrl: 'fresh-qr' }) + expect(fetchQrCode).toHaveBeenCalledWith(client) + await vi.advanceTimersByTimeAsync(5000) + expect(reconnect).not.toHaveBeenCalled() + + // A replacement session can use the same client and report online again. + client.setSession(createSession()) + selfInfo.online = true + const online = vi.fn() + ctx.on('qq/online', online) + protocol['maybeEmitOnline']() + expect(online).toHaveBeenCalledTimes(1) + expect(authTokenStatus.loginError).toBe('') + }) + + it('preserves saved credentials on an ordinary transport close', () => { + const ctx = new Context() + const protocol = new DirectQQProtocol(ctx) + protocol['directClient'] = client + protocol['onlineEmitted'] = true + protocol['bindDirectClientEvents'](client) + const reconnect = vi + .spyOn(protocol as unknown as { scheduleReconnect(): void }, 'scheduleReconnect') + .mockImplementation(() => {}) + + client['conn'].emit('close') + + expect(selfInfo.online).toBe(false) + expect(deleteSession).not.toHaveBeenCalled() + expect(reconnect).toHaveBeenCalledTimes(1) + }) + + it.each(['uin', 'login', 'register'] as const)('abandons QR completion invalidated during %s', async (stage) => { + const protocol = new DirectQQProtocol(new Context()) + protocol['directClient'] = client + protocol['qrPollToken'] = 1 + protocol['directQrResult'] = { + url: 'https://example.com/qr?k=test', + image: Buffer.alloc(0), + sig: Buffer.alloc(16), + tgtgtKey: Buffer.alloc(16), + } + protocol['directPollResult'] = { state: 0, tgtgtKey: Buffer.alloc(16) } + selfInfo.online = false + vi.spyOn(protocol as unknown as { ensureQrLoop(): void }, 'ensureQrLoop').mockImplementation(() => {}) + protocol['bindDirectClientEvents'](client) + const uin = deferred() + const login = deferred() + const registration = deferred() + vi.mocked(getCorrectUin).mockReturnValue(stage === 'uin' ? uin.promise : Promise.resolve(123456)) + vi.mocked(loginWithQrResult).mockReturnValue(stage === 'login' ? login.promise : Promise.resolve(loginSuccess)) + vi.mocked(registerOnline).mockReturnValue(registration.promise) + const completion = protocol['completeDirectLogin'](1) + if (stage === 'login') await vi.waitFor(() => expect(loginWithQrResult).toHaveBeenCalled()) + if (stage === 'register') await vi.waitFor(() => expect(registerOnline).toHaveBeenCalled()) + + client['conn'].emit('packet', responseFrame(0, -10001, '', authMessage)) + uin.resolve(123456) + login.resolve(loginSuccess) + registration.resolve('ok') + await completion + + expect(client.isLoggedIn).toBe(false) + expect(selfInfo.online).toBe(false) + expect(protocol['onlineEmitted']).toBe(false) + expect(saveSession).not.toHaveBeenCalled() + expect(startHeartbeat).not.toHaveBeenCalled() + expect(authTokenStatus.loginError).toContain(authMessage) + expect(getCurrentLoginState()).toMatchObject({ state: 'need_qrcode' }) + }) + + it('does not let a late registration failure clear a replacement session', async () => { + const protocol = new DirectQQProtocol(new Context()) + protocol['directClient'] = client + protocol['qrPollToken'] = 1 + protocol['directQrResult'] = { + url: 'https://example.com/qr?k=test', + image: Buffer.alloc(0), + sig: Buffer.alloc(16), + tgtgtKey: Buffer.alloc(16), + } + protocol['directPollResult'] = { state: 0, tgtgtKey: Buffer.alloc(16) } + selfInfo.online = false + vi.spyOn(protocol as unknown as { ensureQrLoop(): void }, 'ensureQrLoop').mockImplementation(() => {}) + protocol['bindDirectClientEvents'](client) + vi.mocked(getCorrectUin).mockResolvedValue(123456) + vi.mocked(loginWithQrResult).mockResolvedValue(loginSuccess) + const registration = deferred() + vi.mocked(registerOnline).mockReturnValue(registration.promise) + const completion = protocol['completeDirectLogin'](1) + await vi.waitFor(() => expect(registerOnline).toHaveBeenCalled()) + + client['conn'].emit('packet', responseFrame(0, -10001, '', authMessage)) + const replacement = createSession() + client.setSession(replacement) + selfInfo.online = true + authTokenStatus.loginError = '' + registration.reject(new Error('Old registration failed')) + await completion + + expect(client.getSession()).toBe(replacement) + expect(selfInfo.online).toBe(true) + expect(authTokenStatus.loginError).toBe('') + expect(saveSession).not.toHaveBeenCalled() + expect(startHeartbeat).not.toHaveBeenCalled() + }) + + it.each([false, true])('guards saved-session registration after invalidation (late failure: %s)', async (fails) => { + const protocol = new DirectQQProtocol(new Context()) + protocol['directClient'] = client + selfInfo.online = false + vi.spyOn(protocol as unknown as { ensureQrLoop(): void }, 'ensureQrLoop').mockImplementation(() => {}) + protocol['bindDirectClientEvents'](client) + vi.mocked(updateAuthToken).mockResolvedValue(undefined) + vi.mocked(loadSession).mockReturnValue({ + uin: '123456', + uid: 'test-uid', + guid: Buffer.alloc(16).toString('hex'), + savedAt: 0, + }) + vi.mocked(persistedToSessionInfo).mockReturnValue(createSession()) + const registration = deferred() + vi.mocked(registerOnline).mockReturnValue(registration.promise) + const completion = protocol['doInitDirectClient']('test-auth-token') + await vi.waitFor(() => expect(registerOnline).toHaveBeenCalled()) + + client['conn'].emit('packet', responseFrame(0, -10001, '', authMessage)) + const replacement = createSession() + client.setSession(replacement) + if (fails) registration.reject(new Error('Old registration failed')) + else registration.resolve('ok') + await completion + + expect(client.getSession()).toBe(replacement) + expect(selfInfo.online).toBe(false) + expect(startHeartbeat).not.toHaveBeenCalled() + expect(authTokenStatus.loginError).toContain(authMessage) + }) + + it('still saves and announces a current QR login after registration succeeds', async () => { + const ctx = new Context() + const protocol = new DirectQQProtocol(ctx) + protocol['directClient'] = client + protocol['qrPollToken'] = 1 + protocol['directQrResult'] = { + url: 'https://example.com/qr?k=test', + image: Buffer.alloc(0), + sig: Buffer.alloc(16), + tgtgtKey: Buffer.alloc(16), + } + protocol['directPollResult'] = { state: 0, tgtgtKey: Buffer.alloc(16) } + selfInfo.online = false + client.clearSession() + vi.mocked(pollQrCode).mockResolvedValue(protocol['directPollResult']) + vi.mocked(getCorrectUin).mockResolvedValue(123456) + vi.mocked(loginWithQrResult).mockImplementation(async () => { + client.setSession(createSession()) + return loginSuccess + }) + const registration = deferred() + vi.mocked(registerOnline).mockReturnValue(registration.promise) + const online = vi.fn() + ctx.on('qq/online', online) + protocol['startDirectQrPolling']() + await vi.advanceTimersByTimeAsync(2000) + await vi.waitFor(() => expect(registerOnline).toHaveBeenCalled()) + expect(saveSession).not.toHaveBeenCalled() + registration.resolve('ok') + await vi.waitFor(() => expect(online).toHaveBeenCalledTimes(1)) + expect(saveSession).toHaveBeenCalledTimes(1) + expect(startHeartbeat).toHaveBeenCalledExactlyOnceWith(client) + expect(online).toHaveBeenCalledTimes(1) + expect(selfInfo.online).toBe(true) + }) + + it('cancels QR completion on logout even before a session has been installed', async () => { + const protocol = new DirectQQProtocol(new Context()) + protocol['directClient'] = client + protocol['qrPollToken'] = 1 + protocol['directQrResult'] = { + url: 'https://example.com/qr?k=test', + image: Buffer.alloc(0), + sig: Buffer.alloc(16), + tgtgtKey: Buffer.alloc(16), + } + protocol['directPollResult'] = { state: 0, tgtgtKey: Buffer.alloc(16) } + selfInfo.online = false + client.clearSession() + vi.spyOn(protocol as unknown as { ensureQrLoop(): void }, 'ensureQrLoop').mockImplementation(() => {}) + const uin = deferred() + vi.mocked(getCorrectUin).mockReturnValue(uin.promise) + const completion = protocol['completeDirectLogin'](1) + await protocol.logout() + uin.resolve(123456) + await completion + expect(loginWithQrResult).not.toHaveBeenCalled() + expect(saveSession).not.toHaveBeenCalled() + expect(selfInfo.online).toBe(false) + }) + + it('rejects a superseded login response before it can install credentials', async () => { + const { loginWithQrResult: realLogin } = await vi.importActual< + typeof import('../../../src/main/qqProtocol/direct-lib/login') + >('../../../src/main/qqProtocol/direct-lib/login') + client.clearSession() + const response = deferred>>() + vi.spyOn(client, 'sendCommand').mockReturnValue(response.promise) + let current = true + const login = realLogin( + client, + { + state: 0, + uin: '123456', + tgtgtKey: Buffer.alloc(16), + tempPassword: Buffer.alloc(16), + noPicSig: Buffer.alloc(16), + }, + () => current, + ) + const rejected = expect(login).rejects.toThrow('QR login attempt was superseded') + current = false + response.resolve({ seq: 1, retCode: 0, extraMsg: '', cmd: 'wtlogin.login', payload: Buffer.alloc(0) }) + await rejected + expect(client.isLoggedIn).toBe(false) + }) + + it('does not delay disconnect callbacks when unauthenticated pushes keep arriving', async () => { + const protocol = new DirectQQProtocol(new Context()) + protocol['directClient'] = client + protocol['bindDirectClientEvents'](client) + vi.spyOn(protocol as unknown as { ensureQrLoop(): void }, 'ensureQrLoop').mockImplementation(() => {}) + const disconnected = vi.fn() + protocol.onDisconnect(10000, disconnected) + protocol['startDisconnectMonitoring']() + client['conn'].emit('packet', responseFrame(0, -10001, '', authMessage)) + for (let i = 0; i < 6; i++) { + await vi.advanceTimersByTimeAsync(2000) + client['conn'].emit('packet', responseFrame(0, -10001, '', authMessage)) + } + expect(disconnected).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/unit/qqProtocol/offlineNotifications.test.ts b/test/unit/qqProtocol/offlineNotifications.test.ts new file mode 100644 index 000000000..456b93afa --- /dev/null +++ b/test/unit/qqProtocol/offlineNotifications.test.ts @@ -0,0 +1,102 @@ +import { Context } from 'cordis' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { selfInfo } from '@/common/globalVars' +import { EmailNotificationService } from '@/common/emailNotification' +import { EmailConfigManager } from '@/common/emailConfig' +import { MilkyAdapter } from '@/milky/adapter' + +vi.mock('@/common/emailConfig', () => ({ + EmailConfigManager: class { + loadConfig = vi.fn(async () => ({})) + getConfig = vi.fn(() => ({ enabled: true })) + }, +})) + +vi.mock('@/common/emailService', () => ({ + EmailService: class { + sendOfflineNotification = vi.fn(async () => ({ success: true })) + }, +})) + +describe('session expiration notifications', () => { + beforeEach(() => { + vi.useFakeTimers() + selfInfo.online = true + // Exercise the real notification handlers without reading config, watching files, or sending mail. + vi.spyOn( + EmailNotificationService.prototype as unknown as { initializeConfig(): Promise }, + 'initializeConfig', + ).mockResolvedValue(undefined) + }) + + afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it.each(['qq/session-expired', 'nt/kicked-offline'] as const)( + 'sends the reason once and rearms after login for %s', + async (event) => { + const ctx = new Context() + const onDisconnect = vi.fn() + ctx.provide('qqProtocol', { onDisconnect, offDisconnect: vi.fn() }) + const email = new EmailNotificationService(ctx) + const send = vi.mocked(email.getEmailService().sendOfflineNotification) + const reason = 'Test authentication failure' + const emitOffline = () => + event === 'qq/session-expired' + ? ctx.parallel(event, reason) + : ctx.parallel(event, { tipsDesc: reason, tipsTitle: 'Offline', kickedType: 1001 }) + + selfInfo.online = false + await emitOffline() + expect(send).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ uin: '123456' }), reason) + await emitOffline() + onDisconnect.mock.calls[0][1](10000) + expect(send).toHaveBeenCalledTimes(1) + + selfInfo.online = true + await vi.advanceTimersByTimeAsync(5000) + selfInfo.online = false + await emitOffline() + expect(send).toHaveBeenCalledTimes(2) + }, + ) + + it('respects disabled email notifications', async () => { + const ctx = new Context() + ctx.provide('qqProtocol', { onDisconnect: vi.fn(), offDisconnect: vi.fn() }) + const email = new EmailNotificationService(ctx) + vi.mocked(email.getConfigManager().getConfig).mockReturnValue({ enabled: false } as ReturnType< + EmailConfigManager['getConfig'] + >) + selfInfo.online = false + await ctx.parallel('qq/session-expired', 'Test authentication failure') + expect(email.getEmailService().sendOfflineNotification).not.toHaveBeenCalled() + }) + + it.each(['qq/session-expired', 'nt/kicked-offline'] as const)( + 'broadcasts the existing Milky bot_offline payload for %s', + async (event) => { + const ctx = new Context() + // Only event registration and serialization are needed; no HTTP server or webhook is started. + const http = { broadcast: vi.fn() } + const webhook = { broadcast: vi.fn() } + const adapter = Object.assign(Object.create(MilkyAdapter.prototype) as MilkyAdapter, { + ctx, + httpHandler: http, + webhookHandler: webhook, + }) + adapter['setupEventListeners']() + const reason = 'Test authentication failure' + if (event === 'qq/session-expired') await ctx.parallel(event, reason) + else await ctx.parallel(event, { tipsDesc: reason, tipsTitle: 'Offline', kickedType: 1001 }) + + expect(http.broadcast).toHaveBeenCalledTimes(1) + const payload = JSON.parse(http.broadcast.mock.calls[0][0]) + expect(payload).toMatchObject({ self_id: 123456, event_type: 'bot_offline', data: { reason } }) + expect(webhook.broadcast).toHaveBeenCalledExactlyOnceWith(http.broadcast.mock.calls[0][0]) + }, + ) +})