fix: 修复NapCat密码登录状态拆分

This commit is contained in:
sunlei 2026-06-12 21:40:07 +08:00
parent e951f5068a
commit 185457362c
9 changed files with 844 additions and 29 deletions

4
API.md
View File

@ -314,9 +314,9 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
| `POST` | `/qqbot/account/bind/rule` | 绑定账号和自动回复规则 |
| `POST` | `/qqbot/account/unbind/rule` | 解绑账号和自动回复规则 |
账号保存支持可选 `encryptedLoginPassword`,用于 NapCat 密码登录。前端必须先通过 `/auth/password-public-key` 获取公钥并使用 RSA-OAEP 加密,不传明文 `loginPassword`;后端必须使用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密落库,空值和公开默认值会被拒绝,不在列表/详情中返回。
账号保存支持可选 `encryptedLoginPassword`,用于 NapCat 密码登录。前端必须先通过 `/auth/password-public-key` 获取公钥并使用 RSA-OAEP 加密,不传明文 `loginPassword`;后端必须使用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密落库,空值和公开默认值会被拒绝,不在列表/详情中返回。账号列表里的 `connectStatus` 只表示 OneBot 反向 WS`napcat.oneBotOnline`、`napcat.containerOnline`、`napcat.webuiOnline`、`napcat.qqLoginStatus`、`napcat.qqLoginMessage` 分别表示 OneBot、容器、WebUI 和 QQ 登录态,`webuiOnline=null` 表示本次使用缓存且未重新探测 WebUI`qqLoginMessage` 只承载真实 QQ 登录态消息WebUI 配置缺失或请求异常只放在 `lastError`
扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。已有账号的更新登录会先重启目标 NapCat 容器尝试 `ACCOUNT`/`-q` 快速登录;目标账号在线则直接完成会话。没有历史登录态的新容器会跳过快速登录,优先尝试保存的登录密码;快速登录失败后,如果账号保存了登录密码,会短暂注入 `NAPCAT_QUICK_PASSWORD` 继续尝试密码登录,密码登录成功后会重建容器移除该运行态密码,清理失败则本次登录失败;密码登录仍失败、触发强制扫码验证、离线、账号不匹配或缺少 QQ 号时,再进入重置登录态和二维码兜底流程。看门狗自动登录使用同样的 quick -> password 顺序,但不会自动进入扫码阶段。
扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。已有账号的更新登录会先重启目标 NapCat 容器尝试 `ACCOUNT`/`-q` 快速登录;目标账号在线则直接完成会话。没有历史登录态的新容器会跳过快速登录,优先尝试保存的登录密码;快速登录失败后,如果账号保存了登录密码,会临时注入 `NAPCAT_QUICK_PASSWORD` 并按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询密码登录结果,准备阶段的扫码会话会持续续期,避免后台密码登录未完成时前端先判过期。密码登录成功后会重建容器移除该运行态密码,清理失败则本次登录失败;密码登录仍失败、触发强制扫码验证、离线、账号不匹配或缺少 QQ 号时,再进入重置登录态和二维码兜底流程。看门狗自动登录使用同样的 quick -> password 顺序,但不会自动进入扫码阶段。
同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器后端会释放旧绑定和未共享的旧容器避免同账号多实例互相挤下线。OneBot notice 只有机器人下线、登录失效、`KickedOffLine` 等账号级信号才会把账号标记为离线,普通群成员 kick 不属于账号离线信号。下线原因写入 `lastError` 前按 `last_error` 500 字符列宽截断;后续无错误的普通断连只更新离线状态,不清空该原因。账号列表会按近期缓存检查绑定 NapCat 容器的最新登录状态日志,日志检测默认 5 秒超时;`isOnline:false` 属于离线信号;如果账号连接时间或心跳晚于容器检测时间,以账号在线态为准;如果最新日志状态是在线,会清空容器旧离线错误;如果最新状态是离线或被踢下线,会同步回写账号离线状态并生成 `qqbot.account.offline` 站内信。托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像。

View File

@ -135,10 +135,10 @@ pnpm exec jest --runInBand --runTestsByPath test/path/to/file.spec.ts
- QQBot 外发统一走发送排队:默认全局间隔 `2500ms`、同会话间隔 `8000ms`、排队抖动 `0-800ms`,超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS` 时拒绝本次发送,避免高频自动回复形成突发流量。
- QQBot 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。
- QQBot 复读机默认阈值为 4同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。
- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表日志检测带近期缓存和短超时,账号连接时间或心跳晚于容器检测时间时以账号在线态为准,最新日志为在线时清空容器旧离线错误。
- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表拆开展示 OneBot、容器、WebUI 和 QQ 登录态,日志/WebUI 检测带近期缓存和短超时,账号连接时间或心跳晚于容器检测时间时以账号在线态为准,最新日志为在线时清空容器旧离线错误`qqLoginMessage` 只展示 QQ 登录态消息WebUI 配置或请求错误留在 `lastError`
- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。
- NapCat 账号新增/编辑支持可选 QQ 登录密码Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。
- NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。`ACCOUNT` 只负责指定快速登录账号;如果数据卷内没有该 QQ 的历史登录记录,后端会跳过快速登录并优先尝试账号保存的登录密码,密码登录成功后会移除运行态 `NAPCAT_QUICK_PASSWORD`,清理失败则本次登录失败,避免成功态残留明文 env密码登录也失败或触发强制扫码验证时才重置登录态并生成二维码。Admin「更新登录」的 SSE 步骤顺序按实际路径为 `quick-login-*`(已有历史会话)-> `password-login-*` -> `password-env-cleanup` -> `relogin-reset/qrcode/waiting-scan`
- NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。`ACCOUNT` 只负责指定快速登录账号;如果数据卷内没有该 QQ 的历史登录记录,后端会跳过快速登录并优先尝试账号保存的登录密码,密码登录会按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果,准备中的扫码会话会续期,避免后台密码登录未结束时前端先过期;密码登录成功后会移除运行态 `NAPCAT_QUICK_PASSWORD`,清理失败则本次登录失败,避免成功态残留明文 env密码登录也失败或触发强制扫码验证时才重置登录态并生成二维码。Admin「更新登录」的 SSE 步骤顺序按实际路径为 `quick-login-*`(已有历史会话)-> `password-login-*` -> `password-env-cleanup` -> `relogin-reset/qrcode/waiting-scan`
- NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000``QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后先尝试 `ACCOUNT` 历史会话快速登录,再尝试账号保存的登录密码,仍失败时写入离线原因并复用 `super` 站内信告警;看门狗不自动进入扫码阶段。
- BangDream 当前源码根目录是 `src/qqbot/plugins/bangDream`;不要恢复旧 `tsugu` 层级或旧大桶目录。
- BangDream 在线命令以 `registry/operation-registry.ts` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 registry/command-SQL 测试。

View File

@ -27,6 +27,7 @@ import type {
QqbotAccountAbilityType,
QqbotAccountListItem,
QqbotConnectionRole,
QqbotNapcatRuntimeStatusSnapshot,
} from '../qqbot.types';
const NAPCAT_RUNTIME_CHECK_TTL_MS = 30_000;
@ -434,8 +435,10 @@ export class QqbotAccountService {
);
const containerMap = new Map<string, QqbotNapcatContainer>();
if (containerIds.length > 0) {
const containers = await this.napcatContainerRepository
.createQueryBuilder('container')
const containerBuilder = this.napcatContainerRepository
.createQueryBuilder('container');
containerBuilder.addSelect?.('container.webuiToken');
const containers = await containerBuilder
.where('container.id IN (:...containerIds)', { containerIds })
.andWhere('container.isDeleted = :isDeleted', { isDeleted: false })
.getMany();
@ -452,17 +455,28 @@ export class QqbotAccountService {
}
const container = containerMap.get(binding.containerId);
await this.syncNapcatOfflineState(account, container, options);
const runtimeStatus = await this.syncNapcatRuntimeState(
account,
container,
options,
);
return Object.assign(account, {
napcat: {
bindStatus: binding.bindStatus,
containerId: binding.containerId,
containerName: container?.name,
containerOnline:
runtimeStatus?.containerOnline ??
(container?.status === 'running' || false),
containerStatus: container?.status,
lastCheckedAt: container?.lastCheckedAt,
lastError: container?.lastError,
lastCheckedAt: runtimeStatus?.checkedAt || container?.lastCheckedAt,
lastError: runtimeStatus?.lastError ?? container?.lastError,
lastLoginAt: binding.lastLoginAt,
lastStartedAt: container?.lastStartedAt,
oneBotOnline: account.connectStatus === 'online',
qqLoginMessage: runtimeStatus?.qqLoginMessage,
qqLoginStatus: runtimeStatus?.qqLoginStatus,
webuiOnline: runtimeStatus?.webuiOnline,
webuiPort: container?.webuiPort,
},
});
@ -470,44 +484,150 @@ export class QqbotAccountService {
);
}
private async syncNapcatOfflineState(
private async syncNapcatRuntimeState(
account: QqbotAccount,
container?: QqbotNapcatContainer,
options: { autoLogin?: boolean } = {},
) {
if (
account.connectStatus !== 'online' ||
!container ||
container.status !== 'running'
) {
return;
const runtimeStatus = await this.getNapcatRuntimeStatus(account, container);
if (!container || container.status !== 'running') return runtimeStatus;
if (account.connectStatus !== 'online') return runtimeStatus;
if (this.isAccountStateNewerThanRuntimeCheck(account, container)) {
return runtimeStatus;
}
if (this.isAccountStateNewerThanRuntimeCheck(account, container)) return;
const runtimeOfflineReason =
this.getRuntimeStatusOfflineReason(runtimeStatus);
if (runtimeOfflineReason) {
if (options.autoLogin && (await this.tryAutoLogin(account, container))) {
return this.toCachedNapcatRuntimeStatus(account, container);
}
await this.applyNapcatOfflineState(
account,
container,
runtimeOfflineReason,
);
return runtimeStatus;
}
const cachedOfflineReason = this.getFreshCachedOfflineReason(container);
if (cachedOfflineReason) {
if (options.autoLogin && (await this.tryAutoLogin(account, container))) {
return;
return this.toCachedNapcatRuntimeStatus(account, container);
}
await this.applyNapcatOfflineState(
account,
container,
cachedOfflineReason,
);
return;
return runtimeStatus;
}
if (this.isFreshRuntimeCheck(container.lastCheckedAt)) {
return runtimeStatus;
}
if (this.isFreshRuntimeCheck(container.lastCheckedAt)) return;
const offlineReason =
await this.napcatContainerService.detectRuntimeOffline(container);
if (!offlineReason) return;
if (!offlineReason) return runtimeStatus;
if (options.autoLogin && (await this.tryAutoLogin(account, container))) {
return;
return this.toCachedNapcatRuntimeStatus(account, container);
}
await this.applyNapcatOfflineState(account, container, offlineReason);
return {
...runtimeStatus,
checkedAt: new Date(),
lastError: offlineReason,
qqLoginMessage: offlineReason,
qqLoginStatus: 'offline',
} as QqbotNapcatRuntimeStatusSnapshot;
}
private async getNapcatRuntimeStatus(
account: QqbotAccount,
container?: QqbotNapcatContainer,
): Promise<QqbotNapcatRuntimeStatusSnapshot | undefined> {
if (!container) return undefined;
const cached = this.toCachedNapcatRuntimeStatus(account, container);
if (container.status !== 'running') return cached;
if (this.isAccountStateNewerThanRuntimeCheck(account, container)) {
return cached;
}
if (this.isFreshRuntimeCheck(container.lastCheckedAt)) return cached;
if (
typeof this.napcatContainerService.inspectRuntimeStatus !== 'function'
) {
return cached;
}
const inspected =
await this.napcatContainerService.inspectRuntimeStatus(container);
container.lastCheckedAt = inspected.checkedAt as any;
container.lastError = inspected.lastError || null;
return inspected;
}
private toCachedNapcatRuntimeStatus(
account: QqbotAccount,
container: QqbotNapcatContainer,
): QqbotNapcatRuntimeStatusSnapshot {
const containerOnline = container.status === 'running';
const lastError = this.toolsService.toTrimmedString(container.lastError);
const offlineReason = this.toolsService.isNapcatOfflineLoginMessage(
lastError,
)
? lastError
: null;
return {
checkedAt: container.lastCheckedAt || undefined,
containerOnline,
lastError: lastError || null,
qqLoginMessage: offlineReason,
qqLoginStatus: this.toCachedQqLoginStatus(
account,
containerOnline,
lastError,
),
webuiOnline: containerOnline ? null : false,
};
}
private toCachedQqLoginStatus(
account: QqbotAccount,
containerOnline: boolean,
lastError: string,
): QqbotNapcatRuntimeStatusSnapshot['qqLoginStatus'] {
if (!containerOnline) return 'offline';
if (
lastError.includes('二维码已过期') ||
lastError.includes('二维码过期')
) {
return 'qrcode_expired';
}
if (this.toolsService.isNapcatOfflineLoginMessage(lastError)) {
return 'offline';
}
if (account.connectStatus === 'online') return 'online';
return 'unknown';
}
private getRuntimeStatusOfflineReason(
runtimeStatus?: QqbotNapcatRuntimeStatusSnapshot,
) {
if (!runtimeStatus) return null;
if (
runtimeStatus.qqLoginStatus !== 'offline' &&
runtimeStatus.qqLoginStatus !== 'qrcode_expired'
) {
return null;
}
return (
this.toolsService.toTrimmedString(runtimeStatus.qqLoginMessage) ||
this.toolsService.toTrimmedString(runtimeStatus.lastError) ||
'NapCat QQ 登录态不可用'
);
}
private async tryAutoLogin(

View File

@ -153,11 +153,17 @@ export class QqbotNapcatLoginService {
async status(sessionId: string) {
const session = this.getSession(sessionId);
if (Date.now() > session.expiresAt) {
return this.expireSession(session);
if (session.status !== 'pending') {
return this.toResult(session);
}
if (session.preparingRelogin) {
return this.toResult(session);
return this.keepSessionPending(
session,
session.errorMessage || 'NapCat 正在准备登录,请稍后',
);
}
if (Date.now() > session.expiresAt) {
return this.expireSession(session);
}
const container = await this.getSessionContainer(session);
@ -377,6 +383,7 @@ export class QqbotNapcatLoginService {
session.accountId = accountId;
session.status = 'success';
session.errorMessage = undefined;
session.preparingRelogin = false;
this.sessions.set(session.id, session);
const result = {
...this.toResult(session),
@ -455,6 +462,10 @@ export class QqbotNapcatLoginService {
status: QqbotLoginScanEvent['status'],
message: string,
) {
if (session.status === 'pending') {
session.expiresAt = Date.now() + this.getSessionTtlMs();
this.sessions.set(session.id, session);
}
this.publishScanEvent(session, {
message,
result: this.toResult(session),
@ -529,6 +540,7 @@ export class QqbotNapcatLoginService {
) {
session.status = 'error';
session.errorMessage = errorMessage;
session.preparingRelogin = false;
this.publishScanResultEvent(session, 'login-error', 'error', errorMessage);
this.sessions.delete(session.id);
await this.cleanupSessionContainer(session);
@ -859,6 +871,7 @@ export class QqbotNapcatLoginService {
} else {
session.status = 'error';
session.errorMessage = message || 'NapCat 重置登录态失败';
session.preparingRelogin = false;
this.publishScanResultEvent(
session,
'relogin-error',
@ -998,8 +1011,7 @@ export class QqbotNapcatLoginService {
'processing',
'等待 NapCat 密码登录结果',
);
await this.toolsService.sleep(this.getRestartDelayMs());
loginStatus = await this.getLoginStatus(container, true);
loginStatus = await this.waitForPasswordLoginStatus(container);
if (loginStatus.isLogin) {
loginInfo = await this.getLoginInfo(container);
@ -1355,6 +1367,48 @@ export class QqbotNapcatLoginService {
);
}
private async waitForPasswordLoginStatus(container: QqbotNapcatRuntime) {
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());
}
latestStatus = await this.getLoginStatus(container, true);
if (latestStatus.isLogin) return latestStatus;
}
return latestStatus;
}
private getLoginPollAttempts(waitMs: number, intervalMs: number) {
const normalizedWaitMs = Number.isFinite(waitMs) && waitMs > 0 ? waitMs : 1;
const normalizedIntervalMs =
Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 1;
return Math.max(1, Math.ceil(normalizedWaitMs / normalizedIntervalMs));
}
private getPasswordLoginWaitMs() {
return this.getPositiveConfigNumber(
'QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS',
120_000,
);
}
private getLoginPollIntervalMs() {
return this.getPositiveConfigNumber(
'QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS',
3000,
);
}
private getPositiveConfigNumber(key: string, fallback: number) {
const value = Number(this.configService.get(key) || fallback);
return Number.isFinite(value) && value > 0 ? value : fallback;
}
private async executeNapcatRequest<T>(
retry: boolean,
action: () => Promise<T>,

View File

@ -1,5 +1,7 @@
import * as http from 'http';
import * as https from 'https';
import { spawn } from 'child_process';
import { randomBytes, randomUUID } from 'crypto';
import { createHash, randomBytes, randomUUID } from 'crypto';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
@ -8,7 +10,14 @@ import { throwVbenError, ToolsService } from '@/common';
import { QqbotAccount } from '../account/qqbot-account.entity';
import { QqbotAccountNapcat } from './qqbot-account-napcat.entity';
import { QqbotNapcatContainer } from './qqbot-napcat-container.entity';
import type { QqbotNapcatRuntime } from '../qqbot.types';
import type {
NapcatApiResponse,
NapcatCredential,
NapcatLoginStatus,
QqbotNapcatRuntime,
QqbotNapcatRuntimeLoginStatus,
QqbotNapcatRuntimeStatusSnapshot,
} from '../qqbot.types';
type NapcatLoginLogState = 'offline' | 'online' | 'unknown';
@ -457,6 +466,92 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
}
}
async inspectRuntimeStatus(
container: QqbotNapcatContainer,
): Promise<QqbotNapcatRuntimeStatusSnapshot> {
const checkedAt = new Date();
const containerOnline = container.status === 'running';
if (!containerOnline) {
return {
checkedAt,
containerOnline,
lastError: container.lastError,
qqLoginMessage: null,
qqLoginStatus: 'offline',
webuiOnline: false,
};
}
if (!container.baseUrl || !container.webuiToken) {
const message = 'NapCat WebUI 配置缺失';
await this.containerRepository.update(
{ id: container.id },
{
lastCheckedAt: checkedAt,
lastError: message,
},
);
return {
checkedAt,
containerOnline,
lastError: message,
qqLoginMessage: null,
qqLoginStatus: 'unknown',
webuiOnline: false,
};
}
try {
const runtime = this.toRuntime(container);
const credential = await this.getNapcatCredential(runtime);
const status = await this.requestNapcat<NapcatLoginStatus>(
runtime,
'/api/QQLogin/CheckLoginStatus',
{},
credential,
);
const snapshot = this.toRuntimeStatusSnapshot(
status,
containerOnline,
checkedAt,
);
await this.containerRepository.update(
{ id: container.id },
{
lastCheckedAt: checkedAt,
lastError:
snapshot.qqLoginStatus === 'online'
? null
: this.toolsService.toColumnText(
snapshot.qqLoginMessage || snapshot.lastError || '',
500,
) || null,
},
);
return snapshot;
} catch (err) {
const message = this.toolsService.toColumnText(
this.toolsService.getErrorMessage(err),
500,
);
await this.containerRepository.update(
{ id: container.id },
{
lastCheckedAt: checkedAt,
lastError: message,
},
);
return {
checkedAt,
containerOnline,
lastError: message,
qqLoginMessage: null,
qqLoginStatus: 'unknown',
webuiOnline: false,
};
}
}
private async removeContainer(containerId: string) {
const container = await this.containerRepository.findOne({
where: {
@ -979,6 +1074,116 @@ ${accountRunFlag}${passwordRunFlag} -p "$PORT:6099" \\
};
}
private async getNapcatCredential(runtime: QqbotNapcatRuntime) {
const token = runtime.webuiToken || '';
const hash = createHash('sha256').update(`${token}.napcat`).digest('hex');
const data = await this.requestNapcat<NapcatCredential>(
runtime,
'/api/auth/login',
{ hash },
);
if (!data.Credential) {
throwVbenError('NapCat WebUI 登录失败');
}
return data.Credential;
}
private toRuntimeStatusSnapshot(
status: NapcatLoginStatus,
containerOnline: boolean,
checkedAt: Date,
): QqbotNapcatRuntimeStatusSnapshot {
const message = this.toolsService.toTrimmedString(status.loginError);
const qqLoginStatus = this.toQqLoginStatus(status, message);
return {
checkedAt,
containerOnline,
lastError: qqLoginStatus === 'online' ? null : message || null,
qqLoginMessage: qqLoginStatus === 'online' ? null : message || null,
qqLoginStatus,
webuiOnline: true,
};
}
private toQqLoginStatus(
status: NapcatLoginStatus,
message: string,
): QqbotNapcatRuntimeLoginStatus {
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';
}
private requestNapcat<T>(
runtime: QqbotNapcatRuntime,
path: string,
body: Record<string, any> = {},
credential?: string,
): Promise<T> {
const target = new URL(path, runtime.baseUrl);
const payload = JSON.stringify(body);
const client = target.protocol === 'https:' ? https : http;
return new Promise<T>((resolve, reject) => {
const req = client.request(
{
headers: {
...(credential
? {
Authorization: `Bearer ${credential}`,
}
: {}),
'Content-Length': Buffer.byteLength(payload),
'Content-Type': 'application/json',
},
hostname: target.hostname,
method: 'POST',
path: `${target.pathname}${target.search}`,
port: target.port,
protocol: target.protocol,
timeout: this.getRuntimeCheckTimeoutMs(),
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
let result: NapcatApiResponse<T>;
try {
result = raw ? JSON.parse(raw) : ({ code: -1 } as any);
} catch {
reject(new Error('NapCat 返回非 JSON 响应'));
return;
}
if (result.code !== 0) {
reject(new Error(result.message || 'NapCat 请求失败'));
return;
}
resolve(result.data as T);
});
},
);
req.on('error', reject);
req.on('timeout', () => {
req.destroy(new Error('NapCat 请求超时'));
});
req.write(payload);
req.end();
});
}
private normalizeBaseUrl(value: string) {
const baseUrl = `${value || ''}`.trim();
if (!baseUrl) {

View File

@ -63,17 +63,38 @@ export type QqbotNapcatContainerStatus =
export type QqbotAccountNapcatBindStatus = 'bound' | 'disabled' | 'pending';
export type QqbotNapcatRuntimeLoginStatus =
| 'offline'
| 'online'
| 'qrcode_expired'
| 'qrcode_pending'
| 'unknown';
export type QqbotNapcatRuntimeStatusSnapshot = {
checkedAt?: Date;
containerOnline: boolean;
lastError?: null | string;
qqLoginMessage?: null | string;
qqLoginStatus: QqbotNapcatRuntimeLoginStatus;
webuiOnline?: boolean | null;
};
export type QqbotAccountAbilityType = 'command' | 'event_plugin' | 'rule';
export type QqbotAccountNapcatRuntimeInfo = {
bindStatus?: QqbotAccountNapcatBindStatus;
containerId?: string;
containerName?: string;
containerOnline?: boolean;
containerStatus?: QqbotNapcatContainerStatus;
lastCheckedAt?: Date | null;
lastError?: null | string;
lastLoginAt?: Date | null;
lastStartedAt?: Date | null;
oneBotOnline?: boolean;
qqLoginMessage?: null | string;
qqLoginStatus?: QqbotNapcatRuntimeLoginStatus;
webuiOnline?: boolean | null;
webuiPort?: null | number;
};

View File

@ -407,6 +407,174 @@ describe('QqbotAccountService', () => {
);
});
it('separates OneBot, container, WebUI and QQ login status for offline accounts', async () => {
const account = {
connectStatus: 'offline',
enabled: true,
id: 'account-1',
isDeleted: false,
lastError: null,
name: '主账号',
selfId: '1914728559',
};
const binding = {
accountId: 'account-1',
bindStatus: 'bound',
containerId: 'container-1',
isDeleted: false,
isPrimary: true,
lastLoginAt: new Date('2026-06-10T12:00:00.000Z'),
};
const container = {
id: 'container-1',
isDeleted: false,
lastCheckedAt: new Date('2026-06-11T02:00:00.000Z'),
lastError: null,
name: 'kt-qqbot-napcat-1914728559',
status: 'running',
webuiPort: 6101,
};
const accountRepository = {
createQueryBuilder: jest.fn(() => ({
andWhere: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[account], 1]),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const napcatContainerService = {
inspectRuntimeStatus: jest.fn().mockResolvedValue({
checkedAt: new Date('2026-06-12T12:00:00.000Z'),
containerOnline: true,
lastError: '二维码已过期,请刷新',
qqLoginMessage: '二维码已过期,请刷新',
qqLoginStatus: 'qrcode_expired',
webuiOnline: true,
}),
};
const service = new QqbotAccountService(
accountRepository as any,
{} as any,
{
createQueryBuilder: jest.fn(() => ({
addOrderBy: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([binding]),
orderBy: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
} as any,
{
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([container]),
where: jest.fn().mockReturnThis(),
})),
} as any,
napcatContainerService as any,
new ToolsService(),
);
const page = await service.page({});
expect(napcatContainerService.inspectRuntimeStatus).toHaveBeenCalledWith(
container,
);
expect(accountRepository.update).not.toHaveBeenCalled();
expect(page.list[0]).toEqual(
expect.objectContaining({
connectStatus: 'offline',
napcat: expect.objectContaining({
containerOnline: true,
oneBotOnline: false,
qqLoginMessage: '二维码已过期,请刷新',
qqLoginStatus: 'qrcode_expired',
webuiOnline: true,
}),
}),
);
});
it('does not expose WebUI errors as QQ login messages in cached runtime status', async () => {
const checkedAt = new Date();
const account = {
connectStatus: 'offline',
enabled: true,
id: 'account-1',
isDeleted: false,
lastError: null,
name: '主账号',
selfId: '1914728559',
};
const binding = {
accountId: 'account-1',
bindStatus: 'bound',
containerId: 'container-1',
isDeleted: false,
isPrimary: true,
lastLoginAt: checkedAt,
};
const container = {
id: 'container-1',
isDeleted: false,
lastCheckedAt: checkedAt,
lastError: 'NapCat WebUI 配置缺失',
name: 'kt-qqbot-napcat-1914728559',
status: 'running',
webuiPort: 6101,
};
const accountRepository = {
createQueryBuilder: jest.fn(() => ({
andWhere: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[account], 1]),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const service = new QqbotAccountService(
accountRepository as any,
{} as any,
{
createQueryBuilder: jest.fn(() => ({
addOrderBy: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([binding]),
orderBy: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
} as any,
{
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([container]),
where: jest.fn().mockReturnThis(),
})),
} as any,
{} as any,
new ToolsService(),
);
const page = await service.page({});
expect(page.list[0].napcat).toEqual(
expect.objectContaining({
containerOnline: true,
lastError: 'NapCat WebUI 配置缺失',
qqLoginMessage: null,
qqLoginStatus: 'unknown',
webuiOnline: null,
}),
);
});
it('ignores cached NapCat offline reason after the account reconnects', async () => {
const checkedAt = new Date('2026-06-11T02:00:00.000Z');
const account = {

View File

@ -156,6 +156,44 @@ describe('QqbotNapcatLoginService', () => {
);
});
it('keeps refresh login session alive while relogin is still preparing', async () => {
const containerService = {
removeUnboundContainer: jest.fn().mockResolvedValue(false),
};
const refreshService = new QqbotNapcatLoginService(
{
get: jest.fn((key: string) =>
key === 'NAPCAT_LOGIN_QR_EXPIRE_MS' ? '120000' : '',
),
} as unknown as ConfigService,
{} as QqbotAccountService,
containerService as unknown as QqbotNapcatContainerService,
new ToolsService(),
);
const session = (refreshService as any).createSession({
accountId: 'account-1',
container: {
id: 'container-ttl',
name: 'napcat-10001',
},
expectedSelfId: '10001',
mode: 'refresh',
preparingRelogin: true,
status: 'pending',
});
session.errorMessage = 'NapCat 正在尝试密码登录,请稍后';
session.expiresAt = Date.now() - 1000;
(refreshService as any).sessions.set(session.id, session);
const result = await refreshService.status(session.id);
expect(result.status).toBe('pending');
expect(result.errorMessage).toBe('NapCat 正在尝试密码登录,请稍后');
expect(result.expiresAt).toBeGreaterThan(Date.now());
expect(containerService.removeUnboundContainer).not.toHaveBeenCalled();
expect((refreshService as any).sessions.has(session.id)).toBe(true);
});
it('uses NapCat -q quick login before generating qrcode for refresh login', async () => {
const container = {
baseUrl: 'http://127.0.0.1:6103/',
@ -479,6 +517,91 @@ describe('QqbotNapcatLoginService', () => {
);
});
it('waits for password login to finish instead of falling back on the first pending status', async () => {
const container = {
baseUrl: 'http://127.0.0.1:6103/',
id: 'container-password-wait',
name: 'napcat-10001',
};
const accountService = {
ensureScannedAccount: jest.fn().mockResolvedValue('account-1'),
};
const containerService = {
bindAccount: jest.fn().mockResolvedValue(undefined),
ensureRuntimeLoginEnv: jest
.fn()
.mockResolvedValue({ changed: true, ok: true }),
resetRuntimeLoginState: jest.fn().mockResolvedValue(true),
restartRuntimeContainer: jest.fn().mockResolvedValue(true),
};
const refreshService = new QqbotNapcatLoginService(
{
get: jest.fn((key: string) => {
const values: Record<string, string> = {
QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS: '1',
QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS: '3',
QQBOT_NAPCAT_QUICK_LOGIN_WAIT_MS: '1',
};
return values[key] || '';
}),
} as unknown as ConfigService,
accountService as unknown as QqbotAccountService,
containerService as unknown as QqbotNapcatContainerService,
new ToolsService(),
);
const session = (refreshService as any).createSession({
accountId: 'account-1',
container,
expectedSelfId: '10001',
mode: 'refresh',
preparingRelogin: true,
status: 'pending',
});
(refreshService as any).sessions.set(session.id, session);
jest
.spyOn((refreshService as any).toolsService, 'sleep')
.mockResolvedValue(undefined);
jest
.spyOn(refreshService as any, 'getLoginStatus')
.mockResolvedValueOnce({
isLogin: false,
loginError: '快速登录未找到历史会话',
})
.mockResolvedValueOnce({
isLogin: false,
loginError: '密码登录处理中',
})
.mockResolvedValueOnce({
isLogin: false,
loginError: '密码登录处理中',
})
.mockResolvedValueOnce({
isLogin: true,
});
jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({
nickname: 'Kwi',
online: true,
uin: '10001',
});
const refreshQrcode = jest
.spyOn(refreshService as any, 'refreshOrGetQrcode')
.mockResolvedValue('fallback-qrcode');
await (refreshService as any).prepareReloginQrcode(
session,
container,
'qq-password',
);
expect(refreshQrcode).not.toHaveBeenCalled();
expect(accountService.ensureScannedAccount).toHaveBeenCalledWith({
accountId: 'account-1',
name: 'Kwi',
selfId: '10001',
});
expect(session.status).toBe('success');
});
it('uses password before qrcode without historical NapCat session', async () => {
const container = {
baseUrl: 'http://127.0.0.1:6103/',
@ -854,6 +977,51 @@ describe('QqbotNapcatLoginService', () => {
expect(getLoginStatus).not.toHaveBeenCalled();
});
it('keeps successful refresh login terminal when status is polled after background relogin', async () => {
(service as any).sessions.set('session-success', {
accountId: 'account-1',
containerId: 'container-success',
containerName: 'napcat-success',
createdAt: Date.now(),
expiresAt: Date.now() + 60_000,
id: 'session-success',
mode: 'refresh',
preparingRelogin: true,
status: 'success',
webuiPort: 6108,
});
const getLoginStatus = jest.spyOn(service as any, 'getLoginStatus');
const result = await service.status('session-success');
expect(result.status).toBe('success');
expect(result.errorMessage).toBeUndefined();
expect(getLoginStatus).not.toHaveBeenCalled();
});
it('keeps failed refresh login terminal when status is polled after background relogin', async () => {
(service as any).sessions.set('session-error', {
accountId: 'account-1',
containerId: 'container-error',
containerName: 'napcat-error',
createdAt: Date.now(),
errorMessage: 'NapCat 快速登录失败',
expiresAt: Date.now() + 60_000,
id: 'session-error',
mode: 'refresh',
preparingRelogin: true,
status: 'error',
webuiPort: 6109,
});
const getLoginStatus = jest.spyOn(service as any, 'getLoginStatus');
const result = await service.status('session-error');
expect(result.status).toBe('error');
expect(result.errorMessage).toBe('NapCat 快速登录失败');
expect(getLoginStatus).not.toHaveBeenCalled();
});
it('replays scan progress events to late SSE subscribers', () => {
const session = {
containerId: 'container-events',

View File

@ -0,0 +1,79 @@
jest.mock('@/common', () => {
const actualCommon = jest.requireActual('@/common');
return {
...actualCommon,
throwVbenError: (message: string) => {
throw new Error(message);
},
};
});
import { ToolsService } from '@/common';
import { QqbotNapcatContainerService } from '@/qqbot/napcat/qqbot-napcat-container.service';
describe('QqbotNapcatContainerService runtime status', () => {
const createService = (repository: {
update: jest.Mock;
}) =>
new QqbotNapcatContainerService(
{ get: jest.fn() } as any,
repository as any,
{} as any,
new ToolsService(),
);
it('keeps WebUI configuration errors out of the QQ login message', async () => {
const repository = {
update: jest.fn(),
};
const service = createService(repository);
const snapshot = await service.inspectRuntimeStatus({
id: 'container-1',
lastError: null,
status: 'running',
} as any);
expect(snapshot).toEqual(
expect.objectContaining({
lastError: 'NapCat WebUI 配置缺失',
qqLoginMessage: null,
qqLoginStatus: 'unknown',
webuiOnline: false,
}),
);
expect(repository.update).toHaveBeenCalledWith(
{ id: 'container-1' },
expect.objectContaining({
lastError: 'NapCat WebUI 配置缺失',
}),
);
});
it('keeps WebUI request errors out of the QQ login message', async () => {
const repository = {
update: jest.fn(),
};
const service = createService(repository);
jest
.spyOn(service as any, 'getNapcatCredential')
.mockRejectedValue(new Error('NapCat WebUI 请求超时'));
const snapshot = await service.inspectRuntimeStatus({
baseUrl: 'http://127.0.0.1:6100/',
id: 'container-1',
lastError: null,
status: 'running',
webuiToken: 'token',
} as any);
expect(snapshot).toEqual(
expect.objectContaining({
lastError: 'NapCat WebUI 请求超时',
qqLoginMessage: null,
qqLoginStatus: 'unknown',
webuiOnline: false,
}),
);
});
});