This commit is contained in:
Can Celik 2026-01-30 01:56:13 +03:00 committed by GitHub
commit f21d90f49c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 909 additions and 10 deletions

View File

@ -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;
}

View File

@ -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 {

View File

@ -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;

View File

@ -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 =

View File

@ -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;

View 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
};

View 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
View 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;
}

View File

@ -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;

View File

@ -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"

View File

@ -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";
}

View File

@ -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+/, "");

View File

@ -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.