fix: 收敛NapCat更新登录恢复链路
This commit is contained in:
parent
832eeeeec7
commit
99bf52163f
10
API.md
10
API.md
@ -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 分钟内只复读一次,并限制普通文本长度,减少自动行为被风控识别的概率。
|
||||
|
||||
|
||||
@ -77,7 +77,7 @@ QQBot 插件定时任务由 manifest 的 `tasks` 声明,平台持久化到 `qq
|
||||
|
||||
Admin 环境总览面板使用 `ENV_DASHBOARD_*` 只读配置聚合 local-dev、NAS 线上、腾讯云和 r4se 状态。`ENV_DASHBOARD_ADMIN_LOCAL_URL` / `ENV_DASHBOARD_ADMIN_PUBLIC_URL` 只用于展示 Admin 本机与线上入口证据。HTTP 快照提供当前拓扑,后端 local/MQTT 事件总线通过 SSE 推送增量事件给 Admin;前端不直连 MQTT,也不轮询刷新。Jenkins、K8s、Tencent Cloud、Caddy、WireGuard、Mihomo/OpenClash 未配置时会显示 `unwired` 证据,不能渲染成健康假象;第一版不暴露重启、部署、迁移、容器重建、插件启停或代理切换等写操作。
|
||||
|
||||
NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布和账号闭环按 `docs/superpowers/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md` 的 Task 10 执行。当前实现覆盖运行态/协议/会话行为/登录事件/风险模式表,真实物理设备风格 hostname/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 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/MAC,NapCat/OneBot 配置 hash,KT `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:*` MAC;Docker run 会注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id`,并同步写入 QQNT Linux `machine-info`,使 `/etc/machine-id`、Docker MAC 和 QQNT 本地设备缓存保持一致。当前策略名为 `qqnt-visible-hostname-v1` / `docker-bridge-mac-v1`。
|
||||
- NapCat 新设备验证走同一 scan session:`CaptchaLogin` 返回 `needNewDevice` 后,后端继续调用 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`,Admin/SSE 分开展示 `captchaUrl`、`newDeviceQrcode`、已扫码、确认中、验证成功、登录成功/失败等中文进度,不把 `jumpUrl` 当作唯一完成入口。
|
||||
- NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000`,`QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后先尝试 `ACCOUNT` 历史会话快速登录,再尝试账号保存的登录密码,仍失败时写入离线原因并复用 `super` 站内信告警;看门狗不自动进入扫码阶段。
|
||||
- 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 回归。
|
||||
|
||||
@ -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<{
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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` 字段生成结果。
|
||||
|
||||
@ -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 ID;定位本次读取、更新、删除或关联的NapCat。
|
||||
@ -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 登录运行态数据。
|
||||
*/
|
||||
|
||||
@ -13,7 +13,7 @@ const MIN_INTERVAL_MS = 30_000;
|
||||
/**
|
||||
* NapCat 离线看门狗:定时主动巡检在线账号,及时发现掉线/被踢并触发既有站内信告警。
|
||||
* 不依赖 @nestjs/schedule,采用 OnModuleInit + setInterval(与代码库既有定时器一致)。
|
||||
* 仅检测 + 告警,自动重登由容器运行时适配器负责。
|
||||
* 仅检测 + 告警,登录恢复必须由管理员在 Admin 手动触发更新登录。
|
||||
*/
|
||||
@Injectable()
|
||||
export class QqbotNapcatWatchdogService
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@ -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
@ -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([]),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user