From 24b63c7332b34def0c8c87421b4fee0161cd3d18 Mon Sep 17 00:00:00 2001 From: sunlei Date: Tue, 16 Jun 2026 20:51:30 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DNapCat=E6=96=B0?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E7=9C=9F=E5=AE=9E=E6=89=AB=E7=A0=81=E6=8E=A8?= =?UTF-8?q?=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../login/qqbot-napcat-login.service.ts | 30 ++++- .../integration/napcat-login-api.client.ts | 41 ++++++- .../qqbot/napcat/new-device-flow.spec.ts | 112 ++++++++++++++++++ .../qqbot-napcat-login.service.spec.ts | 83 +++++++++++++ 4 files changed, 259 insertions(+), 7 deletions(-) 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 2a4179b..88c6a25 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 @@ -675,13 +675,17 @@ export class QqbotNapcatLoginService { ); } if (poll.status === 'confirming') { + const confirmToken = this.pickNewDevicePullQrCodeSig(poll.confirmToken); + if (confirmToken !== undefined) { + session.newDevicePullQrCodeSig = confirmToken; + } this.keepNewDevicePending( session, 'confirming', poll.message || '新设备确认中', 'new-device-confirming', ); - const passwordMd5 = this.toolsService.toTrimmedString(session.passwordMd5); + const passwordMd5 = await this.resolveNewDevicePasswordMd5(session); if (!passwordMd5 || !this.hasNewDevicePullQrCodeSig(session)) { return this.failNewDeviceVerification( session, @@ -694,6 +698,13 @@ export class QqbotNapcatLoginService { passwordMd5, uin, }); + if (loginResult.needNewDevice && loginResult.jumpUrl) { + return this.startNewDeviceVerification(session, container, { + jumpUrl: loginResult.jumpUrl, + needNewDevice: true, + newDevicePullQrCodeSig: loginResult.pullQrCodeSig, + }); + } if (!loginResult.success) { return this.failNewDeviceVerification( session, @@ -740,6 +751,23 @@ export class QqbotNapcatLoginService { ); } + private async resolveNewDevicePasswordMd5(session: QqbotLoginScanSession) { + const existing = this.toolsService.toTrimmedString(session.passwordMd5); + if (existing) return existing; + if (!session.accountId) return ''; + + const account = + await this.accountService.findByIdWithNapcatLoginSecret(session.accountId); + const password = this.accountService.getNapcatLoginPassword(account); + if (!password) return ''; + + session.passwordMd5 = createHash('md5') + .update(password, 'utf8') + .digest('hex'); + this.persistLoginSession(session); + return session.passwordMd5; + } + private async refreshNewDeviceQrcode( session: QqbotLoginScanSession, container: QqbotNapcatRuntime, 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 6a09934..5e60c0e 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 @@ -21,12 +21,16 @@ export type NewDeviceQrCode = { }; export type NewDeviceQrPollResult = { + confirmToken?: string; message?: string; status: Exclude; }; export type NewDeviceLoginResult = { + jumpUrl?: string; message?: string; + needNewDevice?: boolean; + pullQrCodeSig?: unknown; status: 'failed' | 'verified'; success: boolean; }; @@ -215,7 +219,7 @@ export class NapcatLoginApiClient { this.pickString(data.bytes_token, data.bytesToken) || this.deriveBytesToken(strUrl); const newDeviceQrcodeUrl = await this.toQrcodeDataUrl( - qrcodeSource || strUrl || returnedJumpUrl || jumpUrl, + strUrl || qrcodeSource || returnedJumpUrl || jumpUrl, ); if (!newDeviceQrcodeUrl) { throw new Error('NapCat 未返回新设备验证二维码'); @@ -248,10 +252,16 @@ export class NapcatLoginApiClient { { bytesToken, uin }, )) as Record; const status = this.normalizePollStatus( - this.pickString(data.status, data.state, data.result), + this.pickPayload( + data.status, + data.state, + data.result, + data.uint32_guarantee_status, + ), ); return { + confirmToken: this.pickString(data.str_nt_succ_token) || undefined, message: this.pickString(data.message, data.reason) || undefined, status, }; @@ -268,24 +278,42 @@ export class NapcatLoginApiClient { ); } - const data = (await this.transport.post( + const payload = await this.transport.post( '/api/QQLogin/NewDeviceLogin', { newDevicePullQrCodeSig: input.newDevicePullQrCodeSig, passwordMd5, uin, }, - )) as Record; - const success = this.normalizeLoginSuccess(data); + ); + const data = + payload && typeof payload === 'object' + ? (payload as Record) + : {}; + const success = + payload === null || payload === undefined + ? true + : this.normalizeLoginSuccess(data); return { + jumpUrl: this.pickString(data.jumpUrl, data.verifyUrl) || undefined, message: this.pickString(data.message, data.reason) || undefined, + needNewDevice: data.needNewDevice === true, + pullQrCodeSig: this.pickPayload(data.newDevicePullQrCodeSig, data.sig), status: success ? 'verified' : 'failed', success, }; } - private normalizePollStatus(status: string): NewDeviceQrPollResult['status'] { + private normalizePollStatus(status: unknown): NewDeviceQrPollResult['status'] { + if (typeof status === 'number') { + if (status === 3) return 'scanned'; + if (status === 1) return 'confirming'; + if (status < 0) return 'failed'; + return 'qr-pending'; + } + if (typeof status !== 'string') return 'qr-pending'; + const normalized = status.toLowerCase().replace(/[_\s-]+/g, ''); if (['scan', 'scanned'].includes(normalized)) return 'scanned'; if (['confirm', 'confirming'].includes(normalized)) return 'confirming'; @@ -297,6 +325,7 @@ export class NapcatLoginApiClient { } private normalizeLoginSuccess(data: Record) { + if (data.needNewDevice === true) return false; const status = this.pickString(data.status, data.state, data.result) .toLowerCase() .replace(/[_\s-]+/g, ''); diff --git a/test/modules/qqbot/napcat/new-device-flow.spec.ts b/test/modules/qqbot/napcat/new-device-flow.spec.ts index cacd56e..c938326 100644 --- a/test/modules/qqbot/napcat/new-device-flow.spec.ts +++ b/test/modules/qqbot/napcat/new-device-flow.spec.ts @@ -1,4 +1,5 @@ import { NapcatLoginApiClient } from '../../../../src/modules/qqbot/napcat'; +import * as QRCode from 'qrcode'; describe('NapCat new-device flow API client', () => { it('runs GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin with NapCat WebUI payloads', async () => { @@ -55,7 +56,10 @@ describe('NapCat new-device flow API client', () => { expect(scanned.status).toBe('scanned'); expect(confirming.status).toBe('confirming'); expect(verified).toEqual({ + jumpUrl: undefined, message: 'ok', + needNewDevice: false, + pullQrCodeSig: undefined, status: 'verified', success: true, }); @@ -130,6 +134,39 @@ describe('NapCat new-device flow API client', () => { }); }); + it('prefers NapCat str_url over generic url as QR payload', async () => { + const strUrl = 'https://qq.example/new-device/qr?str_url=https%3A%2F%2Fproof.qq.com%2Ftoken'; + const toDataUrl = jest.spyOn( + QRCode, + 'toDataURL', + ) as unknown as jest.Mock; + toDataUrl.mockResolvedValue('data:image/png;base64,encoded-qr'); + const client = new NapcatLoginApiClient({ + post: jest.fn().mockResolvedValue({ + bytes_token: 'bytes-1', + str_url: strUrl, + url: 'https://accounts.qq.com/safe/verify?missing=location-context', + }), + }); + + try { + const qr = await client.getNewDeviceQRCode({ + jumpUrl: 'https://accounts.qq.com/safe/verify?sig=sig&uin-token=token', + uin: '10001', + }); + + expect(qr.qrcodeUrl).toBe('data:image/png;base64,encoded-qr'); + expect(toDataUrl).toHaveBeenCalledWith( + strUrl, + expect.objectContaining({ + type: 'image/png', + }), + ); + } finally { + toDataUrl.mockRestore(); + } + }); + it('normalizes NewDeviceLogin status-only failures', async () => { const client = new NapcatLoginApiClient({ post: jest.fn().mockResolvedValue({ @@ -145,9 +182,84 @@ describe('NapCat new-device flow API client', () => { uin: '10001', }), ).resolves.toEqual({ + jumpUrl: undefined, message: 'denied', + needNewDevice: false, + pullQrCodeSig: undefined, status: 'failed', success: false, }); }); + + it('normalizes NapCat official numeric QR poll statuses and success token', async () => { + const client = new NapcatLoginApiClient({ + post: jest + .fn() + .mockResolvedValueOnce({ uint32_guarantee_status: 3 }) + .mockResolvedValueOnce({ + str_nt_succ_token: 'nt-success-token', + uint32_guarantee_status: 1, + }), + }); + + await expect( + client.pollNewDeviceQR({ bytesToken: 'bytes-1', uin: '10001' }), + ).resolves.toEqual({ + message: undefined, + status: 'scanned', + }); + await expect( + client.pollNewDeviceQR({ bytesToken: 'bytes-1', uin: '10001' }), + ).resolves.toEqual({ + confirmToken: 'nt-success-token', + message: undefined, + status: 'confirming', + }); + }); + + it('does not treat repeated needNewDevice from NewDeviceLogin as success', async () => { + const client = new NapcatLoginApiClient({ + post: jest.fn().mockResolvedValue({ + jumpUrl: 'https://accounts.qq.com/safe/verify?sig=next&uin-token=next', + needNewDevice: true, + newDevicePullQrCodeSig: 'next-sig', + }), + }); + + await expect( + client.newDeviceLogin({ + newDevicePullQrCodeSig: 'nt-success-token', + passwordMd5: '0123456789abcdef0123456789abcdef', + uin: '10001', + }), + ).resolves.toEqual({ + jumpUrl: 'https://accounts.qq.com/safe/verify?sig=next&uin-token=next', + message: undefined, + needNewDevice: true, + pullQrCodeSig: 'next-sig', + status: 'failed', + success: false, + }); + }); + + it('treats NapCat NewDeviceLogin null payload as success', async () => { + const client = new NapcatLoginApiClient({ + post: jest.fn().mockResolvedValue(null), + }); + + await expect( + client.newDeviceLogin({ + newDevicePullQrCodeSig: 'nt-success-token', + passwordMd5: '0123456789abcdef0123456789abcdef', + uin: '10001', + }), + ).resolves.toEqual({ + jumpUrl: undefined, + message: undefined, + needNewDevice: false, + pullQrCodeSig: undefined, + status: 'verified', + success: true, + }); + }); }); diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index 8a64ef4..19dc7ef 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -1728,6 +1728,89 @@ describe('QqbotNapcatLoginService', () => { ); }); + it('uses NapCat confirmed token and reloads saved password when new-device session lost passwordMd5', async () => { + const account = { + id: 'account-1', + napcatLoginPasswordSecret: 'encrypted-secret', + selfId: '10001', + }; + const accountService = { + ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), + findByIdWithNapcatLoginSecret: jest.fn().mockResolvedValue(account), + getNapcatLoginPassword: jest.fn().mockReturnValue('qq-password'), + }; + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-new-device-confirmed', + name: 'napcat-10001', + }; + const containerService = { + bindAccount: jest.fn().mockResolvedValue(undefined), + findRuntimeById: jest.fn().mockResolvedValue(container), + removeUnboundContainer: jest.fn().mockResolvedValue(false), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown 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://accounts.qq.com/safe/verify?sig=sig&uin-token=token'; + session.newDeviceBytesToken = 'bytes-new-device'; + session.newDevicePullQrCodeSig = 'initial-sig'; + session.newDeviceQrcode = 'data:image/png;base64,new-device-qrcode'; + session.newDeviceStatus = 'qr-pending'; + (refreshService as any).sessions.set(session.id, session); + const postNapcat = jest + .spyOn(refreshService as any, 'postNapcat') + .mockResolvedValueOnce({ + str_nt_succ_token: 'nt-success-token', + uint32_guarantee_status: 1, + }) + .mockResolvedValueOnce({ + success: true, + }); + jest + .spyOn(refreshService as any, 'waitForPasswordLoginStatus') + .mockResolvedValue({ + isLogin: true, + }); + jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ + nickname: 'Kwi', + online: true, + uin: '10001', + }); + jest + .spyOn(refreshService as any, 'clearRuntimeLoginPasswordAfterSuccess') + .mockResolvedValue(undefined); + + const result = await refreshService.status(session.id); + + expect(result.status).toBe('success'); + expect(accountService.findByIdWithNapcatLoginSecret).toHaveBeenCalledWith( + 'account-1', + ); + expect(accountService.getNapcatLoginPassword).toHaveBeenCalledWith(account); + expect(postNapcat).toHaveBeenNthCalledWith( + 2, + container, + '/api/QQLogin/NewDeviceLogin', + { + newDevicePullQrCodeSig: 'nt-success-token', + passwordMd5: '7fe1f9ae1130b64e9ca1441492c382c0', + uin: '10001', + }, + ); + expect(session.newDeviceStatus).toBe('verified'); + }); + it('recovers missing new-device bytesToken before polling', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/',