fix: 修复NapCat新设备登录契约

This commit is contained in:
sunlei 2026-06-16 19:57:13 +08:00
parent 749df7cd7e
commit 28afc6ac52
7 changed files with 386 additions and 75 deletions

View File

@ -35,7 +35,7 @@ export type NapcatLoginStatus = {
export type NapcatCaptchaLoginResult = { export type NapcatCaptchaLoginResult = {
jumpUrl?: string; jumpUrl?: string;
needNewDevice?: boolean; needNewDevice?: boolean;
newDevicePullQrCodeSig?: string; newDevicePullQrCodeSig?: unknown;
}; };
export type NapcatQrcode = { export type NapcatQrcode = {
@ -320,7 +320,8 @@ export type QqbotLoginScanSession = {
lastCaptchaLookupAt?: number; lastCaptchaLookupAt?: number;
lastRestartedAt?: number; lastRestartedAt?: number;
mode: QqbotLoginScanMode; mode: QqbotLoginScanMode;
newDevicePullQrCodeSig?: string; newDeviceBytesToken?: string;
newDevicePullQrCodeSig?: unknown;
newDeviceQrcode?: string; newDeviceQrcode?: string;
newDeviceStatus?: QqbotLoginNewDeviceStatus; newDeviceStatus?: QqbotLoginNewDeviceStatus;
passwordMd5?: string; passwordMd5?: string;

View File

@ -6,6 +6,8 @@ import { throwVbenError, ToolsService } from '@/common';
import { import {
NapcatLoginApiClient, NapcatLoginApiClient,
NapcatWebuiHttpClient, NapcatWebuiHttpClient,
type NewDeviceQrCode,
type NewDeviceQrRequest,
} from '../../infrastructure/integration/napcat-login-api.client'; } from '../../infrastructure/integration/napcat-login-api.client';
import type { import type {
NapcatCaptchaLoginResult, NapcatCaptchaLoginResult,
@ -614,7 +616,8 @@ export class QqbotNapcatLoginService {
session.deviceVerifyUrl = this.toolsService.toTrimmedString( session.deviceVerifyUrl = this.toolsService.toTrimmedString(
captchaResult.jumpUrl, captchaResult.jumpUrl,
); );
session.newDevicePullQrCodeSig = this.toolsService.toTrimmedString( session.newDeviceBytesToken = undefined;
session.newDevicePullQrCodeSig = this.pickNewDevicePullQrCodeSig(
captchaResult.newDevicePullQrCodeSig, captchaResult.newDevicePullQrCodeSig,
); );
session.newDeviceStatus = 'qr-pending'; session.newDeviceStatus = 'qr-pending';
@ -631,20 +634,7 @@ export class QqbotNapcatLoginService {
const client = new NapcatLoginApiClient({ const client = new NapcatLoginApiClient({
post: (path, body) => this.postNapcat(container, path, body), post: (path, body) => this.postNapcat(container, path, body),
}); });
const qrcode = await client.getNewDeviceQRCode(session.id); return this.refreshNewDeviceQrcode(session, container, client);
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);
} catch (err) { } catch (err) {
return this.keepSessionPending( return this.keepSessionPending(
session, session,
@ -661,7 +651,21 @@ export class QqbotNapcatLoginService {
const client = new NapcatLoginApiClient({ const client = new NapcatLoginApiClient({
post: (path, body) => this.postNapcat(container, path, body), 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') { if (poll.status === 'scanned') {
return this.keepNewDevicePending( return this.keepNewDevicePending(
session, session,
@ -677,7 +681,19 @@ export class QqbotNapcatLoginService {
poll.message || '新设备确认中', poll.message || '新设备确认中',
'new-device-confirming', '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) { if (!loginResult.success) {
return this.failNewDeviceVerification( return this.failNewDeviceVerification(
session, session,
@ -685,6 +701,7 @@ export class QqbotNapcatLoginService {
loginResult.message || '新设备验证失败', loginResult.message || '新设备验证失败',
); );
} }
session.newDeviceBytesToken = undefined;
session.newDeviceQrcode = undefined; session.newDeviceQrcode = undefined;
session.newDeviceStatus = 'verified'; session.newDeviceStatus = 'verified';
session.errorMessage = '新设备验证成功,继续登录'; 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( private keepNewDevicePending(
session: QqbotLoginScanSession, session: QqbotLoginScanSession,
status: NonNullable<QqbotLoginScanSession['newDeviceStatus']>, status: NonNullable<QqbotLoginScanSession['newDeviceStatus']>,
@ -750,6 +842,7 @@ export class QqbotNapcatLoginService {
message: string, message: string,
) { ) {
session.newDeviceQrcode = undefined; session.newDeviceQrcode = undefined;
session.newDeviceBytesToken = undefined;
session.newDeviceStatus = 'failed'; session.newDeviceStatus = 'failed';
session.errorMessage = message; session.errorMessage = message;
this.persistLoginSession(session); this.persistLoginSession(session);

View File

@ -12,26 +12,41 @@ export type NewDeviceQrStatus =
| 'verified'; | 'verified';
export type NewDeviceQrCode = { export type NewDeviceQrCode = {
bytesToken: string;
deviceVerifyUrl?: string; deviceVerifyUrl?: string;
pullQrCodeSig?: string; pullQrCodeSig?: unknown;
qrcodeUrl: string; qrcodeUrl: string;
sessionId: string;
status: 'qr-pending'; status: 'qr-pending';
strUrl?: string;
}; };
export type NewDeviceQrPollResult = { export type NewDeviceQrPollResult = {
message?: string; message?: string;
sessionId: string;
status: Exclude<NewDeviceQrStatus, 'verified'>; status: Exclude<NewDeviceQrStatus, 'verified'>;
}; };
export type NewDeviceLoginResult = { export type NewDeviceLoginResult = {
message?: string; message?: string;
sessionId: string;
status: 'failed' | 'verified'; status: 'failed' | 'verified';
success: boolean; 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 = { export type NapcatLoginApiTransport = {
post(path: string, body: Record<string, unknown>): Promise<unknown>; post(path: string, body: Record<string, unknown>): Promise<unknown>;
}; };
@ -175,36 +190,62 @@ export class NapcatWebuiHttpClient {
export class NapcatLoginApiClient { export class NapcatLoginApiClient {
constructor(private readonly transport: NapcatLoginApiTransport) {} 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( const data = (await this.transport.post(
'/api/QQLogin/GetNewDeviceQRCode', '/api/QQLogin/GetNewDeviceQRCode',
{ sessionId }, { jumpUrl, uin },
)) as Record<string, unknown>; )) 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.qrcodeurl, data.qrcodeurl,
data.qrcode, data.qrcode,
data.url, data.url,
); );
const jumpUrl = this.pickString(data.jumpUrl, data.verifyUrl); const returnedJumpUrl = this.pickString(data.jumpUrl, data.verifyUrl);
const newDeviceQrcodeUrl = qrcodeUrl || (await this.createQrcode(jumpUrl)); const bytesToken =
this.pickString(data.bytes_token, data.bytesToken) ||
this.deriveBytesToken(strUrl);
const newDeviceQrcodeUrl = await this.toQrcodeDataUrl(
qrcodeSource || strUrl || returnedJumpUrl || jumpUrl,
);
if (!newDeviceQrcodeUrl) { if (!newDeviceQrcodeUrl) {
throw new Error('NapCat 未返回新设备验证二维码'); throw new Error('NapCat 未返回新设备验证二维码');
} }
if (!bytesToken) {
throw new Error('NapCat 未返回新设备验证 bytesToken');
}
return { return {
deviceVerifyUrl: jumpUrl || undefined, bytesToken,
pullQrCodeSig: this.pickString(data.newDevicePullQrCodeSig, data.sig), deviceVerifyUrl: returnedJumpUrl || jumpUrl,
pullQrCodeSig: this.pickPayload(data.newDevicePullQrCodeSig, data.sig),
qrcodeUrl: newDeviceQrcodeUrl, qrcodeUrl: newDeviceQrcodeUrl,
sessionId,
status: 'qr-pending', 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( const data = (await this.transport.post(
'/api/QQLogin/PollNewDeviceQR', '/api/QQLogin/PollNewDeviceQR',
{ sessionId }, { 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.pickString(data.status, data.state, data.result),
@ -212,21 +253,33 @@ export class NapcatLoginApiClient {
return { return {
message: this.pickString(data.message, data.reason) || undefined, message: this.pickString(data.message, data.reason) || undefined,
sessionId,
status, 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( const data = (await this.transport.post(
'/api/QQLogin/NewDeviceLogin', '/api/QQLogin/NewDeviceLogin',
{ sessionId }, {
newDevicePullQrCodeSig: input.newDevicePullQrCodeSig,
passwordMd5,
uin,
},
)) as Record<string, unknown>; )) as Record<string, unknown>;
const success = data.success !== false; const success = this.normalizeLoginSuccess(data);
return { return {
message: this.pickString(data.message, data.reason) || undefined, message: this.pickString(data.message, data.reason) || undefined,
sessionId,
status: success ? 'verified' : 'failed', status: success ? 'verified' : 'failed',
success, success,
}; };
@ -243,6 +296,15 @@ export class NapcatLoginApiClient {
return 'qr-pending'; 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[]) { private pickString(...values: unknown[]) {
for (const value of values) { for (const value of values) {
if (typeof value !== 'string') continue; if (typeof value !== 'string') continue;
@ -252,6 +314,37 @@ export class NapcatLoginApiClient {
return ''; 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) { private async createQrcode(text: string) {
if (!text) return ''; if (!text) return '';
return QRCode.toDataURL(text, { return QRCode.toDataURL(text, {

View File

@ -102,6 +102,7 @@ export class NapcatLoginStateStoreService {
void this.saveChallenge({ void this.saveChallenge({
challengePayload: { challengePayload: {
deviceVerifyUrl: session.deviceVerifyUrl, deviceVerifyUrl: session.deviceVerifyUrl,
newDeviceBytesToken: session.newDeviceBytesToken,
newDevicePullQrCodeSig: session.newDevicePullQrCodeSig, newDevicePullQrCodeSig: session.newDevicePullQrCodeSig,
newDeviceQrcode: session.newDeviceQrcode, newDeviceQrcode: session.newDeviceQrcode,
}, },
@ -230,10 +231,17 @@ export class NapcatLoginStateStoreService {
} }
if ( if (
!session.newDevicePullQrCodeSig && !session.newDevicePullQrCodeSig &&
typeof payload.newDevicePullQrCodeSig === 'string' payload.newDevicePullQrCodeSig !== undefined &&
payload.newDevicePullQrCodeSig !== null
) { ) {
session.newDevicePullQrCodeSig = payload.newDevicePullQrCodeSig; session.newDevicePullQrCodeSig = payload.newDevicePullQrCodeSig;
} }
if (
!session.newDeviceBytesToken &&
typeof payload.newDeviceBytesToken === 'string'
) {
session.newDeviceBytesToken = payload.newDeviceBytesToken;
}
if (!session.newDeviceQrcode) { if (!session.newDeviceQrcode) {
session.newDeviceQrcode = session.newDeviceQrcode =
typeof payload.newDeviceQrcode === 'string' typeof payload.newDeviceQrcode === 'string'

View File

@ -292,7 +292,8 @@ describe('NapCat persistent login state contract', () => {
loginChallengeRepository.rows.push({ loginChallengeRepository.rows.push({
challengePayload: { challengePayload: {
deviceVerifyUrl: 'https://ti.qq.com/new-device/verify', 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', newDeviceQrcode: 'data:image/png;base64,new-device-qrcode',
}, },
challengeType: 'new-device', challengeType: 'new-device',
@ -335,7 +336,8 @@ describe('NapCat persistent login state contract', () => {
await expect(store.get('new-device-recover')).resolves.toEqual( await expect(store.get('new-device-recover')).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
deviceVerifyUrl: 'https://ti.qq.com/new-device/verify', 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', newDeviceQrcode: 'data:image/png;base64,new-device-qrcode',
newDeviceStatus: 'qr-pending', newDeviceStatus: 'qr-pending',
}), }),

View File

@ -1,15 +1,17 @@
import { NapcatLoginApiClient } from '../../../../src/modules/qqbot/napcat'; import { NapcatLoginApiClient } from '../../../../src/modules/qqbot/napcat';
describe('NapCat new-device flow API client', () => { 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 calls: Array<{ body: unknown; path: string }> = [];
const pullQrCodeSig = { key: 'sig-1' };
const client = new NapcatLoginApiClient({ const client = new NapcatLoginApiClient({
post: jest.fn(async (path: string, body: unknown) => { post: jest.fn(async (path: string, body: unknown) => {
calls.push({ body, path }); calls.push({ body, path });
if (path.endsWith('/GetNewDeviceQRCode')) { if (path.endsWith('/GetNewDeviceQRCode')) {
return { return {
newDevicePullQrCodeSig: 'sig-1', bytes_token: 'bytes-1',
qrcodeUrl: 'data:image/png;base64,new-device', 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) { if (path.endsWith('/PollNewDeviceQR') && calls.length === 2) {
@ -19,84 +21,133 @@ describe('NapCat new-device flow API client', () => {
return { status: 'confirming' }; return { status: 'confirming' };
} }
if (path.endsWith('/NewDeviceLogin')) { if (path.endsWith('/NewDeviceLogin')) {
return { message: 'ok', success: true }; return { message: 'ok', status: 'ok' };
} }
throw new Error(`unexpected path ${path}`); throw new Error(`unexpected path ${path}`);
}), }),
}); });
const qr = await client.getNewDeviceQRCode('session-1'); const qr = await client.getNewDeviceQRCode({
const scanned = await client.pollNewDeviceQR('session-1'); jumpUrl: 'https://accounts.qq.com/safe/verify?sid=sid-1',
const confirming = await client.pollNewDeviceQR('session-1'); uin: '10001',
const verified = await client.newDeviceLogin('session-1'); });
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({ expect(qr).toMatchObject({
pullQrCodeSig: 'sig-1', bytesToken: 'bytes-1',
qrcodeUrl: 'data:image/png;base64,new-device', deviceVerifyUrl: 'https://accounts.qq.com/safe/verify?sid=sid-1',
sessionId: 'session-1', pullQrCodeSig,
status: 'qr-pending', status: 'qr-pending',
}); });
expect(qr.qrcodeUrl).toMatch(/^data:image\/png;base64,/);
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({
message: 'ok', message: 'ok',
sessionId: 'session-1',
status: 'verified', status: 'verified',
success: true, success: true,
}); });
expect(calls).toEqual([ expect(calls).toEqual([
{ {
body: { sessionId: 'session-1' }, body: {
jumpUrl: 'https://accounts.qq.com/safe/verify?sid=sid-1',
uin: '10001',
},
path: '/api/QQLogin/GetNewDeviceQRCode', path: '/api/QQLogin/GetNewDeviceQRCode',
}, },
{ {
body: { sessionId: 'session-1' }, body: { bytesToken: 'bytes-1', uin: '10001' },
path: '/api/QQLogin/PollNewDeviceQR', path: '/api/QQLogin/PollNewDeviceQR',
}, },
{ {
body: { sessionId: 'session-1' }, body: { bytesToken: 'bytes-1', uin: '10001' },
path: '/api/QQLogin/PollNewDeviceQR', path: '/api/QQLogin/PollNewDeviceQR',
}, },
{ {
body: { sessionId: 'session-1' }, body: {
newDevicePullQrCodeSig: pullQrCodeSig,
passwordMd5: '0123456789abcdef0123456789abcdef',
uin: '10001',
},
path: '/api/QQLogin/NewDeviceLogin', 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({ const client = new NapcatLoginApiClient({
post: jest post: jest
.fn() .fn()
.mockResolvedValueOnce({ jumpUrl: 'https://qq.example/new-device' }) .mockResolvedValueOnce({ str_url: strUrl })
.mockResolvedValueOnce({ status: 'waiting' }) .mockResolvedValueOnce({ status: 'waiting' })
.mockResolvedValueOnce({ status: 'expired', message: 'expired' }) .mockResolvedValueOnce({ status: 'expired', message: 'expired' })
.mockResolvedValueOnce({ status: 'failed', message: 'denied' }), .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({ expect(qr).toMatchObject({
deviceVerifyUrl: 'https://qq.example/new-device', bytesToken: Buffer.from(rawProofUrl, 'utf8').toString('base64'),
sessionId: 'session-2', deviceVerifyUrl: 'https://accounts.qq.com/safe/verify?sid=sid-2',
status: 'qr-pending', status: 'qr-pending',
}); });
expect(qr.qrcodeUrl).toMatch(/^data:image\/png;base64,/); expect(qr.qrcodeUrl).toMatch(/^data:image\/png;base64,/);
expect(qr.qrcodeUrl).not.toBe('https://qq.example/new-device'); expect(qr.qrcodeUrl).not.toBe(strUrl);
await expect(client.pollNewDeviceQR('session-2')).resolves.toEqual({ await expect(
client.pollNewDeviceQR({ bytesToken: qr.bytesToken, uin: '10002' }),
).resolves.toEqual({
message: undefined, message: undefined,
sessionId: 'session-2',
status: 'qr-pending', status: 'qr-pending',
}); });
await expect(client.pollNewDeviceQR('session-2')).resolves.toEqual({ await expect(
client.pollNewDeviceQR({ bytesToken: qr.bytesToken, uin: '10002' }),
).resolves.toEqual({
message: 'expired', message: 'expired',
sessionId: 'session-2',
status: 'expired', status: 'expired',
}); });
await expect(client.pollNewDeviceQR('session-2')).resolves.toEqual({ await expect(
client.pollNewDeviceQR({ bytesToken: qr.bytesToken, uin: '10002' }),
).resolves.toEqual({
message: 'denied', message: 'denied',
sessionId: 'session-2',
status: 'failed', 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,
});
});
}); });

View File

@ -1567,6 +1567,7 @@ describe('QqbotNapcatLoginService', () => {
} }
if (path === '/api/QQLogin/GetNewDeviceQRCode') { if (path === '/api/QQLogin/GetNewDeviceQRCode') {
return { return {
bytes_token: 'bytes-new-device',
newDevicePullQrCodeSig: 'sig-new-device', newDevicePullQrCodeSig: 'sig-new-device',
qrcodeUrl: 'data:image/png;base64,new-device-qrcode', qrcodeUrl: 'data:image/png;base64,new-device-qrcode',
}; };
@ -1589,7 +1590,8 @@ describe('QqbotNapcatLoginService', () => {
container, container,
'/api/QQLogin/GetNewDeviceQRCode', '/api/QQLogin/GetNewDeviceQRCode',
{ {
sessionId: session.id, jumpUrl: 'https://ti.qq.com/new-device/verify',
uin: '10001',
}, },
); );
expect(waitForPasswordLoginStatus).not.toHaveBeenCalled(); expect(waitForPasswordLoginStatus).not.toHaveBeenCalled();
@ -1602,6 +1604,7 @@ describe('QqbotNapcatLoginService', () => {
expect(result.newDeviceStatus).toBe('qr-pending'); expect(result.newDeviceStatus).toBe('qr-pending');
expect(result.errorMessage).toBe('新设备二维码待扫码'); expect(result.errorMessage).toBe('新设备二维码待扫码');
expect(session.newDevicePullQrCodeSig).toBe('sig-new-device'); 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'); expect(session.deviceVerifyUrl).toBe('https://ti.qq.com/new-device/verify');
const steps = ( const steps = (
(refreshService as any).sessionEventLogs.get(session.id) ?? [] (refreshService as any).sessionEventLogs.get(session.id) ?? []
@ -1619,6 +1622,7 @@ describe('QqbotNapcatLoginService', () => {
const accountService = { const accountService = {
ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), ensureScannedAccount: jest.fn().mockResolvedValue('account-1'),
}; };
const pullQrCodeSig = { key: 'sig-new-device' };
const container = { const container = {
baseUrl: 'http://127.0.0.1:6103/', baseUrl: 'http://127.0.0.1:6103/',
id: 'container-new-device-poll', id: 'container-new-device-poll',
@ -1643,7 +1647,8 @@ describe('QqbotNapcatLoginService', () => {
status: 'pending', status: 'pending',
}); });
session.deviceVerifyUrl = 'https://ti.qq.com/new-device/verify'; 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.newDeviceQrcode = 'data:image/png;base64,new-device-qrcode';
session.newDeviceStatus = 'qr-pending'; session.newDeviceStatus = 'qr-pending';
session.passwordMd5 = '0123456789abcdef0123456789abcdef'; session.passwordMd5 = '0123456789abcdef0123456789abcdef';
@ -1685,7 +1690,8 @@ describe('QqbotNapcatLoginService', () => {
container, container,
'/api/QQLogin/PollNewDeviceQR', '/api/QQLogin/PollNewDeviceQR',
{ {
sessionId: session.id, bytesToken: 'bytes-new-device',
uin: '10001',
}, },
); );
expect(postNapcat).toHaveBeenNthCalledWith( expect(postNapcat).toHaveBeenNthCalledWith(
@ -1693,7 +1699,8 @@ describe('QqbotNapcatLoginService', () => {
container, container,
'/api/QQLogin/PollNewDeviceQR', '/api/QQLogin/PollNewDeviceQR',
{ {
sessionId: session.id, bytesToken: 'bytes-new-device',
uin: '10001',
}, },
); );
expect(postNapcat).toHaveBeenNthCalledWith( expect(postNapcat).toHaveBeenNthCalledWith(
@ -1701,7 +1708,9 @@ describe('QqbotNapcatLoginService', () => {
container, container,
'/api/QQLogin/NewDeviceLogin', '/api/QQLogin/NewDeviceLogin',
{ {
sessionId: session.id, newDevicePullQrCodeSig: pullQrCodeSig,
passwordMd5: '0123456789abcdef0123456789abcdef',
uin: '10001',
}, },
); );
expect(session.newDeviceQrcode).toBeUndefined(); 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 () => { it('cleans runtime password env when cancelling captcha pending login', async () => {
const container = { const container = {
baseUrl: 'http://127.0.0.1:6103/', baseUrl: 'http://127.0.0.1:6103/',