From 7f4bff4a1ae2e85bd9b65e2ab69069b656f72cc0 Mon Sep 17 00:00:00 2001 From: sunlei Date: Thu, 18 Jun 2026 12:39:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=A8=B3=E5=AE=9ANapCat=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=81=A2=E5=A4=8D=E7=99=BB=E5=BD=95=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../qqbot-napcat-account-runtime.service.ts | 22 ++ .../runtime/napcat-login-event.service.ts | 141 +++++++++ src/modules/qqbot/napcat/index.ts | 1 + .../qqbot-napcat-container.service.ts | 220 +++++++++++++- .../qqbot/napcat/qqbot-napcat.module.ts | 3 + .../qqbot/napcat/login-event-watchdog.spec.ts | 279 ++++++++++++++++++ 6 files changed, 661 insertions(+), 5 deletions(-) create mode 100644 src/modules/qqbot/napcat/application/runtime/napcat-login-event.service.ts create mode 100644 test/modules/qqbot/napcat/login-event-watchdog.spec.ts diff --git a/src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service.ts b/src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service.ts index 2c8ef05..dd1496c 100644 --- a/src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service.ts +++ b/src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service.ts @@ -11,6 +11,7 @@ import type { QqbotAccountListItem, QqbotNapcatRuntimeStatusSnapshot, } from '@/modules/qqbot/core/contract/qqbot.types'; +import { NapcatLoginEventService } from '../runtime/napcat-login-event.service'; import { NapcatRuntimeProfileInspectorService } from '../runtime/napcat-runtime-profile-inspector.service'; import { QqbotNapcatContainerService } from '../../infrastructure/integration/container/qqbot-napcat-container.service'; import { NapcatAccountBinding } from '../../infrastructure/persistence/napcat-account-binding.entity'; @@ -29,6 +30,7 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti * @param napcatContainerService - Runtime integration service for bounded NapCat/WebUI status probes and auto-login. * @param toolsService - Shared helpers for status text normalization and NapCat offline-message classification. * @param runtimeProfileInspector - Optional profile reader that enriches list rows without changing login state. + * @param loginEventService - Optional login-event gate that prevents watchdog from repeating suspended recovery flows. */ constructor( @InjectRepository(NapcatAccountBinding) @@ -38,6 +40,7 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti private readonly napcatContainerService: QqbotNapcatContainerService, private readonly toolsService: ToolsService, private readonly runtimeProfileInspector?: NapcatRuntimeProfileInspectorService, + private readonly loginEventService?: NapcatLoginEventService, ) {} /** @@ -371,6 +374,25 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti actions: QqbotAccountNapcatRuntimeActions, ) { try { + const recoveryGate = + await this.loginEventService?.canAttemptAutomaticRecovery({ + accountId: account.id, + containerId: container.id, + resetAfter: account.lastConnectedAt, + }); + if (recoveryGate && !recoveryGate.allowed) { + await this.loginEventService?.recordSuspended({ + accountId: account.id, + containerId: container.id, + evidence: { + reason: recoveryGate.reason, + }, + reason: recoveryGate.reason || 'recovery_suspended', + source: 'watchdog', + }); + return false; + } + const result = await this.napcatContainerService.tryAutoLogin(container, { loginPassword: actions.getLoginPassword(account), selfId: account.selfId, diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-login-event.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-login-event.service.ts new file mode 100644 index 0000000..1b8b53a --- /dev/null +++ b/src/modules/qqbot/napcat/application/runtime/napcat-login-event.service.ts @@ -0,0 +1,141 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { + NapcatLoginEvent, + type NapcatLoginEventKind, + type NapcatLoginEventSource, + type NapcatLoginEventStatus, +} from '../../infrastructure/persistence/napcat-login-event.entity'; + +const NAPCAT_AUTOMATIC_RECOVERY_BLOCKING_EVENTS: NapcatLoginEventKind[] = [ + 'captcha_required', + 'manual_qr_created', + 'new_device_required', + 'recovery_suspended', +]; + +export type NapcatAutomaticRecoveryGate = { + allowed: boolean; + reason?: NapcatLoginEventKind; +}; + +@Injectable() +export class NapcatLoginEventService { + /** + * Initializes login-event persistence. + * @param loginEventRepository - Repository used to append login-side audit and recovery-gating events. + */ + constructor( + @InjectRepository(NapcatLoginEvent) + private readonly loginEventRepository: Repository, + ) {} + + /** + * Records a login-side event for audit, automatic recovery gating, and Admin evidence. + * @param input - Event payload produced by Admin actions, watchdog, runtime checks, or system workflows. + * @returns Persisted login-event row created from the append-only payload. + */ + async record(input: { + accountId: string; + containerId?: null | string; + eventKind: NapcatLoginEventKind; + eventSource: NapcatLoginEventSource; + eventStatus: NapcatLoginEventStatus; + evidence?: Record; + }) { + return this.loginEventRepository.save( + this.loginEventRepository.create({ + accountId: input.accountId, + containerId: input.containerId || null, + eventKind: input.eventKind, + eventSource: input.eventSource, + eventStatus: input.eventStatus, + evidence: input.evidence || null, + }), + ); + } + + /** + * Records that automation stopped before QR, captcha, or new-device verification. + * @param input - Account, container, source, and domain reason explaining why watchdog recovery must stop. + * @returns Persisted `recovery_suspended` event carrying the blocking reason in evidence. + */ + recordSuspended(input: { + accountId: string; + containerId?: null | string; + evidence: Record; + reason: NapcatLoginEventKind; + source: NapcatLoginEventSource; + }) { + return this.record({ + accountId: input.accountId, + containerId: input.containerId, + eventKind: 'recovery_suspended', + eventSource: input.source, + eventStatus: 'blocked', + evidence: { + ...input.evidence, + reason: input.reason, + }, + }); + } + + /** + * Checks whether watchdog may run quick -> password recovery for an account/container pair. + * @param input - Account, optional container, and latest successful connection time used as manual reset evidence. + * @returns Recovery gate result; blocked reasons map to the latest blocking login-side event. + */ + async canAttemptAutomaticRecovery(input: { + accountId: string; + containerId?: null | string; + resetAfter?: Date | null; + }): Promise { + const where: Record = { + accountId: input.accountId, + eventKind: In(NAPCAT_AUTOMATIC_RECOVERY_BLOCKING_EVENTS), + }; + if (input.containerId) where.containerId = input.containerId; + + const latest = await this.loginEventRepository.findOne({ + order: { createTime: 'DESC' }, + where: where as any, + }); + if (!latest) return { allowed: true }; + + if (this.isResetAfterEvent(input.resetAfter, latest.createTime)) { + return { allowed: true }; + } + if (this.hasManualResetEvidence(latest.evidence)) { + return { allowed: true }; + } + + return { allowed: false, reason: latest.eventKind }; + } + + /** + * Compares a later successful connection against the blocking event timestamp. + * @param resetAfter - Successful connection or manual reset time from account state. + * @param eventTime - Blocking login-event creation time. + * @returns Whether the reset timestamp is newer than the blocking event. + */ + private isResetAfterEvent( + resetAfter: Date | null | undefined, + eventTime: Date, + ) { + if (!resetAfter) return false; + const resetAt = new Date(resetAfter).getTime(); + const blockedAt = new Date(eventTime).getTime(); + return Number.isFinite(resetAt) && resetAt > blockedAt; + } + + /** + * Detects explicit manual reset evidence embedded in a blocking event. + * @param evidence - Login event evidence object persisted with the blocking event. + * @returns Whether automation can continue because a human reset has been recorded. + */ + private hasManualResetEvidence(evidence: null | Record) { + if (!evidence) return false; + return Boolean(evidence.manualResetAt || evidence.manualReset); + } +} diff --git a/src/modules/qqbot/napcat/index.ts b/src/modules/qqbot/napcat/index.ts index 0b619ea..4297996 100644 --- a/src/modules/qqbot/napcat/index.ts +++ b/src/modules/qqbot/napcat/index.ts @@ -15,6 +15,7 @@ export * from './infrastructure/integration/napcat-login-api.client'; export * from './domain/login/napcat-login-state-machine'; export * from './application/login/qqbot-napcat-login.service'; export * from './application/login/qqbot-napcat-watchdog.service'; +export * from './application/runtime/napcat-login-event.service'; export * from './application/runtime/napcat-runtime-profile-inspector.service'; export * from './infrastructure/persistence'; export * from './infrastructure/integration/container/qqbot-napcat-container.service'; diff --git a/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts b/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts index bd7e43d..0b839c6 100644 --- a/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts +++ b/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts @@ -2,12 +2,13 @@ import * as http from 'http'; import * as https from 'https'; import { spawn } from 'child_process'; import { createHash, randomBytes, randomUUID } from 'crypto'; -import { Injectable } from '@nestjs/common'; +import { Injectable, Optional } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { throwVbenError, ToolsService } from '@/common'; import { NapcatConfigWriterService } from '../../../application/runtime/napcat-config-writer.service'; +import { NapcatLoginEventService } from '../../../application/runtime/napcat-login-event.service'; import { NapcatRuntimeProfileService } from '../../../application/runtime/napcat-runtime-profile.service'; import type { NapcatConfigFile } from '../../../domain/runtime/napcat-profile.types'; import { @@ -18,6 +19,7 @@ import { NapcatDeviceIdentityService } from '../device/napcat-device-identity.se import { QqbotAccount } from '@/modules/qqbot/core/infrastructure/persistence/account/qqbot-account.entity'; import { NapcatAccountBinding } from '../../persistence/napcat-account-binding.entity'; import { NapcatContainer } from '../../persistence/napcat-container.entity'; +import type { NapcatLoginEventKind } from '../../persistence/napcat-login-event.entity'; import type { NapcatApiResponse, NapcatCredential, @@ -66,6 +68,7 @@ export class QqbotNapcatContainerService { * @param deviceIdentityService - Device identity resolver that supplies stable hostname, MAC, data-dir, and machine-id values. * @param runtimeProfileService - Runtime profile resolver used to generate Docker env and mount settings. * @param configWriterService - Config writer used to generate NapCat and OneBot config files. + * @param loginEventService - Optional login-event recorder used by watchdog auto-login attempts. */ constructor( private readonly configService: ConfigService, @@ -77,6 +80,8 @@ export class QqbotNapcatContainerService { private readonly deviceIdentityService?: NapcatDeviceIdentityService, runtimeProfileService?: NapcatRuntimeProfileService, configWriterService?: NapcatConfigWriterService, + @Optional() + private readonly loginEventService?: NapcatLoginEventService, ) { this.runtimeProfileService = runtimeProfileService || new NapcatRuntimeProfileService(configService); @@ -259,26 +264,109 @@ export class QqbotNapcatContainerService { const runtimeContainer = await this.findContainerWithToken(container.id); if (!runtimeContainer?.name) return { success: false }; const runtime = this.toRuntime(runtimeContainer); + const accountId = this.getAutoLoginAccountId(container, runtimeContainer); + await this.recordAutoLoginEvent({ + accountId, + container, + eventKind: 'quick_attempt', + eventStatus: 'pending', + evidence: { containerName: runtime.name }, + }); const quickCleanup = await this.ensureRuntimeLoginEnv(runtime, { clearLoginPassword: true, selfId, }); - if (!quickCleanup.ok) return { cleanupFailed: true, success: false }; + if (!quickCleanup.ok) { + await this.recordAutoLoginEvent({ + accountId, + container, + eventKind: 'quick_attempt', + eventStatus: 'failed', + evidence: { + containerName: runtime.name, + reason: 'login-env-cleanup-failed', + }, + }); + return { cleanupFailed: true, success: false }; + } const quickState = await this.restartAndDetectLoginState(runtime); if (quickState.state === 'online') { + await this.recordAutoLoginEvent({ + accountId, + container, + eventKind: 'quick_attempt', + eventStatus: 'success', + evidence: { containerName: runtime.name }, + }); return { method: 'quick', success: true }; } + await this.recordAutoLoginEvent({ + accountId, + container, + eventKind: 'quick_attempt', + eventStatus: 'failed', + evidence: { + containerName: runtime.name, + offlineReason: quickState.offlineReason, + }, + }); + const quickBlockReason = this.toAutoLoginSuspendedReason( + quickState.offlineReason, + ); + if (quickBlockReason) { + await this.recordAutoLoginSuspended({ + accountId, + container, + offlineReason: quickState.offlineReason, + reason: quickBlockReason, + }); + return { success: false }; + } const loginPassword = this.toolsService.toSecretText(options.loginPassword); - if (!loginPassword) return { success: false }; + if (!loginPassword) { + await this.recordAutoLoginSuspended({ + accountId, + container, + offlineReason: quickState.offlineReason, + reason: 'recovery_suspended', + suspendedReason: 'login-password-missing', + }); + return { success: false }; + } + await this.recordAutoLoginEvent({ + accountId, + container, + eventKind: 'password_attempt', + eventStatus: 'pending', + evidence: { containerName: runtime.name }, + }); const passwordEnv = await this.ensureRuntimeLoginEnv(runtime, { loginPassword, selfId, }); - if (!passwordEnv.ok) return { success: false }; + if (!passwordEnv.ok) { + await this.recordAutoLoginEvent({ + accountId, + container, + eventKind: 'password_attempt', + eventStatus: 'failed', + evidence: { + containerName: runtime.name, + reason: 'login-env-prepare-failed', + }, + }); + await this.recordAutoLoginSuspended({ + accountId, + container, + reason: 'recovery_suspended', + suspendedReason: 'login-env-prepare-failed', + }); + return { success: false }; + } const passwordState = await this.restartAndDetectLoginState(runtime); if (passwordState.state !== 'online') { @@ -286,6 +374,26 @@ export class QqbotNapcatContainerService { clearLoginPassword: true, selfId, }); + await this.recordAutoLoginEvent({ + accountId, + container, + eventKind: 'password_attempt', + eventStatus: 'failed', + evidence: { + cleanupOk: cleaned.ok, + containerName: runtime.name, + offlineReason: passwordState.offlineReason, + }, + }); + await this.recordAutoLoginSuspended({ + accountId, + container, + offlineReason: passwordState.offlineReason, + reason: + this.toAutoLoginSuspendedReason(passwordState.offlineReason) || + 'recovery_suspended', + suspendedReason: 'password-auto-login-failed', + }); return cleaned.ok ? { success: false } : { cleanupFailed: true, success: false }; @@ -295,11 +403,113 @@ export class QqbotNapcatContainerService { clearLoginPassword: true, selfId, }); - if (!cleaned.ok) return { cleanupFailed: true, success: false }; + if (!cleaned.ok) { + await this.recordAutoLoginEvent({ + accountId, + container, + eventKind: 'password_attempt', + eventStatus: 'failed', + evidence: { + containerName: runtime.name, + reason: 'login-env-cleanup-failed', + }, + }); + return { cleanupFailed: true, success: false }; + } + await this.recordAutoLoginEvent({ + accountId, + container, + eventKind: 'password_attempt', + eventStatus: 'success', + evidence: { containerName: runtime.name }, + }); return { method: 'password', success: true }; } + /** + * Chooses the account id used by watchdog login-event records. + * @param inputContainer - Container row passed by account runtime, usually carrying account ownership. + * @param runtimeContainer - Container row reloaded with WebUI token for runtime actions. + * @returns Account id when it can be derived without looking up additional state. + */ + private getAutoLoginAccountId( + inputContainer: NapcatContainer, + runtimeContainer: NapcatContainer, + ) { + return this.toolsService.toTrimmedString( + inputContainer.accountId || runtimeContainer.accountId, + ); + } + + /** + * Records a watchdog quick/password attempt when the owning account is known. + * @param input - Attempt kind, status, container, and non-secret runtime evidence. + */ + private async recordAutoLoginEvent(input: { + accountId: string; + container: NapcatContainer; + eventKind: 'password_attempt' | 'quick_attempt'; + eventStatus: 'failed' | 'pending' | 'success'; + evidence?: Record; + }) { + if (!this.loginEventService || !input.accountId) return; + await this.loginEventService.record({ + accountId: input.accountId, + containerId: input.container.id, + eventKind: input.eventKind, + eventSource: 'watchdog', + eventStatus: input.eventStatus, + evidence: input.evidence, + }); + } + + /** + * Records that watchdog auto-login must stop and wait for a human-visible login path. + * @param input - Account/container plus the reason automation cannot safely continue. + */ + private async recordAutoLoginSuspended(input: { + accountId: string; + container: NapcatContainer; + offlineReason?: null | string; + reason: NapcatLoginEventKind; + suspendedReason?: string; + }) { + if (!this.loginEventService || !input.accountId) return; + await this.loginEventService.recordSuspended({ + accountId: input.accountId, + containerId: input.container.id, + evidence: { + offlineReason: input.offlineReason || undefined, + reason: input.suspendedReason || input.reason, + }, + reason: input.reason, + source: 'watchdog', + }); + } + + /** + * Maps runtime login failure text to the first manual login path watchdog must not enter. + * @param offlineReason - NapCat log/status text produced after quick or password login attempt. + * @returns Blocking event kind when the next step requires captcha, new-device verification, or manual QR. + */ + private toAutoLoginSuspendedReason( + offlineReason?: null | string, + ): NapcatLoginEventKind | null { + const reason = this.toolsService.toTrimmedString(offlineReason); + if (!reason) return null; + if (/captcha|proofWater|验证码|安全验证/i.test(reason)) { + return 'captcha_required'; + } + if (/new.?device|新设备|设备验证/i.test(reason)) { + return 'new_device_required'; + } + if (/qr.?code|二维码/i.test(reason)) { + return 'manual_qr_created'; + } + return null; + } + /** * 执行 NapCat 登录运行态流程。 * @param name - 名称文本;驱动 `this.runProcess()` 的 NapCat步骤。 diff --git a/src/modules/qqbot/napcat/qqbot-napcat.module.ts b/src/modules/qqbot/napcat/qqbot-napcat.module.ts index 0a0aad3..4e5e4fd 100644 --- a/src/modules/qqbot/napcat/qqbot-napcat.module.ts +++ b/src/modules/qqbot/napcat/qqbot-napcat.module.ts @@ -8,6 +8,7 @@ import { QqbotNapcatAccountRuntimeService } from './application/account-runtime/ import { QqbotNapcatLoginService } from './application/login/qqbot-napcat-login.service'; import { QqbotNapcatWatchdogService } from './application/login/qqbot-napcat-watchdog.service'; import { NapcatConfigWriterService } from './application/runtime/napcat-config-writer.service'; +import { NapcatLoginEventService } from './application/runtime/napcat-login-event.service'; import { NapcatRuntimeProfileInspectorService } from './application/runtime/napcat-runtime-profile-inspector.service'; import { NapcatRuntimeProfileService } from './application/runtime/napcat-runtime-profile.service'; import { QqbotNapcatLoginController } from './contract/qqbot-napcat-login.controller'; @@ -28,6 +29,7 @@ export const QQBOT_NAPCAT_PROVIDERS = [ NapcatConfigWriterService, NapcatDeviceIdentityService, NapcatLoginStateStoreService, + NapcatLoginEventService, NapcatRuntimeProfileInspectorService, NapcatRuntimeProfileService, QqbotNapcatAccountRuntimeService, @@ -42,6 +44,7 @@ export const QQBOT_NAPCAT_PROVIDERS = [ export const QQBOT_NAPCAT_EXPORTS = [ NapcatDeviceIdentityService, + NapcatLoginEventService, NapcatLoginStateStoreService, QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT, QqbotNapcatLoginService, diff --git a/test/modules/qqbot/napcat/login-event-watchdog.spec.ts b/test/modules/qqbot/napcat/login-event-watchdog.spec.ts new file mode 100644 index 0000000..3a70b89 --- /dev/null +++ b/test/modules/qqbot/napcat/login-event-watchdog.spec.ts @@ -0,0 +1,279 @@ +import { ToolsService } from '@/common'; +import { QqbotNapcatAccountRuntimeService } from '../../../../src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service'; +import { NapcatLoginEventService } from '../../../../src/modules/qqbot/napcat/application/runtime/napcat-login-event.service'; +import { QqbotNapcatContainerService } from '../../../../src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service'; + +/** + * Creates a TypeORM-like repository mock for login-event service tests. + * @returns Repository mock that captures created and saved login-event payloads. + */ +const createRepository = () => ({ + create: jest.fn((input) => input), + findOne: jest.fn(), + save: jest.fn(async (input) => input), + update: jest.fn(), +}); + +/** + * Creates the chained query builder shape used by account runtime joins. + * @param rows - Rows returned by `getMany()` for the current query. + * @returns Query builder mock with chainable filter and ordering methods. + */ +const createManyQueryBuilder = (rows: T[]) => ({ + addOrderBy: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(rows), + orderBy: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), +}); + +describe('NapCat login event and recovery lease', () => { + it('records quick and password attempts as login events, not send budgets', async () => { + const repository = createRepository(); + const service = new NapcatLoginEventService(repository as any); + + await service.record({ + accountId: 'account-1', + containerId: 'container-1', + eventKind: 'quick_attempt', + eventSource: 'watchdog', + eventStatus: 'success', + evidence: { method: 'quick' }, + }); + await service.record({ + accountId: 'account-1', + containerId: 'container-1', + eventKind: 'password_attempt', + eventSource: 'watchdog', + eventStatus: 'failed', + evidence: { method: 'password' }, + }); + + expect(repository.save).toHaveBeenCalledTimes(2); + expect(JSON.stringify(repository.save.mock.calls)).not.toMatch( + /daily|hour|quota|budget/i, + ); + }); + + it('suspends automatic recovery after captcha, new-device, or manual QR is required', async () => { + const repository = createRepository(); + const service = new NapcatLoginEventService(repository as any); + + await service.recordSuspended({ + accountId: 'account-1', + containerId: 'container-1', + evidence: { reason: 'new-device-required' }, + reason: 'new_device_required', + source: 'watchdog', + }); + + expect(repository.save).toHaveBeenCalledWith( + expect.objectContaining({ + eventKind: 'recovery_suspended', + eventSource: 'watchdog', + eventStatus: 'blocked', + }), + ); + }); + + it('blocks automatic recovery until a later successful connection exists', async () => { + const repository = createRepository(); + repository.findOne.mockResolvedValue({ + createTime: new Date('2026-06-18T08:00:00.000Z'), + eventKind: 'new_device_required', + }); + const service = new NapcatLoginEventService(repository as any); + + await expect( + service.canAttemptAutomaticRecovery({ + accountId: 'account-1', + containerId: 'container-1', + resetAfter: new Date('2026-06-18T07:59:00.000Z'), + }), + ).resolves.toEqual({ + allowed: false, + reason: 'new_device_required', + }); + await expect( + service.canAttemptAutomaticRecovery({ + accountId: 'account-1', + containerId: 'container-1', + resetAfter: new Date('2026-06-18T08:01:00.000Z'), + }), + ).resolves.toEqual({ allowed: true }); + }); +}); + +describe('NapCat watchdog auto-login boundaries', () => { + it('does not call container auto-login when recovery is suspended', async () => { + const accountNapcatRepository = { + createQueryBuilder: jest.fn(() => + createManyQueryBuilder([ + { + accountId: 'account-1', + bindStatus: 'bound', + containerId: 'container-1', + isPrimary: true, + }, + ]), + ), + }; + const containerRepository = { + createQueryBuilder: jest.fn(() => + createManyQueryBuilder([ + { + id: 'container-1', + lastCheckedAt: new Date(), + lastError: '账号状态变更为离线', + name: 'kt-qqbot-napcat-10001', + status: 'running', + }, + ]), + ), + }; + const containerService = { + detectRuntimeOffline: jest.fn(), + tryAutoLogin: jest.fn(), + }; + const loginEventService = { + canAttemptAutomaticRecovery: jest.fn().mockResolvedValue({ + allowed: false, + reason: 'recovery_suspended', + }), + recordSuspended: jest.fn(), + }; + const service = new QqbotNapcatAccountRuntimeService( + accountNapcatRepository as any, + containerRepository as any, + containerService as any, + new ToolsService(), + undefined, + loginEventService as any, + ); + + await service.appendRuntime( + [ + { + connectStatus: 'online', + id: 'account-1', + lastConnectedAt: new Date('2026-06-18T00:00:00.000Z'), + selfId: '10001', + } as any, + ], + { autoLogin: true }, + { + clearQqLoginError: jest.fn(), + getLoginPassword: jest.fn(), + markOnline: jest.fn(), + markQqLoginOffline: jest.fn(), + publishOfflineNotice: jest.fn(), + }, + ); + + expect(containerService.tryAutoLogin).not.toHaveBeenCalled(); + expect(loginEventService.recordSuspended).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: 'account-1', + containerId: 'container-1', + reason: 'recovery_suspended', + source: 'watchdog', + }), + ); + }); + + it('does not create a manual QR session from watchdog auto-login', async () => { + const service = new QqbotNapcatContainerService( + { get: jest.fn().mockReturnValue('') } as any, + {} as any, + {} as any, + new ToolsService(), + ) as any; + service.getManagedMode = jest.fn().mockReturnValue('ssh'); + service.findContainerWithToken = jest.fn().mockResolvedValue({ + baseUrl: 'http://127.0.0.1:6100/', + id: 'container-1', + name: 'kt-qqbot-napcat-10001', + }); + service.ensureRuntimeLoginEnv = jest.fn().mockResolvedValue({ + changed: false, + ok: true, + }); + service.restartAndDetectLoginState = jest + .fn() + .mockResolvedValue({ offlineReason: '历史会话失效', state: 'offline' }); + + const result = await service.tryAutoLogin( + { id: 'container-1', name: 'kt-qqbot-napcat-10001' }, + { selfId: '10001' }, + ); + + expect(result.success).toBe(false); + expect(JSON.stringify(service.runProcess?.mock?.calls || [])).not.toContain( + 'qrcode', + ); + }); + + it('records password auto-login failure as suspended recovery', async () => { + const loginEventService = { + record: jest.fn(), + recordSuspended: jest.fn(), + }; + const service = new QqbotNapcatContainerService( + { get: jest.fn().mockReturnValue('') } as any, + {} as any, + {} as any, + new ToolsService(), + undefined, + undefined, + undefined, + loginEventService as any, + ) as any; + service.getManagedMode = jest.fn().mockReturnValue('ssh'); + service.findContainerWithToken = jest.fn().mockResolvedValue({ + accountId: 'account-1', + baseUrl: 'http://127.0.0.1:6100/', + id: 'container-1', + name: 'kt-qqbot-napcat-10001', + }); + service.ensureRuntimeLoginEnv = jest + .fn() + .mockResolvedValueOnce({ changed: false, ok: true }) + .mockResolvedValueOnce({ changed: true, ok: true }) + .mockResolvedValueOnce({ changed: true, ok: true }); + service.restartAndDetectLoginState = jest + .fn() + .mockResolvedValueOnce({ + offlineReason: '历史会话失效', + state: 'offline', + }) + .mockResolvedValueOnce({ + offlineReason: '密码登录失败', + state: 'offline', + }); + + const result = await service.tryAutoLogin( + { accountId: 'account-1', id: 'container-1' } as any, + { + loginPassword: 'qq-password', + selfId: '10001', + }, + ); + + expect(result).toEqual({ success: false }); + expect(loginEventService.record).toHaveBeenCalledWith( + expect.objectContaining({ + eventKind: 'password_attempt', + eventStatus: 'failed', + }), + ); + expect(loginEventService.recordSuspended).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: 'account-1', + containerId: 'container-1', + reason: 'recovery_suspended', + source: 'watchdog', + }), + ); + }); +});