fix: 保持NapCat验证码会话pending
This commit is contained in:
parent
2c3d2abd95
commit
24bc1f4232
2
API.md
2
API.md
@ -317,7 +317,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
|
||||
|
||||
账号保存支持可选 `encryptedLoginPassword`,用于 NapCat 密码登录。前端必须先通过 `/auth/password-public-key` 获取公钥并使用 RSA-OAEP 加密,不传明文 `loginPassword`;后端必须使用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密落库,空值和公开默认值会被拒绝,不在列表/详情中返回。账号列表里的 `connectStatus` 只表示 OneBot 反向 WS;`napcat.oneBotOnline`、`napcat.containerOnline`、`napcat.webuiOnline`、`napcat.qqLoginStatus`、`napcat.qqLoginMessage` 分别表示 OneBot、容器、WebUI 和 QQ 登录态,`webuiOnline=null` 表示本次使用缓存且未重新探测 WebUI;`qqLoginMessage` 只承载真实 QQ 登录态消息,WebUI 配置缺失或请求异常只放在 `lastError`。
|
||||
|
||||
扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。已有账号的更新登录会先重启目标 NapCat 容器尝试 `ACCOUNT`/`-q` 快速登录;目标账号在线则直接完成会话。没有历史登录态的新容器会跳过快速登录,优先尝试保存的登录密码;快速登录失败后,如果账号保存了登录密码,会临时注入 `NAPCAT_QUICK_PASSWORD` 并按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询密码登录结果,准备阶段的扫码会话会持续续期,避免后台密码登录未完成时前端先判过期。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。密码登录成功后会重建容器移除该运行态密码,清理失败则本次登录失败;密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,再进入重置登录态和二维码兜底流程。看门狗自动登录使用同样的 quick -> password 顺序,但不会自动进入扫码阶段。
|
||||
扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。已有账号的更新登录会先重启目标 NapCat 容器尝试 `ACCOUNT`/`-q` 快速登录;目标账号在线则直接完成会话。没有历史登录态的新容器会跳过快速登录,优先尝试保存的登录密码;快速登录失败后,如果账号保存了登录密码,会临时注入 `NAPCAT_QUICK_PASSWORD` 并按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询密码登录结果,准备阶段的扫码会话会持续续期,避免后台密码登录未完成时前端先判过期。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。会话已有 `captchaUrl` 后,`/qqbot/account/scan/status` 遇到 NapCat 继续返回“需要验证码/继续完成验证/安全验证”但不带 URL 时仍保持 `pending` 和原 `captchaUrl`。密码登录成功后会重建容器移除该运行态密码,清理失败则本次登录失败;密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,再进入重置登录态和二维码兜底流程。看门狗自动登录使用同样的 quick -> password 顺序,但不会自动进入扫码阶段。
|
||||
|
||||
同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器,后端会释放旧绑定和未共享的旧容器,避免同账号多实例互相挤下线。OneBot notice 只有机器人下线、登录失效、`KickedOffLine` 等账号级信号才会记录 QQ 登录态异常并生成 `qqbot.account.offline` 站内信,普通群成员 kick 不属于账号离线信号。下线原因写入 `lastError` 前按 `last_error` 500 字符列宽截断;后续无错误的普通断连只更新 OneBot 连接状态,不清空该原因。账号列表会按近期缓存检查绑定 NapCat 容器的最新登录状态日志,日志检测默认 5 秒超时;`isOnline:false` 属于 QQ 登录态离线信号;心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态。托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像。
|
||||
|
||||
|
||||
@ -189,6 +189,13 @@ export class QqbotNapcatLoginService {
|
||||
);
|
||||
}
|
||||
if (session.captchaUrl) {
|
||||
if (this.isPasswordCaptchaStillRequired(status)) {
|
||||
return this.keepPasswordCaptchaPending(
|
||||
session,
|
||||
session.captchaUrl,
|
||||
status.loginError || '等待 QQ 安全验证结果',
|
||||
);
|
||||
}
|
||||
if (!status.isOffline && !status.loginError) {
|
||||
return this.keepPasswordCaptchaPending(
|
||||
session,
|
||||
@ -1438,6 +1445,31 @@ export class QqbotNapcatLoginService {
|
||||
);
|
||||
}
|
||||
|
||||
private isPasswordCaptchaStillRequired(status: NapcatLoginStatus) {
|
||||
if (this.getCaptchaUrlFromStatus(status)) return true;
|
||||
const message = this.toolsService.toTrimmedString(status.loginError);
|
||||
if (
|
||||
this.toolsService.includesAny(message, [
|
||||
'失败',
|
||||
'错误',
|
||||
'过期',
|
||||
'失效',
|
||||
'拒绝',
|
||||
'取消',
|
||||
])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
message.includes('proofWaterUrl') ||
|
||||
message.includes('需要验证码') ||
|
||||
message.includes('继续完成验证') ||
|
||||
message.includes('需要安全验证') ||
|
||||
message.includes('继续安全验证') ||
|
||||
message.includes('完成安全验证')
|
||||
);
|
||||
}
|
||||
|
||||
private async detectPasswordCaptchaUrl(
|
||||
container: QqbotNapcatRuntime,
|
||||
sinceMs?: number,
|
||||
|
||||
@ -194,6 +194,102 @@ describe('QqbotNapcatLoginService', () => {
|
||||
expect((refreshService as any).sessions.has(session.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps existing password captcha pending when NapCat still reports captcha without url', async () => {
|
||||
const captchaUrl =
|
||||
'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001';
|
||||
const container = {
|
||||
baseUrl: 'http://127.0.0.1:6103/',
|
||||
id: 'container-captcha-status',
|
||||
name: 'napcat-10001',
|
||||
};
|
||||
const containerService = {
|
||||
ensureRuntimeLoginEnv: jest.fn().mockResolvedValue({
|
||||
changed: true,
|
||||
ok: true,
|
||||
}),
|
||||
findRuntimeById: jest.fn().mockResolvedValue(container),
|
||||
removeUnboundContainer: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
const refreshService = new QqbotNapcatLoginService(
|
||||
{ get: jest.fn() } as unknown as ConfigService,
|
||||
{} as QqbotAccountService,
|
||||
containerService as unknown as QqbotNapcatContainerService,
|
||||
new ToolsService(),
|
||||
);
|
||||
const session = (refreshService as any).createSession({
|
||||
accountId: 'account-1',
|
||||
container,
|
||||
expectedSelfId: '10001',
|
||||
mode: 'refresh',
|
||||
status: 'pending',
|
||||
});
|
||||
session.captchaUrl = captchaUrl;
|
||||
session.passwordMd5 = '0123456789abcdef0123456789abcdef';
|
||||
(refreshService as any).sessions.set(session.id, session);
|
||||
jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({
|
||||
isLogin: false,
|
||||
loginError: '密码回退需要验证码,请在 WebUi 中继续完成验证',
|
||||
});
|
||||
|
||||
const result = await refreshService.status(session.id);
|
||||
|
||||
expect(result.status).toBe('pending');
|
||||
expect(result.captchaUrl).toBe(captchaUrl);
|
||||
expect(result.errorMessage).toContain('密码登录需要完成 QQ 安全验证');
|
||||
expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not keep existing password captcha pending when safety verification fails', async () => {
|
||||
const captchaUrl =
|
||||
'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001';
|
||||
const container = {
|
||||
baseUrl: 'http://127.0.0.1:6103/',
|
||||
id: 'container-captcha-failed-status',
|
||||
name: 'napcat-10001',
|
||||
};
|
||||
const containerService = {
|
||||
ensureRuntimeLoginEnv: jest.fn().mockResolvedValue({
|
||||
changed: true,
|
||||
ok: true,
|
||||
}),
|
||||
findRuntimeById: jest.fn().mockResolvedValue(container),
|
||||
removeUnboundContainer: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
const refreshService = new QqbotNapcatLoginService(
|
||||
{ get: jest.fn() } as unknown as ConfigService,
|
||||
{} as QqbotAccountService,
|
||||
containerService as unknown as QqbotNapcatContainerService,
|
||||
new ToolsService(),
|
||||
);
|
||||
const session = (refreshService as any).createSession({
|
||||
accountId: 'account-1',
|
||||
container,
|
||||
expectedSelfId: '10001',
|
||||
mode: 'refresh',
|
||||
status: 'pending',
|
||||
});
|
||||
session.captchaUrl = captchaUrl;
|
||||
session.passwordMd5 = '0123456789abcdef0123456789abcdef';
|
||||
(refreshService as any).sessions.set(session.id, session);
|
||||
jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({
|
||||
isLogin: false,
|
||||
loginError: '安全验证失败,请重新登录',
|
||||
});
|
||||
|
||||
const result = await refreshService.status(session.id);
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.captchaUrl).toBeUndefined();
|
||||
expect(result.errorMessage).toBe('安全验证失败,请重新登录');
|
||||
expect(containerService.ensureRuntimeLoginEnv).toHaveBeenCalledWith(
|
||||
container,
|
||||
{
|
||||
clearLoginPassword: true,
|
||||
selfId: '10001',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('uses NapCat -q quick login before generating qrcode for refresh login', async () => {
|
||||
const container = {
|
||||
baseUrl: 'http://127.0.0.1:6103/',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user