Merge 7ccfd53b09 into 4583f88626
This commit is contained in:
commit
f21d90f49c
@ -1,11 +1,12 @@
|
||||
import { normalizeCommandBody } from "./commands-registry.js";
|
||||
|
||||
export type GroupActivationMode = "mention" | "always";
|
||||
export type GroupActivationMode = "mention" | "always" | "engagement";
|
||||
|
||||
export function normalizeGroupActivation(raw?: string | null): GroupActivationMode | undefined {
|
||||
const value = raw?.trim().toLowerCase();
|
||||
if (value === "mention") return "mention";
|
||||
if (value === "always") return "always";
|
||||
if (value === "engagement") return "engagement";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@ -111,7 +111,7 @@ export async function buildStatusReply(params: {
|
||||
resolvedElevatedLevel?: ElevatedLevel;
|
||||
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
|
||||
isGroup: boolean;
|
||||
defaultGroupActivation: () => "always" | "mention";
|
||||
defaultGroupActivation: () => "always" | "mention" | "engagement";
|
||||
mediaDecisions?: MediaUnderstandingDecision[];
|
||||
}): Promise<ReplyPayload | undefined> {
|
||||
const {
|
||||
|
||||
@ -39,7 +39,7 @@ export type HandleCommandsParams = {
|
||||
storePath?: string;
|
||||
sessionScope?: SessionScope;
|
||||
workspaceDir: string;
|
||||
defaultGroupActivation: () => "always" | "mention";
|
||||
defaultGroupActivation: () => "always" | "mention" | "engagement";
|
||||
resolvedThinkLevel?: ThinkLevel;
|
||||
resolvedVerboseLevel: VerboseLevel;
|
||||
resolvedReasoningLevel: ReasoningLevel;
|
||||
|
||||
@ -49,7 +49,9 @@ export function resolveGroupRequireMention(params: {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function defaultGroupActivation(requireMention: boolean): "always" | "mention" {
|
||||
export function defaultGroupActivation(
|
||||
requireMention: boolean,
|
||||
): "always" | "mention" | "engagement" {
|
||||
return requireMention === false ? "always" : "mention";
|
||||
}
|
||||
|
||||
@ -57,7 +59,7 @@ export function buildGroupIntro(params: {
|
||||
cfg: MoltbotConfig;
|
||||
sessionCtx: TemplateContext;
|
||||
sessionEntry?: SessionEntry;
|
||||
defaultActivation: "always" | "mention";
|
||||
defaultActivation: "always" | "mention" | "engagement";
|
||||
silentToken: string;
|
||||
}): string {
|
||||
const activation =
|
||||
|
||||
@ -59,7 +59,7 @@ type StatusArgs = {
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionKey?: string;
|
||||
sessionScope?: SessionScope;
|
||||
groupActivation?: "mention" | "always";
|
||||
groupActivation?: "mention" | "always" | "engagement";
|
||||
resolvedThink?: ThinkLevel;
|
||||
resolvedVerbose?: VerboseLevel;
|
||||
resolvedReasoning?: ReasoningLevel;
|
||||
|
||||
188
src/channels/engagement-debounce.ts
Normal file
188
src/channels/engagement-debounce.ts
Normal file
@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Debounce manager for engagement mode.
|
||||
*
|
||||
* Collects messages in bursts and processes them together after a quiet period.
|
||||
* This ensures the agent sees the full conversation context, not fragmented triggers.
|
||||
*/
|
||||
|
||||
import type { EngagementState } from "../config/engagement.js";
|
||||
|
||||
export type PendingMessage<T> = {
|
||||
message: T;
|
||||
timestamp: number;
|
||||
/** Whether this message triggered engagement (vs just collected for context) */
|
||||
triggered: boolean;
|
||||
/** Engagement state to persist after processing */
|
||||
nextState?: EngagementState;
|
||||
};
|
||||
|
||||
export type DebounceConfig = {
|
||||
/** Time to wait after last message before processing (ms) */
|
||||
debounceMs: number;
|
||||
/** Maximum time to wait before processing regardless of new messages (ms) */
|
||||
maxWaitMs: number;
|
||||
};
|
||||
|
||||
export type DebounceBatch<T> = {
|
||||
messages: PendingMessage<T>[];
|
||||
firstMessageAt: number;
|
||||
lastMessageAt: number;
|
||||
};
|
||||
|
||||
type GroupDebounceState<T> = {
|
||||
pending: PendingMessage<T>[];
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
firstMessageAt: number;
|
||||
maxWaitTimer: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
export type DebounceManager<T> = {
|
||||
/**
|
||||
* Add a message to the pending batch.
|
||||
* Returns true if this is the first message (batch just started).
|
||||
*/
|
||||
addMessage: (groupKey: string, message: PendingMessage<T>) => boolean;
|
||||
|
||||
/**
|
||||
* Check if there's a pending batch for a group.
|
||||
*/
|
||||
hasPending: (groupKey: string) => boolean;
|
||||
|
||||
/**
|
||||
* Get and clear the pending batch for a group.
|
||||
*/
|
||||
flush: (groupKey: string) => DebounceBatch<T> | null;
|
||||
|
||||
/**
|
||||
* Cancel any pending timers for a group.
|
||||
*/
|
||||
cancel: (groupKey: string) => void;
|
||||
|
||||
/**
|
||||
* Set the callback to invoke when debounce timer fires.
|
||||
*/
|
||||
onFlush: (callback: (groupKey: string, batch: DebounceBatch<T>) => void) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a debounce manager for engagement mode.
|
||||
*/
|
||||
export function createEngagementDebouncer<T>(config: DebounceConfig): DebounceManager<T> {
|
||||
const groups = new Map<string, GroupDebounceState<T>>();
|
||||
let flushCallback: ((groupKey: string, batch: DebounceBatch<T>) => void) | null = null;
|
||||
|
||||
const getOrCreateState = (groupKey: string): GroupDebounceState<T> => {
|
||||
let state = groups.get(groupKey);
|
||||
if (!state) {
|
||||
state = {
|
||||
pending: [],
|
||||
timer: null,
|
||||
firstMessageAt: 0,
|
||||
maxWaitTimer: null,
|
||||
};
|
||||
groups.set(groupKey, state);
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
const clearTimers = (state: GroupDebounceState<T>) => {
|
||||
if (state.timer) {
|
||||
clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
if (state.maxWaitTimer) {
|
||||
clearTimeout(state.maxWaitTimer);
|
||||
state.maxWaitTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const triggerFlush = (groupKey: string) => {
|
||||
const state = groups.get(groupKey);
|
||||
if (!state || state.pending.length === 0) return;
|
||||
|
||||
const batch: DebounceBatch<T> = {
|
||||
messages: [...state.pending],
|
||||
firstMessageAt: state.firstMessageAt,
|
||||
lastMessageAt: state.pending[state.pending.length - 1]?.timestamp ?? Date.now(),
|
||||
};
|
||||
|
||||
// Clear state
|
||||
clearTimers(state);
|
||||
state.pending = [];
|
||||
state.firstMessageAt = 0;
|
||||
|
||||
// Invoke callback
|
||||
if (flushCallback) {
|
||||
flushCallback(groupKey, batch);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
addMessage(groupKey: string, message: PendingMessage<T>): boolean {
|
||||
const state = getOrCreateState(groupKey);
|
||||
const isFirst = state.pending.length === 0;
|
||||
|
||||
state.pending.push(message);
|
||||
|
||||
if (isFirst) {
|
||||
state.firstMessageAt = message.timestamp;
|
||||
|
||||
// Start max wait timer (cap total wait time)
|
||||
state.maxWaitTimer = setTimeout(() => {
|
||||
triggerFlush(groupKey);
|
||||
}, config.maxWaitMs);
|
||||
}
|
||||
|
||||
// Reset debounce timer on each message
|
||||
if (state.timer) {
|
||||
clearTimeout(state.timer);
|
||||
}
|
||||
state.timer = setTimeout(() => {
|
||||
triggerFlush(groupKey);
|
||||
}, config.debounceMs);
|
||||
|
||||
return isFirst;
|
||||
},
|
||||
|
||||
hasPending(groupKey: string): boolean {
|
||||
const state = groups.get(groupKey);
|
||||
return Boolean(state && state.pending.length > 0);
|
||||
},
|
||||
|
||||
flush(groupKey: string): DebounceBatch<T> | null {
|
||||
const state = groups.get(groupKey);
|
||||
if (!state || state.pending.length === 0) return null;
|
||||
|
||||
const batch: DebounceBatch<T> = {
|
||||
messages: [...state.pending],
|
||||
firstMessageAt: state.firstMessageAt,
|
||||
lastMessageAt: state.pending[state.pending.length - 1]?.timestamp ?? Date.now(),
|
||||
};
|
||||
|
||||
clearTimers(state);
|
||||
state.pending = [];
|
||||
state.firstMessageAt = 0;
|
||||
|
||||
return batch;
|
||||
},
|
||||
|
||||
cancel(groupKey: string): void {
|
||||
const state = groups.get(groupKey);
|
||||
if (state) {
|
||||
clearTimers(state);
|
||||
state.pending = [];
|
||||
state.firstMessageAt = 0;
|
||||
}
|
||||
},
|
||||
|
||||
onFlush(callback: (groupKey: string, batch: DebounceBatch<T>) => void): void {
|
||||
flushCallback = callback;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Default debounce config for engagement mode */
|
||||
export const DEFAULT_ENGAGEMENT_DEBOUNCE: DebounceConfig = {
|
||||
debounceMs: 2500, // Wait 2.5 seconds after last message
|
||||
maxWaitMs: 10000, // But never wait more than 10 seconds total
|
||||
};
|
||||
197
src/channels/engagement-gating.ts
Normal file
197
src/channels/engagement-gating.ts
Normal file
@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Engagement mode gating logic for group messages.
|
||||
*
|
||||
* This module handles the probabilistic response logic for engagement mode,
|
||||
* including state management and persistence.
|
||||
*
|
||||
* @see docs/experiments/plans/engagement-mode.md
|
||||
*/
|
||||
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import { shouldRespond, type EngagementState } from "../config/engagement.js";
|
||||
import {
|
||||
resolveChannelGroupEngagement,
|
||||
resolveChannelGroupMode,
|
||||
type GroupPolicyChannel,
|
||||
} from "../config/group-policy.js";
|
||||
import { loadSessionStore, updateSessionStore } from "../config/sessions/store.js";
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
|
||||
export type EngagementGatingParams = {
|
||||
cfg: MoltbotConfig;
|
||||
channel: GroupPolicyChannel;
|
||||
groupId: string;
|
||||
accountId?: string;
|
||||
sessionKey: string;
|
||||
storePath: string;
|
||||
messageText: string;
|
||||
/** Was the bot explicitly mentioned? */
|
||||
wasMentioned: boolean;
|
||||
/** Injectable for testing */
|
||||
random?: () => number;
|
||||
/** Injectable for testing */
|
||||
now?: number;
|
||||
};
|
||||
|
||||
export type EngagementGatingResult = {
|
||||
/** Whether to process this message */
|
||||
shouldProcess: boolean;
|
||||
/** The resolved group mode */
|
||||
mode: "mention" | "always" | "engagement";
|
||||
/** Whether engagement mode decided to respond (only set if mode="engagement") */
|
||||
engagementTriggered?: boolean;
|
||||
/** Updated engagement state (only set if mode="engagement") */
|
||||
nextState?: EngagementState;
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply engagement gating logic to a group message.
|
||||
*
|
||||
* For "mention" mode: requires mention to process
|
||||
* For "always" mode: always processes
|
||||
* For "engagement" mode: probabilistic response based on config
|
||||
*
|
||||
* This function does NOT persist state - call `persistEngagementState` after
|
||||
* processing if you want to save the state.
|
||||
*/
|
||||
export function applyEngagementGating(params: EngagementGatingParams): EngagementGatingResult {
|
||||
const { cfg, channel, groupId, accountId, sessionKey, storePath, messageText, wasMentioned } =
|
||||
params;
|
||||
const now = params.now ?? Date.now();
|
||||
|
||||
// Resolve group mode from config
|
||||
const mode = resolveChannelGroupMode({
|
||||
cfg,
|
||||
channel,
|
||||
groupId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
logVerbose(`[engagement-gating] channel=${channel} groupId=${groupId} resolvedMode=${mode}`);
|
||||
|
||||
// Handle non-engagement modes
|
||||
if (mode === "always") {
|
||||
logVerbose(`[engagement-gating] mode=always, processing`);
|
||||
return { shouldProcess: true, mode };
|
||||
}
|
||||
|
||||
if (mode === "mention") {
|
||||
logVerbose(`[engagement-gating] mode=mention, wasMentioned=${wasMentioned}`);
|
||||
return { shouldProcess: wasMentioned, mode };
|
||||
}
|
||||
|
||||
// Engagement mode
|
||||
const engagementConfig = resolveChannelGroupEngagement({
|
||||
cfg,
|
||||
channel,
|
||||
groupId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
logVerbose(`[engagement-gating] engagementConfig=${engagementConfig ? "found" : "missing"}`);
|
||||
|
||||
// If no engagement config, fall back to mention behavior
|
||||
if (!engagementConfig) {
|
||||
logVerbose(`[engagement-gating] no engagement config, falling back to mention mode`);
|
||||
return { shouldProcess: wasMentioned, mode: "mention" };
|
||||
}
|
||||
|
||||
// If mentioned, always respond in engagement mode (and transition to engaged)
|
||||
// But don't corrupt state - this is a USER message, not a bot message
|
||||
if (wasMentioned) {
|
||||
const currentState = loadEngagementState(storePath, sessionKey);
|
||||
const nextState: EngagementState = {
|
||||
engaged: true,
|
||||
engagedAt: currentState.engaged ? currentState.engagedAt : now,
|
||||
lastResponseAt: currentState.lastResponseAt, // Don't update - bot hasn't responded yet
|
||||
lastMessageAt: now,
|
||||
messagesSinceResponse: (currentState.messagesSinceResponse ?? 0) + 1, // Increment - this is a user message
|
||||
recentMessages: appendToRecentMessages(
|
||||
currentState.recentMessages,
|
||||
false, // This is a USER message, not bot
|
||||
now,
|
||||
engagementConfig.ratioWindow ?? 10,
|
||||
),
|
||||
};
|
||||
return {
|
||||
shouldProcess: true,
|
||||
mode: "engagement",
|
||||
engagementTriggered: true,
|
||||
nextState,
|
||||
};
|
||||
}
|
||||
|
||||
// Probabilistic response
|
||||
const currentState = loadEngagementState(storePath, sessionKey);
|
||||
logVerbose(
|
||||
`[engagement-gating] currentState: engaged=${currentState.engaged} messagesSinceResponse=${currentState.messagesSinceResponse ?? 0}`,
|
||||
);
|
||||
|
||||
const result = shouldRespond({
|
||||
config: engagementConfig,
|
||||
state: currentState,
|
||||
messageText,
|
||||
now,
|
||||
wasMentioned,
|
||||
random: params.random,
|
||||
});
|
||||
|
||||
logVerbose(
|
||||
`[engagement-gating] shouldRespond result: respond=${result.respond} nextEngaged=${result.nextState.engaged}`,
|
||||
);
|
||||
|
||||
return {
|
||||
shouldProcess: result.respond,
|
||||
mode: "engagement",
|
||||
engagementTriggered: result.respond,
|
||||
nextState: result.nextState,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist engagement state to the session store.
|
||||
* Call this after processing a message in engagement mode.
|
||||
*/
|
||||
export async function persistEngagementState(params: {
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
state: EngagementState;
|
||||
}): Promise<void> {
|
||||
const { storePath, sessionKey, state } = params;
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
const entry = store[sessionKey];
|
||||
if (entry) {
|
||||
entry.engagementState = state;
|
||||
// Also update groupActivation to reflect engagement mode
|
||||
entry.groupActivation = "engagement";
|
||||
}
|
||||
return store;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load engagement state from the session store.
|
||||
*/
|
||||
function loadEngagementState(storePath: string, sessionKey: string): EngagementState {
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store[sessionKey] as SessionEntry | undefined;
|
||||
return entry?.engagementState ?? { engaged: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a message to the recent messages window, trimming to size.
|
||||
*/
|
||||
function appendToRecentMessages(
|
||||
messages: EngagementState["recentMessages"],
|
||||
isBot: boolean,
|
||||
at: number,
|
||||
windowSize: number,
|
||||
): EngagementState["recentMessages"] {
|
||||
const current = messages ?? [];
|
||||
const updated = [...current, { isBot, at }];
|
||||
if (updated.length > windowSize) {
|
||||
return updated.slice(-windowSize);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
262
src/config/engagement.ts
Normal file
262
src/config/engagement.ts
Normal file
@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Engagement mode for groups — natural participation without @mentions.
|
||||
*
|
||||
* State machine:
|
||||
* LURKING ──(chance hit)──► ENGAGED
|
||||
* ▲ │
|
||||
* └──(cooldown: time OR msgs)┘
|
||||
*
|
||||
* @see docs/experiments/plans/engagement-mode.md
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export type EngagementConfig = {
|
||||
/** Base probability when lurking (0-1) */
|
||||
baseChance: number;
|
||||
/** Probability when engaged (0-1) */
|
||||
engagedChance: number;
|
||||
/** Words that boost response probability (case-insensitive substring match) */
|
||||
triggerWords?: string[];
|
||||
/** Probability boost when trigger word found (0-1, added to base/engaged chance) */
|
||||
triggerBoost: number;
|
||||
/** Cooldown settings — either threshold triggers reset to lurking */
|
||||
cooldown: {
|
||||
/** Seconds of silence before resetting to lurking */
|
||||
time: number;
|
||||
/** Messages without bot responding before resetting to lurking */
|
||||
messages: number;
|
||||
};
|
||||
/** Max ratio of bot messages in recent window (0-1) — suppresses if exceeded */
|
||||
maxRatio?: number;
|
||||
/** Number of messages to consider for ratio calculation */
|
||||
ratioWindow?: number;
|
||||
};
|
||||
|
||||
export type RecentMessage = {
|
||||
isBot: boolean;
|
||||
at: number;
|
||||
};
|
||||
|
||||
export type EngagementState = {
|
||||
engaged: boolean;
|
||||
engagedAt?: number;
|
||||
lastResponseAt?: number;
|
||||
lastMessageAt?: number;
|
||||
messagesSinceResponse?: number;
|
||||
recentMessages?: RecentMessage[];
|
||||
};
|
||||
|
||||
export type ShouldRespondResult = {
|
||||
respond: boolean;
|
||||
nextState: EngagementState;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Trigger word detection
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check if message contains any trigger word (case-insensitive substring match).
|
||||
*/
|
||||
export function hasTriggerWord(message: string, triggerWords: string[] | undefined): boolean {
|
||||
if (!triggerWords || triggerWords.length === 0) return false;
|
||||
if (!message) return false;
|
||||
|
||||
const lowerMessage = message.toLowerCase();
|
||||
return triggerWords.some((word) => lowerMessage.includes(word.toLowerCase()));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Ratio calculation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Calculate the ratio of bot messages in the recent window.
|
||||
*/
|
||||
export function calculateBotRatio(
|
||||
recentMessages: RecentMessage[] | undefined,
|
||||
windowSize: number,
|
||||
): number {
|
||||
if (!recentMessages || recentMessages.length === 0) return 0;
|
||||
|
||||
// Take only the most recent `windowSize` messages
|
||||
const window = recentMessages.slice(-windowSize);
|
||||
if (window.length === 0) return 0;
|
||||
|
||||
const botCount = window.filter((m) => m.isBot).length;
|
||||
return botCount / window.length;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Cooldown logic
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check if engagement has cooled down (should reset to lurking).
|
||||
* Returns true if cooldown threshold exceeded.
|
||||
*/
|
||||
export function checkCooldown(
|
||||
state: EngagementState,
|
||||
cooldown: EngagementConfig["cooldown"],
|
||||
now: number,
|
||||
): boolean {
|
||||
// Not engaged → nothing to cool down
|
||||
if (!state.engaged) return false;
|
||||
|
||||
// No lastMessageAt → treat as stale (cooled down)
|
||||
if (state.lastMessageAt === undefined) return true;
|
||||
|
||||
// Time-based cooldown (convert seconds to ms)
|
||||
const timeSinceLastMessage = now - state.lastMessageAt;
|
||||
if (timeSinceLastMessage > cooldown.time * 1000) return true;
|
||||
|
||||
// Message-based cooldown
|
||||
const messagesSinceResponse = state.messagesSinceResponse ?? 0;
|
||||
if (messagesSinceResponse > cooldown.messages) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main logic
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Determine whether to respond to a message and compute next state.
|
||||
*
|
||||
* @param params.wasMentioned - Whether bot was @mentioned (bypasses maxRatio)
|
||||
* @param params.random - Injectable random function for testing (default: Math.random)
|
||||
*/
|
||||
export function shouldRespond(params: {
|
||||
config: EngagementConfig;
|
||||
state: EngagementState;
|
||||
messageText: string;
|
||||
now: number;
|
||||
wasMentioned?: boolean;
|
||||
random?: () => number;
|
||||
}): ShouldRespondResult {
|
||||
const { config, state, messageText, now } = params;
|
||||
const random = params.random ?? Math.random;
|
||||
|
||||
const ratioWindow = config.ratioWindow ?? 10;
|
||||
const hasTrigger = hasTriggerWord(messageText, config.triggerWords);
|
||||
|
||||
// Direct summons (trigger word or @mention) bypass maxRatio
|
||||
// Someone explicitly called the bot, they want a response
|
||||
const directSummon = hasTrigger || params.wasMentioned;
|
||||
|
||||
// 1. Check ratio limit — suppress if bot is dominating (unless direct summon)
|
||||
if (config.maxRatio !== undefined && !directSummon) {
|
||||
const currentRatio = calculateBotRatio(state.recentMessages, ratioWindow);
|
||||
if (currentRatio >= config.maxRatio) {
|
||||
// Suppress response, but still update window with this message (as non-bot)
|
||||
return {
|
||||
respond: false,
|
||||
nextState: updateStateForNonResponse(state, now, ratioWindow),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check cooldown → maybe reset to lurking
|
||||
const cooledDown = checkCooldown(state, config.cooldown, now);
|
||||
const effectiveEngaged = state.engaged && !cooledDown;
|
||||
|
||||
// 3. When engaged, always respond (no probability roll)
|
||||
// This makes engagement feel like "I'm in this conversation now"
|
||||
// The bot will stay engaged until cooldown or maxRatio kicks in
|
||||
if (effectiveEngaged) {
|
||||
return {
|
||||
respond: true,
|
||||
nextState: updateStateForResponse(state, effectiveEngaged, now, ratioWindow),
|
||||
};
|
||||
}
|
||||
|
||||
// 4. When lurking, use probability (baseChance + trigger boost)
|
||||
let chance = config.baseChance;
|
||||
|
||||
if (hasTrigger) {
|
||||
chance = Math.min(1, chance + config.triggerBoost);
|
||||
}
|
||||
|
||||
// 5. Roll the dice
|
||||
const respond = random() < chance;
|
||||
|
||||
// 6. Compute next state
|
||||
if (respond) {
|
||||
return {
|
||||
respond: true,
|
||||
nextState: updateStateForResponse(state, effectiveEngaged, now, ratioWindow),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
respond: false,
|
||||
nextState: updateStateForNonResponse(state, now, ratioWindow, effectiveEngaged),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// State update helpers
|
||||
// =============================================================================
|
||||
|
||||
function updateStateForResponse(
|
||||
state: EngagementState,
|
||||
wasEngaged: boolean,
|
||||
now: number,
|
||||
ratioWindow: number,
|
||||
): EngagementState {
|
||||
const recentMessages = appendToWindow(
|
||||
state.recentMessages,
|
||||
{ isBot: true, at: now },
|
||||
ratioWindow,
|
||||
);
|
||||
|
||||
return {
|
||||
engaged: true,
|
||||
engagedAt: wasEngaged ? state.engagedAt : now,
|
||||
lastResponseAt: now,
|
||||
lastMessageAt: now,
|
||||
messagesSinceResponse: 0,
|
||||
recentMessages,
|
||||
};
|
||||
}
|
||||
|
||||
function updateStateForNonResponse(
|
||||
state: EngagementState,
|
||||
now: number,
|
||||
ratioWindow: number,
|
||||
stayEngaged?: boolean,
|
||||
): EngagementState {
|
||||
const recentMessages = appendToWindow(
|
||||
state.recentMessages,
|
||||
{ isBot: false, at: now },
|
||||
ratioWindow,
|
||||
);
|
||||
|
||||
return {
|
||||
engaged: stayEngaged ?? state.engaged,
|
||||
engagedAt: state.engagedAt,
|
||||
lastResponseAt: state.lastResponseAt,
|
||||
lastMessageAt: now,
|
||||
messagesSinceResponse: (state.messagesSinceResponse ?? 0) + 1,
|
||||
recentMessages,
|
||||
};
|
||||
}
|
||||
|
||||
function appendToWindow(
|
||||
messages: RecentMessage[] | undefined,
|
||||
newMessage: RecentMessage,
|
||||
windowSize: number,
|
||||
): RecentMessage[] {
|
||||
const current = messages ?? [];
|
||||
const updated = [...current, newMessage];
|
||||
|
||||
// Trim to window size (keep most recent)
|
||||
if (updated.length > windowSize) {
|
||||
return updated.slice(-windowSize);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
@ -1,12 +1,15 @@
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import { normalizeAccountId } from "../routing/session-key.js";
|
||||
import type { MoltbotConfig } from "./config.js";
|
||||
import type { EngagementConfig } from "./engagement.js";
|
||||
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
|
||||
|
||||
export type GroupPolicyChannel = ChannelId;
|
||||
|
||||
export type ChannelGroupConfig = {
|
||||
requireMention?: boolean;
|
||||
mode?: "mention" | "always" | "engagement";
|
||||
engagement?: EngagementConfig;
|
||||
tools?: GroupToolPolicyConfig;
|
||||
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||
};
|
||||
@ -154,6 +157,31 @@ export function resolveChannelGroupRequireMention(params: {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveChannelGroupMode(params: {
|
||||
cfg: MoltbotConfig;
|
||||
channel: GroupPolicyChannel;
|
||||
groupId?: string | null;
|
||||
accountId?: string | null;
|
||||
}): "mention" | "always" | "engagement" {
|
||||
const { groupConfig, defaultConfig } = resolveChannelGroupPolicy(params);
|
||||
// Explicit mode takes precedence
|
||||
if (groupConfig?.mode) return groupConfig.mode;
|
||||
if (defaultConfig?.mode) return defaultConfig.mode;
|
||||
// Fall back to legacy requireMention behavior
|
||||
const requireMention = resolveChannelGroupRequireMention(params);
|
||||
return requireMention ? "mention" : "always";
|
||||
}
|
||||
|
||||
export function resolveChannelGroupEngagement(params: {
|
||||
cfg: MoltbotConfig;
|
||||
channel: GroupPolicyChannel;
|
||||
groupId?: string | null;
|
||||
accountId?: string | null;
|
||||
}): EngagementConfig | undefined {
|
||||
const { groupConfig, defaultConfig } = resolveChannelGroupPolicy(params);
|
||||
return groupConfig?.engagement ?? defaultConfig?.engagement;
|
||||
}
|
||||
|
||||
export function resolveChannelGroupToolsPolicy(
|
||||
params: {
|
||||
cfg: MoltbotConfig;
|
||||
|
||||
@ -3,6 +3,7 @@ import crypto from "node:crypto";
|
||||
import type { Skill } from "@mariozechner/pi-coding-agent";
|
||||
import type { NormalizedChatType } from "../../channels/chat-type.js";
|
||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||
import type { EngagementState } from "../engagement.js";
|
||||
import type { DeliveryContext } from "../../utils/delivery-context.js";
|
||||
import type { TtsAutoMode } from "../types.tts.js";
|
||||
|
||||
@ -54,8 +55,9 @@ export type SessionEntry = {
|
||||
authProfileOverride?: string;
|
||||
authProfileOverrideSource?: "auto" | "user";
|
||||
authProfileOverrideCompactionCount?: number;
|
||||
groupActivation?: "mention" | "always";
|
||||
groupActivation?: "mention" | "always" | "engagement";
|
||||
groupActivationNeedsSystemIntro?: boolean;
|
||||
engagementState?: EngagementState;
|
||||
sendPolicy?: "allow" | "deny";
|
||||
queueMode?:
|
||||
| "steer"
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { normalizeGroupActivation } from "../../../auto-reply/group-activation.js";
|
||||
import type { loadConfig } from "../../../config/config.js";
|
||||
import {
|
||||
resolveChannelGroupMode,
|
||||
resolveChannelGroupPolicy,
|
||||
resolveChannelGroupRequireMention,
|
||||
} from "../../../config/group-policy.js";
|
||||
@ -39,6 +40,19 @@ export function resolveGroupRequireMentionFor(
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveGroupModeFor(cfg: ReturnType<typeof loadConfig>, conversationId: string) {
|
||||
const groupId = resolveGroupSessionKey({
|
||||
From: conversationId,
|
||||
ChatType: "group",
|
||||
Provider: "whatsapp",
|
||||
})?.id;
|
||||
return resolveChannelGroupMode({
|
||||
cfg,
|
||||
channel: "whatsapp",
|
||||
groupId: groupId ?? conversationId,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveGroupActivationFor(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
agentId: string;
|
||||
@ -50,7 +64,16 @@ export function resolveGroupActivationFor(params: {
|
||||
});
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store[params.sessionKey];
|
||||
|
||||
// Session-level override takes precedence
|
||||
const sessionActivation = normalizeGroupActivation(entry?.groupActivation);
|
||||
if (sessionActivation) return sessionActivation;
|
||||
|
||||
// Then check config mode (supports "engagement")
|
||||
const configMode = resolveGroupModeFor(params.cfg, params.conversationId);
|
||||
if (configMode) return configMode;
|
||||
|
||||
// Legacy fallback
|
||||
const requireMention = resolveGroupRequireMentionFor(params.cfg, params.conversationId);
|
||||
const defaultActivation = requireMention === false ? "always" : "mention";
|
||||
return normalizeGroupActivation(entry?.groupActivation) ?? defaultActivation;
|
||||
return requireMention === false ? "always" : "mention";
|
||||
}
|
||||
|
||||
@ -3,6 +3,11 @@ import { parseActivationCommand } from "../../../auto-reply/group-activation.js"
|
||||
import type { loadConfig } from "../../../config/config.js";
|
||||
import { normalizeE164 } from "../../../utils.js";
|
||||
import { resolveMentionGating } from "../../../channels/mention-gating.js";
|
||||
import {
|
||||
applyEngagementGating,
|
||||
persistEngagementState,
|
||||
} from "../../../channels/engagement-gating.js";
|
||||
import { resolveGroupSessionKey, resolveStorePath } from "../../../config/sessions.js";
|
||||
import type { MentionConfig } from "../mentions.js";
|
||||
import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions.js";
|
||||
import type { WebInboundMsg } from "../types.js";
|
||||
@ -40,7 +45,7 @@ export function applyGroupGating(params: {
|
||||
groupMemberNames: Map<string, Map<string, string>>;
|
||||
logVerbose: (msg: string) => void;
|
||||
replyLogger: { debug: (obj: unknown, msg: string) => void };
|
||||
}) {
|
||||
}): { shouldProcess: boolean; mode?: "engagement" | "mention" | "always" } {
|
||||
const groupPolicy = resolveGroupPolicyFor(params.cfg, params.conversationId);
|
||||
if (groupPolicy.allowlistEnabled && !groupPolicy.allowed) {
|
||||
params.logVerbose(`Skipping group message ${params.conversationId} (not in allowlist)`);
|
||||
@ -101,6 +106,109 @@ export function applyGroupGating(params: {
|
||||
sessionKey: params.sessionKey,
|
||||
conversationId: params.conversationId,
|
||||
});
|
||||
|
||||
// Handle engagement mode separately
|
||||
if (activation === "engagement") {
|
||||
const groupId = resolveGroupSessionKey({
|
||||
From: params.conversationId,
|
||||
ChatType: "group",
|
||||
Provider: "whatsapp",
|
||||
})?.id;
|
||||
const storePath = resolveStorePath(params.cfg.session?.store, {
|
||||
agentId: params.agentId,
|
||||
});
|
||||
|
||||
const selfJid = params.msg.selfJid?.replace(/:\\d+/, "");
|
||||
const replySenderJid = params.msg.replyToSenderJid?.replace(/:\\d+/, "");
|
||||
const selfE164 = params.msg.selfE164 ? normalizeE164(params.msg.selfE164) : null;
|
||||
const replySenderE164 = params.msg.replyToSenderE164
|
||||
? normalizeE164(params.msg.replyToSenderE164)
|
||||
: null;
|
||||
const implicitMention = Boolean(
|
||||
(selfJid && replySenderJid && selfJid === replySenderJid) ||
|
||||
(selfE164 && replySenderE164 && selfE164 === replySenderE164),
|
||||
);
|
||||
const effectiveMentioned = wasMentioned || implicitMention || shouldBypassMention;
|
||||
|
||||
params.logVerbose(
|
||||
`[engagement] group=${groupId ?? params.conversationId} mode=engagement msg="${params.msg.body.slice(0, 50)}" mentioned=${effectiveMentioned}`,
|
||||
);
|
||||
|
||||
const engagementResult = applyEngagementGating({
|
||||
cfg: params.cfg,
|
||||
channel: "whatsapp",
|
||||
groupId: groupId ?? params.conversationId,
|
||||
sessionKey: params.sessionKey,
|
||||
storePath,
|
||||
messageText: params.msg.body,
|
||||
wasMentioned: effectiveMentioned,
|
||||
});
|
||||
|
||||
params.logVerbose(
|
||||
`[engagement] result: shouldProcess=${engagementResult.shouldProcess} triggered=${engagementResult.engagementTriggered} mode=${engagementResult.mode}`,
|
||||
);
|
||||
|
||||
params.msg.wasMentioned = effectiveMentioned && engagementResult.shouldProcess;
|
||||
|
||||
if (!engagementResult.shouldProcess) {
|
||||
params.logVerbose(
|
||||
`Group message stored for context (engagement mode, not triggered) in ${params.conversationId}: ${params.msg.body}`,
|
||||
);
|
||||
const sender =
|
||||
params.msg.senderName && params.msg.senderE164
|
||||
? `${params.msg.senderName} (${params.msg.senderE164})`
|
||||
: (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown");
|
||||
recordPendingHistoryEntryIfEnabled({
|
||||
historyMap: params.groupHistories,
|
||||
historyKey: params.groupHistoryKey,
|
||||
limit: params.groupHistoryLimit,
|
||||
entry: {
|
||||
sender,
|
||||
body: params.msg.body,
|
||||
timestamp: params.msg.timestamp,
|
||||
id: params.msg.id,
|
||||
senderJid: params.msg.senderJid,
|
||||
},
|
||||
});
|
||||
// Still persist state update even if we don't respond
|
||||
if (engagementResult.nextState) {
|
||||
persistEngagementState({
|
||||
storePath,
|
||||
sessionKey: params.sessionKey,
|
||||
state: engagementResult.nextState,
|
||||
}).catch(() => {
|
||||
// Ignore persistence errors for non-responses
|
||||
});
|
||||
}
|
||||
return { shouldProcess: false };
|
||||
}
|
||||
|
||||
// Persist engagement state for responses
|
||||
if (engagementResult.nextState) {
|
||||
persistEngagementState({
|
||||
storePath,
|
||||
sessionKey: params.sessionKey,
|
||||
state: engagementResult.nextState,
|
||||
}).catch((err) => {
|
||||
params.logVerbose(`Failed to persist engagement state: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
|
||||
params.replyLogger.debug(
|
||||
{
|
||||
conversationId: params.conversationId,
|
||||
mode: "engagement",
|
||||
triggered: engagementResult.engagementTriggered,
|
||||
wasMentioned: effectiveMentioned,
|
||||
},
|
||||
"engagement mode triggered",
|
||||
);
|
||||
|
||||
// Return mode so caller can suppress history clearing for engagement
|
||||
return { shouldProcess: true, mode: "engagement" };
|
||||
}
|
||||
|
||||
// Standard mention/always mode handling
|
||||
const requireMention = activation !== "always";
|
||||
const selfJid = params.msg.selfJid?.replace(/:\\d+/, "");
|
||||
const replySenderJid = params.msg.replyToSenderJid?.replace(/:\\d+/, "");
|
||||
|
||||
@ -14,6 +14,19 @@ import { applyGroupGating } from "./group-gating.js";
|
||||
import { updateLastRouteInBackground } from "./last-route.js";
|
||||
import { resolvePeerId } from "./peer.js";
|
||||
import { processMessage } from "./process-message.js";
|
||||
import {
|
||||
createEngagementDebouncer,
|
||||
DEFAULT_ENGAGEMENT_DEBOUNCE,
|
||||
type DebounceBatch,
|
||||
} from "../../../channels/engagement-debounce.js";
|
||||
|
||||
/** Info needed to process a debounced message */
|
||||
type DebouncedMessageInfo = {
|
||||
msg: WebInboundMsg;
|
||||
route: ReturnType<typeof resolveAgentRoute>;
|
||||
groupHistoryKey: string;
|
||||
historyEntry: GroupHistoryEntry;
|
||||
};
|
||||
|
||||
export function createWebOnMessageHandler(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
@ -30,6 +43,11 @@ export function createWebOnMessageHandler(params: {
|
||||
baseMentionConfig: MentionConfig;
|
||||
account: { authDir?: string; accountId?: string };
|
||||
}) {
|
||||
// Debouncer for engagement mode - collects message bursts
|
||||
const engagementDebouncer = createEngagementDebouncer<DebouncedMessageInfo>(
|
||||
DEFAULT_ENGAGEMENT_DEBOUNCE,
|
||||
);
|
||||
|
||||
const processForRoute = async (
|
||||
msg: WebInboundMsg,
|
||||
route: ReturnType<typeof resolveAgentRoute>,
|
||||
@ -60,6 +78,48 @@ export function createWebOnMessageHandler(params: {
|
||||
suppressGroupHistoryClear: opts?.suppressGroupHistoryClear,
|
||||
});
|
||||
|
||||
// Set up debounce callback - fires when message burst settles
|
||||
engagementDebouncer.onFlush(
|
||||
async (groupKey: string, batch: DebounceBatch<DebouncedMessageInfo>) => {
|
||||
if (batch.messages.length === 0) return;
|
||||
|
||||
// Check if any message in the batch triggered engagement
|
||||
const hasTriggered = batch.messages.some((m) => m.triggered);
|
||||
if (!hasTriggered) {
|
||||
// No triggers in batch - just add all to history for future context
|
||||
for (const pending of batch.messages) {
|
||||
const existing = params.groupHistories.get(groupKey) ?? [];
|
||||
existing.push(pending.message.historyEntry);
|
||||
while (existing.length > params.groupHistoryLimit) existing.shift();
|
||||
params.groupHistories.set(groupKey, existing);
|
||||
}
|
||||
logVerbose(
|
||||
`[engagement-debounce] batch for ${groupKey} had no triggers, added ${batch.messages.length} to history`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build history from all messages except the last one (which is "current")
|
||||
const historyMessages = batch.messages.slice(0, -1).map((m) => m.message.historyEntry);
|
||||
const lastMessage = batch.messages[batch.messages.length - 1];
|
||||
|
||||
logVerbose(
|
||||
`[engagement-debounce] processing batch for ${groupKey}: ${batch.messages.length} messages, ${historyMessages.length} as history`,
|
||||
);
|
||||
|
||||
// Process with the batched history
|
||||
await processForRoute(
|
||||
lastMessage.message.msg,
|
||||
lastMessage.message.route,
|
||||
lastMessage.message.groupHistoryKey,
|
||||
{
|
||||
groupHistory: historyMessages,
|
||||
suppressGroupHistoryClear: true,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return async (msg: WebInboundMsg) => {
|
||||
const conversationId = msg.conversationId ?? msg.from;
|
||||
const peerId = resolvePeerId(msg);
|
||||
@ -138,6 +198,34 @@ export function createWebOnMessageHandler(params: {
|
||||
logVerbose,
|
||||
replyLogger: params.replyLogger,
|
||||
});
|
||||
// In engagement mode, use debouncing to collect message bursts
|
||||
if (gating.mode === "engagement") {
|
||||
const sender =
|
||||
msg.senderName && msg.senderE164
|
||||
? `${msg.senderName} (${msg.senderE164})`
|
||||
: (msg.senderName ?? msg.senderE164 ?? "Unknown");
|
||||
|
||||
const historyEntry: GroupHistoryEntry = {
|
||||
sender,
|
||||
body: msg.body,
|
||||
timestamp: msg.timestamp,
|
||||
id: msg.id,
|
||||
senderJid: msg.senderJid,
|
||||
};
|
||||
|
||||
// Add to debouncer - it will fire callback after quiet period
|
||||
engagementDebouncer.addMessage(groupHistoryKey, {
|
||||
message: { msg, route, groupHistoryKey, historyEntry },
|
||||
timestamp: msg.timestamp ?? Date.now(),
|
||||
triggered: gating.shouldProcess,
|
||||
});
|
||||
|
||||
logVerbose(
|
||||
`[engagement-debounce] added message to batch for ${conversationId}, triggered=${gating.shouldProcess}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!gating.shouldProcess) return;
|
||||
} else {
|
||||
// Ensure `peerId` for DMs is stable and stored as E.164 when possible.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user