fix: tighten WhatsApp ack reactions and migrate config (#629) (thanks @pasogott)
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
- Telegram: add `/whoami` + `/id` commands to reveal sender id for allowlists.
|
- Telegram: add `/whoami` + `/id` commands to reveal sender id for allowlists.
|
||||||
- Telegram/Onboarding: allow `@username` and prefixed ids in `allowFrom` prompts (with stability warning).
|
- 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.
|
- 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
|
## 2026.1.11-6
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
- 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.
|
- Fire-and-forget: reaction failures are logged but don't prevent the bot from replying.
|
||||||
- Participant JID is automatically included for group reactions.
|
- Participant JID is automatically included for group reactions.
|
||||||
|
- WhatsApp ignores `messages.ackReaction`; use `whatsapp.ackReaction` instead.
|
||||||
|
|
||||||
## Agent tool (reactions)
|
## Agent tool (reactions)
|
||||||
- Tool: `whatsapp` with `react` action (`chatJid`, `messageId`, `emoji`, optional `remove`).
|
- Tool: `whatsapp` with `react` action (`chatJid`, `messageId`, `emoji`, optional `remove`).
|
||||||
|
|||||||
@@ -40,8 +40,10 @@ Directives (`/think`, `/verbose`, `/reasoning`, `/elevated`) are parsed even whe
|
|||||||
Text + native (when enabled):
|
Text + native (when enabled):
|
||||||
- `/help`
|
- `/help`
|
||||||
- `/commands`
|
- `/commands`
|
||||||
- `/status` (show current status; includes a short usage line when available; alias: `/usage`)
|
- `/status`
|
||||||
- `/whoami` (show your sender id; alias: `/id`)
|
- `/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`)
|
- `/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`)
|
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
|
||||||
- `/cost on|off` (toggle per-response usage line)
|
- `/cost on|off` (toggle per-response usage line)
|
||||||
|
|||||||
@@ -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 };
|
return { config: next, changes };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export type ResolvedWhatsAppAccount = {
|
|||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
|
ackReaction?: WhatsAppAccountConfig["ackReaction"];
|
||||||
groups?: WhatsAppAccountConfig["groups"];
|
groups?: WhatsAppAccountConfig["groups"];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,6 +130,7 @@ export function resolveWhatsAppAccount(params: {
|
|||||||
mediaMaxMb: accountCfg?.mediaMaxMb ?? params.cfg.whatsapp?.mediaMaxMb,
|
mediaMaxMb: accountCfg?.mediaMaxMb ?? params.cfg.whatsapp?.mediaMaxMb,
|
||||||
blockStreaming:
|
blockStreaming:
|
||||||
accountCfg?.blockStreaming ?? params.cfg.whatsapp?.blockStreaming,
|
accountCfg?.blockStreaming ?? params.cfg.whatsapp?.blockStreaming,
|
||||||
|
ackReaction: accountCfg?.ackReaction ?? params.cfg.whatsapp?.ackReaction,
|
||||||
groups: accountCfg?.groups ?? params.cfg.whatsapp?.groups,
|
groups: accountCfg?.groups ?? params.cfg.whatsapp?.groups,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,17 @@ describe("WhatsApp ack reaction logic", () => {
|
|||||||
}),
|
}),
|
||||||
).toBe(false);
|
).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", () => {
|
describe("group chat - always mode", () => {
|
||||||
@@ -257,4 +268,18 @@ describe("WhatsApp ack reaction logic", () => {
|
|||||||
).toBe(true);
|
).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -826,6 +826,7 @@ export async function monitorWebProvider(
|
|||||||
...baseCfg,
|
...baseCfg,
|
||||||
whatsapp: {
|
whatsapp: {
|
||||||
...baseCfg.whatsapp,
|
...baseCfg.whatsapp,
|
||||||
|
ackReaction: account.ackReaction,
|
||||||
messagePrefix: account.messagePrefix,
|
messagePrefix: account.messagePrefix,
|
||||||
allowFrom: account.allowFrom,
|
allowFrom: account.allowFrom,
|
||||||
groupAllowFrom: account.groupAllowFrom,
|
groupAllowFrom: account.groupAllowFrom,
|
||||||
@@ -1149,101 +1150,6 @@ export async function monitorWebProvider(
|
|||||||
status.lastMessageAt = Date.now();
|
status.lastMessageAt = Date.now();
|
||||||
status.lastEventAt = status.lastMessageAt;
|
status.lastEventAt = status.lastMessageAt;
|
||||||
emitStatus();
|
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;
|
const conversationId = msg.conversationId ?? msg.from;
|
||||||
let combinedBody = buildLine(msg, route.agentId);
|
let combinedBody = buildLine(msg, route.agentId);
|
||||||
let shouldClearGroupHistory = false;
|
let shouldClearGroupHistory = false;
|
||||||
@@ -1294,6 +1200,64 @@ export async function monitorWebProvider(
|
|||||||
return false;
|
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();
|
const correlationId = msg.id ?? newConnectionId();
|
||||||
replyLogger.info(
|
replyLogger.info(
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user