diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index b6ae260ce..5d9653c16 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -239,6 +239,18 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately - `"mentions"`: React only when bot is @mentioned - `"never"`: Never react in groups +**Per-session override:** +The global and per-account configurations can be overridden for specific sessions (DMs or Groups) using session configuration (in `sessions.json`). +- `ackReaction`: `"always" | "mentions" | "never"` + - `"always"`: Enables reactions (overrides global off/never). + - `"never"`: Disables reactions (overrides global on/always/mentions). + - `"mentions"`: Enforces **strict** mention-only reactions (requires actual @mention, ignores group activation state). + - Useful for disabling reactions in specific DMs (`"never"`) or forcing them in specific groups. + +> **Note:** Setting `"mentions"` on a DM session effectively disables reactions for that DM, since direct messages don't have @mentions. Use `"always"` or `"never"` for DMs. +> +> **Note:** Unlike the global `group: "mentions"` setting which respects group activation state, the session-level `"mentions"` override is strict and always requires an actual @mention. + **Per-account override:** ```json { diff --git a/src/channels/ack-reactions.test.ts b/src/channels/ack-reactions.test.ts index ed018ba5a..b0cb70acb 100644 --- a/src/channels/ack-reactions.test.ts +++ b/src/channels/ack-reactions.test.ts @@ -225,6 +225,77 @@ describe("shouldAckReactionForWhatsApp", () => { }), ).toBe(false); }); + it("honors session overrides", () => { + // Session: NEVER overrides Global: ALWAYS + expect( + shouldAckReactionForWhatsApp({ + emoji: "👀", + isDirect: false, + isGroup: true, + directEnabled: true, + groupMode: "always", + wasMentioned: false, + groupActivated: false, + sessionMode: "never", + }), + ).toBe(false); + + // Session: ALWAYS overrides Global: NEVER + expect( + shouldAckReactionForWhatsApp({ + emoji: "👀", + isDirect: false, + isGroup: true, + directEnabled: true, + groupMode: "never", + wasMentioned: false, + groupActivated: false, + sessionMode: "always", + }), + ).toBe(true); + + // Session: MENTIONS in DM (effectively OFF unless mentioned, which is false) + expect( + shouldAckReactionForWhatsApp({ + emoji: "👀", + isDirect: true, + isGroup: false, + directEnabled: true, // Globally enabled + groupMode: "mentions", + wasMentioned: false, + groupActivated: false, + sessionMode: "mentions", // Override to strict mentions + }), + ).toBe(false); + + // Session: MENTIONS in Group (works if mentioned) + expect( + shouldAckReactionForWhatsApp({ + emoji: "👀", + isDirect: false, + isGroup: true, + directEnabled: true, + groupMode: "never", // Global OFF + wasMentioned: true, // But mentioned + groupActivated: false, + sessionMode: "mentions", // Session override ON (if mentioned) + }), + ).toBe(true); + + // Session: MENTIONS is strict - does NOT bypass via groupActivated + expect( + shouldAckReactionForWhatsApp({ + emoji: "👀", + isDirect: false, + isGroup: true, + directEnabled: true, + groupMode: "always", + wasMentioned: false, // Not mentioned + groupActivated: true, // But group is activated + sessionMode: "mentions", // Explicit mentions = strict, no bypass + }), + ).toBe(false); + }); }); describe("removeAckReactionAfterReply", () => { diff --git a/src/channels/ack-reactions.ts b/src/channels/ack-reactions.ts index f35ae76d8..00734c5e4 100644 --- a/src/channels/ack-reactions.ts +++ b/src/channels/ack-reactions.ts @@ -36,8 +36,28 @@ export function shouldAckReactionForWhatsApp(params: { groupMode: WhatsAppAckReactionMode; wasMentioned: boolean; groupActivated: boolean; + sessionMode?: "always" | "mentions" | "never"; }): boolean { if (!params.emoji) return false; + + // Session override + if (params.sessionMode === "never") return false; + if (params.sessionMode === "always") return true; + if (params.sessionMode === "mentions") { + // Strict mention-only: explicit session override should not bypass via activation + return shouldAckReaction({ + scope: "group-mentions", + isDirect: params.isDirect, + isGroup: params.isGroup, + isMentionableGroup: true, + requireMention: true, + canDetectMention: true, + effectiveWasMentioned: params.wasMentioned, + // Note: intentionally not passing shouldBypassMention here + }); + } + + // Config fallback if (params.isDirect) return params.directEnabled; if (!params.isGroup) return false; if (params.groupMode === "never") return false; diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 48ce428c1..9efbfd00f 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -54,6 +54,7 @@ export type SessionEntry = { authProfileOverride?: string; authProfileOverrideSource?: "auto" | "user"; authProfileOverrideCompactionCount?: number; + ackReaction?: "always" | "mentions" | "never"; groupActivation?: "mention" | "always"; groupActivationNeedsSystemIntro?: boolean; sendPolicy?: "allow" | "deny"; diff --git a/src/web/auto-reply/monitor/ack-reaction.ts b/src/web/auto-reply/monitor/ack-reaction.ts index 6a99da312..6a589bc88 100644 --- a/src/web/auto-reply/monitor/ack-reaction.ts +++ b/src/web/auto-reply/monitor/ack-reaction.ts @@ -1,4 +1,5 @@ import type { loadConfig } from "../../../config/config.js"; +import { loadSessionStore, resolveStorePath } from "../../../config/sessions.js"; import { logVerbose } from "../../../globals.js"; import { shouldAckReactionForWhatsApp } from "../../../channels/ack-reactions.js"; import { sendReactionWhatsApp } from "../../outbound.js"; @@ -25,6 +26,16 @@ export function maybeSendAckReaction(params: { const groupMode = ackConfig?.group ?? "mentions"; const conversationIdForCheck = params.msg.conversationId ?? params.msg.from; + // Load session to check for per-session ackReaction override. + // Note: loadSessionStore uses an in-memory cache (45s TTL) so this is not + // doing disk I/O on every message in typical usage patterns. + const storePath = resolveStorePath(params.cfg.session?.store, { + agentId: params.agentId, + }); + const store = loadSessionStore(storePath); + const sessionEntry = store[params.sessionKey]; + const sessionMode = sessionEntry?.ackReaction; + const activation = params.msg.chatType === "group" ? resolveGroupActivationFor({ @@ -41,6 +52,7 @@ export function maybeSendAckReaction(params: { isGroup: params.msg.chatType === "group", directEnabled, groupMode, + sessionMode, wasMentioned: params.msg.wasMentioned === true, groupActivated: activation === "always", });