From 185457362c3c705e5141f7160da08012e33e19da Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 12 Jun 2026 21:40:07 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DNapCat=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E7=99=BB=E5=BD=95=E7=8A=B6=E6=80=81=E6=8B=86=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 4 +- README.md | 4 +- src/qqbot/account/qqbot-account.service.ts | 156 +++++++++++-- .../account/qqbot-napcat-login.service.ts | 64 +++++- .../napcat/qqbot-napcat-container.service.ts | 209 +++++++++++++++++- src/qqbot/qqbot.types.ts | 21 ++ .../account/qqbot-account.service.spec.ts | 168 ++++++++++++++ .../qqbot-napcat-login.service.spec.ts | 168 ++++++++++++++ ...ot-napcat-container-runtime-status.spec.ts | 79 +++++++ 9 files changed, 844 insertions(+), 29 deletions(-) create mode 100644 test/qqbot/napcat/qqbot-napcat-container-runtime-status.spec.ts diff --git a/API.md b/API.md index cbc9604..aca1bb8 100644 --- a/API.md +++ b/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` 默认镜像。 diff --git a/README.md b/README.md index f8cbc45..8784264 100644 --- a/README.md +++ b/README.md @@ -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 测试。 diff --git a/src/qqbot/account/qqbot-account.service.ts b/src/qqbot/account/qqbot-account.service.ts index c7c1e9f..2612956 100644 --- a/src/qqbot/account/qqbot-account.service.ts +++ b/src/qqbot/account/qqbot-account.service.ts @@ -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(); 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 { + 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( diff --git a/src/qqbot/account/qqbot-napcat-login.service.ts b/src/qqbot/account/qqbot-napcat-login.service.ts index eaee083..4481b21 100644 --- a/src/qqbot/account/qqbot-napcat-login.service.ts +++ b/src/qqbot/account/qqbot-napcat-login.service.ts @@ -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( retry: boolean, action: () => Promise, diff --git a/src/qqbot/napcat/qqbot-napcat-container.service.ts b/src/qqbot/napcat/qqbot-napcat-container.service.ts index c1182af..d04e9d5 100644 --- a/src/qqbot/napcat/qqbot-napcat-container.service.ts +++ b/src/qqbot/napcat/qqbot-napcat-container.service.ts @@ -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 { + 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( + 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( + 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( + runtime: QqbotNapcatRuntime, + path: string, + body: Record = {}, + credential?: string, + ): Promise { + const target = new URL(path, runtime.baseUrl); + const payload = JSON.stringify(body); + const client = target.protocol === 'https:' ? https : http; + + return new Promise((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; + 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) { diff --git a/src/qqbot/qqbot.types.ts b/src/qqbot/qqbot.types.ts index 5f8bc2d..4cb5479 100644 --- a/src/qqbot/qqbot.types.ts +++ b/src/qqbot/qqbot.types.ts @@ -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; }; diff --git a/test/qqbot/account/qqbot-account.service.spec.ts b/test/qqbot/account/qqbot-account.service.spec.ts index 2e7c0e7..1ae2b10 100644 --- a/test/qqbot/account/qqbot-account.service.spec.ts +++ b/test/qqbot/account/qqbot-account.service.spec.ts @@ -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 = { diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index e6609b5..eecd50b 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -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 = { + 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', diff --git a/test/qqbot/napcat/qqbot-napcat-container-runtime-status.spec.ts b/test/qqbot/napcat/qqbot-napcat-container-runtime-status.spec.ts new file mode 100644 index 0000000..31c8a75 --- /dev/null +++ b/test/qqbot/napcat/qqbot-napcat-container-runtime-status.spec.ts @@ -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, + }), + ); + }); +});