fix: 修复NapCat密码登录状态拆分
This commit is contained in:
parent
e951f5068a
commit
185457362c
4
API.md
4
API.md
@ -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` 默认镜像。
|
||||
|
||||
|
||||
@ -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 测试。
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user