fix: 修复NapCat新设备真实扫码推进
This commit is contained in:
parent
28afc6ac52
commit
24b63c7332
@ -675,13 +675,17 @@ export class QqbotNapcatLoginService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (poll.status === 'confirming') {
|
if (poll.status === 'confirming') {
|
||||||
|
const confirmToken = this.pickNewDevicePullQrCodeSig(poll.confirmToken);
|
||||||
|
if (confirmToken !== undefined) {
|
||||||
|
session.newDevicePullQrCodeSig = confirmToken;
|
||||||
|
}
|
||||||
this.keepNewDevicePending(
|
this.keepNewDevicePending(
|
||||||
session,
|
session,
|
||||||
'confirming',
|
'confirming',
|
||||||
poll.message || '新设备确认中',
|
poll.message || '新设备确认中',
|
||||||
'new-device-confirming',
|
'new-device-confirming',
|
||||||
);
|
);
|
||||||
const passwordMd5 = this.toolsService.toTrimmedString(session.passwordMd5);
|
const passwordMd5 = await this.resolveNewDevicePasswordMd5(session);
|
||||||
if (!passwordMd5 || !this.hasNewDevicePullQrCodeSig(session)) {
|
if (!passwordMd5 || !this.hasNewDevicePullQrCodeSig(session)) {
|
||||||
return this.failNewDeviceVerification(
|
return this.failNewDeviceVerification(
|
||||||
session,
|
session,
|
||||||
@ -694,6 +698,13 @@ export class QqbotNapcatLoginService {
|
|||||||
passwordMd5,
|
passwordMd5,
|
||||||
uin,
|
uin,
|
||||||
});
|
});
|
||||||
|
if (loginResult.needNewDevice && loginResult.jumpUrl) {
|
||||||
|
return this.startNewDeviceVerification(session, container, {
|
||||||
|
jumpUrl: loginResult.jumpUrl,
|
||||||
|
needNewDevice: true,
|
||||||
|
newDevicePullQrCodeSig: loginResult.pullQrCodeSig,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!loginResult.success) {
|
if (!loginResult.success) {
|
||||||
return this.failNewDeviceVerification(
|
return this.failNewDeviceVerification(
|
||||||
session,
|
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(
|
private async refreshNewDeviceQrcode(
|
||||||
session: QqbotLoginScanSession,
|
session: QqbotLoginScanSession,
|
||||||
container: QqbotNapcatRuntime,
|
container: QqbotNapcatRuntime,
|
||||||
|
|||||||
@ -21,12 +21,16 @@ export type NewDeviceQrCode = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type NewDeviceQrPollResult = {
|
export type NewDeviceQrPollResult = {
|
||||||
|
confirmToken?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
status: Exclude<NewDeviceQrStatus, 'verified'>;
|
status: Exclude<NewDeviceQrStatus, 'verified'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NewDeviceLoginResult = {
|
export type NewDeviceLoginResult = {
|
||||||
|
jumpUrl?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
needNewDevice?: boolean;
|
||||||
|
pullQrCodeSig?: unknown;
|
||||||
status: 'failed' | 'verified';
|
status: 'failed' | 'verified';
|
||||||
success: boolean;
|
success: boolean;
|
||||||
};
|
};
|
||||||
@ -215,7 +219,7 @@ export class NapcatLoginApiClient {
|
|||||||
this.pickString(data.bytes_token, data.bytesToken) ||
|
this.pickString(data.bytes_token, data.bytesToken) ||
|
||||||
this.deriveBytesToken(strUrl);
|
this.deriveBytesToken(strUrl);
|
||||||
const newDeviceQrcodeUrl = await this.toQrcodeDataUrl(
|
const newDeviceQrcodeUrl = await this.toQrcodeDataUrl(
|
||||||
qrcodeSource || strUrl || returnedJumpUrl || jumpUrl,
|
strUrl || qrcodeSource || returnedJumpUrl || jumpUrl,
|
||||||
);
|
);
|
||||||
if (!newDeviceQrcodeUrl) {
|
if (!newDeviceQrcodeUrl) {
|
||||||
throw new Error('NapCat 未返回新设备验证二维码');
|
throw new Error('NapCat 未返回新设备验证二维码');
|
||||||
@ -248,10 +252,16 @@ export class NapcatLoginApiClient {
|
|||||||
{ bytesToken, uin },
|
{ bytesToken, uin },
|
||||||
)) as Record<string, unknown>;
|
)) as Record<string, unknown>;
|
||||||
const status = this.normalizePollStatus(
|
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 {
|
return {
|
||||||
|
confirmToken: this.pickString(data.str_nt_succ_token) || undefined,
|
||||||
message: this.pickString(data.message, data.reason) || undefined,
|
message: this.pickString(data.message, data.reason) || undefined,
|
||||||
status,
|
status,
|
||||||
};
|
};
|
||||||
@ -268,24 +278,42 @@ export class NapcatLoginApiClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await this.transport.post(
|
const payload = await this.transport.post(
|
||||||
'/api/QQLogin/NewDeviceLogin',
|
'/api/QQLogin/NewDeviceLogin',
|
||||||
{
|
{
|
||||||
newDevicePullQrCodeSig: input.newDevicePullQrCodeSig,
|
newDevicePullQrCodeSig: input.newDevicePullQrCodeSig,
|
||||||
passwordMd5,
|
passwordMd5,
|
||||||
uin,
|
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 {
|
return {
|
||||||
|
jumpUrl: this.pickString(data.jumpUrl, data.verifyUrl) || undefined,
|
||||||
message: this.pickString(data.message, data.reason) || undefined,
|
message: this.pickString(data.message, data.reason) || undefined,
|
||||||
|
needNewDevice: data.needNewDevice === true,
|
||||||
|
pullQrCodeSig: this.pickPayload(data.newDevicePullQrCodeSig, data.sig),
|
||||||
status: success ? 'verified' : 'failed',
|
status: success ? 'verified' : 'failed',
|
||||||
success,
|
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, '');
|
const normalized = status.toLowerCase().replace(/[_\s-]+/g, '');
|
||||||
if (['scan', 'scanned'].includes(normalized)) return 'scanned';
|
if (['scan', 'scanned'].includes(normalized)) return 'scanned';
|
||||||
if (['confirm', 'confirming'].includes(normalized)) return 'confirming';
|
if (['confirm', 'confirming'].includes(normalized)) return 'confirming';
|
||||||
@ -297,6 +325,7 @@ export class NapcatLoginApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private normalizeLoginSuccess(data: Record<string, unknown>) {
|
private normalizeLoginSuccess(data: Record<string, unknown>) {
|
||||||
|
if (data.needNewDevice === true) return false;
|
||||||
const status = this.pickString(data.status, data.state, data.result)
|
const status = this.pickString(data.status, data.state, data.result)
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[_\s-]+/g, '');
|
.replace(/[_\s-]+/g, '');
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { NapcatLoginApiClient } from '../../../../src/modules/qqbot/napcat';
|
import { NapcatLoginApiClient } from '../../../../src/modules/qqbot/napcat';
|
||||||
|
import * as QRCode from 'qrcode';
|
||||||
|
|
||||||
describe('NapCat new-device flow API client', () => {
|
describe('NapCat new-device flow API client', () => {
|
||||||
it('runs GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin with NapCat WebUI payloads', async () => {
|
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(scanned.status).toBe('scanned');
|
||||||
expect(confirming.status).toBe('confirming');
|
expect(confirming.status).toBe('confirming');
|
||||||
expect(verified).toEqual({
|
expect(verified).toEqual({
|
||||||
|
jumpUrl: undefined,
|
||||||
message: 'ok',
|
message: 'ok',
|
||||||
|
needNewDevice: false,
|
||||||
|
pullQrCodeSig: undefined,
|
||||||
status: 'verified',
|
status: 'verified',
|
||||||
success: true,
|
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 () => {
|
it('normalizes NewDeviceLogin status-only failures', async () => {
|
||||||
const client = new NapcatLoginApiClient({
|
const client = new NapcatLoginApiClient({
|
||||||
post: jest.fn().mockResolvedValue({
|
post: jest.fn().mockResolvedValue({
|
||||||
@ -145,9 +182,84 @@ describe('NapCat new-device flow API client', () => {
|
|||||||
uin: '10001',
|
uin: '10001',
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
|
jumpUrl: undefined,
|
||||||
message: 'denied',
|
message: 'denied',
|
||||||
|
needNewDevice: false,
|
||||||
|
pullQrCodeSig: undefined,
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
success: false,
|
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 () => {
|
it('recovers missing new-device bytesToken before polling', async () => {
|
||||||
const container = {
|
const container = {
|
||||||
baseUrl: 'http://127.0.0.1:6103/',
|
baseUrl: 'http://127.0.0.1:6103/',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user