fix: 修复NapCat验证码日志捕获

This commit is contained in:
sunlei 2026-06-13 12:03:01 +08:00
parent 492bf4b682
commit 3dec63b5d2
3 changed files with 170 additions and 11 deletions

View File

@ -1266,7 +1266,10 @@ export class QqbotNapcatLoginService {
'processing',
'等待 NapCat 密码登录结果',
);
loginStatus = await this.waitForPasswordLoginStatus(container);
loginStatus = await this.waitForPasswordLoginStatus(
container,
session.lastRestartedAt,
);
if (loginStatus.isLogin) {
loginInfo = await this.getLoginInfo(container);
@ -1413,6 +1416,32 @@ export class QqbotNapcatLoginService {
) {
const statusCaptchaUrl = this.getCaptchaUrlFromStatus(loginStatus);
if (statusCaptchaUrl) return statusCaptchaUrl;
const runtimeCaptchaUrl = await this.detectPasswordCaptchaUrl(
container,
sinceMs,
false,
);
if (runtimeCaptchaUrl) return runtimeCaptchaUrl;
if (
!this.toolsService.isNapcatCaptchaRequiredMessage(loginStatus.loginError)
) {
return '';
}
return this.detectPasswordCaptchaUrl(container, sinceMs, true);
}
private getCaptchaUrlFromStatus(status: NapcatLoginStatus) {
return (
this.toolsService.toTrimmedString(status.captchaUrl) ||
this.toolsService.extractNapcatCaptchaUrl(status.loginError)
);
}
private async detectPasswordCaptchaUrl(
container: QqbotNapcatRuntime,
sinceMs?: number,
allowTailFallback = true,
) {
if (typeof this.containerService.detectRuntimeCaptchaUrl !== 'function') {
return '';
}
@ -1421,20 +1450,12 @@ export class QqbotNapcatLoginService {
sinceMs,
);
if (recentCaptchaUrl) return recentCaptchaUrl;
if (
!this.toolsService.isNapcatCaptchaRequiredMessage(loginStatus.loginError)
) {
return '';
}
if (!allowTailFallback) return '';
return (
(await this.containerService.detectRuntimeCaptchaUrl(container)) || ''
);
}
private getCaptchaUrlFromStatus(status: NapcatLoginStatus) {
return this.toolsService.extractNapcatCaptchaUrl(status.loginError);
}
private isPasswordQrcodeChallenge(status: NapcatLoginStatus) {
return (
!!this.toolsService.toTrimmedString(status.qrcodeurl) ||
@ -1710,7 +1731,10 @@ export class QqbotNapcatLoginService {
);
}
private async waitForPasswordLoginStatus(container: QqbotNapcatRuntime) {
private async waitForPasswordLoginStatus(
container: QqbotNapcatRuntime,
sinceMs?: number,
) {
let latestStatus: NapcatLoginStatus = { isLogin: false };
const attempts = this.getLoginPollAttempts(
this.getPasswordLoginWaitMs(),
@ -1726,6 +1750,10 @@ export class QqbotNapcatLoginService {
const errorMessage = this.toolsService.getErrorMessage(err);
if (this.toolsService.isNapcatCaptchaRequiredMessage(errorMessage)) {
return {
captchaUrl: await this.detectPasswordCaptchaUrl(
container,
sinceMs,
),
isLogin: false,
loginError: errorMessage,
};
@ -1739,6 +1767,17 @@ export class QqbotNapcatLoginService {
latestStatus.loginError,
)
) {
if (
!this.getCaptchaUrlFromStatus(latestStatus) &&
this.toolsService.isNapcatCaptchaRequiredMessage(
latestStatus.loginError,
)
) {
latestStatus.captchaUrl = await this.detectPasswordCaptchaUrl(
container,
sinceMs,
);
}
return latestStatus;
}
}

View File

@ -25,6 +25,7 @@ export type NapcatLoginInfo = Record<string, any> & {
};
export type NapcatLoginStatus = {
captchaUrl?: string;
isLogin?: boolean;
isOffline?: boolean;
loginError?: string;

View File

@ -748,6 +748,77 @@ describe('QqbotNapcatLoginService', () => {
expect(session.captchaUrl).toBe(captchaUrl);
});
it('does not keep stale captcha from tail logs when current password status is processing', async () => {
const staleCaptchaUrl =
'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001';
const container = {
baseUrl: 'http://127.0.0.1:6103/',
id: 'container-password-stale-captcha',
name: 'napcat-10001',
};
const containerService = {
detectRuntimeCaptchaUrl: jest
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(staleCaptchaUrl),
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: '密码登录处理中',
});
jest
.spyOn(refreshService as any, 'refreshOrGetQrcode')
.mockResolvedValue('fallback-qrcode');
await (refreshService as any).prepareReloginQrcode(
session,
container,
'qq-password',
);
expect(session.captchaUrl).toBeUndefined();
expect(containerService.detectRuntimeCaptchaUrl).toHaveBeenCalledTimes(1);
expect(session.qrcode).toBe('fallback-qrcode');
});
it('keeps password captcha pending before cleanup when status check throws captcha error', async () => {
const captchaUrl =
'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001';
@ -1210,6 +1281,54 @@ describe('QqbotNapcatLoginService', () => {
expect(sleep).not.toHaveBeenCalled();
});
it('captures captcha url from runtime logs when status check throws captcha message without url', async () => {
const restartedAt = Date.now() - 1500;
const captchaUrl =
'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001';
const containerService = {
detectRuntimeCaptchaUrl: jest.fn().mockResolvedValue(captchaUrl),
};
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,
containerService as unknown as QqbotNapcatContainerService,
new ToolsService(),
);
jest
.spyOn(refreshService as any, 'getLoginStatus')
.mockRejectedValueOnce(new Error('需要验证码'));
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-log',
name: 'napcat-10001',
webuiToken: 'token',
},
restartedAt,
);
expect(status).toMatchObject({
captchaUrl,
isLogin: false,
loginError: '需要验证码',
});
expect(containerService.detectRuntimeCaptchaUrl).toHaveBeenCalledWith(
expect.objectContaining({ name: 'napcat-10001' }),
restartedAt,
);
expect(sleep).not.toHaveBeenCalled();
});
it('returns qrcode status immediately without waiting full password window', async () => {
const refreshService = new QqbotNapcatLoginService(
{