feat: 稳定NapCat自动恢复登录事件
This commit is contained in:
parent
6738dbd3df
commit
7f4bff4a1a
@ -11,6 +11,7 @@ import type {
|
|||||||
QqbotAccountListItem,
|
QqbotAccountListItem,
|
||||||
QqbotNapcatRuntimeStatusSnapshot,
|
QqbotNapcatRuntimeStatusSnapshot,
|
||||||
} from '@/modules/qqbot/core/contract/qqbot.types';
|
} 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 { NapcatRuntimeProfileInspectorService } from '../runtime/napcat-runtime-profile-inspector.service';
|
||||||
import { QqbotNapcatContainerService } from '../../infrastructure/integration/container/qqbot-napcat-container.service';
|
import { QqbotNapcatContainerService } from '../../infrastructure/integration/container/qqbot-napcat-container.service';
|
||||||
import { NapcatAccountBinding } from '../../infrastructure/persistence/napcat-account-binding.entity';
|
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 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 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 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(
|
constructor(
|
||||||
@InjectRepository(NapcatAccountBinding)
|
@InjectRepository(NapcatAccountBinding)
|
||||||
@ -38,6 +40,7 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti
|
|||||||
private readonly napcatContainerService: QqbotNapcatContainerService,
|
private readonly napcatContainerService: QqbotNapcatContainerService,
|
||||||
private readonly toolsService: ToolsService,
|
private readonly toolsService: ToolsService,
|
||||||
private readonly runtimeProfileInspector?: NapcatRuntimeProfileInspectorService,
|
private readonly runtimeProfileInspector?: NapcatRuntimeProfileInspectorService,
|
||||||
|
private readonly loginEventService?: NapcatLoginEventService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -371,6 +374,25 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti
|
|||||||
actions: QqbotAccountNapcatRuntimeActions,
|
actions: QqbotAccountNapcatRuntimeActions,
|
||||||
) {
|
) {
|
||||||
try {
|
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, {
|
const result = await this.napcatContainerService.tryAutoLogin(container, {
|
||||||
loginPassword: actions.getLoginPassword(account),
|
loginPassword: actions.getLoginPassword(account),
|
||||||
selfId: account.selfId,
|
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 './domain/login/napcat-login-state-machine';
|
||||||
export * from './application/login/qqbot-napcat-login.service';
|
export * from './application/login/qqbot-napcat-login.service';
|
||||||
export * from './application/login/qqbot-napcat-watchdog.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 './application/runtime/napcat-runtime-profile-inspector.service';
|
||||||
export * from './infrastructure/persistence';
|
export * from './infrastructure/persistence';
|
||||||
export * from './infrastructure/integration/container/qqbot-napcat-container.service';
|
export * from './infrastructure/integration/container/qqbot-napcat-container.service';
|
||||||
|
|||||||
@ -2,12 +2,13 @@ import * as http from 'http';
|
|||||||
import * as https from 'https';
|
import * as https from 'https';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { createHash, randomBytes, randomUUID } from 'crypto';
|
import { createHash, randomBytes, randomUUID } from 'crypto';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Optional } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { throwVbenError, ToolsService } from '@/common';
|
import { throwVbenError, ToolsService } from '@/common';
|
||||||
import { NapcatConfigWriterService } from '../../../application/runtime/napcat-config-writer.service';
|
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 { NapcatRuntimeProfileService } from '../../../application/runtime/napcat-runtime-profile.service';
|
||||||
import type { NapcatConfigFile } from '../../../domain/runtime/napcat-profile.types';
|
import type { NapcatConfigFile } from '../../../domain/runtime/napcat-profile.types';
|
||||||
import {
|
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 { QqbotAccount } from '@/modules/qqbot/core/infrastructure/persistence/account/qqbot-account.entity';
|
||||||
import { NapcatAccountBinding } from '../../persistence/napcat-account-binding.entity';
|
import { NapcatAccountBinding } from '../../persistence/napcat-account-binding.entity';
|
||||||
import { NapcatContainer } from '../../persistence/napcat-container.entity';
|
import { NapcatContainer } from '../../persistence/napcat-container.entity';
|
||||||
|
import type { NapcatLoginEventKind } from '../../persistence/napcat-login-event.entity';
|
||||||
import type {
|
import type {
|
||||||
NapcatApiResponse,
|
NapcatApiResponse,
|
||||||
NapcatCredential,
|
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 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 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 configWriterService - Config writer used to generate NapCat and OneBot config files.
|
||||||
|
* @param loginEventService - Optional login-event recorder used by watchdog auto-login attempts.
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@ -77,6 +80,8 @@ export class QqbotNapcatContainerService {
|
|||||||
private readonly deviceIdentityService?: NapcatDeviceIdentityService,
|
private readonly deviceIdentityService?: NapcatDeviceIdentityService,
|
||||||
runtimeProfileService?: NapcatRuntimeProfileService,
|
runtimeProfileService?: NapcatRuntimeProfileService,
|
||||||
configWriterService?: NapcatConfigWriterService,
|
configWriterService?: NapcatConfigWriterService,
|
||||||
|
@Optional()
|
||||||
|
private readonly loginEventService?: NapcatLoginEventService,
|
||||||
) {
|
) {
|
||||||
this.runtimeProfileService =
|
this.runtimeProfileService =
|
||||||
runtimeProfileService || new NapcatRuntimeProfileService(configService);
|
runtimeProfileService || new NapcatRuntimeProfileService(configService);
|
||||||
@ -259,26 +264,109 @@ export class QqbotNapcatContainerService {
|
|||||||
const runtimeContainer = await this.findContainerWithToken(container.id);
|
const runtimeContainer = await this.findContainerWithToken(container.id);
|
||||||
if (!runtimeContainer?.name) return { success: false };
|
if (!runtimeContainer?.name) return { success: false };
|
||||||
const runtime = this.toRuntime(runtimeContainer);
|
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, {
|
const quickCleanup = await this.ensureRuntimeLoginEnv(runtime, {
|
||||||
clearLoginPassword: true,
|
clearLoginPassword: true,
|
||||||
selfId,
|
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);
|
const quickState = await this.restartAndDetectLoginState(runtime);
|
||||||
if (quickState.state === 'online') {
|
if (quickState.state === 'online') {
|
||||||
|
await this.recordAutoLoginEvent({
|
||||||
|
accountId,
|
||||||
|
container,
|
||||||
|
eventKind: 'quick_attempt',
|
||||||
|
eventStatus: 'success',
|
||||||
|
evidence: { containerName: runtime.name },
|
||||||
|
});
|
||||||
return { method: 'quick', success: true };
|
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);
|
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, {
|
const passwordEnv = await this.ensureRuntimeLoginEnv(runtime, {
|
||||||
loginPassword,
|
loginPassword,
|
||||||
selfId,
|
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);
|
const passwordState = await this.restartAndDetectLoginState(runtime);
|
||||||
if (passwordState.state !== 'online') {
|
if (passwordState.state !== 'online') {
|
||||||
@ -286,6 +374,26 @@ export class QqbotNapcatContainerService {
|
|||||||
clearLoginPassword: true,
|
clearLoginPassword: true,
|
||||||
selfId,
|
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
|
return cleaned.ok
|
||||||
? { success: false }
|
? { success: false }
|
||||||
: { cleanupFailed: true, success: false };
|
: { cleanupFailed: true, success: false };
|
||||||
@ -295,11 +403,113 @@ export class QqbotNapcatContainerService {
|
|||||||
clearLoginPassword: true,
|
clearLoginPassword: true,
|
||||||
selfId,
|
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 };
|
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 登录运行态流程。
|
* 执行 NapCat 登录运行态流程。
|
||||||
* @param name - 名称文本;驱动 `this.runProcess()` 的 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 { QqbotNapcatLoginService } from './application/login/qqbot-napcat-login.service';
|
||||||
import { QqbotNapcatWatchdogService } from './application/login/qqbot-napcat-watchdog.service';
|
import { QqbotNapcatWatchdogService } from './application/login/qqbot-napcat-watchdog.service';
|
||||||
import { NapcatConfigWriterService } from './application/runtime/napcat-config-writer.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 { NapcatRuntimeProfileInspectorService } from './application/runtime/napcat-runtime-profile-inspector.service';
|
||||||
import { NapcatRuntimeProfileService } from './application/runtime/napcat-runtime-profile.service';
|
import { NapcatRuntimeProfileService } from './application/runtime/napcat-runtime-profile.service';
|
||||||
import { QqbotNapcatLoginController } from './contract/qqbot-napcat-login.controller';
|
import { QqbotNapcatLoginController } from './contract/qqbot-napcat-login.controller';
|
||||||
@ -28,6 +29,7 @@ export const QQBOT_NAPCAT_PROVIDERS = [
|
|||||||
NapcatConfigWriterService,
|
NapcatConfigWriterService,
|
||||||
NapcatDeviceIdentityService,
|
NapcatDeviceIdentityService,
|
||||||
NapcatLoginStateStoreService,
|
NapcatLoginStateStoreService,
|
||||||
|
NapcatLoginEventService,
|
||||||
NapcatRuntimeProfileInspectorService,
|
NapcatRuntimeProfileInspectorService,
|
||||||
NapcatRuntimeProfileService,
|
NapcatRuntimeProfileService,
|
||||||
QqbotNapcatAccountRuntimeService,
|
QqbotNapcatAccountRuntimeService,
|
||||||
@ -42,6 +44,7 @@ export const QQBOT_NAPCAT_PROVIDERS = [
|
|||||||
|
|
||||||
export const QQBOT_NAPCAT_EXPORTS = [
|
export const QQBOT_NAPCAT_EXPORTS = [
|
||||||
NapcatDeviceIdentityService,
|
NapcatDeviceIdentityService,
|
||||||
|
NapcatLoginEventService,
|
||||||
NapcatLoginStateStoreService,
|
NapcatLoginStateStoreService,
|
||||||
QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT,
|
QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT,
|
||||||
QqbotNapcatLoginService,
|
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