diff --git a/API.md b/API.md index 6150fc5..4f1b3d7 100644 --- a/API.md +++ b/API.md @@ -345,7 +345,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 账号保存支持可选 `encryptedLoginPassword`,用于 NapCat 密码登录。前端必须先通过 `/auth/password-public-key` 获取公钥并使用 RSA-OAEP 加密,不传明文 `loginPassword`;后端必须使用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密落库,空值和公开默认值会被拒绝,不在列表/详情中返回。账号列表里的 `connectStatus` 只表示 OneBot 反向 WS;`napcat.oneBotOnline`、`napcat.containerOnline`、`napcat.webuiOnline`、`napcat.qqLoginStatus`、`napcat.qqLoginMessage` 分别表示 OneBot、容器、WebUI 和 QQ 登录态,`webuiOnline=null` 表示本次使用缓存且未重新探测 WebUI;`qqLoginMessage` 只承载真实 QQ 登录态消息,WebUI 配置缺失或请求异常只放在 `lastError`。 -扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。已有账号的更新登录会先重启目标 NapCat 容器尝试 `ACCOUNT`/`-q` 快速登录;目标账号在线则直接完成会话。没有历史登录态的新容器会跳过快速登录,优先尝试保存的登录密码;快速登录失败后,如果账号保存了登录密码,会临时注入 `NAPCAT_QUICK_PASSWORD` 并按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询密码登录结果,准备阶段的扫码会话会持续续期,避免后台密码登录未完成时前端先判过期。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码等待窗口加缓冲)后,`/qqbot/account/scan/status` 会自动恢复普通登录态检测,不再永久停留在“正在尝试密码登录”;`/qqbot/account/scan/events` 在进程内事件缓存丢失时会先推送当前会话快照。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。会话已有 `captchaUrl` 后,`/qqbot/account/scan/status` 遇到 NapCat 继续返回“需要验证码/继续完成验证/安全验证”但不带 URL 时仍保持 `pending` 和原 `captchaUrl`。密码登录成功后会重建容器移除该运行态密码,清理失败则本次登录失败;密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,再进入重置登录态和二维码兜底流程。看门狗自动登录使用同样的 quick -> password 顺序,但不会自动进入扫码阶段。 +扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。已有账号的更新登录会先重启目标 NapCat 容器尝试 `ACCOUNT`/`-q` 快速登录;目标账号在线则直接完成会话。没有历史登录态的新容器会跳过快速登录,优先尝试保存的登录密码;快速登录失败后,如果账号保存了登录密码,会临时注入 `NAPCAT_QUICK_PASSWORD` 并按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询密码登录结果,准备阶段的扫码会话会持续续期,避免后台密码登录未完成时前端先判过期。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码等待窗口加缓冲)后,`/qqbot/account/scan/status` 会自动恢复普通登录态检测,不再永久停留在“正在尝试密码登录”;`/qqbot/account/scan/events` 在进程内事件缓存丢失时会先推送当前会话快照。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。`/qqbot/account/scan/status` 遇到 NapCat 只返回“需要验证码/继续完成验证/安全验证”但不带 URL 时,会先从当前容器日志提取 `proofWaterUrl`,提取不到则保持验证码处理中而不切到二维码兜底;会话已有 `captchaUrl` 后,同类状态仍保持 `pending` 和原 `captchaUrl`。密码登录成功后会重建容器移除该运行态密码,清理失败则本次登录失败;密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,再进入重置登录态和二维码兜底流程。看门狗自动登录使用同样的 quick -> password 顺序,但不会自动进入扫码阶段。 密码验证码通过后如果 NapCat 返回 `needNewDevice`,后端不会只把 `jumpUrl` 透给 Admin,而是在同一会话中继续调用 `/api/QQLogin/GetNewDeviceQRCode` 生成新设备验证二维码;`/qqbot/account/scan/status` 后续轮询会代理 `/api/QQLogin/PollNewDeviceQR`,状态映射为 `newDeviceStatus=qr-pending|scanned|confirming|verified|expired|failed`,进入确认态后再调用 `/api/QQLogin/NewDeviceLogin` 并回到密码登录完成检查。扫码会话结果新增 `newDeviceQrcode`、`newDeviceStatus`、`deviceVerifyUrl` 字段;`captchaUrl` 和 `newDeviceQrcode` 分别表示腾讯安全验证码和 QQ 新设备验证二维码,前端必须分开展示。SSE 进度文案包含快速登录、密码登录、验证码、新设备二维码、已扫码、确认中、登录成功/失败和运行态清理失败。 diff --git a/README.md b/README.md index 9a504a9..7e97004 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow - QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表拆开展示 OneBot、容器、WebUI 和 QQ 登录态,心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态;`qqLoginMessage` 只展示 QQ 登录态消息,WebUI 配置或请求错误留在 `lastError`。 - NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。 - NapCat 账号新增/编辑支持可选 QQ 登录密码:Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。 -- NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。`ACCOUNT` 只负责指定快速登录账号;如果数据卷内没有该 QQ 的历史登录记录,后端会跳过快速登录并优先尝试账号保存的登录密码,密码登录会按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果,准备中的扫码会话会续期,避免后台密码登录未结束时前端先过期;如果 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码登录等待窗口加缓冲)后会自动恢复为普通状态检测,不会永久卡在“正在尝试密码登录”。如果 NapCat 返回 `proofWaterUrl` 或日志出现“需要验证码”,会保持会话 pending 并把腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`,不是让用户只打开外链;密码登录成功后会移除运行态 `NAPCAT_QUICK_PASSWORD`,清理失败则本次登录失败,避免成功态残留明文 env;密码登录也失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时才重置登录态并生成二维码。Admin「更新登录」的 SSE 步骤顺序按实际路径为 `quick-login-*`(已有历史会话)-> `password-login-*` / `password-login-captcha` -> `password-env-cleanup` -> `relogin-reset/qrcode/waiting-scan`;SSE 事件缓存因 Pod 重启丢失时,新订阅会收到当前会话快照。 +- NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。`ACCOUNT` 只负责指定快速登录账号;如果数据卷内没有该 QQ 历史登录记录,后端会跳过快速登录并优先尝试账号保存的登录密码,密码登录会按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果,准备中的扫码会话会续期,避免后台密码登录未结束时前端先过期;如果 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码登录等待窗口加缓冲)后会自动恢复为普通状态检测,不会永久卡在“正在尝试密码登录”。如果 NapCat 返回 `proofWaterUrl` 或日志出现“需要验证码”,会保持会话 pending 并把腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`,不是让用户只打开外链;状态轮询遇到验证码文案但缺少 URL 时,会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底;密码登录成功后会移除运行态 `NAPCAT_QUICK_PASSWORD`,清理失败则本次登录失败,避免成功态残留明文 env;密码登录也失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时才重置登录态并生成二维码。Admin「更新登录」的 SSE 步骤顺序按实际路径为 `quick-login-*`(已有历史会话)-> `password-login-*` / `password-login-captcha` -> `password-env-cleanup` -> `relogin-reset/qrcode/waiting-scan`;SSE 事件缓存因 Pod 重启丢失时,新订阅会收到当前会话快照。 - NapCat 设备身份按账号持久化到 `napcat_device_identity`:同一账号重建容器会复用数据目录、hostname、machine-id 和 MAC,并在 Docker run 中注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id`,避免频繁重建被 QQ 判定为全新设备。 - NapCat 新设备验证走同一 scan session:`CaptchaLogin` 返回 `needNewDevice` 后,后端继续调用 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`,Admin/SSE 分开展示 `captchaUrl`、`newDeviceQrcode`、已扫码、确认中、验证成功、登录成功/失败等中文进度,不把 `jumpUrl` 当作唯一完成入口。 - NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000`,`QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后先尝试 `ACCOUNT` 历史会话快速登录,再尝试账号保存的登录密码,仍失败时写入离线原因并复用 `super` 站内信告警;看门狗不自动进入扫码阶段。 diff --git a/src/modules/qqbot/core/contract/qqbot.types.ts b/src/modules/qqbot/core/contract/qqbot.types.ts index 3972e16..487972e 100644 --- a/src/modules/qqbot/core/contract/qqbot.types.ts +++ b/src/modules/qqbot/core/contract/qqbot.types.ts @@ -317,6 +317,7 @@ export type QqbotLoginScanSession = { expiresAt: number; expectedSelfId?: string; id: string; + lastCaptchaLookupAt?: number; lastRestartedAt?: number; mode: QqbotLoginScanMode; newDevicePullQrCodeSig?: string; diff --git a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts index e04495f..00b2aa2 100644 --- a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts +++ b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts @@ -223,6 +223,18 @@ export class QqbotNapcatLoginService { status.loginError, ); } + const recoveredCaptchaUrl = await this.resolveStatusCaptchaUrl( + session, + container, + status, + ); + if (recoveredCaptchaUrl) { + return this.keepPasswordCaptchaPending( + session, + recoveredCaptchaUrl, + status.loginError, + ); + } if (session.captchaUrl) { if (this.isPasswordCaptchaStillRequired(status)) { return this.keepPasswordCaptchaPending( @@ -244,6 +256,12 @@ export class QqbotNapcatLoginService { status.loginError || '验证码登录未完成', ); } + if (this.isPasswordCaptchaStillRequired(status)) { + return this.keepPasswordCaptchaWaitingForUrl( + session, + status.loginError, + ); + } session.errorMessage = status.loginError || undefined; if ( @@ -983,6 +1001,31 @@ export class QqbotNapcatLoginService { return this.toResult(session); } + private keepPasswordCaptchaWaitingForUrl( + session: QqbotLoginScanSession, + reason?: string, + ) { + const message = + this.toolsService.toTrimmedString(reason) || + '密码登录需要完成 QQ 安全验证'; + const shouldPublish = session.errorMessage !== message; + session.status = 'pending'; + session.captchaUrl = undefined; + session.qrcode = undefined; + session.errorMessage = message; + session.expiresAt = Date.now() + this.getSessionTtlMs(); + this.persistLoginSession(session); + if (shouldPublish) { + this.publishScanResultEvent( + session, + 'password-login-captcha', + 'processing', + message, + ); + } + return this.toResult(session); + } + private async failCaptchaLogin( session: QqbotLoginScanSession, container: QqbotNapcatRuntime, @@ -1780,6 +1823,18 @@ export class QqbotNapcatLoginService { return this.waitForPasswordCaptchaUrl(container, sinceMs); } + private async resolveStatusCaptchaUrl( + session: QqbotLoginScanSession, + container: QqbotNapcatRuntime, + loginStatus: NapcatLoginStatus, + ) { + if (!this.isPasswordCaptchaStillRequired(loginStatus)) return ''; + if (!this.shouldLookupStatusCaptchaUrl(session)) return ''; + session.lastCaptchaLookupAt = Date.now(); + this.persistLoginSession(session); + return this.detectPasswordCaptchaUrl(container, session.lastRestartedAt); + } + private getCaptchaUrlFromStatus(status: NapcatLoginStatus) { return ( this.toolsService.toTrimmedString(status.captchaUrl) || @@ -1787,6 +1842,13 @@ export class QqbotNapcatLoginService { ); } + private shouldLookupStatusCaptchaUrl(session: QqbotLoginScanSession) { + const lastCheckedAt = Number(session.lastCaptchaLookupAt || 0); + if (!Number.isFinite(lastCheckedAt) || lastCheckedAt <= 0) return true; + const cooldownMs = Math.max(15_000, this.getLoginPollIntervalMs() * 5); + return Date.now() - lastCheckedAt > cooldownMs; + } + private isPasswordCaptchaStillRequired(status: NapcatLoginStatus) { if (this.getCaptchaUrlFromStatus(status)) return true; const message = this.toolsService.toTrimmedString(status.loginError); diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index 89c6f13..9078532 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -247,6 +247,49 @@ describe('QqbotNapcatLoginService', () => { .toBe(false); }); + it('uses runtime logs to recover captcha url when status only says captcha required', async () => { + const captchaUrl = + 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001'; + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-status-captcha', + name: 'napcat-10001', + }; + const containerService = { + detectRuntimeCaptchaUrl: jest.fn().mockResolvedValue(captchaUrl), + findRuntimeById: jest.fn().mockResolvedValue(container), + removeUnboundContainer: jest.fn().mockResolvedValue(false), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + {} as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (refreshService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '10001', + mode: 'refresh', + status: 'pending', + }); + session.lastRestartedAt = Date.now() - 10_000; + (refreshService as any).sessions.set(session.id, session); + jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ + isLogin: false, + loginError: '密码回退需要验证码,请在 WebUi 中继续完成验证', + }); + + const result = await refreshService.status(session.id); + + expect(containerService.detectRuntimeCaptchaUrl).toHaveBeenCalledWith( + container, + session.lastRestartedAt, + ); + expect(result.captchaUrl).toBe(captchaUrl); + expect(result.errorMessage).toContain('密码登录需要完成 QQ 安全验证'); + }); + it('keeps existing password captcha pending when NapCat still reports captcha without url', async () => { const captchaUrl = 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001';