kt-template-online-api/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts

3837 lines
131 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
type NewDeviceQrCode,
type NewDeviceQrRequest,
} from '../../infrastructure/integration/napcat-login-api.client';
import type {
NapcatCaptchaLoginResult,
NapcatLoginInfo,
NapcatLoginStatus,
NapcatQrcode,
NapcatRestartOptions,
QqbotLoginCaptchaSubmitInput,
QqbotLoginScanMode,
QqbotLoginScanEvent,
QqbotLoginScanResult,
QqbotLoginScanSession,
QqbotLoginScanStatus,
QqbotNapcatRuntimeLoginStatus,
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';
type PendingQrcodeUpdateOptions = {
clearStaleQrcode?: boolean;
requireFresh?: boolean;
};
type ScanStatusMonitorDeadline = {
expiresAt: number;
qrcode: string;
};
@Injectable()
export class QqbotNapcatLoginService {
private readonly fallbackLoginSessionStore =
new NapcatLoginStateStoreService();
private readonly sessionEventLogCache: Record<string, QqbotLoginScanEvent[]> =
{};
private readonly sessionEventListenerCache: Record<
string,
Set<(event: QqbotLoginScanEvent) => void>
> = {};
private readonly scanStatusMonitorTimers: Record<
string,
NodeJS.Timeout | undefined
> = {};
private readonly scanStatusMonitorDeadlines: Record<
string,
ScanStatusMonitorDeadline | undefined
> = {};
private readonly refreshStartTasks: Record<
string,
Promise<QqbotLoginScanResult> | undefined
> = {};
readonly sessions = {
/**
* 清理 NapCat回调状态。
*/
clear: () => {
this.stopAllScanStatusMonitors();
this.loginSessionStore.clear();
},
/**
* 读取 NapCat回调数据。
* @param sessionId - NapCat ID定位本次读取、更新、删除或关联的NapCat。
*/
get: (sessionId: string) => this.loginSessionStore.getCached(sessionId),
/**
* 判断 NapCat回调条件。
* @param sessionId - NapCat ID定位本次读取、更新、删除或关联的NapCat。
*/
has: (sessionId: string) => this.loginSessionStore.has(sessionId),
/**
* 写入 NapCat回调数据。
* @param sessionId - NapCat ID定位本次读取、更新、删除或关联的NapCat。
* @param session - session 输入;使用 `id` 字段生成结果。
*/
set: (sessionId: string, session: QqbotLoginScanSession) => {
if (!session.id) session.id = sessionId;
this.loginSessionStore.set(session);
},
};
readonly sessionEventLogs = {
/**
* 清理 NapCat回调状态。
*/
clear: () =>
Object.keys(this.sessionEventLogCache).forEach((sessionId) => {
delete this.sessionEventLogCache[sessionId];
}),
/**
* 读取 NapCat回调数据。
* @param sessionId - NapCat ID定位本次读取、更新、删除或关联的NapCat。
*/
get: (sessionId: string) => this.sessionEventLogCache[sessionId],
};
readonly sessionEventListeners = {
/**
* 清理 NapCat回调状态。
*/
clear: () =>
Object.keys(this.sessionEventListenerCache).forEach((sessionId) => {
delete this.sessionEventListenerCache[sessionId];
}),
};
private readonly webuiClient = new NapcatWebuiHttpClient({
/**
* 读取 NapCat回调数据。
*/
getTimeoutMs: () => this.getTimeout(),
});
/**
* 初始化 QqbotNapcatLoginService 实例。
* @param configService - Nest ConfigService 依赖;影响 constructor 的返回值。
* @param accountService - accountService 服务依赖;影响 constructor 的返回值。
* @param containerService - containerService 服务依赖;影响 constructor 的返回值。
* @param toolsService - ToolsService 依赖;影响 constructor 的返回值。
* @param loginStateStore - loginStateStore 输入;影响 constructor 的返回值。
*/
constructor(
private readonly configService: ConfigService,
private readonly accountService: QqbotAccountService,
private readonly containerService: QqbotNapcatContainerService,
private readonly toolsService: ToolsService,
@Optional()
private readonly loginStateStore?: NapcatLoginStateStoreService,
) {}
/**
* 执行 NapCat 登录运行态流程。
*/
private get loginSessionStore() {
return this.loginStateStore || this.fallbackLoginSessionStore;
}
/**
* Starts a create-login session without waiting for remote container startup.
* @returns Pending scan session snapshot; the container startup and QR fetch continue in the background.
*/
async startCreate() {
await this.cleanupSessions();
const container = await this.containerService.reserveCreateContainer();
const session = this.createSession({
container,
mode: 'create',
preparingContainer: true,
status: 'pending',
});
session.lastRestartedAt = Date.now();
session.errorMessage = 'NapCat 正在创建登录容器,请稍后';
this.persistLoginSession(session);
this.publishScanResultEvent(
session,
'container-starting',
'processing',
session.errorMessage,
);
void this.prepareCreateContainerQrcode(session, container);
return this.toResult(session);
}
/**
* 启动Refresh。
* @param accountId - 账号 ID定位本次读取、更新、删除或关联的账号。
*/
async startRefresh(accountId: string) {
const activeSession = this.findActiveRefreshSession(accountId);
if (activeSession) {
if (
!(await this.invalidatePasswordlessRefreshSessionWhenPasswordExists(
accountId,
activeSession,
))
) {
if (activeSession.qrcode) return this.refreshQrcode(activeSession.id);
return this.toResult(activeSession);
}
}
const runningTask = this.refreshStartTasks[accountId];
if (runningTask) return runningTask;
const task = this.createRefreshScan(accountId);
this.refreshStartTasks[accountId] = task;
try {
return await task;
} finally {
if (this.refreshStartTasks[accountId] === task) {
delete this.refreshStartTasks[accountId];
}
}
}
/**
* 启动账号更新登录会话。
* @param accountId - 账号 ID定位账号、主容器和可选登录密码。
* @returns 创建后的扫码/更新登录会话快照。
*/
private async createRefreshScan(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,
loginPassword,
);
const scanOptions: {
accountId: string;
expectedSelfId: string;
forceRelogin: true;
hasExistingPrimaryBinding?: boolean;
loginPasswordAvailable?: boolean;
loginPassword?: string;
mode: 'refresh';
sourceContainerOnline?: boolean;
} = {
accountId: account.id,
expectedSelfId: account.selfId,
forceRelogin: true,
hasExistingPrimaryBinding: container.hasExistingPrimaryBinding,
loginPasswordAvailable: !!this.toolsService.toSecretText(loginPassword),
loginPassword,
mode: 'refresh',
};
if (container.sourceContainerOnline !== undefined) {
scanOptions.sourceContainerOnline = container.sourceContainerOnline;
}
return this.startScan(scanOptions, container);
}
/**
* 查找可复用的账号更新登录会话。
* @param accountId - 账号 ID限定同一账号的 pending refresh 会话。
* @returns 当前仍有效的 pending refresh 会话;没有时返回 undefined。
*/
private findActiveRefreshSession(accountId: string) {
const now = Date.now();
let activeSession: QqbotLoginScanSession | undefined;
this.loginSessionStore.forEach((session) => {
if (activeSession) return;
if (
session.accountId === accountId &&
session.mode === 'refresh' &&
session.status === 'pending' &&
now <= session.expiresAt
) {
activeSession = session;
}
});
return activeSession;
}
/**
* 当账号后续维护了 QQ 登录密码时,废弃旧的无密码更新登录会话。
* @param accountId - 正在更新登录态的账号 ID用于重新读取账号表里的最新登录密码密文。
* @param session - 可能早于密码维护动作创建的 pending 更新登录会话。
* @returns 旧会话已退役且调用方应重新创建更新登录会话时返回 true。
*/
private async invalidatePasswordlessRefreshSessionWhenPasswordExists(
accountId: string,
session: QqbotLoginScanSession,
) {
if (this.hasRefreshPasswordContext(session)) return false;
const accountService = this.accountService as Partial<
Pick<
QqbotAccountService,
'findByIdWithNapcatLoginSecret' | 'getNapcatLoginPassword'
>
>;
if (
!accountService.findByIdWithNapcatLoginSecret ||
!accountService.getNapcatLoginPassword
) {
return false;
}
const account =
await accountService.findByIdWithNapcatLoginSecret(accountId);
if (!account) return false;
const loginPassword = accountService.getNapcatLoginPassword(account);
if (!this.toolsService.toSecretText(loginPassword)) return false;
await this.retireRefreshSessionForPasswordReload(session);
return true;
}
/**
* 判断更新登录会话是否已经携带或进入过密码登录相关上下文。
* @param session - 待复用的 pending 更新登录会话。
* @returns 复用该会话不会忽略账号已维护登录密码时返回 true。
*/
private hasRefreshPasswordContext(session: QqbotLoginScanSession) {
return !!(
session.loginPasswordAvailable ||
session.passwordMd5 ||
session.captchaUrl ||
session.deviceVerifyUrl ||
session.newDeviceQrcode ||
session.newDeviceStatus
);
}
/**
* 仅退役过期的更新登录会话记录,不移除已绑定的 NapCat 容器。
* @param session - 不应继续提供旧二维码或旧 pending 状态的无密码更新登录会话。
*/
private async retireRefreshSessionForPasswordReload(
session: QqbotLoginScanSession,
) {
session.errorMessage = '账号登录密码已更新,重新创建更新登录会话';
session.preparingRelogin = false;
session.qrcode = undefined;
session.status = 'expired';
this.persistLoginSession(session);
this.publishScanResultEvent(
session,
'session-recreated',
'processing',
session.errorMessage,
);
await this.loginSessionStore.flushSessionWrites(session.id);
this.loginSessionStore.delete(session.id);
this.cleanupSessionEvents(session.id);
}
/**
* 执行 NapCat 登录运行态流程。
* @param sessionId - NapCat ID定位本次读取、更新、删除或关联的NapCat。
*/
async refreshQrcode(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.refreshQrcode(sessionId);
}
return this.keepSessionPending(
session,
session.errorMessage || 'NapCat 正在尝试快速登录,请稍后',
);
}
if (session.preparingContainer) {
if (Date.now() > session.expiresAt) {
return this.expireSession(session);
}
if (this.recoverStaleCreateContainerPreparation(session)) {
return this.toResult(session);
}
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;
return this.keepSessionPending(
session,
'NapCat 通信超时,请稍后重试或确认运行容器仍在线',
true,
);
}
if (!loginStatus.isLogin) {
await this.syncSessionQqLoginStatus(session, loginStatus);
}
if (
loginStatus.isOffline &&
this.shouldRestartNapcatWorkerForOnlineRefresh(session)
) {
loginStatus = await this.restartNapcatWorkerForOnlineRefresh(
session,
container,
loginStatus.loginError || 'NapCat 账号已离线,正在重启登录服务',
);
} else if (loginStatus.isOffline && session.mode !== 'refresh') {
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,
);
}
}
/**
* 执行 NapCat 登录运行态流程。
* @param sessionId - NapCat ID定位本次读取、更新、删除或关联的NapCat。
*/
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) {
const recovered = await this.recoverExpiredQrcodeSession(session);
if (recovered) return recovered;
return this.expireSession(session);
}
if (session.preparingContainer) {
if (this.recoverStaleCreateContainerPreparation(session)) {
return this.toResult(session);
}
return this.keepSessionPending(
session,
session.errorMessage || 'NapCat 正在创建登录容器,请稍后',
);
}
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) {
await this.syncSessionQqLoginStatus(session, status);
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,
);
}
if (
status.isOffline &&
this.shouldRestartNapcatWorkerForOnlineRefresh(session)
) {
status = await this.restartNapcatWorkerForOnlineRefresh(
session,
container,
status.loginError || 'NapCat 账号已离线,正在重启登录服务',
);
if (status.isLogin) {
return this.completeLogin(session, container);
}
await this.syncSessionQqLoginStatus(session, status);
}
if (this.shouldRefreshNearlyExpiredQrcode(status)) {
return this.refreshNearlyExpiredQrcode(session, container, status);
}
if (this.shouldAutoRefreshPendingQrcode(session, status)) {
return this.refreshPendingQrcodeFromStatus(session, container, status);
}
session.errorMessage = status.loginError || undefined;
if (
status.qrcodeurl &&
(session.mode !== 'refresh' || !session.qrcode) &&
!this.toolsService.isNapcatExpiredQrcodeStatus(status)
) {
const qrcodeChanged = session.qrcode !== status.qrcodeurl;
session.qrcode = status.qrcodeurl;
session.errorMessage = undefined;
if (qrcodeChanged) {
this.publishScanResultEvent(
session,
'qrcode-ready',
'success',
'登录二维码已生成',
);
}
} else if (status.isOffline && session.mode !== 'refresh') {
session.qrcode = undefined;
} else if (
session.mode === 'refresh' &&
session.qrcode &&
!this.toolsService.isNapcatExpiredQrcodeStatus(status)
) {
session.errorMessage = undefined;
} else if (!this.toolsService.isNapcatExpiredQrcodeStatus(status)) {
await this.tryUpdatePendingQrcode(container, session, status, {
clearStaleQrcode: session.mode === 'refresh',
requireFresh: session.mode === 'refresh',
});
}
this.persistLoginSession(session);
return this.toResult(session);
}
return this.completeLogin(session, container);
}
/**
* 执行 NapCat 登录运行态流程。
* @param sessionId - NapCat ID定位本次读取、更新、删除或关联的NapCat。
* @param input - input 输入;使用 `ticket`、`randstr`、`sid` 字段生成结果。
*/
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,
'验证码登录成功',
);
}
/**
* 执行 NapCat 登录运行态流程。
* @param sessionId - NapCat ID定位本次读取、更新、删除或关联的NapCat。
*/
events(sessionId: string) {
if (!this.loginSessionStore.getCached(sessionId)) {
void this.loginSessionStore.get(sessionId);
}
return new Observable<{ data: QqbotLoginScanEvent }>((subscriber) => {
/**
* 监听 NapCat 登录运行态事件。
* @param event - event 输入;限定 NapCat查询范围。
*/
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];
}
};
});
}
/**
* 判断 NapCat 登录运行态条件。
* @param sessionId - NapCat ID定位本次读取、更新、删除或关联的NapCat。
*/
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.loginSessionStore.flushSessionWrites(sessionId);
await this.cleanupSessionContainer(session);
this.cleanupSessionEvents(sessionId);
}
return true;
}
/**
* 启动Scan。
* @param options - NapCat列表使用 `forceRelogin`、`loginPassword`、`hasExistingPrimaryBinding` 字段生成结果。
* @param container - NapCat WebUI 运行态refresh 模式只通过 WebUI 登录接口推进状态。
* @returns 异步完成后的 NapCat 登录运行态结果。
*/
private async startScan(
options: {
accountId?: string;
expectedSelfId?: string;
forceRelogin?: boolean;
hasExistingPrimaryBinding?: boolean;
loginPasswordAvailable?: boolean;
loginPassword?: string;
mode: QqbotLoginScanMode;
sourceContainerOnline?: boolean;
},
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.catch(() => undefined);
return this.toResult(session);
}
try {
const loginStatus = await this.getLoginStatus(container, true);
if (loginStatus.isOffline) {
if (options.mode === 'refresh') {
const qrcode = await this.refreshOrGetQrcode(container, false, {
fallbackStatus: loginStatus,
requireFresh: true,
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);
}
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;
}
}
/**
* Starts a reserved create-login container and publishes QR progress back to the existing session.
* @param session - Pending create-login session returned to Admin before the remote container operation starts.
* @param container - Reserved container runtime whose provisional device identity must be used for the first startup.
*/
private async prepareCreateContainerQrcode(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
) {
try {
await this.containerService.startCreateContainer(container);
if (!this.loginSessionStore.getCached(session.id)) {
await this.cleanupRuntimeContainer(container, {
includeDeletedCreateContainer: true,
});
return;
}
session.preparingContainer = false;
session.errorMessage = undefined;
this.publishScanResultEvent(
session,
'container-ready',
'processing',
'NapCat 登录容器已启动',
);
await this.prepareCreateQrcodeAfterContainerReady(session, container);
} catch (err) {
session.preparingContainer = false;
const cleanupError = await this.cleanupRuntimeContainer(container, {
includeDeletedCreateContainer: true,
});
const message = this.toolsService.getErrorMessage(err);
await this.failSession(
session,
cleanupError
? `${message};清理未绑定容器失败:${cleanupError}`
: message,
);
}
}
/**
* Reads login state from a running create-login container and updates the original session with a QR or success result.
* @param session - Pending create-login session whose id is already known by Admin and SSE listeners.
* @param container - Running container that should now answer NapCat WebUI login endpoints.
*/
private async prepareCreateQrcodeAfterContainerReady(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
) {
const loginStatus = await this.getLoginStatus(container, true);
if (loginStatus.isOffline) {
await this.restartNapcatForLogin(container, { waitForReady: false });
session.lastRestartedAt = Date.now();
session.errorMessage =
loginStatus.loginError || 'NapCat 账号已离线,已重新生成二维码';
this.publishScanResultEvent(
session,
'container-restarted',
'processing',
session.errorMessage,
);
return;
}
if (loginStatus.isLogin) {
await this.completeLogin(session, container);
return;
}
session.qrcode = await this.refreshOrGetQrcode(container, true, {
fallbackStatus: loginStatus,
requireFresh: this.toolsService.isNapcatExpiredQrcodeStatus(loginStatus),
staleQrcode: loginStatus.qrcodeurl,
});
session.errorMessage = undefined;
this.persistLoginSession(session);
this.publishScanResultEvent(
session,
'qrcode-ready',
'success',
'登录二维码已生成',
);
this.publishScanResultEvent(
session,
'waiting-scan',
'processing',
'等待扫码确认',
);
}
/**
* 执行 NapCat 登录运行态流程。
* @param session - session 输入;使用 `expectedSelfId`、`accountId`、`containerId`、`captchaUrl` 字段生成结果。
* @param container - container 输入;驱动 `this.getLoginInfo()` 的 NapCat步骤。
* @param options - NapCat列表使用 `loginInfo`、`successMessage` 字段生成结果。
* @returns 异步完成后的 NapCat 登录运行态结果。
*/
private async completeLogin(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
options: { loginInfo?: NapcatLoginInfo; successMessage?: string } = {},
): Promise<QqbotLoginScanResult> {
const stalePendingResult = await this.resolveStalePendingSession(session);
if (stalePendingResult) return stalePendingResult;
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.keepLoginSelfIdPending(session);
}
session.loginSelfIdMissingSince = undefined;
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,
selfId,
);
session.accountId = accountId;
session.captchaUrl = undefined;
session.status = 'success';
session.errorMessage = undefined;
session.passwordMd5 = undefined;
session.preparingContainer = false;
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;
}
/**
* 创建 NapCat 登录运行态对象或配置。
* @param input - input 输入;使用 `accountId`、`container`、`expectedSelfId`、`mode` 字段生成结果。
* @returns 创建后的 NapCat 登录运行态对象或配置。
*/
private createSession(input: {
accountId?: string;
container: QqbotNapcatRuntime;
expectedSelfId?: string;
mode: QqbotLoginScanMode;
loginPasswordAvailable?: boolean;
preparingContainer?: boolean;
preparingRelogin?: boolean;
qrcode?: string;
runtimeRebuildCount?: number;
sourceContainerOnline?: boolean;
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(),
loginPasswordAvailable: input.loginPasswordAvailable,
mode: input.mode,
preparingContainer: input.preparingContainer,
preparingRelogin: input.preparingRelogin,
qrcode: input.qrcode,
runtimeRebuildCount:
input.runtimeRebuildCount ?? input.container.runtimeRebuildCount,
sourceContainerOnline:
input.sourceContainerOnline ?? input.container.sourceContainerOnline,
status: input.status,
webuiPort: input.container.webuiPort,
};
}
/**
* 执行 NapCat 登录运行态流程。
* @param session - session 输入;使用 `accountId`、`captchaUrl`、`containerId`、`containerName` 字段生成结果。
* @returns NapCat 登录运行态产出的 QqbotLoginScanResult。
*/
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,
};
}
/**
* Persists the QQ-login-only status observed during a scan session without altering container or OneBot state.
* @param session - Login session that carries the target QQ number for refresh-login flows.
* @param status - Latest NapCat WebUI CheckLoginStatus response used as the QQ login source of truth.
*/
private async syncSessionQqLoginStatus(
session: QqbotLoginScanSession,
status: NapcatLoginStatus,
) {
const selfId = this.toolsService.toTrimmedString(session.expectedSelfId);
if (!selfId) return;
const marker = (
this.accountService as unknown as {
markQqLoginStatus?: (
selfId: string,
qqLoginStatus: QqbotNapcatRuntimeLoginStatus,
lastError?: null | string,
) => Promise<void>;
}
).markQqLoginStatus;
if (!marker) return;
const qqLoginStatus = this.toSessionQqLoginStatus(status);
const lastError = this.toSessionQqLoginError(status, qqLoginStatus);
await marker.call(this.accountService, selfId, qqLoginStatus, lastError);
}
/**
* Reconciles an API-side QR TTL timeout with NapCat's current login state before marking the scan as expired.
* @param session - Pending scan session whose local `expiresAt` has elapsed; its current QR and container binding decide whether recovery is safe.
* @returns A pending or success result when NapCat still exposes a usable QR or has already logged in; otherwise undefined so the caller can expire the session.
*/
private async recoverExpiredQrcodeSession(
session: QqbotLoginScanSession,
): Promise<QqbotLoginScanResult | undefined> {
if (!session.qrcode || session.preparingContainer) return undefined;
if (typeof this.containerService.findRuntimeById !== 'function') {
return undefined;
}
const container = await this.getSessionContainer(session);
let status: NapcatLoginStatus;
try {
status = await this.getLoginStatus(container);
} catch (err) {
if (!this.toolsService.isNapcatTemporaryError(err)) throw err;
return this.keepSessionPending(
session,
'NapCat 正在确认二维码状态,请稍后',
true,
);
}
if (status.isLogin) {
this.renewSessionExpiry(session);
return this.completeLogin(session, container);
}
await this.syncSessionQqLoginStatus(session, status);
if (this.toolsService.isNapcatExpiredQrcodeStatus(status)) {
session.errorMessage = status.loginError || session.errorMessage;
this.persistLoginSession(session);
return undefined;
}
if (this.shouldRefreshNearlyExpiredQrcode(status)) {
return this.refreshNearlyExpiredQrcode(session, container, status);
}
if (status.qrcodeurl) {
session.qrcode = status.qrcodeurl;
session.errorMessage = undefined;
return this.keepSessionPending(session, '等待扫码确认');
}
return undefined;
}
/**
* Converts a NapCat WebUI login probe into the account-table QQ login status vocabulary.
* @param status - Raw WebUI CheckLoginStatus payload returned by the current container.
* @returns Persistable QQ-login-only state.
*/
private toSessionQqLoginStatus(
status: NapcatLoginStatus,
): QqbotNapcatRuntimeLoginStatus {
const message = this.toolsService.toTrimmedString(status.loginError);
if (status.isLogin) return 'online';
if (
this.toolsService.isNapcatExpiredQrcodeStatus(status) ||
message.includes('二维码已过期')
) {
return 'qrcode_expired';
}
if (status.qrcodeurl) return 'qrcode_pending';
if (
status.isOffline ||
this.toolsService.isNapcatOfflineLoginMessage(message)
) {
return 'offline';
}
return 'unknown';
}
/**
* Selects the account error text that should accompany a QQ-login-only status update.
* @param status - Raw WebUI status containing the optional NapCat login error text.
* @param qqLoginStatus - Normalized state that decides whether stale error text should be cleared.
* @returns Null to clear stale errors, a reason string, or undefined when the account row should be left unchanged.
*/
private toSessionQqLoginError(
status: NapcatLoginStatus,
qqLoginStatus: QqbotNapcatRuntimeLoginStatus,
) {
const message = this.toolsService.toTrimmedString(status.loginError);
if (qqLoginStatus === 'online' || qqLoginStatus === 'qrcode_pending') {
return message || null;
}
if (qqLoginStatus === 'offline') {
return message || 'NapCat 账号已离线,请重新扫码登录';
}
if (qqLoginStatus === 'qrcode_expired') {
return message || 'NapCat 登录二维码已过期';
}
return message || undefined;
}
/**
* 启动New Device Verification。
* @param session - session 输入;使用 `status`、`captchaUrl`、`qrcode`、`deviceVerifyUrl` 字段生成结果。
* @param container - container 输入;驱动 `this.postNapcat()`、`this.refreshNewDeviceQrcode()` 的 NapCat步骤。
* @param captchaResult - captchaResult 输入;使用 `jumpUrl`、`newDevicePullQrCodeSig` 字段生成结果。
*/
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.newDeviceBytesToken = undefined;
session.newDevicePullQrCodeSig = this.pickNewDevicePullQrCodeSig(
captchaResult.newDevicePullQrCodeSig,
);
session.newDeviceStatus = 'qr-pending';
session.errorMessage = '需要新设备验证二维码';
this.persistLoginSession(session);
this.publishScanResultEvent(
session,
'new-device-required',
'processing',
'需要新设备验证二维码',
);
try {
const client = new NapcatLoginApiClient({
/**
* 发送 NapCat回调消息。
* @param path - 路由或文件路径;驱动 `this.postNapcat()` 的 NapCat步骤。
* @param body - 请求体 DTO承载 NapCat新增、更新、导入或执行字段。
*/
post: (path, body) => this.postNapcat(container, path, body),
});
return this.refreshNewDeviceQrcode(session, container, client);
} catch (err) {
return this.keepSessionPending(
session,
this.toolsService.getErrorMessage(err) || '新设备二维码生成失败',
true,
);
}
}
/**
* 轮询New Device Verification。
* @param session - session 输入;使用 `newDeviceBytesToken`、`expectedSelfId`、`newDevicePullQrCodeSig`、`newDeviceQrcode` 字段生成结果。
* @param container - container 输入;驱动 `this.postNapcat()`、`this.refreshNewDeviceQrcode()`、`this.failNewDeviceVerification()`、`this.startNewDeviceVerification()` 的 NapCat步骤。
*/
private async pollNewDeviceVerification(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
) {
const client = new NapcatLoginApiClient({
/**
* 发送 NapCat回调消息。
* @param path - 路由或文件路径;驱动 `this.postNapcat()` 的 NapCat步骤。
* @param body - 请求体 DTO承载 NapCat新增、更新、导入或执行字段。
*/
post: (path, body) => this.postNapcat(container, path, body),
});
if (!session.newDeviceBytesToken) {
return this.refreshNewDeviceQrcode(session, container, client);
}
const uin = this.toolsService.toTrimmedString(session.expectedSelfId);
if (!uin) {
return this.failNewDeviceVerification(
session,
container,
'新设备验证账号上下文缺失,请重新更新登录',
);
}
const poll = await client.pollNewDeviceQR({
bytesToken: session.newDeviceBytesToken,
uin,
});
if (poll.status === 'scanned') {
return this.keepNewDevicePending(
session,
'scanned',
poll.message || '新设备二维码已扫码',
'new-device-scanned',
);
}
if (poll.status === 'confirming') {
const confirmToken = this.pickNewDevicePullQrCodeSig(poll.confirmToken);
if (confirmToken !== undefined) {
session.newDevicePullQrCodeSig = confirmToken;
}
this.keepNewDevicePending(
session,
'confirming',
poll.message || '新设备确认中',
'new-device-confirming',
);
const passwordMd5 = await this.resolveNewDevicePasswordMd5(session);
if (!passwordMd5 || !this.hasNewDevicePullQrCodeSig(session)) {
return this.failNewDeviceVerification(
session,
container,
'新设备验证登录上下文缺失,请重新更新登录',
);
}
const loginResult = await client.newDeviceLogin({
newDevicePullQrCodeSig: session.newDevicePullQrCodeSig,
passwordMd5,
uin,
});
if (loginResult.needNewDevice && loginResult.jumpUrl) {
return this.startNewDeviceVerification(session, container, {
jumpUrl: loginResult.jumpUrl,
needNewDevice: true,
newDevicePullQrCodeSig: loginResult.pullQrCodeSig,
});
}
if (!loginResult.success) {
return this.failNewDeviceVerification(
session,
container,
loginResult.message || '新设备验证失败',
);
}
session.newDeviceBytesToken = undefined;
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',
);
}
/**
* 解析New Device Password Md5。
* @param session - session 输入;使用 `passwordMd5`、`accountId` 字段生成结果。
*/
private async resolveNewDevicePasswordMd5(session: QqbotLoginScanSession) {
const existing = this.toolsService.toTrimmedString(session.passwordMd5);
if (existing) return existing;
if (!session.accountId) return '';
const account = await this.accountService.findByIdWithNapcatLoginSecret(
session.accountId,
);
const password = this.accountService.getNapcatLoginPassword(account);
if (!password) return '';
session.passwordMd5 = createHash('md5')
.update(password, 'utf8')
.digest('hex');
this.persistLoginSession(session);
return session.passwordMd5;
}
/**
* 执行 NapCat 登录运行态流程。
* @param session - session 输入;驱动 `this.getNewDeviceQrRequest()`、`this.failNewDeviceVerification()`、`this.applyNewDeviceQrcode()`、`this.persistLoginSession()` 的 NapCat步骤。
* @param container - container 输入;驱动 `this.failNewDeviceVerification()` 的 NapCat步骤。
* @param client - client 输入;执行 `client.getNewDeviceQRCode()` 对应的 NapCat步骤。
*/
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',
);
}
}
/**
* 执行 NapCat 登录运行态流程。
* @param session - session 输入;使用 `newDeviceQrcode`、`newDeviceBytesToken`、`deviceVerifyUrl`、`newDevicePullQrCodeSig` 字段生成结果。
* @param qrcode - qrcode 输入;使用 `qrcodeUrl`、`bytesToken`、`deviceVerifyUrl`、`pullQrCodeSig` 字段生成结果。
*/
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 = '新设备二维码待扫码';
}
/**
* 查询 NapCat 登录运行态数据。
* @param session - session 输入;使用 `expectedSelfId`、`deviceVerifyUrl` 字段生成结果。
* @returns NapCat 登录运行态查询结果。
*/
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 };
}
/**
* 执行 NapCat 登录运行态流程。
* @param value - 待转换值;驱动 `toolsService.toTrimmedString()` 的 NapCat步骤。
*/
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;
}
/**
* 判断 NapCat 登录运行态条件。
* @param session - session 输入;使用 `newDevicePullQrCodeSig` 字段计算判断结果。
*/
private hasNewDevicePullQrCodeSig(session: QqbotLoginScanSession) {
return (
this.pickNewDevicePullQrCodeSig(session.newDevicePullQrCodeSig) !==
undefined
);
}
/**
* 执行 NapCat 登录运行态流程。
* @param session - session 输入;使用 `newDeviceStatus`、`errorMessage`、`status`、`captchaUrl` 字段生成结果。
* @param status - NapCat列表影响 keepNewDevicePending 的返回值。
* @param message - message 输入;驱动 `this.publishScanResultEvent()` 的 NapCat步骤。
* @param step - step 输入;驱动 `this.publishScanResultEvent()` 的 NapCat步骤。
*/
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;
this.renewSessionExpiry(session);
this.persistLoginSession(session);
if (shouldPublish) {
this.publishScanResultEvent(session, step, 'processing', message);
}
return this.toResult(session);
}
/**
* 执行 NapCat 登录运行态流程。
* @param session - session 输入;使用 `newDeviceQrcode`、`newDeviceBytesToken`、`newDeviceStatus`、`errorMessage` 字段生成结果。
* @param container - container 输入;驱动 `this.failCaptchaLogin()` 的 NapCat步骤。
* @param message - message 输入;驱动 `this.failCaptchaLogin()` 的 NapCat步骤。
*/
private async failNewDeviceVerification(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
message: string,
) {
session.newDeviceQrcode = undefined;
session.newDeviceBytesToken = undefined;
session.newDeviceStatus = 'failed';
session.errorMessage = message;
this.persistLoginSession(session);
return this.failCaptchaLogin(session, container, message);
}
/**
* 执行 NapCat 登录运行态流程。
* @param session - session 输入;使用 `expectedSelfId` 字段生成结果。
* @param container - container 输入;驱动 `this.waitForPasswordLoginStatus()`、`this.failCaptchaLogin()`、`this.getLoginInfo()` 的 NapCat步骤。
* @param successMessage - successMessage 输入;影响 completePasswordLoginAfterChallenge 的返回值。
*/
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) {
return this.failSession(
session,
`当前密码登录账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`,
);
}
return this.completeLogin(session, container, {
loginInfo,
successMessage,
});
}
/**
* 投递 NapCat 登录运行态消息或任务。
* @param session - session 输入;使用 `id` 字段生成结果。
* @param input - input 输入;影响 publishScanEvent 的返回值。
*/
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),
);
}
/**
* 投递 NapCat 登录运行态消息或任务。
* @param session - session 输入;使用 `status`、`expiresAt` 字段生成结果。
* @param step - step 输入;驱动 `this.toResult()` 的 NapCat步骤。
* @param status - NapCat列表驱动 `this.toResult()` 的 NapCat步骤。
* @param message - message 输入;影响 publishScanResultEvent 的返回值。
*/
private publishScanResultEvent(
session: QqbotLoginScanSession,
step: string,
status: QqbotLoginScanEvent['status'],
message: string,
) {
if (session.status === 'pending') {
this.renewSessionExpiry(session);
this.persistLoginSession(session);
}
this.publishScanEvent(session, {
message,
result: this.toResult(session),
status,
step,
});
if (this.shouldMonitorScanStatus(session)) {
this.startScanStatusMonitor(session);
}
}
/**
* 执行 NapCat 登录运行态流程。
* @param sessionId - NapCat ID定位本次读取、更新、删除或关联的NapCat。
* @param listener - listener 输入;影响 emitCurrentSessionSnapshot 的返回值。
*/
private async emitCurrentSessionSnapshot(
sessionId: string,
listener: (event: QqbotLoginScanEvent) => void,
) {
const session = await this.loginSessionStore.get(sessionId);
if (!session) return;
listener(this.toSessionSnapshotEvent(session));
}
/**
* 执行 NapCat 登录运行态流程。
* @param session - session 输入;驱动 `Date.now()` 的 NapCat步骤。
* @returns NapCat 登录运行态产出的 QqbotLoginScanEvent。
*/
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),
};
}
/**
* 查询 NapCat 登录运行态数据。
* @param session - session 输入;使用 `status` 字段生成结果。
* @returns NapCat 登录运行态查询结果。
*/
private getSessionSnapshotStatus(
session: QqbotLoginScanSession,
): QqbotLoginScanEvent['status'] {
if (session.status === 'success') return 'success';
if (session.status === 'error' || session.status === 'expired') {
return 'error';
}
return 'processing';
}
/**
* 查询 NapCat 登录运行态数据。
* @param session - session 输入;使用 `status`、`newDeviceStatus`、`captchaUrl`、`qrcode` 字段生成结果。
*/
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.preparingContainer) return 'container-starting';
if (session.passwordMd5) return 'password-login';
return 'scan-status';
}
/**
* 查询 NapCat 登录运行态数据。
* @param session - session 输入;使用 `errorMessage`、`status`、`newDeviceStatus`、`captchaUrl` 字段生成结果。
*/
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 '登录二维码已生成';
if (session.preparingContainer) return 'NapCat 正在创建登录容器,请稍后';
return '登录处理中';
}
/**
* 执行 NapCat 登录运行态流程。
* @param session - session 输入;使用 `preparingRelogin`、`errorMessage` 字段生成结果。
*/
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;
}
/**
* Restarts a lost create-login background task after a persisted preparing session becomes stale.
* @param session - Pending create-login session restored from persistence or left behind by a lost async task.
* @returns True when a recovery task was launched and the session snapshot was updated.
*/
private recoverStaleCreateContainerPreparation(
session: QqbotLoginScanSession,
) {
if (!this.isStaleCreateContainerPreparation(session)) return false;
session.lastRestartedAt = Date.now();
session.errorMessage = 'NapCat 创建任务已恢复,继续创建登录容器';
this.publishScanResultEvent(
session,
'container-start-recovered',
'processing',
session.errorMessage,
);
void this.resumeCreateContainerPreparation(session).catch(() => undefined);
return true;
}
/**
* Reattaches a stale create-login session to its reserved container and continues the QR preparation flow.
* @param session - Pending create-login session whose original in-memory background promise may have been lost.
*/
private async resumeCreateContainerPreparation(
session: QqbotLoginScanSession,
) {
const container = await this.getSessionContainer(session);
await this.prepareCreateContainerQrcode(session, container);
}
/**
* 判断 NapCat 登录运行态条件。
* @param session - session 输入;使用 `preparingRelogin`、`lastRestartedAt` 字段计算判断结果。
*/
private isStaleReloginPreparation(session: QqbotLoginScanSession) {
if (!session.preparingRelogin || !session.lastRestartedAt) return false;
return (
Date.now() - session.lastRestartedAt > this.getReloginPreparationStaleMs()
);
}
/**
* Checks whether create-login container preparation is old enough to be recovered by a new background task.
* @param session - Create-login session; `lastRestartedAt` marks the last background task launch and `createdAt` is the fallback seed.
* @returns True when the session is still pending but the previous create task should be considered lost.
*/
private isStaleCreateContainerPreparation(session: QqbotLoginScanSession) {
if (!session.preparingContainer) return false;
const startedAt = session.lastRestartedAt || session.createdAt;
if (!startedAt) return false;
return Date.now() - startedAt > this.getCreateContainerPreparationStaleMs();
}
/**
* 查询 NapCat 登录运行态数据。
*/
private getReloginPreparationStaleMs() {
return this.getPositiveConfigNumber(
'QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS',
this.getPasswordLoginWaitMs() +
Math.max(this.getRestartDelayMs(), this.getTimeout()) +
this.getLoginPollIntervalMs() * 2,
);
}
/**
* Reads the stale window for create-login container startup recovery.
* @returns Milliseconds to wait before assuming the original remote-start background task was lost.
*/
private getCreateContainerPreparationStaleMs() {
return this.getPositiveConfigNumber(
'QQBOT_NAPCAT_CREATE_PREPARING_STALE_MS',
Math.max(
this.getSessionTtlMs(),
this.getTimeout() * 3 + this.getLoginPollIntervalMs() * 2,
),
);
}
/**
* 清理 NapCat 登录运行态状态。
* @param sessionId - NapCat ID定位本次读取、更新、删除或关联的NapCat。
*/
private cleanupSessionEvents(sessionId: string) {
this.stopScanStatusMonitor(sessionId);
delete this.sessionEventLogCache[sessionId];
delete this.sessionEventListenerCache[sessionId];
}
/**
* Determines whether a pending QR session needs server-side polling so SSE can progress without browser polling.
* @param session - Login session whose QR, preparation flags, and terminal status decide monitor ownership.
* @returns True when the backend should keep reconciling NapCat status for this session.
*/
private shouldMonitorScanStatus(session: QqbotLoginScanSession) {
return (
session.status === 'pending' &&
!!session.qrcode &&
!session.preparingContainer &&
!session.preparingRelogin
);
}
/**
* Starts one bounded status monitor for a QR session and lets the timer avoid holding the Node process open.
* @param session - Pending QR session whose id is used to drive later status reconciliation.
*/
private startScanStatusMonitor(session: QqbotLoginScanSession) {
this.ensureScanStatusMonitorDeadline(session);
if (this.hasScanStatusMonitorDeadlinePassed(session)) {
void this.expireSession(session);
return;
}
if (this.scanStatusMonitorTimers[session.id]) return;
const timer = setTimeout(() => {
this.scanStatusMonitorTimers[session.id] = undefined;
void this.runScanStatusMonitor(session.id);
}, this.getLoginPollIntervalMs());
timer.unref?.();
this.scanStatusMonitorTimers[session.id] = timer;
}
/**
* Stops any server-side status monitor for a login session.
* @param sessionId - Session key whose timer should no longer reconcile NapCat state.
*/
private stopScanStatusMonitor(sessionId: string) {
const timer = this.scanStatusMonitorTimers[sessionId];
if (timer) clearTimeout(timer);
delete this.scanStatusMonitorTimers[sessionId];
delete this.scanStatusMonitorDeadlines[sessionId];
}
/**
* Stops every server-side QR status monitor before the backing session store is cleared.
*/
private stopAllScanStatusMonitors() {
Object.keys(this.scanStatusMonitorTimers).forEach((sessionId) => {
this.stopScanStatusMonitor(sessionId);
});
}
/**
* Captures the active QR code deadline so monitor polling cannot extend the same QR session forever.
* @param session - Pending QR session whose current QR URL and expiry form the monitor deadline snapshot.
*/
private ensureScanStatusMonitorDeadline(session: QqbotLoginScanSession) {
if (!session.qrcode) return;
const current = this.scanStatusMonitorDeadlines[session.id];
if (current?.qrcode === session.qrcode) return;
this.scanStatusMonitorDeadlines[session.id] = {
expiresAt: session.expiresAt,
qrcode: session.qrcode,
};
}
/**
* Checks the monitor-owned QR deadline instead of the session expiry that status polling may renew.
* @param session - Pending QR session whose monitor snapshot decides terminal expiry.
* @returns True when the monitored QR should be expired by the backend monitor.
*/
private hasScanStatusMonitorDeadlinePassed(
session: QqbotLoginScanSession,
) {
const deadline = this.scanStatusMonitorDeadlines[session.id];
if (deadline && session.qrcode && deadline.qrcode !== session.qrcode) {
this.ensureScanStatusMonitorDeadline(session);
return false;
}
return !!deadline && Date.now() > deadline.expiresAt;
}
/**
* Polls the same status path used by Admin so SSE can emit success/expired events after QR generation.
* @param sessionId - Pending QR session id to reload from the session store before each poll.
*/
private async runScanStatusMonitor(sessionId: string) {
try {
const session = await this.loginSessionStore.get(sessionId);
if (!session || !this.shouldMonitorScanStatus(session)) return;
if (this.hasScanStatusMonitorDeadlinePassed(session)) {
await this.expireSession(session);
return;
}
const result = await this.status(sessionId);
if (result.status !== 'pending') return;
const current =
this.loginSessionStore.getCached(sessionId) ||
(await this.loginSessionStore.get(sessionId));
if (current && this.shouldMonitorScanStatus(current)) {
if (this.hasScanStatusMonitorDeadlinePassed(current)) {
await this.expireSession(current);
return;
}
this.startScanStatusMonitor(current);
}
} catch {
const current = this.loginSessionStore.getCached(sessionId);
if (current && this.shouldMonitorScanStatus(current)) {
if (this.hasScanStatusMonitorDeadlinePassed(current)) {
await this.expireSession(current);
return;
}
this.startScanStatusMonitor(current);
}
}
}
/**
* 执行 NapCat 登录运行态流程。
* @param session - session 输入;使用 `status`、`errorMessage`、`expiresAt`、`qrcode` 字段生成结果。
* @param errorMessage - errorMessage 输入;影响 keepSessionPending 的返回值。
* @param clearQrcode - clearQrcode 输入;决定 NapCat条件分支。
*/
private keepSessionPending(
session: QqbotLoginScanSession,
errorMessage: string,
clearQrcode = false,
) {
session.status = 'pending';
session.errorMessage = errorMessage;
this.renewSessionExpiry(session);
if (clearQrcode) session.qrcode = undefined;
this.persistLoginSession(session);
return this.toResult(session);
}
/**
* 执行 NapCat 登录运行态流程。
* @param session - session 输入;使用 `captchaUrl`、`errorMessage`、`status`、`preparingRelogin` 字段生成结果。
* @param captchaUrl - 访问地址;影响 keepPasswordCaptchaPending 的返回值。
* @param reason - reason 输入;驱动 `toolsService.isNapcatCaptchaRequiredMessage()` 的 NapCat步骤。
*/
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.preparingRelogin = false;
session.qrcode = undefined;
session.errorMessage = message;
this.renewSessionExpiry(session);
this.persistLoginSession(session);
if (shouldPublish) {
this.publishScanResultEvent(
session,
'password-login-captcha',
'processing',
`${message},请完成验证码验证`,
);
}
return this.toResult(session);
}
/**
* 执行 NapCat 登录运行态流程。
* @param session - session 输入;使用 `errorMessage`、`status`、`captchaUrl`、`preparingRelogin` 字段生成结果。
* @param reason - reason 输入;驱动 `toolsService.toTrimmedString()` 的 NapCat步骤。
*/
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.preparingRelogin = false;
session.qrcode = undefined;
session.errorMessage = message;
this.renewSessionExpiry(session);
this.persistLoginSession(session);
if (shouldPublish) {
this.publishScanResultEvent(
session,
'password-login-captcha',
'processing',
message,
);
}
return this.toResult(session);
}
/**
* 执行 NapCat 登录运行态流程。
* @param session - session 输入;使用 `expectedSelfId`、`status`、`captchaUrl`、`errorMessage` 字段生成结果。
* @param container - container 输入;驱动 `this.cleanupPasswordLoginContext()` 的 NapCat步骤。
* @param errorMessage - errorMessage 输入;影响 failCaptchaLogin 的返回值。
*/
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.preparingContainer = false;
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);
}
/**
* 清理 NapCat 登录运行态状态。
* @param session - session 输入;使用 `passwordMd5`、`captchaUrl`、`status`、`expectedSelfId` 字段生成结果。
* @param container - container 输入;影响 cleanupPasswordLoginContext 的返回值。
* @param selfId - 账号 ID定位本次读取、更新、删除或关联的账号。
* @param cleanupFailureMessage - cleanupFailureMessage 输入;影响 cleanupPasswordLoginContext 的返回值。
*/
private async cleanupPasswordLoginContext(
session: QqbotLoginScanSession,
container?: QqbotNapcatRuntime,
selfId?: string,
cleanupFailureMessage?: string,
) {
if (!session.passwordMd5 && !session.captchaUrl) return true;
void container;
void selfId;
void cleanupFailureMessage;
session.captchaUrl = undefined;
session.passwordMd5 = undefined;
this.persistLoginSession(session);
return true;
}
/**
* 保存 NapCat 登录运行态数据。
* @param session - session 输入;驱动 `loginSessionStore.set()`、`this.persistLoginChallenge()` 的 NapCat步骤。
*/
private persistLoginSession(session: QqbotLoginScanSession) {
this.loginSessionStore.set(session);
this.persistLoginChallenge(session);
}
/**
* 保存 NapCat 登录运行态数据。
* @param session - session 输入;驱动 `loginSessionStore.recordCaptchaChallenge()`、`loginSessionStore.recordNewDeviceChallenge()` 的 NapCat步骤。
*/
private persistLoginChallenge(session: QqbotLoginScanSession) {
this.loginSessionStore.recordCaptchaChallenge(session);
this.loginSessionStore.recordNewDeviceChallenge(session);
}
/**
* 查询 NapCat 登录运行态数据。
* @param sessionId - NapCat ID定位本次读取、更新、删除或关联的NapCat。
*/
private async getSession(sessionId: string) {
const session = await this.loginSessionStore.get(sessionId);
if (!session) {
throwVbenError('扫码会话不存在或已过期');
}
return session;
}
/**
* 查询 NapCat 登录运行态数据。
* @param session - session 输入;使用 `containerId` 字段生成结果。
*/
private async getSessionContainer(session: QqbotLoginScanSession) {
return this.containerService.findRuntimeById(session.containerId);
}
/**
* 清理 NapCat 登录运行态状态。
*/
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)),
);
}
/**
* 执行 NapCat 登录运行态流程。
* @param session - session 输入;使用 `status`、`errorMessage`、`id` 字段生成结果。
*/
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.persistLoginSession(session);
await this.loginSessionStore.flushSessionWrites(session.id);
this.publishScanResultEvent(
session,
'session-expired',
'error',
session.errorMessage,
);
this.loginSessionStore.delete(session.id);
await this.closeSession(session);
return this.toResult(session);
}
/**
* Keeps a login-positive session alive while NapCat finishes exposing the logged-in QQ number.
* @param session - Scan session whose WebUI status is already logged in but whose `GetQQLoginInfo` payload lacks `uin`/`selfId`.
* @returns Pending result during the bounded wait window, or a terminal failure once the missing-self-id window is exhausted.
*/
private async keepLoginSelfIdPending(session: QqbotLoginScanSession) {
const now = Date.now();
session.loginSelfIdMissingSince ??= now;
if (now - session.loginSelfIdMissingSince > this.getLoginSelfIdWaitMs()) {
return this.failSession(session, 'NapCat 已登录但未返回 QQ 号');
}
const message = 'NapCat 已登录,正在读取 QQ 号';
const shouldPublish = session.errorMessage !== message;
session.status = 'pending';
session.captchaUrl = undefined;
session.errorMessage = message;
session.preparingContainer = false;
session.preparingRelogin = false;
session.qrcode = undefined;
this.renewSessionExpiry(session);
this.persistLoginSession(session);
if (shouldPublish) {
this.publishScanResultEvent(
session,
'login-self-id-wait',
'processing',
message,
);
}
return this.toResult(session);
}
/**
* Stops delayed background login work from mutating a pending session after it expired or was replaced.
* @param session - Pending scan session captured by an async relogin task; its cache identity and TTL decide whether writes are still valid.
* @returns A terminal or current result when the task must stop, otherwise undefined to allow the caller to continue.
*/
private async resolveStalePendingSession(session: QqbotLoginScanSession) {
if (session.status !== 'pending') return undefined;
if (Date.now() > session.expiresAt) return this.expireSession(session);
const current = this.loginSessionStore.getCached(session.id);
if (current === session) return undefined;
if (current) return this.toResult(current);
session.errorMessage = '登录会话已失效,请重新发起更新登录';
return this.toResult(session);
}
/**
* 执行 NapCat 登录运行态流程。
* @param session - session 输入;使用 `status`、`captchaUrl`、`errorMessage`、`passwordMd5` 字段生成结果。
* @param errorMessage - errorMessage 输入;驱动 `this.publishScanResultEvent()` 的 NapCat步骤。
*/
private async failSession(
session: QqbotLoginScanSession,
errorMessage: string,
) {
session.status = 'error';
session.captchaUrl = undefined;
session.errorMessage = errorMessage;
session.passwordMd5 = undefined;
session.preparingRelogin = false;
this.persistLoginSession(session);
await this.loginSessionStore.flushSessionWrites(session.id);
this.publishScanResultEvent(session, 'login-error', 'error', errorMessage);
this.loginSessionStore.delete(session.id);
await this.closeSession(session);
return this.toResult(session);
}
/**
* 执行 NapCat 登录运行态流程。
* @param session - session 输入;使用 `id` 字段生成结果。
*/
private async closeSession(session: QqbotLoginScanSession) {
await this.cleanupPasswordLoginContext(session);
await this.cleanupSessionContainer(session);
this.loginSessionStore.delete(session.id);
this.cleanupSessionEvents(session.id);
}
/**
* 清理 NapCat 登录运行态状态。
* @param session - session 输入;使用 `containerId`、`containerName`、`webuiPort`、`errorMessage` 字段生成结果。
*/
private async cleanupSessionContainer(session: QqbotLoginScanSession) {
const cleanupError = await this.cleanupRuntimeContainer(
{
baseUrl: '',
id: session.containerId,
name: session.containerName || '',
webuiPort: session.webuiPort,
},
{
includeDeletedCreateContainer: session.mode === 'create',
},
);
if (cleanupError) {
session.errorMessage = session.errorMessage
? `${session.errorMessage};清理未绑定容器失败:${cleanupError}`
: `清理未绑定容器失败:${cleanupError}`;
}
}
/**
* 执行 NapCat 登录运行态流程。
* @param container - container 输入;驱动 `this.getQrcode()` 的 NapCat步骤。
* @param session - session 输入;使用 `qrcode`、`errorMessage` 字段生成结果。
* @param status - NapCat列表使用 `qrcodeurl`、`loginError` 字段生成结果。
* @param options - refresh 会话要求二维码必须区别于已知旧码,失败时避免继续把旧码回传给 Admin。
*/
private async tryUpdatePendingQrcode(
container: QqbotNapcatRuntime,
session: QqbotLoginScanSession,
status: NapcatLoginStatus,
options: PendingQrcodeUpdateOptions = {},
) {
const requireFresh = options.requireFresh ?? !!session.qrcode;
try {
const qrcode = await this.getQrcode(container, false, {
requireFresh,
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;
if (options.clearStaleQrcode || requireFresh) {
session.qrcode = undefined;
}
session.errorMessage =
session.errorMessage || 'NapCat 正在重新生成二维码,请稍后';
}
}
/**
* 清理 NapCat 登录运行态状态。
* @param container - container 输入;使用 `id` 字段生成结果。
* @param options - Cleanup switches; create-login cleanup may revisit already-deleted provisional rows after async startup races.
*/
private async cleanupRuntimeContainer(
container: QqbotNapcatRuntime,
options: { includeDeletedCreateContainer?: boolean } = {},
) {
try {
if (options.includeDeletedCreateContainer) {
await this.containerService.removeUnboundCreateContainer(container.id);
return null;
}
await this.containerService.removeUnboundContainer(container.id);
return null;
} catch (err) {
return this.toolsService.getErrorMessage(err);
}
}
/**
* 查询 NapCat 登录运行态数据。
* @param container - container 输入;驱动 `this.normalizeLoginStatus()` 的 NapCat步骤。
* @param retry - retry 输入;决定 NapCat条件分支。
*/
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;
}
/**
* 转换 NapCat 登录运行态输入。
* @param container - container 输入;驱动 `this.getLoginInfo()` 的 NapCat步骤。
* @param status - NapCat列表使用 `isLogin` 字段生成结果。
*/
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;
}
/**
* 执行 NapCat 登录运行态流程。
* @param status - NapCat列表使用 `loginError` 字段生成结果。
* @param errorMessage - errorMessage 输入;影响 toOfflineLoginStatus 的返回值。
* @returns NapCat 登录运行态产出的 NapcatLoginStatus。
*/
private toOfflineLoginStatus(
status: NapcatLoginStatus,
errorMessage = 'NapCat 账号已离线,请重新扫码登录',
): NapcatLoginStatus {
return {
...status,
isLogin: false,
isOffline: true,
loginError: status.loginError || errorMessage,
};
}
/**
* Refreshes a QR code that is still present in NapCat but too close to the native QQ expiry window for human scanning.
* @param session - Pending scan session that would otherwise return the nearly expired QR to Admin.
* @param container - NapCat WebUI runtime used to request a fresh QR from the same login service.
* @param status - Latest WebUI login status containing the stale QR URL and its native update timestamp.
* @returns Pending scan result with a fresh QR when NapCat accepts refresh, or a pending no-QR result while refresh is still in progress.
*/
private async refreshNearlyExpiredQrcode(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
status: NapcatLoginStatus,
) {
try {
session.qrcode = await this.refreshOrGetQrcode(container, false, {
fallbackStatus: status,
requireFresh: true,
staleQrcode: status.qrcodeurl,
});
session.errorMessage = undefined;
this.publishScanResultEvent(
session,
'qrcode-ready',
'success',
'登录二维码已刷新',
);
return this.toResult(session);
} catch (err) {
if (!this.toolsService.isNapcatTemporaryError(err)) throw err;
session.qrcode = undefined;
return this.keepSessionPending(
session,
'登录二维码即将过期NapCat 正在重新生成二维码',
);
}
}
/**
* Determines whether a NapCat QR is close enough to QQ's native timeout that Admin should not show it for a new scan.
* @param status - WebUI status carrying `qrcodeUpdatedAt`; missing timestamps are treated as safe to avoid refreshing every legacy response.
* @returns True when the QR is present, not already expired, and has less than the configured safe remaining window.
*/
private shouldRefreshNearlyExpiredQrcode(status: NapcatLoginStatus) {
if (
!status.qrcodeurl ||
this.toolsService.isNapcatExpiredQrcodeStatus(status)
) {
return false;
}
const updatedAt = Number(status.qrcodeUpdatedAt);
if (!Number.isFinite(updatedAt) || updatedAt <= 0) return false;
const ageMs = Date.now() - updatedAt;
return ageMs >= this.getNativeQrcodeTtlMs() - this.getQrcodeSafeScanMs();
}
/**
* Decides whether a pending scan status poll should actively ask NapCat for a QR again.
* @param session - Pending scan session; challenge states and recent refresh attempts suppress automatic retries.
* @param status - Latest WebUI status; existing, expired, or successful QR/login states are handled elsewhere.
* @returns True when the status poll should call the same-container RefreshQRcode path once per cooldown window.
*/
private shouldAutoRefreshPendingQrcode(
session: QqbotLoginScanSession,
status: NapcatLoginStatus,
) {
if (session.preparingContainer || session.preparingRelogin) return false;
if (session.captchaUrl || session.newDeviceStatus) return false;
if (session.qrcode || status.qrcodeurl || status.isLogin) return false;
if (this.toolsService.isNapcatExpiredQrcodeStatus(status)) return false;
const lastRefreshAt = Number(session.lastQrcodeRefreshAt || 0);
if (!Number.isFinite(lastRefreshAt) || lastRefreshAt <= 0) return true;
return Date.now() - lastRefreshAt >= this.getQrcodeAutoRefreshCooldownMs();
}
/**
* Requests a fresh QR during status polling so SSE can recover from a previous accepted-but-not-updated refresh.
* @param session - Pending scan session whose result is returned to Admin/SSE.
* @param container - Current NapCat WebUI runtime; the method never rebuilds or restarts it.
* @param status - Latest WebUI status used as fallback metadata for QR freshness checks.
* @returns Updated scan result, either with a fresh QR or a bounded pending message.
*/
private async refreshPendingQrcodeFromStatus(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
status: NapcatLoginStatus,
) {
session.lastQrcodeRefreshAt = Date.now();
this.persistLoginSession(session);
this.publishScanResultEvent(
session,
'qrcode-fetch',
'processing',
'正在重新生成登录二维码',
);
try {
session.qrcode = await this.refreshOrGetQrcode(container, false, {
fallbackStatus: status,
requireFresh:
session.mode === 'refresh' ||
!!session.qrcode ||
this.toolsService.isNapcatExpiredQrcodeStatus(status),
staleQrcode: session.qrcode || status.qrcodeurl,
});
session.errorMessage = undefined;
session.expiresAt = Date.now() + this.getSessionTtlMs();
this.persistLoginSession(session);
this.publishScanResultEvent(
session,
'qrcode-ready',
'success',
'登录二维码已生成',
);
this.publishScanResultEvent(
session,
'waiting-scan',
'processing',
'等待扫码确认',
);
return this.toResult(session);
} catch (err) {
if (!this.toolsService.isNapcatTemporaryError(err)) throw err;
session.qrcode = undefined;
session.errorMessage =
'NapCat 正在重新生成二维码,请稍后刷新或等待自动更新';
this.persistLoginSession(session);
return this.toResult(session);
}
}
/**
* Reads the expected native QQ QR lifetime used only for safe-display decisions.
* @returns Milliseconds before a QR is considered too old to show without refreshing.
*/
private getNativeQrcodeTtlMs() {
return this.getPositiveConfigNumber(
'NAPCAT_LOGIN_NATIVE_QR_EXPIRE_MS',
2 * 60 * 1000,
);
}
/**
* Reads the minimum QR lifetime that must remain before Admin is allowed to show an existing QR for manual scanning.
* @returns Milliseconds kept as human scanning/confirmation safety margin.
*/
private getQrcodeSafeScanMs() {
return this.getPositiveConfigNumber(
'NAPCAT_LOGIN_QR_SAFE_SCAN_MS',
45 * 1000,
);
}
/**
* Reads the cooldown between automatic QR refresh attempts during status polling.
* @returns Milliseconds to wait before SSE/status may request another QR regeneration.
*/
private getQrcodeAutoRefreshCooldownMs() {
return this.getPositiveConfigNumber(
'NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS',
Math.max(5000, this.getLoginPollIntervalMs() * 2),
);
}
/**
* Reads the bounded wait window for NapCat to expose QQ number after WebUI already reports login-positive.
* @returns Milliseconds allowed for `GetQQLoginInfo` to start returning `uin`/`selfId` before treating the state as inconsistent.
*/
private getLoginSelfIdWaitMs() {
return this.getPositiveConfigNumber(
'NAPCAT_LOGIN_SELF_ID_WAIT_MS',
30_000,
);
}
/**
* 查询 NapCat 登录运行态数据。
* @param container - container 输入;限定 NapCat查询范围。
*/
private async getLoginInfo(container: QqbotNapcatRuntime) {
return this.postNapcat<NapcatLoginInfo>(
container,
'/api/QQLogin/GetQQLoginInfo',
);
}
/**
* 执行 NapCat 登录运行态流程。
* @param container - container 输入;影响 callRefreshQrcode 的返回值。
* @param retry - retry 输入;驱动 `this.executeNapcatRequest()` 的 NapCat步骤。
*/
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;
}
});
}
/**
* 查询 NapCat 登录运行态数据。
* @param container - container 输入;驱动 `this.getQrcodeFromStatus()`、`this.getLoginStatus()` 的 NapCat步骤。
* @param retry - retry 输入;驱动 `this.executeNapcatRequest()` 的 NapCat步骤。
* @param options - NapCat列表驱动 `this.getQrcodeFromStatus()`、`toolsService.ensureFreshQrcode()` 的 NapCat步骤。
*/
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)) {
if (options.requireFresh) {
throw new Error('NapCat WebUI 登录态仍阻止生成新二维码');
}
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;
}
});
}
/**
* 执行 NapCat 登录运行态流程。
* @param container - NapCat WebUI 运行态;只用于调用二维码刷新/获取接口。
* @param retry - retry 输入;驱动 `this.callRefreshQrcode()`、`this.getQrcode()` 的 NapCat步骤。
* @param options - NapCat列表使用 `fallbackStatus`、`requireFresh`、`staleQrcode` 字段生成结果。
*/
private async refreshOrGetQrcode(
container: QqbotNapcatRuntime,
retry = false,
options: QrcodeRefreshOptions = {},
) {
const fallbackStatus = options.fallbackStatus;
const lookupOptions: QrcodeLookupOptions = {
requireFresh: options.requireFresh || fallbackStatus?.isOffline,
staleQrcode: options.staleQrcode || fallbackStatus?.qrcodeurl,
};
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;
}
}
/**
* 查询 NapCat 登录运行态数据。
* @param container - container 输入;驱动 `this.getLoginStatus()` 的 NapCat步骤。
* @param options - NapCat列表使用 `requireFresh` 字段生成结果。
*/
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 未返回登录二维码');
}
/**
* 执行 NapCat 登录运行态流程。
* @param container - container 输入;影响 postNapcat 的返回值。
* @param path - 路由或文件路径;影响 postNapcat 的返回值。
* @param body - 请求体 DTO承载 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 请求失败');
});
}
/**
* 执行 NapCat 登录运行态流程。
* @param session - 更新登录会话;保存 WebUI 登录尝试、二维码和人工验证状态。
* @param container - NapCat WebUI 运行态;所有刷新动作都通过 WebUI 登录接口完成。
* @param loginPassword - loginPassword 输入;驱动 `toolsService.toSecretText()` 的 NapCat步骤。
* @param hasExistingPrimaryBinding - hasExistingPrimaryBinding 输入;决定 NapCat条件分支。
*/
private async prepareReloginQrcode(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
loginPassword?: string,
hasExistingPrimaryBinding = true,
) {
try {
const password = this.toolsService.toSecretText(loginPassword);
if (session.sourceContainerOnline === true) {
const completed = await this.completeOnlineSourceRefresh(
session,
container,
);
if (completed) return;
}
if (hasExistingPrimaryBinding) {
const quickLoginCompleted = await this.tryQuickRelogin(
session,
container,
!!password,
);
if (quickLoginCompleted) return;
}
if (await this.resolveStalePendingSession(session)) return;
const passwordLoginCompleted = await this.tryPasswordRelogin(
session,
container,
password,
);
if (passwordLoginCompleted) return;
if (await this.resolveStalePendingSession(session)) return;
this.publishScanResultEvent(
session,
'qrcode-fetch',
'processing',
'正在获取登录二维码',
);
session.qrcode = await this.refreshOrGetQrcode(container, false, {
requireFresh: true,
});
session.errorMessage = undefined;
session.expiresAt = Date.now() + this.getSessionTtlMs();
this.publishScanResultEvent(
session,
'qrcode-ready',
'success',
'登录二维码已生成',
);
this.publishScanResultEvent(
session,
'waiting-scan',
'processing',
'等待扫码确认',
);
await this.syncSessionQqLoginStatus(session, {
isLogin: false,
qrcodeurl: session.qrcode,
});
} 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);
}
}
}
/**
* 处理源运行容器仍在线的更新登录流程。
* @param session - 更新登录会话;若当前容器已经登录目标账号则直接完成。
* @param container - NapCat 运行态;用于只读检查当前 QQ 登录态。
* @returns 当前容器已登录目标账号并完成会话时返回 true否则返回 false 继续 WebUI 登录流程。
*/
private async completeOnlineSourceRefresh(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
) {
const loginStatus = await this.getLoginStatus(container, true);
if (
loginStatus.isOffline &&
this.shouldRestartNapcatWorkerForOnlineRefresh(session)
) {
await this.restartNapcatWorkerForOnlineRefresh(
session,
container,
loginStatus.loginError || 'NapCat 账号已离线,正在重启登录服务',
);
return false;
}
if (!loginStatus.isLogin) return false;
const loginInfo = await this.getLoginInfo(container);
if (loginInfo.online === false) return false;
const selfId = this.toolsService.pickNapcatSelfId(loginInfo);
if (!selfId) return false;
if (session.expectedSelfId && session.expectedSelfId !== selfId) {
return false;
}
await this.completeLogin(session, container, {
loginInfo,
successMessage: '当前 NapCat 容器已在线,无需重建登录',
});
return true;
}
/**
* 执行 NapCat 登录运行态流程。
* @param session - 更新登录会话;提供目标 QQ 号并保存 quick 登录进度。
* @param container - NapCat WebUI 运行态;接收 SetQuickLogin 并返回登录状态。
* @param hasPasswordFallback - hasPasswordFallback 输入;驱动 `this.publishQuickLoginFallback()` 的 NapCat步骤。
*/
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 快速登录',
);
try {
const uin = this.toolsService.toTrimmedString(session.expectedSelfId);
if (!uin) {
this.publishQuickLoginFallback(
session,
'缺少目标 QQ 号',
hasPasswordFallback,
);
return false;
}
await this.postNapcat<null>(container, '/api/QQLogin/SetQuickLogin', {
uin,
});
this.publishScanResultEvent(
session,
'quick-login-wait',
'processing',
'等待 NapCat 快速登录结果',
);
const loginStatus = await this.waitForQuickLoginStatus(container);
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) {
if (this.toolsService.isNapcatAlreadyLoggedInError(err)) {
return this.completeAlreadyLoggedInQuickRelogin(
session,
container,
hasPasswordFallback,
);
}
this.publishQuickLoginFallback(
session,
this.toolsService.getErrorMessage(err),
hasPasswordFallback,
);
return false;
}
await this.completeLogin(session, container, {
loginInfo,
successMessage: '快速登录成功',
});
return true;
}
/**
* Decides whether this refresh session may spend its single same-container worker restart budget.
* @param session - Login refresh session; its source-runtime flag proves the managed runtime is already alive and the attempt flag prevents restart storms.
* @returns True only before the first NapCat worker restart attempt in this online-source refresh session.
*/
private shouldRestartNapcatWorkerForOnlineRefresh(
session: QqbotLoginScanSession,
) {
return (
session.mode === 'refresh' &&
session.sourceContainerOnline === true &&
session.onlineSourceWorkerRestartAttempted !== true
);
}
/**
* Restarts only the NapCat worker when the managed runtime is alive but QQCore login service is stale.
* @param session - Refresh login session that owns progress messages and retry timestamps.
* @param container - Current online WebUI runtime; its device identity and environment must be preserved.
* @param reason - Latest QQ login-state evidence shown to Admin before the worker restart.
* @returns Fresh WebUI login status after the worker restart completes.
*/
private async restartNapcatWorkerForOnlineRefresh(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
reason: string,
) {
session.lastRestartedAt = Date.now();
session.onlineSourceWorkerRestartAttempted = true;
session.errorMessage = reason;
this.persistLoginSession(session);
this.publishScanResultEvent(
session,
'napcat-worker-restart',
'processing',
reason,
);
await this.restartNapcatForLogin(container, {
processOnly: true,
waitForReady: true,
});
return this.getLoginStatus(container, true);
}
/**
* 在 NapCat WebUI 拒绝重复 quick 登录时读取真实 QQ 在线态。
* @param session - 当前更新登录会话;成功时直接完成会话,失败时写入 fallback 进度。
* @param container - 当前 NapCat WebUI 容器;只读调用 CheckLoginStatus/GetQQLoginInfo。
* @param hasPasswordFallback - 是否还有密码登录分支;决定 fallback 文案里的下一步。
* @returns 真实 QQ 在线且账号匹配时返回 true否则返回 false 继续后续登录分支。
*/
private async completeAlreadyLoggedInQuickRelogin(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
hasPasswordFallback: boolean,
) {
this.publishScanResultEvent(
session,
'quick-login-status-check',
'processing',
'NapCat 报告账号已登录,正在确认真实在线状态',
);
let loginInfo: NapcatLoginInfo;
try {
const loginStatus = await this.getLoginStatus(container, true);
if (!loginStatus.isLogin) {
await this.syncSessionQqLoginStatus(session, loginStatus);
this.publishQuickLoginFallback(
session,
loginStatus.loginError || 'NapCat 已登录标记残留但真实 QQ 已离线',
hasPasswordFallback,
);
return false;
}
loginInfo = await this.getLoginInfo(container);
if (loginInfo.online === false) {
this.publishQuickLoginFallback(
session,
'NapCat 已登录标记残留但真实 QQ 已离线',
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: 'NapCat 已登录,已确认真实在线状态',
});
return true;
}
/**
* 执行 NapCat 登录运行态流程。
* @param session - 更新登录会话;保存密码 MD5、验证码和新设备验证上下文。
* @param container - NapCat WebUI 运行态;接收 PasswordLogin 并返回后续人工验证要求。
* @param loginPassword - 解密后的 QQ 密码明文;只用于本次 WebUI PasswordLogin 的 MD5 计算。
*/
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;
const passwordLogSinceMs = Date.now();
session.passwordMd5 = createHash('md5')
.update(password, 'utf8')
.digest('hex');
session.lastRestartedAt = passwordLogSinceMs;
session.errorMessage = 'NapCat 正在尝试密码登录,请稍后';
this.persistLoginSession(session);
this.publishScanResultEvent(
session,
'password-login-start',
'processing',
'正在尝试 NapCat 密码登录',
);
let loginStatus: NapcatLoginStatus;
try {
const uin = this.toolsService.toTrimmedString(session.expectedSelfId);
if (!uin) {
this.publishPasswordLoginFallback(session, '缺少目标 QQ 号');
return false;
}
const passwordResult =
await this.postNapcat<NapcatCaptchaLoginResult | null>(
container,
'/api/QQLogin/PasswordLogin',
{
passwordMd5: session.passwordMd5,
uin,
},
);
const passwordResultPending = await this.applyPasswordLoginResult(
session,
container,
passwordResult,
passwordLogSinceMs,
);
if (passwordResultPending) return true;
this.publishScanResultEvent(
session,
'password-login-wait',
'processing',
'等待 NapCat 密码登录结果',
);
loginStatus = await this.waitForPasswordLoginStatus(
container,
passwordLogSinceMs,
);
if (loginStatus.isLogin) {
loginInfo = await this.getLoginInfo(container);
}
} catch (err) {
const errorMessage = this.toolsService.getErrorMessage(err);
if (this.toolsService.isNapcatCaptchaRequiredMessage(errorMessage)) {
const captchaUrl = await this.waitForPasswordCaptchaUrl(
container,
passwordLogSinceMs,
);
if (captchaUrl) {
this.keepPasswordCaptchaPending(session, captchaUrl, errorMessage);
} else {
this.keepPasswordCaptchaWaitingForUrl(session, errorMessage);
}
return true;
}
this.publishPasswordLoginFallback(session, errorMessage);
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;
}
this.publishPasswordLoginFallback(session, loginStatus.loginError);
return false;
}
if (loginInfo?.online === false) {
this.publishPasswordLoginFallback(session, 'NapCat 当前账号已离线');
return false;
}
if (!loginInfo) {
this.publishPasswordLoginFallback(session, 'NapCat 未返回登录信息');
return false;
}
const selfId = this.toolsService.pickNapcatSelfId(loginInfo);
if (!selfId) {
this.publishPasswordLoginFallback(session, 'NapCat 未返回 QQ 号');
return false;
}
if (session.expectedSelfId && session.expectedSelfId !== selfId) {
this.publishPasswordLoginFallback(
session,
`当前密码登录账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`,
);
return false;
}
await this.completeLogin(session, container, {
loginInfo,
successMessage: '密码登录成功',
});
return true;
}
/**
* 轮询 NapCat WebUI 快速登录后的 QQ 登录态。
* @param container - NapCat WebUI 运行态;用于调用 CheckLoginStatus。
* @returns 最早出现的成功、失败或可继续二维码状态。
*/
private async waitForQuickLoginStatus(container: QqbotNapcatRuntime) {
let latestStatus: NapcatLoginStatus = { isLogin: false };
const attempts = this.getLoginPollAttempts(
this.getQuickLoginWaitMs(),
this.getLoginPollIntervalMs(),
);
for (let index = 0; index < attempts; index += 1) {
if (index > 0) {
await this.toolsService.sleep(this.getLoginPollIntervalMs());
}
latestStatus = await this.getLoginStatus(container, true);
if (
latestStatus.isLogin ||
latestStatus.isOffline ||
latestStatus.loginError ||
latestStatus.qrcodeurl
) {
return latestStatus;
}
}
return latestStatus;
}
/**
* 处理 NapCat WebUI PasswordLogin 的同步返回结果。
* @param session - 更新登录会话;保存验证码、新设备二维码和密码 MD5 上下文。
* @param container - NapCat WebUI 运行态;新设备二维码和验证码后续提交都回到同一容器。
* @param result - PasswordLogin 返回体;官方用它声明验证码或新设备验证。
* @param sinceMs - PasswordLogin 发起时间;限定容器日志验证码 URL 的读取窗口。
* @returns 已进入人工验证 pending 态时返回 true没有同步挑战时返回 false。
*/
private async applyPasswordLoginResult(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
result: NapcatCaptchaLoginResult | null,
sinceMs: number,
) {
if (!result) return false;
if (result.needNewDevice) {
await this.startNewDeviceVerification(session, container, result);
return true;
}
const proofWaterUrl = this.toolsService.toTrimmedString(
result.proofWaterUrl,
);
if (!result.needCaptcha && !proofWaterUrl) return false;
if (proofWaterUrl) {
this.keepPasswordCaptchaPending(session, proofWaterUrl);
return true;
}
const captchaUrl = await this.waitForPasswordCaptchaUrl(container, sinceMs);
if (captchaUrl) {
this.keepPasswordCaptchaPending(session, captchaUrl);
return true;
}
this.keepPasswordCaptchaWaitingForUrl(
session,
'密码登录需要完成 QQ 安全验证',
);
return true;
}
/**
* 执行 NapCat 登录运行态流程。
* @param session - session 输入;使用 `expectedSelfId`、`qrcode`、`captchaUrl`、`errorMessage` 字段生成结果。
* @param container - container 输入;驱动 `this.refreshOrGetQrcode()` 的 NapCat步骤。
* @param loginStatus - NapCat列表使用 `qrcodeurl` 字段生成结果。
*/
private async keepPasswordQrcodePending(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
loginStatus: NapcatLoginStatus,
) {
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();
session.passwordMd5 = undefined;
this.persistLoginSession(session);
this.publishScanResultEvent(
session,
'qrcode-ready',
'success',
'登录二维码已生成',
);
this.publishScanResultEvent(
session,
'waiting-scan',
'processing',
'等待扫码确认',
);
}
/**
* 解析Password Captcha Url。
* @param container - container 输入;驱动 `this.detectPasswordCaptchaUrl()`、`this.waitForPasswordCaptchaUrl()` 的 NapCat步骤。
* @param loginStatus - NapCat列表使用 `loginError` 字段生成结果。
* @param sinceMs - NapCat列表驱动 `this.detectPasswordCaptchaUrl()`、`this.waitForPasswordCaptchaUrl()` 的 NapCat步骤。
*/
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);
}
/**
* 解析Status Captcha Url。
* @param session - session 输入;使用 `lastCaptchaLookupAt`、`lastRestartedAt` 字段生成结果。
* @param container - container 输入;驱动 `this.detectPasswordCaptchaUrl()` 的 NapCat步骤。
* @param loginStatus - NapCat列表决定 NapCat条件分支。
*/
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);
}
/**
* 查询 NapCat 登录运行态数据。
* @param status - NapCat列表使用 `captchaUrl`、`loginError` 字段生成结果。
*/
private getCaptchaUrlFromStatus(status: NapcatLoginStatus) {
return (
this.toolsService.toTrimmedString(status.captchaUrl) ||
this.toolsService.extractNapcatCaptchaUrl(status.loginError)
);
}
/**
* 判断 NapCat 登录运行态条件。
* @param session - session 输入;使用 `lastCaptchaLookupAt` 字段生成结果。
*/
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;
}
/**
* 判断 NapCat 登录运行态条件。
* @param status - NapCat列表使用 `loginError` 字段计算判断结果。
*/
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('完成安全验证')
);
}
/**
* 执行 NapCat 登录运行态流程。
* @param container - container 输入;驱动 `containerService.detectRuntimeCaptchaUrl()` 的 NapCat步骤。
* @param sinceMs - NapCat列表驱动 `containerService.detectRuntimeCaptchaUrl()` 的 NapCat步骤。
* @param allowTailFallback - allowTailFallback 输入;决定 NapCat条件分支。
*/
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)) || ''
);
}
/**
* 执行 NapCat 登录运行态流程。
* @param container - container 输入;驱动 `this.detectPasswordCaptchaUrl()` 的 NapCat步骤。
* @param sinceMs - NapCat列表驱动 `this.detectPasswordCaptchaUrl()` 的 NapCat步骤。
*/
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 '';
}
/**
* 判断 NapCat 登录运行态条件。
* @param status - NapCat列表使用 `qrcodeurl` 字段计算判断结果。
*/
private isPasswordQrcodeChallenge(status: NapcatLoginStatus) {
return (
!!this.toolsService.toTrimmedString(status.qrcodeurl) ||
this.toolsService.isNapcatExpiredQrcodeStatus(status)
);
}
/**
* 查询 NapCat 登录运行态数据。
* @param options - NapCat列表使用 `hasExistingPrimaryBinding`、`loginPassword` 字段生成结果。
*/
private getReloginPreparingMessage(options: {
hasExistingPrimaryBinding?: boolean;
loginPassword?: string;
}) {
if (options.hasExistingPrimaryBinding !== false) {
return 'NapCat 正在尝试快速登录,请稍后';
}
return this.toolsService.toSecretText(options.loginPassword)
? 'NapCat 正在尝试密码登录,请稍后'
: 'NapCat 正在准备登录二维码,请稍后';
}
/**
* 投递 NapCat 登录运行态消息或任务。
* @param session - session 输入;使用 `errorMessage` 字段生成结果。
* @param reason - reason 输入;影响 publishQuickLoginFallback 的返回值。
* @param hasPasswordFallback - hasPasswordFallback 输入;影响 publishQuickLoginFallback 的返回值。
*/
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,
);
}
/**
* 投递 NapCat 登录运行态消息或任务。
* @param session - session 输入;使用 `errorMessage` 字段生成结果。
* @param reason - reason 输入;影响 publishPasswordLoginFallback 的返回值。
*/
private publishPasswordLoginFallback(
session: QqbotLoginScanSession,
reason?: string,
) {
session.errorMessage = reason
? `密码登录未完成:${reason},开始生成二维码`
: '密码登录未完成,开始生成二维码';
this.persistLoginSession(session);
this.publishScanResultEvent(
session,
'password-login-fallback',
'processing',
session.errorMessage,
);
}
/**
* 执行 NapCat 登录运行态流程。
* @param container - container 输入;驱动 `containerService.restartRuntimeContainer()`、`webuiClient.clearCredential()`、`this.getLoginStatus()` 的 NapCat步骤。
* @param options - NapCat列表使用 `waitForReady` 字段生成结果。
*/
private async restartNapcatForLogin(
container: QqbotNapcatRuntime,
options: NapcatRestartOptions = {},
) {
const restartedByContainer = options.processOnly
? false
: 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);
}
/**
* 查询 NapCat 登录运行态数据。
*/
private getSessionTtlMs() {
return Number(
this.configService.get('NAPCAT_LOGIN_QR_EXPIRE_MS') || 2 * 60 * 1000,
);
}
/**
* 查询 NapCat 登录运行态数据。
* @returns 人工验证码与新设备验证会话的保活窗口,至少不短于普通登录二维码窗口。
*/
private getHumanVerificationSessionTtlMs() {
const configured = Number(
this.configService.get('NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS') ||
15 * 60 * 1000,
);
const fallback = 15 * 60 * 1000;
const ttl =
Number.isFinite(configured) && configured > 0 ? configured : fallback;
return Math.max(ttl, this.getSessionTtlMs());
}
/**
* 查询 NapCat 登录运行态数据。
* @param session - 当前登录会话;根据验证码、新设备链接或新设备二维码判断是否需要人工验证保活窗口。
*/
private getSessionRenewalTtlMs(session: QqbotLoginScanSession) {
if (
session.captchaUrl ||
session.deviceVerifyUrl ||
session.newDeviceQrcode ||
session.newDeviceStatus
) {
return this.getHumanVerificationSessionTtlMs();
}
return this.getSessionTtlMs();
}
/**
* 执行 NapCat 登录运行态流程。
* @param session - 当前登录会话;写回下一次状态轮询前允许保持 pending 的截止时间。
*/
private renewSessionExpiry(session: QqbotLoginScanSession) {
session.expiresAt = Date.now() + this.getSessionRenewalTtlMs(session);
}
/**
* 查询 NapCat 登录运行态数据。
*/
private getTimeout() {
return Number(this.configService.get('NAPCAT_WEBUI_TIMEOUT_MS') || 8000);
}
/**
* 查询 NapCat 登录运行态数据。
*/
private getRestartDelayMs() {
return Number(
this.configService.get('NAPCAT_WEBUI_RESTART_DELAY_MS') || 3000,
);
}
/**
* 执行 NapCat 登录运行态流程。
* @param container - container 输入;驱动 `this.getLoginStatus()`、`this.detectPasswordCaptchaUrl()`、`this.getCaptchaUrlFromStatus()` 的 NapCat步骤。
* @param sinceMs - NapCat列表驱动 `this.detectPasswordCaptchaUrl()`、`Number.isFinite()`、`this.getCaptchaUrlFromStatus()` 的 NapCat步骤。
*/
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;
}
/**
* 查询 NapCat 登录运行态数据。
* @param waitMs - NapCat列表驱动 `Number.isFinite()` 的 NapCat步骤。
* @param intervalMs - NapCat列表驱动 `Number.isFinite()` 的 NapCat步骤。
*/
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));
}
/**
* 查询 NapCat 登录运行态数据。
*/
private getPasswordLoginWaitMs() {
return this.getPositiveConfigNumber(
'QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS',
120_000,
);
}
/**
* 查询 NapCat 快速登录结果的等待窗口。
* @returns WebUI SetQuickLogin 后最多等待 QQ 登录态变化的毫秒数。
*/
private getQuickLoginWaitMs() {
return this.getPositiveConfigNumber(
'QQBOT_NAPCAT_QUICK_LOGIN_WAIT_MS',
15_000,
);
}
/**
* 查询 NapCat 登录运行态数据。
*/
private getLoginPollIntervalMs() {
return this.getPositiveConfigNumber(
'QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS',
3000,
);
}
/**
* 查询 NapCat 登录运行态数据。
* @param key - 键名;驱动 `Number()` 的 NapCat步骤。
* @param fallback - 兜底值;驱动 `Number()`、`Number.isFinite()` 的 NapCat步骤。
*/
private getPositiveConfigNumber(key: string, fallback: number) {
const value = Number(this.configService.get(key) || fallback);
return Number.isFinite(value) && value > 0 ? value : fallback;
}
/**
* 执行Napcat Request。
* @param retry - retry 输入;决定 NapCat条件分支。
* @param action - action 输入;影响 executeNapcatRequest 的返回值。
*/
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;
}
}