feat: 增加NapCat会话行为Profile

This commit is contained in:
sunlei 2026-06-18 12:45:04 +08:00
parent 7f4bff4a1a
commit 170aefcc3e
6 changed files with 296 additions and 2 deletions

View File

@ -1,5 +1,9 @@
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
import { ToolsService } from '@/common'; import { ToolsService } from '@/common';
import {
NapcatSessionBehaviorService,
type NapcatAutoCapabilityStage,
} from '@/modules/qqbot/napcat/application/runtime/napcat-session-behavior.service';
import { import {
QQBOT_PLUGIN_EXECUTION_PORT, QQBOT_PLUGIN_EXECUTION_PORT,
type QqbotPluginExecutionPort, type QqbotPluginExecutionPort,
@ -24,6 +28,7 @@ export class QqbotCommandEngineService {
* @param replyTemplate - replyTemplate constructor * @param replyTemplate - replyTemplate constructor
* @param sendService - sendService constructor * @param sendService - sendService constructor
* @param toolsService - ToolsService constructor * @param toolsService - ToolsService constructor
* @param sessionBehaviorService - Optional NapCat staged behavior gate for command replies.
*/ */
constructor( constructor(
private readonly commandParser: QqbotCommandParserService, private readonly commandParser: QqbotCommandParserService,
@ -33,6 +38,8 @@ export class QqbotCommandEngineService {
private readonly replyTemplate: QqbotReplyTemplateService, private readonly replyTemplate: QqbotReplyTemplateService,
private readonly sendService: QqbotSendService, private readonly sendService: QqbotSendService,
private readonly toolsService: ToolsService, private readonly toolsService: ToolsService,
@Optional()
private readonly sessionBehaviorService?: NapcatSessionBehaviorService,
) {} ) {}
/** /**
@ -45,6 +52,16 @@ export class QqbotCommandEngineService {
const matched = await this.commandParser.match(command, message); const matched = await this.commandParser.match(command, message);
if (!matched) continue; if (!matched) continue;
if (this.commandService.isInCooldown(command)) return true; if (this.commandService.isInCooldown(command)) return true;
const behaviorDecision = this.sessionBehaviorService?.decideAutomation({
automationKind: 'command_reply',
stage: this.getBehaviorStage(message),
}) || { allowed: true };
if (!behaviorDecision.allowed) {
this.logger.warn(
`QQBot 命令回复已按 NapCat 会话行为阶段跳过: ${behaviorDecision.reason}`,
);
return true;
}
await this.commandService.markHit(command); await this.commandService.markHit(command);
const input = this.mergeInput(command, matched.input); const input = this.mergeInput(command, matched.input);
@ -289,4 +306,32 @@ export class QqbotCommandEngineService {
userId, userId,
}; };
} }
/**
* Reads a behavior stage hint from raw event metadata when NapCat runtime supplied one.
* @param message - Normalized OneBot message that may carry staged behavior metadata.
* @returns Valid behavior stage or `undefined` when command handling should use default behavior.
*/
private getBehaviorStage(
message: QqbotNormalizedMessage,
): NapcatAutoCapabilityStage | undefined {
const stage =
message.rawEvent.napcatBehaviorStage ||
message.rawEvent.napcat_behavior_stage;
return this.isBehaviorStage(stage) ? stage : undefined;
}
/**
* Validates raw behavior-stage metadata before passing it to NapCat behavior decisions.
* @param stage - Raw event metadata value.
* @returns Whether the value is a supported NapCat automation capability stage.
*/
private isBehaviorStage(stage: unknown): stage is NapcatAutoCapabilityStage {
return (
stage === 'automation' ||
stage === 'image_and_large_message' ||
stage === 'low_risk_text' ||
stage === 'manual_command'
);
}
} }

View File

@ -1,5 +1,10 @@
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
import { ToolsService } from '@/common'; import { ToolsService } from '@/common';
import {
NapcatSessionBehaviorService,
type NapcatAutomationKind,
type NapcatAutoCapabilityStage,
} from '@/modules/qqbot/napcat/application/runtime/napcat-session-behavior.service';
import { import {
QQBOT_PLUGIN_EXECUTION_PORT, QQBOT_PLUGIN_EXECUTION_PORT,
type QqbotPluginExecutionPort, type QqbotPluginExecutionPort,
@ -22,6 +27,7 @@ export class QqbotRuleEngineService {
* @param ruleService - ruleService constructor * @param ruleService - ruleService constructor
* @param sendService - sendService constructor * @param sendService - sendService constructor
* @param toolsService - ToolsService constructor * @param toolsService - ToolsService constructor
* @param sessionBehaviorService - Optional staged automation gate supplied by NapCat runtime profile.
*/ */
constructor( constructor(
private readonly commandEngineService: QqbotCommandEngineService, private readonly commandEngineService: QqbotCommandEngineService,
@ -31,6 +37,8 @@ export class QqbotRuleEngineService {
private readonly ruleService: QqbotRuleService, private readonly ruleService: QqbotRuleService,
private readonly sendService: QqbotSendService, private readonly sendService: QqbotSendService,
private readonly toolsService: ToolsService, private readonly toolsService: ToolsService,
@Optional()
private readonly sessionBehaviorService?: NapcatSessionBehaviorService,
) {} ) {}
/** /**
@ -47,6 +55,14 @@ export class QqbotRuleEngineService {
if (this.ruleService.isInCooldown(rule)) continue; if (this.ruleService.isInCooldown(rule)) continue;
if (!this.ruleService.isMatched(rule, message)) continue; if (!this.ruleService.isMatched(rule, message)) continue;
const ruleDecision = this.decideAutomation('rule_reply', message);
if (!ruleDecision.allowed) {
this.logger.warn(
`QQBot 自动回复已按 NapCat 会话行为阶段跳过: ${ruleDecision.reason}`,
);
return;
}
await this.ruleService.markHit(rule); await this.ruleService.markHit(rule);
try { try {
await this.sendService.sendText({ await this.sendService.sendText({
@ -66,9 +82,63 @@ export class QqbotRuleEngineService {
return; return;
} }
const eventDecision = this.decideAutomation('event_plugin', message);
if (!eventDecision.allowed) {
this.logger.warn(
`QQBot 事件插件已按 NapCat 会话行为阶段跳过: ${eventDecision.reason}`,
);
return;
}
await this.pluginExecution.dispatchEvent({ await this.pluginExecution.dispatchEvent({
eventKey: 'message', eventKey: 'message',
message, message,
}); });
} }
/**
* Applies optional NapCat behavior-stage gating to automatic rule and event-plugin paths.
* @param automationKind - Automatic behavior category being considered for the current inbound message.
* @param message - Normalized OneBot message whose raw event may carry a staged behavior profile hint.
* @returns Allow/skip decision; missing NapCat profile data keeps current behavior unchanged.
*/
private decideAutomation(
automationKind: NapcatAutomationKind,
message: QqbotNormalizedMessage,
) {
return (
this.sessionBehaviorService?.decideAutomation({
automationKind,
stage: this.getBehaviorStage(message),
}) || { allowed: true }
);
}
/**
* Reads a behavior stage hint from raw event metadata when the runtime profile has provided one.
* @param message - Normalized OneBot message with raw event metadata from the connection boundary.
* @returns Valid behavior stage or `undefined` when no runtime profile hint is present.
*/
private getBehaviorStage(
message: QqbotNormalizedMessage,
): NapcatAutoCapabilityStage | undefined {
const stage =
message.rawEvent.napcatBehaviorStage ||
message.rawEvent.napcat_behavior_stage;
return this.isBehaviorStage(stage) ? stage : undefined;
}
/**
* Validates raw stage metadata before passing it to the behavior decision service.
* @param stage - Raw event metadata that may name a NapCat automation capability stage.
* @returns Whether the value belongs to the NapCat behavior-stage vocabulary.
*/
private isBehaviorStage(stage: unknown): stage is NapcatAutoCapabilityStage {
return (
stage === 'automation' ||
stage === 'image_and_large_message' ||
stage === 'low_risk_text' ||
stage === 'manual_command'
);
}
} }

View File

@ -0,0 +1,100 @@
import { Injectable } from '@nestjs/common';
export type NapcatAutoCapabilityStage =
| 'automation'
| 'image_and_large_message'
| 'low_risk_text'
| 'manual_command';
export type NapcatAutomationKind =
| 'command_reply'
| 'event_plugin'
| 'rule_reply';
export type NapcatAutomationDecision = {
allowed: boolean;
reason?: string;
};
@Injectable()
export class NapcatSessionBehaviorService {
/**
* Creates the first behavior profile after account login or profile migration.
* @param accountId - Account id whose automation stage and housekeeping schedule are initialized.
* @param now - Current time supplied by caller for deterministic tests and evidence.
* @returns Default cold-start behavior profile without any send quota counters.
*/
createDefaultProfile(accountId: string, now = new Date()) {
return {
accountId,
autoCapabilityStage: 'manual_command' as const,
coldStartUntil: new Date(now.getTime() + 10 * 60_000),
housekeepingEnabled: true,
housekeepingIntervalMs: 30 * 60_000,
nextHousekeepingAt: new Date(now.getTime() + 30 * 60_000),
presenceEnabled: false,
presenceStrategy: 'disabled',
profileVersion: 'session-behavior-v1',
};
}
/**
* Converts housekeeping failure into an evidence-only action.
* @param input - Account and failure summary from a low-side-effect housekeeping call.
* @returns Decision that disables behavior extensions without resetting login, retrying password, recreating Docker, or refreshing QR.
*/
handleHousekeepingFailure(input: {
accountId: string;
failureMessage: string;
}) {
void input;
return {
disableBehaviorExtensions: true,
loginAction: 'none' as const,
recordEvidence: true,
};
}
/**
* Calculates the next automation recovery stage after the current observation window passes.
* @param stage - Current staged capability value persisted for the account.
* @returns Next capability stage, capped at full automation.
*/
nextCapabilityStage(
stage: NapcatAutoCapabilityStage,
): NapcatAutoCapabilityStage {
if (stage === 'manual_command') return 'low_risk_text';
if (stage === 'low_risk_text') return 'image_and_large_message';
return 'automation';
}
/**
* Decides whether a behavior extension may run for the current staged capability.
* @param input - Automation kind and optional stage; missing stage means no persisted behavior profile is active yet.
* @returns Allow/skip decision that never writes or checks hourly/daily send counters.
*/
decideAutomation(input: {
automationKind: NapcatAutomationKind;
manual?: boolean;
stage?: NapcatAutoCapabilityStage;
}): NapcatAutomationDecision {
if (input.manual || !input.stage) return { allowed: true };
if (input.automationKind === 'command_reply') return { allowed: true };
if (
input.automationKind === 'rule_reply' &&
input.stage !== 'manual_command'
) {
return { allowed: true };
}
if (
input.automationKind === 'event_plugin' &&
input.stage === 'automation'
) {
return { allowed: true };
}
return {
allowed: false,
reason: `session-behavior-stage:${input.stage}`,
};
}
}

View File

@ -17,5 +17,6 @@ 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-login-event.service';
export * from './application/runtime/napcat-runtime-profile-inspector.service'; export * from './application/runtime/napcat-runtime-profile-inspector.service';
export * from './application/runtime/napcat-session-behavior.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';

View File

@ -11,6 +11,7 @@ import { NapcatConfigWriterService } from './application/runtime/napcat-config-w
import { NapcatLoginEventService } from './application/runtime/napcat-login-event.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 { NapcatSessionBehaviorService } from './application/runtime/napcat-session-behavior.service';
import { QqbotNapcatLoginController } from './contract/qqbot-napcat-login.controller'; import { QqbotNapcatLoginController } from './contract/qqbot-napcat-login.controller';
import { QqbotNapcatRuntimeController } from './contract/qqbot-napcat-runtime.controller'; import { QqbotNapcatRuntimeController } from './contract/qqbot-napcat-runtime.controller';
import { QqbotNapcatContainerService } from './infrastructure/integration/container/qqbot-napcat-container.service'; import { QqbotNapcatContainerService } from './infrastructure/integration/container/qqbot-napcat-container.service';
@ -32,6 +33,7 @@ export const QQBOT_NAPCAT_PROVIDERS = [
NapcatLoginEventService, NapcatLoginEventService,
NapcatRuntimeProfileInspectorService, NapcatRuntimeProfileInspectorService,
NapcatRuntimeProfileService, NapcatRuntimeProfileService,
NapcatSessionBehaviorService,
QqbotNapcatAccountRuntimeService, QqbotNapcatAccountRuntimeService,
QqbotNapcatContainerService, QqbotNapcatContainerService,
QqbotNapcatLoginService, QqbotNapcatLoginService,
@ -46,6 +48,7 @@ export const QQBOT_NAPCAT_EXPORTS = [
NapcatDeviceIdentityService, NapcatDeviceIdentityService,
NapcatLoginEventService, NapcatLoginEventService,
NapcatLoginStateStoreService, NapcatLoginStateStoreService,
NapcatSessionBehaviorService,
QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT, QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT,
QqbotNapcatLoginService, QqbotNapcatLoginService,
]; ];

View File

@ -0,0 +1,75 @@
import { NapcatSessionBehaviorService } from '../../../../src/modules/qqbot/napcat/application/runtime/napcat-session-behavior.service';
describe('NapCat session behavior profile', () => {
it('keeps cold-start staged capability separate from send budgets', () => {
const service = new NapcatSessionBehaviorService();
const profile = service.createDefaultProfile(
'account-1',
new Date('2026-06-18T03:00:00.000Z'),
);
expect(profile).toMatchObject({
accountId: 'account-1',
autoCapabilityStage: 'manual_command',
housekeepingEnabled: true,
presenceEnabled: false,
});
expect(JSON.stringify(profile)).not.toMatch(/daily|hour|quota|budget/i);
});
it('does not trigger login reset, password retry, docker recreate, or QR refresh on housekeeping failure', () => {
const service = new NapcatSessionBehaviorService();
const decision = service.handleHousekeepingFailure({
accountId: 'account-1',
failureMessage: 'NapCat status API timeout',
});
expect(decision).toEqual({
disableBehaviorExtensions: true,
loginAction: 'none',
recordEvidence: true,
});
});
it('steps capability recovery from manual command to automation only after windows pass', () => {
const service = new NapcatSessionBehaviorService();
expect(service.nextCapabilityStage('manual_command')).toBe('low_risk_text');
expect(service.nextCapabilityStage('low_risk_text')).toBe(
'image_and_large_message',
);
expect(service.nextCapabilityStage('image_and_large_message')).toBe(
'automation',
);
expect(service.nextCapabilityStage('automation')).toBe('automation');
});
it('allows manual command paths while blocking cold-start event automation', () => {
const service = new NapcatSessionBehaviorService();
expect(
service.decideAutomation({
automationKind: 'command_reply',
stage: 'manual_command',
}),
).toEqual({ allowed: true });
expect(
service.decideAutomation({
automationKind: 'rule_reply',
stage: 'manual_command',
}),
).toEqual({
allowed: false,
reason: 'session-behavior-stage:manual_command',
});
expect(
service.decideAutomation({
automationKind: 'event_plugin',
stage: 'manual_command',
}),
).toEqual({
allowed: false,
reason: 'session-behavior-stage:manual_command',
});
});
});