fix: 修复NapCat新设备登录契约
This commit is contained in:
parent
749df7cd7e
commit
28afc6ac52
@ -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;
|
||||
|
||||
@ -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<QqbotLoginScanSession['newDeviceStatus']>,
|
||||
@ -750,6 +842,7 @@ export class QqbotNapcatLoginService {
|
||||
message: string,
|
||||
) {
|
||||
session.newDeviceQrcode = undefined;
|
||||
session.newDeviceBytesToken = undefined;
|
||||
session.newDeviceStatus = 'failed';
|
||||
session.errorMessage = message;
|
||||
this.persistLoginSession(session);
|
||||
|
||||
@ -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<NewDeviceQrStatus, 'verified'>;
|
||||
};
|
||||
|
||||
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<string, unknown>): Promise<unknown>;
|
||||
};
|
||||
@ -175,36 +190,62 @@ export class NapcatWebuiHttpClient {
|
||||
export class NapcatLoginApiClient {
|
||||
constructor(private readonly transport: NapcatLoginApiTransport) {}
|
||||
|
||||
async getNewDeviceQRCode(sessionId: string): Promise<NewDeviceQrCode> {
|
||||
async getNewDeviceQRCode(
|
||||
input: NewDeviceQrRequest,
|
||||
): Promise<NewDeviceQrCode> {
|
||||
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<string, unknown>;
|
||||
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<NewDeviceQrPollResult> {
|
||||
async pollNewDeviceQR(
|
||||
input: NewDeviceQrPollRequest,
|
||||
): Promise<NewDeviceQrPollResult> {
|
||||
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<string, unknown>;
|
||||
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<NewDeviceLoginResult> {
|
||||
async newDeviceLogin(
|
||||
input: NewDeviceLoginRequest,
|
||||
): Promise<NewDeviceLoginResult> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>) {
|
||||
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, {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
}),
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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/',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user