fix: 修复NapCat验证码登录闭环
This commit is contained in:
parent
185457362c
commit
0a46e44097
3
API.md
3
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` 默认镜像。
|
||||
|
||||
|
||||
@ -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 测试。
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 扫码登录会话' })
|
||||
|
||||
@ -50,3 +50,14 @@ export class QqbotAccountScanStatusDto {
|
||||
@ApiProperty()
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export class QqbotAccountScanCaptchaDto extends QqbotAccountScanStatusDto {
|
||||
@ApiProperty()
|
||||
randstr: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
sid?: string;
|
||||
|
||||
@ApiProperty()
|
||||
ticket: string;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
`;
|
||||
}
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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/',
|
||||
|
||||
64
test/qqbot/napcat/qqbot-napcat-captcha-log.spec.ts
Normal file
64
test/qqbot/napcat/qqbot-napcat-captcha-log.spec.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user