From 0a46e440971b700e76fa1770fea0b0b7e5a88302 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 13 Jun 2026 09:25:50 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DNapCat=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E7=A0=81=E7=99=BB=E5=BD=95=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 3 +- README.md | 2 +- src/common/services/tool.service.ts | 26 ++ src/qqbot/account/qqbot-account.controller.ts | 10 + src/qqbot/account/qqbot-account.dto.ts | 11 + .../account/qqbot-napcat-login.service.ts | 363 +++++++++++++-- .../napcat/qqbot-napcat-container.service.ts | 38 +- src/qqbot/qqbot.types.ts | 15 + .../account/qqbot-account.controller.spec.ts | 42 +- .../qqbot-napcat-login.service.spec.ts | 418 ++++++++++++++++++ .../napcat/qqbot-napcat-captcha-log.spec.ts | 64 +++ 11 files changed, 951 insertions(+), 41 deletions(-) create mode 100644 test/qqbot/napcat/qqbot-napcat-captcha-log.spec.ts diff --git a/API.md b/API.md index aca1bb8..423d9bf 100644 --- a/API.md +++ b/API.md @@ -306,6 +306,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT | `GET` | `/qqbot/account/scan/status?sessionId=` | 查询扫码会话状态 | | `GET` | `/qqbot/account/scan/events?sessionId=` | SSE 订阅扫码进度 | | `POST` | `/qqbot/account/scan/qrcode/refresh?sessionId=` | 刷新当前会话二维码 | +| `POST` | `/qqbot/account/scan/captcha/submit` | 提交密码登录安全验证码结果 | | `POST` | `/qqbot/account/scan/cancel?sessionId=` | 取消扫码会话 | | `POST` | `/qqbot/account/delete?id=` | 删除账号并断开 WS | | `POST` | `/qqbot/account/kick?selfId=` | 断开反向 WS 会话 | @@ -316,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 号时,再进入重置登录态和二维码兜底流程。看门狗自动登录使用同样的 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` 继续密码登录第二步。密码登录成功后会重建容器移除该运行态密码,清理失败则本次登录失败;密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,再进入重置登录态和二维码兜底流程。看门狗自动登录使用同样的 quick -> password 顺序,但不会自动进入扫码阶段。 同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器,后端会释放旧绑定和未共享的旧容器,避免同账号多实例互相挤下线。OneBot notice 只有机器人下线、登录失效、`KickedOffLine` 等账号级信号才会把账号标记为离线,普通群成员 kick 不属于账号离线信号。下线原因写入 `lastError` 前按 `last_error` 500 字符列宽截断;后续无错误的普通断连只更新离线状态,不清空该原因。账号列表会按近期缓存检查绑定 NapCat 容器的最新登录状态日志,日志检测默认 5 秒超时;`isOnline:false` 属于离线信号;如果账号连接时间或心跳晚于容器检测时间,以账号在线态为准;如果最新日志状态是在线,会清空容器旧离线错误;如果最新状态是离线或被踢下线,会同步回写账号离线状态并生成 `qqbot.account.offline` 站内信。托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像。 diff --git a/README.md b/README.md index 8784264..1fe8ee6 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ pnpm exec jest --runInBand --runTestsByPath test/path/to/file.spec.ts - QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表拆开展示 OneBot、容器、WebUI 和 QQ 登录态,日志/WebUI 检测带近期缓存和短超时,账号连接时间或心跳晚于容器检测时间时以账号在线态为准,最新日志为在线时清空容器旧离线错误;`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` 轮询结果,准备中的扫码会话会续期,避免后台密码登录未结束时前端先过期;密码登录成功后会移除运行态 `NAPCAT_QUICK_PASSWORD`,清理失败则本次登录失败,避免成功态残留明文 env;密码登录也失败或触发强制扫码验证时才重置登录态并生成二维码。Admin「更新登录」的 SSE 步骤顺序按实际路径为 `quick-login-*`(已有历史会话)-> `password-login-*` -> `password-env-cleanup` -> `relogin-reset/qrcode/waiting-scan`。 +- NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。`ACCOUNT` 只负责指定快速登录账号;如果数据卷内没有该 QQ 的历史登录记录,后端会跳过快速登录并优先尝试账号保存的登录密码,密码登录会按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_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`。 - NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000`,`QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后先尝试 `ACCOUNT` 历史会话快速登录,再尝试账号保存的登录密码,仍失败时写入离线原因并复用 `super` 站内信告警;看门狗不自动进入扫码阶段。 - BangDream 当前源码根目录是 `src/qqbot/plugins/bangDream`;不要恢复旧 `tsugu` 层级或旧大桶目录。 - BangDream 在线命令以 `registry/operation-registry.ts` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 registry/command-SQL 测试。 diff --git a/src/common/services/tool.service.ts b/src/common/services/tool.service.ts index 4e4331e..c05f50c 100644 --- a/src/common/services/tool.service.ts +++ b/src/common/services/tool.service.ts @@ -451,6 +451,28 @@ export class ToolsService { return this.matchesNapcatOnlineFlag(message, true); } + extractNapcatCaptchaUrl(message?: string) { + const text = this.toTrimmedString(message); + if (!text) return ''; + + const proofWaterUrl = text.match( + /["']?proofWaterUrl["']?\s*[::]\s*["']?(https?:\/\/[^"'\s,,}]+)/i, + )?.[1]; + const fallbackUrl = text.includes('验证码') + ? text.match(/https?:\/\/[^"'\s,,)\]}>。;;、]+/i)?.[0] + : ''; + + return this.normalizeExtractedUrl(proofWaterUrl || fallbackUrl || ''); + } + + isNapcatCaptchaRequiredMessage(message?: string) { + const text = this.toTrimmedString(message); + return ( + !!this.extractNapcatCaptchaUrl(text) || + this.includesAny(text, ['proofWaterUrl', '需要验证码', '验证码']) + ); + } + private matchesNapcatOnlineFlag(message: unknown, expected: boolean) { const text = this.toTrimmedString(message); if (!text) return false; @@ -460,6 +482,10 @@ export class ToolsService { ).test(text); } + private normalizeExtractedUrl(value: string) { + return this.toTrimmedString(value).replace(/[)"'\]}>,。;;、,]+$/g, ''); + } + private deriveSecretKey(secret: unknown) { const normalizedSecret = this.toTrimmedString(secret); if (!normalizedSecret) { diff --git a/src/qqbot/account/qqbot-account.controller.ts b/src/qqbot/account/qqbot-account.controller.ts index d4d13e2..881e649 100644 --- a/src/qqbot/account/qqbot-account.controller.ts +++ b/src/qqbot/account/qqbot-account.controller.ts @@ -15,6 +15,7 @@ import { vbenSuccess } from '@/common'; import { QqbotAccountBodyDto, QqbotAccountQueryDto, + QqbotAccountScanCaptchaDto, QqbotAccountScanStatusDto, QqbotAccountUpdateDto, } from './qqbot-account.dto'; @@ -94,6 +95,15 @@ export class QqbotAccountController { ); } + @Post('scan/captcha/submit') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '提交 QQBot 登录安全验证码' }) + async submitScanCaptcha(@Body() body: QqbotAccountScanCaptchaDto) { + return vbenSuccess( + await this.napcatLoginService.submitCaptcha(body.sessionId, body), + ); + } + @Post('scan/cancel') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '取消 QQBot 扫码登录会话' }) diff --git a/src/qqbot/account/qqbot-account.dto.ts b/src/qqbot/account/qqbot-account.dto.ts index 0cfc7db..bbcb8e9 100644 --- a/src/qqbot/account/qqbot-account.dto.ts +++ b/src/qqbot/account/qqbot-account.dto.ts @@ -50,3 +50,14 @@ export class QqbotAccountScanStatusDto { @ApiProperty() sessionId: string; } + +export class QqbotAccountScanCaptchaDto extends QqbotAccountScanStatusDto { + @ApiProperty() + randstr: string; + + @ApiPropertyOptional() + sid?: string; + + @ApiProperty() + ticket: string; +} diff --git a/src/qqbot/account/qqbot-napcat-login.service.ts b/src/qqbot/account/qqbot-napcat-login.service.ts index 4481b21..9ca98a6 100644 --- a/src/qqbot/account/qqbot-napcat-login.service.ts +++ b/src/qqbot/account/qqbot-napcat-login.service.ts @@ -7,11 +7,13 @@ import { Observable } from 'rxjs'; import { throwVbenError, ToolsService } from '@/common'; import type { NapcatApiResponse, + NapcatCaptchaLoginResult, NapcatCredential, NapcatLoginInfo, NapcatLoginStatus, NapcatQrcode, NapcatRestartOptions, + QqbotLoginCaptchaSubmitInput, QqbotLoginScanMode, QqbotLoginScanEvent, QqbotLoginScanResult, @@ -178,6 +180,29 @@ export class QqbotNapcatLoginService { ); } if (!status.isLogin) { + const captchaUrl = this.getCaptchaUrlFromStatus(status); + if (captchaUrl) { + return this.keepPasswordCaptchaPending( + session, + captchaUrl, + status.loginError, + ); + } + if (session.captchaUrl) { + if (!status.isOffline && !status.loginError) { + return this.keepPasswordCaptchaPending( + session, + session.captchaUrl, + '等待 QQ 安全验证结果', + ); + } + return this.failCaptchaLogin( + session, + container, + status.loginError || '验证码登录未完成', + ); + } + session.errorMessage = status.loginError || undefined; if ( status.qrcodeurl && @@ -205,6 +230,117 @@ export class QqbotNapcatLoginService { return this.completeLogin(session, container); } + async submitCaptcha(sessionId: string, input: QqbotLoginCaptchaSubmitInput) { + const session = this.getSession(sessionId); + if (session.status !== 'pending') { + return this.toResult(session); + } + + const ticket = this.toolsService.toTrimmedString(input.ticket); + const randstr = this.toolsService.toTrimmedString(input.randstr); + const sid = this.toolsService.toTrimmedString(input.sid); + if (!ticket || !randstr) { + throwVbenError('验证码结果缺失,请重新验证'); + } + if (!session.captchaUrl) { + throwVbenError('当前登录会话不需要验证码'); + } + if (!session.expectedSelfId || !session.passwordMd5) { + throwVbenError('验证码登录上下文已失效,请重新更新登录'); + } + + const container = await this.getSessionContainer(session); + this.publishScanResultEvent( + session, + 'password-login-captcha-submit', + 'processing', + '正在提交 QQ 安全验证结果', + ); + + let captchaResult: NapcatCaptchaLoginResult | null; + try { + captchaResult = await this.postNapcat( + container, + '/api/QQLogin/CaptchaLogin', + { + passwordMd5: session.passwordMd5, + randstr, + sid, + ticket, + uin: session.expectedSelfId, + }, + ); + } catch (err) { + return this.keepPasswordCaptchaPending( + session, + session.captchaUrl, + this.toolsService.getErrorMessage(err) || '验证码登录失败', + ); + } + + if (captchaResult?.needNewDevice && captchaResult.jumpUrl) { + return this.keepSessionPending( + session, + '验证码已通过,但 QQ 仍要求新设备验证,请在 NapCat WebUI 继续完成', + true, + ); + } + + const loginStatus = await this.waitForPasswordLoginStatus(container); + if (!loginStatus.isLogin) { + const captchaUrl = this.getCaptchaUrlFromStatus(loginStatus); + if (captchaUrl) { + return this.keepPasswordCaptchaPending( + session, + captchaUrl, + loginStatus.loginError, + ); + } + return this.failCaptchaLogin( + session, + container, + `验证码登录未完成:${loginStatus.loginError || 'NapCat 未返回登录成功'}`, + ); + } + + const loginInfo = await this.getLoginInfo(container); + const selfId = this.toolsService.pickNapcatSelfId(loginInfo); + if (loginInfo.online === false || !selfId) { + return this.failCaptchaLogin( + session, + container, + loginInfo.online === false + ? 'NapCat 当前账号已离线' + : 'NapCat 未返回 QQ 号', + ); + } + 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: '验证码登录成功', + }); + } + events(sessionId: string) { this.getSession(sessionId); return new Observable<{ data: QqbotLoginScanEvent }>((subscriber) => { @@ -230,6 +366,7 @@ export class QqbotNapcatLoginService { async cancel(sessionId: string) { const session = this.sessions.get(sessionId); if (session) { + await this.cleanupPasswordLoginContext(session); this.publishScanEvent(session, { message: '扫码会话已取消', result: this.toResult(session), @@ -381,8 +518,10 @@ export class QqbotNapcatLoginService { }); await this.containerService.bindAccount(accountId, session.containerId); session.accountId = accountId; + session.captchaUrl = undefined; session.status = 'success'; session.errorMessage = undefined; + session.passwordMd5 = undefined; session.preparingRelogin = false; this.sessions.set(session.id, session); const result = { @@ -428,6 +567,7 @@ export class QqbotNapcatLoginService { private toResult(session: QqbotLoginScanSession): QqbotLoginScanResult { return { accountId: session.accountId, + captchaUrl: session.captchaUrl, containerId: session.containerId, containerName: session.containerName, errorMessage: session.errorMessage, @@ -492,6 +632,110 @@ export class QqbotNapcatLoginService { return this.toResult(session); } + private keepPasswordCaptchaPending( + session: QqbotLoginScanSession, + captchaUrl: string, + reason?: string, + ) { + const captchaMessage = '密码登录需要完成 QQ 安全验证'; + const detail = this.toolsService.isNapcatCaptchaRequiredMessage(reason) + ? '' + : this.toolsService.toTrimmedString(reason); + const message = detail ? `${captchaMessage}:${detail}` : captchaMessage; + const shouldPublish = + session.captchaUrl !== captchaUrl || + !session.errorMessage?.includes(captchaMessage); + + session.status = 'pending'; + session.captchaUrl = captchaUrl; + session.qrcode = undefined; + session.errorMessage = message; + session.expiresAt = Date.now() + this.getSessionTtlMs(); + this.sessions.set(session.id, session); + if (shouldPublish) { + this.publishScanResultEvent( + session, + 'password-login-captcha', + 'processing', + `${message},请完成验证码验证`, + ); + } + return this.toResult(session); + } + + private async failCaptchaLogin( + session: QqbotLoginScanSession, + container: QqbotNapcatRuntime, + errorMessage: string, + ) { + const cleaned = await this.cleanupPasswordLoginContext( + session, + container, + session.expectedSelfId, + ); + if (!cleaned) return this.toResult(session); + + session.status = 'error'; + session.captchaUrl = undefined; + session.errorMessage = errorMessage; + session.passwordMd5 = undefined; + session.preparingRelogin = false; + this.sessions.set(session.id, session); + this.publishScanEvent(session, { + message: errorMessage, + result: this.toResult(session), + status: 'error', + step: 'password-login-captcha-failed', + }); + return this.toResult(session); + } + + private async cleanupPasswordLoginContext( + session: QqbotLoginScanSession, + container?: QqbotNapcatRuntime, + selfId?: string, + 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.sessions.set(session.id, session); + this.publishScanEvent(session, { + message: failureMessage, + result: this.toResult(session), + status: 'error', + step: 'password-env-cleanup-failed', + }); + return false; + } + + session.captchaUrl = undefined; + session.passwordMd5 = undefined; + this.sessions.set(session.id, session); + return true; + } + private getSession(sessionId: string) { const session = this.sessions.get(sessionId); if (!session) { @@ -510,16 +754,17 @@ export class QqbotNapcatLoginService { this.sessions.forEach((session, sessionId) => { if (session.status !== 'pending' || now > session.expiresAt) { this.sessions.delete(sessionId); - this.cleanupSessionEvents(sessionId); expiredSessions.push(session); } }); await Promise.all( - expiredSessions.map((session) => this.cleanupSessionContainer(session)), + expiredSessions.map((session) => this.closeSession(session)), ); } private async expireSession(session: QqbotLoginScanSession) { + const cleaned = await this.cleanupPasswordLoginContext(session); + if (!cleaned) return this.toResult(session); session.status = 'expired'; session.errorMessage = session.errorMessage || '扫码会话已过期'; this.publishScanResultEvent( @@ -529,8 +774,7 @@ export class QqbotNapcatLoginService { session.errorMessage, ); this.sessions.delete(session.id); - await this.cleanupSessionContainer(session); - this.cleanupSessionEvents(session.id); + await this.closeSession(session); return this.toResult(session); } @@ -539,15 +783,23 @@ export class QqbotNapcatLoginService { errorMessage: string, ) { session.status = 'error'; + session.captchaUrl = undefined; session.errorMessage = errorMessage; + session.passwordMd5 = undefined; session.preparingRelogin = false; this.publishScanResultEvent(session, 'login-error', 'error', errorMessage); this.sessions.delete(session.id); - await this.cleanupSessionContainer(session); - this.cleanupSessionEvents(session.id); + await this.closeSession(session); return this.toResult(session); } + private async closeSession(session: QqbotLoginScanSession) { + await this.cleanupPasswordLoginContext(session); + await this.cleanupSessionContainer(session); + this.sessions.delete(session.id); + this.cleanupSessionEvents(session.id); + } + private async cleanupSessionContainer(session: QqbotLoginScanSession) { const cleanupError = await this.cleanupRuntimeContainer({ baseUrl: '', @@ -980,6 +1232,9 @@ export class QqbotNapcatLoginService { let loginInfo: NapcatLoginInfo | undefined; let loggedInSelfId = ''; + session.passwordMd5 = createHash('md5') + .update(password, 'utf8') + .digest('hex'); session.errorMessage = 'NapCat 正在尝试密码登录,请稍后'; this.sessions.set(session.id, session); this.publishScanResultEvent( @@ -1030,6 +1285,20 @@ export class QqbotNapcatLoginService { } if (!loginStatus.isLogin) { + const captchaUrl = await this.resolvePasswordCaptchaUrl( + container, + loginStatus, + session.lastRestartedAt, + ); + if (captchaUrl) { + this.keepPasswordCaptchaPending( + session, + captchaUrl, + loginStatus.loginError, + ); + return true; + } + await this.clearRuntimeLoginPasswordAfterFailedPassword( session, container, @@ -1094,6 +1363,28 @@ export class QqbotNapcatLoginService { return true; } + private async resolvePasswordCaptchaUrl( + container: QqbotNapcatRuntime, + loginStatus: NapcatLoginStatus, + sinceMs?: number, + ) { + const statusCaptchaUrl = this.getCaptchaUrlFromStatus(loginStatus); + if (statusCaptchaUrl) return statusCaptchaUrl; + if (typeof this.containerService.detectRuntimeCaptchaUrl !== 'function') { + return ''; + } + return ( + (await this.containerService.detectRuntimeCaptchaUrl( + container, + sinceMs, + )) || '' + ); + } + + private getCaptchaUrlFromStatus(status: NapcatLoginStatus) { + return this.toolsService.extractNapcatCaptchaUrl(status.loginError); + } + private async clearRuntimeLoginPasswordBeforeQuick( session: QqbotLoginScanSession, container: QqbotNapcatRuntime, @@ -1104,10 +1395,13 @@ export class QqbotNapcatLoginService { 'processing', '正在清理运行态登录密码', ); - const cleaned = await this.containerService.ensureRuntimeLoginEnv(container, { - clearLoginPassword: true, - selfId: session.expectedSelfId, - }); + const cleaned = await this.containerService.ensureRuntimeLoginEnv( + container, + { + clearLoginPassword: true, + selfId: session.expectedSelfId, + }, + ); if (!cleaned.ok) { throw new Error('NapCat 快速登录前运行态密码清理失败,请重试更新登录'); } @@ -1118,20 +1412,16 @@ export class QqbotNapcatLoginService { container: QqbotNapcatRuntime, selfId?: string, ) { - this.publishScanResultEvent( + const failureMessage = + 'NapCat 密码登录未完成,且运行态密码清理失败,请重试更新登录'; + const cleaned = await this.cleanupPasswordLoginContext( session, - 'password-env-cleanup', - 'processing', - '正在移除运行态登录密码', - ); - const cleaned = await this.containerService.ensureRuntimeLoginEnv(container, { - clearLoginPassword: true, + container, selfId, - }); - if (!cleaned.ok) { - throw new Error( - 'NapCat 密码登录未完成,且运行态密码清理失败,请重试更新登录', - ); + failureMessage, + ); + if (!cleaned) { + throw new Error(failureMessage); } } @@ -1140,20 +1430,16 @@ export class QqbotNapcatLoginService { container: QqbotNapcatRuntime, selfId: string, ) { - this.publishScanResultEvent( + const failureMessage = + 'NapCat 密码登录已完成,但运行态密码清理失败,请重试更新登录'; + const cleaned = await this.cleanupPasswordLoginContext( session, - 'password-env-cleanup', - 'processing', - '正在移除运行态登录密码', - ); - const cleaned = await this.containerService.ensureRuntimeLoginEnv(container, { - clearLoginPassword: true, + container, selfId, - }); - if (!cleaned.ok) { - throw new Error( - 'NapCat 密码登录已完成,但运行态密码清理失败,请重试更新登录', - ); + failureMessage, + ); + if (!cleaned) { + throw new Error(failureMessage); } } @@ -1378,7 +1664,14 @@ export class QqbotNapcatLoginService { await this.toolsService.sleep(this.getLoginPollIntervalMs()); } latestStatus = await this.getLoginStatus(container, true); - if (latestStatus.isLogin) return latestStatus; + if ( + latestStatus.isLogin || + this.toolsService.isNapcatCaptchaRequiredMessage( + latestStatus.loginError, + ) + ) { + return latestStatus; + } } return latestStatus; } diff --git a/src/qqbot/napcat/qqbot-napcat-container.service.ts b/src/qqbot/napcat/qqbot-napcat-container.service.ts index d04e9d5..23943db 100644 --- a/src/qqbot/napcat/qqbot-napcat-container.service.ts +++ b/src/qqbot/napcat/qqbot-napcat-container.service.ts @@ -222,7 +222,9 @@ export class QqbotNapcatContainerService { env.has('NAPCAT_QUICK_PASSWORD_MD5'); if (options.clearLoginPassword) return !hasPassword; - const loginPassword = this.toolsService.toSecretText(options.loginPassword); + const loginPassword = this.toolsService.toSecretText( + options.loginPassword, + ); if (loginPassword) { return env.get('NAPCAT_QUICK_PASSWORD') === loginPassword; } @@ -466,6 +468,34 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME" } } + async detectRuntimeCaptchaUrl( + runtime: Pick, + sinceMs?: number, + ) { + if (this.getManagedMode() !== 'ssh' || !runtime.name) return null; + + const since = + typeof sinceMs === 'number' && Number.isFinite(sinceMs) + ? new Date(Math.max(0, sinceMs - 1000)).toISOString() + : ''; + const script = since + ? this.buildRemoteRecentLogsSinceScript(runtime.name, since) + : this.buildRemoteRecentLogsByNameScript(runtime.name); + + try { + const result = await this.runProcess( + 'ssh', + [...this.getSshArgs(), 'sh -s'], + script, + undefined, + this.getRuntimeCheckTimeoutMs(), + ); + return this.toolsService.extractNapcatCaptchaUrl(result.stdout) || null; + } catch { + return null; + } + } + async inspectRuntimeStatus( container: QqbotNapcatContainer, ): Promise { @@ -681,11 +711,13 @@ echo "__KT_PROGRESS__:container-started:NapCat 容器已启动" } private buildRemoteRecentLogsScript(container: QqbotNapcatContainer) { - const name = this.sh(container.name); + return this.buildRemoteRecentLogsByNameScript(container.name); + } + private buildRemoteRecentLogsByNameScript(name: string) { return ` set -eu -NAME=${name} +NAME=${this.sh(name)} docker logs --tail 300 "$NAME" 2>&1 || true `; } diff --git a/src/qqbot/qqbot.types.ts b/src/qqbot/qqbot.types.ts index 4cb5479..8507384 100644 --- a/src/qqbot/qqbot.types.ts +++ b/src/qqbot/qqbot.types.ts @@ -31,6 +31,12 @@ export type NapcatLoginStatus = { qrcodeurl?: string; }; +export type NapcatCaptchaLoginResult = { + jumpUrl?: string; + needNewDevice?: boolean; + newDevicePullQrCodeSig?: string; +}; + export type NapcatQrcode = { qrcode?: string; qrcodeurl?: string; @@ -255,6 +261,7 @@ export type QqbotBusHandler = (payload: any) => Promise | void; export type QqbotLoginScanResult = { accountId?: string; + captchaUrl?: string; containerId?: string; containerName?: string; errorMessage?: string; @@ -283,6 +290,7 @@ export type QqbotLoginScanEvent = { export type QqbotLoginScanSession = { accountId?: string; + captchaUrl?: string; containerId?: string; containerName?: string; createdAt: number; @@ -292,12 +300,19 @@ export type QqbotLoginScanSession = { id: string; lastRestartedAt?: number; mode: QqbotLoginScanMode; + passwordMd5?: string; preparingRelogin?: boolean; qrcode?: string; status: QqbotLoginScanStatus; webuiPort?: null | number; }; +export type QqbotLoginCaptchaSubmitInput = { + randstr: string; + sid?: string; + ticket: string; +}; + export type QqbotNapcatRuntime = { baseUrl: string; /** Heuristic for quick login: existing primary binding implies a reusable data volume may exist. */ diff --git a/test/qqbot/account/qqbot-account.controller.spec.ts b/test/qqbot/account/qqbot-account.controller.spec.ts index 6aae601..cea7409 100644 --- a/test/qqbot/account/qqbot-account.controller.spec.ts +++ b/test/qqbot/account/qqbot-account.controller.spec.ts @@ -28,13 +28,19 @@ describe('QqbotAccountController', () => { const accountService = { save: jest.fn().mockResolvedValue('account-1'), }; + const napcatLoginService = { + submitCaptcha: jest.fn().mockResolvedValue({ + message: '验证码登录成功', + status: 'success', + }), + }; beforeAll(async () => { const moduleRef = await Test.createTestingModule({ controllers: [QqbotAccountController], providers: [ { provide: QqbotAccountService, useValue: accountService }, - { provide: QqbotNapcatLoginService, useValue: {} }, + { provide: QqbotNapcatLoginService, useValue: napcatLoginService }, { provide: QqbotReverseWsService, useValue: {} }, ], }).compile(); @@ -49,6 +55,7 @@ describe('QqbotAccountController', () => { beforeEach(() => { accountService.save.mockClear(); + napcatLoginService.submitCaptcha.mockClear(); }); it('accepts encrypted NapCat login password through account save API', async () => { @@ -76,4 +83,37 @@ describe('QqbotAccountController', () => { }), ); }); + + it('submits NapCat captcha result through scan captcha API', async () => { + await request(app.getHttpServer()) + .post('/qqbot/account/scan/captcha/submit') + .send({ + randstr: '@captcha-randstr', + sessionId: 'session-1', + sid: 'captcha-sid', + ticket: 'captcha-ticket', + }) + .expect(200) + .expect((response) => { + expect(response.body).toEqual( + expect.objectContaining({ + code: 200, + data: expect.objectContaining({ + message: '验证码登录成功', + status: 'success', + }), + }), + ); + }); + + expect(napcatLoginService.submitCaptcha).toHaveBeenCalledWith( + 'session-1', + expect.objectContaining({ + randstr: '@captcha-randstr', + sessionId: 'session-1', + sid: 'captcha-sid', + ticket: 'captcha-ticket', + }), + ); + }); }); diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index eecd50b..9352a51 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -602,6 +602,424 @@ describe('QqbotNapcatLoginService', () => { expect(session.status).toBe('success'); }); + it('keeps password login pending when QQ requires captcha verification from login status', 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-password-captcha', + name: 'napcat-10001', + }; + const containerService = { + ensureRuntimeLoginEnv: jest + .fn() + .mockResolvedValue({ changed: true, ok: true }), + resetRuntimeLoginState: jest.fn().mockResolvedValue(true), + restartRuntimeContainer: jest.fn().mockResolvedValue(true), + }; + const refreshService = new QqbotNapcatLoginService( + { + get: jest.fn((key: string) => { + const values: Record = { + QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS: '1', + QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS: '1', + QQBOT_NAPCAT_QUICK_LOGIN_WAIT_MS: '1', + }; + return values[key] || ''; + }), + } 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', + preparingRelogin: true, + status: 'pending', + }); + (refreshService as any).sessions.set(session.id, session); + jest + .spyOn((refreshService as any).toolsService, 'sleep') + .mockResolvedValue(undefined); + jest + .spyOn(refreshService as any, 'getLoginStatus') + .mockResolvedValueOnce({ + isLogin: false, + loginError: '快速登录未找到历史会话', + }) + .mockResolvedValueOnce({ + isLogin: false, + loginError: `需要验证码, proofWaterUrl: ${captchaUrl}`, + }); + const refreshQrcode = jest + .spyOn(refreshService as any, 'refreshOrGetQrcode') + .mockResolvedValue('fallback-qrcode'); + + await (refreshService as any).prepareReloginQrcode( + session, + container, + 'qq-password', + ); + + expect(refreshQrcode).not.toHaveBeenCalled(); + expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); + expect(session.status).toBe('pending'); + expect(session.captchaUrl).toBe(captchaUrl); + expect(session.errorMessage).toContain('需要完成 QQ 安全验证'); + const steps = ( + (refreshService as any).sessionEventLogs.get(session.id) ?? [] + ).map((event: { step: string }) => event.step); + expect(steps).toContain('password-login-captcha'); + expect(steps).not.toContain('relogin-reset-start'); + }); + + it('keeps password login pending when QQ captcha URL is only in runtime logs', 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-password-captcha-log', + name: 'napcat-10001', + }; + const containerService = { + detectRuntimeCaptchaUrl: jest.fn().mockResolvedValue(captchaUrl), + ensureRuntimeLoginEnv: jest + .fn() + .mockResolvedValue({ changed: true, ok: true }), + resetRuntimeLoginState: jest.fn().mockResolvedValue(true), + restartRuntimeContainer: jest.fn().mockResolvedValue(true), + }; + const refreshService = new QqbotNapcatLoginService( + { + get: jest.fn((key: string) => { + const values: Record = { + QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS: '1', + QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS: '1', + QQBOT_NAPCAT_QUICK_LOGIN_WAIT_MS: '1', + }; + return values[key] || ''; + }), + } 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', + preparingRelogin: true, + status: 'pending', + }); + (refreshService as any).sessions.set(session.id, session); + jest + .spyOn((refreshService as any).toolsService, 'sleep') + .mockResolvedValue(undefined); + jest + .spyOn(refreshService as any, 'getLoginStatus') + .mockResolvedValueOnce({ + isLogin: false, + loginError: '快速登录未找到历史会话', + }) + .mockResolvedValueOnce({ + isLogin: false, + loginError: '密码登录处理中', + }); + const refreshQrcode = jest + .spyOn(refreshService as any, 'refreshOrGetQrcode') + .mockResolvedValue('fallback-qrcode'); + + await (refreshService as any).prepareReloginQrcode( + session, + container, + 'qq-password', + ); + + expect(containerService.detectRuntimeCaptchaUrl).toHaveBeenCalledWith( + container, + expect.any(Number), + ); + expect(refreshQrcode).not.toHaveBeenCalled(); + expect(session.status).toBe('pending'); + expect(session.captchaUrl).toBe(captchaUrl); + }); + + it('submits captcha result back to NapCat and completes password login', async () => { + const accountService = { + ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), + }; + const containerService = { + bindAccount: jest.fn().mockResolvedValue(undefined), + findRuntimeById: jest.fn().mockResolvedValue({ + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-captcha-submit', + name: 'napcat-10001', + }), + removeUnboundContainer: jest.fn().mockResolvedValue(false), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (refreshService as any).createSession({ + accountId: 'account-1', + container: { + id: 'container-captcha-submit', + name: 'napcat-10001', + }, + expectedSelfId: '10001', + mode: 'refresh', + status: 'pending', + }); + session.captchaUrl = + 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001&sid=sid-from-url'; + session.passwordMd5 = '0123456789abcdef0123456789abcdef'; + (refreshService as any).sessions.set(session.id, session); + const postNapcat = jest + .spyOn(refreshService as any, 'postNapcat') + .mockResolvedValueOnce(null); + jest + .spyOn(refreshService as any, 'waitForPasswordLoginStatus') + .mockResolvedValue({ + isLogin: true, + }); + jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ + nickname: 'Kwi', + online: true, + uin: '10001', + }); + jest + .spyOn(refreshService as any, 'clearRuntimeLoginPasswordAfterSuccess') + .mockResolvedValue(undefined); + + const result = await refreshService.submitCaptcha(session.id, { + randstr: '@rand', + sid: 'sid-from-url', + ticket: 'captcha-ticket', + }); + + expect(postNapcat).toHaveBeenCalledWith( + expect.objectContaining({ id: 'container-captcha-submit' }), + '/api/QQLogin/CaptchaLogin', + { + passwordMd5: '0123456789abcdef0123456789abcdef', + randstr: '@rand', + sid: 'sid-from-url', + ticket: 'captcha-ticket', + uin: '10001', + }, + ); + expect(result.status).toBe('success'); + expect(session.captchaUrl).toBeUndefined(); + expect(session.passwordMd5).toBeUndefined(); + }); + + it('cleans runtime password env when cancelling captcha pending login', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-captcha-cancel', + 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 = + 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001'; + session.passwordMd5 = '0123456789abcdef0123456789abcdef'; + (refreshService as any).sessions.set(session.id, session); + + await refreshService.cancel(session.id); + + expect(containerService.ensureRuntimeLoginEnv).toHaveBeenCalledWith( + container, + { + clearLoginPassword: true, + selfId: '10001', + }, + ); + expect(session.passwordMd5).toBeUndefined(); + expect(session.captchaUrl).toBeUndefined(); + }); + + it('marks captcha submit as error when password env cleanup after success fails', async () => { + const accountService = { + ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), + }; + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-captcha-cleanup-failed', + name: 'napcat-10001', + }; + const containerService = { + bindAccount: jest.fn().mockResolvedValue(undefined), + ensureRuntimeLoginEnv: jest.fn().mockResolvedValue({ + changed: false, + ok: false, + }), + findRuntimeById: jest.fn().mockResolvedValue(container), + removeUnboundContainer: jest.fn().mockResolvedValue(false), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (refreshService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '10001', + mode: 'refresh', + status: 'pending', + }); + session.captchaUrl = + 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001&sid=sid-from-url'; + session.passwordMd5 = '0123456789abcdef0123456789abcdef'; + (refreshService as any).sessions.set(session.id, session); + jest.spyOn(refreshService as any, 'postNapcat').mockResolvedValueOnce(null); + jest + .spyOn(refreshService as any, 'waitForPasswordLoginStatus') + .mockResolvedValue({ + isLogin: true, + }); + jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ + nickname: 'Kwi', + online: true, + uin: '10001', + }); + + const result = await refreshService.submitCaptcha(session.id, { + randstr: '@rand', + sid: 'sid-from-url', + ticket: 'captcha-ticket', + }); + + expect(result.status).toBe('error'); + expect(result.errorMessage).toBe( + 'NapCat 密码登录已完成,但运行态密码清理失败,请重试更新登录', + ); + expect(accountService.ensureScannedAccount).not.toHaveBeenCalled(); + const steps = ( + (refreshService as any).sessionEventLogs.get(session.id) ?? [] + ).map((event: { step: string }) => event.step); + expect(steps).toContain('password-env-cleanup-failed'); + expect(steps).not.toContain('login-success'); + }); + + it('returns captcha status immediately without waiting full password window', async () => { + const captchaUrl = + 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001'; + const refreshService = new QqbotNapcatLoginService( + { + get: jest.fn((key: string) => { + const values: Record = { + QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS: '1000', + QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS: '3000', + }; + return values[key] || ''; + }), + } as unknown as ConfigService, + {} as QqbotAccountService, + {} as QqbotNapcatContainerService, + new ToolsService(), + ); + jest.spyOn(refreshService as any, 'postNapcat').mockResolvedValueOnce({ + isLogin: false, + loginError: `需要验证码, proofWaterUrl: ${captchaUrl}`, + }); + const sleep = jest.spyOn((refreshService as any).toolsService, 'sleep'); + + const status = await (refreshService as any).waitForPasswordLoginStatus({ + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-captcha-early', + name: 'napcat-10001', + webuiToken: 'token', + }); + + expect(status.loginError).toContain(captchaUrl); + expect(sleep).not.toHaveBeenCalled(); + }); + + it('does not keep stale captcha pending after captcha submit returns non-captcha failure', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-captcha-normal-failure', + 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 = + 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001'; + session.passwordMd5 = '0123456789abcdef0123456789abcdef'; + (refreshService as any).sessions.set(session.id, session); + jest.spyOn(refreshService as any, 'postNapcat').mockResolvedValueOnce(null); + jest + .spyOn(refreshService as any, 'waitForPasswordLoginStatus') + .mockResolvedValue({ + isLogin: false, + loginError: '密码错误', + }); + + const result = await refreshService.submitCaptcha(session.id, { + randstr: '@rand', + ticket: 'captcha-ticket', + }); + + expect(result.status).toBe('error'); + expect(result.captchaUrl).toBeUndefined(); + expect(result.errorMessage).toBe('验证码登录未完成:密码错误'); + expect(containerService.ensureRuntimeLoginEnv).toHaveBeenCalledWith( + container, + { + clearLoginPassword: true, + selfId: '10001', + }, + ); + }); + it('uses password before qrcode without historical NapCat session', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', diff --git a/test/qqbot/napcat/qqbot-napcat-captcha-log.spec.ts b/test/qqbot/napcat/qqbot-napcat-captcha-log.spec.ts new file mode 100644 index 0000000..879949e --- /dev/null +++ b/test/qqbot/napcat/qqbot-napcat-captcha-log.spec.ts @@ -0,0 +1,64 @@ +import { ToolsService } from '@/common'; +import { QqbotNapcatContainerService } from '@/qqbot/napcat/qqbot-napcat-container.service'; + +describe('QqbotNapcatContainerService captcha logs', () => { + it('extracts captcha url from recent runtime logs', async () => { + const service = new QqbotNapcatContainerService( + { + get: jest.fn((key: string) => + key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '', + ), + } as any, + { update: jest.fn() } as any, + {} as any, + new ToolsService(), + ); + jest.spyOn(service as any, 'runProcess').mockResolvedValue({ + stderr: '', + stdout: + '密码回退需要验证码,请在 WebUi 中继续完成验证:https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001\n', + }); + + const result = await service.detectRuntimeCaptchaUrl( + { + id: 'container-1', + name: 'napcat-10001', + } as any, + Date.now(), + ); + + expect(result).toBe( + 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001', + ); + }); + + it('extracts captcha url from quoted json runtime logs', async () => { + const service = new QqbotNapcatContainerService( + { + get: jest.fn((key: string) => + key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '', + ), + } as any, + { update: jest.fn() } as any, + {} as any, + new ToolsService(), + ); + jest.spyOn(service as any, 'runProcess').mockResolvedValue({ + stderr: '', + stdout: + '{"loginErrorInfo":{"proofWaterUrl":"https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001&sid=abc"}},\n', + }); + + const result = await service.detectRuntimeCaptchaUrl( + { + id: 'container-1', + name: 'napcat-10001', + } as any, + Date.now(), + ); + + expect(result).toBe( + 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001&sid=abc', + ); + }); +});