diff --git a/src/modules/qqbot/core/application/command/qqbot-command-engine.service.ts b/src/modules/qqbot/core/application/command/qqbot-command-engine.service.ts index 9124c30..525d84d 100644 --- a/src/modules/qqbot/core/application/command/qqbot-command-engine.service.ts +++ b/src/modules/qqbot/core/application/command/qqbot-command-engine.service.ts @@ -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' + ); + } } diff --git a/src/modules/qqbot/core/application/rule/qqbot-rule-engine.service.ts b/src/modules/qqbot/core/application/rule/qqbot-rule-engine.service.ts index 18feffb..6ca16d6 100644 --- a/src/modules/qqbot/core/application/rule/qqbot-rule-engine.service.ts +++ b/src/modules/qqbot/core/application/rule/qqbot-rule-engine.service.ts @@ -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' + ); + } } diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-session-behavior.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-session-behavior.service.ts new file mode 100644 index 0000000..e47566b --- /dev/null +++ b/src/modules/qqbot/napcat/application/runtime/napcat-session-behavior.service.ts @@ -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}`, + }; + } +} diff --git a/src/modules/qqbot/napcat/index.ts b/src/modules/qqbot/napcat/index.ts index 4297996..190cd0d 100644 --- a/src/modules/qqbot/napcat/index.ts +++ b/src/modules/qqbot/napcat/index.ts @@ -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'; diff --git a/src/modules/qqbot/napcat/qqbot-napcat.module.ts b/src/modules/qqbot/napcat/qqbot-napcat.module.ts index 4e5e4fd..57309fd 100644 --- a/src/modules/qqbot/napcat/qqbot-napcat.module.ts +++ b/src/modules/qqbot/napcat/qqbot-napcat.module.ts @@ -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, ]; diff --git a/test/modules/qqbot/napcat/session-behavior-profile.spec.ts b/test/modules/qqbot/napcat/session-behavior-profile.spec.ts new file mode 100644 index 0000000..314f2fc --- /dev/null +++ b/test/modules/qqbot/napcat/session-behavior-profile.spec.ts @@ -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', + }); + }); +});