2205 lines
66 KiB
TypeScript
2205 lines
66 KiB
TypeScript
import { createHash, randomUUID } from 'crypto';
|
||
import { Injectable, Optional } from '@nestjs/common';
|
||
import { ConfigService } from '@nestjs/config';
|
||
import { Observable } from 'rxjs';
|
||
import { throwVbenError, ToolsService } from '@/common';
|
||
import {
|
||
NapcatLoginApiClient,
|
||
NapcatWebuiHttpClient,
|
||
} from '../../infrastructure/integration/napcat-login-api.client';
|
||
import type {
|
||
NapcatCaptchaLoginResult,
|
||
NapcatLoginInfo,
|
||
NapcatLoginStatus,
|
||
NapcatQrcode,
|
||
NapcatRestartOptions,
|
||
QqbotLoginCaptchaSubmitInput,
|
||
QqbotLoginScanMode,
|
||
QqbotLoginScanEvent,
|
||
QqbotLoginScanResult,
|
||
QqbotLoginScanSession,
|
||
QqbotLoginScanStatus,
|
||
QqbotNapcatRuntime,
|
||
QrcodeLookupOptions,
|
||
QrcodeRefreshOptions,
|
||
} from '@/modules/qqbot/core/contract/qqbot.types';
|
||
import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service';
|
||
import { NapcatLoginStateStoreService } from '../../infrastructure/persistence/napcat-login-state-store.service';
|
||
import { QqbotNapcatContainerService } from '../../infrastructure/integration/container/qqbot-napcat-container.service';
|
||
|
||
@Injectable()
|
||
export class QqbotNapcatLoginService {
|
||
private readonly fallbackLoginSessionStore =
|
||
new NapcatLoginStateStoreService();
|
||
private readonly sessionEventLogCache: Record<string, QqbotLoginScanEvent[]> =
|
||
{};
|
||
private readonly sessionEventListenerCache: Record<
|
||
string,
|
||
Set<(event: QqbotLoginScanEvent) => void>
|
||
> = {};
|
||
readonly sessions = {
|
||
clear: () => this.loginSessionStore.clear(),
|
||
get: (sessionId: string) => this.loginSessionStore.getCached(sessionId),
|
||
has: (sessionId: string) => this.loginSessionStore.has(sessionId),
|
||
set: (sessionId: string, session: QqbotLoginScanSession) => {
|
||
if (!session.id) session.id = sessionId;
|
||
this.loginSessionStore.set(session);
|
||
},
|
||
};
|
||
readonly sessionEventLogs = {
|
||
clear: () =>
|
||
Object.keys(this.sessionEventLogCache).forEach((sessionId) => {
|
||
delete this.sessionEventLogCache[sessionId];
|
||
}),
|
||
get: (sessionId: string) => this.sessionEventLogCache[sessionId],
|
||
};
|
||
readonly sessionEventListeners = {
|
||
clear: () =>
|
||
Object.keys(this.sessionEventListenerCache).forEach((sessionId) => {
|
||
delete this.sessionEventListenerCache[sessionId];
|
||
}),
|
||
};
|
||
private readonly webuiClient = new NapcatWebuiHttpClient({
|
||
getTimeoutMs: () => this.getTimeout(),
|
||
});
|
||
|
||
constructor(
|
||
private readonly configService: ConfigService,
|
||
private readonly accountService: QqbotAccountService,
|
||
private readonly containerService: QqbotNapcatContainerService,
|
||
private readonly toolsService: ToolsService,
|
||
@Optional()
|
||
private readonly loginStateStore?: NapcatLoginStateStoreService,
|
||
) {}
|
||
|
||
private get loginSessionStore() {
|
||
return this.loginStateStore || this.fallbackLoginSessionStore;
|
||
}
|
||
|
||
async startCreate() {
|
||
const container = await this.containerService.prepareCreateContainer();
|
||
return this.startScan({ mode: 'create' }, container);
|
||
}
|
||
|
||
async startRefresh(accountId: string) {
|
||
const account =
|
||
await this.accountService.findByIdWithNapcatLoginSecret(accountId);
|
||
if (!account) {
|
||
throwVbenError('QQBot 账号不存在');
|
||
}
|
||
const loginPassword = this.accountService.getNapcatLoginPassword(account);
|
||
const container =
|
||
await this.containerService.prepareAccountContainer(account);
|
||
|
||
return this.startScan(
|
||
{
|
||
accountId: account.id,
|
||
expectedSelfId: account.selfId,
|
||
forceRelogin: true,
|
||
hasExistingPrimaryBinding: container.hasExistingPrimaryBinding,
|
||
loginPassword,
|
||
mode: 'refresh',
|
||
},
|
||
container,
|
||
);
|
||
}
|
||
|
||
async refreshQrcode(sessionId: string) {
|
||
const session = await this.getSession(sessionId);
|
||
if (session.status !== 'pending') {
|
||
return this.toResult(session);
|
||
}
|
||
if (session.preparingRelogin) {
|
||
return this.keepSessionPending(
|
||
session,
|
||
session.errorMessage || 'NapCat 正在尝试快速登录,请稍后',
|
||
);
|
||
}
|
||
|
||
const container = await this.getSessionContainer(session);
|
||
let loginStatus: NapcatLoginStatus;
|
||
try {
|
||
loginStatus = await this.getLoginStatus(container);
|
||
} catch (err) {
|
||
if (!this.toolsService.isNapcatTemporaryError(err)) throw err;
|
||
await this.restartNapcatForLogin(container, { waitForReady: false });
|
||
session.lastRestartedAt = Date.now();
|
||
return this.keepSessionPending(
|
||
session,
|
||
'NapCat 通信超时,已尝试重启容器并重新生成二维码',
|
||
true,
|
||
);
|
||
}
|
||
|
||
if (loginStatus.isOffline) {
|
||
if (session.mode === 'refresh') {
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'relogin-reset-start',
|
||
'processing',
|
||
'开始重置 NapCat 登录态',
|
||
);
|
||
await this.resetNapcatForLogin(
|
||
container,
|
||
{ waitForReady: false },
|
||
(step, message) => {
|
||
this.publishScanResultEvent(session, step, 'processing', message);
|
||
},
|
||
);
|
||
} else {
|
||
await this.restartNapcatForLogin(container, { waitForReady: false });
|
||
}
|
||
session.lastRestartedAt = Date.now();
|
||
return this.keepSessionPending(
|
||
session,
|
||
loginStatus.loginError || 'NapCat 账号已离线,已重新生成二维码',
|
||
true,
|
||
);
|
||
}
|
||
|
||
try {
|
||
session.qrcode = await this.refreshOrGetQrcode(container, false, {
|
||
fallbackStatus: loginStatus,
|
||
requireFresh: true,
|
||
staleQrcode: session.qrcode || loginStatus.qrcodeurl,
|
||
});
|
||
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
||
session.errorMessage = undefined;
|
||
this.persistLoginSession(session);
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'qrcode-ready',
|
||
'success',
|
||
'登录二维码已刷新',
|
||
);
|
||
return this.toResult(session);
|
||
} catch (err) {
|
||
if (!this.toolsService.isNapcatTemporaryError(err)) throw err;
|
||
return this.keepSessionPending(
|
||
session,
|
||
'NapCat 正在重新生成二维码,请稍后刷新或等待自动更新',
|
||
true,
|
||
);
|
||
}
|
||
}
|
||
|
||
async status(sessionId: string) {
|
||
const session = await this.getSession(sessionId);
|
||
if (session.status !== 'pending') {
|
||
return this.toResult(session);
|
||
}
|
||
if (session.preparingRelogin) {
|
||
if (!this.recoverStaleReloginPreparation(session)) {
|
||
return this.keepSessionPending(
|
||
session,
|
||
session.errorMessage || 'NapCat 正在准备登录,请稍后',
|
||
);
|
||
}
|
||
}
|
||
if (Date.now() > session.expiresAt) {
|
||
return this.expireSession(session);
|
||
}
|
||
|
||
const container = await this.getSessionContainer(session);
|
||
if (session.newDeviceStatus && session.newDeviceStatus !== 'verified') {
|
||
return this.pollNewDeviceVerification(session, container);
|
||
}
|
||
let status: NapcatLoginStatus;
|
||
try {
|
||
status = await this.getLoginStatus(container);
|
||
} catch (err) {
|
||
if (!this.toolsService.isNapcatTemporaryError(err)) throw err;
|
||
return this.keepSessionPending(
|
||
session,
|
||
'NapCat 正在重启或生成二维码,请稍后',
|
||
);
|
||
}
|
||
if (!status.isLogin) {
|
||
const captchaUrl = this.getCaptchaUrlFromStatus(status);
|
||
if (captchaUrl) {
|
||
return this.keepPasswordCaptchaPending(
|
||
session,
|
||
captchaUrl,
|
||
status.loginError,
|
||
);
|
||
}
|
||
const recoveredCaptchaUrl = await this.resolveStatusCaptchaUrl(
|
||
session,
|
||
container,
|
||
status,
|
||
);
|
||
if (recoveredCaptchaUrl) {
|
||
return this.keepPasswordCaptchaPending(
|
||
session,
|
||
recoveredCaptchaUrl,
|
||
status.loginError,
|
||
);
|
||
}
|
||
if (session.captchaUrl) {
|
||
if (this.isPasswordCaptchaStillRequired(status)) {
|
||
return this.keepPasswordCaptchaPending(
|
||
session,
|
||
session.captchaUrl,
|
||
status.loginError || '等待 QQ 安全验证结果',
|
||
);
|
||
}
|
||
if (!status.isOffline && !status.loginError) {
|
||
return this.keepPasswordCaptchaPending(
|
||
session,
|
||
session.captchaUrl,
|
||
'等待 QQ 安全验证结果',
|
||
);
|
||
}
|
||
return this.failCaptchaLogin(
|
||
session,
|
||
container,
|
||
status.loginError || '验证码登录未完成',
|
||
);
|
||
}
|
||
if (this.isPasswordCaptchaStillRequired(status)) {
|
||
return this.keepPasswordCaptchaWaitingForUrl(
|
||
session,
|
||
status.loginError,
|
||
);
|
||
}
|
||
|
||
session.errorMessage = status.loginError || undefined;
|
||
if (
|
||
status.qrcodeurl &&
|
||
!this.toolsService.isNapcatExpiredQrcodeStatus(status)
|
||
) {
|
||
const qrcodeChanged = session.qrcode !== status.qrcodeurl;
|
||
session.qrcode = status.qrcodeurl;
|
||
if (qrcodeChanged) {
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'qrcode-ready',
|
||
'success',
|
||
'登录二维码已生成',
|
||
);
|
||
}
|
||
} else if (status.isOffline) {
|
||
session.qrcode = undefined;
|
||
} else if (!this.toolsService.isNapcatExpiredQrcodeStatus(status)) {
|
||
await this.tryUpdatePendingQrcode(container, session, status);
|
||
}
|
||
this.persistLoginSession(session);
|
||
return this.toResult(session);
|
||
}
|
||
|
||
return this.completeLogin(session, container);
|
||
}
|
||
|
||
async submitCaptcha(sessionId: string, input: QqbotLoginCaptchaSubmitInput) {
|
||
const session = await this.getSession(sessionId);
|
||
if (session.status !== 'pending') {
|
||
return this.toResult(session);
|
||
}
|
||
|
||
const ticket = this.toolsService.toTrimmedString(input.ticket);
|
||
const randstr = this.toolsService.toTrimmedString(input.randstr);
|
||
const sid = this.toolsService.toTrimmedString(input.sid);
|
||
if (!ticket || !randstr) {
|
||
throwVbenError('验证码结果缺失,请重新验证');
|
||
}
|
||
if (!session.captchaUrl) {
|
||
throwVbenError('当前登录会话不需要验证码');
|
||
}
|
||
if (!session.expectedSelfId || !session.passwordMd5) {
|
||
throwVbenError('验证码登录上下文已失效,请重新更新登录');
|
||
}
|
||
|
||
const container = await this.getSessionContainer(session);
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'password-login-captcha-submit',
|
||
'processing',
|
||
'正在提交 QQ 安全验证结果',
|
||
);
|
||
|
||
let captchaResult: NapcatCaptchaLoginResult | null;
|
||
try {
|
||
captchaResult = await this.postNapcat<NapcatCaptchaLoginResult | null>(
|
||
container,
|
||
'/api/QQLogin/CaptchaLogin',
|
||
{
|
||
passwordMd5: session.passwordMd5,
|
||
randstr,
|
||
sid,
|
||
ticket,
|
||
uin: session.expectedSelfId,
|
||
},
|
||
);
|
||
} catch (err) {
|
||
return this.keepPasswordCaptchaPending(
|
||
session,
|
||
session.captchaUrl,
|
||
this.toolsService.getErrorMessage(err) || '验证码登录失败',
|
||
);
|
||
}
|
||
|
||
if (captchaResult?.needNewDevice) {
|
||
return this.startNewDeviceVerification(
|
||
session,
|
||
container,
|
||
captchaResult,
|
||
);
|
||
}
|
||
|
||
return this.completePasswordLoginAfterChallenge(
|
||
session,
|
||
container,
|
||
'验证码登录成功',
|
||
);
|
||
}
|
||
|
||
events(sessionId: string) {
|
||
if (!this.loginSessionStore.getCached(sessionId)) {
|
||
void this.loginSessionStore.get(sessionId);
|
||
}
|
||
return new Observable<{ data: QqbotLoginScanEvent }>((subscriber) => {
|
||
const listener = (event: QqbotLoginScanEvent) => {
|
||
subscriber.next({ data: event });
|
||
};
|
||
const replayEvents = this.sessionEventLogCache[sessionId] || [];
|
||
replayEvents.forEach(listener);
|
||
const listeners =
|
||
this.sessionEventListenerCache[sessionId] ||
|
||
new Set<(event: QqbotLoginScanEvent) => void>();
|
||
listeners.add(listener);
|
||
this.sessionEventListenerCache[sessionId] = listeners;
|
||
if (replayEvents.length <= 0) {
|
||
void this.emitCurrentSessionSnapshot(sessionId, listener).catch(
|
||
() => undefined,
|
||
);
|
||
}
|
||
|
||
return () => {
|
||
listeners.delete(listener);
|
||
if (listeners.size <= 0) {
|
||
delete this.sessionEventListenerCache[sessionId];
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
async cancel(sessionId: string) {
|
||
const session = await this.loginSessionStore.get(sessionId);
|
||
if (session) {
|
||
await this.cleanupPasswordLoginContext(session);
|
||
this.publishScanEvent(session, {
|
||
message: '扫码会话已取消',
|
||
result: this.toResult(session),
|
||
status: 'info',
|
||
step: 'session-cancelled',
|
||
});
|
||
this.loginSessionStore.delete(sessionId);
|
||
await this.cleanupSessionContainer(session);
|
||
this.cleanupSessionEvents(sessionId);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private async startScan(
|
||
options: {
|
||
accountId?: string;
|
||
expectedSelfId?: string;
|
||
forceRelogin?: boolean;
|
||
hasExistingPrimaryBinding?: boolean;
|
||
loginPassword?: string;
|
||
mode: QqbotLoginScanMode;
|
||
},
|
||
container: QqbotNapcatRuntime,
|
||
): Promise<QqbotLoginScanResult> {
|
||
await this.cleanupSessions();
|
||
|
||
if (options.forceRelogin) {
|
||
const session = this.createSession({
|
||
...options,
|
||
container,
|
||
preparingRelogin: true,
|
||
status: 'pending',
|
||
});
|
||
session.lastRestartedAt = Date.now();
|
||
session.errorMessage = this.getReloginPreparingMessage(options);
|
||
this.persistLoginSession(session);
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'session-created',
|
||
'processing',
|
||
'已创建更新登录会话',
|
||
);
|
||
const reloginTask = this.prepareReloginQrcode(
|
||
session,
|
||
container,
|
||
options.loginPassword,
|
||
options.hasExistingPrimaryBinding,
|
||
);
|
||
void reloginTask;
|
||
return this.toResult(session);
|
||
}
|
||
|
||
try {
|
||
const loginStatus = await this.getLoginStatus(container, true);
|
||
if (loginStatus.isOffline) {
|
||
await this.restartNapcatForLogin(container, { waitForReady: false });
|
||
const session = this.createSession({
|
||
...options,
|
||
container,
|
||
status: 'pending',
|
||
});
|
||
session.lastRestartedAt = Date.now();
|
||
session.errorMessage =
|
||
loginStatus.loginError || 'NapCat 账号已离线,已重新生成二维码';
|
||
this.persistLoginSession(session);
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'container-restarted',
|
||
'processing',
|
||
session.errorMessage,
|
||
);
|
||
return this.toResult(session);
|
||
}
|
||
|
||
if (loginStatus.isLogin) {
|
||
const session = this.createSession({
|
||
...options,
|
||
container,
|
||
qrcode: loginStatus.qrcodeurl,
|
||
status: 'success',
|
||
});
|
||
return this.completeLogin(session, container);
|
||
}
|
||
|
||
const qrcode = await this.refreshOrGetQrcode(container, true, {
|
||
fallbackStatus: loginStatus,
|
||
requireFresh:
|
||
this.toolsService.isNapcatExpiredQrcodeStatus(loginStatus),
|
||
staleQrcode: loginStatus.qrcodeurl,
|
||
});
|
||
const session = this.createSession({
|
||
...options,
|
||
container,
|
||
qrcode,
|
||
status: 'pending',
|
||
});
|
||
this.persistLoginSession(session);
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'qrcode-ready',
|
||
'success',
|
||
'登录二维码已生成',
|
||
);
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'waiting-scan',
|
||
'processing',
|
||
'等待扫码确认',
|
||
);
|
||
return this.toResult(session);
|
||
} catch (err) {
|
||
const cleanupError = await this.cleanupRuntimeContainer(container);
|
||
if (cleanupError) {
|
||
throwVbenError(
|
||
`${this.toolsService.getErrorMessage(
|
||
err,
|
||
)};清理未绑定容器失败:${cleanupError}`,
|
||
);
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
private async completeLogin(
|
||
session: QqbotLoginScanSession,
|
||
container: QqbotNapcatRuntime,
|
||
options: { loginInfo?: NapcatLoginInfo; successMessage?: string } = {},
|
||
): Promise<QqbotLoginScanResult> {
|
||
const loginInfo = options.loginInfo ?? (await this.getLoginInfo(container));
|
||
if (loginInfo.online === false) {
|
||
return this.failSession(session, 'NapCat 当前账号已离线,请重新更新登录');
|
||
}
|
||
|
||
const selfId = this.toolsService.pickNapcatSelfId(loginInfo);
|
||
if (!selfId) {
|
||
return this.failSession(session, 'NapCat 已登录但未返回 QQ 号');
|
||
}
|
||
if (session.expectedSelfId && session.expectedSelfId !== selfId) {
|
||
return this.failSession(
|
||
session,
|
||
`当前扫码账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`,
|
||
);
|
||
}
|
||
|
||
const accountId = await this.accountService.ensureScannedAccount({
|
||
accountId: session.accountId,
|
||
name: this.toolsService.pickNapcatNickname(loginInfo),
|
||
selfId,
|
||
});
|
||
await this.containerService.bindAccount(accountId, session.containerId);
|
||
session.accountId = accountId;
|
||
session.captchaUrl = undefined;
|
||
session.status = 'success';
|
||
session.errorMessage = undefined;
|
||
session.passwordMd5 = undefined;
|
||
session.preparingRelogin = false;
|
||
this.persistLoginSession(session);
|
||
const result = {
|
||
...this.toResult(session),
|
||
accountId,
|
||
selfId,
|
||
};
|
||
this.publishScanEvent(session, {
|
||
message: options.successMessage || '扫码登录成功',
|
||
result,
|
||
status: 'success',
|
||
step: 'login-success',
|
||
});
|
||
return result;
|
||
}
|
||
|
||
private createSession(input: {
|
||
accountId?: string;
|
||
container: QqbotNapcatRuntime;
|
||
expectedSelfId?: string;
|
||
mode: QqbotLoginScanMode;
|
||
preparingRelogin?: boolean;
|
||
qrcode?: string;
|
||
status: QqbotLoginScanStatus;
|
||
}): QqbotLoginScanSession {
|
||
const now = Date.now();
|
||
return {
|
||
accountId: input.accountId,
|
||
containerId: input.container.id,
|
||
containerName: input.container.name,
|
||
createdAt: now,
|
||
expectedSelfId: input.expectedSelfId,
|
||
expiresAt: now + this.getSessionTtlMs(),
|
||
id: randomUUID(),
|
||
mode: input.mode,
|
||
preparingRelogin: input.preparingRelogin,
|
||
qrcode: input.qrcode,
|
||
status: input.status,
|
||
webuiPort: input.container.webuiPort,
|
||
};
|
||
}
|
||
|
||
private toResult(session: QqbotLoginScanSession): QqbotLoginScanResult {
|
||
return {
|
||
accountId: session.accountId,
|
||
captchaUrl: session.captchaUrl,
|
||
containerId: session.containerId,
|
||
containerName: session.containerName,
|
||
deviceVerifyUrl: session.deviceVerifyUrl,
|
||
errorMessage: session.errorMessage,
|
||
expiresAt: session.expiresAt,
|
||
mode: session.mode,
|
||
newDeviceQrcode: session.newDeviceQrcode,
|
||
newDeviceStatus: session.newDeviceStatus,
|
||
qrcode: session.qrcode,
|
||
sessionId: session.id,
|
||
status: session.status,
|
||
webuiPort: session.webuiPort,
|
||
};
|
||
}
|
||
|
||
private async startNewDeviceVerification(
|
||
session: QqbotLoginScanSession,
|
||
container: QqbotNapcatRuntime,
|
||
captchaResult: NapcatCaptchaLoginResult,
|
||
) {
|
||
session.status = 'pending';
|
||
session.captchaUrl = undefined;
|
||
session.qrcode = undefined;
|
||
session.deviceVerifyUrl = this.toolsService.toTrimmedString(
|
||
captchaResult.jumpUrl,
|
||
);
|
||
session.newDevicePullQrCodeSig = this.toolsService.toTrimmedString(
|
||
captchaResult.newDevicePullQrCodeSig,
|
||
);
|
||
session.newDeviceStatus = 'qr-pending';
|
||
session.errorMessage = '需要新设备验证二维码';
|
||
this.persistLoginSession(session);
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'new-device-required',
|
||
'processing',
|
||
'需要新设备验证二维码',
|
||
);
|
||
|
||
try {
|
||
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);
|
||
} catch (err) {
|
||
return this.keepSessionPending(
|
||
session,
|
||
this.toolsService.getErrorMessage(err) || '新设备二维码生成失败',
|
||
true,
|
||
);
|
||
}
|
||
}
|
||
|
||
private async pollNewDeviceVerification(
|
||
session: QqbotLoginScanSession,
|
||
container: QqbotNapcatRuntime,
|
||
) {
|
||
const client = new NapcatLoginApiClient({
|
||
post: (path, body) => this.postNapcat(container, path, body),
|
||
});
|
||
const poll = await client.pollNewDeviceQR(session.id);
|
||
if (poll.status === 'scanned') {
|
||
return this.keepNewDevicePending(
|
||
session,
|
||
'scanned',
|
||
poll.message || '新设备二维码已扫码',
|
||
'new-device-scanned',
|
||
);
|
||
}
|
||
if (poll.status === 'confirming') {
|
||
this.keepNewDevicePending(
|
||
session,
|
||
'confirming',
|
||
poll.message || '新设备确认中',
|
||
'new-device-confirming',
|
||
);
|
||
const loginResult = await client.newDeviceLogin(session.id);
|
||
if (!loginResult.success) {
|
||
return this.failNewDeviceVerification(
|
||
session,
|
||
container,
|
||
loginResult.message || '新设备验证失败',
|
||
);
|
||
}
|
||
session.newDeviceQrcode = undefined;
|
||
session.newDeviceStatus = 'verified';
|
||
session.errorMessage = '新设备验证成功,继续登录';
|
||
this.persistLoginSession(session);
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'new-device-verified',
|
||
'success',
|
||
'新设备验证成功,继续登录',
|
||
);
|
||
return this.completePasswordLoginAfterChallenge(
|
||
session,
|
||
container,
|
||
'新设备验证登录成功',
|
||
);
|
||
}
|
||
if (poll.status === 'expired') {
|
||
return this.failNewDeviceVerification(
|
||
session,
|
||
container,
|
||
poll.message || '新设备二维码已过期',
|
||
);
|
||
}
|
||
if (poll.status === 'failed') {
|
||
return this.failNewDeviceVerification(
|
||
session,
|
||
container,
|
||
poll.message || '新设备验证失败',
|
||
);
|
||
}
|
||
return this.keepNewDevicePending(
|
||
session,
|
||
'qr-pending',
|
||
poll.message || '新设备二维码待扫码',
|
||
'new-device-qrcode-ready',
|
||
);
|
||
}
|
||
|
||
private keepNewDevicePending(
|
||
session: QqbotLoginScanSession,
|
||
status: NonNullable<QqbotLoginScanSession['newDeviceStatus']>,
|
||
message: string,
|
||
step: string,
|
||
) {
|
||
const shouldPublish =
|
||
session.newDeviceStatus !== status || session.errorMessage !== message;
|
||
session.status = 'pending';
|
||
session.captchaUrl = undefined;
|
||
session.qrcode = undefined;
|
||
session.newDeviceStatus = status;
|
||
session.errorMessage = message;
|
||
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
||
this.persistLoginSession(session);
|
||
if (shouldPublish) {
|
||
this.publishScanResultEvent(session, step, 'processing', message);
|
||
}
|
||
return this.toResult(session);
|
||
}
|
||
|
||
private async failNewDeviceVerification(
|
||
session: QqbotLoginScanSession,
|
||
container: QqbotNapcatRuntime,
|
||
message: string,
|
||
) {
|
||
session.newDeviceQrcode = undefined;
|
||
session.newDeviceStatus = 'failed';
|
||
session.errorMessage = message;
|
||
this.persistLoginSession(session);
|
||
return this.failCaptchaLogin(session, container, message);
|
||
}
|
||
|
||
private async completePasswordLoginAfterChallenge(
|
||
session: QqbotLoginScanSession,
|
||
container: QqbotNapcatRuntime,
|
||
successMessage: string,
|
||
) {
|
||
const loginStatus = await this.waitForPasswordLoginStatus(container);
|
||
if (!loginStatus.isLogin) {
|
||
const captchaUrl = this.getCaptchaUrlFromStatus(loginStatus);
|
||
if (captchaUrl) {
|
||
return this.keepPasswordCaptchaPending(
|
||
session,
|
||
captchaUrl,
|
||
loginStatus.loginError,
|
||
);
|
||
}
|
||
return this.failCaptchaLogin(
|
||
session,
|
||
container,
|
||
`验证码登录未完成:${loginStatus.loginError || 'NapCat 未返回登录成功'}`,
|
||
);
|
||
}
|
||
|
||
const loginInfo = await this.getLoginInfo(container);
|
||
const selfId = this.toolsService.pickNapcatSelfId(loginInfo);
|
||
if (loginInfo.online === false || !selfId) {
|
||
return this.failCaptchaLogin(
|
||
session,
|
||
container,
|
||
loginInfo.online === false
|
||
? 'NapCat 当前账号已离线'
|
||
: 'NapCat 未返回 QQ 号',
|
||
);
|
||
}
|
||
if (session.expectedSelfId && session.expectedSelfId !== selfId) {
|
||
await this.clearRuntimeLoginPasswordAfterFailedPassword(
|
||
session,
|
||
container,
|
||
selfId,
|
||
);
|
||
return this.failSession(
|
||
session,
|
||
`当前密码登录账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`,
|
||
);
|
||
}
|
||
|
||
try {
|
||
await this.clearRuntimeLoginPasswordAfterSuccess(
|
||
session,
|
||
container,
|
||
selfId,
|
||
);
|
||
} catch {
|
||
return this.toResult(session);
|
||
}
|
||
return this.completeLogin(session, container, {
|
||
loginInfo,
|
||
successMessage,
|
||
});
|
||
}
|
||
|
||
private publishScanEvent(
|
||
session: QqbotLoginScanSession,
|
||
input: Omit<QqbotLoginScanEvent, 'createdAt'>,
|
||
) {
|
||
const event: QqbotLoginScanEvent = {
|
||
...input,
|
||
createdAt: Date.now(),
|
||
};
|
||
const logs = this.sessionEventLogCache[session.id] || [];
|
||
logs.push(event);
|
||
this.sessionEventLogCache[session.id] = logs.slice(-50);
|
||
this.sessionEventListenerCache[session.id]?.forEach((listener) =>
|
||
listener(event),
|
||
);
|
||
}
|
||
|
||
private publishScanResultEvent(
|
||
session: QqbotLoginScanSession,
|
||
step: string,
|
||
status: QqbotLoginScanEvent['status'],
|
||
message: string,
|
||
) {
|
||
if (session.status === 'pending') {
|
||
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
||
this.persistLoginSession(session);
|
||
}
|
||
this.publishScanEvent(session, {
|
||
message,
|
||
result: this.toResult(session),
|
||
status,
|
||
step,
|
||
});
|
||
}
|
||
|
||
private async emitCurrentSessionSnapshot(
|
||
sessionId: string,
|
||
listener: (event: QqbotLoginScanEvent) => void,
|
||
) {
|
||
const session = await this.loginSessionStore.get(sessionId);
|
||
if (!session) return;
|
||
listener(this.toSessionSnapshotEvent(session));
|
||
}
|
||
|
||
private toSessionSnapshotEvent(
|
||
session: QqbotLoginScanSession,
|
||
): QqbotLoginScanEvent {
|
||
return {
|
||
createdAt: Date.now(),
|
||
message: this.getSessionSnapshotMessage(session),
|
||
result: this.toResult(session),
|
||
status: this.getSessionSnapshotStatus(session),
|
||
step: this.getSessionSnapshotStep(session),
|
||
};
|
||
}
|
||
|
||
private getSessionSnapshotStatus(
|
||
session: QqbotLoginScanSession,
|
||
): QqbotLoginScanEvent['status'] {
|
||
if (session.status === 'success') return 'success';
|
||
if (session.status === 'error' || session.status === 'expired') {
|
||
return 'error';
|
||
}
|
||
return 'processing';
|
||
}
|
||
|
||
private getSessionSnapshotStep(session: QqbotLoginScanSession) {
|
||
if (session.status === 'success') return 'login-success';
|
||
if (session.status === 'error') return 'login-failed';
|
||
if (session.status === 'expired') return 'session-expired';
|
||
if (session.newDeviceStatus) {
|
||
if (session.newDeviceStatus === 'scanned') return 'new-device-scanned';
|
||
if (session.newDeviceStatus === 'confirming') {
|
||
return 'new-device-confirming';
|
||
}
|
||
if (session.newDeviceStatus === 'verified') return 'new-device-verified';
|
||
if (['expired', 'failed'].includes(session.newDeviceStatus)) {
|
||
return 'login-failed';
|
||
}
|
||
return 'new-device-qrcode-ready';
|
||
}
|
||
if (session.captchaUrl) return 'password-login-captcha';
|
||
if (session.qrcode) return 'qrcode-ready';
|
||
if (session.preparingRelogin) {
|
||
const message = this.toolsService.toTrimmedString(session.errorMessage);
|
||
if (message.includes('密码')) return 'password-login-start';
|
||
if (message.includes('快速')) return 'quick-login-start';
|
||
return 'relogin-preparing';
|
||
}
|
||
if (session.passwordMd5) return 'password-login';
|
||
return 'scan-status';
|
||
}
|
||
|
||
private getSessionSnapshotMessage(session: QqbotLoginScanSession) {
|
||
const message = this.toolsService.toTrimmedString(session.errorMessage);
|
||
if (message) return message;
|
||
if (session.status === 'success') return '登录成功';
|
||
if (session.status === 'error') return '登录失败';
|
||
if (session.status === 'expired') return '扫码会话已过期';
|
||
if (session.newDeviceStatus === 'scanned') return '新设备二维码已扫码';
|
||
if (session.newDeviceStatus === 'confirming') return '新设备确认中';
|
||
if (session.newDeviceStatus) return '新设备二维码待扫码';
|
||
if (session.captchaUrl) return '密码登录需要完成 QQ 安全验证';
|
||
if (session.qrcode) return '登录二维码已生成';
|
||
return '登录处理中';
|
||
}
|
||
|
||
private recoverStaleReloginPreparation(session: QqbotLoginScanSession) {
|
||
if (!this.isStaleReloginPreparation(session)) return false;
|
||
session.preparingRelogin = false;
|
||
session.errorMessage = '更新登录任务已恢复,继续检测 NapCat 登录状态';
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'relogin-recovered',
|
||
'processing',
|
||
session.errorMessage,
|
||
);
|
||
return true;
|
||
}
|
||
|
||
private isStaleReloginPreparation(session: QqbotLoginScanSession) {
|
||
if (!session.preparingRelogin || !session.lastRestartedAt) return false;
|
||
return (
|
||
Date.now() - session.lastRestartedAt >
|
||
this.getReloginPreparationStaleMs()
|
||
);
|
||
}
|
||
|
||
private getReloginPreparationStaleMs() {
|
||
return this.getPositiveConfigNumber(
|
||
'QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS',
|
||
this.getPasswordLoginWaitMs() +
|
||
Math.max(this.getRestartDelayMs(), this.getTimeout()) +
|
||
this.getLoginPollIntervalMs() * 2,
|
||
);
|
||
}
|
||
|
||
private cleanupSessionEvents(sessionId: string) {
|
||
delete this.sessionEventLogCache[sessionId];
|
||
delete this.sessionEventListenerCache[sessionId];
|
||
}
|
||
|
||
private keepSessionPending(
|
||
session: QqbotLoginScanSession,
|
||
errorMessage: string,
|
||
clearQrcode = false,
|
||
) {
|
||
session.status = 'pending';
|
||
session.errorMessage = errorMessage;
|
||
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
||
if (clearQrcode) session.qrcode = undefined;
|
||
this.persistLoginSession(session);
|
||
return this.toResult(session);
|
||
}
|
||
|
||
private keepPasswordCaptchaPending(
|
||
session: QqbotLoginScanSession,
|
||
captchaUrl: string,
|
||
reason?: string,
|
||
) {
|
||
const captchaMessage = '密码登录需要完成 QQ 安全验证';
|
||
const detail = this.toolsService.isNapcatCaptchaRequiredMessage(reason)
|
||
? ''
|
||
: this.toolsService.toTrimmedString(reason);
|
||
const message = detail ? `${captchaMessage}:${detail}` : captchaMessage;
|
||
const shouldPublish =
|
||
session.captchaUrl !== captchaUrl ||
|
||
!session.errorMessage?.includes(captchaMessage);
|
||
|
||
session.status = 'pending';
|
||
session.captchaUrl = captchaUrl;
|
||
session.qrcode = undefined;
|
||
session.errorMessage = message;
|
||
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
||
this.persistLoginSession(session);
|
||
if (shouldPublish) {
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'password-login-captcha',
|
||
'processing',
|
||
`${message},请完成验证码验证`,
|
||
);
|
||
}
|
||
return this.toResult(session);
|
||
}
|
||
|
||
private keepPasswordCaptchaWaitingForUrl(
|
||
session: QqbotLoginScanSession,
|
||
reason?: string,
|
||
) {
|
||
const message =
|
||
this.toolsService.toTrimmedString(reason) ||
|
||
'密码登录需要完成 QQ 安全验证';
|
||
const shouldPublish = session.errorMessage !== message;
|
||
session.status = 'pending';
|
||
session.captchaUrl = undefined;
|
||
session.qrcode = undefined;
|
||
session.errorMessage = message;
|
||
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
||
this.persistLoginSession(session);
|
||
if (shouldPublish) {
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'password-login-captcha',
|
||
'processing',
|
||
message,
|
||
);
|
||
}
|
||
return this.toResult(session);
|
||
}
|
||
|
||
private async failCaptchaLogin(
|
||
session: QqbotLoginScanSession,
|
||
container: QqbotNapcatRuntime,
|
||
errorMessage: string,
|
||
) {
|
||
const cleaned = await this.cleanupPasswordLoginContext(
|
||
session,
|
||
container,
|
||
session.expectedSelfId,
|
||
);
|
||
if (!cleaned) return this.toResult(session);
|
||
|
||
session.status = 'error';
|
||
session.captchaUrl = undefined;
|
||
session.errorMessage = errorMessage;
|
||
session.passwordMd5 = undefined;
|
||
session.preparingRelogin = false;
|
||
this.persistLoginSession(session);
|
||
this.publishScanEvent(session, {
|
||
message: errorMessage,
|
||
result: this.toResult(session),
|
||
status: 'error',
|
||
step: 'password-login-captcha-failed',
|
||
});
|
||
return this.toResult(session);
|
||
}
|
||
|
||
private async cleanupPasswordLoginContext(
|
||
session: QqbotLoginScanSession,
|
||
container?: QqbotNapcatRuntime,
|
||
selfId?: string,
|
||
cleanupFailureMessage?: string,
|
||
) {
|
||
if (!session.passwordMd5 && !session.captchaUrl) return true;
|
||
const runtime = container || (await this.getSessionContainer(session));
|
||
const failureMessage =
|
||
cleanupFailureMessage ||
|
||
(session.status === 'success'
|
||
? 'NapCat 密码登录已完成,但运行态密码清理失败,请重试更新登录'
|
||
: 'NapCat 密码登录未完成,且运行态密码清理失败,请重试更新登录');
|
||
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'password-env-cleanup',
|
||
'processing',
|
||
'正在移除运行态登录密码',
|
||
);
|
||
const cleaned = await this.containerService.ensureRuntimeLoginEnv(runtime, {
|
||
clearLoginPassword: true,
|
||
selfId: selfId || session.expectedSelfId,
|
||
});
|
||
if (!cleaned.ok) {
|
||
session.status = 'error';
|
||
session.captchaUrl = undefined;
|
||
session.errorMessage = failureMessage;
|
||
session.passwordMd5 = undefined;
|
||
session.preparingRelogin = false;
|
||
this.persistLoginSession(session);
|
||
this.persistRuntimeCleanup(session, {
|
||
errorMessage: failureMessage,
|
||
status: 'failed',
|
||
});
|
||
this.publishScanEvent(session, {
|
||
message: failureMessage,
|
||
result: this.toResult(session),
|
||
status: 'error',
|
||
step: 'password-env-cleanup-failed',
|
||
});
|
||
return false;
|
||
}
|
||
|
||
session.captchaUrl = undefined;
|
||
session.passwordMd5 = undefined;
|
||
this.persistLoginSession(session);
|
||
this.persistRuntimeCleanup(session, { status: 'success' });
|
||
return true;
|
||
}
|
||
|
||
private persistLoginSession(session: QqbotLoginScanSession) {
|
||
this.loginSessionStore.set(session);
|
||
this.persistLoginChallenge(session);
|
||
}
|
||
|
||
private persistLoginChallenge(session: QqbotLoginScanSession) {
|
||
this.loginSessionStore.recordCaptchaChallenge(session);
|
||
this.loginSessionStore.recordNewDeviceChallenge(session);
|
||
}
|
||
|
||
private persistRuntimeCleanup(
|
||
session: QqbotLoginScanSession,
|
||
input: { errorMessage?: string; status: 'failed' | 'pending' | 'success' },
|
||
) {
|
||
this.loginSessionStore.recordRuntimeCleanup(session, {
|
||
cleanupType: 'password-login-env',
|
||
...input,
|
||
});
|
||
}
|
||
|
||
private async getSession(sessionId: string) {
|
||
const session = await this.loginSessionStore.get(sessionId);
|
||
if (!session) {
|
||
throwVbenError('扫码会话不存在或已过期');
|
||
}
|
||
return session;
|
||
}
|
||
|
||
private async getSessionContainer(session: QqbotLoginScanSession) {
|
||
return this.containerService.findRuntimeById(session.containerId);
|
||
}
|
||
|
||
private async cleanupSessions() {
|
||
const now = Date.now();
|
||
const expiredSessions: QqbotLoginScanSession[] = [];
|
||
this.loginSessionStore.forEach((session, sessionId) => {
|
||
if (session.status !== 'pending' || now > session.expiresAt) {
|
||
this.loginSessionStore.delete(sessionId);
|
||
expiredSessions.push(session);
|
||
}
|
||
});
|
||
await Promise.all(
|
||
expiredSessions.map((session) => this.closeSession(session)),
|
||
);
|
||
}
|
||
|
||
private async expireSession(session: QqbotLoginScanSession) {
|
||
const cleaned = await this.cleanupPasswordLoginContext(session);
|
||
if (!cleaned) return this.toResult(session);
|
||
session.status = 'expired';
|
||
session.errorMessage = session.errorMessage || '扫码会话已过期';
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'session-expired',
|
||
'error',
|
||
session.errorMessage,
|
||
);
|
||
this.loginSessionStore.delete(session.id);
|
||
await this.closeSession(session);
|
||
return this.toResult(session);
|
||
}
|
||
|
||
private async failSession(
|
||
session: QqbotLoginScanSession,
|
||
errorMessage: string,
|
||
) {
|
||
session.status = 'error';
|
||
session.captchaUrl = undefined;
|
||
session.errorMessage = errorMessage;
|
||
session.passwordMd5 = undefined;
|
||
session.preparingRelogin = false;
|
||
this.publishScanResultEvent(session, 'login-error', 'error', errorMessage);
|
||
this.loginSessionStore.delete(session.id);
|
||
await this.closeSession(session);
|
||
return this.toResult(session);
|
||
}
|
||
|
||
private async closeSession(session: QqbotLoginScanSession) {
|
||
await this.cleanupPasswordLoginContext(session);
|
||
await this.cleanupSessionContainer(session);
|
||
this.loginSessionStore.delete(session.id);
|
||
this.cleanupSessionEvents(session.id);
|
||
}
|
||
|
||
private async cleanupSessionContainer(session: QqbotLoginScanSession) {
|
||
const cleanupError = await this.cleanupRuntimeContainer({
|
||
baseUrl: '',
|
||
id: session.containerId,
|
||
name: session.containerName || '',
|
||
webuiPort: session.webuiPort,
|
||
});
|
||
if (cleanupError) {
|
||
session.errorMessage = session.errorMessage
|
||
? `${session.errorMessage};清理未绑定容器失败:${cleanupError}`
|
||
: `清理未绑定容器失败:${cleanupError}`;
|
||
}
|
||
}
|
||
|
||
private async tryUpdatePendingQrcode(
|
||
container: QqbotNapcatRuntime,
|
||
session: QqbotLoginScanSession,
|
||
status: NapcatLoginStatus,
|
||
) {
|
||
try {
|
||
const qrcode = await this.getQrcode(container, false, {
|
||
requireFresh: !!session.qrcode,
|
||
staleQrcode: session.qrcode || status.qrcodeurl,
|
||
});
|
||
if (qrcode) {
|
||
const qrcodeChanged = session.qrcode !== qrcode;
|
||
session.qrcode = qrcode;
|
||
session.errorMessage = status.loginError || undefined;
|
||
if (qrcodeChanged) {
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'qrcode-ready',
|
||
'success',
|
||
'登录二维码已生成',
|
||
);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
if (!this.toolsService.isNapcatTemporaryError(err)) throw err;
|
||
session.errorMessage =
|
||
session.errorMessage || 'NapCat 正在重新生成二维码,请稍后';
|
||
}
|
||
}
|
||
|
||
private async cleanupRuntimeContainer(container: QqbotNapcatRuntime) {
|
||
try {
|
||
await this.containerService.removeUnboundContainer(container.id);
|
||
return null;
|
||
} catch (err) {
|
||
return this.toolsService.getErrorMessage(err);
|
||
}
|
||
}
|
||
|
||
private async getLoginStatus(container: QqbotNapcatRuntime, retry = false) {
|
||
if (!retry) {
|
||
const status = await this.postNapcat<NapcatLoginStatus>(
|
||
container,
|
||
'/api/QQLogin/CheckLoginStatus',
|
||
);
|
||
return this.normalizeLoginStatus(container, status);
|
||
}
|
||
|
||
let lastError: unknown;
|
||
const attempts = Number(
|
||
this.configService.get('NAPCAT_WEBUI_READY_RETRIES') || 10,
|
||
);
|
||
for (let index = 0; index < attempts; index += 1) {
|
||
try {
|
||
const status = await this.postNapcat<NapcatLoginStatus>(
|
||
container,
|
||
'/api/QQLogin/CheckLoginStatus',
|
||
);
|
||
return await this.normalizeLoginStatus(container, status);
|
||
} catch (err) {
|
||
lastError = err;
|
||
if (!this.toolsService.isNapcatTemporaryError(err)) break;
|
||
await this.toolsService.sleep(1500);
|
||
}
|
||
}
|
||
throw lastError;
|
||
}
|
||
|
||
private async normalizeLoginStatus(
|
||
container: QqbotNapcatRuntime,
|
||
status: NapcatLoginStatus,
|
||
) {
|
||
if (this.toolsService.isNapcatOfflineLoginStatus(status)) {
|
||
return this.toOfflineLoginStatus(status);
|
||
}
|
||
|
||
if (!status.isLogin) return status;
|
||
|
||
try {
|
||
const loginInfo = await this.getLoginInfo(container);
|
||
if (loginInfo.online === false) {
|
||
return this.toOfflineLoginStatus(
|
||
status,
|
||
'NapCat 账号已离线,请重新扫码登录',
|
||
);
|
||
}
|
||
} catch (err) {
|
||
const errorMessage = this.toolsService.getErrorMessage(err);
|
||
if (this.toolsService.isNapcatOfflineLoginMessage(errorMessage)) {
|
||
return this.toOfflineLoginStatus(status, errorMessage);
|
||
}
|
||
throw err;
|
||
}
|
||
|
||
return status;
|
||
}
|
||
|
||
private toOfflineLoginStatus(
|
||
status: NapcatLoginStatus,
|
||
errorMessage = 'NapCat 账号已离线,请重新扫码登录',
|
||
): NapcatLoginStatus {
|
||
return {
|
||
...status,
|
||
isLogin: false,
|
||
isOffline: true,
|
||
loginError: status.loginError || errorMessage,
|
||
};
|
||
}
|
||
|
||
private async getLoginInfo(container: QqbotNapcatRuntime) {
|
||
return this.postNapcat<NapcatLoginInfo>(
|
||
container,
|
||
'/api/QQLogin/GetQQLoginInfo',
|
||
);
|
||
}
|
||
|
||
private async callRefreshQrcode(
|
||
container: QqbotNapcatRuntime,
|
||
retry = false,
|
||
) {
|
||
return this.executeNapcatRequest(retry, async () => {
|
||
try {
|
||
const data = await this.postNapcat<NapcatQrcode | null>(
|
||
container,
|
||
'/api/QQLogin/RefreshQRcode',
|
||
);
|
||
return this.toolsService.pickQrcode(data);
|
||
} catch (err) {
|
||
if (this.toolsService.isNapcatAlreadyLoggedInError(err)) return '';
|
||
throw err;
|
||
}
|
||
});
|
||
}
|
||
|
||
private async getQrcode(
|
||
container: QqbotNapcatRuntime,
|
||
retry = false,
|
||
options: QrcodeLookupOptions = {},
|
||
) {
|
||
return this.executeNapcatRequest(retry, async () => {
|
||
try {
|
||
const data = await this.postNapcat<NapcatQrcode>(
|
||
container,
|
||
'/api/QQLogin/GetQQLoginQrcode',
|
||
);
|
||
const qrcode = this.toolsService.pickQrcode(data);
|
||
if (!qrcode) {
|
||
return this.getQrcodeFromStatus(container, options);
|
||
}
|
||
return this.toolsService.ensureFreshQrcode(qrcode, options);
|
||
} catch (err) {
|
||
if (this.toolsService.isNapcatAlreadyLoggedInError(err)) {
|
||
const status = await this.getLoginStatus(container);
|
||
return this.toolsService.ensureFreshQrcode(
|
||
status.qrcodeurl || '',
|
||
options,
|
||
);
|
||
}
|
||
if (this.toolsService.isNapcatQrcodePendingError(err)) {
|
||
return this.getQrcodeFromStatus(container, options);
|
||
}
|
||
throw err;
|
||
}
|
||
});
|
||
}
|
||
|
||
private async refreshOrGetQrcode(
|
||
container: QqbotNapcatRuntime,
|
||
retry = false,
|
||
options: QrcodeRefreshOptions = {},
|
||
) {
|
||
let fallbackStatus = options.fallbackStatus;
|
||
const lookupOptions: QrcodeLookupOptions = {
|
||
requireFresh: options.requireFresh || fallbackStatus?.isOffline,
|
||
staleQrcode: options.staleQrcode || fallbackStatus?.qrcodeurl,
|
||
};
|
||
if (fallbackStatus?.isOffline) {
|
||
await this.restartNapcatForLogin(container);
|
||
fallbackStatus = undefined;
|
||
}
|
||
try {
|
||
const refreshedQrcode = await this.callRefreshQrcode(container, retry);
|
||
if (refreshedQrcode) {
|
||
return this.toolsService.ensureFreshQrcode(
|
||
refreshedQrcode,
|
||
lookupOptions,
|
||
);
|
||
}
|
||
return await this.getQrcode(container, retry, lookupOptions);
|
||
} catch (err) {
|
||
if (
|
||
!lookupOptions.requireFresh &&
|
||
fallbackStatus?.qrcodeurl &&
|
||
!this.toolsService.isNapcatExpiredQrcodeStatus(fallbackStatus)
|
||
) {
|
||
return fallbackStatus.qrcodeurl;
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
private async getQrcodeFromStatus(
|
||
container: QqbotNapcatRuntime,
|
||
options: QrcodeLookupOptions = {},
|
||
) {
|
||
const status = await this.getLoginStatus(container);
|
||
if (
|
||
status.qrcodeurl &&
|
||
!this.toolsService.isNapcatExpiredQrcodeStatus(status)
|
||
) {
|
||
return this.toolsService.ensureFreshQrcode(status.qrcodeurl, options);
|
||
}
|
||
if (options.requireFresh && status.qrcodeurl) {
|
||
throw new Error('NapCat 二维码仍未刷新');
|
||
}
|
||
throwVbenError('NapCat 未返回登录二维码');
|
||
}
|
||
|
||
private async postNapcat<T>(
|
||
container: QqbotNapcatRuntime,
|
||
path: string,
|
||
body: Record<string, any> = {},
|
||
) {
|
||
return this.webuiClient
|
||
.post<T>(container, path, body)
|
||
.catch((err): never => {
|
||
const message = this.toolsService.getErrorMessage(err);
|
||
return throwVbenError(message || 'NapCat 请求失败');
|
||
});
|
||
}
|
||
|
||
private async prepareReloginQrcode(
|
||
session: QqbotLoginScanSession,
|
||
container: QqbotNapcatRuntime,
|
||
loginPassword?: string,
|
||
hasExistingPrimaryBinding = true,
|
||
) {
|
||
try {
|
||
const password = this.toolsService.toSecretText(loginPassword);
|
||
if (hasExistingPrimaryBinding) {
|
||
const quickLoginCompleted = await this.tryQuickRelogin(
|
||
session,
|
||
container,
|
||
!!password,
|
||
);
|
||
if (quickLoginCompleted) return;
|
||
}
|
||
|
||
const passwordLoginCompleted = await this.tryPasswordRelogin(
|
||
session,
|
||
container,
|
||
password,
|
||
);
|
||
if (passwordLoginCompleted) return;
|
||
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'relogin-reset-start',
|
||
'processing',
|
||
'开始重置 NapCat 登录态',
|
||
);
|
||
await this.resetNapcatForLogin(
|
||
container,
|
||
{ waitForReady: false },
|
||
(step, message) => {
|
||
this.publishScanResultEvent(session, step, 'processing', message);
|
||
},
|
||
);
|
||
session.lastRestartedAt = Date.now();
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'napcat-ready-wait',
|
||
'processing',
|
||
'等待 NapCat WebUI 启动',
|
||
);
|
||
await this.toolsService.sleep(this.getRestartDelayMs());
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'qrcode-fetch',
|
||
'processing',
|
||
'正在获取登录二维码',
|
||
);
|
||
session.qrcode = await this.refreshOrGetQrcode(container, true, {
|
||
requireFresh: true,
|
||
});
|
||
session.errorMessage = undefined;
|
||
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'qrcode-ready',
|
||
'success',
|
||
'登录二维码已生成',
|
||
);
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'waiting-scan',
|
||
'processing',
|
||
'等待扫码确认',
|
||
);
|
||
} catch (err) {
|
||
const message = this.toolsService.getErrorMessage(err);
|
||
if (this.toolsService.isNapcatTemporaryError(err)) {
|
||
session.errorMessage =
|
||
'NapCat 正在重新生成二维码,请稍后刷新或等待自动更新';
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'qrcode-pending',
|
||
'processing',
|
||
session.errorMessage,
|
||
);
|
||
} else {
|
||
session.status = 'error';
|
||
session.errorMessage = message || 'NapCat 重置登录态失败';
|
||
session.preparingRelogin = false;
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'relogin-error',
|
||
'error',
|
||
session.errorMessage,
|
||
);
|
||
}
|
||
} finally {
|
||
const current = this.loginSessionStore.getCached(session.id);
|
||
if (current === session && current.status === 'pending') {
|
||
current.preparingRelogin = false;
|
||
this.persistLoginSession(current);
|
||
}
|
||
}
|
||
}
|
||
|
||
private async tryQuickRelogin(
|
||
session: QqbotLoginScanSession,
|
||
container: QqbotNapcatRuntime,
|
||
hasPasswordFallback = false,
|
||
) {
|
||
let loginInfo: NapcatLoginInfo;
|
||
session.errorMessage = 'NapCat 正在尝试快速登录,请稍后';
|
||
this.persistLoginSession(session);
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'quick-login-start',
|
||
'processing',
|
||
'正在尝试 NapCat -q 快速登录',
|
||
);
|
||
await this.clearRuntimeLoginPasswordBeforeQuick(session, container);
|
||
|
||
try {
|
||
await this.restartNapcatForLogin(container, { waitForReady: false });
|
||
session.lastRestartedAt = Date.now();
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'quick-login-wait',
|
||
'processing',
|
||
'等待 NapCat 快速登录结果',
|
||
);
|
||
await this.toolsService.sleep(this.getRestartDelayMs());
|
||
const loginStatus = await this.getLoginStatus(container, true);
|
||
if (!loginStatus.isLogin) {
|
||
this.publishQuickLoginFallback(
|
||
session,
|
||
loginStatus.loginError,
|
||
hasPasswordFallback,
|
||
);
|
||
return false;
|
||
}
|
||
|
||
loginInfo = await this.getLoginInfo(container);
|
||
if (loginInfo.online === false) {
|
||
this.publishQuickLoginFallback(
|
||
session,
|
||
'NapCat 当前账号已离线',
|
||
hasPasswordFallback,
|
||
);
|
||
return false;
|
||
}
|
||
|
||
const selfId = this.toolsService.pickNapcatSelfId(loginInfo);
|
||
if (!selfId) {
|
||
this.publishQuickLoginFallback(
|
||
session,
|
||
'NapCat 未返回 QQ 号',
|
||
hasPasswordFallback,
|
||
);
|
||
return false;
|
||
}
|
||
if (session.expectedSelfId && session.expectedSelfId !== selfId) {
|
||
this.publishQuickLoginFallback(
|
||
session,
|
||
`当前快速登录账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`,
|
||
hasPasswordFallback,
|
||
);
|
||
return false;
|
||
}
|
||
} catch (err) {
|
||
this.publishQuickLoginFallback(
|
||
session,
|
||
this.toolsService.getErrorMessage(err),
|
||
hasPasswordFallback,
|
||
);
|
||
return false;
|
||
}
|
||
|
||
await this.completeLogin(session, container, {
|
||
loginInfo,
|
||
successMessage: '快速登录成功',
|
||
});
|
||
return true;
|
||
}
|
||
|
||
private async tryPasswordRelogin(
|
||
session: QqbotLoginScanSession,
|
||
container: QqbotNapcatRuntime,
|
||
loginPassword?: string,
|
||
) {
|
||
const password = this.toolsService.toSecretText(loginPassword);
|
||
if (!password) {
|
||
this.publishPasswordLoginFallback(session, '未配置 QQ 登录密码');
|
||
return false;
|
||
}
|
||
|
||
let loginInfo: NapcatLoginInfo | undefined;
|
||
let loggedInSelfId = '';
|
||
const passwordLogSinceMs = Date.now();
|
||
session.passwordMd5 = createHash('md5')
|
||
.update(password, 'utf8')
|
||
.digest('hex');
|
||
session.errorMessage = 'NapCat 正在尝试密码登录,请稍后';
|
||
this.persistLoginSession(session);
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'password-login-start',
|
||
'processing',
|
||
'正在尝试 NapCat 密码登录',
|
||
);
|
||
|
||
const passwordEnv = await this.containerService.ensureRuntimeLoginEnv(
|
||
container,
|
||
{
|
||
loginPassword: password,
|
||
selfId: session.expectedSelfId,
|
||
},
|
||
);
|
||
if (!passwordEnv.ok) {
|
||
this.publishPasswordLoginFallback(session, '运行态密码环境准备失败');
|
||
return false;
|
||
}
|
||
|
||
let loginStatus: NapcatLoginStatus;
|
||
try {
|
||
await this.restartNapcatForLogin(container, { waitForReady: false });
|
||
session.lastRestartedAt = Date.now();
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'password-login-wait',
|
||
'processing',
|
||
'等待 NapCat 密码登录结果',
|
||
);
|
||
loginStatus = await this.waitForPasswordLoginStatus(
|
||
container,
|
||
passwordLogSinceMs,
|
||
);
|
||
|
||
if (loginStatus.isLogin) {
|
||
loginInfo = await this.getLoginInfo(container);
|
||
}
|
||
} catch (err) {
|
||
await this.clearRuntimeLoginPasswordAfterFailedPassword(
|
||
session,
|
||
container,
|
||
session.expectedSelfId || loggedInSelfId,
|
||
);
|
||
this.publishPasswordLoginFallback(
|
||
session,
|
||
this.toolsService.getErrorMessage(err),
|
||
);
|
||
return false;
|
||
}
|
||
|
||
if (!loginStatus.isLogin) {
|
||
if (this.isPasswordQrcodeChallenge(loginStatus)) {
|
||
await this.keepPasswordQrcodePending(session, container, loginStatus);
|
||
return true;
|
||
}
|
||
const captchaUrl = await this.resolvePasswordCaptchaUrl(
|
||
container,
|
||
loginStatus,
|
||
passwordLogSinceMs,
|
||
);
|
||
if (captchaUrl) {
|
||
this.keepPasswordCaptchaPending(
|
||
session,
|
||
captchaUrl,
|
||
loginStatus.loginError,
|
||
);
|
||
return true;
|
||
}
|
||
|
||
await this.clearRuntimeLoginPasswordAfterFailedPassword(
|
||
session,
|
||
container,
|
||
session.expectedSelfId,
|
||
);
|
||
this.publishPasswordLoginFallback(session, loginStatus.loginError);
|
||
return false;
|
||
}
|
||
|
||
if (loginInfo?.online === false) {
|
||
await this.clearRuntimeLoginPasswordAfterFailedPassword(
|
||
session,
|
||
container,
|
||
session.expectedSelfId,
|
||
);
|
||
this.publishPasswordLoginFallback(session, 'NapCat 当前账号已离线');
|
||
return false;
|
||
}
|
||
if (!loginInfo) {
|
||
await this.clearRuntimeLoginPasswordAfterFailedPassword(
|
||
session,
|
||
container,
|
||
session.expectedSelfId,
|
||
);
|
||
this.publishPasswordLoginFallback(session, 'NapCat 未返回登录信息');
|
||
return false;
|
||
}
|
||
|
||
const selfId = this.toolsService.pickNapcatSelfId(loginInfo);
|
||
if (!selfId) {
|
||
await this.clearRuntimeLoginPasswordAfterFailedPassword(
|
||
session,
|
||
container,
|
||
session.expectedSelfId,
|
||
);
|
||
this.publishPasswordLoginFallback(session, 'NapCat 未返回 QQ 号');
|
||
return false;
|
||
}
|
||
loggedInSelfId = selfId;
|
||
if (session.expectedSelfId && session.expectedSelfId !== selfId) {
|
||
await this.clearRuntimeLoginPasswordAfterFailedPassword(
|
||
session,
|
||
container,
|
||
selfId,
|
||
);
|
||
this.publishPasswordLoginFallback(
|
||
session,
|
||
`当前密码登录账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`,
|
||
);
|
||
return false;
|
||
}
|
||
|
||
await this.clearRuntimeLoginPasswordAfterSuccess(
|
||
session,
|
||
container,
|
||
loggedInSelfId,
|
||
);
|
||
await this.completeLogin(session, container, {
|
||
loginInfo,
|
||
successMessage: '密码登录成功',
|
||
});
|
||
return true;
|
||
}
|
||
|
||
private async keepPasswordQrcodePending(
|
||
session: QqbotLoginScanSession,
|
||
container: QqbotNapcatRuntime,
|
||
loginStatus: NapcatLoginStatus,
|
||
) {
|
||
await this.clearRuntimeLoginPasswordAfterFailedPassword(
|
||
session,
|
||
container,
|
||
session.expectedSelfId,
|
||
);
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'password-login-qrcode',
|
||
'processing',
|
||
'密码登录未完成,已切换到扫码确认',
|
||
);
|
||
session.qrcode = await this.refreshOrGetQrcode(container, true, {
|
||
fallbackStatus: loginStatus,
|
||
requireFresh: this.toolsService.isNapcatExpiredQrcodeStatus(loginStatus),
|
||
staleQrcode: loginStatus.qrcodeurl,
|
||
});
|
||
session.captchaUrl = undefined;
|
||
session.errorMessage = undefined;
|
||
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
||
this.persistLoginSession(session);
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'qrcode-ready',
|
||
'success',
|
||
'登录二维码已生成',
|
||
);
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'waiting-scan',
|
||
'processing',
|
||
'等待扫码确认',
|
||
);
|
||
}
|
||
|
||
private async resolvePasswordCaptchaUrl(
|
||
container: QqbotNapcatRuntime,
|
||
loginStatus: NapcatLoginStatus,
|
||
sinceMs?: number,
|
||
) {
|
||
const statusCaptchaUrl = this.getCaptchaUrlFromStatus(loginStatus);
|
||
if (statusCaptchaUrl) return statusCaptchaUrl;
|
||
const runtimeCaptchaUrl = await this.detectPasswordCaptchaUrl(
|
||
container,
|
||
sinceMs,
|
||
false,
|
||
);
|
||
if (runtimeCaptchaUrl) return runtimeCaptchaUrl;
|
||
if (
|
||
!this.toolsService.isNapcatCaptchaRequiredMessage(loginStatus.loginError)
|
||
) {
|
||
return '';
|
||
}
|
||
return this.waitForPasswordCaptchaUrl(container, sinceMs);
|
||
}
|
||
|
||
private async resolveStatusCaptchaUrl(
|
||
session: QqbotLoginScanSession,
|
||
container: QqbotNapcatRuntime,
|
||
loginStatus: NapcatLoginStatus,
|
||
) {
|
||
if (!this.isPasswordCaptchaStillRequired(loginStatus)) return '';
|
||
if (!this.shouldLookupStatusCaptchaUrl(session)) return '';
|
||
session.lastCaptchaLookupAt = Date.now();
|
||
this.persistLoginSession(session);
|
||
return this.detectPasswordCaptchaUrl(container, session.lastRestartedAt);
|
||
}
|
||
|
||
private getCaptchaUrlFromStatus(status: NapcatLoginStatus) {
|
||
return (
|
||
this.toolsService.toTrimmedString(status.captchaUrl) ||
|
||
this.toolsService.extractNapcatCaptchaUrl(status.loginError)
|
||
);
|
||
}
|
||
|
||
private shouldLookupStatusCaptchaUrl(session: QqbotLoginScanSession) {
|
||
const lastCheckedAt = Number(session.lastCaptchaLookupAt || 0);
|
||
if (!Number.isFinite(lastCheckedAt) || lastCheckedAt <= 0) return true;
|
||
const cooldownMs = Math.max(15_000, this.getLoginPollIntervalMs() * 5);
|
||
return Date.now() - lastCheckedAt > cooldownMs;
|
||
}
|
||
|
||
private isPasswordCaptchaStillRequired(status: NapcatLoginStatus) {
|
||
if (this.getCaptchaUrlFromStatus(status)) return true;
|
||
const message = this.toolsService.toTrimmedString(status.loginError);
|
||
if (
|
||
this.toolsService.includesAny(message, [
|
||
'失败',
|
||
'错误',
|
||
'过期',
|
||
'失效',
|
||
'拒绝',
|
||
'取消',
|
||
])
|
||
) {
|
||
return false;
|
||
}
|
||
return (
|
||
message.includes('proofWaterUrl') ||
|
||
message.includes('需要验证码') ||
|
||
message.includes('继续完成验证') ||
|
||
message.includes('需要安全验证') ||
|
||
message.includes('继续安全验证') ||
|
||
message.includes('完成安全验证')
|
||
);
|
||
}
|
||
|
||
private async detectPasswordCaptchaUrl(
|
||
container: QqbotNapcatRuntime,
|
||
sinceMs?: number,
|
||
allowTailFallback = true,
|
||
) {
|
||
if (typeof this.containerService.detectRuntimeCaptchaUrl !== 'function') {
|
||
return '';
|
||
}
|
||
const recentCaptchaUrl = await this.containerService.detectRuntimeCaptchaUrl(
|
||
container,
|
||
sinceMs,
|
||
);
|
||
if (recentCaptchaUrl) return recentCaptchaUrl;
|
||
if (!allowTailFallback) return '';
|
||
return (
|
||
(await this.containerService.detectRuntimeCaptchaUrl(container)) || ''
|
||
);
|
||
}
|
||
|
||
private async waitForPasswordCaptchaUrl(
|
||
container: QqbotNapcatRuntime,
|
||
sinceMs?: number,
|
||
) {
|
||
const attempts = 5;
|
||
for (let index = 0; index < attempts; index += 1) {
|
||
if (index > 0) {
|
||
await this.toolsService.sleep(this.getLoginPollIntervalMs());
|
||
}
|
||
const captchaUrl = await this.detectPasswordCaptchaUrl(
|
||
container,
|
||
sinceMs,
|
||
true,
|
||
);
|
||
if (captchaUrl) return captchaUrl;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
private isPasswordQrcodeChallenge(status: NapcatLoginStatus) {
|
||
return (
|
||
!!this.toolsService.toTrimmedString(status.qrcodeurl) ||
|
||
this.toolsService.isNapcatExpiredQrcodeStatus(status)
|
||
);
|
||
}
|
||
|
||
private async clearRuntimeLoginPasswordBeforeQuick(
|
||
session: QqbotLoginScanSession,
|
||
container: QqbotNapcatRuntime,
|
||
) {
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'quick-login-cleanup',
|
||
'processing',
|
||
'正在清理运行态登录密码',
|
||
);
|
||
const cleaned = await this.containerService.ensureRuntimeLoginEnv(
|
||
container,
|
||
{
|
||
clearLoginPassword: true,
|
||
selfId: session.expectedSelfId,
|
||
},
|
||
);
|
||
if (!cleaned.ok) {
|
||
throw new Error('NapCat 快速登录前运行态密码清理失败,请重试更新登录');
|
||
}
|
||
}
|
||
|
||
private async clearRuntimeLoginPasswordAfterFailedPassword(
|
||
session: QqbotLoginScanSession,
|
||
container: QqbotNapcatRuntime,
|
||
selfId?: string,
|
||
) {
|
||
const failureMessage =
|
||
'NapCat 密码登录未完成,且运行态密码清理失败,请重试更新登录';
|
||
const cleaned = await this.cleanupPasswordLoginContext(
|
||
session,
|
||
container,
|
||
selfId,
|
||
failureMessage,
|
||
);
|
||
if (!cleaned) {
|
||
throw new Error(failureMessage);
|
||
}
|
||
}
|
||
|
||
private async clearRuntimeLoginPasswordAfterSuccess(
|
||
session: QqbotLoginScanSession,
|
||
container: QqbotNapcatRuntime,
|
||
selfId: string,
|
||
) {
|
||
const failureMessage =
|
||
'NapCat 密码登录已完成,但运行态密码清理失败,请重试更新登录';
|
||
const cleaned = await this.cleanupPasswordLoginContext(
|
||
session,
|
||
container,
|
||
selfId,
|
||
failureMessage,
|
||
);
|
||
if (!cleaned) {
|
||
throw new Error(failureMessage);
|
||
}
|
||
}
|
||
|
||
private getReloginPreparingMessage(options: {
|
||
hasExistingPrimaryBinding?: boolean;
|
||
loginPassword?: string;
|
||
}) {
|
||
if (options.hasExistingPrimaryBinding !== false) {
|
||
return 'NapCat 正在尝试快速登录,请稍后';
|
||
}
|
||
return this.toolsService.toSecretText(options.loginPassword)
|
||
? 'NapCat 正在尝试密码登录,请稍后'
|
||
: 'NapCat 正在准备登录二维码,请稍后';
|
||
}
|
||
|
||
private publishQuickLoginFallback(
|
||
session: QqbotLoginScanSession,
|
||
reason?: string,
|
||
hasPasswordFallback = false,
|
||
) {
|
||
const nextStepMessage = hasPasswordFallback
|
||
? '开始尝试密码登录'
|
||
: '开始生成二维码';
|
||
session.errorMessage = reason
|
||
? `快速登录未完成:${reason},${nextStepMessage}`
|
||
: `快速登录未完成,${nextStepMessage}`;
|
||
this.persistLoginSession(session);
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'quick-login-fallback',
|
||
'processing',
|
||
session.errorMessage,
|
||
);
|
||
}
|
||
|
||
private publishPasswordLoginFallback(
|
||
session: QqbotLoginScanSession,
|
||
reason?: string,
|
||
) {
|
||
session.errorMessage = reason
|
||
? `密码登录未完成:${reason},开始生成二维码`
|
||
: '密码登录未完成,开始生成二维码';
|
||
this.persistLoginSession(session);
|
||
this.publishScanResultEvent(
|
||
session,
|
||
'password-login-fallback',
|
||
'processing',
|
||
session.errorMessage,
|
||
);
|
||
}
|
||
|
||
private async resetNapcatForLogin(
|
||
container: QqbotNapcatRuntime,
|
||
options: NapcatRestartOptions = {},
|
||
onProgress?: (step: string, message: string) => void,
|
||
) {
|
||
const resetByContainer = await this.containerService.resetRuntimeLoginState(
|
||
container,
|
||
onProgress,
|
||
);
|
||
if (!resetByContainer) {
|
||
onProgress?.('napcat-restart-webui', '正在调用 NapCat 重启接口');
|
||
await this.restartNapcatForLogin(container, options);
|
||
return;
|
||
}
|
||
|
||
this.webuiClient.clearCredential(container);
|
||
if (options.waitForReady === false) return;
|
||
|
||
onProgress?.('napcat-ready-wait', '等待 NapCat WebUI 启动');
|
||
await this.toolsService.sleep(this.getRestartDelayMs());
|
||
await this.getLoginStatus(container, true);
|
||
}
|
||
|
||
private async restartNapcatForLogin(
|
||
container: QqbotNapcatRuntime,
|
||
options: NapcatRestartOptions = {},
|
||
) {
|
||
const restartedByContainer =
|
||
await this.containerService.restartRuntimeContainer(container);
|
||
if (!restartedByContainer) {
|
||
try {
|
||
await this.postNapcat<Record<string, any> | null>(
|
||
container,
|
||
'/api/QQLogin/RestartNapCat',
|
||
);
|
||
} catch (err) {
|
||
if (!this.toolsService.isNapcatTemporaryError(err)) throw err;
|
||
}
|
||
}
|
||
|
||
this.webuiClient.clearCredential(container);
|
||
if (options.waitForReady === false) return;
|
||
|
||
await this.toolsService.sleep(this.getRestartDelayMs());
|
||
await this.getLoginStatus(container, true);
|
||
}
|
||
|
||
private getSessionTtlMs() {
|
||
return Number(
|
||
this.configService.get('NAPCAT_LOGIN_QR_EXPIRE_MS') || 2 * 60 * 1000,
|
||
);
|
||
}
|
||
|
||
private getTimeout() {
|
||
return Number(this.configService.get('NAPCAT_WEBUI_TIMEOUT_MS') || 8000);
|
||
}
|
||
|
||
private getRestartDelayMs() {
|
||
return Number(
|
||
this.configService.get('NAPCAT_WEBUI_RESTART_DELAY_MS') || 3000,
|
||
);
|
||
}
|
||
|
||
private async waitForPasswordLoginStatus(
|
||
container: QqbotNapcatRuntime,
|
||
sinceMs?: number,
|
||
) {
|
||
let latestStatus: NapcatLoginStatus = { isLogin: false };
|
||
const attempts = this.getLoginPollAttempts(
|
||
this.getPasswordLoginWaitMs(),
|
||
this.getLoginPollIntervalMs(),
|
||
);
|
||
for (let index = 0; index < attempts; index += 1) {
|
||
if (index > 0) {
|
||
await this.toolsService.sleep(this.getLoginPollIntervalMs());
|
||
}
|
||
try {
|
||
latestStatus = await this.getLoginStatus(container, true);
|
||
} catch (err) {
|
||
const errorMessage = this.toolsService.getErrorMessage(err);
|
||
if (this.toolsService.isNapcatCaptchaRequiredMessage(errorMessage)) {
|
||
return {
|
||
captchaUrl: await this.detectPasswordCaptchaUrl(
|
||
container,
|
||
sinceMs,
|
||
),
|
||
isLogin: false,
|
||
loginError: errorMessage,
|
||
};
|
||
}
|
||
throw err;
|
||
}
|
||
const qrcodeChallenge = this.isPasswordQrcodeChallenge(latestStatus);
|
||
const captchaRequired = this.toolsService.isNapcatCaptchaRequiredMessage(
|
||
latestStatus.loginError,
|
||
);
|
||
const hasRestartTimestamp =
|
||
typeof sinceMs === 'number' && Number.isFinite(sinceMs);
|
||
if (
|
||
!latestStatus.isLogin &&
|
||
!qrcodeChallenge &&
|
||
(hasRestartTimestamp || captchaRequired)
|
||
) {
|
||
latestStatus.captchaUrl =
|
||
this.getCaptchaUrlFromStatus(latestStatus) ||
|
||
(await this.detectPasswordCaptchaUrl(
|
||
container,
|
||
sinceMs,
|
||
captchaRequired,
|
||
));
|
||
}
|
||
if (latestStatus.isLogin || qrcodeChallenge || captchaRequired) {
|
||
if (
|
||
!this.getCaptchaUrlFromStatus(latestStatus) &&
|
||
captchaRequired
|
||
) {
|
||
latestStatus.captchaUrl = await this.detectPasswordCaptchaUrl(
|
||
container,
|
||
sinceMs,
|
||
true,
|
||
);
|
||
}
|
||
return latestStatus;
|
||
}
|
||
if (latestStatus.captchaUrl) return latestStatus;
|
||
}
|
||
return latestStatus;
|
||
}
|
||
|
||
private getLoginPollAttempts(waitMs: number, intervalMs: number) {
|
||
const normalizedWaitMs = Number.isFinite(waitMs) && waitMs > 0 ? waitMs : 1;
|
||
const normalizedIntervalMs =
|
||
Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 1;
|
||
return Math.max(1, Math.ceil(normalizedWaitMs / normalizedIntervalMs));
|
||
}
|
||
|
||
private getPasswordLoginWaitMs() {
|
||
return this.getPositiveConfigNumber(
|
||
'QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS',
|
||
120_000,
|
||
);
|
||
}
|
||
|
||
private getLoginPollIntervalMs() {
|
||
return this.getPositiveConfigNumber(
|
||
'QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS',
|
||
3000,
|
||
);
|
||
}
|
||
|
||
private getPositiveConfigNumber(key: string, fallback: number) {
|
||
const value = Number(this.configService.get(key) || fallback);
|
||
return Number.isFinite(value) && value > 0 ? value : fallback;
|
||
}
|
||
|
||
private async executeNapcatRequest<T>(
|
||
retry: boolean,
|
||
action: () => Promise<T>,
|
||
) {
|
||
if (!retry) return action();
|
||
|
||
let lastError: unknown;
|
||
const attempts = Number(
|
||
this.configService.get('NAPCAT_WEBUI_READY_RETRIES') || 10,
|
||
);
|
||
for (let index = 0; index < attempts; index += 1) {
|
||
try {
|
||
return await action();
|
||
} catch (err) {
|
||
lastError = err;
|
||
if (!this.toolsService.isNapcatTemporaryError(err)) break;
|
||
await this.toolsService.sleep(1500);
|
||
}
|
||
}
|
||
throw lastError;
|
||
}
|
||
}
|