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 = {
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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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