diff --git a/API.md b/API.md index 06d8379..90fd31f 100644 --- a/API.md +++ b/API.md @@ -317,7 +317,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` 轮询密码登录结果,准备阶段的扫码会话会持续续期,避免后台密码登录未完成时前端先判过期。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。密码登录成功后会重建容器移除该运行态密码,清理失败则本次登录失败;密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,再进入重置登录态和二维码兜底流程。看门狗自动登录使用同样的 quick -> password 顺序,但不会自动进入扫码阶段。 +扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。已有账号的更新登录会先重启目标 NapCat 容器尝试 `ACCOUNT`/`-q` 快速登录;目标账号在线则直接完成会话。没有历史登录态的新容器会跳过快速登录,优先尝试保存的登录密码;快速登录失败后,如果账号保存了登录密码,会临时注入 `NAPCAT_QUICK_PASSWORD` 并按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询密码登录结果,准备阶段的扫码会话会持续续期,避免后台密码登录未完成时前端先判过期。密码登录触发 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 顺序,但不会自动进入扫码阶段。 同一 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` 默认镜像。 diff --git a/src/qqbot/account/qqbot-napcat-login.service.ts b/src/qqbot/account/qqbot-napcat-login.service.ts index b109408..2a97db2 100644 --- a/src/qqbot/account/qqbot-napcat-login.service.ts +++ b/src/qqbot/account/qqbot-napcat-login.service.ts @@ -189,6 +189,13 @@ export class QqbotNapcatLoginService { ); } if (session.captchaUrl) { + if (this.isPasswordCaptchaStillRequired(status)) { + return this.keepPasswordCaptchaPending( + session, + session.captchaUrl, + status.loginError || '等待 QQ 安全验证结果', + ); + } if (!status.isOffline && !status.loginError) { return this.keepPasswordCaptchaPending( session, @@ -1438,6 +1445,31 @@ export class QqbotNapcatLoginService { ); } + private isPasswordCaptchaStillRequired(status: NapcatLoginStatus) { + if (this.getCaptchaUrlFromStatus(status)) return true; + const message = this.toolsService.toTrimmedString(status.loginError); + if ( + this.toolsService.includesAny(message, [ + '失败', + '错误', + '过期', + '失效', + '拒绝', + '取消', + ]) + ) { + return false; + } + return ( + message.includes('proofWaterUrl') || + message.includes('需要验证码') || + message.includes('继续完成验证') || + message.includes('需要安全验证') || + message.includes('继续安全验证') || + message.includes('完成安全验证') + ); + } + private async detectPasswordCaptchaUrl( container: QqbotNapcatRuntime, sinceMs?: number, diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index 95b853b..74ec8ad 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -194,6 +194,102 @@ describe('QqbotNapcatLoginService', () => { expect((refreshService as any).sessions.has(session.id)).toBe(true); }); + 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'; + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-captcha-status', + name: 'napcat-10001', + }; + const containerService = { + ensureRuntimeLoginEnv: jest.fn().mockResolvedValue({ + changed: true, + ok: true, + }), + 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.captchaUrl = captchaUrl; + session.passwordMd5 = '0123456789abcdef0123456789abcdef'; + (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(result.status).toBe('pending'); + expect(result.captchaUrl).toBe(captchaUrl); + expect(result.errorMessage).toContain('密码登录需要完成 QQ 安全验证'); + expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); + }); + + it('does not keep existing password captcha pending when safety verification fails', 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-captcha-failed-status', + name: 'napcat-10001', + }; + const containerService = { + ensureRuntimeLoginEnv: jest.fn().mockResolvedValue({ + changed: true, + ok: true, + }), + 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.captchaUrl = captchaUrl; + session.passwordMd5 = '0123456789abcdef0123456789abcdef'; + (refreshService as any).sessions.set(session.id, session); + jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ + isLogin: false, + loginError: '安全验证失败,请重新登录', + }); + + const result = await refreshService.status(session.id); + + expect(result.status).toBe('error'); + expect(result.captchaUrl).toBeUndefined(); + expect(result.errorMessage).toBe('安全验证失败,请重新登录'); + expect(containerService.ensureRuntimeLoginEnv).toHaveBeenCalledWith( + container, + { + clearLoginPassword: true, + selfId: '10001', + }, + ); + }); + it('uses NapCat -q quick login before generating qrcode for refresh login', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/',