diff --git a/src/qqbot/account/qqbot-napcat-login.service.ts b/src/qqbot/account/qqbot-napcat-login.service.ts index b02a150..a70782c 100644 --- a/src/qqbot/account/qqbot-napcat-login.service.ts +++ b/src/qqbot/account/qqbot-napcat-login.service.ts @@ -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; } } diff --git a/src/qqbot/qqbot.types.ts b/src/qqbot/qqbot.types.ts index 8507384..e62114c 100644 --- a/src/qqbot/qqbot.types.ts +++ b/src/qqbot/qqbot.types.ts @@ -25,6 +25,7 @@ export type NapcatLoginInfo = Record & { }; export type NapcatLoginStatus = { + captchaUrl?: string; isLogin?: boolean; isOffline?: boolean; loginError?: string; diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index 7d84e8b..81aed5a 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -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 = { + 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 = { + 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( {