feat: 增加NapCat会话行为Profile
This commit is contained in:
parent
7f4bff4a1a
commit
170aefcc3e
@ -1,5 +1,9 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
||||
import { ToolsService } from '@/common';
|
||||
import {
|
||||
NapcatSessionBehaviorService,
|
||||
type NapcatAutoCapabilityStage,
|
||||
} from '@/modules/qqbot/napcat/application/runtime/napcat-session-behavior.service';
|
||||
import {
|
||||
QQBOT_PLUGIN_EXECUTION_PORT,
|
||||
type QqbotPluginExecutionPort,
|
||||
@ -24,6 +28,7 @@ export class QqbotCommandEngineService {
|
||||
* @param replyTemplate - replyTemplate 输入;影响 constructor 的返回值。
|
||||
* @param sendService - sendService 服务依赖;影响 constructor 的返回值。
|
||||
* @param toolsService - ToolsService 依赖;影响 constructor 的返回值。
|
||||
* @param sessionBehaviorService - Optional NapCat staged behavior gate for command replies.
|
||||
*/
|
||||
constructor(
|
||||
private readonly commandParser: QqbotCommandParserService,
|
||||
@ -33,6 +38,8 @@ export class QqbotCommandEngineService {
|
||||
private readonly replyTemplate: QqbotReplyTemplateService,
|
||||
private readonly sendService: QqbotSendService,
|
||||
private readonly toolsService: ToolsService,
|
||||
@Optional()
|
||||
private readonly sessionBehaviorService?: NapcatSessionBehaviorService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -45,6 +52,16 @@ export class QqbotCommandEngineService {
|
||||
const matched = await this.commandParser.match(command, message);
|
||||
if (!matched) continue;
|
||||
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);
|
||||
const input = this.mergeInput(command, matched.input);
|
||||
@ -289,4 +306,32 @@ export class QqbotCommandEngineService {
|
||||
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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
||||
import { ToolsService } from '@/common';
|
||||
import {
|
||||
NapcatSessionBehaviorService,
|
||||
type NapcatAutomationKind,
|
||||
type NapcatAutoCapabilityStage,
|
||||
} from '@/modules/qqbot/napcat/application/runtime/napcat-session-behavior.service';
|
||||
import {
|
||||
QQBOT_PLUGIN_EXECUTION_PORT,
|
||||
type QqbotPluginExecutionPort,
|
||||
@ -22,6 +27,7 @@ export class QqbotRuleEngineService {
|
||||
* @param ruleService - ruleService 服务依赖;影响 constructor 的返回值。
|
||||
* @param sendService - sendService 服务依赖;影响 constructor 的返回值。
|
||||
* @param toolsService - ToolsService 依赖;影响 constructor 的返回值。
|
||||
* @param sessionBehaviorService - Optional staged automation gate supplied by NapCat runtime profile.
|
||||
*/
|
||||
constructor(
|
||||
private readonly commandEngineService: QqbotCommandEngineService,
|
||||
@ -31,6 +37,8 @@ export class QqbotRuleEngineService {
|
||||
private readonly ruleService: QqbotRuleService,
|
||||
private readonly sendService: QqbotSendService,
|
||||
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.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);
|
||||
try {
|
||||
await this.sendService.sendText({
|
||||
@ -66,9 +82,63 @@ export class QqbotRuleEngineService {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventDecision = this.decideAutomation('event_plugin', message);
|
||||
if (!eventDecision.allowed) {
|
||||
this.logger.warn(
|
||||
`QQBot 事件插件已按 NapCat 会话行为阶段跳过: ${eventDecision.reason}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.pluginExecution.dispatchEvent({
|
||||
eventKey: '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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -17,5 +17,6 @@ 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 './application/runtime/napcat-session-behavior.service';
|
||||
export * from './infrastructure/persistence';
|
||||
export * from './infrastructure/integration/container/qqbot-napcat-container.service';
|
||||
|
||||
@ -11,6 +11,7 @@ import { NapcatConfigWriterService } from './application/runtime/napcat-config-w
|
||||
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 { NapcatSessionBehaviorService } from './application/runtime/napcat-session-behavior.service';
|
||||
import { QqbotNapcatLoginController } from './contract/qqbot-napcat-login.controller';
|
||||
import { QqbotNapcatRuntimeController } from './contract/qqbot-napcat-runtime.controller';
|
||||
import { QqbotNapcatContainerService } from './infrastructure/integration/container/qqbot-napcat-container.service';
|
||||
@ -32,6 +33,7 @@ export const QQBOT_NAPCAT_PROVIDERS = [
|
||||
NapcatLoginEventService,
|
||||
NapcatRuntimeProfileInspectorService,
|
||||
NapcatRuntimeProfileService,
|
||||
NapcatSessionBehaviorService,
|
||||
QqbotNapcatAccountRuntimeService,
|
||||
QqbotNapcatContainerService,
|
||||
QqbotNapcatLoginService,
|
||||
@ -46,6 +48,7 @@ export const QQBOT_NAPCAT_EXPORTS = [
|
||||
NapcatDeviceIdentityService,
|
||||
NapcatLoginEventService,
|
||||
NapcatLoginStateStoreService,
|
||||
NapcatSessionBehaviorService,
|
||||
QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT,
|
||||
QqbotNapcatLoginService,
|
||||
];
|
||||
|
||||
75
test/modules/qqbot/napcat/session-behavior-profile.spec.ts
Normal file
75
test/modules/qqbot/napcat/session-behavior-profile.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user