feat: 稳定NapCat自动恢复登录事件

This commit is contained in:
sunlei 2026-06-18 12:39:46 +08:00
parent 6738dbd3df
commit 7f4bff4a1a
6 changed files with 661 additions and 5 deletions

View File

@ -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,

View File

@ -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<NapcatLoginEvent>,
) {}
/**
* 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<string, unknown>;
}) {
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<string, unknown>;
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<NapcatAutomaticRecoveryGate> {
const where: Record<string, unknown> = {
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<string, unknown>) {
if (!evidence) return false;
return Boolean(evidence.manualResetAt || evidence.manualReset);
}
}

View File

@ -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';

View File

@ -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<string, unknown>;
}) {
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步骤

View File

@ -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,

View File

@ -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 = <T>(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',
}),
);
});
});