From aa8d4117fb17f8a3269e1e2bf36fe40149a99231 Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 19 Jun 2026 04:14:47 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=BB=B6=E9=95=BFNapCat=E4=BA=BA?= =?UTF-8?q?=E5=B7=A5=E9=AA=8C=E8=AF=81=E4=BC=9A=E8=AF=9DTTL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + API.md | 2 +- README.md | 2 +- .../login/qqbot-napcat-login.service.ts | 49 +++++++++++++++++-- src/runtime/config/runtime-config.service.ts | 1 + .../qqbot-napcat-login.service.spec.ts | 48 ++++++++++++++++++ test/runtime/runtime-config.service.spec.ts | 8 +++ 7 files changed, 104 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index c8e2fc0..82659bd 100644 --- a/.env.example +++ b/.env.example @@ -82,6 +82,7 @@ NAPCAT_WEBUI_BASE_URL=http://127.0.0.1:6099 NAPCAT_WEBUI_TOKEN= NAPCAT_WEBUI_TIMEOUT_MS=8000 NAPCAT_LOGIN_QR_EXPIRE_MS=120000 +NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS=900000 NAPCAT_WEBUI_READY_RETRIES=10 NAPCAT_WEBUI_RESTART_DELAY_MS=3000 # 留空时使用密码登录等待窗口 + WebUI 重启/请求缓冲 + 2 个轮询间隔。 diff --git a/API.md b/API.md index 542665c..9d892f8 100644 --- a/API.md +++ b/API.md @@ -346,7 +346,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 账号保存支持可选 `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` 并按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询密码登录结果,准备阶段的扫码会话会持续续期,避免后台密码登录未完成时前端先判过期。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码等待窗口加缓冲)后,`/qqbot/account/scan/status` 会自动恢复普通登录态检测,不再永久停留在“正在尝试密码登录”;`/qqbot/account/scan/events` 在进程内事件缓存丢失时会先推送当前会话快照。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。`/qqbot/account/scan/status` 遇到 NapCat 只返回“需要验证码/继续完成验证/安全验证”但不带 URL 时,会先从当前容器日志提取 `proofWaterUrl`,提取不到则保持验证码处理中而不切到二维码兜底;会话已有 `captchaUrl` 后,同类状态仍保持 `pending` 和原 `captchaUrl`。密码登录成功后会重建容器移除该运行态密码,清理失败则本次登录失败;密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,再进入重置登录态和二维码兜底流程。看门狗自动登录使用同样的 quick -> password 顺序,但不会自动进入扫码阶段。 +扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。已有账号的更新登录会先重启目标 NapCat 容器尝试 `ACCOUNT`/`-q` 快速登录;目标账号在线则直接完成会话。没有历史登录态的新容器会跳过快速登录,优先尝试保存的登录密码;快速登录失败后,如果账号保存了登录密码,会临时注入 `NAPCAT_QUICK_PASSWORD` 并按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询密码登录结果,准备阶段的扫码会话会持续续期,避免后台密码登录未完成时前端先判过期。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码等待窗口加缓冲)后,`/qqbot/account/scan/status` 会自动恢复普通登录态检测,不再永久停留在“正在尝试密码登录”;`/qqbot/account/scan/events` 在进程内事件缓存丢失时会先推送当前会话快照。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。验证码和新设备验证这类真人交互态使用 `NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS`(默认 15 分钟,且至少不短于普通二维码 TTL)续期;普通登录二维码仍使用 `NAPCAT_LOGIN_QR_EXPIRE_MS`。`/qqbot/account/scan/status` 遇到 NapCat 只返回“需要验证码/继续完成验证/安全验证”但不带 URL 时,会先从当前容器日志提取 `proofWaterUrl`,提取不到则保持验证码处理中而不切到二维码兜底;会话已有 `captchaUrl` 后,同类状态仍保持 `pending` 和原 `captchaUrl`。密码登录成功后会重建容器移除该运行态密码,清理失败则本次登录失败;密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,再进入重置登录态和二维码兜底流程。看门狗自动登录使用同样的 quick -> password 顺序,但不会自动进入扫码阶段。 密码验证码通过后如果 NapCat 返回 `needNewDevice`,后端不会只把 `jumpUrl` 透给 Admin,而是在同一会话中继续调用 `/api/QQLogin/GetNewDeviceQRCode` 生成新设备验证二维码;`/qqbot/account/scan/status` 后续轮询会代理 `/api/QQLogin/PollNewDeviceQR`,状态映射为 `newDeviceStatus=qr-pending|scanned|confirming|verified|expired|failed`,进入确认态后再调用 `/api/QQLogin/NewDeviceLogin` 并回到密码登录完成检查。扫码会话结果新增 `newDeviceQrcode`、`newDeviceStatus`、`deviceVerifyUrl` 字段;`captchaUrl` 和 `newDeviceQrcode` 分别表示腾讯安全验证码和 QQ 新设备验证二维码,前端必须分开展示。SSE 进度文案包含快速登录、密码登录、验证码、新设备二维码、已扫码、确认中、登录成功/失败和运行态清理失败。 diff --git a/README.md b/README.md index 5402cf1..3dc6cf2 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow - QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表拆开展示 OneBot、容器、WebUI 和 QQ 登录态,心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态;`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 历史登录记录,后端会跳过快速登录并优先尝试账号保存的登录密码,密码登录会按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果,准备中的扫码会话会续期,避免后台密码登录未结束时前端先过期;如果 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码登录等待窗口加缓冲)后会自动恢复为普通状态检测,不会永久卡在“正在尝试密码登录”。如果 NapCat 返回 `proofWaterUrl` 或日志出现“需要验证码”,会保持会话 pending 并把腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`,不是让用户只打开外链;状态轮询遇到验证码文案但缺少 URL 时,会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底;密码登录成功后会移除运行态 `NAPCAT_QUICK_PASSWORD`,清理失败则本次登录失败,避免成功态残留明文 env;密码登录也失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时才重置登录态并生成二维码。Admin「更新登录」的 SSE 步骤顺序按实际路径为 `quick-login-*`(已有历史会话)-> `password-login-*` / `password-login-captcha` -> `password-env-cleanup` -> `relogin-reset/qrcode/waiting-scan`;SSE 事件缓存因 Pod 重启丢失时,新订阅会收到当前会话快照。 +- NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。`ACCOUNT` 只负责指定快速登录账号;如果数据卷内没有该 QQ 历史登录记录,后端会跳过快速登录并优先尝试账号保存的登录密码,密码登录会按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果,准备中的扫码会话会续期,避免后台密码登录未结束时前端先过期;如果 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码登录等待窗口加缓冲)后会自动恢复为普通状态检测,不会永久卡在“正在尝试密码登录”。如果 NapCat 返回 `proofWaterUrl` 或日志出现“需要验证码”,会保持会话 pending 并把腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`,不是让用户只打开外链;验证码和新设备验证这类真人交互态使用 `NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS`(默认 15 分钟)续期,普通二维码仍使用 `NAPCAT_LOGIN_QR_EXPIRE_MS`;状态轮询遇到验证码文案但缺少 URL 时,会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底;密码登录成功后会移除运行态 `NAPCAT_QUICK_PASSWORD`,清理失败则本次登录失败,避免成功态残留明文 env;密码登录也失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时才重置登录态并生成二维码。Admin「更新登录」的 SSE 步骤顺序按实际路径为 `quick-login-*`(已有历史会话)-> `password-login-*` / `password-login-captcha` -> `password-env-cleanup` -> `relogin-reset/qrcode/waiting-scan`;SSE 事件缓存因 Pod 重启丢失时,新订阅会收到当前会话快照。 - NapCat 设备身份按账号持久化到 `napcat_device_identity`:同一账号重建容器会复用数据目录、`pc-<8hex>` hostname、machine-id 和 `02:42:*` MAC;Docker run 会注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id`,并同步写入 QQNT Linux `machine-info`,使 `/etc/machine-id`、Docker MAC 和 QQNT 本地设备缓存保持一致。当前策略名为 `qqnt-visible-hostname-v1` / `docker-bridge-mac-v1`。 - NapCat 新设备验证走同一 scan session:`CaptchaLogin` 返回 `needNewDevice` 后,后端继续调用 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`,Admin/SSE 分开展示 `captchaUrl`、`newDeviceQrcode`、已扫码、确认中、验证成功、登录成功/失败等中文进度,不把 `jumpUrl` 当作唯一完成入口。 - NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000`,`QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后先尝试 `ACCOUNT` 历史会话快速登录,再尝试账号保存的登录密码,仍失败时写入离线原因并复用 `super` 站内信告警;看门狗不自动进入扫码阶段。 diff --git a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts index cbd181e..c477a43 100644 --- a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts +++ b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts @@ -1004,7 +1004,7 @@ export class QqbotNapcatLoginService { session.qrcode = undefined; session.newDeviceStatus = status; session.errorMessage = message; - session.expiresAt = Date.now() + this.getSessionTtlMs(); + this.renewSessionExpiry(session); this.persistLoginSession(session); if (shouldPublish) { this.publishScanResultEvent(session, step, 'processing', message); @@ -1132,7 +1132,7 @@ export class QqbotNapcatLoginService { message: string, ) { if (session.status === 'pending') { - session.expiresAt = Date.now() + this.getSessionTtlMs(); + this.renewSessionExpiry(session); this.persistLoginSession(session); } this.publishScanEvent(session, { @@ -1300,7 +1300,7 @@ export class QqbotNapcatLoginService { ) { session.status = 'pending'; session.errorMessage = errorMessage; - session.expiresAt = Date.now() + this.getSessionTtlMs(); + this.renewSessionExpiry(session); if (clearQrcode) session.qrcode = undefined; this.persistLoginSession(session); return this.toResult(session); @@ -1331,7 +1331,7 @@ export class QqbotNapcatLoginService { session.preparingRelogin = false; session.qrcode = undefined; session.errorMessage = message; - session.expiresAt = Date.now() + this.getSessionTtlMs(); + this.renewSessionExpiry(session); this.persistLoginSession(session); if (shouldPublish) { this.publishScanResultEvent( @@ -1362,7 +1362,7 @@ export class QqbotNapcatLoginService { session.preparingRelogin = false; session.qrcode = undefined; session.errorMessage = message; - session.expiresAt = Date.now() + this.getSessionTtlMs(); + this.renewSessionExpiry(session); this.persistLoginSession(session); if (shouldPublish) { this.publishScanResultEvent( @@ -2647,6 +2647,45 @@ export class QqbotNapcatLoginService { ); } + /** + * 查询 NapCat 登录运行态数据。 + * @returns 人工验证码与新设备验证会话的保活窗口,至少不短于普通登录二维码窗口。 + */ + private getHumanVerificationSessionTtlMs() { + const configured = Number( + this.configService.get('NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS') || + 15 * 60 * 1000, + ); + const fallback = 15 * 60 * 1000; + const ttl = + Number.isFinite(configured) && configured > 0 ? configured : fallback; + return Math.max(ttl, this.getSessionTtlMs()); + } + + /** + * 查询 NapCat 登录运行态数据。 + * @param session - 当前登录会话;根据验证码、新设备链接或新设备二维码判断是否需要人工验证保活窗口。 + */ + private getSessionRenewalTtlMs(session: QqbotLoginScanSession) { + if ( + session.captchaUrl || + session.deviceVerifyUrl || + session.newDeviceQrcode || + session.newDeviceStatus + ) { + return this.getHumanVerificationSessionTtlMs(); + } + return this.getSessionTtlMs(); + } + + /** + * 执行 NapCat 登录运行态流程。 + * @param session - 当前登录会话;写回下一次状态轮询前允许保持 pending 的截止时间。 + */ + private renewSessionExpiry(session: QqbotLoginScanSession) { + session.expiresAt = Date.now() + this.getSessionRenewalTtlMs(session); + } + /** * 查询 NapCat 登录运行态数据。 */ diff --git a/src/runtime/config/runtime-config.service.ts b/src/runtime/config/runtime-config.service.ts index 6eb1dd4..a4ff202 100644 --- a/src/runtime/config/runtime-config.service.ts +++ b/src/runtime/config/runtime-config.service.ts @@ -57,6 +57,7 @@ const OPTIONAL_CONFIG_CHECKS: ReadonlyArray = [ 'QQBOT_NAPCAT_SSH_TARGET', 'QQBOT_NAPCAT_SSH_PORT', 'QQBOT_NAPCAT_SSH_KEY_PATH', + 'NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS', ['QQBOT_NAPCAT_REVERSE_WS_URL', 'QQBOT_NAPCAT_REVERSE_WS_BASE'], ['NAPCAT_WEBUI_BASE_URL', 'QQBOT_NAPCAT_WEBUI_URL'], ['NAPCAT_WEBUI_TOKEN', 'QQBOT_NAPCAT_WEBUI_TOKEN'], diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index 2572ab5..9c0608a 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -198,6 +198,54 @@ describe('QqbotNapcatLoginService', () => { expect((refreshService as any).sessions.has(session.id)).toBe(true); }); + it('uses a longer TTL for human captcha and new-device verification states', () => { + const now = new Date('2026-06-19T04:10:00+08:00').getTime(); + jest.spyOn(Date, 'now').mockReturnValue(now); + const refreshService = new QqbotNapcatLoginService( + { + get: jest.fn((key: string) => { + if (key === 'NAPCAT_LOGIN_QR_EXPIRE_MS') return '120000'; + if (key === 'NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS') return '900000'; + return ''; + }), + } as unknown as ConfigService, + {} as QqbotAccountService, + {} as QqbotNapcatContainerService, + new ToolsService(), + ); + + const qrcodeSession = (refreshService as any).createSession({ + container: { id: 'container-qr', name: 'napcat-qr' }, + mode: 'refresh', + status: 'pending', + }); + const captchaSession = (refreshService as any).createSession({ + container: { id: 'container-captcha', name: 'napcat-captcha' }, + mode: 'refresh', + status: 'pending', + }); + const newDeviceSession = (refreshService as any).createSession({ + container: { id: 'container-device', name: 'napcat-device' }, + mode: 'refresh', + status: 'pending', + }); + + const captcha = (refreshService as any).keepPasswordCaptchaPending( + captchaSession, + 'https://ti.qq.com/safe/tools/captcha/sms-verify-login', + ); + const newDevice = (refreshService as any).keepNewDevicePending( + newDeviceSession, + 'qr-pending', + '新设备二维码待扫码', + 'new-device-qrcode-ready', + ); + + expect(qrcodeSession.expiresAt).toBe(now + 120_000); + expect(captcha.expiresAt).toBe(now + 900_000); + expect(newDevice.expiresAt).toBe(now + 900_000); + }); + it('recovers stale refresh preparation left by a restarted API pod', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', diff --git a/test/runtime/runtime-config.service.spec.ts b/test/runtime/runtime-config.service.spec.ts index 068f7e6..875ab56 100644 --- a/test/runtime/runtime-config.service.spec.ts +++ b/test/runtime/runtime-config.service.spec.ts @@ -132,6 +132,7 @@ describe('RuntimeConfigService', () => { QQBOT_NAPCAT_SSH_TARGET: 'nas', QQBOT_NAPCAT_SSH_PORT: '2202', QQBOT_NAPCAT_SSH_KEY_PATH: '/home/kt/.ssh/napcat', + NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS: '900000', QQBOT_NAPCAT_REVERSE_WS_BASE: 'ws://api.example.test/onebot', QQBOT_REVERSE_WS_PATH: '/qqbot/reverse', QQBOT_REVERSE_WS_TOKEN: 'qq-reverse-token', @@ -195,6 +196,13 @@ describe('RuntimeConfigService', () => { present: true, }), ); + expect(checks).toContainEqual( + expect.objectContaining({ + key: 'NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS', + level: 'optional', + present: true, + }), + ); expect(checks).toContainEqual( expect.objectContaining({ key: 'NAPCAT_WEBUI_BASE_URL|QQBOT_NAPCAT_WEBUI_URL',