fix: 修复NapCat验证码登录闭环

This commit is contained in:
sunlei 2026-06-13 09:25:50 +08:00
parent 185457362c
commit 0a46e44097
11 changed files with 951 additions and 41 deletions

3
API.md
View File

@ -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` 默认镜像。

View File

@ -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 测试。

View File

@ -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) {

View File

@ -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 扫码登录会话' })

View File

@ -50,3 +50,14 @@ export class QqbotAccountScanStatusDto {
@ApiProperty()
sessionId: string;
}
export class QqbotAccountScanCaptchaDto extends QqbotAccountScanStatusDto {
@ApiProperty()
randstr: string;
@ApiPropertyOptional()
sid?: string;
@ApiProperty()
ticket: string;
}

View File

@ -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<NapcatCaptchaLoginResult | null>(
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;
}

View File

@ -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<QqbotNapcatRuntime, 'name'>,
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<QqbotNapcatRuntimeStatusSnapshot> {
@ -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
`;
}

View File

@ -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> | 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. */

View File

@ -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',
}),
);
});
});

View File

@ -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<string, string> = {
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<string, string> = {
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<string, string> = {
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/',

View File

@ -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',
);
});
});