From d03e7c09a4c49519bc6f5a92aa5e71a40f65aff6 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 13 Jun 2026 10:03:31 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DNapCat=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E7=99=BB=E5=BD=95=E4=BA=8C=E7=BB=B4=E7=A0=81=E5=85=9C?= =?UTF-8?q?=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../account/qqbot-napcat-login.service.ts | 51 +++++ .../napcat/qqbot-napcat-container.service.ts | 8 +- .../qqbot-napcat-login.service.spec.ts | 192 ++++++++++++++++++ .../qqbot-napcat-container.service.spec.ts | 72 ++++++- 4 files changed, 316 insertions(+), 7 deletions(-) diff --git a/src/qqbot/account/qqbot-napcat-login.service.ts b/src/qqbot/account/qqbot-napcat-login.service.ts index 9ca98a6..24ef2bf 100644 --- a/src/qqbot/account/qqbot-napcat-login.service.ts +++ b/src/qqbot/account/qqbot-napcat-login.service.ts @@ -1285,6 +1285,10 @@ export class QqbotNapcatLoginService { } if (!loginStatus.isLogin) { + if (this.isPasswordQrcodeChallenge(loginStatus)) { + await this.keepPasswordQrcodePending(session, container, loginStatus); + return true; + } const captchaUrl = await this.resolvePasswordCaptchaUrl( container, loginStatus, @@ -1363,6 +1367,45 @@ export class QqbotNapcatLoginService { return true; } + private async keepPasswordQrcodePending( + session: QqbotLoginScanSession, + container: QqbotNapcatRuntime, + loginStatus: NapcatLoginStatus, + ) { + await this.clearRuntimeLoginPasswordAfterFailedPassword( + session, + container, + session.expectedSelfId, + ); + this.publishScanResultEvent( + session, + 'password-login-qrcode', + 'processing', + '密码登录未完成,已切换到扫码确认', + ); + session.qrcode = await this.refreshOrGetQrcode(container, true, { + fallbackStatus: loginStatus, + requireFresh: this.toolsService.isNapcatExpiredQrcodeStatus(loginStatus), + staleQrcode: loginStatus.qrcodeurl, + }); + session.captchaUrl = undefined; + session.errorMessage = undefined; + session.expiresAt = Date.now() + this.getSessionTtlMs(); + this.sessions.set(session.id, session); + this.publishScanResultEvent( + session, + 'qrcode-ready', + 'success', + '登录二维码已生成', + ); + this.publishScanResultEvent( + session, + 'waiting-scan', + 'processing', + '等待扫码确认', + ); + } + private async resolvePasswordCaptchaUrl( container: QqbotNapcatRuntime, loginStatus: NapcatLoginStatus, @@ -1385,6 +1428,13 @@ export class QqbotNapcatLoginService { return this.toolsService.extractNapcatCaptchaUrl(status.loginError); } + private isPasswordQrcodeChallenge(status: NapcatLoginStatus) { + return ( + !!this.toolsService.toTrimmedString(status.qrcodeurl) || + this.toolsService.isNapcatExpiredQrcodeStatus(status) + ); + } + private async clearRuntimeLoginPasswordBeforeQuick( session: QqbotLoginScanSession, container: QqbotNapcatRuntime, @@ -1666,6 +1716,7 @@ export class QqbotNapcatLoginService { latestStatus = await this.getLoginStatus(container, true); if ( latestStatus.isLogin || + this.isPasswordQrcodeChallenge(latestStatus) || this.toolsService.isNapcatCaptchaRequiredMessage( latestStatus.loginError, ) diff --git a/src/qqbot/napcat/qqbot-napcat-container.service.ts b/src/qqbot/napcat/qqbot-napcat-container.service.ts index 23943db..f40a55f 100644 --- a/src/qqbot/napcat/qqbot-napcat-container.service.ts +++ b/src/qqbot/napcat/qqbot-napcat-container.service.ts @@ -132,15 +132,19 @@ export class QqbotNapcatContainerService { skipPull: true, token: container.webuiToken, }); + const verified = await this.runtimeMatchesLoginEnv(container.name, { + ...options, + selfId: account, + }); await this.containerRepository.update( { id: container.id }, { - lastError: null, + lastError: verified ? null : 'NapCat 运行态登录环境校验失败', lastStartedAt: new Date(), status: 'running', }, ); - return { changed: true, ok: true }; + return { changed: true, ok: verified }; } catch { return { changed: false, ok: false }; } diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index 9352a51..66ed470 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -748,6 +748,162 @@ describe('QqbotNapcatLoginService', () => { expect(session.captchaUrl).toBe(captchaUrl); }); + it('keeps qrcode pending when password login falls into NapCat qrcode status', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-password-qrcode', + name: 'napcat-10001', + }; + const containerService = { + ensureRuntimeLoginEnv: jest + .fn() + .mockResolvedValueOnce({ changed: false, ok: true }) + .mockResolvedValueOnce({ changed: true, ok: true }) + .mockResolvedValueOnce({ 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: '10', + 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: '二维码已过期,请刷新', + qrcodeurl: 'expired-qrcode', + }); + const refreshQrcode = jest + .spyOn(refreshService as any, 'refreshOrGetQrcode') + .mockResolvedValue('fresh-qrcode'); + + await (refreshService as any).prepareReloginQrcode( + session, + container, + 'qq-password', + ); + + expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); + expect(refreshQrcode).toHaveBeenCalledWith( + container, + true, + expect.objectContaining({ + requireFresh: true, + staleQrcode: 'expired-qrcode', + }), + ); + expect(session.status).toBe('pending'); + expect(session.captchaUrl).toBeUndefined(); + expect(session.passwordMd5).toBeUndefined(); + expect(session.qrcode).toBe('fresh-qrcode'); + const steps = ( + (refreshService as any).sessionEventLogs.get(session.id) ?? [] + ).map((event: { step: string }) => event.step); + expect(steps).toContain('password-login-qrcode'); + expect(steps).not.toContain('relogin-reset-start'); + }); + + it('prioritizes current qrcode status over stale captcha URL in runtime logs', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-password-qrcode-over-captcha-log', + name: 'napcat-10001', + }; + const containerService = { + detectRuntimeCaptchaUrl: jest + .fn() + .mockResolvedValue( + 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001', + ), + ensureRuntimeLoginEnv: jest + .fn() + .mockResolvedValueOnce({ changed: false, ok: true }) + .mockResolvedValueOnce({ changed: true, ok: true }) + .mockResolvedValueOnce({ 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: '10', + }; + 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: '二维码已过期,请刷新', + qrcodeurl: 'expired-qrcode', + }); + jest + .spyOn(refreshService as any, 'refreshOrGetQrcode') + .mockResolvedValue('fresh-qrcode'); + + await (refreshService as any).prepareReloginQrcode( + session, + container, + 'qq-password', + ); + + expect(containerService.detectRuntimeCaptchaUrl).not.toHaveBeenCalled(); + expect(session.status).toBe('pending'); + expect(session.captchaUrl).toBeUndefined(); + expect(session.qrcode).toBe('fresh-qrcode'); + }); + it('submits captcha result back to NapCat and completes password login', async () => { const accountService = { ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), @@ -964,6 +1120,42 @@ describe('QqbotNapcatLoginService', () => { expect(sleep).not.toHaveBeenCalled(); }); + it('returns qrcode status immediately without waiting full password window', async () => { + 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, + {} as QqbotNapcatContainerService, + new ToolsService(), + ); + const getLoginStatus = jest + .spyOn(refreshService as any, 'getLoginStatus') + .mockResolvedValue({ + isLogin: false, + loginError: '二维码已过期,请刷新', + qrcodeurl: 'expired-qrcode', + }); + 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-qrcode-early', + name: 'napcat-10001', + webuiToken: 'token', + }); + + expect(status.qrcodeurl).toBe('expired-qrcode'); + expect(getLoginStatus).toHaveBeenCalledTimes(1); + 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/', diff --git a/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts index 721b034..8466e2a 100644 --- a/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts +++ b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts @@ -418,7 +418,11 @@ describe('QqbotNapcatContainerService', () => { service.runProcess = jest .fn() .mockRejectedValueOnce(new Error('inspect failed')) - .mockResolvedValueOnce({ stderr: '', stdout: '' }); + .mockResolvedValueOnce({ stderr: '', stdout: '' }) + .mockResolvedValueOnce({ + stderr: '', + stdout: 'ACCOUNT=2354598417\nNAPCAT_QUICK_PASSWORD=qq-password\n', + }); const recreated = await service.ensureRuntimeLoginEnv( { id: 'container-1', name: 'kt-qqbot-napcat-x' }, @@ -429,7 +433,7 @@ describe('QqbotNapcatContainerService', () => { ); expect(recreated).toEqual({ changed: true, ok: true }); - expect(service.runProcess).toHaveBeenCalledTimes(2); + expect(service.runProcess).toHaveBeenCalledTimes(3); expect(service.runProcess.mock.calls[1][2]).toContain( "NAPCAT_QUICK_PASSWORD='qq-password'", ); @@ -466,7 +470,11 @@ describe('QqbotNapcatContainerService', () => { service.runProcess = jest .fn() .mockResolvedValueOnce({ stderr: '', stdout: 'ACCOUNT=2354598417\n' }) - .mockResolvedValueOnce({ stderr: '', stdout: '' }); + .mockResolvedValueOnce({ stderr: '', stdout: '' }) + .mockResolvedValueOnce({ + stderr: '', + stdout: 'ACCOUNT=2354598417\nNAPCAT_QUICK_PASSWORD=qq-password\n', + }); const recreated = await service.ensureRuntimeLoginEnv( { id: 'container-1', name: 'kt-qqbot-napcat-x' }, @@ -477,7 +485,7 @@ describe('QqbotNapcatContainerService', () => { ); expect(recreated).toEqual({ changed: true, ok: true }); - expect(service.runProcess).toHaveBeenCalledTimes(2); + expect(service.runProcess).toHaveBeenCalledTimes(3); expect(service.runProcess.mock.calls[1][2]).toContain( "NAPCAT_QUICK_PASSWORD='qq-password'", ); @@ -490,6 +498,59 @@ describe('QqbotNapcatContainerService', () => { ); }); + it('fails login env preparation when recreated container still misses password env', async () => { + const containerRepository = { + createQueryBuilder: jest.fn(() => ({ + addSelect: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue({ + dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-x', + id: 'container-1', + image: 'mlikiowa/napcat-docker:latest', + name: 'kt-qqbot-napcat-x', + reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse', + webuiPort: 6100, + webuiToken: 'token-x', + }), + where: jest.fn().mockReturnThis(), + })), + update: jest.fn(), + }; + const service = new QqbotNapcatContainerService( + { + get: jest.fn((key: string) => + key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '', + ), + } as any, + containerRepository as any, + {} as any, + new ToolsService(), + ) as any; + service.runProcess = jest + .fn() + .mockResolvedValueOnce({ stderr: '', stdout: 'ACCOUNT=2354598417\n' }) + .mockResolvedValueOnce({ stderr: '', stdout: '' }) + .mockResolvedValueOnce({ stderr: '', stdout: 'ACCOUNT=2354598417\n' }); + + const recreated = await service.ensureRuntimeLoginEnv( + { id: 'container-1', name: 'kt-qqbot-napcat-x' }, + { + loginPassword: 'qq-password', + selfId: '2354598417', + }, + ); + + expect(recreated).toEqual({ changed: true, ok: false }); + expect(service.runProcess).toHaveBeenCalledTimes(3); + expect(containerRepository.update).toHaveBeenCalledWith( + { id: 'container-1' }, + expect.objectContaining({ + lastError: 'NapCat 运行态登录环境校验失败', + status: 'running', + }), + ); + }); + it('removes password env for a quick-login-only runtime stage', async () => { const containerRepository = { createQueryBuilder: jest.fn(() => ({ @@ -524,7 +585,8 @@ describe('QqbotNapcatContainerService', () => { stderr: '', stdout: 'ACCOUNT=2354598417\nNAPCAT_QUICK_PASSWORD=old-password\n', }) - .mockResolvedValueOnce({ stderr: '', stdout: '' }); + .mockResolvedValueOnce({ stderr: '', stdout: '' }) + .mockResolvedValueOnce({ stderr: '', stdout: 'ACCOUNT=2354598417\n' }); const recreated = await service.ensureRuntimeLoginEnv( { id: 'container-1', name: 'kt-qqbot-napcat-x' },