feat: 稳定NapCat自动恢复登录事件
This commit is contained in:
parent
6738dbd3df
commit
7f4bff4a1a
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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步骤。
|
||||
|
||||
@ -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,
|
||||
|
||||
279
test/modules/qqbot/napcat/login-event-watchdog.spec.ts
Normal file
279
test/modules/qqbot/napcat/login-event-watchdog.spec.ts
Normal 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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user