diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index cb104fda2..ea34feda2 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -200,7 +200,10 @@ The wizard uses it to set your **allowlist/owner** so your own DMs are permitted - Activation modes: - `mention` (default): requires @mention or regex match. - `always`: always triggers. -- `/activation mention|always` is owner-only and must be sent as a standalone message. + - `replies`: only triggers when someone replies to a bot message. + - `mention+replies`: triggers on @mention/regex match OR replies to bot messages. + - `never`: never triggers (except for control commands from owners). +- `/activation mention|always|replies|mention+replies|never` is owner-only and must be sent as a standalone message. - Owner = `channels.whatsapp.allowFrom` (or self E.164 if unset). - **History injection** (pending-only): - Recent *unprocessed* messages (default 50) inserted under: diff --git a/src/acp/commands.ts b/src/acp/commands.ts index 6bd8e85a8..6448a3374 100644 --- a/src/acp/commands.ts +++ b/src/acp/commands.ts @@ -21,7 +21,10 @@ export function getAvailableCommands(): AvailableCommand[] { { name: "dock-telegram", description: "Route replies to Telegram." }, { name: "dock-discord", description: "Route replies to Discord." }, { name: "dock-slack", description: "Route replies to Slack." }, - { name: "activation", description: "Set group activation (mention|always)." }, + { + name: "activation", + description: "Set group activation (mention|always|replies|mention+replies|never).", + }, { name: "send", description: "Set send mode (on|off|inherit)." }, { name: "reset", description: "Reset the session (/new)." }, { name: "new", description: "Reset the session (/reset)." }, diff --git a/src/auto-reply/group-activation.ts b/src/auto-reply/group-activation.ts index 7dcd2e696..dd3729e35 100644 --- a/src/auto-reply/group-activation.ts +++ b/src/auto-reply/group-activation.ts @@ -1,11 +1,14 @@ import { normalizeCommandBody } from "./commands-registry.js"; -export type GroupActivationMode = "mention" | "always"; +export type GroupActivationMode = "mention" | "always" | "replies" | "mention+replies" | "never"; 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 === "replies") return "replies"; + if (value === "mention+replies") return "mention+replies"; + if (value === "never") return "never"; return undefined; } diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index b63bddb21..432c24622 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -66,7 +66,7 @@ export const handleActivationCommand: CommandHandler = async (params, allowTextC if (!activationCommand.mode) { return { shouldContinue: false, - reply: { text: "⚙️ Usage: /activation mention|always" }, + reply: { text: "⚙️ Usage: /activation mention|always|replies|mention+replies|never" }, }; } if (params.sessionEntry && params.sessionStore && params.sessionKey) { diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 0587343be..dd0d38f80 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" | "replies" | "mention+replies" | "never"; resolvedThink?: ThinkLevel; resolvedVerbose?: VerboseLevel; resolvedReasoning?: ReasoningLevel; diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 48ce428c1..11f691a90 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -54,7 +54,7 @@ export type SessionEntry = { authProfileOverride?: string; authProfileOverrideSource?: "auto" | "user"; authProfileOverrideCompactionCount?: number; - groupActivation?: "mention" | "always"; + groupActivation?: "mention" | "always" | "replies" | "mention+replies" | "never"; groupActivationNeedsSystemIntro?: boolean; sendPolicy?: "allow" | "deny"; queueMode?: diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index 3789cbae6..3e7c1ede6 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -303,7 +303,9 @@ export async function applySessionsPatchToStore(params: { } else if (raw !== undefined) { const normalized = normalizeGroupActivation(String(raw)); if (!normalized) { - return invalid('invalid groupActivation (use "mention"|"always")'); + return invalid( + 'invalid groupActivation (use "mention"|"always"|"replies"|"mention+replies"|"never")', + ); } next.groupActivation = normalized; } diff --git a/src/tui/commands.ts b/src/tui/commands.ts index f119a93ae..da43bd813 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -150,7 +150,7 @@ export function helpText(options: SlashCommandOptions = {}): string { "/usage ", "/elevated ", "/elev ", - "/activation ", + "/activation ", "/new or /reset", "/abort", "/settings", diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index a14172809..6ccd37923 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -391,7 +391,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { break; case "activation": if (!args) { - chatLog.addSystem("usage: /activation "); + chatLog.addSystem("usage: /activation "); break; } try { diff --git a/src/web/auto-reply/monitor/group-gating.ts b/src/web/auto-reply/monitor/group-gating.ts index 8d1a33645..5f2be9e0c 100644 --- a/src/web/auto-reply/monitor/group-gating.ts +++ b/src/web/auto-reply/monitor/group-gating.ts @@ -101,28 +101,47 @@ export function applyGroupGating(params: { sessionKey: params.sessionKey, conversationId: params.conversationId, }); - const requireMention = activation !== "always"; + + // Check if this message is a reply to the bot 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( + const isReplyToBot = Boolean( (selfJid && replySenderJid && selfJid === replySenderJid) || (selfE164 && replySenderE164 && selfE164 === replySenderE164), ); + + // Ensure safe default for shouldBypassMention in case it's undefined in some contexts + const safeShouldBypassMention = typeof shouldBypassMention !== "undefined" ? shouldBypassMention : false; + + // Determine if we should process based on activation mode + const shouldProcess = (() => { + if (activation === "always") return true; + if (activation === "never") return safeShouldBypassMention; + if (activation === "replies") return isReplyToBot || safeShouldBypassMention; + if (activation === "mention+replies") + return wasMentioned || isReplyToBot || safeShouldBypassMention; + // Default to "mention" mode + return wasMentioned || safeShouldBypassMention; + })(); + + // require mention only in strict 'mention' mode + const requireMention = activation === "mention"; const mentionGate = resolveMentionGating({ requireMention, canDetectMention: true, wasMentioned, - implicitMention, - shouldBypassMention, + implicitMention: isReplyToBot, // treat reply-to-bot as an implicit mention + shouldBypassMention: safeShouldBypassMention, }); params.msg.wasMentioned = mentionGate.effectiveWasMentioned; - if (!shouldBypassMention && requireMention && mentionGate.shouldSkip) { + + if (!shouldProcess) { params.logVerbose( - `Group message stored for context (no mention detected) in ${params.conversationId}: ${params.msg.body}`, + `Group message stored for context (no activation pass) in ${params.conversationId}: ${params.msg.body}`, ); const sender = params.msg.senderName && params.msg.senderE164