fix: 收敛NapCat更新登录恢复链路

This commit is contained in:
sunlei 2026-06-22 14:58:59 +08:00
parent 832eeeeec7
commit 99bf52163f
16 changed files with 1102 additions and 1487 deletions

10
API.md
View File

@ -361,9 +361,9 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
账号保存支持可选 `encryptedLoginPassword`,用于 NapCat 密码登录。前端必须先通过 `/auth/password-public-key` 获取公钥并使用 RSA-OAEP 加密,不传明文 `loginPassword`;后端必须使用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密落库,空值和公开默认值会被拒绝,不在列表/详情中返回。账号列表里的 `connectStatus` 只表示 OneBot 反向 WS`napcat.oneBotOnline`、`napcat.containerOnline`、`napcat.webuiOnline`、`napcat.qqLoginStatus`、`napcat.qqLoginMessage` 分别表示 OneBot、容器、WebUI 和 QQ 登录态,`webuiOnline=null` 表示本次使用缓存且未重新探测 WebUI`qqLoginMessage` 只承载真实 QQ 登录态消息WebUI 配置缺失或请求异常只放在 `lastError`
扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。已有账号的更新登录会先重启目标 NapCat 容器尝试 `ACCOUNT`/`-q` 快速登录;目标账号在线则直接完成会话。没有历史登录态的新容器会跳过快速登录,优先尝试保存的登录密码;快速登录失败后,如果账号保存了登录密码,会临时注入 `NAPCAT_QUICK_PASSWORD``QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询密码登录结果,准备阶段的扫码会话会持续续期,避免后台密码登录未完成时前端先判过期。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码等待窗口加缓冲)后,`/qqbot/account/scan/status` 会自动恢复普通登录态检测,不再永久停留在“正在尝试密码登录”;`/qqbot/account/scan/events` 在进程内事件缓存丢失时会先推送当前会话快照。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。验证码和新设备验证这类真人交互态使用 `NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS`(默认 15 分钟,且至少不短于普通二维码 TTL续期普通登录二维码仍使用 `NAPCAT_LOGIN_QR_EXPIRE_MS`。`/qqbot/account/scan/status` 遇到 NapCat 只返回“需要验证码/继续完成验证/安全验证”但不带 URL 时,会先从当前容器日志提取 `proofWaterUrl`,提取不到则保持验证码处理中而不切到二维码兜底;会话已有 `captchaUrl` 后,同类状态仍保持 `pending` 和原 `captchaUrl`。密码登录成功后会重建容器移除该运行态密码,清理失败则本次登录失败;密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,再进入重置登录态和二维码兜底流程。看门狗自动登录使用同样的 quick -> password 顺序,但不会自动进入扫码阶段
扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。已有账号的更新登录不会通过 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 登录或扫码登录
密码验证码通过后如果 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` 默认镜像。
@ -373,9 +373,11 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
| 方法 | 路径 | 说明 |
| ----- | ----------------------------------------- | ------------------------------------------------------------------------------------- |
| `GET` | `/qqbot/napcat/runtime/detail?accountId=` | 读取账号 NapCat runtime/protocol/session behavior profile、风险降载和最近登录事件证据 |
| `GET` | `/qqbot/napcat/runtime/detail?accountId=` | 读取账号 NapCat runtime/protocol/session behavior profile、风险降载和历史登录事件兼容表状态 |
该接口只返回脱敏后的运行态证据,供 Admin 排查镜像、locale、shm、配置 hash、漂移状态、风险模式和 watchdog 恢复状态;不会返回 WebUI token、reverse WS token、QQ 登录密码、SSH 私钥或运行态密码环境。账号列表只挂载 `napcat.profileStatus`、`napcat.runtimeProfile` 等摘要字段不触发登录、重建或修复动作。watchdog 自动恢复只允许 quick -> password遇到二维码、验证码或新设备验证会挂起并记录 `recovery_suspended`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_login_event` 实体和表仅作为历史 schema 兼容保留watchdog 不再写入 quick/password 恢复事件,也不再依赖该表判断是否恢复登录。
外发消息不直接抢发:后端会按 `QQBOT_SEND_GLOBAL_INTERVAL_MS`、`QQBOT_SEND_TARGET_INTERVAL_MS` 和 `QQBOT_SEND_JITTER_MS` 预约发送窗口,默认全局 2500ms、同会话 8000ms、抖动 0-800ms如果等待超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS`,本次发送会在下发前被拒绝。在线命令和自动回复规则会叠加运行时保底冷却,默认命令 5000ms、规则 30000ms复读机默认连续 4 次相同普通文本才触发,同一会话默认 10 分钟内只复读一次,并限制普通文本长度,减少自动行为被风控识别的概率。

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` 证据,不能渲染成健康假象;第一版不暴露重启、部署、迁移、容器重建、插件启停或代理切换等写操作。
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 quick -> password 熔断,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network也不做账号级每小时/每日累计发送预算。
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也不做账号级每小时/每日累计发送预算。
## 启动
@ -167,10 +167,10 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow
- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表拆开展示 OneBot、容器、WebUI 和 QQ 登录态,心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态;`qqLoginMessage` 只展示 QQ 登录态消息WebUI 配置或请求错误留在 `lastError`
- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。
- NapCat 账号新增/编辑支持可选 QQ 登录密码Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。
- NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。`ACCOUNT` 只负责指定快速登录账号;如果数据卷内没有该 QQ 历史登录记录,后端会跳过快速登录并优先尝试账号保存的登录密码,密码登录会`QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果,准备中的扫码会话会续期,避免后台密码登录未结束时前端先过期;如果 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码登录等待窗口加缓冲)后会自动恢复为普通状态检测,不会永久卡在“正在尝试密码登录”。如果 NapCat 返回 `proofWaterUrl` 或日志出现“需要验证码”,会保持会话 pending 并把腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`,不是让用户只打开外链;验证码和新设备验证这类真人交互态使用 `NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS`(默认 15 分钟)续期,普通二维码仍使用 `NAPCAT_LOGIN_QR_EXPIRE_MS`;状态轮询遇到验证码文案但缺少 URL 时会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底;密码登录成功后会移除运行态 `NAPCAT_QUICK_PASSWORD`,清理失败则本次登录失败,避免成功态残留明文 env密码登录也失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时才重置登录态并生成二维码。Admin「更新登录」的 SSE 步骤顺序按实际路径为 `quick-login-*`(已有历史会话)-> `password-login-*` / `password-login-captcha` -> `password-env-cleanup` -> `relogin-reset/qrcode/waiting-scan`SSE 事件缓存因 Pod 重启丢失时,新订阅会收到当前会话快照。
- NapCat 容器为已知 `selfId` 创建/重建时会一次性注入 `ACCOUNT` 等必要 env容器重启崩溃/重启策略/宿主重启)可复用持久化会话,但硬踢 `登录已失效` 仍需人工登录。Admin「更新登录」不通过 Docker 重建、重启或补 env 刷新登录态:只要源容器在线,就保持同一容器并通过 NapCat WebUI `SetQuickLogin -> PasswordLogin -> RefreshQRcode/GetQQLoginQrcode` 推进原弹窗流程;只有 Docker 容器离线或缺失时,容器准备阶段才创建/重建并一次性注入 env。快速登录失败后如果账号保存了登录密码后端使用解密密码计算 MD5 调 `/api/QQLogin/PasswordLogin`,不会把密码写入运行态 env也没有成功后的 env 清理步骤;密码登录`QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果。准备中的扫码会话会续期,重复调用更新登录会复用同一 pending `sessionId`,不会再次启动 quick/password/二维码准备。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS` 后会自动恢复为普通状态检测。验证码和新设备验证保持同一会话 pending腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`;状态轮询遇到验证码文案但缺少 URL 时会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。Admin SSE 步骤顺序按实际路径为 `quick-login-*` -> `password-login-*` / `password-login-captcha` / 新设备验证 -> `qrcode/waiting-scan` -> `login-success|login-failed`SSE 事件缓存因 Pod 重启丢失时,新订阅会收到当前会话快照。
- NapCat 设备身份按账号持久化到 `napcat_device_identity`:同一账号重建容器会复用数据目录、`pc-<8hex>` hostname、machine-id 和 `02:42:*` MACDocker run 会注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id`,并同步写入 QQNT Linux `machine-info`,使 `/etc/machine-id`、Docker MAC 和 QQNT 本地设备缓存保持一致。当前策略名为 `qqnt-visible-hostname-v1` / `docker-bridge-mac-v1`
- NapCat 新设备验证走同一 scan session`CaptchaLogin` 返回 `needNewDevice` 后,后端继续调用 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`Admin/SSE 分开展示 `captchaUrl`、`newDeviceQrcode`、已扫码、确认中、验证成功、登录成功/失败等中文进度,不把 `jumpUrl` 当作唯一完成入口。
- NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000``QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后先尝试 `ACCOUNT` 历史会话快速登录,再尝试账号保存的登录密码,仍失败时写入离线原因并复用 `super` 站内信告警;看门狗不自动进入扫码阶段
- NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000``QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后只写入离线原因并复用 `super` 站内信告警,恢复登录必须由管理员在 Admin 手动触发「更新登录」
- BangDream 当前源码根目录是 `src/modules/qqbot/plugins/bangdream/src`;按第三期插件结构放置真实职责代码:业务在 `domain/*`,编排在 `application`,操作在 `operations`,外部 API 在 `infrastructure/integration`,缓存/静态修正在 `infrastructure/storage`,字典和静态配置在 `config`,视觉渲染公共件在 `theme`;不要恢复旧 `tsugu` 层级、旧大桶目录、纯 re-export 转接文件或空 `.gitkeep` 目录壳。
- BangDream 在线命令以 `plugins/bangdream/plugin.json` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 manifest/command-SQL 测试。
- BangDream event stage 大图必须保持分页拆图行为,线上 smoke 关注 `imageCount=5`,避免大 canvas OOM 回归。

View File

@ -1,8 +1,5 @@
import type { QqbotAccount } from '../../infrastructure/persistence/account/qqbot-account.entity';
import type {
QqbotAccountListItem,
QqbotConnectionRole,
} from '../../contract/qqbot.types';
import type { QqbotAccountListItem } from '../../contract/qqbot.types';
export const QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT = Symbol(
'QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT',
@ -10,14 +7,6 @@ export const QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT = Symbol(
export type QqbotAccountNapcatRuntimeActions = {
clearQqLoginError(selfId: string): Promise<void>;
getLoginPassword(
account: Pick<QqbotAccount, 'napcatLoginPasswordSecret'>,
): string;
markOnline(
selfId: string,
clientRole: QqbotConnectionRole,
lastError?: null | string,
): Promise<void>;
markQqLoginOffline(selfId: string, lastError: string): Promise<void>;
publishOfflineNotice(
selfId: string,
@ -29,7 +18,6 @@ export type QqbotAccountNapcatRuntimeActions = {
export type QqbotAccountNapcatRuntimePort = {
appendRuntime(
accounts: QqbotAccount[],
options: { autoLogin?: boolean },
actions: QqbotAccountNapcatRuntimeActions,
): Promise<QqbotAccountListItem[]>;
removeAccountContainers(accountId: string): Promise<{

View File

@ -475,7 +475,7 @@ export class QqbotAccountService {
/**
* 线线 +
* 线/
* 线
* 线/
*/
async runOfflineWatchdog(): Promise<{ checked: number }> {
const accounts = await this.accountRepository
@ -489,7 +489,7 @@ export class QqbotAccountService {
.getMany();
if (accounts.length <= 0) return { checked: 0 };
await this.appendNapcatRuntime(accounts, { autoLogin: true });
await this.appendNapcatRuntime(accounts);
return { checked: accounts.length };
}
@ -544,12 +544,10 @@ export class QqbotAccountService {
/**
* QQBot
* @param accounts - QQBot列表项
* @param options - QQBot列表 `napcatRuntime.appendRuntime()` QQBot步骤
* @returns QQBot
*/
private async appendNapcatRuntime(
accounts: QqbotAccount[],
options: { autoLogin?: boolean } = {},
): Promise<QqbotAccountListItem[]> {
if (!this.napcatRuntime) {
return accounts.map((account) =>
@ -557,7 +555,7 @@ export class QqbotAccountService {
);
}
return this.napcatRuntime.appendRuntime(accounts, options, {
return this.napcatRuntime.appendRuntime(accounts, {
/**
* QQBot回调
* @param selfId - ID
@ -565,19 +563,6 @@ export class QqbotAccountService {
clearQqLoginError: async (selfId) => {
await this.accountRepository.update({ selfId }, { lastError: null });
},
/**
* QQBot回调数据
* @param account - account `this.getNapcatLoginPassword()` QQBot步骤
*/
getLoginPassword: (account) => this.getNapcatLoginPassword(account),
/**
* QQBot回调
* @param selfId - ID
* @param clientRole - clientRole `this.markOnline()` QQBot步骤
* @param lastError - lastError `this.markOnline()` QQBot步骤
*/
markOnline: (selfId, clientRole, lastError) =>
this.markOnline(selfId, clientRole, lastError),
/**
* QQBot回调
* @param selfId - ID
@ -609,9 +594,10 @@ export class QqbotAccountService {
) {
if (!this.systemNoticePublisher) return;
const noticeContent = `${offlineReason}\n请在 Admin 的 QQBot 账号页面手动点击「更新登录」重新登录。`;
void this.systemNoticePublisher
.publishSystemNotice({
content: offlineReason,
content: noticeContent,
dedupeKey: `qqbot:offline:${selfId}`,
eventType: 'qqbot.account.offline',
metadata: {
@ -621,7 +607,7 @@ export class QqbotAccountService {
notifyRoleCode: 'super',
severity: 'error',
source: 'qqbot',
summary: offlineReason,
summary: noticeContent,
title: `QQBot 账号已下线:${selfId}`,
})
.catch(() => undefined);

View File

@ -34,8 +34,10 @@ export type NapcatLoginStatus = {
export type NapcatCaptchaLoginResult = {
jumpUrl?: string;
needCaptcha?: boolean;
needNewDevice?: boolean;
newDevicePullQrCodeSig?: unknown;
proofWaterUrl?: string;
};
export type NapcatQrcode = {
@ -337,6 +339,8 @@ export type QqbotLoginScanSession = {
passwordMd5?: string;
preparingRelogin?: boolean;
qrcode?: string;
runtimeRebuildCount?: number;
sourceContainerOnline?: boolean;
status: QqbotLoginScanStatus;
webuiPort?: null | number;
};
@ -353,6 +357,8 @@ export type QqbotNapcatRuntime = {
hasExistingPrimaryBinding?: boolean;
id?: string;
name: string;
runtimeRebuildCount?: number;
sourceContainerOnline?: boolean;
webuiPort?: null | number;
webuiToken?: null | string;
};

View File

@ -11,15 +11,12 @@ import type {
QqbotAccountListItem,
QqbotNapcatRuntimeStatusSnapshot,
} from '@/modules/qqbot/core/contract/qqbot.types';
import { NapcatLoginEventService } from '../runtime/napcat-login-event.service';
import { NapcatRuntimeProfileInspectorService } from '../runtime/napcat-runtime-profile-inspector.service';
import { QqbotNapcatContainerService } from '../../infrastructure/integration/container/qqbot-napcat-container.service';
import { NapcatAccountBinding } from '../../infrastructure/persistence/napcat-account-binding.entity';
import { NapcatContainer } from '../../infrastructure/persistence/napcat-container.entity';
const NAPCAT_RUNTIME_CHECK_TTL_MS = 30_000;
const NAPCAT_AUTO_LOGIN_CLEANUP_FAILED_MESSAGE =
'NapCat 自动登录后运行态密码清理失败,请手动更新登录';
@Injectable()
export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRuntimePort {
@ -27,10 +24,9 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti
* Creates the account-list runtime adapter that joins persisted bindings, container status, and optional profile summaries.
* @param accountNapcatRepository - Binding repository used to pick the primary NapCat container for each QQBot account.
* @param napcatContainerRepository - Container repository used for cached runtime/WebUI status and non-secret metadata.
* @param napcatContainerService - Runtime integration service for bounded NapCat/WebUI status probes and auto-login.
* @param napcatContainerService - Runtime integration service for bounded NapCat/WebUI status probes.
* @param toolsService - Shared helpers for status text normalization and NapCat offline-message classification.
* @param runtimeProfileInspector - Optional profile reader that enriches list rows without changing login state.
* @param loginEventService - Optional login-event gate that prevents watchdog from repeating suspended recovery flows.
*/
constructor(
@InjectRepository(NapcatAccountBinding)
@ -40,19 +36,16 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti
private readonly napcatContainerService: QqbotNapcatContainerService,
private readonly toolsService: ToolsService,
private readonly runtimeProfileInspector?: NapcatRuntimeProfileInspectorService,
private readonly loginEventService?: NapcatLoginEventService,
) {}
/**
* NapCat
* @param accounts - 使 `length`
* @param options - NapCat列表 `this.syncNapcatRuntimeState()` NapCat步骤
* @param actions - NapCat列表 `this.syncNapcatRuntimeState()` NapCat步骤
* @returns NapCat
*/
async appendRuntime(
accounts: QqbotAccount[],
options: { autoLogin?: boolean },
actions: QqbotAccountNapcatRuntimeActions,
): Promise<QqbotAccountListItem[]> {
if (accounts.length <= 0) return [];
@ -104,7 +97,6 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti
const runtimeStatus = await this.syncNapcatRuntimeState(
account,
container,
options,
actions,
);
return Object.assign(account, {
@ -144,13 +136,11 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti
* NapCat
* @param account - account 使 `connectStatus`
* @param container - container 使 `status``lastCheckedAt`
* @param options - NapCat列表使 `autoLogin`
* @param actions - NapCat列表 `this.getNapcatRuntimeStatus()``this.applyNapcatOfflineState()` NapCat步骤
*/
private async syncNapcatRuntimeState(
account: QqbotAccount,
container: NapcatContainer | undefined,
options: { autoLogin?: boolean },
actions: QqbotAccountNapcatRuntimeActions,
) {
const runtimeStatus = await this.getNapcatRuntimeStatus(
@ -168,12 +158,6 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti
const runtimeOfflineReason =
this.getRuntimeStatusOfflineReason(runtimeStatus);
if (runtimeOfflineReason) {
if (
options.autoLogin &&
(await this.tryAutoLogin(account, container, actions))
) {
return this.toCachedNapcatRuntimeStatus(container);
}
await this.applyNapcatOfflineState(
account,
container,
@ -185,12 +169,6 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti
const cachedOfflineReason = this.getFreshCachedOfflineReason(container);
if (cachedOfflineReason) {
if (
options.autoLogin &&
(await this.tryAutoLogin(account, container, actions))
) {
return this.toCachedNapcatRuntimeStatus(container);
}
await this.applyNapcatOfflineState(
account,
container,
@ -207,13 +185,6 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti
await this.napcatContainerService.detectRuntimeOffline(container);
if (!offlineReason) return runtimeStatus;
if (
options.autoLogin &&
(await this.tryAutoLogin(account, container, actions))
) {
return this.toCachedNapcatRuntimeStatus(container);
}
await this.applyNapcatOfflineState(
account,
container,
@ -362,63 +333,6 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti
);
}
/**
* NapCat
* @param account - account 使 `selfId``clientRole``connectStatus``lastConnectedAt`
* @param container - container `napcatContainerService.tryAutoLogin()``this.applyNapcatOfflineState()` NapCat步骤
* @param actions - NapCat列表 `actions.getLoginPassword()``actions.markOnline()` NapCat步骤
*/
private async tryAutoLogin(
account: QqbotAccount,
container: NapcatContainer,
actions: QqbotAccountNapcatRuntimeActions,
) {
try {
const recoveryGate =
await this.loginEventService?.canAttemptAutomaticRecovery({
accountId: account.id,
containerId: container.id,
resetAfter: account.lastConnectedAt,
});
if (recoveryGate && !recoveryGate.allowed) {
await this.loginEventService?.recordSuspended({
accountId: account.id,
containerId: container.id,
evidence: {
reason: recoveryGate.reason,
},
reason: recoveryGate.reason || 'recovery_suspended',
source: 'watchdog',
});
return false;
}
const result = await this.napcatContainerService.tryAutoLogin(container, {
loginPassword: actions.getLoginPassword(account),
selfId: account.selfId,
});
if (result.cleanupFailed) {
await this.applyNapcatOfflineState(
account,
container,
NAPCAT_AUTO_LOGIN_CLEANUP_FAILED_MESSAGE,
actions,
);
return true;
}
if (!result.success) return false;
await actions.markOnline(account.selfId, 'Universal', null);
account.clientRole = 'Universal';
account.connectStatus = 'online';
account.lastConnectedAt = new Date() as any;
account.lastError = null;
return true;
} catch {
return false;
}
}
/**
* NapCat
* @param account - account 使 `selfId``lastError`

View File

@ -39,6 +39,10 @@ export class QqbotNapcatLoginService {
string,
Set<(event: QqbotLoginScanEvent) => void>
> = {};
private readonly refreshStartTasks: Record<
string,
Promise<QqbotLoginScanResult> | undefined
> = {};
readonly sessions = {
/**
* NapCat回调状态
@ -131,26 +135,83 @@ export class QqbotNapcatLoginService {
* @param accountId - ID
*/
async startRefresh(accountId: string) {
const activeSession = this.findActiveRefreshSession(accountId);
if (activeSession) return this.toResult(activeSession);
const runningTask = this.refreshStartTasks[accountId];
if (runningTask) return runningTask;
const task = this.createRefreshScan(accountId);
this.refreshStartTasks[accountId] = task;
try {
return await task;
} finally {
if (this.refreshStartTasks[accountId] === task) {
delete this.refreshStartTasks[accountId];
}
}
}
/**
*
* @param accountId - ID
* @returns /
*/
private async createRefreshScan(accountId: string) {
const account =
await this.accountService.findByIdWithNapcatLoginSecret(accountId);
if (!account) {
throwVbenError('QQBot 账号不存在');
}
const loginPassword = this.accountService.getNapcatLoginPassword(account);
const container =
await this.containerService.prepareAccountContainer(account);
return this.startScan(
{
accountId: account.id,
expectedSelfId: account.selfId,
forceRelogin: true,
hasExistingPrimaryBinding: container.hasExistingPrimaryBinding,
loginPassword,
mode: 'refresh',
},
container,
const container = await this.containerService.prepareAccountContainer(
account,
loginPassword,
);
const scanOptions: {
accountId: string;
expectedSelfId: string;
forceRelogin: true;
hasExistingPrimaryBinding?: boolean;
loginPassword?: string;
mode: 'refresh';
sourceContainerOnline?: boolean;
} = {
accountId: account.id,
expectedSelfId: account.selfId,
forceRelogin: true,
hasExistingPrimaryBinding: container.hasExistingPrimaryBinding,
loginPassword,
mode: 'refresh',
};
if (container.sourceContainerOnline !== undefined) {
scanOptions.sourceContainerOnline = container.sourceContainerOnline;
}
return this.startScan(scanOptions, container);
}
/**
*
* @param accountId - ID pending refresh
* @returns pending refresh undefined
*/
private findActiveRefreshSession(accountId: string) {
const now = Date.now();
let activeSession: QqbotLoginScanSession | undefined;
this.loginSessionStore.forEach((session) => {
if (activeSession) return;
if (
session.accountId === accountId &&
session.mode === 'refresh' &&
session.status === 'pending' &&
now <= session.expiresAt
) {
activeSession = session;
}
});
return activeSession;
}
/**
@ -175,33 +236,15 @@ export class QqbotNapcatLoginService {
loginStatus = await this.getLoginStatus(container);
} catch (err) {
if (!this.toolsService.isNapcatTemporaryError(err)) throw err;
await this.restartNapcatForLogin(container, { waitForReady: false });
session.lastRestartedAt = Date.now();
return this.keepSessionPending(
session,
'NapCat 通信超时,已尝试重启容器并重新生成二维码',
'NapCat 通信超时,请稍后重试或确认 Docker 容器仍在线',
true,
);
}
if (loginStatus.isOffline) {
if (session.mode === 'refresh') {
this.publishScanResultEvent(
session,
'relogin-reset-start',
'processing',
'开始重置 NapCat 登录态',
);
await this.resetNapcatForLogin(
container,
{ waitForReady: false },
(step, message) => {
this.publishScanResultEvent(session, step, 'processing', message);
},
);
} else {
await this.restartNapcatForLogin(container, { waitForReady: false });
}
if (loginStatus.isOffline && session.mode !== 'refresh') {
await this.restartNapcatForLogin(container, { waitForReady: false });
session.lastRestartedAt = Date.now();
return this.keepSessionPending(
session,
@ -473,7 +516,7 @@ export class QqbotNapcatLoginService {
/**
* Scan
* @param options - NapCat列表使 `forceRelogin``loginPassword``hasExistingPrimaryBinding`
* @param container - container `this.prepareReloginQrcode()``this.getLoginStatus()``this.restartNapcatForLogin()``this.publishScanResultEvent()` NapCat步骤
* @param container - NapCat WebUI refresh WebUI
* @returns NapCat
*/
private async startScan(
@ -484,6 +527,7 @@ export class QqbotNapcatLoginService {
hasExistingPrimaryBinding?: boolean;
loginPassword?: string;
mode: QqbotLoginScanMode;
sourceContainerOnline?: boolean;
},
container: QqbotNapcatRuntime,
): Promise<QqbotLoginScanResult> {
@ -518,6 +562,34 @@ export class QqbotNapcatLoginService {
try {
const loginStatus = await this.getLoginStatus(container, true);
if (loginStatus.isOffline) {
if (options.mode === 'refresh') {
const qrcode = await this.refreshOrGetQrcode(container, false, {
fallbackStatus: loginStatus,
requireFresh: true,
staleQrcode: loginStatus.qrcodeurl,
});
const session = this.createSession({
...options,
container,
qrcode,
status: 'pending',
});
this.persistLoginSession(session);
this.publishScanResultEvent(
session,
'qrcode-ready',
'success',
'登录二维码已生成',
);
this.publishScanResultEvent(
session,
'waiting-scan',
'processing',
'等待扫码确认',
);
return this.toResult(session);
}
await this.restartNapcatForLogin(container, { waitForReady: false });
const session = this.createSession({
...options,
@ -653,6 +725,8 @@ export class QqbotNapcatLoginService {
mode: QqbotLoginScanMode;
preparingRelogin?: boolean;
qrcode?: string;
runtimeRebuildCount?: number;
sourceContainerOnline?: boolean;
status: QqbotLoginScanStatus;
}): QqbotLoginScanSession {
const now = Date.now();
@ -667,6 +741,10 @@ export class QqbotNapcatLoginService {
mode: input.mode,
preparingRelogin: input.preparingRelogin,
qrcode: input.qrcode,
runtimeRebuildCount:
input.runtimeRebuildCount ?? input.container.runtimeRebuildCount,
sourceContainerOnline:
input.sourceContainerOnline ?? input.container.sourceContainerOnline,
status: input.status,
webuiPort: input.container.webuiPort,
};
@ -1034,7 +1112,7 @@ export class QqbotNapcatLoginService {
/**
* NapCat
* @param session - session 使 `expectedSelfId`
* @param container - container `this.waitForPasswordLoginStatus()``this.failCaptchaLogin()``this.getLoginInfo()``this.clearRuntimeLoginPasswordAfterFailedPassword()` NapCat步骤
* @param container - container `this.waitForPasswordLoginStatus()``this.failCaptchaLogin()``this.getLoginInfo()` NapCat步骤
* @param successMessage - successMessage completePasswordLoginAfterChallenge
*/
private async completePasswordLoginAfterChallenge(
@ -1071,26 +1149,12 @@ export class QqbotNapcatLoginService {
);
}
if (session.expectedSelfId && session.expectedSelfId !== selfId) {
await this.clearRuntimeLoginPasswordAfterFailedPassword(
session,
container,
selfId,
);
return this.failSession(
session,
`当前密码登录账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`,
);
}
try {
await this.clearRuntimeLoginPasswordAfterSuccess(
session,
container,
selfId,
);
} catch {
return this.toResult(session);
}
return this.completeLogin(session, container, {
loginInfo,
successMessage,
@ -1422,47 +1486,13 @@ export class QqbotNapcatLoginService {
cleanupFailureMessage?: string,
) {
if (!session.passwordMd5 && !session.captchaUrl) return true;
const runtime = container || (await this.getSessionContainer(session));
const failureMessage =
cleanupFailureMessage ||
(session.status === 'success'
? 'NapCat 密码登录已完成,但运行态密码清理失败,请重试更新登录'
: 'NapCat 密码登录未完成,且运行态密码清理失败,请重试更新登录');
this.publishScanResultEvent(
session,
'password-env-cleanup',
'processing',
'正在移除运行态登录密码',
);
const cleaned = await this.containerService.ensureRuntimeLoginEnv(runtime, {
clearLoginPassword: true,
selfId: selfId || session.expectedSelfId,
});
if (!cleaned.ok) {
session.status = 'error';
session.captchaUrl = undefined;
session.errorMessage = failureMessage;
session.passwordMd5 = undefined;
session.preparingRelogin = false;
this.persistLoginSession(session);
this.persistRuntimeCleanup(session, {
errorMessage: failureMessage,
status: 'failed',
});
this.publishScanEvent(session, {
message: failureMessage,
result: this.toResult(session),
status: 'error',
step: 'password-env-cleanup-failed',
});
return false;
}
void container;
void selfId;
void cleanupFailureMessage;
session.captchaUrl = undefined;
session.passwordMd5 = undefined;
this.persistLoginSession(session);
this.persistRuntimeCleanup(session, { status: 'success' });
return true;
}
@ -1484,21 +1514,6 @@ export class QqbotNapcatLoginService {
this.loginSessionStore.recordNewDeviceChallenge(session);
}
/**
* NapCat
* @param session - session `loginSessionStore.recordRuntimeCleanup()` NapCat步骤
* @param input - input persistRuntimeCleanup
*/
private persistRuntimeCleanup(
session: QqbotLoginScanSession,
input: { errorMessage?: string; status: 'failed' | 'pending' | 'success' },
) {
this.loginSessionStore.recordRuntimeCleanup(session, {
cleanupType: 'password-login-env',
...input,
});
}
/**
* NapCat
* @param sessionId - NapCat IDNapCat
@ -1814,7 +1829,7 @@ export class QqbotNapcatLoginService {
/**
* NapCat
* @param container - container `this.restartNapcatForLogin()``this.callRefreshQrcode()``this.getQrcode()` NapCat步骤
* @param container - NapCat WebUI /
* @param retry - retry `this.callRefreshQrcode()``this.getQrcode()` NapCat步骤
* @param options - NapCat列表使 `fallbackStatus``requireFresh``staleQrcode`
*/
@ -1823,15 +1838,11 @@ export class QqbotNapcatLoginService {
retry = false,
options: QrcodeRefreshOptions = {},
) {
let fallbackStatus = options.fallbackStatus;
const fallbackStatus = options.fallbackStatus;
const lookupOptions: QrcodeLookupOptions = {
requireFresh: options.requireFresh || fallbackStatus?.isOffline,
staleQrcode: options.staleQrcode || fallbackStatus?.qrcodeurl,
};
if (fallbackStatus?.isOffline) {
await this.restartNapcatForLogin(container);
fallbackStatus = undefined;
}
try {
const refreshedQrcode = await this.callRefreshQrcode(container, retry);
if (refreshedQrcode) {
@ -1896,8 +1907,8 @@ export class QqbotNapcatLoginService {
/**
* NapCat
* @param session - session 使 `lastRestartedAt``qrcode``errorMessage``expiresAt`
* @param container - container `this.tryQuickRelogin()``this.tryPasswordRelogin()``this.resetNapcatForLogin()``this.refreshOrGetQrcode()` NapCat步骤
* @param session - WebUI
* @param container - NapCat WebUI WebUI
* @param loginPassword - loginPassword `toolsService.toSecretText()` NapCat步骤
* @param hasExistingPrimaryBinding - hasExistingPrimaryBinding NapCat条件分支
*/
@ -1909,6 +1920,13 @@ export class QqbotNapcatLoginService {
) {
try {
const password = this.toolsService.toSecretText(loginPassword);
if (session.sourceContainerOnline === true) {
const completed = await this.completeOnlineSourceRefresh(
session,
container,
);
if (completed) return;
}
if (hasExistingPrimaryBinding) {
const quickLoginCompleted = await this.tryQuickRelogin(
session,
@ -1925,34 +1943,13 @@ export class QqbotNapcatLoginService {
);
if (passwordLoginCompleted) return;
this.publishScanResultEvent(
session,
'relogin-reset-start',
'processing',
'开始重置 NapCat 登录态',
);
await this.resetNapcatForLogin(
container,
{ waitForReady: false },
(step, message) => {
this.publishScanResultEvent(session, step, 'processing', message);
},
);
session.lastRestartedAt = Date.now();
this.publishScanResultEvent(
session,
'napcat-ready-wait',
'processing',
'等待 NapCat WebUI 启动',
);
await this.toolsService.sleep(this.getRestartDelayMs());
this.publishScanResultEvent(
session,
'qrcode-fetch',
'processing',
'正在获取登录二维码',
);
session.qrcode = await this.refreshOrGetQrcode(container, true, {
session.qrcode = await this.refreshOrGetQrcode(container, false, {
requireFresh: true,
});
session.errorMessage = undefined;
@ -2000,10 +1997,39 @@ export class QqbotNapcatLoginService {
}
}
/**
* Docker 线
* @param session -
* @param container - NapCat QQ
* @returns true false WebUI
*/
private async completeOnlineSourceRefresh(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
) {
const loginStatus = await this.getLoginStatus(container, true);
if (!loginStatus.isLogin) return false;
const loginInfo = await this.getLoginInfo(container);
if (loginInfo.online === false) return false;
const selfId = this.toolsService.pickNapcatSelfId(loginInfo);
if (!selfId) return false;
if (session.expectedSelfId && session.expectedSelfId !== selfId) {
return false;
}
await this.completeLogin(session, container, {
loginInfo,
successMessage: '当前 NapCat 容器已在线,无需重建登录',
});
return true;
}
/**
* NapCat
* @param session - session 使 `errorMessage``lastRestartedAt``expectedSelfId`
* @param container - container `this.clearRuntimeLoginPasswordBeforeQuick()``this.restartNapcatForLogin()``this.getLoginStatus()``this.getLoginInfo()` NapCat步骤
* @param session - QQ quick
* @param container - NapCat WebUI SetQuickLogin
* @param hasPasswordFallback - hasPasswordFallback `this.publishQuickLoginFallback()` NapCat步骤
*/
private async tryQuickRelogin(
@ -2018,21 +2044,29 @@ export class QqbotNapcatLoginService {
session,
'quick-login-start',
'processing',
'正在尝试 NapCat -q 快速登录',
'正在尝试 NapCat 快速登录',
);
await this.clearRuntimeLoginPasswordBeforeQuick(session, container);
try {
await this.restartNapcatForLogin(container, { waitForReady: false });
session.lastRestartedAt = Date.now();
const uin = this.toolsService.toTrimmedString(session.expectedSelfId);
if (!uin) {
this.publishQuickLoginFallback(
session,
'缺少目标 QQ 号',
hasPasswordFallback,
);
return false;
}
await this.postNapcat<null>(container, '/api/QQLogin/SetQuickLogin', {
uin,
});
this.publishScanResultEvent(
session,
'quick-login-wait',
'processing',
'等待 NapCat 快速登录结果',
);
await this.toolsService.sleep(this.getRestartDelayMs());
const loginStatus = await this.getLoginStatus(container, true);
const loginStatus = await this.waitForQuickLoginStatus(container);
if (!loginStatus.isLogin) {
this.publishQuickLoginFallback(
session,
@ -2087,9 +2121,9 @@ export class QqbotNapcatLoginService {
/**
* NapCat
* @param session - session 使 `passwordMd5``errorMessage``expectedSelfId``lastRestartedAt`
* @param container - container `containerService.ensureRuntimeLoginEnv()``this.restartNapcatForLogin()``this.waitForPasswordLoginStatus()``this.getLoginInfo()` NapCat步骤
* @param loginPassword - loginPassword `toolsService.toSecretText()` NapCat步骤
* @param session - MD5
* @param container - NapCat WebUI PasswordLogin
* @param loginPassword - QQ WebUI PasswordLogin MD5
*/
private async tryPasswordRelogin(
session: QqbotLoginScanSession,
@ -2103,11 +2137,11 @@ export class QqbotNapcatLoginService {
}
let loginInfo: NapcatLoginInfo | undefined;
let loggedInSelfId = '';
const passwordLogSinceMs = Date.now();
session.passwordMd5 = createHash('md5')
.update(password, 'utf8')
.digest('hex');
session.lastRestartedAt = passwordLogSinceMs;
session.errorMessage = 'NapCat 正在尝试密码登录,请稍后';
this.persistLoginSession(session);
this.publishScanResultEvent(
@ -2117,22 +2151,29 @@ export class QqbotNapcatLoginService {
'正在尝试 NapCat 密码登录',
);
const passwordEnv = await this.containerService.ensureRuntimeLoginEnv(
container,
{
loginPassword: password,
selfId: session.expectedSelfId,
},
);
if (!passwordEnv.ok) {
this.publishPasswordLoginFallback(session, '运行态密码环境准备失败');
return false;
}
let loginStatus: NapcatLoginStatus;
try {
await this.restartNapcatForLogin(container, { waitForReady: false });
session.lastRestartedAt = Date.now();
const uin = this.toolsService.toTrimmedString(session.expectedSelfId);
if (!uin) {
this.publishPasswordLoginFallback(session, '缺少目标 QQ 号');
return false;
}
const passwordResult =
await this.postNapcat<NapcatCaptchaLoginResult | null>(
container,
'/api/QQLogin/PasswordLogin',
{
passwordMd5: session.passwordMd5,
uin,
},
);
const passwordResultPending = await this.applyPasswordLoginResult(
session,
container,
passwordResult,
passwordLogSinceMs,
);
if (passwordResultPending) return true;
this.publishScanResultEvent(
session,
'password-login-wait',
@ -2148,14 +2189,22 @@ export class QqbotNapcatLoginService {
loginInfo = await this.getLoginInfo(container);
}
} catch (err) {
await this.clearRuntimeLoginPasswordAfterFailedPassword(
session,
container,
session.expectedSelfId || loggedInSelfId,
);
const errorMessage = this.toolsService.getErrorMessage(err);
if (this.toolsService.isNapcatCaptchaRequiredMessage(errorMessage)) {
const captchaUrl = await this.waitForPasswordCaptchaUrl(
container,
passwordLogSinceMs,
);
if (captchaUrl) {
this.keepPasswordCaptchaPending(session, captchaUrl, errorMessage);
} else {
this.keepPasswordCaptchaWaitingForUrl(session, errorMessage);
}
return true;
}
this.publishPasswordLoginFallback(
session,
this.toolsService.getErrorMessage(err),
errorMessage,
);
return false;
}
@ -2179,51 +2228,25 @@ export class QqbotNapcatLoginService {
return true;
}
await this.clearRuntimeLoginPasswordAfterFailedPassword(
session,
container,
session.expectedSelfId,
);
this.publishPasswordLoginFallback(session, loginStatus.loginError);
return false;
}
if (loginInfo?.online === false) {
await this.clearRuntimeLoginPasswordAfterFailedPassword(
session,
container,
session.expectedSelfId,
);
this.publishPasswordLoginFallback(session, 'NapCat 当前账号已离线');
return false;
}
if (!loginInfo) {
await this.clearRuntimeLoginPasswordAfterFailedPassword(
session,
container,
session.expectedSelfId,
);
this.publishPasswordLoginFallback(session, 'NapCat 未返回登录信息');
return false;
}
const selfId = this.toolsService.pickNapcatSelfId(loginInfo);
if (!selfId) {
await this.clearRuntimeLoginPasswordAfterFailedPassword(
session,
container,
session.expectedSelfId,
);
this.publishPasswordLoginFallback(session, 'NapCat 未返回 QQ 号');
return false;
}
loggedInSelfId = selfId;
if (session.expectedSelfId && session.expectedSelfId !== selfId) {
await this.clearRuntimeLoginPasswordAfterFailedPassword(
session,
container,
selfId,
);
this.publishPasswordLoginFallback(
session,
`当前密码登录账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`,
@ -2231,11 +2254,6 @@ export class QqbotNapcatLoginService {
return false;
}
await this.clearRuntimeLoginPasswordAfterSuccess(
session,
container,
loggedInSelfId,
);
await this.completeLogin(session, container, {
loginInfo,
successMessage: '密码登录成功',
@ -2243,10 +2261,82 @@ export class QqbotNapcatLoginService {
return true;
}
/**
* NapCat WebUI QQ
* @param container - NapCat WebUI CheckLoginStatus
* @returns
*/
private async waitForQuickLoginStatus(container: QqbotNapcatRuntime) {
let latestStatus: NapcatLoginStatus = { isLogin: false };
const attempts = this.getLoginPollAttempts(
this.getQuickLoginWaitMs(),
this.getLoginPollIntervalMs(),
);
for (let index = 0; index < attempts; index += 1) {
if (index > 0) {
await this.toolsService.sleep(this.getLoginPollIntervalMs());
}
latestStatus = await this.getLoginStatus(container, true);
if (
latestStatus.isLogin ||
latestStatus.isOffline ||
latestStatus.loginError ||
latestStatus.qrcodeurl
) {
return latestStatus;
}
}
return latestStatus;
}
/**
* NapCat WebUI PasswordLogin
* @param session - MD5
* @param container - NapCat WebUI
* @param result - PasswordLogin
* @param sinceMs - PasswordLogin URL
* @returns pending true false
*/
private async applyPasswordLoginResult(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
result: NapcatCaptchaLoginResult | null,
sinceMs: number,
) {
if (!result) return false;
if (result.needNewDevice) {
await this.startNewDeviceVerification(session, container, result);
return true;
}
const proofWaterUrl = this.toolsService.toTrimmedString(
result.proofWaterUrl,
);
if (!result.needCaptcha && !proofWaterUrl) return false;
if (proofWaterUrl) {
this.keepPasswordCaptchaPending(session, proofWaterUrl);
return true;
}
const captchaUrl = await this.waitForPasswordCaptchaUrl(
container,
sinceMs,
);
if (captchaUrl) {
this.keepPasswordCaptchaPending(session, captchaUrl);
return true;
}
this.keepPasswordCaptchaWaitingForUrl(
session,
'密码登录需要完成 QQ 安全验证',
);
return true;
}
/**
* NapCat
* @param session - session 使 `expectedSelfId``qrcode``captchaUrl``errorMessage`
* @param container - container `this.clearRuntimeLoginPasswordAfterFailedPassword()``this.refreshOrGetQrcode()` NapCat步骤
* @param container - container `this.refreshOrGetQrcode()` NapCat步骤
* @param loginStatus - NapCat列表使 `qrcodeurl`
*/
private async keepPasswordQrcodePending(
@ -2254,11 +2344,6 @@ export class QqbotNapcatLoginService {
container: QqbotNapcatRuntime,
loginStatus: NapcatLoginStatus,
) {
await this.clearRuntimeLoginPasswordAfterFailedPassword(
session,
container,
session.expectedSelfId,
);
this.publishScanResultEvent(
session,
'password-login-qrcode',
@ -2273,6 +2358,7 @@ export class QqbotNapcatLoginService {
session.captchaUrl = undefined;
session.errorMessage = undefined;
session.expiresAt = Date.now() + this.getSessionTtlMs();
session.passwordMd5 = undefined;
this.persistLoginSession(session);
this.publishScanResultEvent(
session,
@ -2442,81 +2528,6 @@ export class QqbotNapcatLoginService {
);
}
/**
* Runtime Login Password Before Quick
* @param session - session 使 `expectedSelfId`
* @param container - container `containerService.ensureRuntimeLoginEnv()` NapCat步骤
*/
private async clearRuntimeLoginPasswordBeforeQuick(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
) {
this.publishScanResultEvent(
session,
'quick-login-cleanup',
'processing',
'正在清理运行态登录密码',
);
const cleaned = await this.containerService.ensureRuntimeLoginEnv(
container,
{
clearLoginPassword: true,
selfId: session.expectedSelfId,
},
);
if (!cleaned.ok) {
throw new Error('NapCat 快速登录前运行态密码清理失败,请重试更新登录');
}
}
/**
* Runtime Login Password After Failed Password
* @param session - session `this.cleanupPasswordLoginContext()` NapCat步骤
* @param container - container `this.cleanupPasswordLoginContext()` NapCat步骤
* @param selfId - ID
*/
private async clearRuntimeLoginPasswordAfterFailedPassword(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
selfId?: string,
) {
const failureMessage =
'NapCat 密码登录未完成,且运行态密码清理失败,请重试更新登录';
const cleaned = await this.cleanupPasswordLoginContext(
session,
container,
selfId,
failureMessage,
);
if (!cleaned) {
throw new Error(failureMessage);
}
}
/**
* Runtime Login Password After Success
* @param session - session `this.cleanupPasswordLoginContext()` NapCat步骤
* @param container - container `this.cleanupPasswordLoginContext()` NapCat步骤
* @param selfId - ID
*/
private async clearRuntimeLoginPasswordAfterSuccess(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
selfId: string,
) {
const failureMessage =
'NapCat 密码登录已完成,但运行态密码清理失败,请重试更新登录';
const cleaned = await this.cleanupPasswordLoginContext(
session,
container,
selfId,
failureMessage,
);
if (!cleaned) {
throw new Error(failureMessage);
}
}
/**
* NapCat
* @param options - NapCat列表使 `hasExistingPrimaryBinding``loginPassword`
@ -2580,35 +2591,6 @@ export class QqbotNapcatLoginService {
);
}
/**
* Napcat For Login
* @param container - container `containerService.resetRuntimeLoginState()``this.restartNapcatForLogin()``webuiClient.clearCredential()``this.getLoginStatus()` NapCat步骤
* @param options - NapCat列表使 `waitForReady`
* @param onProgress - NapCat列表 `containerService.resetRuntimeLoginState()` NapCat步骤
*/
private async resetNapcatForLogin(
container: QqbotNapcatRuntime,
options: NapcatRestartOptions = {},
onProgress?: (step: string, message: string) => void,
) {
const resetByContainer = await this.containerService.resetRuntimeLoginState(
container,
onProgress,
);
if (!resetByContainer) {
onProgress?.('napcat-restart-webui', '正在调用 NapCat 重启接口');
await this.restartNapcatForLogin(container, options);
return;
}
this.webuiClient.clearCredential(container);
if (options.waitForReady === false) return;
onProgress?.('napcat-ready-wait', '等待 NapCat WebUI 启动');
await this.toolsService.sleep(this.getRestartDelayMs());
await this.getLoginStatus(container, true);
}
/**
* NapCat
* @param container - container `containerService.restartRuntimeContainer()``webuiClient.clearCredential()``this.getLoginStatus()` NapCat步骤
@ -2789,6 +2771,17 @@ export class QqbotNapcatLoginService {
);
}
/**
* NapCat
* @returns WebUI SetQuickLogin QQ
*/
private getQuickLoginWaitMs() {
return this.getPositiveConfigNumber(
'QQBOT_NAPCAT_QUICK_LOGIN_WAIT_MS',
15_000,
);
}
/**
* NapCat
*/

View File

@ -13,7 +13,7 @@ const MIN_INTERVAL_MS = 30_000;
/**
* NapCat 线线线/
* @nestjs/schedule OnModuleInit + setInterval
* +
* + Admin
*/
@Injectable()
export class QqbotNapcatWatchdogService

View File

@ -1,141 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import {
NapcatLoginEvent,
type NapcatLoginEventKind,
type NapcatLoginEventSource,
type NapcatLoginEventStatus,
} from '../../infrastructure/persistence/napcat-login-event.entity';
const NAPCAT_AUTOMATIC_RECOVERY_BLOCKING_EVENTS: NapcatLoginEventKind[] = [
'captcha_required',
'manual_qr_created',
'new_device_required',
'recovery_suspended',
];
export type NapcatAutomaticRecoveryGate = {
allowed: boolean;
reason?: NapcatLoginEventKind;
};
@Injectable()
export class NapcatLoginEventService {
/**
* Initializes login-event persistence.
* @param loginEventRepository - Repository used to append login-side audit and recovery-gating events.
*/
constructor(
@InjectRepository(NapcatLoginEvent)
private readonly loginEventRepository: Repository<NapcatLoginEvent>,
) {}
/**
* Records a login-side event for audit, automatic recovery gating, and Admin evidence.
* @param input - Event payload produced by Admin actions, watchdog, runtime checks, or system workflows.
* @returns Persisted login-event row created from the append-only payload.
*/
async record(input: {
accountId: string;
containerId?: null | string;
eventKind: NapcatLoginEventKind;
eventSource: NapcatLoginEventSource;
eventStatus: NapcatLoginEventStatus;
evidence?: Record<string, unknown>;
}) {
return this.loginEventRepository.save(
this.loginEventRepository.create({
accountId: input.accountId,
containerId: input.containerId || null,
eventKind: input.eventKind,
eventSource: input.eventSource,
eventStatus: input.eventStatus,
evidence: input.evidence || null,
}),
);
}
/**
* Records that automation stopped before QR, captcha, or new-device verification.
* @param input - Account, container, source, and domain reason explaining why watchdog recovery must stop.
* @returns Persisted `recovery_suspended` event carrying the blocking reason in evidence.
*/
recordSuspended(input: {
accountId: string;
containerId?: null | string;
evidence: Record<string, unknown>;
reason: NapcatLoginEventKind;
source: NapcatLoginEventSource;
}) {
return this.record({
accountId: input.accountId,
containerId: input.containerId,
eventKind: 'recovery_suspended',
eventSource: input.source,
eventStatus: 'blocked',
evidence: {
...input.evidence,
reason: input.reason,
},
});
}
/**
* Checks whether watchdog may run quick -> password recovery for an account/container pair.
* @param input - Account, optional container, and latest successful connection time used as manual reset evidence.
* @returns Recovery gate result; blocked reasons map to the latest blocking login-side event.
*/
async canAttemptAutomaticRecovery(input: {
accountId: string;
containerId?: null | string;
resetAfter?: Date | null;
}): Promise<NapcatAutomaticRecoveryGate> {
const where: Record<string, unknown> = {
accountId: input.accountId,
eventKind: In(NAPCAT_AUTOMATIC_RECOVERY_BLOCKING_EVENTS),
};
if (input.containerId) where.containerId = input.containerId;
const latest = await this.loginEventRepository.findOne({
order: { createTime: 'DESC' },
where: where as any,
});
if (!latest) return { allowed: true };
if (this.isResetAfterEvent(input.resetAfter, latest.createTime)) {
return { allowed: true };
}
if (this.hasManualResetEvidence(latest.evidence)) {
return { allowed: true };
}
return { allowed: false, reason: latest.eventKind };
}
/**
* Compares a later successful connection against the blocking event timestamp.
* @param resetAfter - Successful connection or manual reset time from account state.
* @param eventTime - Blocking login-event creation time.
* @returns Whether the reset timestamp is newer than the blocking event.
*/
private isResetAfterEvent(
resetAfter: Date | null | undefined,
eventTime: Date,
) {
if (!resetAfter) return false;
const resetAt = new Date(resetAfter).getTime();
const blockedAt = new Date(eventTime).getTime();
return Number.isFinite(resetAt) && resetAt > blockedAt;
}
/**
* Detects explicit manual reset evidence embedded in a blocking event.
* @param evidence - Login event evidence object persisted with the blocking event.
* @returns Whether automation can continue because a human reset has been recorded.
*/
private hasManualResetEvidence(evidence: null | Record<string, unknown>) {
if (!evidence) return false;
return Boolean(evidence.manualResetAt || evidence.manualReset);
}
}

View File

@ -15,7 +15,6 @@ export * from './infrastructure/integration/napcat-login-api.client';
export * from './domain/login/napcat-login-state-machine';
export * from './application/login/qqbot-napcat-login.service';
export * from './application/login/qqbot-napcat-watchdog.service';
export * from './application/runtime/napcat-login-event.service';
export * from './application/runtime/napcat-runtime-profile-inspector.service';
export * from './application/runtime/napcat-session-behavior.service';
export * from './infrastructure/persistence';

View File

@ -2,13 +2,12 @@ import * as http from 'http';
import * as https from 'https';
import { spawn } from 'child_process';
import { createHash, randomBytes, randomUUID } from 'crypto';
import { Injectable, Optional } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { throwVbenError, ToolsService } from '@/common';
import { NapcatConfigWriterService } from '../../../application/runtime/napcat-config-writer.service';
import { NapcatLoginEventService } from '../../../application/runtime/napcat-login-event.service';
import { NapcatRuntimeProfileService } from '../../../application/runtime/napcat-runtime-profile.service';
import type { NapcatConfigFile } from '../../../domain/runtime/napcat-profile.types';
import {
@ -19,7 +18,6 @@ import { NapcatDeviceIdentityService } from '../device/napcat-device-identity.se
import { QqbotAccount } from '@/modules/qqbot/core/infrastructure/persistence/account/qqbot-account.entity';
import { NapcatAccountBinding } from '../../persistence/napcat-account-binding.entity';
import { NapcatContainer } from '../../persistence/napcat-container.entity';
import type { NapcatLoginEventKind } from '../../persistence/napcat-login-event.entity';
import type {
NapcatApiResponse,
NapcatCredential,
@ -47,12 +45,6 @@ type NapcatLoginEnvUpdateResult = {
ok: boolean;
};
type NapcatAutoLoginResult = {
cleanupFailed?: boolean;
method?: 'password' | 'quick';
success: boolean;
};
@Injectable()
export class QqbotNapcatContainerService {
private readonly configWriterService: NapcatConfigWriterService;
@ -68,7 +60,6 @@ export class QqbotNapcatContainerService {
* @param deviceIdentityService - Device identity resolver that supplies stable hostname, MAC, data-dir, and machine-id values.
* @param runtimeProfileService - Runtime profile resolver used to generate Docker env and mount settings.
* @param configWriterService - Config writer used to generate NapCat and OneBot config files.
* @param loginEventService - Optional login-event recorder used by watchdog auto-login attempts.
*/
constructor(
private readonly configService: ConfigService,
@ -80,8 +71,6 @@ export class QqbotNapcatContainerService {
private readonly deviceIdentityService?: NapcatDeviceIdentityService,
runtimeProfileService?: NapcatRuntimeProfileService,
configWriterService?: NapcatConfigWriterService,
@Optional()
private readonly loginEventService?: NapcatLoginEventService,
) {
this.runtimeProfileService =
runtimeProfileService || new NapcatRuntimeProfileService(configService);
@ -101,26 +90,51 @@ export class QqbotNapcatContainerService {
}
/**
* NapCat
* @param account - account 使 `id``selfId`
* NapCat
* @param account - QQBot ACCOUNT env
* @param loginPassword - QQ Docker 线 env
* @returns NapCat Docker 线
*/
async prepareAccountContainer(account: QqbotAccount) {
async prepareAccountContainer(account: QqbotAccount, loginPassword?: string) {
if (!this.isManagedMode()) {
return this.getLegacyRuntime();
}
const existing = await this.getPrimaryRuntime(account.id);
if (existing) {
await this.ensureRuntimeQuickLogin(existing, account.selfId);
return { ...existing, hasExistingPrimaryBinding: true };
const sourceContainerOnline = existing.sourceContainerOnline !== false;
const quickLoginEnv = sourceContainerOnline
? { changed: false, ok: true }
: await this.ensureRuntimeLoginEnv(existing, {
loginPassword,
selfId: account.selfId,
});
if (
!sourceContainerOnline &&
quickLoginEnv.ok &&
!quickLoginEnv.changed &&
!(await this.startRuntimeContainer(existing))
) {
throwVbenError('NapCat Docker 容器未运行,启动失败');
}
return {
...existing,
hasExistingPrimaryBinding: true,
sourceContainerOnline,
runtimeRebuildCount: quickLoginEnv.changed ? 1 : 0,
};
}
const created = await this.createManagedContainer(
account.selfId,
undefined,
loginPassword,
account.id,
);
return { ...created, hasExistingPrimaryBinding: false };
return {
...created,
hasExistingPrimaryBinding: false,
sourceContainerOnline: false,
};
}
/**
@ -212,6 +226,30 @@ export class QqbotNapcatContainerService {
}
}
/**
*
* @param runtime - NapCat id Docker env
* @param options - ACCOUNT / env
* @returns Docker env true false
*/
async runtimeLoginEnvMatches(
runtime: QqbotNapcatRuntime,
options: NapcatLoginEnvOptions,
) {
if (this.getManagedMode() !== 'ssh' || !runtime.id) return true;
const account = this.toolsService.toTrimmedString(options.selfId);
if (!account) return false;
const container = await this.findContainerWithToken(runtime.id);
if (!container?.name) return false;
return this.runtimeMatchesLoginEnv(container.name, {
...options,
selfId: account,
});
}
/**
* Runtime Device Identity
* @param container - container 使 `id`
@ -266,270 +304,6 @@ export class QqbotNapcatContainerService {
return identity.id;
}
/**
* NapCat
* @param container - container 使 `id`
* @param options - NapCat列表使 `selfId``loginPassword`
* @returns NapCat
*/
async tryAutoLogin(
container: NapcatContainer,
options: NapcatLoginEnvOptions,
): Promise<NapcatAutoLoginResult> {
const selfId = this.toolsService.toTrimmedString(options.selfId);
if (!selfId || this.getManagedMode() !== 'ssh' || !container.id) {
return { success: false };
}
const runtimeContainer = await this.findContainerWithToken(container.id);
if (!runtimeContainer?.name) return { success: false };
const runtime = this.toRuntime(runtimeContainer);
const accountId = this.getAutoLoginAccountId(container, runtimeContainer);
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'quick_attempt',
eventStatus: 'pending',
evidence: { containerName: runtime.name },
});
const quickCleanup = await this.ensureRuntimeLoginEnv(runtime, {
clearLoginPassword: true,
selfId,
});
if (!quickCleanup.ok) {
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'quick_attempt',
eventStatus: 'failed',
evidence: {
containerName: runtime.name,
reason: 'login-env-cleanup-failed',
},
});
return { cleanupFailed: true, success: false };
}
const quickState = await this.restartAndDetectLoginState(runtime);
if (quickState.state === 'online') {
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'quick_attempt',
eventStatus: 'success',
evidence: { containerName: runtime.name },
});
return { method: 'quick', success: true };
}
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'quick_attempt',
eventStatus: 'failed',
evidence: {
containerName: runtime.name,
offlineReason: quickState.offlineReason,
},
});
const quickBlockReason = this.toAutoLoginSuspendedReason(
quickState.offlineReason,
);
if (quickBlockReason) {
await this.recordAutoLoginSuspended({
accountId,
container,
offlineReason: quickState.offlineReason,
reason: quickBlockReason,
});
return { success: false };
}
const loginPassword = this.toolsService.toSecretText(options.loginPassword);
if (!loginPassword) {
await this.recordAutoLoginSuspended({
accountId,
container,
offlineReason: quickState.offlineReason,
reason: 'recovery_suspended',
suspendedReason: 'login-password-missing',
});
return { success: false };
}
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'password_attempt',
eventStatus: 'pending',
evidence: { containerName: runtime.name },
});
const passwordEnv = await this.ensureRuntimeLoginEnv(runtime, {
loginPassword,
selfId,
});
if (!passwordEnv.ok) {
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'password_attempt',
eventStatus: 'failed',
evidence: {
containerName: runtime.name,
reason: 'login-env-prepare-failed',
},
});
await this.recordAutoLoginSuspended({
accountId,
container,
reason: 'recovery_suspended',
suspendedReason: 'login-env-prepare-failed',
});
return { success: false };
}
const passwordState = await this.restartAndDetectLoginState(runtime);
if (passwordState.state !== 'online') {
const cleaned = await this.ensureRuntimeLoginEnv(runtime, {
clearLoginPassword: true,
selfId,
});
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'password_attempt',
eventStatus: 'failed',
evidence: {
cleanupOk: cleaned.ok,
containerName: runtime.name,
offlineReason: passwordState.offlineReason,
},
});
await this.recordAutoLoginSuspended({
accountId,
container,
offlineReason: passwordState.offlineReason,
reason:
this.toAutoLoginSuspendedReason(passwordState.offlineReason) ||
'recovery_suspended',
suspendedReason: 'password-auto-login-failed',
});
return cleaned.ok
? { success: false }
: { cleanupFailed: true, success: false };
}
const cleaned = await this.ensureRuntimeLoginEnv(runtime, {
clearLoginPassword: true,
selfId,
});
if (!cleaned.ok) {
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'password_attempt',
eventStatus: 'failed',
evidence: {
containerName: runtime.name,
reason: 'login-env-cleanup-failed',
},
});
return { cleanupFailed: true, success: false };
}
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'password_attempt',
eventStatus: 'success',
evidence: { containerName: runtime.name },
});
return { method: 'password', success: true };
}
/**
* Chooses the account id used by watchdog login-event records.
* @param inputContainer - Container row passed by account runtime, usually carrying account ownership.
* @param runtimeContainer - Container row reloaded with WebUI token for runtime actions.
* @returns Account id when it can be derived without looking up additional state.
*/
private getAutoLoginAccountId(
inputContainer: NapcatContainer,
runtimeContainer: NapcatContainer,
) {
return this.toolsService.toTrimmedString(
inputContainer.accountId || runtimeContainer.accountId,
);
}
/**
* Records a watchdog quick/password attempt when the owning account is known.
* @param input - Attempt kind, status, container, and non-secret runtime evidence.
*/
private async recordAutoLoginEvent(input: {
accountId: string;
container: NapcatContainer;
eventKind: 'password_attempt' | 'quick_attempt';
eventStatus: 'failed' | 'pending' | 'success';
evidence?: Record<string, unknown>;
}) {
if (!this.loginEventService || !input.accountId) return;
await this.loginEventService.record({
accountId: input.accountId,
containerId: input.container.id,
eventKind: input.eventKind,
eventSource: 'watchdog',
eventStatus: input.eventStatus,
evidence: input.evidence,
});
}
/**
* Records that watchdog auto-login must stop and wait for a human-visible login path.
* @param input - Account/container plus the reason automation cannot safely continue.
*/
private async recordAutoLoginSuspended(input: {
accountId: string;
container: NapcatContainer;
offlineReason?: null | string;
reason: NapcatLoginEventKind;
suspendedReason?: string;
}) {
if (!this.loginEventService || !input.accountId) return;
await this.loginEventService.recordSuspended({
accountId: input.accountId,
containerId: input.container.id,
evidence: {
offlineReason: input.offlineReason || undefined,
reason: input.suspendedReason || input.reason,
},
reason: input.reason,
source: 'watchdog',
});
}
/**
* Maps runtime login failure text to the first manual login path watchdog must not enter.
* @param offlineReason - NapCat log/status text produced after quick or password login attempt.
* @returns Blocking event kind when the next step requires captcha, new-device verification, or manual QR.
*/
private toAutoLoginSuspendedReason(
offlineReason?: null | string,
): NapcatLoginEventKind | null {
const reason = this.toolsService.toTrimmedString(offlineReason);
if (!reason) return null;
if (/captcha|proofWater|验证码|安全验证/i.test(reason)) {
return 'captcha_required';
}
if (/new.?device|新设备|设备验证/i.test(reason)) {
return 'new_device_required';
}
if (/qr.?code|二维码/i.test(reason)) {
return 'manual_qr_created';
}
return null;
}
/**
* NapCat
* @param name - `this.runProcess()` NapCat步骤
@ -635,6 +409,10 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
accountId,
containerId,
);
await this.containerRepository.update(
{ id: containerId, isDeleted: false },
{ accountId },
);
const existing = await this.bindingRepository.findOne({
where: {
@ -756,6 +534,32 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
return true;
}
/**
* Starts an existing managed NapCat container without rebuilding or restarting it.
* @param runtime - Persisted runtime row whose Docker name and id identify the stopped container to start.
* @returns True when the Docker start command was issued and the persisted row was marked running.
*/
private async startRuntimeContainer(runtime: QqbotNapcatRuntime) {
if (this.getManagedMode() !== 'ssh' || !runtime.id || !runtime.name) {
return false;
}
await this.runProcess(
'ssh',
[...this.getSshArgs(), 'docker', 'start', runtime.name],
'',
);
await this.containerRepository.update(
{ id: runtime.id },
{
lastError: null,
lastStartedAt: new Date(),
status: 'running',
},
);
return true;
}
/**
* Runtime Login State
* @param runtime - runtime 使 `id``name`
@ -1156,47 +960,6 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
`;
}
/**
* NapCat
* @param runtime - runtime 使 `name`
*/
private async restartAndDetectLoginState(runtime: QqbotNapcatRuntime) {
const since = new Date(Date.now() - 1000).toISOString();
await this.restartRuntimeContainer(runtime);
const attempts = Number(
this.getConfig('QQBOT_NAPCAT_AUTO_LOGIN_RETRIES', '10'),
);
const delayMs = Number(
this.getConfig('QQBOT_NAPCAT_AUTO_LOGIN_INTERVAL_MS', '2000'),
);
let latest: NapcatLoginLogResult = {
offlineReason: null,
state: 'unknown',
};
for (let index = 0; index < attempts; index += 1) {
await this.toolsService.sleep(Number.isFinite(delayMs) ? delayMs : 2000);
latest = await this.detectRuntimeLoginStateSince(runtime.name, since);
if (latest.state !== 'unknown') return latest;
}
return latest;
}
/**
* NapCat
* @param name - `this.runProcess()` NapCat步骤
* @param since - since `this.runProcess()` NapCat步骤
*/
private async detectRuntimeLoginStateSince(name: string, since: string) {
const result = await this.runProcess(
'ssh',
[...this.getSshArgs(), 'sh -s'],
this.buildRemoteRecentLogsSinceScript(name, since),
undefined,
this.getRuntimeCheckTimeoutMs(),
);
return this.extractLoginState(result.stdout);
}
/**
* NapCat
* @param logs - NapCat列表 extractLoginState
@ -1733,6 +1496,7 @@ ${file.content}EOF`;
baseUrl: this.normalizeBaseUrl(container.baseUrl),
id: container.id,
name: container.name,
sourceContainerOnline: container.status === 'running',
webuiPort: container.webuiPort,
webuiToken: container.webuiToken,
};

View File

@ -8,7 +8,6 @@ import { QqbotNapcatAccountRuntimeService } from './application/account-runtime/
import { QqbotNapcatLoginService } from './application/login/qqbot-napcat-login.service';
import { QqbotNapcatWatchdogService } from './application/login/qqbot-napcat-watchdog.service';
import { NapcatConfigWriterService } from './application/runtime/napcat-config-writer.service';
import { NapcatLoginEventService } from './application/runtime/napcat-login-event.service';
import { NapcatRuntimeProfileInspectorService } from './application/runtime/napcat-runtime-profile-inspector.service';
import { NapcatRuntimeProfileService } from './application/runtime/napcat-runtime-profile.service';
import { NapcatSessionBehaviorService } from './application/runtime/napcat-session-behavior.service';
@ -31,7 +30,6 @@ export const QQBOT_NAPCAT_PROVIDERS = [
NapcatConfigWriterService,
NapcatDeviceIdentityService,
NapcatLoginStateStoreService,
NapcatLoginEventService,
NapcatRuntimeProfileInspectorService,
NapcatRuntimeProfileInspectionScriptService,
NapcatRuntimeProfileService,
@ -48,7 +46,6 @@ export const QQBOT_NAPCAT_PROVIDERS = [
export const QQBOT_NAPCAT_EXPORTS = [
NapcatDeviceIdentityService,
NapcatLoginEventService,
NapcatLoginStateStoreService,
NapcatSessionBehaviorService,
QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT,

View File

@ -1,18 +1,5 @@
import { ToolsService } from '@/common';
import { QqbotNapcatAccountRuntimeService } from '../../../../src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service';
import { NapcatLoginEventService } from '../../../../src/modules/qqbot/napcat/application/runtime/napcat-login-event.service';
import { QqbotNapcatContainerService } from '../../../../src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service';
/**
* Creates a TypeORM-like repository mock for login-event service tests.
* @returns Repository mock that captures created and saved login-event payloads.
*/
const createRepository = () => ({
create: jest.fn((input) => input),
findOne: jest.fn(),
save: jest.fn(async (input) => input),
update: jest.fn(),
});
/**
* Creates the chained query builder shape used by account runtime joins.
@ -28,85 +15,8 @@ const createManyQueryBuilder = <T>(rows: T[]) => ({
where: jest.fn().mockReturnThis(),
});
describe('NapCat login event and recovery lease', () => {
it('records quick and password attempts as login events, not send budgets', async () => {
const repository = createRepository();
const service = new NapcatLoginEventService(repository as any);
await service.record({
accountId: 'account-1',
containerId: 'container-1',
eventKind: 'quick_attempt',
eventSource: 'watchdog',
eventStatus: 'success',
evidence: { method: 'quick' },
});
await service.record({
accountId: 'account-1',
containerId: 'container-1',
eventKind: 'password_attempt',
eventSource: 'watchdog',
eventStatus: 'failed',
evidence: { method: 'password' },
});
expect(repository.save).toHaveBeenCalledTimes(2);
expect(JSON.stringify(repository.save.mock.calls)).not.toMatch(
/daily|hour|quota|budget/i,
);
});
it('suspends automatic recovery after captcha, new-device, or manual QR is required', async () => {
const repository = createRepository();
const service = new NapcatLoginEventService(repository as any);
await service.recordSuspended({
accountId: 'account-1',
containerId: 'container-1',
evidence: { reason: 'new-device-required' },
reason: 'new_device_required',
source: 'watchdog',
});
expect(repository.save).toHaveBeenCalledWith(
expect.objectContaining({
eventKind: 'recovery_suspended',
eventSource: 'watchdog',
eventStatus: 'blocked',
}),
);
});
it('blocks automatic recovery until a later successful connection exists', async () => {
const repository = createRepository();
repository.findOne.mockResolvedValue({
createTime: new Date('2026-06-18T08:00:00.000Z'),
eventKind: 'new_device_required',
});
const service = new NapcatLoginEventService(repository as any);
await expect(
service.canAttemptAutomaticRecovery({
accountId: 'account-1',
containerId: 'container-1',
resetAfter: new Date('2026-06-18T07:59:00.000Z'),
}),
).resolves.toEqual({
allowed: false,
reason: 'new_device_required',
});
await expect(
service.canAttemptAutomaticRecovery({
accountId: 'account-1',
containerId: 'container-1',
resetAfter: new Date('2026-06-18T08:01:00.000Z'),
}),
).resolves.toEqual({ allowed: true });
});
});
describe('NapCat watchdog auto-login boundaries', () => {
it('does not call container auto-login when recovery is suspended', async () => {
describe('NapCat watchdog notification boundaries', () => {
it('does not call container auto-login when watchdog detects QQ login offline', async () => {
const accountNapcatRepository = {
createQueryBuilder: jest.fn(() =>
createManyQueryBuilder([
@ -124,8 +34,8 @@ describe('NapCat watchdog auto-login boundaries', () => {
createManyQueryBuilder([
{
id: 'container-1',
lastCheckedAt: new Date(),
lastError: '账号状态变更为离线',
lastCheckedAt: null,
lastError: null,
name: 'kt-qqbot-napcat-10001',
status: 'running',
},
@ -133,24 +43,21 @@ describe('NapCat watchdog auto-login boundaries', () => {
),
};
const containerService = {
detectRuntimeOffline: jest.fn(),
tryAutoLogin: jest.fn(),
};
const loginEventService = {
canAttemptAutomaticRecovery: jest.fn().mockResolvedValue({
allowed: false,
reason: 'recovery_suspended',
}),
recordSuspended: jest.fn(),
detectRuntimeOffline: jest
.fn()
.mockResolvedValue('NapCat 账号状态变更为离线'),
};
const service = new QqbotNapcatAccountRuntimeService(
accountNapcatRepository as any,
containerRepository as any,
containerService as any,
new ToolsService(),
undefined,
loginEventService as any,
);
const actions = {
clearQqLoginError: jest.fn(),
markQqLoginOffline: jest.fn(),
publishOfflineNotice: jest.fn(),
};
await service.appendRuntime(
[
@ -161,118 +68,19 @@ describe('NapCat watchdog auto-login boundaries', () => {
selfId: '10001',
} as any,
],
{ autoLogin: true },
{
clearQqLoginError: jest.fn(),
getLoginPassword: jest.fn(),
markOnline: jest.fn(),
markQqLoginOffline: jest.fn(),
publishOfflineNotice: jest.fn(),
},
actions as any,
);
expect(containerService.tryAutoLogin).not.toHaveBeenCalled();
expect(loginEventService.recordSuspended).toHaveBeenCalledWith(
expect(actions.markQqLoginOffline).toHaveBeenCalledWith(
'10001',
'NapCat 账号状态变更为离线',
);
expect(actions.publishOfflineNotice).toHaveBeenCalledWith(
'10001',
'NapCat 账号状态变更为离线',
expect.objectContaining({
accountId: 'account-1',
containerId: 'container-1',
reason: 'recovery_suspended',
source: 'watchdog',
}),
);
});
it('does not create a manual QR session from watchdog auto-login', async () => {
const service = new QqbotNapcatContainerService(
{ get: jest.fn().mockReturnValue('') } as any,
{} as any,
{} as any,
new ToolsService(),
) as any;
service.getManagedMode = jest.fn().mockReturnValue('ssh');
service.findContainerWithToken = jest.fn().mockResolvedValue({
baseUrl: 'http://127.0.0.1:6100/',
id: 'container-1',
name: 'kt-qqbot-napcat-10001',
});
service.ensureRuntimeLoginEnv = jest.fn().mockResolvedValue({
changed: false,
ok: true,
});
service.restartAndDetectLoginState = jest
.fn()
.mockResolvedValue({ offlineReason: '历史会话失效', state: 'offline' });
const result = await service.tryAutoLogin(
{ id: 'container-1', name: 'kt-qqbot-napcat-10001' },
{ selfId: '10001' },
);
expect(result.success).toBe(false);
expect(JSON.stringify(service.runProcess?.mock?.calls || [])).not.toContain(
'qrcode',
);
});
it('records password auto-login failure as suspended recovery', async () => {
const loginEventService = {
record: jest.fn(),
recordSuspended: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{ get: jest.fn().mockReturnValue('') } as any,
{} as any,
{} as any,
new ToolsService(),
undefined,
undefined,
undefined,
loginEventService as any,
) as any;
service.getManagedMode = jest.fn().mockReturnValue('ssh');
service.findContainerWithToken = jest.fn().mockResolvedValue({
accountId: 'account-1',
baseUrl: 'http://127.0.0.1:6100/',
id: 'container-1',
name: 'kt-qqbot-napcat-10001',
});
service.ensureRuntimeLoginEnv = jest
.fn()
.mockResolvedValueOnce({ changed: false, ok: true })
.mockResolvedValueOnce({ changed: true, ok: true })
.mockResolvedValueOnce({ changed: true, ok: true });
service.restartAndDetectLoginState = jest
.fn()
.mockResolvedValueOnce({
offlineReason: '历史会话失效',
state: 'offline',
})
.mockResolvedValueOnce({
offlineReason: '密码登录失败',
state: 'offline',
});
const result = await service.tryAutoLogin(
{ accountId: 'account-1', id: 'container-1' } as any,
{
loginPassword: 'qq-password',
selfId: '10001',
},
);
expect(result).toEqual({ success: false });
expect(loginEventService.record).toHaveBeenCalledWith(
expect.objectContaining({
eventKind: 'password_attempt',
eventStatus: 'failed',
}),
);
expect(loginEventService.recordSuspended).toHaveBeenCalledWith(
expect.objectContaining({
accountId: 'account-1',
containerId: 'container-1',
reason: 'recovery_suspended',
source: 'watchdog',
containerName: 'kt-qqbot-napcat-10001',
}),
);
});

View File

@ -400,7 +400,8 @@ describe('QqbotAccountService', () => {
);
expect(systemNoticePublisher.publishSystemNotice).toHaveBeenCalledWith(
expect.objectContaining({
content: 'NapCat 账号状态变更为离线',
content:
'NapCat 账号状态变更为离线\n请在 Admin 的 QQBot 账号页面手动点击「更新登录」重新登录。',
dedupeKey: 'qqbot:offline:1914728559',
eventType: 'qqbot.account.offline',
notifyRoleCode: 'super',
@ -990,7 +991,7 @@ describe('QqbotAccountService', () => {
);
});
it('lets watchdog auto-login before marking an account offline', async () => {
it('notifies manual re-login instead of auto-login when watchdog detects QQ login offline', async () => {
const toolsService = new ToolsService();
const account = {
connectStatus: 'online',
@ -1034,10 +1035,6 @@ describe('QqbotAccountService', () => {
detectRuntimeOffline: jest
.fn()
.mockResolvedValue('NapCat 账号状态变更为离线'),
tryAutoLogin: jest.fn().mockResolvedValue({
method: 'password',
success: true,
}),
};
const systemNoticePublisher = {
publishSystemNotice: jest.fn().mockResolvedValue(undefined),
@ -1073,26 +1070,24 @@ describe('QqbotAccountService', () => {
const result = await service.runOfflineWatchdog();
expect(result).toEqual({ checked: 1 });
expect(napcatContainerService.tryAutoLogin).toHaveBeenCalledWith(
container,
{
loginPassword: 'qq-login-password',
selfId: '1914728559',
},
);
expect(accountRepository.update).toHaveBeenCalledWith(
{ selfId: '1914728559' },
{
lastError: 'NapCat 账号状态变更为离线',
},
);
expect(systemNoticePublisher.publishSystemNotice).toHaveBeenCalledWith(
expect.objectContaining({
clientRole: 'Universal',
connectStatus: 'online',
lastConnectedAt: expect.any(Date),
lastError: null,
content:
'NapCat 账号状态变更为离线\n请在 Admin 的 QQBot 账号页面手动点击「更新登录」重新登录。',
eventType: 'qqbot.account.offline',
severity: 'error',
title: 'QQBot 账号已下线1914728559',
}),
);
expect(systemNoticePublisher.publishSystemNotice).not.toHaveBeenCalled();
});
it('records a cleanup failure when watchdog auto-login leaves runtime password env uncertain', async () => {
it('never reports auto-login cleanup failure from watchdog because watchdog no longer logs in', async () => {
const toolsService = new ToolsService();
const account = {
connectStatus: 'online',
@ -1136,10 +1131,6 @@ describe('QqbotAccountService', () => {
detectRuntimeOffline: jest
.fn()
.mockResolvedValue('NapCat 账号状态变更为离线'),
tryAutoLogin: jest.fn().mockResolvedValue({
cleanupFailed: true,
success: false,
}),
};
const systemNoticePublisher = {
publishSystemNotice: jest.fn().mockResolvedValue(undefined),
@ -1178,19 +1169,20 @@ describe('QqbotAccountService', () => {
expect(accountRepository.update).toHaveBeenCalledWith(
{ selfId: '1914728559' },
{
lastError: 'NapCat 自动登录后运行态密码清理失败,请手动更新登录',
lastError: 'NapCat 账号状态变更为离线',
},
);
expect(systemNoticePublisher.publishSystemNotice).toHaveBeenCalledWith(
expect.objectContaining({
content: 'NapCat 自动登录后运行态密码清理失败,请手动更新登录',
content:
'NapCat 账号状态变更为离线\n请在 Admin 的 QQBot 账号页面手动点击「更新登录」重新登录。',
eventType: 'qqbot.account.offline',
severity: 'error',
title: 'QQBot 账号已下线1914728559',
}),
);
expect(JSON.stringify(accountRepository.update.mock.calls)).not.toContain(
'NapCat 账号状态变更为离线',
'NapCat 自动登录后运行态密码清理失败,请手动更新登录',
);
});
});

File diff suppressed because it is too large Load Diff

View File

@ -83,6 +83,51 @@ describe('QqbotNapcatContainerService', () => {
);
});
it('backfills container ownership when binding an account to a legacy container', async () => {
const bindingRepository = {
create: jest.fn((input) => input),
createQueryBuilder: jest.fn(() => ({
andWhere: jest.fn().mockReturnThis(),
getCount: jest.fn().mockResolvedValue(0),
where: jest.fn().mockReturnThis(),
})),
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue({
accountId: 'account-1',
containerId: 'container-old',
id: 'binding-1',
isDeleted: false,
}),
save: jest.fn(),
update: jest.fn(),
};
const containerRepository = {
findOne: jest.fn(),
update: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{ get: jest.fn().mockReturnValue('') } as unknown as ConfigService,
containerRepository as any,
bindingRepository as any,
new ToolsService(),
);
await service.bindAccount('account-1', 'container-1');
expect(containerRepository.update).toHaveBeenCalledWith(
{ id: 'container-1', isDeleted: false },
{ accountId: 'account-1' },
);
expect(bindingRepository.update).toHaveBeenCalledWith(
{ id: 'binding-1' },
expect.objectContaining({
bindStatus: 'bound',
containerId: 'container-1',
isPrimary: true,
}),
);
});
it('uses the latest NapCat account status line as runtime offline truth', () => {
const service = new QqbotNapcatContainerService(
{ get: jest.fn() } as any,
@ -390,9 +435,7 @@ describe('QqbotNapcatContainerService', () => {
expect(script).toContain('-e XDG_DATA_HOME=/app/.local/share');
expect(script).toContain('-e XDG_RUNTIME_DIR=/tmp/runtime-napcat');
expect(script).toContain('-v "$DATA_DIR/cache:/app/.cache"');
expect(script).toContain(
'-v "$DATA_DIR/local-share:/app/.local/share"',
);
expect(script).toContain('-v "$DATA_DIR/local-share:/app/.local/share"');
expect(script).toContain('-v "$DATA_DIR/logs:/app/napcat/logs"');
expect(script).toContain('cat > "$DATA_DIR/config/napcat_10001.json"');
expect(script).toContain('cat > "$DATA_DIR/config/onebot11_10001.json"');
@ -447,6 +490,141 @@ describe('QqbotNapcatContainerService', () => {
expect(containerRepository.update).not.toHaveBeenCalled();
});
it('does not recreate quick-login env when the bound source container is already online', async () => {
const bindingRepository = {
findOne: jest.fn().mockResolvedValue({
accountId: 'account-1',
bindStatus: 'bound',
containerId: 'container-online',
isDeleted: false,
isPrimary: true,
}),
};
const containerRepository = {
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue({
baseUrl: 'http://127.0.0.1:6100/',
dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-x',
id: 'container-online',
image: 'mlikiowa/napcat-docker:latest',
lastError: null,
name: 'kt-qqbot-napcat-x',
reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse',
status: 'running',
webuiPort: 6100,
webuiToken: 'token-x',
}),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{
get: jest.fn((key: string) =>
key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '',
),
} as any,
containerRepository as any,
bindingRepository as any,
new ToolsService(),
) as any;
const ensureQuickLogin = jest.spyOn(service, 'ensureRuntimeQuickLogin');
jest.spyOn(service, 'inspectRuntimeStatus').mockResolvedValue({
containerOnline: true,
qqLoginStatus: 'online',
webuiOnline: true,
});
const runtime = await service.prepareAccountContainer({
id: 'account-1',
selfId: '2354598417',
});
expect(ensureQuickLogin).not.toHaveBeenCalled();
expect(runtime).toEqual(
expect.objectContaining({
hasExistingPrimaryBinding: true,
runtimeRebuildCount: 0,
sourceContainerOnline: true,
}),
);
});
it('starts a stopped primary container when login env already matches', async () => {
const bindingRepository = {
findOne: jest.fn().mockResolvedValue({
accountId: 'account-1',
bindStatus: 'bound',
containerId: 'container-stopped',
isDeleted: false,
isPrimary: true,
}),
};
const containerRepository = {
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue({
baseUrl: 'http://127.0.0.1:6100/',
dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-x',
id: 'container-stopped',
image: 'mlikiowa/napcat-docker:latest',
lastError: null,
name: 'kt-qqbot-napcat-x',
reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse',
status: 'stopped',
webuiPort: 6100,
webuiToken: 'token-x',
}),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{
get: jest.fn((key: string) =>
key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '',
),
} as any,
containerRepository as any,
bindingRepository as any,
new ToolsService(),
) as any;
service.runProcess = jest
.fn()
.mockResolvedValueOnce({
stderr: '',
stdout: 'ACCOUNT=2354598417\nPATH=/x',
})
.mockResolvedValueOnce({ stderr: '', stdout: 'kt-qqbot-napcat-x\n' });
const runtime = await service.prepareAccountContainer({
id: 'account-1',
selfId: '2354598417',
});
expect(runtime).toEqual(
expect.objectContaining({
hasExistingPrimaryBinding: true,
runtimeRebuildCount: 0,
sourceContainerOnline: false,
}),
);
expect(service.runProcess).toHaveBeenCalledTimes(2);
expect(service.runProcess.mock.calls[1][1]).toEqual(
expect.arrayContaining(['docker', 'start', 'kt-qqbot-napcat-x']),
);
expect(containerRepository.update).toHaveBeenCalledWith(
{ id: 'container-stopped' },
expect.objectContaining({
lastError: null,
status: 'running',
}),
);
});
it('recreates login env when docker env inspection fails', async () => {
const containerRepository = {
createQueryBuilder: jest.fn(() => ({
@ -662,176 +840,6 @@ describe('QqbotNapcatContainerService', () => {
);
});
it('does not report password auto-login success when password env cleanup fails', async () => {
const containerRepository = {
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue({
baseUrl: 'http://127.0.0.1:6100/',
id: 'container-1',
name: 'kt-qqbot-napcat-x',
webuiPort: 6100,
webuiToken: 'token-x',
}),
where: jest.fn().mockReturnThis(),
})),
};
const service = new QqbotNapcatContainerService(
{
get: jest.fn((key: string) =>
key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '',
),
} as any,
containerRepository as any,
{} as any,
new ToolsService(),
) as any;
jest
.spyOn(service, 'ensureRuntimeLoginEnv')
.mockResolvedValueOnce({ changed: false, ok: true })
.mockResolvedValueOnce({ changed: true, ok: true })
.mockResolvedValueOnce({ changed: false, ok: false });
jest
.spyOn(service, 'restartAndDetectLoginState')
.mockResolvedValueOnce({
offlineReason: '历史会话失效',
state: 'offline',
})
.mockResolvedValueOnce({ offlineReason: null, state: 'online' });
const result = await service.tryAutoLogin(
{ id: 'container-1' },
{
loginPassword: 'qq-password',
selfId: '2354598417',
},
);
expect(result).toEqual({
cleanupFailed: true,
success: false,
});
expect(service.ensureRuntimeLoginEnv).toHaveBeenNthCalledWith(
3,
expect.objectContaining({ id: 'container-1' }),
{
clearLoginPassword: true,
selfId: '2354598417',
},
);
});
it('does not leave password env untracked when password auto-login fails and cleanup fails', async () => {
const containerRepository = {
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue({
baseUrl: 'http://127.0.0.1:6100/',
id: 'container-1',
name: 'kt-qqbot-napcat-x',
webuiPort: 6100,
webuiToken: 'token-x',
}),
where: jest.fn().mockReturnThis(),
})),
};
const service = new QqbotNapcatContainerService(
{
get: jest.fn((key: string) =>
key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '',
),
} as any,
containerRepository as any,
{} as any,
new ToolsService(),
) as any;
jest
.spyOn(service, 'ensureRuntimeLoginEnv')
.mockResolvedValueOnce({ changed: false, ok: true })
.mockResolvedValueOnce({ changed: true, ok: true })
.mockResolvedValueOnce({ changed: false, ok: false });
jest
.spyOn(service, 'restartAndDetectLoginState')
.mockResolvedValueOnce({
offlineReason: '历史会话失效',
state: 'offline',
})
.mockResolvedValueOnce({
offlineReason: '密码登录失败',
state: 'offline',
});
const result = await service.tryAutoLogin(
{ id: 'container-1' },
{
loginPassword: 'qq-password',
selfId: '2354598417',
},
);
expect(result).toEqual({
cleanupFailed: true,
success: false,
});
expect(service.ensureRuntimeLoginEnv).toHaveBeenNthCalledWith(
3,
expect.objectContaining({ id: 'container-1' }),
{
clearLoginPassword: true,
selfId: '2354598417',
},
);
});
it('does not restart quick auto-login when password env cleanup fails first', async () => {
const containerRepository = {
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue({
baseUrl: 'http://127.0.0.1:6100/',
id: 'container-1',
name: 'kt-qqbot-napcat-x',
webuiPort: 6100,
webuiToken: 'token-x',
}),
where: jest.fn().mockReturnThis(),
})),
};
const service = new QqbotNapcatContainerService(
{
get: jest.fn((key: string) =>
key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '',
),
} as any,
containerRepository as any,
{} as any,
new ToolsService(),
) as any;
jest
.spyOn(service, 'ensureRuntimeLoginEnv')
.mockResolvedValueOnce({ changed: false, ok: false });
jest
.spyOn(service, 'restartAndDetectLoginState')
.mockResolvedValueOnce({ offlineReason: null, state: 'online' });
const result = await service.tryAutoLogin(
{ id: 'container-1' },
{
loginPassword: 'qq-password',
selfId: '2354598417',
},
);
expect(result).toEqual({
cleanupFailed: true,
success: false,
});
expect(service.restartAndDetectLoginState).not.toHaveBeenCalled();
});
it('requires an explicit NapCat image when creating a managed container', async () => {
const containerRepository = {
find: jest.fn().mockResolvedValue([]),