fix: 修复NapCat新设备真实扫码推进

This commit is contained in:
sunlei 2026-06-16 20:51:30 +08:00
parent 28afc6ac52
commit 24b63c7332
4 changed files with 259 additions and 7 deletions

View File

@ -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,

View File

@ -21,12 +21,16 @@ export type NewDeviceQrCode = {
};
export type NewDeviceQrPollResult = {
confirmToken?: string;
message?: string;
status: Exclude<NewDeviceQrStatus, 'verified'>;
};
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<string, unknown>;
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<string, unknown>;
const success = this.normalizeLoginSuccess(data);
);
const data =
payload && typeof payload === 'object'
? (payload as Record<string, unknown>)
: {};
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<string, unknown>) {
if (data.needNewDevice === true) return false;
const status = this.pickString(data.status, data.state, data.result)
.toLowerCase()
.replace(/[_\s-]+/g, '');

View File

@ -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,
});
});
});

View File

@ -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/',