From 98c8568184294aba933659bd6648b809730316e5 Mon Sep 17 00:00:00 2001 From: sunlei Date: Wed, 24 Jun 2026 09:41:23 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=88=B7=E6=96=B0=E4=B8=B4=E8=BF=91?= =?UTF-8?q?=E8=BF=87=E6=9C=9FNapCat=E7=99=BB=E5=BD=95=E4=BA=8C=E7=BB=B4?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 2 +- README.md | 4 +- ci/napcat-desktop-cn/README.md | 4 +- k8s/prod/api.yaml | 4 +- .../qqbot/core/contract/qqbot.types.ts | 2 + .../login/qqbot-napcat-login.service.ts | 133 ++++++++++++++++++ .../runtime/napcat-runtime-profile.service.ts | 2 +- .../napcat/napcat-desktop-cn-image.spec.ts | 8 +- .../napcat/runtime-protocol-profile.spec.ts | 2 +- .../qqbot-napcat-login.service.spec.ts | 118 ++++++++++++++++ 10 files changed, 266 insertions(+), 13 deletions(-) diff --git a/API.md b/API.md index 58cf9e7..c64886d 100644 --- a/API.md +++ b/API.md @@ -377,7 +377,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 该接口只返回脱敏后的运行态证据,供 Admin 排查镜像、locale、shm、配置 hash、漂移状态、风险模式和 watchdog 巡检告警状态;不会返回 WebUI token、reverse WS token、QQ 登录密码、SSH 私钥或运行态密码环境。账号列表只挂载 `napcat.profileStatus`、`napcat.runtimeProfile` 等摘要字段,不触发登录、重建或修复动作。watchdog 不执行登录恢复:遇到 QQ 登录态离线只记录离线原因并通知 `super`,登录恢复统一由 Admin 手动「更新登录」触发;session behavior profile 只做冷启动、housekeeping、presence 和自动能力分阶段降载,不实现账号级每小时/每日累计发送预算。 -NapCat Chinese Desktop Runtime v7 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,且只有明确二维码过期的 QR session failure 才自动换码,避免扫码确认期间被 `ErrType=1/ErrCode=1` 抢刷新打断。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v7` digest。 +NapCat Chinese Desktop Runtime v8 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,且只有明确二维码过期或扫码确认窗口失效的 QR session failure 才自动换码,避免扫码确认期间被 `ErrType=1/ErrCode=1` 抢刷新打断。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v8` digest。 `napcat_login_event` 实体和表仅作为历史 schema 兼容保留;watchdog 不再写入 quick/password 恢复事件,也不再依赖该表判断是否恢复登录。 diff --git a/README.md b/README.md index 1eda3db..b4fd9de 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ QQBot 插件定时任务由 manifest 的 `tasks` 声明,平台持久化到 `qq Admin 环境总览面板使用 `ENV_DASHBOARD_*` 只读配置聚合 local-dev、NAS 线上、腾讯云和 r4se 状态。`ENV_DASHBOARD_ADMIN_LOCAL_URL` / `ENV_DASHBOARD_ADMIN_PUBLIC_URL` 只用于展示 Admin 本机与线上入口证据。HTTP 快照提供当前拓扑,后端 local/MQTT 事件总线通过 SSE 推送增量事件给 Admin;前端不直连 MQTT,也不轮询刷新。Jenkins、K8s、Tencent Cloud、Caddy、WireGuard、Mihomo/OpenClash 未配置时会显示 `unwired` 证据,不能渲染成健康假象;第一版不暴露重启、部署、迁移、容器重建、插件启停或代理切换等写操作。 -NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布和账号闭环按 `docs/superpowers/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md` 的 Task 10 执行。当前实现覆盖运行态/协议/会话行为/历史登录事件兼容表/风险模式表,真实物理设备风格 hostname/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v7 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,且只有明确二维码过期的 QR session failure 才自动换码,避免扫码确认期间被 `ErrType=1/ErrCode=1` 抢刷新打断。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v7` digest。 +NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布和账号闭环按 `docs/superpowers/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md` 的 Task 10 执行。当前实现覆盖运行态/协议/会话行为/历史登录事件兼容表/风险模式表,真实物理设备风格 hostname/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v8 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,且只有明确二维码过期或扫码确认窗口失效的 QR session failure 才自动换码,避免扫码确认期间被 `ErrType=1/ErrCode=1` 抢刷新打断。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v8` digest。 ## 启动 @@ -165,7 +165,7 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow - QQBot 插件平台统一使用 `plugin.json` manifest 描述插件 key、版本、操作、事件、权限、运行预算和包入口;CLI 负责 create/validate/pack/install-local,后端只暴露受控 SDK 能力并通过插件维度记录安装、配置、账号绑定和运行事件。 - Bilibili Card 是事件型内置插件:`bilibili-card.message` 只在账号绑定后监听 QQ/NapCat `share/json/xml/lightapp` 卡片或文本里的 Bilibili 链接,`b23.tv` 短链通过平台 `resolveRedirect` 受控 host 能力解析,视频信息从 Bilibili `x/web-interface/view` 获取后回复纯文本摘要。 - 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 并单账号观察。`desktop-cn-v7` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复踢下线后原 native login service 不产新二维码、失败回调反复重置以及扫码确认期间非过期 QR failure 抢刷新打断登录的问题。 +- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。`desktop-cn-v8` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复踢下线后原 native login service 不产新二维码、失败回调反复重置以及扫码确认期间非过期 QR failure 抢刷新打断登录的问题。 - NapCat 账号新增/编辑支持可选 QQ 登录密码:Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。 - NapCat 容器为已知 `selfId` 创建/重建时会一次性注入 `ACCOUNT` 等必要 env;容器重启(崩溃/重启策略/宿主重启)可复用持久化会话,但硬踢 `登录已失效` 仍需人工登录。Admin「更新登录」不通过 Docker 重建、重启或补 env 刷新登录态:只要源容器在线,就保持同一容器并通过 NapCat WebUI `SetQuickLogin -> PasswordLogin -> RefreshQRcode/GetQQLoginQrcode` 推进原弹窗流程;只有 Docker 容器离线或缺失时,容器准备阶段才创建/重建并一次性注入 env。快速登录失败后,如果账号保存了登录密码,后端使用解密密码计算 MD5 调 `/api/QQLogin/PasswordLogin`,不会把密码写入运行态 env,也没有成功后的 env 清理步骤;密码登录按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果。准备中的扫码会话会续期,重复调用更新登录会复用同一 pending `sessionId`,不会再次启动 quick/password/二维码准备。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS` 后会自动恢复为普通状态检测。验证码和新设备验证保持同一会话 pending:腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`;状态轮询遇到验证码文案但缺少 URL 时会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。Admin SSE 步骤顺序按实际路径为 `quick-login-*` -> `password-login-*` / `password-login-captcha` / 新设备验证 -> `qrcode/waiting-scan` -> `login-success|login-failed`;SSE 事件缓存因 Pod 重启丢失时,新订阅会收到当前会话快照。 - NapCat 设备身份按账号持久化到 `napcat_device_identity`:同一账号重建容器会复用数据目录、`pc-<8hex>` hostname、machine-id 和实体 OUI 风格 MAC,明确排除 Docker `02:42`、QEMU/KVM `52:54:00`、VMware、Hyper-V 等虚拟化前缀;新增账号首次扫码会先用预留容器 id 创建临时设备身份并应用到第一次 Docker run,扫码成功后归属到真实账号并同步 runtime/protocol profile;Docker run 会注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id`,并同步写入 QQNT Linux `machine-info`,使 `/etc/machine-id`、Docker MAC 和 QQNT 本地设备缓存保持一致。当前策略名为 `qqnt-visible-hostname-v1` / `physical-oui-mac-v1`。 diff --git a/ci/napcat-desktop-cn/README.md b/ci/napcat-desktop-cn/README.md index fc65eee..d2ec27e 100644 --- a/ci/napcat-desktop-cn/README.md +++ b/ci/napcat-desktop-cn/README.md @@ -25,12 +25,12 @@ $baseImage = docker image inspect mlikiowa/napcat-docker:latest --format '{{inde if (-not $baseImage) { throw 'NapCat upstream image digest not found; pull and inspect the image before building.' } docker build ` --build-arg NAPCAT_BASE_IMAGE=$baseImage ` - -t kt-napcat-desktop-cn:desktop-cn-v7 ` + -t kt-napcat-desktop-cn:desktop-cn-v8 ` -f .kt-workspace/napcat-desktop-cn-build/ci/napcat-desktop-cn/Dockerfile ` .kt-workspace/napcat-desktop-cn-build $name = "kt-napcat-v6-verify-$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())" -docker run -d --name $name kt-napcat-desktop-cn:desktop-cn-v7 +docker run -d --name $name kt-napcat-desktop-cn:desktop-cn-v8 docker exec $name sh /ci/napcat-desktop-cn/verify.sh docker rm -f $name ``` diff --git a/k8s/prod/api.yaml b/k8s/prod/api.yaml index 551ae9b..5a9650c 100644 --- a/k8s/prod/api.yaml +++ b/k8s/prod/api.yaml @@ -33,9 +33,9 @@ spec: - name: DB_TIMEZONE value: "+08:00" - name: QQBOT_NAPCAT_IMAGE - value: kt-napcat-desktop-cn:desktop-cn-v7 + value: kt-napcat-desktop-cn:desktop-cn-v8 - name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION - value: desktop-cn-v7 + value: desktop-cn-v8 - name: QQBOT_NAPCAT_SSH_KEY_PATH value: /app/secrets/napcat-ssh/id_rsa - name: QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX diff --git a/src/modules/qqbot/core/contract/qqbot.types.ts b/src/modules/qqbot/core/contract/qqbot.types.ts index b951010..5e64479 100644 --- a/src/modules/qqbot/core/contract/qqbot.types.ts +++ b/src/modules/qqbot/core/contract/qqbot.types.ts @@ -29,6 +29,8 @@ export type NapcatLoginStatus = { isLogin?: boolean; isOffline?: boolean; loginError?: string; + qrcodeRevision?: number; + qrcodeUpdatedAt?: number; qrcodeurl?: string; }; 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 d21e464..e6dec3a 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 @@ -342,6 +342,8 @@ export class QqbotNapcatLoginService { } } if (Date.now() > session.expiresAt) { + const recovered = await this.recoverExpiredQrcodeSession(session); + if (recovered) return recovered; return this.expireSession(session); } if (session.preparingContainer) { @@ -418,6 +420,10 @@ export class QqbotNapcatLoginService { ); } + if (this.shouldRefreshNearlyExpiredQrcode(status)) { + return this.refreshNearlyExpiredQrcode(session, container, status); + } + session.errorMessage = status.loginError || undefined; if ( status.qrcodeurl && @@ -970,6 +976,57 @@ export class QqbotNapcatLoginService { await marker.call(this.accountService, selfId, qqLoginStatus, lastError); } + /** + * Reconciles an API-side QR TTL timeout with NapCat's current login state before marking the scan as expired. + * @param session - Pending scan session whose local `expiresAt` has elapsed; its current QR and container binding decide whether recovery is safe. + * @returns A pending or success result when NapCat still exposes a usable QR or has already logged in; otherwise undefined so the caller can expire the session. + */ + private async recoverExpiredQrcodeSession( + session: QqbotLoginScanSession, + ): Promise { + if (!session.qrcode || session.preparingContainer) return undefined; + if (typeof this.containerService.findRuntimeById !== 'function') { + return undefined; + } + + const container = await this.getSessionContainer(session); + let status: NapcatLoginStatus; + try { + status = await this.getLoginStatus(container); + } catch (err) { + if (!this.toolsService.isNapcatTemporaryError(err)) throw err; + return this.keepSessionPending( + session, + 'NapCat 正在确认二维码状态,请稍后', + true, + ); + } + + if (status.isLogin) { + this.renewSessionExpiry(session); + return this.completeLogin(session, container); + } + + await this.syncSessionQqLoginStatus(session, status); + if (this.toolsService.isNapcatExpiredQrcodeStatus(status)) { + session.errorMessage = status.loginError || session.errorMessage; + this.persistLoginSession(session); + return undefined; + } + + if (this.shouldRefreshNearlyExpiredQrcode(status)) { + return this.refreshNearlyExpiredQrcode(session, container, status); + } + + if (status.qrcodeurl) { + session.qrcode = status.qrcodeurl; + session.errorMessage = undefined; + return this.keepSessionPending(session, '等待扫码确认'); + } + + return undefined; + } + /** * Converts a NapCat WebUI login probe into the account-table QQ login status vocabulary. * @param status - Raw WebUI CheckLoginStatus payload returned by the current container. @@ -2099,6 +2156,82 @@ export class QqbotNapcatLoginService { }; } + /** + * Refreshes a QR code that is still present in NapCat but too close to the native QQ expiry window for human scanning. + * @param session - Pending scan session that would otherwise return the nearly expired QR to Admin. + * @param container - NapCat WebUI runtime used to request a fresh QR from the same login service. + * @param status - Latest WebUI login status containing the stale QR URL and its native update timestamp. + * @returns Pending scan result with a fresh QR when NapCat accepts refresh, or a pending no-QR result while refresh is still in progress. + */ + private async refreshNearlyExpiredQrcode( + session: QqbotLoginScanSession, + container: QqbotNapcatRuntime, + status: NapcatLoginStatus, + ) { + try { + session.qrcode = await this.refreshOrGetQrcode(container, false, { + fallbackStatus: status, + requireFresh: true, + staleQrcode: status.qrcodeurl, + }); + session.errorMessage = undefined; + this.publishScanResultEvent( + session, + 'qrcode-ready', + 'success', + '登录二维码已刷新', + ); + return this.toResult(session); + } catch (err) { + if (!this.toolsService.isNapcatTemporaryError(err)) throw err; + session.qrcode = undefined; + return this.keepSessionPending( + session, + '登录二维码即将过期,NapCat 正在重新生成二维码', + ); + } + } + + /** + * Determines whether a NapCat QR is close enough to QQ's native timeout that Admin should not show it for a new scan. + * @param status - WebUI status carrying `qrcodeUpdatedAt`; missing timestamps are treated as safe to avoid refreshing every legacy response. + * @returns True when the QR is present, not already expired, and has less than the configured safe remaining window. + */ + private shouldRefreshNearlyExpiredQrcode(status: NapcatLoginStatus) { + if ( + !status.qrcodeurl || + this.toolsService.isNapcatExpiredQrcodeStatus(status) + ) { + return false; + } + const updatedAt = Number(status.qrcodeUpdatedAt); + if (!Number.isFinite(updatedAt) || updatedAt <= 0) return false; + const ageMs = Date.now() - updatedAt; + return ageMs >= this.getNativeQrcodeTtlMs() - this.getQrcodeSafeScanMs(); + } + + /** + * Reads the expected native QQ QR lifetime used only for safe-display decisions. + * @returns Milliseconds before a QR is considered too old to show without refreshing. + */ + private getNativeQrcodeTtlMs() { + return this.getPositiveConfigNumber( + 'NAPCAT_LOGIN_NATIVE_QR_EXPIRE_MS', + 2 * 60 * 1000, + ); + } + + /** + * Reads the minimum QR lifetime that must remain before Admin is allowed to show an existing QR for manual scanning. + * @returns Milliseconds kept as human scanning/confirmation safety margin. + */ + private getQrcodeSafeScanMs() { + return this.getPositiveConfigNumber( + 'NAPCAT_LOGIN_QR_SAFE_SCAN_MS', + 45 * 1000, + ); + } + /** * 查询 NapCat 登录运行态数据。 * @param container - container 输入;限定 NapCat查询范围。 diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts index a240d82..9dba3bd 100644 --- a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts +++ b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts @@ -71,7 +71,7 @@ export class NapcatRuntimeProfileService { dataDir: input.dataDir, desktopProfileVersion: this.getString( 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION', - 'desktop-cn-v7', + 'desktop-cn-v8', ), deviceIdentityId: input.deviceIdentityId, imageRef: this.getString('QQBOT_NAPCAT_IMAGE', ''), diff --git a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts index bfa72b2..647d483 100644 --- a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts +++ b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts @@ -76,14 +76,14 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(verify).not.toContain('selfInfo?.online !== false'); }); - it('deploys the production API with the verified desktop-cn-v7 runtime profile', () => { + it('deploys the production API with the verified desktop-cn-v8 runtime profile', () => { const manifest = readSource('k8s/prod/api.yaml'); expect(manifest).toContain('name: QQBOT_NAPCAT_IMAGE'); - expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v7'); + expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v8'); expect(manifest).toContain('name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION'); - expect(manifest).toContain('value: desktop-cn-v7'); - expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v6'); + expect(manifest).toContain('value: desktop-cn-v8'); + expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v7'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v4'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v3'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v2'); diff --git a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts index e3ef096..cd68d42 100644 --- a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts +++ b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts @@ -123,7 +123,7 @@ describe('NapCat runtime profile generation', () => { }); expect(profile).toMatchObject({ - desktopProfileVersion: 'desktop-cn-v7', + desktopProfileVersion: 'desktop-cn-v8', imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest', locale: 'zh_CN.UTF-8', runtimeGid: 1101, diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index 59170e6..7bf1ec0 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -3375,6 +3375,124 @@ describe('QqbotNapcatLoginService', () => { ); }); + it('keeps an API-expired refresh session pending while NapCat still exposes the same live qrcode', async () => { + const loginSessionRepository = createLoginSessionRepository(); + const loginStateStore = new NapcatLoginStateStoreService( + loginSessionRepository as any, + ); + const expireService = new QqbotNapcatLoginService( + { + get: jest.fn((key: string) => { + if (key === 'NAPCAT_LOGIN_NATIVE_QR_EXPIRE_MS') return '120000'; + if (key === 'NAPCAT_LOGIN_QR_SAFE_SCAN_MS') return '45000'; + if (key === 'NAPCAT_LOGIN_QR_EXPIRE_MS') return '120000'; + return ''; + }), + } as unknown as ConfigService, + { + markQqLoginStatus: jest.fn().mockResolvedValue(undefined), + } as unknown as QqbotAccountService, + { + findRuntimeById: jest + .fn() + .mockResolvedValue({ id: 'container-expired-live' }), + removeUnboundContainer: jest.fn().mockResolvedValue(undefined), + } as unknown as QqbotNapcatContainerService, + new ToolsService(), + loginStateStore, + ); + const session = { + accountId: 'account-expired-live', + containerId: 'container-expired-live', + containerName: 'napcat-expired-live', + createdAt: Date.now() - 180_000, + expectedSelfId: '1914728559', + expiresAt: Date.now() - 1, + id: 'session-expired-live-qrcode', + mode: 'refresh', + qrcode: 'live-qrcode', + status: 'pending', + webuiPort: 6110, + }; + (expireService as any).sessions.set(session.id, session); + jest.spyOn(expireService as any, 'getLoginStatus').mockResolvedValue({ + isLogin: false, + qrcodeUpdatedAt: Date.now(), + qrcodeurl: 'live-qrcode', + }); + await loginStateStore.flushSessionWrites(session.id); + + const result = await expireService.status(session.id); + await loginStateStore.flushSessionWrites(session.id); + + expect(result.status).toBe('pending'); + expect(result.qrcode).toBe('live-qrcode'); + expect(result.expiresAt).toBeGreaterThan(Date.now()); + expect(loginSessionRepository.rows[0]).toEqual( + expect.objectContaining({ + sessionKey: session.id, + status: 'pending', + }), + ); + expect(loginSessionRepository.rows[0].completedAt).toBeNull(); + }); + + it('refreshes a qrcode that is too close to the native QQ expiry window', async () => { + const expiringService = new QqbotNapcatLoginService( + { + get: jest.fn((key: string) => { + if (key === 'NAPCAT_LOGIN_NATIVE_QR_EXPIRE_MS') return '120000'; + if (key === 'NAPCAT_LOGIN_QR_SAFE_SCAN_MS') return '45000'; + return ''; + }), + } as unknown as ConfigService, + { + markQqLoginStatus: jest.fn().mockResolvedValue(undefined), + } as unknown as QqbotAccountService, + { + findRuntimeById: jest + .fn() + .mockResolvedValue({ id: 'container-near-expiry' }), + } as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (expiringService as any).createSession({ + accountId: 'account-near-expiry', + container: { + id: 'container-near-expiry', + name: 'napcat-near-expiry', + webuiPort: 6110, + }, + expectedSelfId: '1914728559', + mode: 'refresh', + qrcode: 'old-qrcode', + status: 'pending', + }); + (expiringService as any).sessions.set(session.id, session); + jest.spyOn(expiringService as any, 'getLoginStatus').mockResolvedValue({ + isLogin: false, + qrcodeUpdatedAt: Date.now() - 90_000, + qrcodeurl: 'old-qrcode', + }); + jest + .spyOn(expiringService as any, 'refreshOrGetQrcode') + .mockResolvedValue('fresh-qrcode'); + + const result = await expiringService.status(session.id); + + expect(result.status).toBe('pending'); + expect(result.qrcode).toBe('fresh-qrcode'); + expect((expiringService as any).refreshOrGetQrcode).toHaveBeenCalledWith( + { id: 'container-near-expiry' }, + false, + { + fallbackStatus: expect.objectContaining({ qrcodeurl: 'old-qrcode' }), + requireFresh: true, + staleQrcode: 'old-qrcode', + }, + ); + }); + it('does not complete an expired refresh session from a delayed background relogin task', async () => { const loginSessionRepository = createLoginSessionRepository(); const loginStateStore = new NapcatLoginStateStoreService(