fix: tighten WhatsApp ack reactions and migrate config (#629) (thanks @pasogott)

This commit is contained in:
Peter Steinberger
2026-01-11 04:09:14 +01:00
parent c928df7237
commit 38604acd94
7 changed files with 125 additions and 97 deletions

View File

@@ -6,6 +6,7 @@
- Telegram: add `/whoami` + `/id` commands to reveal sender id for allowlists.
- Telegram/Onboarding: allow `@username` and prefixed ids in `allowFrom` prompts (with stability warning).
- Control UI: stop auto-writing `telegram.groups["*"]` and warn/confirm before enabling wildcard groups.
- WhatsApp: send ack reactions only for handled messages and ignore legacy `messages.ackReaction` (doctor copies to `whatsapp.ackReaction`). (#629) — thanks @pasogott.
## 2026.1.11-6

View File

@@ -206,6 +206,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
- In groups with `requireMention: false` (activation: always), `group: "mentions"` will react to all messages (not just @mentions).
- Fire-and-forget: reaction failures are logged but don't prevent the bot from replying.
- Participant JID is automatically included for group reactions.
- WhatsApp ignores `messages.ackReaction`; use `whatsapp.ackReaction` instead.
## Agent tool (reactions)
- Tool: `whatsapp` with `react` action (`chatJid`, `messageId`, `emoji`, optional `remove`).

View File

@@ -40,8 +40,10 @@ Directives (`/think`, `/verbose`, `/reasoning`, `/elevated`) are parsed even whe
Text + native (when enabled):
- `/help`
- `/commands`
- `/status` (show current status; includes a short usage line when available; alias: `/usage`)
- `/whoami` (show your sender id; alias: `/id`)
- `/status`
- `/status` (show current status; includes a short usage line when available)
- `/usage` (alias: `/status`)
- `/whoami` (alias: `/id`)
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
- `/cost on|off` (toggle per-response usage line)

View File

@@ -251,6 +251,39 @@ export function normalizeLegacyConfigValues(cfg: ClawdbotConfig): {
}
}
const legacyAckReaction = cfg.messages?.ackReaction?.trim();
if (legacyAckReaction) {
const hasWhatsAppAck = cfg.whatsapp?.ackReaction !== undefined;
if (!hasWhatsAppAck) {
const legacyScope = cfg.messages?.ackReactionScope ?? "group-mentions";
let direct = true;
let group: "always" | "mentions" | "never" = "mentions";
if (legacyScope === "all") {
direct = true;
group = "always";
} else if (legacyScope === "direct") {
direct = true;
group = "never";
} else if (legacyScope === "group-all") {
direct = false;
group = "always";
} else if (legacyScope === "group-mentions") {
direct = false;
group = "mentions";
}
next = {
...next,
whatsapp: {
...next.whatsapp,
ackReaction: { emoji: legacyAckReaction, direct, group },
},
};
changes.push(
`Copied messages.ackReaction → whatsapp.ackReaction (scope: ${legacyScope}).`,
);
}
}
return { config: next, changes };
}

View File

@@ -26,6 +26,7 @@ export type ResolvedWhatsAppAccount = {
textChunkLimit?: number;
mediaMaxMb?: number;
blockStreaming?: boolean;
ackReaction?: WhatsAppAccountConfig["ackReaction"];
groups?: WhatsAppAccountConfig["groups"];
};
@@ -129,6 +130,7 @@ export function resolveWhatsAppAccount(params: {
mediaMaxMb: accountCfg?.mediaMaxMb ?? params.cfg.whatsapp?.mediaMaxMb,
blockStreaming:
accountCfg?.blockStreaming ?? params.cfg.whatsapp?.blockStreaming,
ackReaction: accountCfg?.ackReaction ?? params.cfg.whatsapp?.ackReaction,
groups: accountCfg?.groups ?? params.cfg.whatsapp?.groups,
};
}

View File

@@ -76,6 +76,17 @@ describe("WhatsApp ack reaction logic", () => {
}),
).toBe(false);
});
it("should not react when message id is missing", () => {
const cfg: ClawdbotConfig = {
whatsapp: { ackReaction: { emoji: "👀", direct: true } },
};
expect(
shouldSendReaction(cfg, {
chatType: "direct",
}),
).toBe(false);
});
});
describe("group chat - always mode", () => {
@@ -257,4 +268,18 @@ describe("WhatsApp ack reaction logic", () => {
).toBe(true);
});
});
describe("legacy config is ignored", () => {
it("does not use messages.ackReaction for WhatsApp", () => {
const cfg: ClawdbotConfig = {
messages: { ackReaction: "👀", ackReactionScope: "all" },
};
expect(
shouldSendReaction(cfg, {
id: "m1",
chatType: "direct",
}),
).toBe(false);
});
});
});

View File

@@ -826,6 +826,7 @@ export async function monitorWebProvider(
...baseCfg,
whatsapp: {
...baseCfg.whatsapp,
ackReaction: account.ackReaction,
messagePrefix: account.messagePrefix,
allowFrom: account.allowFrom,
groupAllowFrom: account.groupAllowFrom,
@@ -1149,101 +1150,6 @@ export async function monitorWebProvider(
status.lastMessageAt = Date.now();
status.lastEventAt = status.lastMessageAt;
emitStatus();
// Send ack reaction immediately upon message receipt
if (msg.id) {
const ackConfig = cfg.whatsapp?.ackReaction;
// Backward compatibility: support old messages.ackReaction format (legacy, undocumented)
const messages = cfg.messages as
| undefined
| (typeof cfg.messages & {
ackReaction?: string;
ackReactionScope?: string;
});
const legacyEmoji = messages?.ackReaction;
const legacyScope = messages?.ackReactionScope;
let emoji = (ackConfig?.emoji ?? "").trim();
let directEnabled = ackConfig?.direct ?? true;
let groupMode = ackConfig?.group ?? "mentions";
// Fallback to legacy config if new config is not set
if (!emoji && typeof legacyEmoji === "string") {
emoji = legacyEmoji.trim();
if (legacyScope === "all") {
directEnabled = true;
groupMode = "always";
} else if (legacyScope === "direct") {
directEnabled = true;
groupMode = "never";
} else if (legacyScope === "group-all") {
directEnabled = false;
groupMode = "always";
} else if (legacyScope === "group-mentions") {
directEnabled = false;
groupMode = "mentions";
}
}
const conversationIdForCheck = msg.conversationId ?? msg.from;
const shouldSendReaction = () => {
if (!emoji) return false;
// Direct chat logic
if (msg.chatType === "direct") {
return directEnabled;
}
// Group chat logic
if (msg.chatType === "group") {
if (groupMode === "never") return false;
if (groupMode === "always") {
// Always react to group messages
return true;
}
if (groupMode === "mentions") {
// Check if group has requireMention setting
const activation = resolveGroupActivationFor({
agentId: route.agentId,
sessionKey: route.sessionKey,
conversationId: conversationIdForCheck,
});
// If group activation is "always" (requireMention=false), react to all
if (activation === "always") return true;
// Otherwise, only react if bot was mentioned
return msg.wasMentioned === true;
}
}
return false;
};
if (shouldSendReaction()) {
replyLogger.info(
{ chatId: msg.chatId, messageId: msg.id, emoji },
"sending ack reaction",
);
sendReactionWhatsApp(msg.chatId, msg.id, emoji, {
verbose,
fromMe: false,
participant: msg.senderJid,
accountId: route.accountId,
}).catch((err) => {
replyLogger.warn(
{
error: formatError(err),
chatId: msg.chatId,
messageId: msg.id,
},
"failed to send ack reaction",
);
logVerbose(
`WhatsApp ack reaction failed for chat ${msg.chatId}: ${formatError(err)}`,
);
});
}
}
const conversationId = msg.conversationId ?? msg.from;
let combinedBody = buildLine(msg, route.agentId);
let shouldClearGroupHistory = false;
@@ -1294,6 +1200,64 @@ export async function monitorWebProvider(
return false;
}
// Send ack reaction immediately upon message receipt (post-gating)
if (msg.id) {
const ackConfig = cfg.whatsapp?.ackReaction;
const emoji = (ackConfig?.emoji ?? "").trim();
const directEnabled = ackConfig?.direct ?? true;
const groupMode = ackConfig?.group ?? "mentions";
const conversationIdForCheck = msg.conversationId ?? msg.from;
const shouldSendReaction = () => {
if (!emoji) return false;
if (msg.chatType === "direct") {
return directEnabled;
}
if (msg.chatType === "group") {
if (groupMode === "never") return false;
if (groupMode === "always") return true;
if (groupMode === "mentions") {
const activation = resolveGroupActivationFor({
agentId: route.agentId,
sessionKey: route.sessionKey,
conversationId: conversationIdForCheck,
});
if (activation === "always") return true;
return msg.wasMentioned === true;
}
}
return false;
};
if (shouldSendReaction()) {
replyLogger.info(
{ chatId: msg.chatId, messageId: msg.id, emoji },
"sending ack reaction",
);
sendReactionWhatsApp(msg.chatId, msg.id, emoji, {
verbose,
fromMe: false,
participant: msg.senderJid,
accountId: route.accountId,
}).catch((err) => {
replyLogger.warn(
{
error: formatError(err),
chatId: msg.chatId,
messageId: msg.id,
},
"failed to send ack reaction",
);
logVerbose(
`WhatsApp ack reaction failed for chat ${msg.chatId}: ${formatError(err)}`,
);
});
}
}
const correlationId = msg.id ?? newConnectionId();
replyLogger.info(
{