chore: 同步main到dev

This commit is contained in:
sunlei 2026-07-01 17:53:43 +08:00
commit 616346ccbc
31 changed files with 2013 additions and 101 deletions

8
API.md
View File

@ -149,6 +149,8 @@ QQBot 插件 worker 队列依赖 Redis。K8s 生产清单提供内部 Redis Serv
| `PUT` | `/system/user/:id` | 更新用户 | | `PUT` | `/system/user/:id` | 更新用户 |
| `DELETE` | `/system/user/:id` | 删除用户 | | `DELETE` | `/system/user/:id` | 删除用户 |
系统菜单实体包含 `sort` 字段;菜单树输出按 `meta.order` 优先,其次按 `sort` 升序排列。Admin 菜单管理页面维护 `sort`,不要把普通菜单排序写进隐藏的 route meta。
### Dict ### Dict
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
@ -361,13 +363,13 @@ 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` 账号保存支持可选 `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 请求完成;新增账号扫码会先预留容器和临时设备身份后立即返回 pending会话后台再启动远端 Docker 和生成二维码。已有账号的更新登录不会通过 Docker 重建、重启或补 env 来刷新 QQ 登录态;如果目标容器仍在线,即使 QQ 账号已离线,也会保持同一容器并通过 NapCat WebUI 依次调用 `SetQuickLogin`、`PasswordLogin`、`RefreshQRcode` / `GetQQLoginQrcode` 推进原有弹窗流程。只有 Docker 容器离线或缺失时,容器准备阶段才会创建/重建容器,并在创建时一次性注入 `ACCOUNT` 和必要登录 env已在线的源容器不补 env。快速登录失败后如果账号保存了登录密码后端使用解密后的密码计算 MD5 调用 `/api/QQLogin/PasswordLogin`,不会把密码写回运行态 env也没有成功后的 env 清理步骤;密码登录结果按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询。准备阶段的扫码会话会持续续期,避免后台登录未完成时前端先判过期。同一账号已有 pending 更新登录会话时,重复调用 `/qqbot/account/scan/refresh` 会返回原 `sessionId`,不会再次启动 quick/password/二维码准备。若 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 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。看门狗只做离线巡检、账号错误写入和 `super` 站内信告警,不会触发 quick/password 登录或扫码登录。 扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成;新增账号扫码会先预留容器和临时设备身份后立即返回 pending会话后台再启动远端 Docker 和生成二维码。`CheckLoginStatus.isLogin=true` 只表示 NapCat 登录阳性,新增账号必须继续等 `GetQQLoginInfo` 返回 `uin/selfId` 后才允许创建和绑定真实 QQ 号;短暂缺号时 `/qqbot/account/scan/status` 保持同一会话 pending 并显示正在读取 QQ 号,等待 `NAPCAT_LOGIN_SELF_ID_WAIT_MS`,不得重建容器、补 env 或从容器元数据猜号。已有账号的更新登录不会通过 Docker 重建、重启或补 env 来刷新 QQ 登录态;如果目标容器仍在线,即使 QQ 账号已离线,也会保持同一容器并通过 NapCat WebUI 推进原有弹窗流程。若 WebUI 明确返回 QQ 离线API 会先调用同容器 `/api/QQLogin/RestartNapCat` 重启 NapCat worker 以重建 QQCore login service再继续 `SetQuickLogin`、`PasswordLogin`、`RefreshQRcode` / `GetQQLoginQrcode`;这不是 Docker 容器重建/重启设备身份、env 和 dataDir 不变,同一个更新登录 session 只消费一次 worker restart 预算,后续轮询继续刷新二维码但不得反复重启 worker。只有 Docker 容器离线或缺失时,容器准备阶段才会创建/重建容器,并在创建时一次性注入 `ACCOUNT` 和必要登录 env已在线的源容器不补 env。快速登录失败后如果账号保存了登录密码后端使用解密后的密码计算 MD5 调用 `/api/QQLogin/PasswordLogin`,不会把密码写回运行态 env也没有成功后的 env 清理步骤;密码登录结果按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询。准备阶段的扫码会话会持续续期,避免后台登录未完成时前端先判过期。同一账号已有 pending 更新登录会话时,重复调用 `/qqbot/account/scan/refresh` 通常会返回原 `sessionId`,不会再次启动 quick/password/二维码准备;但当这条 pending 会话创建时账号还没有保存登录密码、且会话尚未进入密码验证码或新设备验证上下文而账号后来通过编辑维护了登录密码时API 必须退役旧无密码会话并新建 refresh session重新读取最新密码后进入 `PasswordLogin`。取消扫码会话必须在接口返回前把持久化 `napcat_login_session` 落到非 pending 终态并写入完成时间,避免已取消的测试二维码从 DB 恢复成可轮询会话。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码等待窗口加缓冲)后,`/qqbot/account/scan/status` 会自动恢复普通登录态检测,不再永久停留在“正在尝试密码登录”;`/qqbot/account/scan/events` 在进程内事件缓存丢失时会先推送当前会话快照。pending refresh 会话如果没有二维码、验证码或新设备挑战,`/qqbot/account/scan/status` 会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试 `RefreshQRcode/GetQQLoginQrcode`,避免 SSE 长时间卡在“二维码生成中”。密码登录触发 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 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。看门狗只做离线巡检、账号错误写入和 `super` 站内信告警,不会触发 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 进度文案包含快速登录、密码登录、验证码、新设备二维码、已扫码、确认中、二维码兜底、登录成功/失败。 密码验证码通过后如果 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 进度文案包含快速登录、密码登录、验证码、新设备二维码、已扫码、确认中、二维码兜底、登录成功/失败。
同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器后端会释放旧绑定和未共享的旧容器避免同账号多实例互相挤下线。OneBot notice 只有机器人下线、登录失效、`KickedOffLine` 等账号级信号才会记录 QQ 登录态异常并生成 `qqbot.account.offline` 站内信,普通群成员 kick 不属于账号离线信号。下线原因写入 `lastError` 前按 `last_error` 500 字符列宽截断;后续无错误的普通断连只更新 OneBot 连接状态,不清空该原因。账号列表会按近期缓存检查绑定 NapCat 容器的最新登录状态日志,日志检测默认 5 秒超时;`isOnline:false` 属于 QQ 登录态离线信号;心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态。托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像。 同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器后端会释放旧绑定和未共享的旧容器避免同账号多实例互相挤下线。OneBot notice 只有机器人下线、登录失效、`KickedOffLine` 等账号级信号才会记录 QQ 登录态异常并生成 `qqbot.account.offline` 站内信,普通群成员 kick 不属于账号离线信号。下线原因写入 `lastError` 前按 `last_error` 500 字符列宽截断;后续无错误的普通断连只更新 OneBot 连接状态,不清空该原因。账号列表会按近期缓存检查绑定 NapCat 容器的最新登录状态日志,日志检测默认 5 秒超时;`isOnline:false` 属于 QQ 登录态离线信号;心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态。托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像。
托管 NapCat 容器按账号持久化设备身份,`napcat_device_identity` 保存账号对应的数据目录、hostname、machine-id 路径、MAC 地址、验证状态和最近登录证据。重建同一账号容器时会复用 `pc-<8hex>` hostname、实体 OUI 风格 MAC 和 machine-id并明确排除 Docker `02:42`、QEMU/KVM `52:54:00`、VMware、Hyper-V 等虚拟化前缀;新增账号创建期在真实 QQ selfId 未知时使用预留容器 id 创建临时设备身份,第一次 Docker run 就注入完整拟真参数,扫码成功后再把该身份和 runtime/protocol profile 归属到真实账号。Docker run 会注入 `--hostname`、`--mac-address` 和只读 `/etc/machine-id` 挂载;后端还会同步写入 QQNT Linux `machine-info`,让 QQNT 计算 GUID 时使用的 MAC 与 Docker 网卡一致。当前策略名为 `qqnt-visible-hostname-v1` / `physical-oui-mac-v1`,绑定关系会回填 `napcat_account_binding.device_identity_id` 托管 NapCat 容器按账号持久化设备身份,`napcat_device_identity` 保存账号对应的数据目录、hostname、machine-id 路径、MAC 地址、验证状态和最近登录证据。重建同一账号容器时会复用 `pc-<8hex>` hostname、实体 OUI 风格 MAC 和 machine-id并明确排除 Docker `02:42`、QEMU/KVM `52:54:00`、VMware、Hyper-V 等虚拟化前缀;新增账号创建期在真实 QQ selfId 未知时使用预留容器 id 创建临时设备身份,第一次 Docker run 就注入完整拟真参数,扫码成功后再把该身份和 runtime/protocol profile 归属到真实账号。Docker run 会注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id` 挂载、`SYS_ADMIN`、`apparmor=unconfined`、`seccomp=unconfined` 和 `NAPCAT_REQUIRE_DEVICE_PROFILE=1`;后端还会同步写入 QQNT Linux `machine-info`,让 QQNT 计算 GUID 时使用的 MAC 与 Docker 网卡一致。派生镜像 entrypoint 会用同一设备 profile 覆盖 QQCore 实际打开的 DMI、boot_id、kernel release/version/proc version、CPU model、uptime、TTY active、mountinfo、`/etc/hosts` 和 `/proc/devices` 等探针NapCat fork native login 和 core session config 的 `machineId``systemVersion` 也从该 profile 读取,避免 QQ native 入参和 Docker 可见探针不一致。当前策略名为 `qqnt-visible-hostname-v1` / `physical-oui-mac-v1`,绑定关系会回填 `napcat_account_binding.device_identity_id`
### NapCat Runtime Profile ### NapCat Runtime Profile
@ -377,7 +379,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 和自动能力分阶段降载,不实现账号级每小时/每日累计发送预算。 该接口只返回脱敏后的运行态证据,供 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 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。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v8` / `desktop-cn-v8` 安全默认值Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE``QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile或清空两个 override 后重新部署回 manifest 默认值。 NapCat Chinese Desktop Runtime 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service。v14 起运行时会为 QQ/NapCat/Xvfb 长期进程做 PID 级 `/proc/<pid>/mountinfo` 遮蔽,避免 QQCore 通过 `/proc/self/mountinfo` 看到 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances`、`btrfs`、`/dev/mapper/trim` 等宿主路径v15 在扫码登录成功回调中先写入 `QQLoginInfo` 再写登录态,避免 API 读到 `isLogin=true` 但 QQ 号为空的短暂不一致v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛v17/v18 增加 WebUI 鉴权的 `/api/Debug/RuntimeViewProbe` 同进程诊断并修正 native maps 截断导致的 hook 证据假阴性v19 保留 WebUI `RestartNapCat` 重启 worker 时的 `-q <uin>` 快速登录参数避免重启后退回无账号扫码v20 保护 API 预写的 `/app/napcat/config`,避免上游首次解包 `NapCat.Shell/*` 覆盖 `bypass.*=true``o3HookMode=0`。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:*` digest。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v20` / `desktop-cn-v20` 稳定默认值Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE``QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile或清空两个 override 后重新部署回 manifest 默认值。
API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,且 `fork-artifact.json` 必须包含完整 marker metadataupstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。NapCat base image 在 release evidence 中必须 pin 到 digest。API Jenkins 只做显式参数推广,不负责自动合并上游、自动构建运行时镜像或在 override 为空时隐式改写 NapCat env。 API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,且 `fork-artifact.json` 必须包含完整 marker metadataupstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。NapCat base image 在 release evidence 中必须 pin 到 digest。API Jenkins 只做显式参数推广,不负责自动合并上游、自动构建运行时镜像或在 override 为空时隐式改写 NapCat env。

View File

@ -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` 证据,不能渲染成健康假象;第一版不暴露重启、部署、迁移、容器重建、插件启停或代理切换等写操作。 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/MACNapCat/OneBot 配置 hashKT `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。`k8s/prod/api.yaml` 保留 `desktop-cn-v8` 安全默认值Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE``QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile或清空两个 override 后重新部署 manifest 默认值。 NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布和账号闭环按 `docs/superpowers/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md` 的 Task 10 执行。当前实现覆盖运行态/协议/会话行为/历史登录事件兼容表/风险模式表,真实物理设备风格 hostname/MACNapCat/OneBot 配置 hashKT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v20 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact并在 QQ `KickedOffLine`标记 native login service staleAPI 在源 Docker 容器在线但 WebUI 明确 QQ 离线时会同容器调用 `RestartNapCat` 重启 NapCat worker重建 QQCore login service 后再推进 quick/password/qrcode不做 Docker 重建、补 env 或设备身份迁移,且同一个更新登录 session 只消费一次 worker restart 预算v14 起还会对 QQ/NapCat/Xvfb 长期进程的 `/proc/<pid>/mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露v15 修复扫码成功时 `QQLoginInfo` 晚于登录态写入造成的 QQ 号回读空窗v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛v17/v18 增加 WebUI 鉴权的 `/api/Debug/RuntimeViewProbe` 同进程诊断并修正 native maps 截断导致的 hook 证据假阴性v19 保留 WebUI `RestartNapCat` 重启 worker 时的 `-q <uin>` 快速登录参数避免重启后退回无账号扫码v20 保护 API 预写的 `/app/napcat/config`,避免上游首次解包 `NapCat.Shell/*` 覆盖 `bypass.*=true``o3HookMode=0`。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v20` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v20` 稳定默认值Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE``QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile或清空两个 override 后重新部署 manifest 默认值。
运行时发布时API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,`fork-artifact.json` 必须带完整 marker metadata包括 upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。release evidence 里的 NapCat base image 必须用 digest pin。API Jenkins 只消费人工确认后的运行时推广参数,不自动合并上游、不自动构建隐藏镜像,也不在 override 为空时覆盖 K8s manifest 中的默认 env。 运行时发布时API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,`fork-artifact.json` 必须带完整 marker metadata包括 upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。release evidence 里的 NapCat base image 必须用 digest pin。API Jenkins 只消费人工确认后的运行时推广参数,不自动合并上游、不自动构建隐藏镜像,也不在 override 为空时覆盖 K8s manifest 中的默认 env。
@ -171,16 +171,16 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow
- WordPress 自动登录失败不会阻断 Admin 主登录,会通过菜单和权限码过滤不可用的 Blog 管理入口。 - WordPress 自动登录失败不会阻断 Admin 主登录,会通过菜单和权限码过滤不可用的 Blog 管理入口。
- 系统日志由 pino 输出Loki 查询统一通过后端 `/system/logs/*` 代理,前端不直连 Loki。 - 系统日志由 pino 输出Loki 查询统一通过后端 `/system/logs/*` 代理,前端不直连 Loki。
- 日志级站内信只承接运行期事件:接口 5xx、QQBot 下线 notice、NapCat 容器最新离线日志会自动聚合通知 `super` 角色;服务端强制 `super` 访问Admin 不再暴露人工新增/编辑入口;长路径接口错误会压缩 `dedupeKey/title` 到表字段长度内,避免通知入库失败。 - 日志级站内信只承接运行期事件:接口 5xx、QQBot 下线 notice、NapCat 容器最新离线日志会自动聚合通知 `super` 角色;服务端强制 `super` 访问Admin 不再暴露人工新增/编辑入口;长路径接口错误会压缩 `dedupeKey/title` 到表字段长度内,避免通知入库失败。
- QQBot 扫码登录通过 SSE `/qqbot/account/scan/events` 暴露进度,耗时链路不应阻塞普通 HTTP 响应;新增账号扫码会先返回 pending `sessionId`,后台再创建 NapCat 容器并生成二维码。 - QQBot 扫码登录通过 SSE `/qqbot/account/scan/events` 暴露进度,耗时链路不应阻塞普通 HTTP 响应;新增账号扫码会先返回 pending `sessionId`,后台再创建 NapCat 容器并生成二维码。`CheckLoginStatus.isLogin=true` 只代表 NapCat 登录阳性,创建账号必须继续等 `GetQQLoginInfo` 返回 `uin/selfId` 后才绑定真实 QQ 号;短暂缺号时会话保持 pending 并显示正在读取 QQ 号,不能重建容器或猜号。
- QQBot 外发统一走发送排队:默认全局间隔 `2500ms`、同会话间隔 `8000ms`、排队抖动 `0-800ms`,超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS` 时拒绝本次发送,避免高频自动回复形成突发流量。 - QQBot 外发统一走发送排队:默认全局间隔 `2500ms`、同会话间隔 `8000ms`、排队抖动 `0-800ms`,超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS` 时拒绝本次发送,避免高频自动回复形成突发流量。
- QQBot 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。 - QQBot 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。
- QQBot 复读机默认阈值为 4同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。 - QQBot 复读机默认阈值为 4同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。
- QQBot 插件平台统一使用 `plugin.json` manifest 描述插件 key、版本、操作、事件、权限、运行预算和包入口CLI 负责 create/validate/pack/install-local后端只暴露受控 SDK 能力并通过插件维度记录安装、配置、账号绑定和运行事件。 - 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` 获取后回复纯文本摘要。 - 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` - 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-v8` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch并修复踢下线后原 native login service 不产新二维码、失败回调反复重置以及扫码确认期间非过期 QR failure 抢刷新打断登录的问题 - NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。`desktop-cn-v20` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch并修复非自动重试 QR failure 后下次 WebUI 登录动作不重置、QQCore 通过进程级 mountinfo 探针看到 Docker/宿主路径、扫码登录成功后 API 立即读不到 QQ 号、生产 native reset 缺少 `offline()` 时半登录态无法清理、runtime view native maps 取证假阴性、WebUI `RestartNapCat` 重启 worker 丢失快速登录账号参数,以及首次解包覆盖 API 预写 NapCat config 导致 bypass 开关回落默认关闭的问题。踢下线后的半登录态不能只靠旧 native reset 兜底;源 Docker 容器在线时 API 会先同容器 `RestartNapCat` 重建 NapCat worker再继续登录流程同一个更新登录 session 不能反复重启 worker
- NapCat 账号新增/编辑支持可选 QQ 登录密码Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。 - 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 容器为已知 `selfId` 创建/重建时会一次性注入 `ACCOUNT` 等必要 env容器重启崩溃/重启策略/宿主重启)可复用持久化会话,但硬踢 `登录已失效` 仍需人工登录。Admin「更新登录」不通过 Docker 重建、重启或补 env 刷新登录态:只要源容器在线,就保持同一 Docker 容器;若 WebUI 明确返回 QQ 离线,会先调用同容器 `/api/QQLogin/RestartNapCat` 重启 NapCat worker随后通过 NapCat WebUI `SetQuickLogin -> PasswordLogin -> RefreshQRcode/GetQQLoginQrcode` 推进原弹窗流程;同一个更新登录 session 只允许一次 worker restart后续状态轮询只能继续 WebUI 登录/二维码刷新,不能反复重启 worker。只有 Docker 容器离线或缺失时,容器准备阶段才创建/重建并一次性注入 env。快速登录失败后如果账号保存了登录密码后端使用解密密码计算 MD5 调 `/api/QQLogin/PasswordLogin`,不会把密码写入运行态 env也没有成功后的 env 清理步骤;密码登录按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果。准备中的扫码会话会续期,重复调用更新登录会复用同一 pending `sessionId`,不会再次启动 quick/password/二维码准备;但如果该 pending 会话是在账号维护登录密码前创建、且尚未进入密码/验证码/新设备上下文,后续更新登录必须退役这条无密码会话并新建 refresh session以重新读取账号表中的最新密码。取消扫码会话会在接口返回前把持久化 session 标成 `error/cancelled` 并写完成时间,避免已取消二维码从 DB 恢复为 active pending。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS` 后会自动恢复为普通状态检测。pending refresh 会话没有二维码、验证码或新设备挑战时,状态轮询会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试二维码刷新,避免 SSE 长时间停在生成中。验证码和新设备验证保持同一会话 pending腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`;状态轮询遇到验证码文案但缺少 URL 时会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底。扫码或密码链路拿到 NapCat 登录阳性但 QQ 号暂不可读时,会按 `NAPCAT_LOGIN_SELF_ID_WAIT_MS` 保持 pending超过窗口才失败。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 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 profileDocker 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` - 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 profileDocker 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`
- NapCat 新设备验证走同一 scan session`CaptchaLogin` 返回 `needNewDevice` 后,后端继续调用 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`Admin/SSE 分开展示 `captchaUrl`、`newDeviceQrcode`、已扫码、确认中、验证成功、登录成功/失败等中文进度,不把 `jumpUrl` 当作唯一完成入口。 - 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` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后只写入离线原因并复用 `super` 站内信告警,恢复登录必须由管理员在 Admin 手动触发「更新登录」。 - NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000``QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后只写入离线原因并复用 `super` 站内信告警,恢复登录必须由管理员在 Admin 手动触发「更新登录」。

View File

@ -30,7 +30,11 @@ RUN set -eux; \
sha256sum /app/NapCat.Shell.zip | awk '{print $1}' > /ci/napcat-desktop-cn/NapCat.Shell.zip.sha256; \ sha256sum /app/NapCat.Shell.zip | awk '{print $1}' > /ci/napcat-desktop-cn/NapCat.Shell.zip.sha256; \
rm -rf /tmp/NapCat.Shell rm -rf /tmp/NapCat.Shell
COPY ci/napcat-desktop-cn/verify.sh /ci/napcat-desktop-cn/verify.sh COPY ci/napcat-desktop-cn/verify.sh /ci/napcat-desktop-cn/verify.sh
RUN sed -i 's/\r$//' /ci/napcat-desktop-cn/verify.sh && chmod +x /ci/napcat-desktop-cn/verify.sh COPY ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh /ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh
RUN set -eux; \
sed -i 's/\r$//' /ci/napcat-desktop-cn/verify.sh /ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh; \
chmod +x /ci/napcat-desktop-cn/verify.sh /ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh; \
/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh
ENV LANG=zh_CN.UTF-8 \ ENV LANG=zh_CN.UTF-8 \
LC_ALL=zh_CN.UTF-8 \ LC_ALL=zh_CN.UTF-8 \

View File

@ -43,14 +43,21 @@ Build and verify:
```powershell ```powershell
docker build ` docker build `
--build-arg NAPCAT_BASE_IMAGE=$napcatBaseImageDigest ` --build-arg NAPCAT_BASE_IMAGE=$napcatBaseImageDigest `
-t kt-napcat-desktop-cn:desktop-cn-v8 ` -t kt-napcat-desktop-cn:desktop-cn-v16 `
-f .kt-workspace/napcat-desktop-cn-build/ci/napcat-desktop-cn/Dockerfile ` -f .kt-workspace/napcat-desktop-cn-build/ci/napcat-desktop-cn/Dockerfile `
.kt-workspace/napcat-desktop-cn-build .kt-workspace/napcat-desktop-cn-build
$name = "kt-napcat-v8-verify-$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())" $name = "kt-napcat-v16-verify-$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())"
docker run -d --name $name kt-napcat-desktop-cn:desktop-cn-v8 docker run -d --name $name `
--cap-add SYS_ADMIN `
--security-opt apparmor=unconfined `
--security-opt seccomp=unconfined `
-e NAPCAT_REQUIRE_DEVICE_PROFILE=1 `
kt-napcat-desktop-cn:desktop-cn-v16
docker exec $name sh /ci/napcat-desktop-cn/verify.sh docker exec $name sh /ci/napcat-desktop-cn/verify.sh
docker rm -f $name docker rm -f $name
``` ```
`verify.sh` checks long-lived QQ/NapCat/Xvfb process `/proc/<pid>/mountinfo` after the entrypoint guard has had a bounded convergence window. If a target process still exposes host paths after `MOUNTINFO_GUARD_TIMEOUT_SECONDS`, verification exits with 78 and prints the target PID, `comm`, `cmdline`, and first leaked mountinfo line.
Record the final image digest in `QQBOT_NAPCAT_IMAGE`, and keep the matching `fork-artifact.json` with the release evidence. For local Windows rehearsal, placeholder `$upstreamReleaseTag`, `$upstreamReleaseCommit`, and `$jenkinsBuildUrl` values are acceptable only if the image is not promoted. Record the final image digest in `QQBOT_NAPCAT_IMAGE`, and keep the matching `fork-artifact.json` with the release evidence. For local Windows rehearsal, placeholder `$upstreamReleaseTag`, `$upstreamReleaseCommit`, and `$jenkinsBuildUrl` values are acceptable only if the image is not promoted.

View File

@ -0,0 +1,266 @@
#!/bin/sh
set -eu
ENTRYPOINT=/app/entrypoint.sh
if grep -q 'KT device profile patch defaults' "$ENTRYPOINT" \
&& grep -q 'KT preserve mounted NapCat config' "$ENTRYPOINT"; then
exit 0
fi
if ! grep -q 'KT preserve mounted NapCat config' "$ENTRYPOINT"; then
grep -Fq 'cp -rf NapCat.Shell/* napcat/' "$ENTRYPOINT"
awk '
/cp -rf NapCat\.Shell\/\* napcat\// {
print " # KT preserve mounted NapCat config."
print " find NapCat.Shell -mindepth 1 -maxdepth 1 ! -name config -exec cp -rf {} napcat/ \\;"
print " if [ ! -f \"napcat/config/napcat.json\" ]; then"
print " mkdir -p napcat/config"
print " cp -rf NapCat.Shell/config/* napcat/config/"
print " fi"
next
}
{ print }
' "$ENTRYPOINT" > "$ENTRYPOINT.tmp"
mv "$ENTRYPOINT.tmp" "$ENTRYPOINT"
chmod +x "$ENTRYPOINT"
fi
if grep -q 'KT device profile patch defaults' "$ENTRYPOINT"; then
exit 0
fi
grep -q 'mount --bind "$FAKE_CMDLINE" /proc/1/cmdline' "$ENTRYPOINT"
cat >/tmp/kt-device-profile-defaults.sh <<'EOF'
# KT device profile patch defaults.
: "${NAPCAT_REQUIRE_DEVICE_PROFILE:=0}"
: "${NAPCAT_DMI_PRODUCT_NAME:=imini Pro}"
: "${NAPCAT_DMI_PRODUCT_UUID:=00000000-0000-0000-0000-000000000000}"
: "${NAPCAT_DMI_SYS_VENDOR:=MECHREVO}"
: "${NAPCAT_DMI_BOARD_VENDOR:=MECHREVO}"
: "${NAPCAT_DMI_BOARD_NAME:=imini Pro}"
: "${NAPCAT_DMI_BIOS_VENDOR:=American Megatrends International, LLC.}"
: "${NAPCAT_DMI_BIOS_VERSION:=imini Pro 1.10}"
: "${NAPCAT_DMI_MODALIAS:=dmi:bvnAmericanMegatrendsInternational,LLC.:bvriminiPro1.10:bd03/31/2024:br1.10:efr1.10:svnMECHREVO:pniminiPro:pvrStandard:rvnMECHREVO:rniminiPro:rvrStandard:cvnMECHREVO:ct3:cvrDefaultstring:skuStandard:}"
: "${NAPCAT_DEVICE_BOOT_ID:=00000000-0000-4000-8000-000000000000}"
: "${NAPCAT_DEVICE_KERNEL_RELEASE:=6.8.0-60-generic}"
: "${NAPCAT_DEVICE_KERNEL_VERSION:=#63-Ubuntu SMP PREEMPT_DYNAMIC Tue Apr 15 19:04:15 UTC 2025}"
: "${NAPCAT_DEVICE_PROC_VERSION:=Linux version 6.8.0-60-generic (buildd@lcy02-amd64-001) (x86_64-linux-gnu-gcc (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0, GNU ld (GNU Binutils for Ubuntu) 2.42) #63-Ubuntu SMP PREEMPT_DYNAMIC Tue Apr 15 19:04:15 UTC 2025}"
: "${NAPCAT_DEVICE_CPU_MODEL:=AMD Ryzen 7 8845H w/ Radeon 780M Graphics}"
: "${NAPCAT_DEVICE_UPTIME:=7200.00 14400.00}"
: "${NAPCAT_DEVICE_TTY_ACTIVE:=tty1}"
: "${NAPCAT_DEVICE_MOUNTINFO_GUARD_ENABLED:=1}"
: "${NAPCAT_DEVICE_MOUNTINFO_GUARD_INTERVAL:=1}"
kt_fake_file() {
kt_target="$1"
kt_content="$2"
kt_name="$(echo "$kt_target" | tr '/' '_')"
kt_fake="$FAKE_CGROUP_DIR/kt_device_profile_$kt_name"
printf '%s\n' "$kt_content" > "$kt_fake"
mount --bind "$kt_fake" "$kt_target" 2>/dev/null || true
}
EOF
cat >/tmp/kt-device-profile-dmi.sh <<'EOF'
# KT device profile DMI overrides for files QQCore opens.
kt_fake_file /sys/class/dmi/id/product_name "$NAPCAT_DMI_PRODUCT_NAME"
kt_fake_file /sys/class/dmi/id/product_uuid "$NAPCAT_DMI_PRODUCT_UUID"
kt_fake_file /sys/class/dmi/id/sys_vendor "$NAPCAT_DMI_SYS_VENDOR"
kt_fake_file /sys/class/dmi/id/board_vendor "$NAPCAT_DMI_BOARD_VENDOR"
kt_fake_file /sys/class/dmi/id/board_name "$NAPCAT_DMI_BOARD_NAME"
kt_fake_file /sys/class/dmi/id/bios_vendor "$NAPCAT_DMI_BIOS_VENDOR"
kt_fake_file /sys/class/dmi/id/bios_version "$NAPCAT_DMI_BIOS_VERSION"
kt_fake_file /sys/class/dmi/id/modalias "$NAPCAT_DMI_MODALIAS"
EOF
cat >/tmp/kt-device-profile-proc.sh <<'EOF'
# KT device profile proc/sys files QQCore opens.
kt_fake_file /proc/sys/kernel/random/boot_id "$NAPCAT_DEVICE_BOOT_ID"
kt_fake_file /proc/sys/kernel/version "$NAPCAT_DEVICE_KERNEL_VERSION"
kt_fake_file /proc/version "$NAPCAT_DEVICE_PROC_VERSION"
kt_fake_file /proc/uptime "$NAPCAT_DEVICE_UPTIME"
if [ -f /proc/cpuinfo ]; then
KT_FAKE_CPUINFO="$FAKE_CGROUP_DIR/kt_device_profile_proc_cpuinfo"
awk -v model="$NAPCAT_DEVICE_CPU_MODEL" '
/^model name[[:space:]]*:/ { $0 = "model name\t: " model }
/^flags[[:space:]]*:/ {
gsub(/(^| )hypervisor( |$)/, " ")
gsub(/[[:space:]]+/, " ")
}
{ print }
' /proc/cpuinfo > "$KT_FAKE_CPUINFO" 2>/dev/null || true
mount --bind "$KT_FAKE_CPUINFO" /proc/cpuinfo 2>/dev/null || true
fi
if [ -f /proc/devices ]; then
KT_FAKE_DEVICES="$FAKE_CGROUP_DIR/kt_device_profile_proc_devices"
grep -Ev 'trim-trashbin|zvol' /proc/devices > "$KT_FAKE_DEVICES" 2>/dev/null || true
mount --bind "$KT_FAKE_DEVICES" /proc/devices 2>/dev/null || true
fi
if [ -f /sys/devices/virtual/tty/tty0/active ]; then
kt_fake_file /sys/devices/virtual/tty/tty0/active "$NAPCAT_DEVICE_TTY_ACTIVE"
fi
if [ -f /etc/hosts ]; then
KT_FAKE_HOSTS="$FAKE_CGROUP_DIR/kt_device_profile_hosts"
{
printf '127.0.0.1\tlocalhost\n'
printf '::1\tlocalhost ip6-localhost ip6-loopback\n'
printf 'fe00::\tip6-localnet\n'
printf 'ff00::\tip6-mcastprefix\n'
printf 'ff02::1\tip6-allnodes\n'
printf 'ff02::2\tip6-allrouters\n'
} > "$KT_FAKE_HOSTS"
mount --bind "$KT_FAKE_HOSTS" /etc/hosts 2>/dev/null || true
fi
# KT device profile mountinfo guard for long-lived QQ/NapCat processes.
KT_MOUNTINFO_HOST_LEAK_PATTERN='overlay|/vol1/docker|docker-init|/docker/containers|napcat-instances|btrfs|/dev/mapper/trim'
KT_FAKE_MOUNTINFO="$FAKE_CGROUP_DIR/kt_device_profile_mountinfo"
cat > "$KT_FAKE_MOUNTINFO" <<'KT_MOUNTINFO_EOF'
22 1 259:2 / / rw,relatime - ext4 /dev/nvme0n1p2 rw,errors=remount-ro
23 22 0:6 / /proc rw,nosuid,nodev,noexec,relatime - proc proc rw
24 22 0:22 / /dev rw,nosuid,relatime - tmpfs udev rw,size=4096000k,nr_inodes=1024000,mode=755,inode64
25 24 0:23 / /dev/pts rw,nosuid,noexec,relatime - devpts devpts rw,gid=5,mode=620,ptmxmode=000
26 22 0:24 / /sys rw,nosuid,nodev,noexec,relatime - sysfs sysfs rw
27 22 0:25 / /run rw,nosuid,nodev,relatime - tmpfs tmpfs rw,mode=755,inode64
28 22 259:2 /home/napcat/.config /app/.config rw,relatime - ext4 /dev/nvme0n1p2 rw,errors=remount-ro
29 22 259:2 /home/napcat/.cache /app/.cache rw,relatime - ext4 /dev/nvme0n1p2 rw,errors=remount-ro
30 22 259:2 /home/napcat/.local/share /app/.local/share rw,relatime - ext4 /dev/nvme0n1p2 rw,errors=remount-ro
KT_MOUNTINFO_EOF
kt_mask_mountinfo_target() {
kt_mountinfo="$1"
if [ -f "$kt_mountinfo" ] && grep -E "$KT_MOUNTINFO_HOST_LEAK_PATTERN" "$kt_mountinfo" >/dev/null 2>&1; then
mount --bind "$KT_FAKE_MOUNTINFO" "$kt_mountinfo" 2>/dev/null || true
fi
}
kt_is_mountinfo_target() {
kt_target_comm="$1"
kt_target_cmdline="$2"
kt_target_argv0="${kt_target_cmdline%% *}"
kt_target_rest="${kt_target_cmdline#* }"
if [ "$kt_target_rest" = "$kt_target_cmdline" ]; then
kt_target_rest=""
fi
kt_target_argv1="${kt_target_rest%% *}"
kt_target_argv0_base="${kt_target_argv0##*/}"
case "$kt_target_comm" in
qq|QQ|NapCat|napcat|Xvfb)
return 0
;;
esac
case "$kt_target_argv0_base" in
qq|QQ|NapCat|napcat|Xvfb)
return 0
;;
esac
case "$kt_target_argv0" in
/opt/QQ/*|/app/napcat/*|/app/NapCat*)
return 0
;;
esac
case "$kt_target_argv1" in
/app/napcat/*|/app/NapCat*|*/NapCat.Shell*|*/napcat.mjs*)
return 0
;;
esac
return 1
}
kt_mountinfo_guard_once() {
for kt_mountinfo_pid_dir in /proc/[0-9]*; do
[ -d "$kt_mountinfo_pid_dir" ] || continue
kt_mountinfo_pid="${kt_mountinfo_pid_dir#/proc/}"
kt_mountinfo_comm="$(cat "$kt_mountinfo_pid_dir/comm" 2>/dev/null || true)"
kt_mountinfo_cmdline="$(tr '\000' ' ' < "$kt_mountinfo_pid_dir/cmdline" 2>/dev/null || true)"
if kt_is_mountinfo_target "$kt_mountinfo_comm" "$kt_mountinfo_cmdline"; then
kt_mask_mountinfo_target "/proc/$kt_mountinfo_pid/mountinfo"
fi
done
}
kt_mountinfo_guard_loop() {
while :; do
kt_mountinfo_guard_once
sleep "${NAPCAT_DEVICE_MOUNTINFO_GUARD_INTERVAL:-1}"
done
}
kt_mask_mountinfo_target /proc/1/mountinfo
kt_mask_mountinfo_target /proc/self/mountinfo
if [ "${NAPCAT_DEVICE_MOUNTINFO_GUARD_ENABLED:-1}" = "1" ]; then
kt_mountinfo_guard_loop >/dev/null 2>&1 &
fi
EOF
cat >/tmp/kt-device-profile-verify.sh <<'EOF'
if [ "${NAPCAT_REQUIRE_DEVICE_PROFILE:-0}" = "1" ]; then
kt_require_device_profile() {
kt_device_profile_label="$1"
shift
"$@" || {
echo "NapCat device profile check failed: $kt_device_profile_label" >&2
exit 78
}
}
kt_require_device_profile proc1-cmdline sh -c "tr '\\000' ' ' </proc/1/cmdline | grep -q '/sbin/init'"
kt_require_device_profile proc1-sched grep -q 'systemd' /proc/1/sched
kt_require_device_profile proc1-status grep -q '^NSpid:[[:space:]]*1' /proc/1/status
kt_require_device_profile proc1-stat grep -q '(systemd)' /proc/1/stat
kt_require_device_profile dmi-product grep -q "$NAPCAT_DMI_PRODUCT_NAME" /sys/class/dmi/id/product_name
kt_require_device_profile dmi-product-uuid grep -q "$NAPCAT_DMI_PRODUCT_UUID" /sys/class/dmi/id/product_uuid
kt_require_device_profile dmi-sys-vendor grep -q "$NAPCAT_DMI_SYS_VENDOR" /sys/class/dmi/id/sys_vendor
kt_require_device_profile dmi-bios-vendor grep -q "$NAPCAT_DMI_BIOS_VENDOR" /sys/class/dmi/id/bios_vendor
kt_require_device_profile dmi-bios-version grep -q "$NAPCAT_DMI_BIOS_VERSION" /sys/class/dmi/id/bios_version
kt_require_device_profile dmi-modalias grep -q "$NAPCAT_DMI_MODALIAS" /sys/class/dmi/id/modalias
kt_require_device_profile boot-id grep -q "$NAPCAT_DEVICE_BOOT_ID" /proc/sys/kernel/random/boot_id
kt_require_device_profile kernel-version grep -q "$NAPCAT_DEVICE_KERNEL_VERSION" /proc/sys/kernel/version
kt_require_device_profile proc-version grep -q "$NAPCAT_DEVICE_KERNEL_RELEASE" /proc/version
kt_require_device_profile proc-uptime grep -q "$NAPCAT_DEVICE_UPTIME" /proc/uptime
kt_require_device_profile proc-cpuinfo grep -q "$NAPCAT_DEVICE_CPU_MODEL" /proc/cpuinfo
if grep -q 'hypervisor' /proc/cpuinfo; then
echo "NapCat device profile check failed: cpuinfo-hypervisor-flag" >&2
exit 78
fi
if [ -f /sys/devices/virtual/tty/tty0/active ]; then
kt_require_device_profile tty-active grep -q "$NAPCAT_DEVICE_TTY_ACTIVE" /sys/devices/virtual/tty/tty0/active
else
echo "NapCat device profile check failed: tty0-active-missing" >&2
exit 78
fi
KT_MOUNTINFO_HOST_LEAK_PATTERN='overlay|/vol1/docker|docker-init|/docker/containers|napcat-instances|btrfs|/dev/mapper/trim'
for kt_mountinfo in /proc/1/mountinfo; do
if grep -E "$KT_MOUNTINFO_HOST_LEAK_PATTERN" "$kt_mountinfo" >/dev/null 2>&1; then
echo "NapCat device profile check failed: mountinfo-host-leak:$kt_mountinfo" >&2
exit 78
fi
done
if [ "$(hostname)" != "localhost" ] && grep -q "$(hostname)" /etc/hosts; then
echo "NapCat device profile check failed: hosts-still-contains-hostname" >&2
exit 78
fi
if grep -E 'trim-trashbin|zvol' /proc/devices >/dev/null 2>&1; then
echo "NapCat device profile check failed: proc-devices-host-leak" >&2
exit 78
fi
fi
EOF
sed -i '/FAKE_CGROUP_DIR="\/tmp\/fake_cgroup"/r /tmp/kt-device-profile-defaults.sh' "$ENTRYPOINT"
sed -i '/# Docker Socket/r /tmp/kt-device-profile-dmi.sh' "$ENTRYPOINT"
sed -i '/# \/proc\/1\/sched/r /tmp/kt-device-profile-proc.sh' "$ENTRYPOINT"
sed -i '/: ${NAPCAT_UID:=0}/r /tmp/kt-device-profile-verify.sh' "$ENTRYPOINT"
rm -f \
/tmp/kt-device-profile-defaults.sh \
/tmp/kt-device-profile-dmi.sh \
/tmp/kt-device-profile-proc.sh \
/tmp/kt-device-profile-verify.sh

View File

@ -43,4 +43,116 @@ test "$EXPECTED_MJS_SHA" = "$ACTUAL_MJS_SHA"
grep -R -q 'getQQLoginRuntimeState' "$TMP_DIR" grep -R -q 'getQQLoginRuntimeState' "$TMP_DIR"
grep -R -q 'qrcodeRevision' "$TMP_DIR" grep -R -q 'qrcodeRevision' "$TMP_DIR"
grep -R -q 'needsLoginServiceReset' "$TMP_DIR" grep -R -q 'needsLoginServiceReset' "$TMP_DIR"
grep -R -q '重置已失效登录服务后重新生成二维码' "$TMP_DIR"
grep -q 'KT device profile patch defaults' /app/entrypoint.sh
grep -q 'NAPCAT_REQUIRE_DEVICE_PROFILE' /app/entrypoint.sh
grep -q '/proc/1/cmdline' /app/entrypoint.sh
grep -q '/proc/1/sched' /app/entrypoint.sh
grep -q '/proc/1/status' /app/entrypoint.sh
grep -q '/proc/1/stat' /app/entrypoint.sh
grep -q '/proc/1/mountinfo' /app/entrypoint.sh
grep -q '/proc/self/mountinfo' /app/entrypoint.sh
grep -q '/proc/$kt_mountinfo_pid/mountinfo' /app/entrypoint.sh
grep -q 'kt_is_mountinfo_target' /app/entrypoint.sh
grep -q 'kt_mountinfo_guard_loop' /app/entrypoint.sh
grep -q 'overlay|/vol1/docker|docker-init|/docker/containers|napcat-instances|btrfs|/dev/mapper/trim' /app/entrypoint.sh
grep -q '/sys/class/dmi/id/product_name' /app/entrypoint.sh
grep -q '/sys/class/dmi/id/bios_vendor' /app/entrypoint.sh
grep -q '/sys/class/dmi/id/bios_version' /app/entrypoint.sh
grep -q '/etc/hosts' /app/entrypoint.sh
grep -q 'KT preserve mounted NapCat config' /app/entrypoint.sh
grep -q 'find NapCat.Shell -mindepth 1 -maxdepth 1 ! -name config' /app/entrypoint.sh
! grep -q 'cp -rf NapCat.Shell/\* napcat/' /app/entrypoint.sh
MOUNTINFO_HOST_LEAK_PATTERN='overlay|/vol1/docker|docker-init|/docker/containers|napcat-instances|btrfs|/dev/mapper/trim'
MOUNTINFO_GUARD_TIMEOUT_SECONDS="${MOUNTINFO_GUARD_TIMEOUT_SECONDS:-12}"
mountinfo_has_host_leak() {
kt_mountinfo="$1"
if [ -r "$kt_mountinfo" ] && grep -E "$MOUNTINFO_HOST_LEAK_PATTERN" "$kt_mountinfo" >/dev/null 2>&1; then
return 0
fi
return 1
}
assert_no_mountinfo_host_leak() {
kt_mountinfo="$1"
if mountinfo_has_host_leak "$kt_mountinfo"; then
echo "NapCat desktop-cn verify failed: mountinfo-host-leak:$kt_mountinfo" >&2
exit 78
fi
}
wait_for_mountinfo_guard_converged() {
kt_mountinfo_pid="$1"
kt_mountinfo_guard_deadline="$MOUNTINFO_GUARD_TIMEOUT_SECONDS"
kt_mountinfo_guard_elapsed=0
while [ "$kt_mountinfo_guard_elapsed" -le "$kt_mountinfo_guard_deadline" ]; do
if [ ! -d "/proc/$kt_mountinfo_pid" ]; then
return 0
fi
if ! mountinfo_has_host_leak "/proc/$kt_mountinfo_pid/mountinfo"; then
return 0
fi
sleep 1
kt_mountinfo_guard_elapsed=$((kt_mountinfo_guard_elapsed + 1))
done
return 1
}
assert_target_mountinfo_guard_converged() {
kt_mountinfo_pid="$1"
if wait_for_mountinfo_guard_converged "$kt_mountinfo_pid"; then
return 0
fi
kt_mountinfo_comm="$(cat "/proc/$kt_mountinfo_pid/comm" 2>/dev/null || true)"
kt_mountinfo_cmdline="$(tr '\000' ' ' < "/proc/$kt_mountinfo_pid/cmdline" 2>/dev/null || true)"
echo "NapCat desktop-cn verify failed: mountinfo-host-leak:/proc/$kt_mountinfo_pid/mountinfo comm=$kt_mountinfo_comm cmdline=$kt_mountinfo_cmdline" >&2
grep -E "$MOUNTINFO_HOST_LEAK_PATTERN" "/proc/$kt_mountinfo_pid/mountinfo" 2>/dev/null | head -n 1 >&2 || true
exit 78
}
kt_is_mountinfo_target() {
kt_target_comm="$1"
kt_target_cmdline="$2"
kt_target_argv0="${kt_target_cmdline%% *}"
kt_target_rest="${kt_target_cmdline#* }"
if [ "$kt_target_rest" = "$kt_target_cmdline" ]; then
kt_target_rest=""
fi
kt_target_argv1="${kt_target_rest%% *}"
kt_target_argv0_base="${kt_target_argv0##*/}"
case "$kt_target_comm" in
qq|QQ|NapCat|napcat|Xvfb)
return 0
;;
esac
case "$kt_target_argv0_base" in
qq|QQ|NapCat|napcat|Xvfb)
return 0
;;
esac
case "$kt_target_argv0" in
/opt/QQ/*|/app/napcat/*|/app/NapCat*)
return 0
;;
esac
case "$kt_target_argv1" in
/app/napcat/*|/app/NapCat*|*/NapCat.Shell*|*/napcat.mjs*)
return 0
;;
esac
return 1
}
assert_no_mountinfo_host_leak /proc/1/mountinfo
for kt_mountinfo_pid_dir in /proc/[0-9]*; do
[ -d "$kt_mountinfo_pid_dir" ] || continue
kt_mountinfo_pid="${kt_mountinfo_pid_dir#/proc/}"
kt_mountinfo_comm="$(cat "$kt_mountinfo_pid_dir/comm" 2>/dev/null || true)"
kt_mountinfo_cmdline="$(tr '\000' ' ' < "$kt_mountinfo_pid_dir/cmdline" 2>/dev/null || true)"
if kt_is_mountinfo_target "$kt_mountinfo_comm" "$kt_mountinfo_cmdline"; then
assert_target_mountinfo_guard_converged "$kt_mountinfo_pid"
fi
done

View File

@ -843,8 +843,8 @@ describe('NapCat runtime promotion contract', () => {
it('keeps a safe default runtime profile in the K8s manifest', () => { it('keeps a safe default runtime profile in the K8s manifest', () => {
const manifest = readSource('k8s/prod/api.yaml'); const manifest = readSource('k8s/prod/api.yaml');
expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v8'); expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v10');
expect(manifest).toContain('value: desktop-cn-v8'); expect(manifest).toContain('value: desktop-cn-v10');
}); });
}); });
``` ```

View File

@ -33,9 +33,9 @@ spec:
- name: DB_TIMEZONE - name: DB_TIMEZONE
value: "+08:00" value: "+08:00"
- name: QQBOT_NAPCAT_IMAGE - name: QQBOT_NAPCAT_IMAGE
value: kt-napcat-desktop-cn:desktop-cn-v8 value: kt-napcat-desktop-cn:desktop-cn-v20
- name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION - name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION
value: desktop-cn-v8 value: desktop-cn-v20
- name: QQBOT_NAPCAT_SSH_KEY_PATH - name: QQBOT_NAPCAT_SSH_KEY_PATH
value: /app/secrets/napcat-ssh/id_rsa value: /app/secrets/napcat-ssh/id_rsa
- name: QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX - name: QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX

View File

@ -161,6 +161,10 @@ copyIntoContext(
resolve(apiRoot, 'ci/napcat-desktop-cn/verify.sh'), resolve(apiRoot, 'ci/napcat-desktop-cn/verify.sh'),
resolve(outputRoot, 'ci/napcat-desktop-cn/verify.sh'), resolve(outputRoot, 'ci/napcat-desktop-cn/verify.sh'),
); );
copyIntoContext(
resolve(apiRoot, 'ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh'),
resolve(outputRoot, 'ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh'),
);
copyIntoContext(shellDist, resolve(outputRoot, 'NapCat.Shell')); copyIntoContext(shellDist, resolve(outputRoot, 'NapCat.Shell'));
const marker = { const marker = {

View File

@ -7,7 +7,6 @@ UPDATE `admin_menu`
SET `meta` = CASE `name` SET `meta` = CASE `name`
WHEN 'Dashboard' THEN '{"order":-1,"title":"page.dashboard.title"}' WHEN 'Dashboard' THEN '{"order":-1,"title":"page.dashboard.title"}'
WHEN 'Analytics' THEN '{"affixTab":true,"title":"page.dashboard.analytics"}' WHEN 'Analytics' THEN '{"affixTab":true,"title":"page.dashboard.analytics"}'
WHEN 'Workspace' THEN '{"icon":"carbon:workspace","title":"page.dashboard.workspace"}'
WHEN 'System' THEN '{"badge":"new","badgeType":"normal","badgeVariants":"primary","icon":"carbon:settings","order":9997,"title":"system.title"}' WHEN 'System' THEN '{"badge":"new","badgeType":"normal","badgeVariants":"primary","icon":"carbon:settings","order":9997,"title":"system.title"}'
WHEN 'SystemRole' THEN '{"icon":"carbon:user-role","title":"system.role.title"}' WHEN 'SystemRole' THEN '{"icon":"carbon:user-role","title":"system.role.title"}'
WHEN 'SystemRoleCreate' THEN '{"title":"common.create"}' WHEN 'SystemRoleCreate' THEN '{"title":"common.create"}'
@ -34,7 +33,6 @@ END
WHERE `name` IN ( WHERE `name` IN (
'Dashboard', 'Dashboard',
'Analytics', 'Analytics',
'Workspace',
'System', 'System',
'SystemRole', 'SystemRole',
'SystemRoleCreate', 'SystemRoleCreate',

View File

@ -47,16 +47,16 @@ INSERT INTO admin_user (
status status
) VALUES ( ) VALUES (
2041700000000000002, 2041700000000000002,
'admin', 'kwitsukasa',
'123456', '123456',
'Admin', 'KwiTsukasa',
'', '',
2041700000000200001, 2041700000000200001,
'/workspace', '/analytics',
'Asia/Shanghai', 'Asia/Shanghai',
1 1
) ON DUPLICATE KEY UPDATE ) ON DUPLICATE KEY UPDATE
password = VALUES(password), username = VALUES(username),
real_name = VALUES(real_name), real_name = VALUES(real_name),
avatar = VALUES(avatar), avatar = VALUES(avatar),
dept_id = VALUES(dept_id), dept_id = VALUES(dept_id),
@ -84,7 +84,7 @@ INSERT INTO admin_menu (
'Dashboard', 'Dashboard',
'/dashboard', '/dashboard',
NULL, NULL,
'/workspace', '/analytics',
NULL, NULL,
'catalog', 'catalog',
'{"order":-1,"title":"page.dashboard.title"}', '{"order":-1,"title":"page.dashboard.title"}',
@ -92,15 +92,15 @@ INSERT INTO admin_menu (
0 0
), ),
( (
2041700000000100102, 2041700000000100101,
2041700000000100001, 2041700000000100001,
'Workspace', 'Analytics',
'/workspace', '/analytics',
'/dashboard/workspace/index', '/dashboard/analytics/index',
NULL, NULL,
NULL, NULL,
'menu', 'menu',
'{"icon":"carbon:workspace","title":"page.dashboard.workspace"}', '{"affixTab":true,"title":"page.dashboard.analytics"}',
1, 1,
0 0
), ),

View File

@ -210,7 +210,6 @@ INSERT INTO `admin_menu` (`id`, `pid`, `name`, `path`, `component`, `redirect`,
VALUES VALUES
(2041700000000100001, 0, 'Dashboard', '/dashboard', NULL, '/analytics', NULL, 'catalog', '{"order":-1,"title":"page.dashboard.title"}', 1, 0), (2041700000000100001, 0, 'Dashboard', '/dashboard', NULL, '/analytics', NULL, 'catalog', '{"order":-1,"title":"page.dashboard.title"}', 1, 0),
(2041700000000100101, 2041700000000100001, 'Analytics', '/analytics', '/dashboard/analytics/index', NULL, NULL, 'menu', '{"affixTab":true,"title":"page.dashboard.analytics"}', 1, 0), (2041700000000100101, 2041700000000100001, 'Analytics', '/analytics', '/dashboard/analytics/index', NULL, NULL, 'menu', '{"affixTab":true,"title":"page.dashboard.analytics"}', 1, 0),
(2041700000000100102, 2041700000000100001, 'Workspace', '/workspace', '/dashboard/workspace/index', NULL, NULL, 'menu', '{"icon":"carbon:workspace","title":"page.dashboard.workspace"}', 1, 0),
(2041700000000100002, 0, 'System', '/system', NULL, NULL, NULL, 'catalog', '{"badge":"new","badgeType":"normal","badgeVariants":"primary","icon":"carbon:settings","order":9997,"title":"system.title"}', 1, 9997), (2041700000000100002, 0, 'System', '/system', NULL, NULL, NULL, 'catalog', '{"badge":"new","badgeType":"normal","badgeVariants":"primary","icon":"carbon:settings","order":9997,"title":"system.title"}', 1, 9997),
(2041700000000100199, 2041700000000100002, 'SystemUser', '/system/user', '/system/user/list', NULL, 'System:User:List', 'menu', '{"icon":"carbon:user-profile","title":"system.user.title"}', 1, 0), (2041700000000100199, 2041700000000100002, 'SystemUser', '/system/user', '/system/user/list', NULL, 'System:User:List', 'menu', '{"icon":"carbon:user-profile","title":"system.user.title"}', 1, 0),
(2041700000000120191, 2041700000000100199, 'SystemUserCreate', NULL, NULL, NULL, 'System:User:Create', 'button', '{"title":"common.create"}', 1, 0), (2041700000000120191, 2041700000000100199, 'SystemUserCreate', NULL, NULL, NULL, 'System:User:Create', 'button', '{"title":"common.create"}', 1, 0),
@ -277,7 +276,7 @@ ON DUPLICATE KEY UPDATE
INSERT INTO `admin_role` (`id`, `role_code`, `name`, `remark`, `status`) INSERT INTO `admin_role` (`id`, `role_code`, `name`, `remark`, `status`)
VALUES VALUES
(2041700000000010001, 'super', '超级管理员', '拥有所有后台权限', 1), (2041700000000010001, 'super', '超级管理员', '拥有所有后台权限', 1),
(2041700000000010002, 'admin', '管理员', '拥有系统管理与工作台权限', 1), (2041700000000010002, 'admin', '管理员', '拥有系统管理与环境总览权限', 1),
(2041700000000010003, 'user', '普通用户', '仅拥有基础查看权限', 1) (2041700000000010003, 'user', '普通用户', '仅拥有基础查看权限', 1)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
`name` = VALUES(`name`), `name` = VALUES(`name`),
@ -287,11 +286,9 @@ ON DUPLICATE KEY UPDATE
INSERT INTO `admin_user` (`id`, `username`, `password`, `real_name`, `avatar`, `dept_id`, `home_path`, `timezone`, `status`) INSERT INTO `admin_user` (`id`, `username`, `password`, `real_name`, `avatar`, `dept_id`, `home_path`, `timezone`, `status`)
VALUES VALUES
(2041700000000000001, 'vben', '123456', 'Vben', '', 2041700000000200002, '/workspace', 'Asia/Shanghai', 1), (2041700000000000002, 'kwitsukasa', '123456', 'KwiTsukasa', '', 2041700000000200001, '/analytics', 'Asia/Shanghai', 1)
(2041700000000000002, 'admin', '123456', 'Admin', '', 2041700000000200001, '/workspace', 'Asia/Shanghai', 1),
(2041700000000000003, 'jack', '123456', 'Jack', '', 2041700000000200003, '/analytics', 'Asia/Shanghai', 1)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
`password` = VALUES(`password`), `username` = VALUES(`username`),
`real_name` = VALUES(`real_name`), `real_name` = VALUES(`real_name`),
`dept_id` = VALUES(`dept_id`), `dept_id` = VALUES(`dept_id`),
`home_path` = VALUES(`home_path`), `home_path` = VALUES(`home_path`),
@ -331,16 +328,12 @@ ON DUPLICATE KEY UPDATE
DELETE FROM `admin_user_role` DELETE FROM `admin_user_role`
WHERE `user_id` IN ( WHERE `user_id` IN (
2041700000000000001, 2041700000000000002
2041700000000000002,
2041700000000000003
); );
INSERT INTO `admin_user_role` (`user_id`, `role_id`) INSERT INTO `admin_user_role` (`user_id`, `role_id`)
VALUES VALUES
(2041700000000000001, 2041700000000010001), (2041700000000000002, 2041700000000010002);
(2041700000000000002, 2041700000000010002),
(2041700000000000003, 2041700000000010003);
DELETE FROM `admin_role_menu` DELETE FROM `admin_role_menu`
WHERE `role_id` IN ( WHERE `role_id` IN (

View File

@ -210,6 +210,7 @@ export class AdminMenuService {
pid: data.pid || '0', pid: data.pid || '0',
redirect: data.redirect || null, redirect: data.redirect || null,
status: data.status ?? 1, status: data.status ?? 1,
sort: Number(data.sort ?? 0),
type: data.type || 'menu', type: data.type || 'menu',
}; };
if (includeEmptyMeta || Object.keys(meta).length > 0) { if (includeEmptyMeta || Object.keys(meta).length > 0) {
@ -321,6 +322,7 @@ export class AdminMenuService {
pid: menu.pid || '0', pid: menu.pid || '0',
redirect: menu.redirect, redirect: menu.redirect,
status: menu.status, status: menu.status,
sort: menu.sort,
type: menu.type, type: menu.type,
} as any; } as any;

View File

@ -10,6 +10,9 @@ import type {
AdminUserListQuery, AdminUserListQuery,
} from '../../contract/admin.types'; } from '../../contract/admin.types';
const BUILTIN_ADMIN_USER_ID = '2041700000000000002';
const DEFAULT_ADMIN_HOME_PATH = '/analytics';
@Injectable() @Injectable()
export class AdminUserService { export class AdminUserService {
/** /**
@ -97,7 +100,7 @@ export class AdminUserService {
const user = this.userRepository.create({ const user = this.userRepository.create({
deptId: data.deptId || null, deptId: data.deptId || null,
homePath: data.homePath || '/workspace', homePath: data.homePath || DEFAULT_ADMIN_HOME_PATH,
password: data.password || '123456', password: data.password || '123456',
realName: data.realName, realName: data.realName,
status: data.status ?? 1, status: data.status ?? 1,
@ -156,7 +159,11 @@ export class AdminUserService {
if (id === currentUserId) { if (id === currentUserId) {
throwVbenError('不能删除当前登录用户', HttpStatus.BAD_REQUEST); throwVbenError('不能删除当前登录用户', HttpStatus.BAD_REQUEST);
} }
if (user.username === 'admin') { if (
user.id === BUILTIN_ADMIN_USER_ID ||
user.username === 'admin' ||
user.username === 'kwitsukasa'
) {
throwVbenError('不能删除内置管理员账号', HttpStatus.BAD_REQUEST); throwVbenError('不能删除内置管理员账号', HttpStatus.BAD_REQUEST);
} }
@ -183,7 +190,8 @@ export class AdminUserService {
user.realName = realName; user.realName = realName;
} }
if (data.homePath !== undefined) { if (data.homePath !== undefined) {
user.homePath = String(data.homePath || '').trim() || '/workspace'; user.homePath =
String(data.homePath || '').trim() || DEFAULT_ADMIN_HOME_PATH;
} }
if (data.avatar !== undefined) { if (data.avatar !== undefined) {
user.avatar = String(data.avatar || '').trim(); user.avatar = String(data.avatar || '').trim();

View File

@ -49,6 +49,7 @@ export type NapcatQrcode = {
}; };
export type NapcatRestartOptions = { export type NapcatRestartOptions = {
processOnly?: boolean;
waitForReady?: boolean; waitForReady?: boolean;
}; };
@ -337,12 +338,16 @@ export type QqbotLoginScanSession = {
expectedSelfId?: string; expectedSelfId?: string;
id: string; id: string;
lastCaptchaLookupAt?: number; lastCaptchaLookupAt?: number;
loginSelfIdMissingSince?: number;
lastQrcodeRefreshAt?: number;
lastRestartedAt?: number; lastRestartedAt?: number;
loginPasswordAvailable?: boolean;
mode: QqbotLoginScanMode; mode: QqbotLoginScanMode;
newDeviceBytesToken?: string; newDeviceBytesToken?: string;
newDevicePullQrCodeSig?: unknown; newDevicePullQrCodeSig?: unknown;
newDeviceQrcode?: string; newDeviceQrcode?: string;
newDeviceStatus?: QqbotLoginNewDeviceStatus; newDeviceStatus?: QqbotLoginNewDeviceStatus;
onlineSourceWorkerRestartAttempted?: boolean;
passwordMd5?: string; passwordMd5?: string;
preparingContainer?: boolean; preparingContainer?: boolean;
preparingRelogin?: boolean; preparingRelogin?: boolean;

View File

@ -176,8 +176,15 @@ export class QqbotNapcatLoginService {
async startRefresh(accountId: string) { async startRefresh(accountId: string) {
const activeSession = this.findActiveRefreshSession(accountId); const activeSession = this.findActiveRefreshSession(accountId);
if (activeSession) { if (activeSession) {
if (activeSession.qrcode) return this.refreshQrcode(activeSession.id); if (
return this.toResult(activeSession); !(await this.invalidatePasswordlessRefreshSessionWhenPasswordExists(
accountId,
activeSession,
))
) {
if (activeSession.qrcode) return this.refreshQrcode(activeSession.id);
return this.toResult(activeSession);
}
} }
const runningTask = this.refreshStartTasks[accountId]; const runningTask = this.refreshStartTasks[accountId];
@ -216,6 +223,7 @@ export class QqbotNapcatLoginService {
expectedSelfId: string; expectedSelfId: string;
forceRelogin: true; forceRelogin: true;
hasExistingPrimaryBinding?: boolean; hasExistingPrimaryBinding?: boolean;
loginPasswordAvailable?: boolean;
loginPassword?: string; loginPassword?: string;
mode: 'refresh'; mode: 'refresh';
sourceContainerOnline?: boolean; sourceContainerOnline?: boolean;
@ -224,6 +232,7 @@ export class QqbotNapcatLoginService {
expectedSelfId: account.selfId, expectedSelfId: account.selfId,
forceRelogin: true, forceRelogin: true,
hasExistingPrimaryBinding: container.hasExistingPrimaryBinding, hasExistingPrimaryBinding: container.hasExistingPrimaryBinding,
loginPasswordAvailable: !!this.toolsService.toSecretText(loginPassword),
loginPassword, loginPassword,
mode: 'refresh', mode: 'refresh',
}; };
@ -256,6 +265,80 @@ export class QqbotNapcatLoginService {
return activeSession; return activeSession;
} }
/**
* QQ
* @param accountId - ID
* @param session - pending
* @returns 退 true
*/
private async invalidatePasswordlessRefreshSessionWhenPasswordExists(
accountId: string,
session: QqbotLoginScanSession,
) {
if (this.hasRefreshPasswordContext(session)) return false;
const accountService = this.accountService as Partial<
Pick<
QqbotAccountService,
'findByIdWithNapcatLoginSecret' | 'getNapcatLoginPassword'
>
>;
if (
!accountService.findByIdWithNapcatLoginSecret ||
!accountService.getNapcatLoginPassword
) {
return false;
}
const account =
await accountService.findByIdWithNapcatLoginSecret(accountId);
if (!account) return false;
const loginPassword = accountService.getNapcatLoginPassword(account);
if (!this.toolsService.toSecretText(loginPassword)) return false;
await this.retireRefreshSessionForPasswordReload(session);
return true;
}
/**
*
* @param session - pending
* @returns true
*/
private hasRefreshPasswordContext(session: QqbotLoginScanSession) {
return !!(
session.loginPasswordAvailable ||
session.passwordMd5 ||
session.captchaUrl ||
session.deviceVerifyUrl ||
session.newDeviceQrcode ||
session.newDeviceStatus
);
}
/**
* 退 NapCat
* @param session - pending
*/
private async retireRefreshSessionForPasswordReload(
session: QqbotLoginScanSession,
) {
session.errorMessage = '账号登录密码已更新,重新创建更新登录会话';
session.preparingRelogin = false;
session.qrcode = undefined;
session.status = 'expired';
this.persistLoginSession(session);
this.publishScanResultEvent(
session,
'session-recreated',
'processing',
session.errorMessage,
);
await this.loginSessionStore.flushSessionWrites(session.id);
this.loginSessionStore.delete(session.id);
this.cleanupSessionEvents(session.id);
}
/** /**
* NapCat * NapCat
* @param sessionId - NapCat IDNapCat * @param sessionId - NapCat IDNapCat
@ -303,7 +386,16 @@ export class QqbotNapcatLoginService {
await this.syncSessionQqLoginStatus(session, loginStatus); await this.syncSessionQqLoginStatus(session, loginStatus);
} }
if (loginStatus.isOffline && session.mode !== 'refresh') { if (
loginStatus.isOffline &&
this.shouldRestartNapcatWorkerForOnlineRefresh(session)
) {
loginStatus = await this.restartNapcatWorkerForOnlineRefresh(
session,
container,
loginStatus.loginError || 'NapCat 账号已离线,正在重启登录服务',
);
} else if (loginStatus.isOffline && session.mode !== 'refresh') {
await this.restartNapcatForLogin(container, { waitForReady: false }); await this.restartNapcatForLogin(container, { waitForReady: false });
session.lastRestartedAt = Date.now(); session.lastRestartedAt = Date.now();
return this.keepSessionPending( return this.keepSessionPending(
@ -435,10 +527,29 @@ export class QqbotNapcatLoginService {
); );
} }
if (
status.isOffline &&
this.shouldRestartNapcatWorkerForOnlineRefresh(session)
) {
status = await this.restartNapcatWorkerForOnlineRefresh(
session,
container,
status.loginError || 'NapCat 账号已离线,正在重启登录服务',
);
if (status.isLogin) {
return this.completeLogin(session, container);
}
await this.syncSessionQqLoginStatus(session, status);
}
if (this.shouldRefreshNearlyExpiredQrcode(status)) { if (this.shouldRefreshNearlyExpiredQrcode(status)) {
return this.refreshNearlyExpiredQrcode(session, container, status); return this.refreshNearlyExpiredQrcode(session, container, status);
} }
if (this.shouldAutoRefreshPendingQrcode(session, status)) {
return this.refreshPendingQrcodeFromStatus(session, container, status);
}
session.errorMessage = status.loginError || undefined; session.errorMessage = status.loginError || undefined;
if ( if (
status.qrcodeurl && status.qrcodeurl &&
@ -594,6 +705,7 @@ export class QqbotNapcatLoginService {
step: 'session-cancelled', step: 'session-cancelled',
}); });
this.loginSessionStore.delete(sessionId); this.loginSessionStore.delete(sessionId);
await this.loginSessionStore.flushSessionWrites(sessionId);
await this.cleanupSessionContainer(session); await this.cleanupSessionContainer(session);
this.cleanupSessionEvents(sessionId); this.cleanupSessionEvents(sessionId);
} }
@ -612,6 +724,7 @@ export class QqbotNapcatLoginService {
expectedSelfId?: string; expectedSelfId?: string;
forceRelogin?: boolean; forceRelogin?: boolean;
hasExistingPrimaryBinding?: boolean; hasExistingPrimaryBinding?: boolean;
loginPasswordAvailable?: boolean;
loginPassword?: string; loginPassword?: string;
mode: QqbotLoginScanMode; mode: QqbotLoginScanMode;
sourceContainerOnline?: boolean; sourceContainerOnline?: boolean;
@ -859,8 +972,9 @@ export class QqbotNapcatLoginService {
const selfId = this.toolsService.pickNapcatSelfId(loginInfo); const selfId = this.toolsService.pickNapcatSelfId(loginInfo);
if (!selfId) { if (!selfId) {
return this.failSession(session, 'NapCat 已登录但未返回 QQ 号'); return this.keepLoginSelfIdPending(session);
} }
session.loginSelfIdMissingSince = undefined;
if (session.expectedSelfId && session.expectedSelfId !== selfId) { if (session.expectedSelfId && session.expectedSelfId !== selfId) {
return this.failSession( return this.failSession(
session, session,
@ -910,6 +1024,7 @@ export class QqbotNapcatLoginService {
container: QqbotNapcatRuntime; container: QqbotNapcatRuntime;
expectedSelfId?: string; expectedSelfId?: string;
mode: QqbotLoginScanMode; mode: QqbotLoginScanMode;
loginPasswordAvailable?: boolean;
preparingContainer?: boolean; preparingContainer?: boolean;
preparingRelogin?: boolean; preparingRelogin?: boolean;
qrcode?: string; qrcode?: string;
@ -926,6 +1041,7 @@ export class QqbotNapcatLoginService {
expectedSelfId: input.expectedSelfId, expectedSelfId: input.expectedSelfId,
expiresAt: now + this.getSessionTtlMs(), expiresAt: now + this.getSessionTtlMs(),
id: randomUUID(), id: randomUUID(),
loginPasswordAvailable: input.loginPasswordAvailable,
mode: input.mode, mode: input.mode,
preparingContainer: input.preparingContainer, preparingContainer: input.preparingContainer,
preparingRelogin: input.preparingRelogin, preparingRelogin: input.preparingRelogin,
@ -2074,6 +2190,39 @@ export class QqbotNapcatLoginService {
return this.toResult(session); return this.toResult(session);
} }
/**
* Keeps a login-positive session alive while NapCat finishes exposing the logged-in QQ number.
* @param session - Scan session whose WebUI status is already logged in but whose `GetQQLoginInfo` payload lacks `uin`/`selfId`.
* @returns Pending result during the bounded wait window, or a terminal failure once the missing-self-id window is exhausted.
*/
private async keepLoginSelfIdPending(session: QqbotLoginScanSession) {
const now = Date.now();
session.loginSelfIdMissingSince ??= now;
if (now - session.loginSelfIdMissingSince > this.getLoginSelfIdWaitMs()) {
return this.failSession(session, 'NapCat 已登录但未返回 QQ 号');
}
const message = 'NapCat 已登录,正在读取 QQ 号';
const shouldPublish = session.errorMessage !== message;
session.status = 'pending';
session.captchaUrl = undefined;
session.errorMessage = message;
session.preparingContainer = false;
session.preparingRelogin = false;
session.qrcode = undefined;
this.renewSessionExpiry(session);
this.persistLoginSession(session);
if (shouldPublish) {
this.publishScanResultEvent(
session,
'login-self-id-wait',
'processing',
message,
);
}
return this.toResult(session);
}
/** /**
* Stops delayed background login work from mutating a pending session after it expired or was replaced. * Stops delayed background login work from mutating a pending session after it expired or was replaced.
* @param session - Pending scan session captured by an async relogin task; its cache identity and TTL decide whether writes are still valid. * @param session - Pending scan session captured by an async relogin task; its cache identity and TTL decide whether writes are still valid.
@ -2105,6 +2254,8 @@ export class QqbotNapcatLoginService {
session.errorMessage = errorMessage; session.errorMessage = errorMessage;
session.passwordMd5 = undefined; session.passwordMd5 = undefined;
session.preparingRelogin = false; session.preparingRelogin = false;
this.persistLoginSession(session);
await this.loginSessionStore.flushSessionWrites(session.id);
this.publishScanResultEvent(session, 'login-error', 'error', errorMessage); this.publishScanResultEvent(session, 'login-error', 'error', errorMessage);
this.loginSessionStore.delete(session.id); this.loginSessionStore.delete(session.id);
await this.closeSession(session); await this.closeSession(session);
@ -2348,6 +2499,82 @@ export class QqbotNapcatLoginService {
return ageMs >= this.getNativeQrcodeTtlMs() - this.getQrcodeSafeScanMs(); return ageMs >= this.getNativeQrcodeTtlMs() - this.getQrcodeSafeScanMs();
} }
/**
* Decides whether a pending scan status poll should actively ask NapCat for a QR again.
* @param session - Pending scan session; challenge states and recent refresh attempts suppress automatic retries.
* @param status - Latest WebUI status; existing, expired, or successful QR/login states are handled elsewhere.
* @returns True when the status poll should call the same-container RefreshQRcode path once per cooldown window.
*/
private shouldAutoRefreshPendingQrcode(
session: QqbotLoginScanSession,
status: NapcatLoginStatus,
) {
if (session.preparingContainer || session.preparingRelogin) return false;
if (session.captchaUrl || session.newDeviceStatus) return false;
if (session.qrcode || status.qrcodeurl || status.isLogin) return false;
if (this.toolsService.isNapcatExpiredQrcodeStatus(status)) return false;
const lastRefreshAt = Number(session.lastQrcodeRefreshAt || 0);
if (!Number.isFinite(lastRefreshAt) || lastRefreshAt <= 0) return true;
return Date.now() - lastRefreshAt >= this.getQrcodeAutoRefreshCooldownMs();
}
/**
* Requests a fresh QR during status polling so SSE can recover from a previous accepted-but-not-updated refresh.
* @param session - Pending scan session whose result is returned to Admin/SSE.
* @param container - Current NapCat WebUI runtime; the method never rebuilds or restarts it.
* @param status - Latest WebUI status used as fallback metadata for QR freshness checks.
* @returns Updated scan result, either with a fresh QR or a bounded pending message.
*/
private async refreshPendingQrcodeFromStatus(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
status: NapcatLoginStatus,
) {
session.lastQrcodeRefreshAt = Date.now();
this.persistLoginSession(session);
this.publishScanResultEvent(
session,
'qrcode-fetch',
'processing',
'正在重新生成登录二维码',
);
try {
session.qrcode = await this.refreshOrGetQrcode(container, false, {
fallbackStatus: status,
requireFresh:
session.mode === 'refresh' ||
!!session.qrcode ||
this.toolsService.isNapcatExpiredQrcodeStatus(status),
staleQrcode: session.qrcode || status.qrcodeurl,
});
session.errorMessage = undefined;
session.expiresAt = Date.now() + this.getSessionTtlMs();
this.persistLoginSession(session);
this.publishScanResultEvent(
session,
'qrcode-ready',
'success',
'登录二维码已生成',
);
this.publishScanResultEvent(
session,
'waiting-scan',
'processing',
'等待扫码确认',
);
return this.toResult(session);
} catch (err) {
if (!this.toolsService.isNapcatTemporaryError(err)) throw err;
session.qrcode = undefined;
session.errorMessage =
'NapCat 正在重新生成二维码,请稍后刷新或等待自动更新';
this.persistLoginSession(session);
return this.toResult(session);
}
}
/** /**
* Reads the expected native QQ QR lifetime used only for safe-display decisions. * 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. * @returns Milliseconds before a QR is considered too old to show without refreshing.
@ -2370,6 +2597,28 @@ export class QqbotNapcatLoginService {
); );
} }
/**
* Reads the cooldown between automatic QR refresh attempts during status polling.
* @returns Milliseconds to wait before SSE/status may request another QR regeneration.
*/
private getQrcodeAutoRefreshCooldownMs() {
return this.getPositiveConfigNumber(
'NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS',
Math.max(5000, this.getLoginPollIntervalMs() * 2),
);
}
/**
* Reads the bounded wait window for NapCat to expose QQ number after WebUI already reports login-positive.
* @returns Milliseconds allowed for `GetQQLoginInfo` to start returning `uin`/`selfId` before treating the state as inconsistent.
*/
private getLoginSelfIdWaitMs() {
return this.getPositiveConfigNumber(
'NAPCAT_LOGIN_SELF_ID_WAIT_MS',
30_000,
);
}
/** /**
* NapCat * NapCat
* @param container - container NapCat查询范围 * @param container - container NapCat查询范围
@ -2632,6 +2881,17 @@ export class QqbotNapcatLoginService {
container: QqbotNapcatRuntime, container: QqbotNapcatRuntime,
) { ) {
const loginStatus = await this.getLoginStatus(container, true); const loginStatus = await this.getLoginStatus(container, true);
if (
loginStatus.isOffline &&
this.shouldRestartNapcatWorkerForOnlineRefresh(session)
) {
await this.restartNapcatWorkerForOnlineRefresh(
session,
container,
loginStatus.loginError || 'NapCat 账号已离线,正在重启登录服务',
);
return false;
}
if (!loginStatus.isLogin) return false; if (!loginStatus.isLogin) return false;
const loginInfo = await this.getLoginInfo(container); const loginInfo = await this.getLoginInfo(container);
@ -2750,6 +3010,50 @@ export class QqbotNapcatLoginService {
return true; return true;
} }
/**
* Decides whether this refresh session may spend its single same-container worker restart budget.
* @param session - Login refresh session; its source-runtime flag proves the managed runtime is already alive and the attempt flag prevents restart storms.
* @returns True only before the first NapCat worker restart attempt in this online-source refresh session.
*/
private shouldRestartNapcatWorkerForOnlineRefresh(
session: QqbotLoginScanSession,
) {
return (
session.mode === 'refresh' &&
session.sourceContainerOnline === true &&
session.onlineSourceWorkerRestartAttempted !== true
);
}
/**
* Restarts only the NapCat worker when the managed runtime is alive but QQCore login service is stale.
* @param session - Refresh login session that owns progress messages and retry timestamps.
* @param container - Current online WebUI runtime; its device identity and environment must be preserved.
* @param reason - Latest QQ login-state evidence shown to Admin before the worker restart.
* @returns Fresh WebUI login status after the worker restart completes.
*/
private async restartNapcatWorkerForOnlineRefresh(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
reason: string,
) {
session.lastRestartedAt = Date.now();
session.onlineSourceWorkerRestartAttempted = true;
session.errorMessage = reason;
this.persistLoginSession(session);
this.publishScanResultEvent(
session,
'napcat-worker-restart',
'processing',
reason,
);
await this.restartNapcatForLogin(container, {
processOnly: true,
waitForReady: true,
});
return this.getLoginStatus(container, true);
}
/** /**
* NapCat WebUI quick QQ 线 * NapCat WebUI quick QQ 线
* @param session - fallback * @param session - fallback
@ -3300,8 +3604,9 @@ export class QqbotNapcatLoginService {
container: QqbotNapcatRuntime, container: QqbotNapcatRuntime,
options: NapcatRestartOptions = {}, options: NapcatRestartOptions = {},
) { ) {
const restartedByContainer = const restartedByContainer = options.processOnly
await this.containerService.restartRuntimeContainer(container); ? false
: await this.containerService.restartRuntimeContainer(container);
if (!restartedByContainer) { if (!restartedByContainer) {
try { try {
await this.postNapcat<Record<string, any> | null>( await this.postNapcat<Record<string, any> | null>(

View File

@ -29,14 +29,14 @@ type OnebotConfig = {
type NapcatConfig = { type NapcatConfig = {
bypass: { bypass: {
container: false; container: true;
hook: false; hook: true;
js: false; js: true;
module: false; module: true;
process: false; process: true;
window: false; window: true;
}; };
o3HookMode: 1; o3HookMode: 0;
packetBackend: 'auto'; packetBackend: 'auto';
packetServer: ''; packetServer: '';
}; };
@ -68,14 +68,14 @@ export class NapcatConfigWriterService {
}; };
const napcatConfig: NapcatConfig = { const napcatConfig: NapcatConfig = {
bypass: { bypass: {
container: false, container: true,
hook: false, hook: true,
js: false, js: true,
module: false, module: true,
process: false, process: true,
window: false, window: true,
}, },
o3HookMode: 1, o3HookMode: 0,
packetBackend: 'auto', packetBackend: 'auto',
packetServer: '', packetServer: '',
}; };

View File

@ -71,7 +71,7 @@ export class NapcatRuntimeProfileService {
dataDir: input.dataDir, dataDir: input.dataDir,
desktopProfileVersion: this.getString( desktopProfileVersion: this.getString(
'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION', 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION',
'desktop-cn-v8', 'desktop-cn-v20',
), ),
deviceIdentityId: input.deviceIdentityId, deviceIdentityId: input.deviceIdentityId,
imageRef: this.getString('QQBOT_NAPCAT_IMAGE', ''), imageRef: this.getString('QQBOT_NAPCAT_IMAGE', ''),
@ -162,7 +162,7 @@ export class NapcatRuntimeProfileService {
napcatConfigHash: input.protocolProfile.napcatConfigHash || null, napcatConfigHash: input.protocolProfile.napcatConfigHash || null,
napcatConfigJson: input.protocolProfile.napcatConfigJson || null, napcatConfigJson: input.protocolProfile.napcatConfigJson || null,
o3HookGrayEnabled: !!input.protocolProfile.o3HookGrayEnabled, o3HookGrayEnabled: !!input.protocolProfile.o3HookGrayEnabled,
o3HookMode: input.protocolProfile.o3HookMode ?? 1, o3HookMode: input.protocolProfile.o3HookMode ?? 0,
onebotConfigHash: input.protocolProfile.onebotConfigHash || null, onebotConfigHash: input.protocolProfile.onebotConfigHash || null,
onebotConfigJson: input.protocolProfile.onebotConfigJson || null, onebotConfigJson: input.protocolProfile.onebotConfigJson || null,
packetBackend: input.protocolProfile.packetBackend || 'auto', packetBackend: input.protocolProfile.packetBackend || 'auto',

View File

@ -49,12 +49,29 @@ type CreateManagedContainerOptions = {
startRemote?: boolean; startRemote?: boolean;
}; };
type NapcatWebuiCredentialCacheEntry = {
credential: string;
expiresAt: number;
};
const NAPCAT_WEBUI_CREDENTIAL_TTL_MS = 50 * 60 * 1000;
@Injectable() @Injectable()
export class QqbotNapcatContainerService { export class QqbotNapcatContainerService {
private readonly configWriterService: NapcatConfigWriterService; private readonly configWriterService: NapcatConfigWriterService;
private readonly runtimeProfileService: NapcatRuntimeProfileService; private readonly runtimeProfileService: NapcatRuntimeProfileService;
private readonly webuiCredentials: Record<
string,
NapcatWebuiCredentialCacheEntry | undefined
> = {};
private readonly webuiCredentialRequests: Record<
string,
Promise<string> | undefined
> = {};
/** /**
* QqbotNapcatContainerService * QqbotNapcatContainerService
* @param configService - Runtime configuration source for NAS SSH, Docker image, port pool, and profile defaults. * @param configService - Runtime configuration source for NAS SSH, Docker image, port pool, and profile defaults.
@ -894,13 +911,7 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
try { try {
const runtime = this.toRuntime(container); const runtime = this.toRuntime(container);
const credential = await this.getNapcatCredential(runtime); const status = await this.requestNapcatLoginStatus(runtime);
const status = await this.requestNapcat<NapcatLoginStatus>(
runtime,
'/api/QQLogin/CheckLoginStatus',
{},
credential,
);
const snapshot = this.toRuntimeStatusSnapshot( const snapshot = this.toRuntimeStatusSnapshot(
status, status,
containerOnline, containerOnline,
@ -1427,6 +1438,31 @@ fi
`DEVICE_ENV_PATH=${this.sh(input.deviceIdentity.deviceEnvPath)}`, `DEVICE_ENV_PATH=${this.sh(input.deviceIdentity.deviceEnvPath)}`,
].join('\n') + '\n' ].join('\n') + '\n'
: ''; : '';
const deviceProfileHeader = [
`NAPCAT_DMI_PRODUCT_NAME=${this.sh('imini Pro')}`,
`NAPCAT_DMI_SYS_VENDOR=${this.sh('MECHREVO')}`,
`NAPCAT_DMI_BOARD_VENDOR=${this.sh('MECHREVO')}`,
`NAPCAT_DMI_BOARD_NAME=${this.sh('imini Pro')}`,
`NAPCAT_DMI_BIOS_VENDOR=${this.sh(
'American Megatrends International, LLC.',
)}`,
`NAPCAT_DMI_BIOS_VERSION=${this.sh('imini Pro 1.10')}`,
`NAPCAT_DMI_MODALIAS=${this.sh(
'dmi:bvnAmericanMegatrendsInternational,LLC.:bvriminiPro1.10:bd03/31/2024:br1.10:efr1.10:svnMECHREVO:pniminiPro:pvrStandard:rvnMECHREVO:rniminiPro:rvrStandard:cvnMECHREVO:ct3:cvrDefaultstring:skuStandard:',
)}`,
`NAPCAT_DEVICE_KERNEL_RELEASE=${this.sh('6.8.0-60-generic')}`,
`NAPCAT_DEVICE_KERNEL_VERSION=${this.sh(
'#63-Ubuntu SMP PREEMPT_DYNAMIC Tue Apr 15 19:04:15 UTC 2025',
)}`,
`NAPCAT_DEVICE_PROC_VERSION=${this.sh(
'Linux version 6.8.0-60-generic (buildd@lcy02-amd64-001) (x86_64-linux-gnu-gcc (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0, GNU ld (GNU Binutils for Ubuntu) 2.42) #63-Ubuntu SMP PREEMPT_DYNAMIC Tue Apr 15 19:04:15 UTC 2025',
)}`,
`NAPCAT_DEVICE_CPU_MODEL=${this.sh(
'AMD Ryzen 7 8845H w/ Radeon 780M Graphics',
)}`,
`NAPCAT_DEVICE_UPTIME=${this.sh('7200.00 14400.00')}`,
`NAPCAT_DEVICE_TTY_ACTIVE=${this.sh('tty1')}`,
].join('\n');
const devicePrepareScript = input.deviceIdentity const devicePrepareScript = input.deviceIdentity
? ` ? `
mkdir -p "$(dirname "$DEVICE_ENV_PATH")" "$(dirname "$MACHINE_INFO_PATH")" mkdir -p "$(dirname "$DEVICE_ENV_PATH")" "$(dirname "$MACHINE_INFO_PATH")"
@ -1453,9 +1489,33 @@ mv "$MACHINE_INFO_TMP" "$MACHINE_INFO_PATH"
chmod 644 "$MACHINE_INFO_PATH" chmod 644 "$MACHINE_INFO_PATH"
` `
: ''; : '';
const deviceProfilePrepareScript = `
if [ -z "\${MACHINE_ID_PATH:-}" ]; then
MACHINE_ID_PATH="$DATA_DIR/machine-id"
fi
mkdir -p "$(dirname "$MACHINE_ID_PATH")"
if [ ! -s "$MACHINE_ID_PATH" ]; then
printf '%s' "$NAME" | sha256sum | cut -c 1-32 > "$MACHINE_ID_PATH"
fi
NAPCAT_DEVICE_MACHINE_ID="$(tr -d '\\r\\n' < "$MACHINE_ID_PATH" | cut -c 1-64)"
format_uuid_from_seed() {
uuid_hash="$(printf '%s' "$1" | sha256sum | awk '{print $1}')"
printf '%s-%s-%s-%s-%s' \\
"$(printf '%s' "$uuid_hash" | cut -c 1-8)" \\
"$(printf '%s' "$uuid_hash" | cut -c 9-12)" \\
"$(printf '%s' "$uuid_hash" | cut -c 13-16)" \\
"$(printf '%s' "$uuid_hash" | cut -c 17-20)" \\
"$(printf '%s' "$uuid_hash" | cut -c 21-32)"
}
if [ ! -s "$DATA_DIR/device-boot-id" ]; then
format_uuid_from_seed "$NAPCAT_DEVICE_MACHINE_ID:boot" > "$DATA_DIR/device-boot-id"
fi
NAPCAT_DEVICE_BOOT_ID="$(tr -d '\\r\\n' < "$DATA_DIR/device-boot-id" | cut -c 1-36)"
NAPCAT_DMI_PRODUCT_UUID="$(format_uuid_from_seed "$NAPCAT_DEVICE_MACHINE_ID:dmi")"
`;
const deviceRunFlags = input.deviceIdentity const deviceRunFlags = input.deviceIdentity
? ' --hostname "$NAPCAT_HOSTNAME" \\\n --mac-address "$NAPCAT_MAC_ADDRESS" \\\n -v "$MACHINE_ID_PATH:/etc/machine-id:ro" \\\n' ? ' --hostname "$NAPCAT_HOSTNAME" \\\n --mac-address "$NAPCAT_MAC_ADDRESS" \\\n -v "$MACHINE_ID_PATH:/etc/machine-id:ro" \\\n'
: ''; : ' -v "$MACHINE_ID_PATH:/etc/machine-id:ro" \\\n';
return ` return `
set -eu set -eu
@ -1471,10 +1531,12 @@ NAPCAT_SHM_SIZE=${this.sh(runtimeProfile.shmSize)}
${accountHeader} ${accountHeader}
${passwordHeader} ${passwordHeader}
${deviceHeader} ${deviceHeader}
${deviceProfileHeader}
mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs" "$DATA_DIR/cache" "$DATA_DIR/local-share" "$DATA_DIR/runtime" mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs" "$DATA_DIR/cache" "$DATA_DIR/local-share" "$DATA_DIR/runtime"
chmod 700 "$DATA_DIR" chmod 700 "$DATA_DIR"
chmod 700 "$DATA_DIR/runtime" chmod 700 "$DATA_DIR/runtime"
${devicePrepareScript} ${devicePrepareScript}
${deviceProfilePrepareScript}
${configWriteScript} ${configWriteScript}
@ -1483,9 +1545,29 @@ docker run -d \\
--name "$NAME" \\ --name "$NAME" \\
--restart unless-stopped \\ --restart unless-stopped \\
--init \\ --init \\
--cap-add SYS_ADMIN \\
--security-opt apparmor=unconfined \\
--security-opt seccomp=unconfined \\
--shm-size "$NAPCAT_SHM_SIZE" \\ --shm-size "$NAPCAT_SHM_SIZE" \\
-e NAPCAT_UID="$NAPCAT_UID" \\ -e NAPCAT_UID="$NAPCAT_UID" \\
-e NAPCAT_GID="$NAPCAT_GID" \\ -e NAPCAT_GID="$NAPCAT_GID" \\
-e NAPCAT_REQUIRE_DEVICE_PROFILE=1 \\
-e NAPCAT_DEVICE_MACHINE_ID="$NAPCAT_DEVICE_MACHINE_ID" \\
-e NAPCAT_DEVICE_BOOT_ID="$NAPCAT_DEVICE_BOOT_ID" \\
-e NAPCAT_DEVICE_KERNEL_RELEASE="$NAPCAT_DEVICE_KERNEL_RELEASE" \\
-e NAPCAT_DEVICE_KERNEL_VERSION="$NAPCAT_DEVICE_KERNEL_VERSION" \\
-e NAPCAT_DEVICE_PROC_VERSION="$NAPCAT_DEVICE_PROC_VERSION" \\
-e NAPCAT_DEVICE_CPU_MODEL="$NAPCAT_DEVICE_CPU_MODEL" \\
-e NAPCAT_DEVICE_UPTIME="$NAPCAT_DEVICE_UPTIME" \\
-e NAPCAT_DEVICE_TTY_ACTIVE="$NAPCAT_DEVICE_TTY_ACTIVE" \\
-e NAPCAT_DMI_PRODUCT_NAME="$NAPCAT_DMI_PRODUCT_NAME" \\
-e NAPCAT_DMI_PRODUCT_UUID="$NAPCAT_DMI_PRODUCT_UUID" \\
-e NAPCAT_DMI_SYS_VENDOR="$NAPCAT_DMI_SYS_VENDOR" \\
-e NAPCAT_DMI_BOARD_VENDOR="$NAPCAT_DMI_BOARD_VENDOR" \\
-e NAPCAT_DMI_BOARD_NAME="$NAPCAT_DMI_BOARD_NAME" \\
-e NAPCAT_DMI_BIOS_VENDOR="$NAPCAT_DMI_BIOS_VENDOR" \\
-e NAPCAT_DMI_BIOS_VERSION="$NAPCAT_DMI_BIOS_VERSION" \\
-e NAPCAT_DMI_MODALIAS="$NAPCAT_DMI_MODALIAS" \\
-e WEBUI_TOKEN="$WEBUI_TOKEN" \\ -e WEBUI_TOKEN="$WEBUI_TOKEN" \\
-e LANG=${runtimeProfile.locale} \\ -e LANG=${runtimeProfile.locale} \\
-e LC_ALL=${runtimeProfile.locale} \\ -e LC_ALL=${runtimeProfile.locale} \\
@ -1693,9 +1775,40 @@ ${file.content}EOF`;
/** /**
* NapCat * NapCat
* @param runtime - runtime 使 `webuiToken` * @param runtime - NapCat runtime使WebUI token Credential
* @returns NapCat WebUI Bearer Credential
*/ */
private async getNapcatCredential(runtime: QqbotNapcatRuntime) { private async getNapcatCredential(runtime: QqbotNapcatRuntime) {
const cacheKey = this.getNapcatCredentialCacheKey(runtime);
const cached = this.webuiCredentials[cacheKey];
if (cached && Date.now() < cached.expiresAt) {
return cached.credential;
}
const pending = this.webuiCredentialRequests[cacheKey];
if (pending) {
return pending;
}
const request = this.fetchNapcatCredential(runtime, cacheKey);
this.webuiCredentialRequests[cacheKey] = request;
try {
return await request;
} finally {
delete this.webuiCredentialRequests[cacheKey];
}
}
/**
* NapCat WebUI Bearer Credential
* @param runtime - NapCat runtime WebUI token `/api/auth/login`
* @param cacheKey - token single-flight
* @returns NapCat WebUI Credential
*/
private async fetchNapcatCredential(
runtime: QqbotNapcatRuntime,
cacheKey: string,
) {
const token = runtime.webuiToken || ''; const token = runtime.webuiToken || '';
const hash = createHash('sha256').update(`${token}.napcat`).digest('hex'); const hash = createHash('sha256').update(`${token}.napcat`).digest('hex');
const data = await this.requestNapcat<NapcatCredential>( const data = await this.requestNapcat<NapcatCredential>(
@ -1706,9 +1819,78 @@ ${file.content}EOF`;
if (!data.Credential) { if (!data.Credential) {
throwVbenError('NapCat WebUI 登录失败'); throwVbenError('NapCat WebUI 登录失败');
} }
this.webuiCredentials[cacheKey] = {
credential: data.Credential,
expiresAt: Date.now() + NAPCAT_WEBUI_CREDENTIAL_TTL_MS,
};
return data.Credential; return data.Credential;
} }
/**
* NapCat QQ WebUI Credential
* @param runtime - NapCat runtime WebUI token
* @returns NapCat WebUI QQ
*/
private async requestNapcatLoginStatus(runtime: QqbotNapcatRuntime) {
const credential = await this.getNapcatCredential(runtime);
try {
return await this.requestNapcat<NapcatLoginStatus>(
runtime,
'/api/QQLogin/CheckLoginStatus',
{},
credential,
);
} catch (err) {
if (!this.isNapcatCredentialRejected(err)) {
throw err;
}
this.clearNapcatCredential(runtime, credential);
const refreshedCredential = await this.getNapcatCredential(runtime);
return this.requestNapcat<NapcatLoginStatus>(
runtime,
'/api/QQLogin/CheckLoginStatus',
{},
refreshedCredential,
);
}
}
/**
* NapCat WebUI Credential
* @param runtime - NapCat runtime`id/baseUrl/token`
* @param rejectedCredential - NapCat Credential
*/
private clearNapcatCredential(
runtime: QqbotNapcatRuntime,
rejectedCredential?: string,
) {
const cacheKey = this.getNapcatCredentialCacheKey(runtime);
const cached = this.webuiCredentials[cacheKey];
if (rejectedCredential && cached?.credential !== rejectedCredential) {
return;
}
delete this.webuiCredentials[cacheKey];
}
/**
* NapCat WebUI Credential
* @param err - NapCat WebUI `Unauthorized` token
* @returns true Credential
*/
private isNapcatCredentialRejected(err: unknown) {
const message = this.toolsService.getErrorMessage(err);
return /Unauthorized|token is invalid/i.test(message);
}
/**
* NapCat WebUI Credential
* @param runtime - NapCat runtime`id/baseUrl` `webuiToken`
* @returns API credential 使
*/
private getNapcatCredentialCacheKey(runtime: QqbotNapcatRuntime) {
return [runtime.id || runtime.baseUrl, runtime.webuiToken || ''].join('\n');
}
/** /**
* NapCat * NapCat
* @param status - NapCat列表使 `loginError` * @param status - NapCat列表使 `loginError`

View File

@ -299,12 +299,33 @@ export class NapcatLoginStateStoreService {
const current = await this.loginSessionRepository.findOne({ const current = await this.loginSessionRepository.findOne({
where: { sessionKey: sessionId }, where: { sessionKey: sessionId },
}); });
if (!current || current.status === 'pending') return; if (!current) return;
const completedAt = new Date() as NapcatLoginSession['completedAt'];
if (current.status === 'pending') {
await this.loginSessionRepository.update(
{ sessionKey: sessionId },
{
completedAt,
loginStage: 'cancelled',
progressMessage: '扫码会话已取消',
sessionPayload: current.sessionPayload
? {
...current.sessionPayload,
errorMessage: '扫码会话已取消',
status: 'error',
}
: current.sessionPayload,
status: 'error',
},
);
return;
}
await this.loginSessionRepository.update( await this.loginSessionRepository.update(
{ sessionKey: sessionId }, { sessionKey: sessionId },
{ {
completedAt: new Date() as NapcatLoginSession['completedAt'], completedAt,
}, },
); );
} }

View File

@ -65,6 +65,75 @@ describe('AdminMenuService', () => {
expect.arrayContaining(['Blog:Theme:List', 'Blog:Theme:Import']), expect.arrayContaining(['Blog:Theme:List', 'Blog:Theme:Import']),
); );
}); });
it('returns and orders menus by the persisted sort field', async () => {
const service = new AdminMenuService({
find: jest.fn(async () => [
createMenu({
id: '2',
meta: { title: 'Second' },
name: 'Second',
path: '/second',
sort: 20,
type: 'menu',
}),
createMenu({
id: '1',
meta: { title: 'First' },
name: 'First',
path: '/first',
sort: 10,
type: 'menu',
}),
]),
} as any);
const user = {
roles: [
{
isDeleted: false,
roleCode: 'super',
status: 1,
},
],
} as AdminUser;
await expect(service.getRouteMenus(user)).resolves.toEqual([
expect.objectContaining({ name: 'First', sort: 10 }),
expect.objectContaining({ name: 'Second', sort: 20 }),
]);
});
it('persists menu sort values from create and update inputs', async () => {
const repository = {
create: jest.fn((input) => input),
save: jest.fn(),
update: jest.fn(),
};
const service = new AdminMenuService(repository as any);
await service.createMenu({
name: 'SortedMenu',
path: '/sorted',
sort: 8,
type: 'menu',
} as any);
await service.updateMenu('menu-1', {
name: 'SortedMenu',
path: '/sorted',
sort: 9,
type: 'menu',
} as any);
expect(repository.create).toHaveBeenCalledWith(
expect.objectContaining({ sort: 8 }),
);
expect(repository.update).toHaveBeenCalledWith(
{
id: 'menu-1',
},
expect.objectContaining({ sort: 9 }),
);
});
}); });
/** /**

View File

@ -10,6 +10,10 @@ describe('admin user avatar sql', () => {
join(process.cwd(), 'sql/vben-admin-init.sql'), join(process.cwd(), 'sql/vben-admin-init.sql'),
'utf8', 'utf8',
); );
const refactorSeedSql = readFileSync(
join(process.cwd(), 'sql/refactor-v3/01-seed-core.sql'),
'utf8',
);
it('adds avatar column for existing databases', () => { it('adds avatar column for existing databases', () => {
expect(avatarSql).toContain("COLUMN_NAME = 'avatar'"); expect(avatarSql).toContain("COLUMN_NAME = 'avatar'");
@ -25,4 +29,17 @@ describe('admin user avatar sql', () => {
); );
expect(initSql).not.toContain('`avatar` = VALUES(`avatar`)'); expect(initSql).not.toContain('`avatar` = VALUES(`avatar`)');
}); });
it('seeds the renamed administrator and removes the fake workspace menu', () => {
expect(initSql).toContain("'kwitsukasa', '123456', 'KwiTsukasa'");
expect(initSql).toContain("'/analytics', 'Asia/Shanghai'");
expect(initSql).toContain("'Analytics', '/analytics'");
expect(initSql).not.toContain("'vben', '123456'");
expect(initSql).not.toContain("'jack', '123456'");
expect(initSql).not.toContain("'Workspace'");
expect(initSql).not.toContain("'/workspace'");
expect(initSql).toContain('`username` = VALUES(`username`)');
expect(initSql).not.toContain('`password` = VALUES(`password`)');
expect(refactorSeedSql).not.toContain('password = VALUES(password)');
});
}); });

View File

@ -2,8 +2,10 @@ import { AdminUserService } from '../../src/modules/admin/identity/user/admin-us
describe('AdminUserService profile', () => { describe('AdminUserService profile', () => {
const userRepository = { const userRepository = {
create: jest.fn((input) => ({ ...input })),
findOne: jest.fn(), findOne: jest.fn(),
save: jest.fn(), save: jest.fn(),
update: jest.fn(),
}; };
const roleRepository = { const roleRepository = {
find: jest.fn(), find: jest.fn(),
@ -22,10 +24,33 @@ describe('AdminUserService profile', () => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
it('uses environment overview as the default home path for created users', async () => {
userRepository.findOne.mockResolvedValue(null);
roleRepository.find.mockResolvedValue([]);
await service.createUser({
realName: '新用户',
roleIds: [],
username: 'new-user',
});
expect(userRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
homePath: '/analytics',
username: 'new-user',
}),
);
expect(userRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
homePath: '/analytics',
}),
);
});
it('updates current profile fields including uploaded avatar url', async () => { it('updates current profile fields including uploaded avatar url', async () => {
const user = { const user = {
avatar: '', avatar: '',
homePath: '/workspace', homePath: '/analytics',
id: '2041700000000000001', id: '2041700000000000001',
realName: '旧姓名', realName: '旧姓名',
roles: [], roles: [],
@ -61,11 +86,51 @@ describe('AdminUserService profile', () => {
expect(result).toEqual(user); expect(result).toEqual(user);
}); });
it('falls back to environment overview when current profile clears home path', async () => {
const user = {
avatar: '',
homePath: '/profile',
id: '2041700000000000002',
realName: 'KwiTsukasa',
roles: [],
timezone: 'Asia/Shanghai',
username: 'kwitsukasa',
};
userRepository.findOne.mockResolvedValue(user);
await service.updateCurrentProfile(user.id, {
homePath: '',
});
expect(userRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
homePath: '/analytics',
}),
);
});
it('prevents deleting the renamed built-in administrator account', async () => {
userRepository.findOne.mockResolvedValue({
id: '2041700000000000002',
isDeleted: false,
username: 'kwitsukasa',
});
await expect(service.deleteUser('2041700000000000002')).rejects.toMatchObject(
{
response: expect.objectContaining({
msg: '不能删除内置管理员账号',
}),
},
);
expect(userRepository.update).not.toHaveBeenCalled();
});
it('serializes avatar and userId for Admin frontend user store', () => { it('serializes avatar and userId for Admin frontend user store', () => {
expect( expect(
service.serializeUser({ service.serializeUser({
avatar: '/api/minio/download?objectName=avatars%2Favatar.jpg', avatar: '/api/minio/download?objectName=avatars%2Favatar.jpg',
homePath: '/workspace', homePath: '/analytics',
id: '2041700000000000001', id: '2041700000000000001',
realName: '管理员', realName: '管理员',
roles: [], roles: [],
@ -74,7 +139,7 @@ describe('AdminUserService profile', () => {
} as any), } as any),
).toEqual({ ).toEqual({
avatar: '/api/minio/download?objectName=avatars%2Favatar.jpg', avatar: '/api/minio/download?objectName=avatars%2Favatar.jpg',
homePath: '/workspace', homePath: '/analytics',
id: '2041700000000000001', id: '2041700000000000001',
realName: '管理员', realName: '管理员',
roles: [], roles: [],

View File

@ -436,6 +436,15 @@ describe('NapCat device identity persistence', () => {
expect(createScript).toContain('--hostname "$NAPCAT_HOSTNAME"'); expect(createScript).toContain('--hostname "$NAPCAT_HOSTNAME"');
expect(createScript).toContain('--mac-address "$NAPCAT_MAC_ADDRESS"'); expect(createScript).toContain('--mac-address "$NAPCAT_MAC_ADDRESS"');
expect(createScript).toContain('-v "$MACHINE_ID_PATH:/etc/machine-id:ro"'); expect(createScript).toContain('-v "$MACHINE_ID_PATH:/etc/machine-id:ro"');
expect(createScript).toContain('--cap-add SYS_ADMIN \\');
expect(createScript).toContain('--security-opt apparmor=unconfined \\');
expect(createScript).toContain('--security-opt seccomp=unconfined \\');
expect(createScript).toContain(
'-e NAPCAT_DEVICE_MACHINE_ID="$NAPCAT_DEVICE_MACHINE_ID"',
);
expect(createScript).toContain(
'-e NAPCAT_DEVICE_CPU_MODEL="$NAPCAT_DEVICE_CPU_MODEL"',
);
expect(createScript).toContain( expect(createScript).toContain(
'-v "$DATA_DIR/runtime:/tmp/runtime-napcat"', '-v "$DATA_DIR/runtime:/tmp/runtime-napcat"',
); );
@ -518,6 +527,15 @@ describe('NapCat device identity persistence', () => {
expect(createScript).toContain('--hostname "$NAPCAT_HOSTNAME"'); expect(createScript).toContain('--hostname "$NAPCAT_HOSTNAME"');
expect(createScript).toContain('--mac-address "$NAPCAT_MAC_ADDRESS"'); expect(createScript).toContain('--mac-address "$NAPCAT_MAC_ADDRESS"');
expect(createScript).toContain('-v "$MACHINE_ID_PATH:/etc/machine-id:ro"'); expect(createScript).toContain('-v "$MACHINE_ID_PATH:/etc/machine-id:ro"');
expect(createScript).toContain('--cap-add SYS_ADMIN \\');
expect(createScript).toContain('--security-opt apparmor=unconfined \\');
expect(createScript).toContain('--security-opt seccomp=unconfined \\');
expect(createScript).toContain(
'-e NAPCAT_DMI_PRODUCT_NAME="$NAPCAT_DMI_PRODUCT_NAME"',
);
expect(createScript).toContain(
'-e NAPCAT_DEVICE_TTY_ACTIVE="$NAPCAT_DEVICE_TTY_ACTIVE"',
);
expect(createScript).toContain('-e LANG=zh_CN.UTF-8'); expect(createScript).toContain('-e LANG=zh_CN.UTF-8');
expect(createScript).toContain('-e LC_ALL=zh_CN.UTF-8'); expect(createScript).toContain('-e LC_ALL=zh_CN.UTF-8');
expect(createScript).toContain('-e TZ=Asia/Shanghai'); expect(createScript).toContain('-e TZ=Asia/Shanghai');

View File

@ -40,10 +40,110 @@ describe('NapCat Chinese Desktop Runtime image assets', () => {
expect(verify).toContain('/.dockerenv'); expect(verify).toContain('/.dockerenv');
expect(verify).toContain('wait_for_absent'); expect(verify).toContain('wait_for_absent');
expect(verify).toContain('/proc/1/cgroup'); expect(verify).toContain('/proc/1/cgroup');
expect(verify).toContain('/proc/1/cmdline');
expect(verify).toContain('/proc/1/sched');
expect(verify).toContain('/proc/1/status');
expect(verify).toContain('/proc/1/stat');
expect(verify).toContain('/proc/1/mountinfo');
expect(verify).toContain('/proc/self/mountinfo');
expect(verify).toContain('/proc/$kt_mountinfo_pid/mountinfo');
expect(verify).toContain('/proc/[0-9]*');
expect(verify).toContain('kt_is_mountinfo_target');
expect(verify).toContain('kt_target_argv1');
expect(verify).toContain(
'overlay|/vol1/docker|docker-init|/docker/containers|napcat-instances|btrfs|/dev/mapper/trim',
);
expect(verify).not.toContain('*qq*|*QQ*|*napcat*|*NapCat*|*Xvfb*');
expect(verify).not.toContain('case "$kt_target_cmdline" in');
expect(verify).toContain('/sys/class/dmi/id/product_name');
expect(verify).toContain('/sys/class/dmi/id/bios_vendor');
expect(verify).toContain('/sys/class/dmi/id/bios_version');
expect(verify).toContain('/etc/hosts');
expect(verify).toContain('XDG_CONFIG_HOME=/app/.config'); expect(verify).toContain('XDG_CONFIG_HOME=/app/.config');
expect(verify).toContain('Asia/Shanghai'); expect(verify).toContain('Asia/Shanghai');
}); });
it('waits for the mountinfo guard to converge before failing target process leaks', () => {
const verify = readSource('ci/napcat-desktop-cn/verify.sh');
expect(verify).toContain('MOUNTINFO_GUARD_TIMEOUT_SECONDS');
expect(verify).toContain('wait_for_mountinfo_guard_converged');
expect(verify).toContain('kt_mountinfo_guard_deadline=');
expect(verify).toContain('sleep 1');
expect(verify).toContain(
'assert_target_mountinfo_guard_converged "$kt_mountinfo_pid"',
);
expect(verify).not.toContain(
'assert_no_mountinfo_host_leak "/proc/$kt_mountinfo_pid/mountinfo"',
);
});
it('patches entrypoint device profile probes that QQCore opens at runtime', () => {
const dockerfile = readSource('ci/napcat-desktop-cn/Dockerfile');
const entrypointPatch = readSource(
'ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh',
);
const source = `${dockerfile}\n${entrypointPatch}`;
expect(source).toContain('entrypoint-device-profile.patch.sh');
expect(source).toContain('NAPCAT_REQUIRE_DEVICE_PROFILE');
expect(source).toContain('NAPCAT_DMI_BIOS_VENDOR');
expect(source).toContain('NAPCAT_DMI_BIOS_VERSION');
expect(source).toContain('NAPCAT_DMI_MODALIAS');
expect(source).toContain('NAPCAT_DEVICE_BOOT_ID');
expect(source).toContain('NAPCAT_DEVICE_KERNEL_RELEASE');
expect(source).toContain('NAPCAT_DEVICE_CPU_MODEL');
expect(source).toContain('NAPCAT_DEVICE_UPTIME');
expect(source).toContain('NAPCAT_DEVICE_TTY_ACTIVE');
expect(source).toContain('/proc/uptime');
expect(source).toContain('/proc/cpuinfo');
expect(source).toContain('/sys/devices/virtual/tty/tty0/active');
expect(source).toContain('kt_mountinfo_guard_loop');
expect(source).toContain('kt_is_mountinfo_target');
expect(source).toContain('kt_target_argv1');
expect(source).toContain('KT_FAKE_MOUNTINFO');
expect(source).toContain('/proc/$kt_mountinfo_pid/mountinfo');
expect(source).toContain(
'mount --bind "$KT_FAKE_MOUNTINFO" "$kt_mountinfo"',
);
expect(source).toContain('mountinfo-host-leak:$kt_mountinfo');
expect(source).toContain(
'overlay|/vol1/docker|docker-init|/docker/containers|napcat-instances|btrfs|/dev/mapper/trim',
);
expect(source).not.toContain(
'for mfile in /proc/self/mountinfo /proc/1/mountinfo',
);
expect(source).not.toContain('*qq*|*QQ*|*napcat*|*NapCat*|*Xvfb*');
expect(source).not.toContain('case "$kt_target_cmdline" in');
expect(source).toContain('mount --bind "$FAKE_CMDLINE" /proc/1/cmdline');
expect(source).toContain('kt_require_device_profile');
expect(source).toContain('exit 78');
expect(source).toContain('dmi-modalias');
expect(source).toContain('proc-devices-host-leak');
});
it('preserves API-written NapCat config during the first shell install', () => {
const entrypointPatch = readSource(
'ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh',
);
const verify = readSource('ci/napcat-desktop-cn/verify.sh');
expect(entrypointPatch).toContain('KT preserve mounted NapCat config');
expect(entrypointPatch).toContain(
"find NapCat.Shell -mindepth 1 -maxdepth 1 ! -name config",
);
expect(entrypointPatch).toContain(
'if [ ! -f \\"napcat/config/napcat.json\\" ]; then',
);
expect(entrypointPatch).toContain(
'cp -rf NapCat.Shell/config/* napcat/config/',
);
expect(verify).toContain('KT preserve mounted NapCat config');
expect(verify).toContain(
"! grep -q 'cp -rf NapCat.Shell/\\* napcat/' /app/entrypoint.sh",
);
});
it('stages source-built NapCat Shell artifacts for Docker build context', () => { it('stages source-built NapCat Shell artifacts for Docker build context', () => {
const script = readSource('scripts/napcat-desktop-cn-stage-build.mjs'); const script = readSource('scripts/napcat-desktop-cn-stage-build.mjs');
@ -56,6 +156,7 @@ describe('NapCat Chinese Desktop Runtime image assets', () => {
expect(script).toContain('jenkinsBuildUrl'); expect(script).toContain('jenkinsBuildUrl');
expect(script).toContain('packages/napcat-shell/dist'); expect(script).toContain('packages/napcat-shell/dist');
expect(script).toContain('fork-artifact.json'); expect(script).toContain('fork-artifact.json');
expect(script).toContain('entrypoint-device-profile.patch.sh');
expect(script).toContain('.kt-workspace/napcat-desktop-cn-build'); expect(script).toContain('.kt-workspace/napcat-desktop-cn-build');
expect(script).toContain('assertSafeOutputRoot'); expect(script).toContain('assertSafeOutputRoot');
expect(script).toContain('workspaceRoot'); expect(script).toContain('workspaceRoot');
@ -91,20 +192,28 @@ describe('NapCat Chinese Desktop Runtime image assets', () => {
expect(verify).toContain('getQQLoginRuntimeState'); expect(verify).toContain('getQQLoginRuntimeState');
expect(verify).toContain('qrcodeRevision'); expect(verify).toContain('qrcodeRevision');
expect(verify).toContain('needsLoginServiceReset'); expect(verify).toContain('needsLoginServiceReset');
expect(verify).toContain('重置已失效登录服务后重新生成二维码');
expect(verify).not.toContain('selfInfo?.online !== false'); expect(verify).not.toContain('selfInfo?.online !== false');
}); });
it('deploys the production API with the verified desktop-cn-v8 runtime profile', () => { it('deploys the production API with the verified desktop-cn-v20 runtime profile', () => {
const manifest = readSource('k8s/prod/api.yaml'); const manifest = readSource('k8s/prod/api.yaml');
expect(manifest).toContain('name: QQBOT_NAPCAT_IMAGE'); expect(manifest).toContain('name: QQBOT_NAPCAT_IMAGE');
expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v8'); expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v20');
expect(manifest).toContain('name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION'); expect(manifest).toContain('name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION');
expect(manifest).toContain('value: desktop-cn-v8'); expect(manifest).toContain('value: desktop-cn-v20');
expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v19');
expect(manifest).not.toContain('value: desktop-cn-v19');
expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v18');
expect(manifest).not.toContain('value: desktop-cn-v18');
expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v16');
expect(manifest).not.toContain('value: desktop-cn-v16');
expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v15');
expect(manifest).not.toContain('value: desktop-cn-v15');
expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v8');
expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v7'); 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-v4');
expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v3'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v3');
expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v2'); expect(manifest).not.toMatch(/kt-napcat-desktop-cn:desktop-cn-v2(?!\d)/);
}); });
}); });

View File

@ -264,7 +264,7 @@ describe('NapCat persistent login state contract', () => {
); );
}); });
it('does not mark a still-pending scan session completed when it is removed from the runtime cache', async () => { it('marks a cancelled pending scan session completed instead of restoring it as active', async () => {
const loginSessionRepository = createRepository<NapcatLoginSession>(); const loginSessionRepository = createRepository<NapcatLoginSession>();
const store = new NapcatLoginStateStoreService( const store = new NapcatLoginStateStoreService(
loginSessionRepository as any, loginSessionRepository as any,
@ -288,9 +288,17 @@ describe('NapCat persistent login state contract', () => {
expect(loginSessionRepository.rows[0]).toEqual( expect(loginSessionRepository.rows[0]).toEqual(
expect.objectContaining({ expect.objectContaining({
completedAt: null, completedAt: expect.any(Date),
loginStage: 'cancelled',
progressMessage: '扫码会话已取消',
sessionKey: 'pending-delete-session', sessionKey: 'pending-delete-session',
status: 'pending', status: 'error',
}),
);
expect(loginSessionRepository.rows[0].sessionPayload).toEqual(
expect.objectContaining({
errorMessage: '扫码会话已取消',
status: 'error',
}), }),
); );
}); });

View File

@ -30,10 +30,18 @@ describe('NapCat runtime promotion contract', () => {
); );
}); });
it('keeps a safe default runtime profile in the K8s manifest', () => { it('keeps the verified v20 runtime profile as the K8s manifest default', () => {
const manifest = readSource('k8s/prod/api.yaml'); const manifest = readSource('k8s/prod/api.yaml');
expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v8'); expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v20');
expect(manifest).toContain('value: desktop-cn-v8'); expect(manifest).toContain('value: desktop-cn-v20');
expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v19');
expect(manifest).not.toContain('value: desktop-cn-v19');
expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v18');
expect(manifest).not.toContain('value: desktop-cn-v18');
expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v16');
expect(manifest).not.toContain('value: desktop-cn-v16');
expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v15');
expect(manifest).not.toContain('value: desktop-cn-v15');
}); });
}); });

View File

@ -123,7 +123,7 @@ describe('NapCat runtime profile generation', () => {
}); });
expect(profile).toMatchObject({ expect(profile).toMatchObject({
desktopProfileVersion: 'desktop-cn-v8', desktopProfileVersion: 'desktop-cn-v20',
imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest', imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest',
locale: 'zh_CN.UTF-8', locale: 'zh_CN.UTF-8',
runtimeGid: 1101, runtimeGid: 1101,
@ -181,9 +181,9 @@ describe('NapCat runtime profile generation', () => {
}, },
protocolProfile: { protocolProfile: {
napcatConfigHash: 'napcat-hash', napcatConfigHash: 'napcat-hash',
napcatConfigJson: { o3HookMode: 1 }, napcatConfigJson: { o3HookMode: 0 },
o3HookGrayEnabled: false, o3HookGrayEnabled: false,
o3HookMode: 1, o3HookMode: 0,
onebotConfigHash: 'onebot-hash', onebotConfigHash: 'onebot-hash',
onebotConfigJson: { network: { websocketClients: [] } }, onebotConfigJson: { network: { websocketClients: [] } },
packetBackend: 'auto', packetBackend: 'auto',
@ -253,6 +253,15 @@ describe('NapCat runtime profile generation', () => {
reconnectInterval: 5000, reconnectInterval: 5000,
reportSelfMessage: false, reportSelfMessage: false,
}); });
expect(result.napcatConfig.bypass).toEqual({
container: true,
hook: true,
js: true,
module: true,
process: true,
window: true,
});
expect(result.napcatConfig.o3HookMode).toBe(0);
expect( expect(
result.files.find((file) => file.path === 'webui.json')?.content, result.files.find((file) => file.path === 'webui.json')?.content,
).toContain(webuiAuthValue); ).toContain(webuiAuthValue);

View File

@ -37,6 +37,8 @@ describe('QqbotNapcatLoginService', () => {
passwordResult?: null | Record<string, unknown>; passwordResult?: null | Record<string, unknown>;
quickError?: string; quickError?: string;
quickResult?: null | Record<string, unknown>; quickResult?: null | Record<string, unknown>;
restartError?: string;
restartResult?: null | Record<string, unknown>;
} = {}, } = {},
) => ) =>
jest jest
@ -50,6 +52,10 @@ describe('QqbotNapcatLoginService', () => {
if (options.passwordError) throw new Error(options.passwordError); if (options.passwordError) throw new Error(options.passwordError);
return options.passwordResult ?? null; return options.passwordResult ?? null;
} }
if (path === '/api/QQLogin/RestartNapCat') {
if (options.restartError) throw new Error(options.restartError);
return options.restartResult ?? null;
}
return null; return null;
}); });
@ -181,13 +187,14 @@ describe('QqbotNapcatLoginService', () => {
'qq-password', 'qq-password',
); );
expect(startScan).toHaveBeenCalledWith( expect(startScan).toHaveBeenCalledWith(
{ expect.objectContaining({
accountId: 'account-1', accountId: 'account-1',
expectedSelfId: '10001', expectedSelfId: '10001',
forceRelogin: true, forceRelogin: true,
loginPasswordAvailable: true,
loginPassword: 'qq-password', loginPassword: 'qq-password',
mode: 'refresh', mode: 'refresh',
}, }),
existingContainer, existingContainer,
); );
}); });
@ -276,9 +283,72 @@ describe('QqbotNapcatLoginService', () => {
expect(prepareReloginQrcode).toHaveBeenCalledTimes(1); expect(prepareReloginQrcode).toHaveBeenCalledTimes(1);
}); });
it('does not return a stale qrcode when reusing an active refresh session', async () => { it('starts a new refresh session after password is maintained on an account with an old pending session', async () => {
const account = {
id: 'account-1',
selfId: '10001',
};
const existingContainer = {
baseUrl: 'http://127.0.0.1:6103/',
hasExistingPrimaryBinding: true,
id: 'container-current',
name: 'napcat-10001',
};
const accountService = {
findByIdWithNapcatLoginSecret: jest.fn().mockResolvedValue(account),
getNapcatLoginPassword: jest.fn().mockReturnValue('qq-password'),
};
const containerService = {
prepareAccountContainer: jest.fn().mockResolvedValue(existingContainer),
};
const refreshService = new QqbotNapcatLoginService( const refreshService = new QqbotNapcatLoginService(
{ get: jest.fn() } as unknown as ConfigService, { get: jest.fn() } as unknown as ConfigService,
accountService as unknown as QqbotAccountService,
containerService as unknown as QqbotNapcatContainerService,
new ToolsService(),
);
(refreshService as any).sessions.set('session-without-password', {
accountId: 'account-1',
containerId: 'container-current',
containerName: 'napcat-10001',
createdAt: Date.now(),
errorMessage: '密码登录未完成:未配置 QQ 登录密码,开始生成二维码',
expiresAt: Date.now() + 60_000,
id: 'session-without-password',
mode: 'refresh',
status: 'pending',
webuiPort: 6103,
});
const prepareReloginQrcode = jest
.spyOn(refreshService as any, 'prepareReloginQrcode')
.mockResolvedValue(undefined);
const result = await refreshService.startRefresh('account-1');
expect(result.sessionId).not.toBe('session-without-password');
expect(accountService.findByIdWithNapcatLoginSecret).toHaveBeenCalledWith(
'account-1',
);
expect(accountService.getNapcatLoginPassword).toHaveBeenCalledWith(account);
expect(prepareReloginQrcode).toHaveBeenCalledWith(
expect.objectContaining({
accountId: 'account-1',
expectedSelfId: '10001',
status: 'pending',
}),
existingContainer,
'qq-password',
existingContainer.hasExistingPrimaryBinding,
);
});
it('does not return a stale qrcode when reusing an active refresh session', async () => {
const refreshService = new QqbotNapcatLoginService(
{
get: jest.fn((key: string) =>
key === 'NAPCAT_WEBUI_RESTART_DELAY_MS' ? '1' : undefined,
),
} as unknown as ConfigService,
{} as QqbotAccountService, {} as QqbotAccountService,
{} as QqbotNapcatContainerService, {} as QqbotNapcatContainerService,
new ToolsService(), new ToolsService(),
@ -1234,6 +1304,116 @@ describe('QqbotNapcatLoginService', () => {
); );
}); });
it('keeps scan pending when NapCat is login-positive before QQ number is readable', async () => {
const container = {
baseUrl: 'http://127.0.0.1:6103/',
id: 'container-login-wait-self-id',
name: 'napcat-login-wait-self-id',
};
const accountService = {
ensureScannedAccount: jest.fn().mockResolvedValue('account-1'),
};
const containerService = {
bindAccount: jest.fn().mockResolvedValue(undefined),
};
const loginService = new QqbotNapcatLoginService(
{ get: jest.fn() } as unknown as ConfigService,
accountService as unknown as QqbotAccountService,
containerService as unknown as QqbotNapcatContainerService,
new ToolsService(),
);
const session = (loginService as any).createSession({
container,
mode: 'create',
qrcode: 'https://txz.qq.com/p?k=ready&f=1600001615',
status: 'pending',
});
(loginService as any).sessions.set(session.id, session);
jest
.spyOn(loginService as any, 'getSessionContainer')
.mockResolvedValue(container);
jest
.spyOn(loginService as any, 'getLoginStatus')
.mockResolvedValue({ isLogin: true });
jest.spyOn(loginService as any, 'getLoginInfo').mockResolvedValue({
nick: 'Mirror',
online: true,
});
const result = await loginService.status(session.id);
expect(result.status).toBe('pending');
expect(result.errorMessage).toBe('NapCat 已登录,正在读取 QQ 号');
expect((loginService as any).sessions.get(session.id)).toEqual(
expect.objectContaining({
errorMessage: 'NapCat 已登录,正在读取 QQ 号',
status: 'pending',
}),
);
expect(accountService.ensureScannedAccount).not.toHaveBeenCalled();
expect(containerService.bindAccount).not.toHaveBeenCalled();
});
it('completes scan after a login-positive session later exposes QQ number', async () => {
const container = {
baseUrl: 'http://127.0.0.1:6103/',
id: 'container-login-late-self-id',
name: 'napcat-login-late-self-id',
};
const accountService = {
ensureScannedAccount: jest.fn().mockResolvedValue('account-1'),
};
const containerService = {
bindAccount: jest.fn().mockResolvedValue(undefined),
};
const loginService = new QqbotNapcatLoginService(
{ get: jest.fn() } as unknown as ConfigService,
accountService as unknown as QqbotAccountService,
containerService as unknown as QqbotNapcatContainerService,
new ToolsService(),
);
const session = (loginService as any).createSession({
container,
mode: 'create',
qrcode: 'https://txz.qq.com/p?k=ready&f=1600001615',
status: 'pending',
});
(loginService as any).sessions.set(session.id, session);
jest
.spyOn(loginService as any, 'getSessionContainer')
.mockResolvedValue(container);
jest
.spyOn(loginService as any, 'getLoginStatus')
.mockResolvedValue({ isLogin: true });
jest
.spyOn(loginService as any, 'getLoginInfo')
.mockResolvedValueOnce({
nick: 'Mirror',
online: true,
})
.mockResolvedValueOnce({
nick: 'Mirror',
online: true,
uin: '1914728559',
});
const pending = await loginService.status(session.id);
const completed = await loginService.status(session.id);
expect(pending.status).toBe('pending');
expect(completed.status).toBe('success');
expect(accountService.ensureScannedAccount).toHaveBeenCalledWith({
name: 'Mirror',
selfId: '1914728559',
});
expect(containerService.bindAccount).toHaveBeenCalledWith(
'account-1',
'container-login-late-self-id',
'1914728559',
);
expect(session.loginSelfIdMissingSince).toBeUndefined();
});
it('does not let the server-side qrcode monitor extend the original qrcode deadline on temporary errors', async () => { it('does not let the server-side qrcode monitor extend the original qrcode deadline on temporary errors', async () => {
const container = { const container = {
baseUrl: 'http://127.0.0.1:6103/', baseUrl: 'http://127.0.0.1:6103/',
@ -1419,7 +1599,7 @@ describe('QqbotNapcatLoginService', () => {
expect(session.qrcode).toBe('budget-qrcode'); expect(session.qrcode).toBe('budget-qrcode');
}); });
it('refreshes qrcode through WebUI when the source Docker container is online but QQ is offline', async () => { it('restarts the NapCat worker before refreshing qrcode when source Docker is online but QQ is offline', async () => {
const container = { const container = {
baseUrl: 'http://127.0.0.1:6103/', baseUrl: 'http://127.0.0.1:6103/',
id: 'container-online-source', id: 'container-online-source',
@ -1438,7 +1618,11 @@ describe('QqbotNapcatLoginService', () => {
ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), ensureScannedAccount: jest.fn().mockResolvedValue('account-1'),
}; };
const refreshService = new QqbotNapcatLoginService( const refreshService = new QqbotNapcatLoginService(
{ get: jest.fn() } as unknown as ConfigService, {
get: jest.fn((key: string) =>
key === 'NAPCAT_WEBUI_RESTART_DELAY_MS' ? '1' : undefined,
),
} as unknown as ConfigService,
accountService as unknown as QqbotAccountService, accountService as unknown as QqbotAccountService,
containerService as unknown as QqbotNapcatContainerService, containerService as unknown as QqbotNapcatContainerService,
new ToolsService(), new ToolsService(),
@ -1453,6 +1637,9 @@ describe('QqbotNapcatLoginService', () => {
status: 'pending', status: 'pending',
}); });
(refreshService as any).sessions.set(session.id, session); (refreshService as any).sessions.set(session.id, session);
jest
.spyOn((refreshService as any).toolsService, 'sleep')
.mockResolvedValue(undefined);
jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({
isOffline: true, isOffline: true,
isLogin: false, isLogin: false,
@ -1461,11 +1648,16 @@ describe('QqbotNapcatLoginService', () => {
const refreshOrGetQrcode = jest const refreshOrGetQrcode = jest
.spyOn(refreshService as any, 'refreshOrGetQrcode') .spyOn(refreshService as any, 'refreshOrGetQrcode')
.mockResolvedValue('online-source-qrcode'); .mockResolvedValue('online-source-qrcode');
const postNapcat = mockWebuiLoginPost(refreshService);
const result = await refreshService.refreshQrcode(session.id); const result = await refreshService.refreshQrcode(session.id);
expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled();
expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled();
expect(postNapcat).toHaveBeenCalledWith(
container,
'/api/QQLogin/RestartNapCat',
);
expect(refreshOrGetQrcode).toHaveBeenCalledWith(container, false, { expect(refreshOrGetQrcode).toHaveBeenCalledWith(container, false, {
fallbackStatus: expect.objectContaining({ isOffline: true }), fallbackStatus: expect.objectContaining({ isOffline: true }),
requireFresh: true, requireFresh: true,
@ -1475,6 +1667,68 @@ describe('QqbotNapcatLoginService', () => {
expect(result.qrcode).toBe('online-source-qrcode'); expect(result.qrcode).toBe('online-source-qrcode');
}); });
it('does not restart the NapCat worker repeatedly for the same online-source refresh session', async () => {
const container = {
baseUrl: 'http://127.0.0.1:6103/',
id: 'container-online-source',
name: 'napcat-10001',
sourceContainerOnline: true,
};
const containerService = {
ensureRuntimeLoginEnv: jest
.fn()
.mockResolvedValue({ changed: false, ok: true }),
findRuntimeById: jest.fn().mockResolvedValue(container),
resetRuntimeLoginState: jest.fn().mockResolvedValue(true),
restartRuntimeContainer: jest.fn().mockResolvedValue(true),
};
const accountService = {
ensureScannedAccount: jest.fn().mockResolvedValue('account-1'),
};
const refreshService = new QqbotNapcatLoginService(
{
get: jest.fn((key: string) =>
key === 'NAPCAT_WEBUI_RESTART_DELAY_MS' ? '1' : undefined,
),
} 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: false,
sourceContainerOnline: 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').mockResolvedValue({
isOffline: true,
isLogin: false,
loginError: 'NapCat 账号状态变更为离线',
});
jest
.spyOn(refreshService as any, 'refreshOrGetQrcode')
.mockResolvedValue('online-source-qrcode');
const postNapcat = mockWebuiLoginPost(refreshService);
await refreshService.refreshQrcode(session.id);
await refreshService.refreshQrcode(session.id);
expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled();
expect(
postNapcat.mock.calls.filter(
([, path]) => path === '/api/QQLogin/RestartNapCat',
),
).toHaveLength(1);
});
it('does not perform runtime env rebuild inside the refresh session', async () => { it('does not perform runtime env rebuild inside the refresh session', async () => {
const container = { const container = {
baseUrl: 'http://127.0.0.1:6103/', baseUrl: 'http://127.0.0.1:6103/',
@ -1573,12 +1827,13 @@ describe('QqbotNapcatLoginService', () => {
}); });
(refreshService as any).sessions.set(session.id, session); (refreshService as any).sessions.set(session.id, session);
jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({
isOffline: true,
isLogin: false, isLogin: false,
loginError: 'Docker 容器在线但 QQ 账号离线', loginError: 'Docker 容器在线但 QQ 账号离线',
}); });
jest const postNapcat = mockWebuiLoginPost(refreshService, {
.spyOn(refreshService as any, 'postNapcat') quickError: '快速登录未成功',
.mockRejectedValue(new Error('快速登录未成功')); });
jest jest
.spyOn(refreshService as any, 'refreshOrGetQrcode') .spyOn(refreshService as any, 'refreshOrGetQrcode')
.mockResolvedValue('online-source-qrcode'); .mockResolvedValue('online-source-qrcode');
@ -1589,6 +1844,10 @@ describe('QqbotNapcatLoginService', () => {
expect(containerService.runtimeLoginEnvMatches).not.toHaveBeenCalled(); expect(containerService.runtimeLoginEnvMatches).not.toHaveBeenCalled();
expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled();
expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled();
expect(postNapcat.mock.calls.map(([, path]) => path)).toEqual([
'/api/QQLogin/RestartNapCat',
'/api/QQLogin/SetQuickLogin',
]);
expect(session.status).toBe('pending'); expect(session.status).toBe('pending');
expect(session.qrcode).toBe('online-source-qrcode'); expect(session.qrcode).toBe('online-source-qrcode');
}); });
@ -2875,10 +3134,15 @@ describe('QqbotNapcatLoginService', () => {
'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001'; 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001';
session.passwordMd5 = '0123456789abcdef0123456789abcdef'; session.passwordMd5 = '0123456789abcdef0123456789abcdef';
(refreshService as any).sessions.set(session.id, session); (refreshService as any).sessions.set(session.id, session);
const flushSessionWrites = jest.spyOn(
(refreshService as any).loginSessionStore,
'flushSessionWrites',
);
await refreshService.cancel(session.id); await refreshService.cancel(session.id);
expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled();
expect(flushSessionWrites).toHaveBeenCalledWith(session.id);
expect(session.passwordMd5).toBeUndefined(); expect(session.passwordMd5).toBeUndefined();
expect(session.captchaUrl).toBeUndefined(); expect(session.captchaUrl).toBeUndefined();
}); });
@ -3629,6 +3893,62 @@ describe('QqbotNapcatLoginService', () => {
); );
}); });
it('persists failed scan sessions before cleaning runtime state', async () => {
const loginSessionRepository = createLoginSessionRepository();
const loginStateStore = new NapcatLoginStateStoreService(
loginSessionRepository as any,
);
const failService = new QqbotNapcatLoginService(
{ get: jest.fn() } as unknown as ConfigService,
{
ensureScannedAccount: jest.fn(),
} as unknown as QqbotAccountService,
{
removeUnboundCreateContainer: jest.fn().mockResolvedValue(undefined),
} as unknown as QqbotNapcatContainerService,
new ToolsService(),
loginStateStore,
);
const session = (failService as any).createSession({
container: {
id: 'container-fail-persist',
name: 'napcat-fail-persist',
webuiPort: 6110,
},
mode: 'create',
qrcode: 'login-qrcode',
status: 'pending',
});
(failService as any).sessions.set(session.id, session);
await loginStateStore.flushSessionWrites(session.id);
const flushSessionWrites = jest.spyOn(
loginStateStore,
'flushSessionWrites',
);
const result = await (failService as any).failSession(
session,
'NapCat 已登录但未返回 QQ 号',
);
expect(result.status).toBe('error');
expect(result.errorMessage).toBe('NapCat 已登录但未返回 QQ 号');
expect(flushSessionWrites).toHaveBeenCalledWith(session.id);
expect(loginSessionRepository.rows[0]).toEqual(
expect.objectContaining({
progressMessage: 'NapCat 已登录但未返回 QQ 号',
sessionKey: session.id,
status: 'error',
}),
);
expect(loginSessionRepository.rows[0].completedAt).toEqual(
expect.any(Date),
);
expect(loginSessionRepository.rows[0].sessionPayload?.status).toBe(
'error',
);
});
it('keeps an API-expired refresh session pending while NapCat still exposes the same live qrcode', async () => { it('keeps an API-expired refresh session pending while NapCat still exposes the same live qrcode', async () => {
const loginSessionRepository = createLoginSessionRepository(); const loginSessionRepository = createLoginSessionRepository();
const loginStateStore = new NapcatLoginStateStoreService( const loginStateStore = new NapcatLoginStateStoreService(
@ -4052,6 +4372,127 @@ describe('QqbotNapcatLoginService', () => {
expect(getQrcode).not.toHaveBeenCalled(); expect(getQrcode).not.toHaveBeenCalled();
}); });
it('auto refreshes a pending refresh session when WebUI has no cached qrcode', async () => {
(service as any).sessions.set('session-auto-refresh-qrcode', {
accountId: 'account-1',
containerId: 'container-auto-refresh-qrcode',
containerName: 'napcat-auto-refresh-qrcode',
createdAt: Date.now(),
errorMessage: 'NapCat 正在重新生成二维码,请稍后刷新或等待自动更新',
expiresAt: Date.now() + 60_000,
expectedSelfId: '10001',
id: 'session-auto-refresh-qrcode',
mode: 'refresh',
status: 'pending',
webuiPort: 6106,
});
const container = { id: 'container-auto-refresh-qrcode' };
const loginStatus = {
isLogin: false,
loginError: '网络连接异常!',
};
jest
.spyOn(service as any, 'getSessionContainer')
.mockResolvedValue(container);
jest
.spyOn(service as any, 'getLoginStatus')
.mockResolvedValue(loginStatus);
const getQrcode = jest
.spyOn(service as any, 'getQrcode')
.mockRejectedValue(new Error('NapCat 未返回登录二维码'));
const refreshOrGetQrcode = jest
.spyOn(service as any, 'refreshOrGetQrcode')
.mockResolvedValue('auto-refresh-qrcode');
const result = await service.status('session-auto-refresh-qrcode');
expect(result.status).toBe('pending');
expect(result.qrcode).toBe('auto-refresh-qrcode');
expect(result.errorMessage).toBeUndefined();
expect(getQrcode).not.toHaveBeenCalled();
expect(refreshOrGetQrcode).toHaveBeenCalledWith(container, false, {
fallbackStatus: loginStatus,
requireFresh: true,
staleQrcode: undefined,
});
expect(
(service as any).sessions.get('session-auto-refresh-qrcode')
.lastQrcodeRefreshAt,
).toEqual(expect.any(Number));
});
it('restarts the NapCat worker once before status auto-refresh for a restored online-source refresh session', async () => {
const container = {
baseUrl: 'http://127.0.0.1:6106/',
id: 'container-status-worker-restart',
name: 'napcat-10001',
sourceContainerOnline: true,
};
const containerService = {
findRuntimeById: jest.fn().mockResolvedValue(container),
restartRuntimeContainer: jest.fn().mockResolvedValue(true),
};
const accountService = {
markQqLoginStatus: jest.fn().mockResolvedValue(undefined),
};
const refreshService = new QqbotNapcatLoginService(
{
get: jest.fn((key: string) =>
key === 'NAPCAT_WEBUI_RESTART_DELAY_MS' ? '1' : undefined,
),
} as unknown as ConfigService,
accountService as unknown as QqbotAccountService,
containerService as unknown as QqbotNapcatContainerService,
new ToolsService(),
);
(refreshService as any).sessions.set('session-status-worker-restart', {
accountId: 'account-1',
containerId: container.id,
containerName: container.name,
createdAt: Date.now(),
errorMessage: 'NapCat 正在重新生成二维码,请稍后刷新或等待自动更新',
expiresAt: Date.now() + 60_000,
expectedSelfId: '10001',
id: 'session-status-worker-restart',
mode: 'refresh',
sourceContainerOnline: true,
status: 'pending',
webuiPort: 6106,
});
const loginStatus = {
isLogin: false,
isOffline: true,
loginError: 'NapCat 账号状态变更为离线',
};
jest
.spyOn(refreshService as any, 'getSessionContainer')
.mockResolvedValue(container);
jest
.spyOn(refreshService as any, 'getLoginStatus')
.mockResolvedValue(loginStatus);
jest
.spyOn(refreshService as any, 'refreshOrGetQrcode')
.mockResolvedValue('status-worker-restart-qrcode');
const postNapcat = mockWebuiLoginPost(refreshService);
const firstResult = await refreshService.status(
'session-status-worker-restart',
);
const secondResult = await refreshService.status(
'session-status-worker-restart',
);
expect(firstResult.status).toBe('pending');
expect(firstResult.qrcode).toBe('status-worker-restart-qrcode');
expect(secondResult.status).toBe('pending');
expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled();
expect(
postNapcat.mock.calls.filter(
([, path]) => path === '/api/QQLogin/RestartNapCat',
),
).toHaveLength(1);
});
it('normalizes login status to offline when login info reports offline', async () => { it('normalizes login status to offline when login info reports offline', async () => {
jest jest
.spyOn(service as any, 'postNapcat') .spyOn(service as any, 'postNapcat')

View File

@ -82,4 +82,240 @@ describe('QqbotNapcatContainerService runtime status', () => {
}), }),
); );
}); });
it('reuses WebUI credential when checking the same running container repeatedly', async () => {
const repository = {
update: jest.fn(),
};
const service = createService(repository);
const requestNapcat = jest
.spyOn(service as any, 'requestNapcat')
.mockImplementation(async (_runtime, path: string) => {
if (path === '/api/auth/login') {
return { Credential: 'credential-1' };
}
if (path === '/api/QQLogin/CheckLoginStatus') {
return {
isLogin: true,
isOffline: false,
loginError: '',
online: true,
qrcodeurl: '',
};
}
throw new Error(`unexpected path ${path}`);
});
const container = {
baseUrl: 'http://127.0.0.1:6100/',
id: 'container-1',
lastError: null,
name: 'napcat-1',
status: 'running',
webuiPort: 6100,
webuiToken: 'token',
} as any;
await service.inspectRuntimeStatus(container);
await service.inspectRuntimeStatus(container);
expect(
requestNapcat.mock.calls.filter(
([, path]: unknown[]) => path === '/api/auth/login',
),
).toHaveLength(1);
expect(
requestNapcat.mock.calls.filter(
([, path]: unknown[]) => path === '/api/QQLogin/CheckLoginStatus',
),
).toHaveLength(2);
});
it('deduplicates concurrent WebUI credential requests for the same container', async () => {
const repository = {
update: jest.fn(),
};
const service = createService(repository);
const requestNapcat = jest
.spyOn(service as any, 'requestNapcat')
.mockImplementation(async (_runtime, path: string) => {
if (path === '/api/auth/login') {
await new Promise((resolve) => setTimeout(resolve, 1));
return { Credential: 'credential-1' };
}
if (path === '/api/QQLogin/CheckLoginStatus') {
return {
isLogin: true,
isOffline: false,
loginError: '',
online: true,
qrcodeurl: '',
};
}
throw new Error(`unexpected path ${path}`);
});
const container = {
baseUrl: 'http://127.0.0.1:6100/',
id: 'container-1',
lastError: null,
name: 'napcat-1',
status: 'running',
webuiPort: 6100,
webuiToken: 'token',
} as any;
await Promise.all([
service.inspectRuntimeStatus(container),
service.inspectRuntimeStatus(container),
]);
expect(
requestNapcat.mock.calls.filter(
([, path]: unknown[]) => path === '/api/auth/login',
),
).toHaveLength(1);
expect(
requestNapcat.mock.calls.filter(
([, path]: unknown[]) => path === '/api/QQLogin/CheckLoginStatus',
),
).toHaveLength(2);
});
it('refreshes cached WebUI credential once when NapCat rejects status auth', async () => {
const repository = {
update: jest.fn(),
};
const service = createService(repository);
let credentialIndex = 0;
let statusChecks = 0;
const requestNapcat = jest
.spyOn(service as any, 'requestNapcat')
.mockImplementation(
async (_runtime, path: string, _body, credential?: string) => {
if (path === '/api/auth/login') {
credentialIndex += 1;
return { Credential: `credential-${credentialIndex}` };
}
if (path === '/api/QQLogin/CheckLoginStatus') {
statusChecks += 1;
if (statusChecks > 1 && credential === 'credential-1') {
throw new Error('Unauthorized');
}
return {
isLogin: true,
isOffline: false,
loginError: '',
online: true,
qrcodeurl: '',
};
}
throw new Error(`unexpected path ${path}`);
},
);
const container = {
baseUrl: 'http://127.0.0.1:6100/',
id: 'container-1',
lastError: null,
name: 'napcat-1',
status: 'running',
webuiPort: 6100,
webuiToken: 'token',
} as any;
await service.inspectRuntimeStatus(container);
const snapshot = await service.inspectRuntimeStatus(container);
expect(snapshot).toEqual(
expect.objectContaining({
qqLoginStatus: 'online',
webuiOnline: true,
}),
);
expect(
requestNapcat.mock.calls.filter(
([, path]: unknown[]) => path === '/api/auth/login',
),
).toHaveLength(2);
expect(
requestNapcat.mock.calls.filter(
([, path]: unknown[]) => path === '/api/QQLogin/CheckLoginStatus',
),
).toHaveLength(3);
});
it('keeps a refreshed credential when a later stale status request is rejected', async () => {
const repository = {
update: jest.fn(),
};
const service = createService(repository);
let credentialIndex = 0;
let staleMode = false;
let staleStatusChecks = 0;
let releaseSecondStaleReject: (() => void) | undefined;
const secondStaleReject = new Promise<void>((resolve) => {
releaseSecondStaleReject = resolve;
});
const requestNapcat = jest
.spyOn(service as any, 'requestNapcat')
.mockImplementation(
async (_runtime, path: string, _body, credential?: string) => {
if (path === '/api/auth/login') {
credentialIndex += 1;
return {
Credential:
credentialIndex === 1
? 'credential-old'
: 'credential-new',
};
}
if (path === '/api/QQLogin/CheckLoginStatus') {
if (staleMode && credential === 'credential-old') {
staleStatusChecks += 1;
if (staleStatusChecks === 1) {
throw new Error('Unauthorized');
}
await secondStaleReject;
throw new Error('Unauthorized');
}
if (staleMode && credential === 'credential-new') {
releaseSecondStaleReject?.();
}
return {
isLogin: true,
isOffline: false,
loginError: '',
online: true,
qrcodeurl: '',
};
}
throw new Error(`unexpected path ${path}`);
},
);
const container = {
baseUrl: 'http://127.0.0.1:6100/',
id: 'container-1',
lastError: null,
name: 'napcat-1',
status: 'running',
webuiPort: 6100,
webuiToken: 'token',
} as any;
await service.inspectRuntimeStatus(container);
staleMode = true;
await Promise.all([
service.inspectRuntimeStatus(container),
service.inspectRuntimeStatus(container),
]);
expect(
requestNapcat.mock.calls.filter(
([, path]: unknown[]) => path === '/api/auth/login',
),
).toHaveLength(2);
});
}); });

View File

@ -505,9 +505,32 @@ describe('QqbotNapcatContainerService', () => {
expect(script).toContain("NAPCAT_GID='1101'"); expect(script).toContain("NAPCAT_GID='1101'");
expect(script).toContain("NAPCAT_SHM_SIZE='512m'"); expect(script).toContain("NAPCAT_SHM_SIZE='512m'");
expect(script).toContain('--init \\'); expect(script).toContain('--init \\');
expect(script).toContain('--cap-add SYS_ADMIN \\');
expect(script).toContain('--security-opt apparmor=unconfined \\');
expect(script).toContain('--security-opt seccomp=unconfined \\');
expect(script).toContain('--shm-size "$NAPCAT_SHM_SIZE"'); expect(script).toContain('--shm-size "$NAPCAT_SHM_SIZE"');
expect(script).toContain('-e NAPCAT_UID="$NAPCAT_UID"'); expect(script).toContain('-e NAPCAT_UID="$NAPCAT_UID"');
expect(script).toContain('-e NAPCAT_GID="$NAPCAT_GID"'); expect(script).toContain('-e NAPCAT_GID="$NAPCAT_GID"');
expect(script).toContain('-e NAPCAT_REQUIRE_DEVICE_PROFILE=1');
expect(script).toContain('-e NAPCAT_DMI_SYS_VENDOR="$NAPCAT_DMI_SYS_VENDOR"');
expect(script).toContain(
'-e NAPCAT_DMI_PRODUCT_UUID="$NAPCAT_DMI_PRODUCT_UUID"',
);
expect(script).toContain(
'-e NAPCAT_DEVICE_BOOT_ID="$NAPCAT_DEVICE_BOOT_ID"',
);
expect(script).toContain(
'-e NAPCAT_DEVICE_KERNEL_RELEASE="$NAPCAT_DEVICE_KERNEL_RELEASE"',
);
expect(script).toContain(
'-e NAPCAT_DEVICE_CPU_MODEL="$NAPCAT_DEVICE_CPU_MODEL"',
);
expect(script).toContain(
'-e NAPCAT_DEVICE_UPTIME="$NAPCAT_DEVICE_UPTIME"',
);
expect(script).toContain(
'-e NAPCAT_DEVICE_TTY_ACTIVE="$NAPCAT_DEVICE_TTY_ACTIVE"',
);
expect(script).toContain('-e LANG=zh_CN.UTF-8'); expect(script).toContain('-e LANG=zh_CN.UTF-8');
expect(script).toContain('-e LC_ALL=zh_CN.UTF-8'); expect(script).toContain('-e LC_ALL=zh_CN.UTF-8');
expect(script).toContain('-e LANGUAGE=zh_CN:zh'); expect(script).toContain('-e LANGUAGE=zh_CN:zh');