fix: 修复NapCat新设备真实扫码推进
This commit is contained in:
parent
28afc6ac52
commit
24b63c7332
@ -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,
|
||||
|
||||
@ -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, '');
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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/',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user