fix: 修复NapCat新设备登录契约
This commit is contained in:
parent
749df7cd7e
commit
28afc6ac52
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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, {
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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/',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user