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)
This commit is contained in:
parent
3fe4b2595a
commit
294d36df8f
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
|
||||||
|
};
|
||||||
201
src/channels/engagement-gating.ts
Normal file
201
src/channels/engagement-gating.ts
Normal file
@ -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<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;
|
||||||
|
}
|
||||||
@ -3,12 +3,21 @@ import { parseActivationCommand } from "../../../auto-reply/group-activation.js"
|
|||||||
import type { loadConfig } from "../../../config/config.js";
|
import type { loadConfig } from "../../../config/config.js";
|
||||||
import { normalizeE164 } from "../../../utils.js";
|
import { normalizeE164 } from "../../../utils.js";
|
||||||
import { resolveMentionGating } from "../../../channels/mention-gating.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 type { MentionConfig } from "../mentions.js";
|
||||||
import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions.js";
|
import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions.js";
|
||||||
import type { WebInboundMsg } from "../types.js";
|
import type { WebInboundMsg } from "../types.js";
|
||||||
import { recordPendingHistoryEntryIfEnabled } from "../../../auto-reply/reply/history.js";
|
import { recordPendingHistoryEntryIfEnabled } from "../../../auto-reply/reply/history.js";
|
||||||
import { stripMentionsForCommand } from "./commands.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";
|
import { noteGroupMember } from "./group-members.js";
|
||||||
|
|
||||||
export type GroupHistoryEntry = {
|
export type GroupHistoryEntry = {
|
||||||
@ -40,7 +49,7 @@ export function applyGroupGating(params: {
|
|||||||
groupMemberNames: Map<string, Map<string, string>>;
|
groupMemberNames: Map<string, Map<string, string>>;
|
||||||
logVerbose: (msg: string) => void;
|
logVerbose: (msg: string) => void;
|
||||||
replyLogger: { debug: (obj: unknown, msg: string) => void };
|
replyLogger: { debug: (obj: unknown, msg: string) => void };
|
||||||
}) {
|
}): { shouldProcess: boolean; mode?: "engagement" | "mention" | "always" } {
|
||||||
const groupPolicy = resolveGroupPolicyFor(params.cfg, params.conversationId);
|
const groupPolicy = resolveGroupPolicyFor(params.cfg, params.conversationId);
|
||||||
if (groupPolicy.allowlistEnabled && !groupPolicy.allowed) {
|
if (groupPolicy.allowlistEnabled && !groupPolicy.allowed) {
|
||||||
params.logVerbose(`Skipping group message ${params.conversationId} (not in allowlist)`);
|
params.logVerbose(`Skipping group message ${params.conversationId} (not in allowlist)`);
|
||||||
@ -101,6 +110,109 @@ export function applyGroupGating(params: {
|
|||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
conversationId: params.conversationId,
|
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 requireMention = activation !== "always";
|
||||||
const selfJid = params.msg.selfJid?.replace(/:\\d+/, "");
|
const selfJid = params.msg.selfJid?.replace(/:\\d+/, "");
|
||||||
const replySenderJid = params.msg.replyToSenderJid?.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 { updateLastRouteInBackground } from "./last-route.js";
|
||||||
import { resolvePeerId } from "./peer.js";
|
import { resolvePeerId } from "./peer.js";
|
||||||
import { processMessage } from "./process-message.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: {
|
export function createWebOnMessageHandler(params: {
|
||||||
cfg: ReturnType<typeof loadConfig>;
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
@ -30,6 +43,11 @@ export function createWebOnMessageHandler(params: {
|
|||||||
baseMentionConfig: MentionConfig;
|
baseMentionConfig: MentionConfig;
|
||||||
account: { authDir?: string; accountId?: string };
|
account: { authDir?: string; accountId?: string };
|
||||||
}) {
|
}) {
|
||||||
|
// Debouncer for engagement mode - collects message bursts
|
||||||
|
const engagementDebouncer = createEngagementDebouncer<DebouncedMessageInfo>(
|
||||||
|
DEFAULT_ENGAGEMENT_DEBOUNCE,
|
||||||
|
);
|
||||||
|
|
||||||
const processForRoute = async (
|
const processForRoute = async (
|
||||||
msg: WebInboundMsg,
|
msg: WebInboundMsg,
|
||||||
route: ReturnType<typeof resolveAgentRoute>,
|
route: ReturnType<typeof resolveAgentRoute>,
|
||||||
@ -60,6 +78,48 @@ export function createWebOnMessageHandler(params: {
|
|||||||
suppressGroupHistoryClear: opts?.suppressGroupHistoryClear,
|
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) => {
|
return async (msg: WebInboundMsg) => {
|
||||||
const conversationId = msg.conversationId ?? msg.from;
|
const conversationId = msg.conversationId ?? msg.from;
|
||||||
const peerId = resolvePeerId(msg);
|
const peerId = resolvePeerId(msg);
|
||||||
@ -138,6 +198,34 @@ export function createWebOnMessageHandler(params: {
|
|||||||
logVerbose,
|
logVerbose,
|
||||||
replyLogger: params.replyLogger,
|
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;
|
if (!gating.shouldProcess) return;
|
||||||
} else {
|
} else {
|
||||||
// Ensure `peerId` for DMs is stable and stored as E.164 when possible.
|
// Ensure `peerId` for DMs is stable and stored as E.164 when possible.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user