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