From 0ddf785b8b8ce3bbd85e6a787695a30b31cdb5ba Mon Sep 17 00:00:00 2001 From: sunlei Date: Thu, 4 Jun 2026 09:19:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(api):=20=E4=BF=AE=E5=A4=8DQQBot=E8=A2=AB?= =?UTF-8?q?=E8=B8=A2=E4=B8=8B=E7=BA=BF=E6=89=AB=E7=A0=81=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + .../qqbot-napcat-login.service.spec.ts | 60 +++++++++++++++++++ .../account/qqbot-napcat-login.service.ts | 50 +++++++++++++--- .../napcat/qqbot-napcat-container.service.ts | 21 +++++++ 4 files changed, 125 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 6b816f6..97f6c1d 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,7 @@ NAPCAT_WEBUI_TOKEN= NAPCAT_WEBUI_TIMEOUT_MS=8000 NAPCAT_LOGIN_QR_EXPIRE_MS=120000 NAPCAT_WEBUI_READY_RETRIES=10 +NAPCAT_WEBUI_RESTART_DELAY_MS=3000 QQBOT_NAPCAT_CONTAINER_MODE= QQBOT_NAPCAT_SSH_TARGET=nas QQBOT_NAPCAT_SSH_PORT= diff --git a/src/qqbot/account/qqbot-napcat-login.service.spec.ts b/src/qqbot/account/qqbot-napcat-login.service.spec.ts index 525918f..73b2c0e 100644 --- a/src/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/src/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -42,6 +42,9 @@ describe('QqbotNapcatLoginService', () => { jest .spyOn(service as any, 'getSessionContainer') .mockResolvedValue({ id: 'container-1' }); + jest.spyOn(service as any, 'getLoginStatus').mockResolvedValue({ + isLogin: false, + }); jest .spyOn(service as any, 'callRefreshQrcode') .mockResolvedValue('new-qrcode-image'); @@ -108,6 +111,10 @@ describe('QqbotNapcatLoginService', () => { jest .spyOn(service as any, 'getSessionContainer') .mockResolvedValue(container); + jest.spyOn(service as any, 'getLoginStatus').mockResolvedValue({ + isLogin: false, + qrcodeurl: 'old-qrcode', + }); jest.spyOn(service as any, 'callRefreshQrcode').mockResolvedValue(''); jest.spyOn(service as any, 'getQrcode').mockResolvedValue('new-qrcode'); @@ -120,6 +127,59 @@ describe('QqbotNapcatLoginService', () => { }); }); + it('restarts the existing NapCat container before qrcode refresh when account is kicked offline', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-3', + name: 'napcat-10001', + }; + const containerService = { + restartRuntimeContainer: jest.fn().mockResolvedValue(true), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + {} as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + ); + jest.spyOn(refreshService as any, 'sleep').mockResolvedValue(undefined); + jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ + isLogin: false, + qrcodeurl: 'new-status-qrcode', + }); + jest + .spyOn(refreshService as any, 'callRefreshQrcode') + .mockResolvedValue(''); + jest + .spyOn(refreshService as any, 'getQrcode') + .mockResolvedValue('new-qrcode'); + + const result = await (refreshService as any).refreshOrGetQrcode( + container, + true, + { + fallbackStatus: { + isLogin: false, + isOffline: true, + loginError: '您的账号已在另一台终端登录', + qrcodeurl: 'old-qrcode', + }, + }, + ); + + expect(result).toBe('new-qrcode'); + expect(containerService.restartRuntimeContainer).toHaveBeenCalledWith( + container, + ); + expect((refreshService as any).getQrcode).toHaveBeenCalledWith( + container, + true, + { + requireFresh: true, + staleQrcode: 'old-qrcode', + }, + ); + }); + it('retries while NapCat still exposes the stale qrcode', async () => { jest.spyOn(service as any, 'sleep').mockResolvedValue(undefined); jest diff --git a/src/qqbot/account/qqbot-napcat-login.service.ts b/src/qqbot/account/qqbot-napcat-login.service.ts index 0d5f0d3..ca7631a 100644 --- a/src/qqbot/account/qqbot-napcat-login.service.ts +++ b/src/qqbot/account/qqbot-napcat-login.service.ts @@ -125,9 +125,11 @@ export class QqbotNapcatLoginService { } const container = await this.getSessionContainer(session); + const loginStatus = await this.getLoginStatus(container); session.qrcode = await this.refreshOrGetQrcode(container, true, { + fallbackStatus: loginStatus, requireFresh: true, - staleQrcode: session.qrcode, + staleQrcode: session.qrcode || loginStatus.qrcodeurl, }); session.expiresAt = Date.now() + this.getSessionTtlMs(); session.errorMessage = undefined; @@ -436,10 +438,15 @@ export class QqbotNapcatLoginService { retry = false, options: QrcodeRefreshOptions = {}, ) { + let fallbackStatus = options.fallbackStatus; const lookupOptions: QrcodeLookupOptions = { - requireFresh: options.requireFresh, - staleQrcode: options.staleQrcode, + requireFresh: options.requireFresh || fallbackStatus?.isOffline, + staleQrcode: options.staleQrcode || fallbackStatus?.qrcodeurl, }; + if (fallbackStatus?.isOffline) { + await this.restartNapcatForLogin(container); + fallbackStatus = undefined; + } try { const refreshedQrcode = await this.callRefreshQrcode(container, retry); if (refreshedQrcode) { @@ -449,10 +456,10 @@ export class QqbotNapcatLoginService { } catch (err) { if ( !lookupOptions.requireFresh && - options.fallbackStatus?.qrcodeurl && - !this.isExpiredQrcodeStatus(options.fallbackStatus) + fallbackStatus?.qrcodeurl && + !this.isExpiredQrcodeStatus(fallbackStatus) ) { - return options.fallbackStatus.qrcodeurl; + return fallbackStatus.qrcodeurl; } throw err; } @@ -505,8 +512,31 @@ export class QqbotNapcatLoginService { return this.requestNapcat(container, path, body, credential); } + private async restartNapcatForLogin(container: QqbotNapcatRuntime) { + const restartedByContainer = + await this.containerService.restartRuntimeContainer(container); + if (!restartedByContainer) { + try { + await this.postNapcat | null>( + container, + '/api/QQLogin/RestartNapCat', + ); + } catch (err) { + if (!this.isTemporaryNapcatError(err)) throw err; + } + } + + this.credentials.delete(this.getCredentialCacheKey(container)); + await this.sleep(this.getRestartDelayMs()); + await this.getLoginStatus(container, true); + } + + private getCredentialCacheKey(container: QqbotNapcatRuntime) { + return container.id || container.baseUrl; + } + private async getCredential(container: QqbotNapcatRuntime) { - const cacheKey = container.id || container.baseUrl; + const cacheKey = this.getCredentialCacheKey(container); const cached = this.credentials.get(cacheKey); if (cached && Date.now() < cached.expiresAt) { return cached.credential; @@ -610,6 +640,12 @@ export class QqbotNapcatLoginService { return Number(this.configService.get('NAPCAT_WEBUI_TIMEOUT_MS') || 8000); } + private getRestartDelayMs() { + return Number( + this.configService.get('NAPCAT_WEBUI_RESTART_DELAY_MS') || 3000, + ); + } + private getSelfId(info: NapcatLoginInfo) { return `${info.uin || info.self_id || info.selfId || ''}`.trim(); } diff --git a/src/qqbot/napcat/qqbot-napcat-container.service.ts b/src/qqbot/napcat/qqbot-napcat-container.service.ts index d390037..0ea99d3 100644 --- a/src/qqbot/napcat/qqbot-napcat-container.service.ts +++ b/src/qqbot/napcat/qqbot-napcat-container.service.ts @@ -151,6 +151,27 @@ export class QqbotNapcatContainerService { return this.removeContainer(containerId); } + async restartRuntimeContainer(runtime: QqbotNapcatRuntime) { + if (this.getManagedMode() !== 'ssh' || !runtime.id || !runtime.name) { + return false; + } + + await this.runProcess( + 'ssh', + [...this.getSshArgs(), 'docker', 'restart', runtime.name], + '', + ); + await this.containerRepository.update( + { id: runtime.id }, + { + lastError: null, + lastStartedAt: new Date(), + status: 'running', + }, + ); + return true; + } + private async removeContainer(containerId: string) { const container = await this.containerRepository.findOne({ where: {