diff --git a/src/modules/qqbot/core/contract/qqbot.types.ts b/src/modules/qqbot/core/contract/qqbot.types.ts index 487972e..91fe224 100644 --- a/src/modules/qqbot/core/contract/qqbot.types.ts +++ b/src/modules/qqbot/core/contract/qqbot.types.ts @@ -35,7 +35,7 @@ export type NapcatLoginStatus = { export type NapcatCaptchaLoginResult = { jumpUrl?: string; needNewDevice?: boolean; - newDevicePullQrCodeSig?: string; + newDevicePullQrCodeSig?: unknown; }; export type NapcatQrcode = { @@ -320,7 +320,8 @@ export type QqbotLoginScanSession = { lastCaptchaLookupAt?: number; lastRestartedAt?: number; mode: QqbotLoginScanMode; - newDevicePullQrCodeSig?: string; + newDeviceBytesToken?: string; + newDevicePullQrCodeSig?: unknown; newDeviceQrcode?: string; newDeviceStatus?: QqbotLoginNewDeviceStatus; passwordMd5?: string; diff --git a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts index f6625d4..2a4179b 100644 --- a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts +++ b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts @@ -6,6 +6,8 @@ import { throwVbenError, ToolsService } from '@/common'; import { NapcatLoginApiClient, NapcatWebuiHttpClient, + type NewDeviceQrCode, + type NewDeviceQrRequest, } from '../../infrastructure/integration/napcat-login-api.client'; import type { NapcatCaptchaLoginResult, @@ -614,7 +616,8 @@ export class QqbotNapcatLoginService { session.deviceVerifyUrl = this.toolsService.toTrimmedString( captchaResult.jumpUrl, ); - session.newDevicePullQrCodeSig = this.toolsService.toTrimmedString( + session.newDeviceBytesToken = undefined; + session.newDevicePullQrCodeSig = this.pickNewDevicePullQrCodeSig( captchaResult.newDevicePullQrCodeSig, ); session.newDeviceStatus = 'qr-pending'; @@ -631,20 +634,7 @@ export class QqbotNapcatLoginService { const client = new NapcatLoginApiClient({ post: (path, body) => this.postNapcat(container, path, body), }); - const qrcode = await client.getNewDeviceQRCode(session.id); - session.newDeviceQrcode = qrcode.qrcodeUrl; - session.newDevicePullQrCodeSig = - qrcode.pullQrCodeSig || session.newDevicePullQrCodeSig; - session.newDeviceStatus = qrcode.status; - session.errorMessage = '新设备二维码待扫码'; - this.persistLoginSession(session); - this.publishScanResultEvent( - session, - 'new-device-qrcode-ready', - 'processing', - '新设备二维码待扫码', - ); - return this.toResult(session); + return this.refreshNewDeviceQrcode(session, container, client); } catch (err) { return this.keepSessionPending( session, @@ -661,7 +651,21 @@ export class QqbotNapcatLoginService { const client = new NapcatLoginApiClient({ post: (path, body) => this.postNapcat(container, path, body), }); - const poll = await client.pollNewDeviceQR(session.id); + if (!session.newDeviceBytesToken) { + return this.refreshNewDeviceQrcode(session, container, client); + } + const uin = this.toolsService.toTrimmedString(session.expectedSelfId); + if (!uin) { + return this.failNewDeviceVerification( + session, + container, + '新设备验证账号上下文缺失,请重新更新登录', + ); + } + const poll = await client.pollNewDeviceQR({ + bytesToken: session.newDeviceBytesToken, + uin, + }); if (poll.status === 'scanned') { return this.keepNewDevicePending( session, @@ -677,7 +681,19 @@ export class QqbotNapcatLoginService { poll.message || '新设备确认中', 'new-device-confirming', ); - const loginResult = await client.newDeviceLogin(session.id); + const passwordMd5 = this.toolsService.toTrimmedString(session.passwordMd5); + if (!passwordMd5 || !this.hasNewDevicePullQrCodeSig(session)) { + return this.failNewDeviceVerification( + session, + container, + '新设备验证登录上下文缺失,请重新更新登录', + ); + } + const loginResult = await client.newDeviceLogin({ + newDevicePullQrCodeSig: session.newDevicePullQrCodeSig, + passwordMd5, + uin, + }); if (!loginResult.success) { return this.failNewDeviceVerification( session, @@ -685,6 +701,7 @@ export class QqbotNapcatLoginService { loginResult.message || '新设备验证失败', ); } + session.newDeviceBytesToken = undefined; session.newDeviceQrcode = undefined; session.newDeviceStatus = 'verified'; session.errorMessage = '新设备验证成功,继续登录'; @@ -723,6 +740,81 @@ export class QqbotNapcatLoginService { ); } + private async refreshNewDeviceQrcode( + session: QqbotLoginScanSession, + container: QqbotNapcatRuntime, + client: NapcatLoginApiClient, + ) { + const request = this.getNewDeviceQrRequest(session); + if (!request) { + return this.failNewDeviceVerification( + session, + container, + '新设备验证上下文缺失,请重新更新登录', + ); + } + + try { + const qrcode = await client.getNewDeviceQRCode(request); + this.applyNewDeviceQrcode(session, qrcode); + this.persistLoginSession(session); + this.publishScanResultEvent( + session, + 'new-device-qrcode-ready', + 'processing', + '新设备二维码待扫码', + ); + return this.toResult(session); + } catch (err) { + return this.keepNewDevicePending( + session, + 'qr-pending', + this.toolsService.getErrorMessage(err) || '新设备二维码生成失败', + 'new-device-required', + ); + } + } + + private applyNewDeviceQrcode( + session: QqbotLoginScanSession, + qrcode: NewDeviceQrCode, + ) { + session.newDeviceQrcode = qrcode.qrcodeUrl; + session.newDeviceBytesToken = qrcode.bytesToken; + session.deviceVerifyUrl = qrcode.deviceVerifyUrl || session.deviceVerifyUrl; + const pullQrCodeSig = this.pickNewDevicePullQrCodeSig(qrcode.pullQrCodeSig); + if (pullQrCodeSig !== undefined) { + session.newDevicePullQrCodeSig = pullQrCodeSig; + } + session.newDeviceStatus = qrcode.status; + session.errorMessage = '新设备二维码待扫码'; + } + + private getNewDeviceQrRequest( + session: QqbotLoginScanSession, + ): NewDeviceQrRequest | null { + const uin = this.toolsService.toTrimmedString(session.expectedSelfId); + const jumpUrl = this.toolsService.toTrimmedString(session.deviceVerifyUrl); + if (!uin || !jumpUrl) return null; + return { jumpUrl, uin }; + } + + private pickNewDevicePullQrCodeSig(value: unknown) { + if (value === undefined || value === null) return undefined; + if (typeof value === 'string') { + const text = this.toolsService.toTrimmedString(value); + return text || undefined; + } + return value; + } + + private hasNewDevicePullQrCodeSig(session: QqbotLoginScanSession) { + return ( + this.pickNewDevicePullQrCodeSig(session.newDevicePullQrCodeSig) !== + undefined + ); + } + private keepNewDevicePending( session: QqbotLoginScanSession, status: NonNullable, @@ -750,6 +842,7 @@ export class QqbotNapcatLoginService { message: string, ) { session.newDeviceQrcode = undefined; + session.newDeviceBytesToken = undefined; session.newDeviceStatus = 'failed'; session.errorMessage = message; this.persistLoginSession(session); diff --git a/src/modules/qqbot/napcat/infrastructure/integration/napcat-login-api.client.ts b/src/modules/qqbot/napcat/infrastructure/integration/napcat-login-api.client.ts index a25b23a..6a09934 100644 --- a/src/modules/qqbot/napcat/infrastructure/integration/napcat-login-api.client.ts +++ b/src/modules/qqbot/napcat/infrastructure/integration/napcat-login-api.client.ts @@ -12,26 +12,41 @@ export type NewDeviceQrStatus = | 'verified'; export type NewDeviceQrCode = { + bytesToken: string; deviceVerifyUrl?: string; - pullQrCodeSig?: string; + pullQrCodeSig?: unknown; qrcodeUrl: string; - sessionId: string; status: 'qr-pending'; + strUrl?: string; }; export type NewDeviceQrPollResult = { message?: string; - sessionId: string; status: Exclude; }; export type NewDeviceLoginResult = { message?: string; - sessionId: string; status: 'failed' | 'verified'; success: boolean; }; +export type NewDeviceQrRequest = { + jumpUrl: string; + uin: string; +}; + +export type NewDeviceQrPollRequest = { + bytesToken: string; + uin: string; +}; + +export type NewDeviceLoginRequest = { + newDevicePullQrCodeSig?: unknown; + passwordMd5: string; + uin: string; +}; + export type NapcatLoginApiTransport = { post(path: string, body: Record): Promise; }; @@ -175,36 +190,62 @@ export class NapcatWebuiHttpClient { export class NapcatLoginApiClient { constructor(private readonly transport: NapcatLoginApiTransport) {} - async getNewDeviceQRCode(sessionId: string): Promise { + async getNewDeviceQRCode( + input: NewDeviceQrRequest, + ): Promise { + const uin = this.pickString(input.uin); + const jumpUrl = this.pickString(input.jumpUrl); + if (!uin || !jumpUrl) { + throw new Error('uin and jumpUrl are required'); + } + const data = (await this.transport.post( '/api/QQLogin/GetNewDeviceQRCode', - { sessionId }, + { jumpUrl, uin }, )) as Record; - const qrcodeUrl = this.pickString( + const strUrl = this.pickString(data.str_url, data.strUrl); + const qrcodeSource = this.pickString( data.qrcodeUrl, data.qrcodeurl, data.qrcode, data.url, ); - const jumpUrl = this.pickString(data.jumpUrl, data.verifyUrl); - const newDeviceQrcodeUrl = qrcodeUrl || (await this.createQrcode(jumpUrl)); + const returnedJumpUrl = this.pickString(data.jumpUrl, data.verifyUrl); + const bytesToken = + this.pickString(data.bytes_token, data.bytesToken) || + this.deriveBytesToken(strUrl); + const newDeviceQrcodeUrl = await this.toQrcodeDataUrl( + qrcodeSource || strUrl || returnedJumpUrl || jumpUrl, + ); if (!newDeviceQrcodeUrl) { throw new Error('NapCat 未返回新设备验证二维码'); } + if (!bytesToken) { + throw new Error('NapCat 未返回新设备验证 bytesToken'); + } return { - deviceVerifyUrl: jumpUrl || undefined, - pullQrCodeSig: this.pickString(data.newDevicePullQrCodeSig, data.sig), + bytesToken, + deviceVerifyUrl: returnedJumpUrl || jumpUrl, + pullQrCodeSig: this.pickPayload(data.newDevicePullQrCodeSig, data.sig), qrcodeUrl: newDeviceQrcodeUrl, - sessionId, status: 'qr-pending', + strUrl: strUrl || undefined, }; } - async pollNewDeviceQR(sessionId: string): Promise { + async pollNewDeviceQR( + input: NewDeviceQrPollRequest, + ): Promise { + const uin = this.pickString(input.uin); + const bytesToken = this.pickString(input.bytesToken); + if (!uin || !bytesToken) { + throw new Error('uin and bytesToken are required'); + } + const data = (await this.transport.post( '/api/QQLogin/PollNewDeviceQR', - { sessionId }, + { bytesToken, uin }, )) as Record; const status = this.normalizePollStatus( this.pickString(data.status, data.state, data.result), @@ -212,21 +253,33 @@ export class NapcatLoginApiClient { return { message: this.pickString(data.message, data.reason) || undefined, - sessionId, status, }; } - async newDeviceLogin(sessionId: string): Promise { + async newDeviceLogin( + input: NewDeviceLoginRequest, + ): Promise { + const uin = this.pickString(input.uin); + const passwordMd5 = this.pickString(input.passwordMd5); + if (!uin || !passwordMd5 || input.newDevicePullQrCodeSig == null) { + throw new Error( + 'uin, passwordMd5 and newDevicePullQrCodeSig are required', + ); + } + const data = (await this.transport.post( '/api/QQLogin/NewDeviceLogin', - { sessionId }, + { + newDevicePullQrCodeSig: input.newDevicePullQrCodeSig, + passwordMd5, + uin, + }, )) as Record; - const success = data.success !== false; + const success = this.normalizeLoginSuccess(data); return { message: this.pickString(data.message, data.reason) || undefined, - sessionId, status: success ? 'verified' : 'failed', success, }; @@ -243,6 +296,15 @@ export class NapcatLoginApiClient { return 'qr-pending'; } + private normalizeLoginSuccess(data: Record) { + const status = this.pickString(data.status, data.state, data.result) + .toLowerCase() + .replace(/[_\s-]+/g, ''); + if (['fail', 'failed', 'error', 'denied'].includes(status)) return false; + if (['ok', 'success', 'verified'].includes(status)) return true; + return data.success !== false; + } + private pickString(...values: unknown[]) { for (const value of values) { if (typeof value !== 'string') continue; @@ -252,6 +314,37 @@ export class NapcatLoginApiClient { return ''; } + private pickPayload(...values: unknown[]) { + for (const value of values) { + if (value === undefined || value === null) continue; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed) return trimmed; + continue; + } + return value; + } + return undefined; + } + + private deriveBytesToken(strUrl: string) { + if (!strUrl) return ''; + try { + const proofUrl = new URL(strUrl).searchParams.get('str_url') || ''; + if (!proofUrl) return ''; + return Buffer.from(proofUrl, 'utf8').toString('base64'); + } catch { + return ''; + } + } + + private async toQrcodeDataUrl(text: string) { + const normalized = this.pickString(text); + if (!normalized) return ''; + if (normalized.startsWith('data:image/')) return normalized; + return this.createQrcode(normalized); + } + private async createQrcode(text: string) { if (!text) return ''; return QRCode.toDataURL(text, { diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts index 634af71..1e980f8 100644 --- a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts +++ b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts @@ -102,6 +102,7 @@ export class NapcatLoginStateStoreService { void this.saveChallenge({ challengePayload: { deviceVerifyUrl: session.deviceVerifyUrl, + newDeviceBytesToken: session.newDeviceBytesToken, newDevicePullQrCodeSig: session.newDevicePullQrCodeSig, newDeviceQrcode: session.newDeviceQrcode, }, @@ -230,10 +231,17 @@ export class NapcatLoginStateStoreService { } if ( !session.newDevicePullQrCodeSig && - typeof payload.newDevicePullQrCodeSig === 'string' + payload.newDevicePullQrCodeSig !== undefined && + payload.newDevicePullQrCodeSig !== null ) { session.newDevicePullQrCodeSig = payload.newDevicePullQrCodeSig; } + if ( + !session.newDeviceBytesToken && + typeof payload.newDeviceBytesToken === 'string' + ) { + session.newDeviceBytesToken = payload.newDeviceBytesToken; + } if (!session.newDeviceQrcode) { session.newDeviceQrcode = typeof payload.newDeviceQrcode === 'string' diff --git a/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts b/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts index 65313c3..7af51a2 100644 --- a/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts +++ b/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts @@ -292,7 +292,8 @@ describe('NapCat persistent login state contract', () => { loginChallengeRepository.rows.push({ challengePayload: { deviceVerifyUrl: 'https://ti.qq.com/new-device/verify', - newDevicePullQrCodeSig: 'sig-new-device', + newDeviceBytesToken: 'bytes-new-device', + newDevicePullQrCodeSig: { key: 'sig-new-device' }, newDeviceQrcode: 'data:image/png;base64,new-device-qrcode', }, challengeType: 'new-device', @@ -335,7 +336,8 @@ describe('NapCat persistent login state contract', () => { await expect(store.get('new-device-recover')).resolves.toEqual( expect.objectContaining({ deviceVerifyUrl: 'https://ti.qq.com/new-device/verify', - newDevicePullQrCodeSig: 'sig-new-device', + newDeviceBytesToken: 'bytes-new-device', + newDevicePullQrCodeSig: { key: 'sig-new-device' }, newDeviceQrcode: 'data:image/png;base64,new-device-qrcode', newDeviceStatus: 'qr-pending', }), diff --git a/test/modules/qqbot/napcat/new-device-flow.spec.ts b/test/modules/qqbot/napcat/new-device-flow.spec.ts index 1c30b82..cacd56e 100644 --- a/test/modules/qqbot/napcat/new-device-flow.spec.ts +++ b/test/modules/qqbot/napcat/new-device-flow.spec.ts @@ -1,15 +1,17 @@ import { NapcatLoginApiClient } from '../../../../src/modules/qqbot/napcat'; describe('NapCat new-device flow API client', () => { - it('runs GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin with normalized states', async () => { + it('runs GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin with NapCat WebUI payloads', async () => { const calls: Array<{ body: unknown; path: string }> = []; + const pullQrCodeSig = { key: 'sig-1' }; const client = new NapcatLoginApiClient({ post: jest.fn(async (path: string, body: unknown) => { calls.push({ body, path }); if (path.endsWith('/GetNewDeviceQRCode')) { return { - newDevicePullQrCodeSig: 'sig-1', - qrcodeUrl: 'data:image/png;base64,new-device', + bytes_token: 'bytes-1', + newDevicePullQrCodeSig: pullQrCodeSig, + str_url: 'https://qq.example/new-device/qr?str_url=https%3A%2F%2Fproof.qq.com%2Ftoken', }; } if (path.endsWith('/PollNewDeviceQR') && calls.length === 2) { @@ -19,84 +21,133 @@ describe('NapCat new-device flow API client', () => { return { status: 'confirming' }; } if (path.endsWith('/NewDeviceLogin')) { - return { message: 'ok', success: true }; + return { message: 'ok', status: 'ok' }; } throw new Error(`unexpected path ${path}`); }), }); - const qr = await client.getNewDeviceQRCode('session-1'); - const scanned = await client.pollNewDeviceQR('session-1'); - const confirming = await client.pollNewDeviceQR('session-1'); - const verified = await client.newDeviceLogin('session-1'); + const qr = await client.getNewDeviceQRCode({ + jumpUrl: 'https://accounts.qq.com/safe/verify?sid=sid-1', + uin: '10001', + }); + const scanned = await client.pollNewDeviceQR({ + bytesToken: qr.bytesToken, + uin: '10001', + }); + const confirming = await client.pollNewDeviceQR({ + bytesToken: qr.bytesToken, + uin: '10001', + }); + const verified = await client.newDeviceLogin({ + newDevicePullQrCodeSig: pullQrCodeSig, + passwordMd5: '0123456789abcdef0123456789abcdef', + uin: '10001', + }); - expect(qr).toEqual({ - pullQrCodeSig: 'sig-1', - qrcodeUrl: 'data:image/png;base64,new-device', - sessionId: 'session-1', + expect(qr).toMatchObject({ + bytesToken: 'bytes-1', + deviceVerifyUrl: 'https://accounts.qq.com/safe/verify?sid=sid-1', + pullQrCodeSig, status: 'qr-pending', }); + expect(qr.qrcodeUrl).toMatch(/^data:image\/png;base64,/); expect(scanned.status).toBe('scanned'); expect(confirming.status).toBe('confirming'); expect(verified).toEqual({ message: 'ok', - sessionId: 'session-1', status: 'verified', success: true, }); expect(calls).toEqual([ { - body: { sessionId: 'session-1' }, + body: { + jumpUrl: 'https://accounts.qq.com/safe/verify?sid=sid-1', + uin: '10001', + }, path: '/api/QQLogin/GetNewDeviceQRCode', }, { - body: { sessionId: 'session-1' }, + body: { bytesToken: 'bytes-1', uin: '10001' }, path: '/api/QQLogin/PollNewDeviceQR', }, { - body: { sessionId: 'session-1' }, + body: { bytesToken: 'bytes-1', uin: '10001' }, path: '/api/QQLogin/PollNewDeviceQR', }, { - body: { sessionId: 'session-1' }, + body: { + newDevicePullQrCodeSig: pullQrCodeSig, + passwordMd5: '0123456789abcdef0123456789abcdef', + uin: '10001', + }, path: '/api/QQLogin/NewDeviceLogin', }, ]); }); - it('encodes jumpUrl as a QR image before polling new-device verification', async () => { + it('derives bytesToken from str_url when NapCat omits bytes_token', async () => { + const rawProofUrl = 'https://proof.qq.com/token?a=1&b=2'; + const strUrl = `https://qq.example/new-device/qr?str_url=${encodeURIComponent(rawProofUrl)}`; const client = new NapcatLoginApiClient({ post: jest .fn() - .mockResolvedValueOnce({ jumpUrl: 'https://qq.example/new-device' }) + .mockResolvedValueOnce({ str_url: strUrl }) .mockResolvedValueOnce({ status: 'waiting' }) .mockResolvedValueOnce({ status: 'expired', message: 'expired' }) .mockResolvedValueOnce({ status: 'failed', message: 'denied' }), }); - const qr = await client.getNewDeviceQRCode('session-2'); + const qr = await client.getNewDeviceQRCode({ + jumpUrl: 'https://accounts.qq.com/safe/verify?sid=sid-2', + uin: '10002', + }); expect(qr).toMatchObject({ - deviceVerifyUrl: 'https://qq.example/new-device', - sessionId: 'session-2', + bytesToken: Buffer.from(rawProofUrl, 'utf8').toString('base64'), + deviceVerifyUrl: 'https://accounts.qq.com/safe/verify?sid=sid-2', status: 'qr-pending', }); expect(qr.qrcodeUrl).toMatch(/^data:image\/png;base64,/); - expect(qr.qrcodeUrl).not.toBe('https://qq.example/new-device'); - await expect(client.pollNewDeviceQR('session-2')).resolves.toEqual({ + expect(qr.qrcodeUrl).not.toBe(strUrl); + await expect( + client.pollNewDeviceQR({ bytesToken: qr.bytesToken, uin: '10002' }), + ).resolves.toEqual({ message: undefined, - sessionId: 'session-2', status: 'qr-pending', }); - await expect(client.pollNewDeviceQR('session-2')).resolves.toEqual({ + await expect( + client.pollNewDeviceQR({ bytesToken: qr.bytesToken, uin: '10002' }), + ).resolves.toEqual({ message: 'expired', - sessionId: 'session-2', status: 'expired', }); - await expect(client.pollNewDeviceQR('session-2')).resolves.toEqual({ + await expect( + client.pollNewDeviceQR({ bytesToken: qr.bytesToken, uin: '10002' }), + ).resolves.toEqual({ message: 'denied', - sessionId: 'session-2', status: 'failed', }); }); + + it('normalizes NewDeviceLogin status-only failures', async () => { + const client = new NapcatLoginApiClient({ + post: jest.fn().mockResolvedValue({ + message: 'denied', + status: 'failed', + }), + }); + + await expect( + client.newDeviceLogin({ + newDevicePullQrCodeSig: 'sig-1', + passwordMd5: '0123456789abcdef0123456789abcdef', + uin: '10001', + }), + ).resolves.toEqual({ + message: 'denied', + status: 'failed', + success: false, + }); + }); }); diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index 9f66c27..8a64ef4 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -1567,6 +1567,7 @@ describe('QqbotNapcatLoginService', () => { } if (path === '/api/QQLogin/GetNewDeviceQRCode') { return { + bytes_token: 'bytes-new-device', newDevicePullQrCodeSig: 'sig-new-device', qrcodeUrl: 'data:image/png;base64,new-device-qrcode', }; @@ -1589,7 +1590,8 @@ describe('QqbotNapcatLoginService', () => { container, '/api/QQLogin/GetNewDeviceQRCode', { - sessionId: session.id, + jumpUrl: 'https://ti.qq.com/new-device/verify', + uin: '10001', }, ); expect(waitForPasswordLoginStatus).not.toHaveBeenCalled(); @@ -1602,6 +1604,7 @@ describe('QqbotNapcatLoginService', () => { expect(result.newDeviceStatus).toBe('qr-pending'); expect(result.errorMessage).toBe('新设备二维码待扫码'); expect(session.newDevicePullQrCodeSig).toBe('sig-new-device'); + expect(session.newDeviceBytesToken).toBe('bytes-new-device'); expect(session.deviceVerifyUrl).toBe('https://ti.qq.com/new-device/verify'); const steps = ( (refreshService as any).sessionEventLogs.get(session.id) ?? [] @@ -1619,6 +1622,7 @@ describe('QqbotNapcatLoginService', () => { const accountService = { ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), }; + const pullQrCodeSig = { key: 'sig-new-device' }; const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-new-device-poll', @@ -1643,7 +1647,8 @@ describe('QqbotNapcatLoginService', () => { status: 'pending', }); session.deviceVerifyUrl = 'https://ti.qq.com/new-device/verify'; - session.newDevicePullQrCodeSig = 'sig-new-device'; + session.newDeviceBytesToken = 'bytes-new-device'; + session.newDevicePullQrCodeSig = pullQrCodeSig; session.newDeviceQrcode = 'data:image/png;base64,new-device-qrcode'; session.newDeviceStatus = 'qr-pending'; session.passwordMd5 = '0123456789abcdef0123456789abcdef'; @@ -1685,7 +1690,8 @@ describe('QqbotNapcatLoginService', () => { container, '/api/QQLogin/PollNewDeviceQR', { - sessionId: session.id, + bytesToken: 'bytes-new-device', + uin: '10001', }, ); expect(postNapcat).toHaveBeenNthCalledWith( @@ -1693,7 +1699,8 @@ describe('QqbotNapcatLoginService', () => { container, '/api/QQLogin/PollNewDeviceQR', { - sessionId: session.id, + bytesToken: 'bytes-new-device', + uin: '10001', }, ); expect(postNapcat).toHaveBeenNthCalledWith( @@ -1701,7 +1708,9 @@ describe('QqbotNapcatLoginService', () => { container, '/api/QQLogin/NewDeviceLogin', { - sessionId: session.id, + newDevicePullQrCodeSig: pullQrCodeSig, + passwordMd5: '0123456789abcdef0123456789abcdef', + uin: '10001', }, ); expect(session.newDeviceQrcode).toBeUndefined(); @@ -1719,6 +1728,60 @@ describe('QqbotNapcatLoginService', () => { ); }); + it('recovers missing new-device bytesToken before polling', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-new-device-recover', + name: 'napcat-10001', + }; + const containerService = { + findRuntimeById: jest.fn().mockResolvedValue(container), + removeUnboundContainer: jest.fn().mockResolvedValue(false), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + {} as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (refreshService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '10001', + mode: 'refresh', + status: 'pending', + }); + session.deviceVerifyUrl = 'https://ti.qq.com/new-device/verify'; + session.newDevicePullQrCodeSig = 'sig-new-device'; + session.newDeviceStatus = 'qr-pending'; + session.passwordMd5 = '0123456789abcdef0123456789abcdef'; + (refreshService as any).sessions.set(session.id, session); + const postNapcat = jest + .spyOn(refreshService as any, 'postNapcat') + .mockResolvedValue({ + bytes_token: 'bytes-recovered', + qrcodeUrl: 'data:image/png;base64,recovered-new-device-qrcode', + }); + + const result = await refreshService.status(session.id); + + expect(result.status).toBe('pending'); + expect(result.newDeviceStatus).toBe('qr-pending'); + expect(result.newDeviceQrcode).toBe( + 'data:image/png;base64,recovered-new-device-qrcode', + ); + expect(session.newDeviceBytesToken).toBe('bytes-recovered'); + expect(postNapcat).toHaveBeenCalledTimes(1); + expect(postNapcat).toHaveBeenCalledWith( + container, + '/api/QQLogin/GetNewDeviceQRCode', + { + jumpUrl: 'https://ti.qq.com/new-device/verify', + uin: '10001', + }, + ); + }); + it('cleans runtime password env when cancelling captcha pending login', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/',