diff --git a/src/qqbot/account/qqbot-napcat-login.service.ts b/src/qqbot/account/qqbot-napcat-login.service.ts index 49500d8..8febbbf 100644 --- a/src/qqbot/account/qqbot-napcat-login.service.ts +++ b/src/qqbot/account/qqbot-napcat-login.service.ts @@ -55,6 +55,7 @@ export class QqbotNapcatLoginService { { accountId: account.id, expectedSelfId: account.selfId, + forceRelogin: true, mode: 'refresh', }, container, @@ -83,12 +84,16 @@ export class QqbotNapcatLoginService { } if (loginStatus.isOffline) { - await this.restartNapcatForLogin(container, { waitForReady: false }); + if (session.mode === 'refresh') { + await this.resetNapcatForLogin(container, { waitForReady: false }); + } else { + await this.restartNapcatForLogin(container, { waitForReady: false }); + } session.lastRestartedAt = Date.now(); return this.keepSessionPending( session, loginStatus.loginError || - 'NapCat 账号已离线,已重启容器并重新生成二维码', + 'NapCat 账号已离线,已重新生成二维码', true, ); } @@ -162,6 +167,7 @@ export class QqbotNapcatLoginService { options: { accountId?: string; expectedSelfId?: string; + forceRelogin?: boolean; mode: QqbotLoginScanMode; }, container: QqbotNapcatRuntime, @@ -169,9 +175,17 @@ export class QqbotNapcatLoginService { await this.cleanupSessions(); try { + if (options.forceRelogin) { + await this.resetNapcatForLogin(container); + } + const loginStatus = await this.getLoginStatus(container, true); if (loginStatus.isOffline) { - await this.restartNapcatForLogin(container, { waitForReady: false }); + if (options.forceRelogin) { + await this.resetNapcatForLogin(container, { waitForReady: false }); + } else { + await this.restartNapcatForLogin(container, { waitForReady: false }); + } const session = this.createSession({ ...options, container, @@ -180,7 +194,18 @@ export class QqbotNapcatLoginService { session.lastRestartedAt = Date.now(); session.errorMessage = loginStatus.loginError || - 'NapCat 账号已离线,已重启容器并重新生成二维码'; + 'NapCat 账号已离线,已重新生成二维码'; + this.sessions.set(session.id, session); + return this.toResult(session); + } + + if (options.forceRelogin && loginStatus.isLogin) { + const session = this.createSession({ + ...options, + container, + status: 'pending', + }); + session.errorMessage = 'NapCat 正在重新生成二维码,请稍后刷新'; this.sessions.set(session.id, session); return this.toResult(session); } @@ -588,6 +613,24 @@ export class QqbotNapcatLoginService { return this.requestNapcat(container, path, body, credential); } + private async resetNapcatForLogin( + container: QqbotNapcatRuntime, + options: NapcatRestartOptions = {}, + ) { + const resetByContainer = + await this.containerService.resetRuntimeLoginState(container); + if (!resetByContainer) { + await this.restartNapcatForLogin(container, options); + return; + } + + this.credentials.delete(this.getCredentialCacheKey(container)); + if (options.waitForReady === false) return; + + await this.toolsService.sleep(this.getRestartDelayMs()); + await this.getLoginStatus(container, true); + } + private async restartNapcatForLogin( container: QqbotNapcatRuntime, options: NapcatRestartOptions = {}, diff --git a/src/qqbot/napcat/qqbot-napcat-container.service.ts b/src/qqbot/napcat/qqbot-napcat-container.service.ts index ee521d4..39c2770 100644 --- a/src/qqbot/napcat/qqbot-napcat-container.service.ts +++ b/src/qqbot/napcat/qqbot-napcat-container.service.ts @@ -166,6 +166,34 @@ export class QqbotNapcatContainerService { return true; } + async resetRuntimeLoginState(runtime: QqbotNapcatRuntime) { + if (this.getManagedMode() !== 'ssh' || !runtime.id || !runtime.name) { + return false; + } + + const container = await this.containerRepository.findOne({ + where: { + id: runtime.id, + isDeleted: false, + }, + }); + if (!container) { + throwVbenError('NapCat 容器不存在或已删除'); + } + + const script = this.buildRemoteResetLoginStateScript(container); + await this.runProcess('ssh', [...this.getSshArgs(), 'sh -s'], script); + 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: { @@ -221,6 +249,39 @@ fi `; } + private buildRemoteResetLoginStateScript(container: QqbotNapcatContainer) { + const dataDir = this.sh(container.dataDir || ''); + const name = this.sh(container.name); + const rootDir = this.sh(this.getRootDir()); + + return ` +set -eu +NAME=${name} +DATA_DIR=${dataDir} +ROOT_DIR=${rootDir} + +if [ -z "$DATA_DIR" ] || [ "$DATA_DIR" = "/" ]; then + echo "unsafe empty data dir" >&2 + exit 1 +fi + +case "$DATA_DIR" in + "$ROOT_DIR"/*) + ;; + *) + echo "skip unsafe data dir: $DATA_DIR" >&2 + exit 1 + ;; +esac + +docker exec "$NAME" rm -f /app/napcat/cache/qrcode.png >/dev/null 2>&1 || true +docker stop "$NAME" >/dev/null 2>&1 || true +mkdir -p "$DATA_DIR/QQ" +find "$DATA_DIR/QQ" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + +docker start "$NAME" >/dev/null +`; + } + private async getPrimaryRuntime(accountId: string) { const binding = await this.bindingRepository.findOne({ order: { diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index a0cc099..f07afaf 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -90,12 +90,59 @@ describe('QqbotNapcatLoginService', () => { { accountId: 'account-1', expectedSelfId: '10001', + forceRelogin: true, mode: 'refresh', }, existingContainer, ); }); + it('resets the existing container login state before refresh login scan', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-current', + name: 'napcat-10001', + }; + const containerService = { + resetRuntimeLoginState: jest.fn().mockResolvedValue(true), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + {} as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + jest + .spyOn((refreshService as any).toolsService, 'sleep') + .mockResolvedValue(undefined); + jest + .spyOn(refreshService as any, 'cleanupSessions') + .mockResolvedValue(undefined); + jest + .spyOn(refreshService as any, 'getLoginStatus') + .mockResolvedValueOnce({ isLogin: false }) + .mockResolvedValueOnce({ isLogin: false }); + jest + .spyOn(refreshService as any, 'refreshOrGetQrcode') + .mockResolvedValue('fresh-qrcode'); + + const result = await (refreshService as any).startScan( + { + accountId: 'account-1', + expectedSelfId: '10001', + forceRelogin: true, + mode: 'refresh', + }, + container, + ); + + expect(result.status).toBe('pending'); + expect(result.qrcode).toBe('fresh-qrcode'); + expect(containerService.resetRuntimeLoginState).toHaveBeenCalledWith( + container, + ); + }); + it('requires a qrcode different from the current one when refreshing qrcode', async () => { (service as any).sessions.set('session-refresh-qrcode', { containerId: 'container-3',