feat(whatsapp): add reply-based activation modes for groups

This commit adds more granular control over group activation with new modes:

- 'replies': Bot only responds when someone replies to one of its messages
- 'mention+replies': Bot responds to @mentions OR replies (most flexible)
- 'never': Bot never responds except for control commands from owners

The existing 'mention' (default) and 'always' modes remain unchanged.

**Implementation details:**
- Added new GroupActivationMode types: 'replies', 'mention+replies', 'never'
- Enhanced group-gating logic to detect replies to bot messages
- Updated all command help messages and documentation
- Reply detection uses both JID and E.164 matching for reliability

**Use cases:**
- 'replies': Reduces noise in busy groups, bot only joins when explicitly engaged
- 'mention+replies': Allows natural conversation flow after initial @mention
- 'never': Temporarily disable bot in a group without removing it

Closes: N/A (feature request)
This commit is contained in:
Rodrigo Gomes da Silva 2026-01-29 15:30:28 -03:00
parent 4583f88626
commit a4e45a9e05
7 changed files with 30 additions and 10 deletions

View File

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

View File

@ -21,7 +21,7 @@ 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)." },

View File

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

View File

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

View File

@ -150,7 +150,7 @@ export function helpText(options: SlashCommandOptions = {}): string {
"/usage <off|tokens|full>",
"/elevated <on|off|ask|full>",
"/elev <on|off|ask|full>",
"/activation <mention|always>",
"/activation <mention|always|replies|mention+replies|never>",
"/new or /reset",
"/abort",
"/settings",

View File

@ -391,7 +391,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
break;
case "activation":
if (!args) {
chatLog.addSystem("usage: /activation <mention|always>");
chatLog.addSystem("usage: /activation <mention|always|replies|mention+replies|never>");
break;
}
try {

View File

@ -101,26 +101,40 @@ 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),
);
// Determine if we should process based on activation mode
const shouldProcess = (() => {
if (activation === "always") return true;
if (activation === "never") return shouldBypassMention;
if (activation === "replies") return isReplyToBot || shouldBypassMention;
if (activation === "mention+replies") return wasMentioned || isReplyToBot || shouldBypassMention;
// Default to "mention" mode
return wasMentioned || shouldBypassMention;
})();
const requireMention = activation !== "always" && activation !== "replies";
const mentionGate = resolveMentionGating({
requireMention,
canDetectMention: true,
wasMentioned,
implicitMention,
implicitMention: isReplyToBot,
shouldBypassMention,
});
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}`,
);