From 294d36df8fcbbb6b314cb70cac4edde9dee84027 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Tue, 27 Jan 2026 19:07:45 +0000 Subject: [PATCH 1/3] feat: add engagement mode for natural group participation - Add engagement mode as new group activation option (mention | always | engagement) - Probabilistic response when lurking (baseChance + triggerBoost for keywords) - Always respond when engaged (100%) until cooldown - Debounce message bursts (2.5s settle, 10s max) for coherent context - Direct summons (trigger words, @mentions) bypass maxRatio limit - Cooldown to lurking after silence (time or message count threshold) - Preserve history across responses in engagement mode - Fix @mention state tracking (was incorrectly marking as bot message) --- src/channels/engagement-debounce.ts | 188 +++++++++++++++ src/channels/engagement-gating.ts | 201 ++++++++++++++++ src/config/engagement.ts | 262 +++++++++++++++++++++ src/web/auto-reply/monitor/group-gating.ts | 116 ++++++++- src/web/auto-reply/monitor/on-message.ts | 88 +++++++ 5 files changed, 853 insertions(+), 2 deletions(-) create mode 100644 src/channels/engagement-debounce.ts create mode 100644 src/channels/engagement-gating.ts create mode 100644 src/config/engagement.ts diff --git a/src/channels/engagement-debounce.ts b/src/channels/engagement-debounce.ts new file mode 100644 index 000000000..ae3c7fe83 --- /dev/null +++ b/src/channels/engagement-debounce.ts @@ -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 = { + 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 = { + messages: PendingMessage[]; + firstMessageAt: number; + lastMessageAt: number; +}; + +type GroupDebounceState = { + pending: PendingMessage[]; + timer: ReturnType | null; + firstMessageAt: number; + maxWaitTimer: ReturnType | null; +}; + +export type DebounceManager = { + /** + * Add a message to the pending batch. + * Returns true if this is the first message (batch just started). + */ + addMessage: (groupKey: string, message: PendingMessage) => 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 | 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) => void) => void; +}; + +/** + * Create a debounce manager for engagement mode. + */ +export function createEngagementDebouncer(config: DebounceConfig): DebounceManager { + const groups = new Map>(); + let flushCallback: ((groupKey: string, batch: DebounceBatch) => void) | null = null; + + const getOrCreateState = (groupKey: string): GroupDebounceState => { + 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) => { + 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 = { + 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): 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 | null { + const state = groups.get(groupKey); + if (!state || state.pending.length === 0) return null; + + const batch: DebounceBatch = { + 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) => 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 +}; diff --git a/src/channels/engagement-gating.ts b/src/channels/engagement-gating.ts new file mode 100644 index 000000000..733c28e8c --- /dev/null +++ b/src/channels/engagement-gating.ts @@ -0,0 +1,201 @@ +/** + * 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 EngagementConfig, + 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 { + 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; +} diff --git a/src/config/engagement.ts b/src/config/engagement.ts new file mode 100644 index 000000000..4eba01879 --- /dev/null +++ b/src/config/engagement.ts @@ -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; +} diff --git a/src/web/auto-reply/monitor/group-gating.ts b/src/web/auto-reply/monitor/group-gating.ts index 8d1a33645..6f5acb113 100644 --- a/src/web/auto-reply/monitor/group-gating.ts +++ b/src/web/auto-reply/monitor/group-gating.ts @@ -3,12 +3,21 @@ 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"; import { recordPendingHistoryEntryIfEnabled } from "../../../auto-reply/reply/history.js"; import { stripMentionsForCommand } from "./commands.js"; -import { resolveGroupActivationFor, resolveGroupPolicyFor } from "./group-activation.js"; +import { + resolveGroupActivationFor, + resolveGroupModeFor, + resolveGroupPolicyFor, +} from "./group-activation.js"; import { noteGroupMember } from "./group-members.js"; export type GroupHistoryEntry = { @@ -40,7 +49,7 @@ export function applyGroupGating(params: { groupMemberNames: Map>; 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 +110,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+/, ""); diff --git a/src/web/auto-reply/monitor/on-message.ts b/src/web/auto-reply/monitor/on-message.ts index 7e260d49e..b9a65c226 100644 --- a/src/web/auto-reply/monitor/on-message.ts +++ b/src/web/auto-reply/monitor/on-message.ts @@ -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; + groupHistoryKey: string; + historyEntry: GroupHistoryEntry; +}; export function createWebOnMessageHandler(params: { cfg: ReturnType; @@ -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( + DEFAULT_ENGAGEMENT_DEBOUNCE, + ); + const processForRoute = async ( msg: WebInboundMsg, route: ReturnType, @@ -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) => { + 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. From d1241cf6b7e9f70c624c53a4f13d8fe547c362f9 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Tue, 27 Jan 2026 22:32:28 +0300 Subject: [PATCH 2/3] fix: add missing type extensions for engagement mode --- src/auto-reply/group-activation.ts | 3 +- src/auto-reply/reply/commands-status.ts | 2 +- src/auto-reply/reply/commands-types.ts | 2 +- src/auto-reply/reply/groups.ts | 6 ++-- src/auto-reply/status.ts | 2 +- src/config/group-policy.ts | 28 +++++++++++++++++++ src/config/sessions/types.ts | 4 ++- .../auto-reply/monitor/group-activation.ts | 27 ++++++++++++++++-- 8 files changed, 65 insertions(+), 9 deletions(-) diff --git a/src/auto-reply/group-activation.ts b/src/auto-reply/group-activation.ts index 7dcd2e696..3ccf1299c 100644 --- a/src/auto-reply/group-activation.ts +++ b/src/auto-reply/group-activation.ts @@ -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; } diff --git a/src/auto-reply/reply/commands-status.ts b/src/auto-reply/reply/commands-status.ts index a6f0b7348..9cb4269cf 100644 --- a/src/auto-reply/reply/commands-status.ts +++ b/src/auto-reply/reply/commands-status.ts @@ -111,7 +111,7 @@ export async function buildStatusReply(params: { resolvedElevatedLevel?: ElevatedLevel; resolveDefaultThinkingLevel: () => Promise; isGroup: boolean; - defaultGroupActivation: () => "always" | "mention"; + defaultGroupActivation: () => "always" | "mention" | "engagement"; mediaDecisions?: MediaUnderstandingDecision[]; }): Promise { const { diff --git a/src/auto-reply/reply/commands-types.ts b/src/auto-reply/reply/commands-types.ts index d8adb2353..2175585b6 100644 --- a/src/auto-reply/reply/commands-types.ts +++ b/src/auto-reply/reply/commands-types.ts @@ -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; diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index 00011136a..b48a06de1 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -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 = diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index e69941cd8..8c85e127f 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -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; diff --git a/src/config/group-policy.ts b/src/config/group-policy.ts index ae8967192..3c5b0ba21 100644 --- a/src/config/group-policy.ts +++ b/src/config/group-policy.ts @@ -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; diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 48ce428c1..2e302accf 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -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" diff --git a/src/web/auto-reply/monitor/group-activation.ts b/src/web/auto-reply/monitor/group-activation.ts index 520671fec..8980fc820 100644 --- a/src/web/auto-reply/monitor/group-activation.ts +++ b/src/web/auto-reply/monitor/group-activation.ts @@ -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, 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; 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"; } From 5c52b0a98746245ad155c3c76ff0b3a960e15824 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Tue, 27 Jan 2026 22:54:28 +0300 Subject: [PATCH 3/3] fix: remove unused imports --- src/channels/engagement-gating.ts | 6 +----- src/web/auto-reply/monitor/group-gating.ts | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/channels/engagement-gating.ts b/src/channels/engagement-gating.ts index 733c28e8c..26039294b 100644 --- a/src/channels/engagement-gating.ts +++ b/src/channels/engagement-gating.ts @@ -9,11 +9,7 @@ import type { MoltbotConfig } from "../config/config.js"; import { logVerbose } from "../globals.js"; -import { - shouldRespond, - type EngagementConfig, - type EngagementState, -} from "../config/engagement.js"; +import { shouldRespond, type EngagementState } from "../config/engagement.js"; import { resolveChannelGroupEngagement, resolveChannelGroupMode, diff --git a/src/web/auto-reply/monitor/group-gating.ts b/src/web/auto-reply/monitor/group-gating.ts index 6f5acb113..82255e114 100644 --- a/src/web/auto-reply/monitor/group-gating.ts +++ b/src/web/auto-reply/monitor/group-gating.ts @@ -13,11 +13,7 @@ import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions. import type { WebInboundMsg } from "../types.js"; import { recordPendingHistoryEntryIfEnabled } from "../../../auto-reply/reply/history.js"; import { stripMentionsForCommand } from "./commands.js"; -import { - resolveGroupActivationFor, - resolveGroupModeFor, - resolveGroupPolicyFor, -} from "./group-activation.js"; +import { resolveGroupActivationFor, resolveGroupPolicyFor } from "./group-activation.js"; import { noteGroupMember } from "./group-members.js"; export type GroupHistoryEntry = {