Merge pull request #629 from pasogott/feature/whatsapp-ack-reaction
feat(whatsapp): add acknowledgment reactions
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -159,6 +159,55 @@ Behavior:
|
||||
- WhatsApp Web sends standard messages (no quoted reply threading in the current gateway).
|
||||
- Reply tags are ignored on this provider.
|
||||
|
||||
## Acknowledgment reactions (auto-react on receipt)
|
||||
|
||||
WhatsApp can automatically send emoji reactions to incoming messages immediately upon receipt, before the bot generates a reply. This provides instant feedback to users that their message was received.
|
||||
|
||||
**Configuration:**
|
||||
```json
|
||||
{
|
||||
"whatsapp": {
|
||||
"ackReaction": {
|
||||
"emoji": "👀",
|
||||
"direct": true,
|
||||
"group": "mentions"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `emoji` (string): Emoji to use for acknowledgment (e.g., "👀", "✅", "📨"). Empty or omitted = feature disabled.
|
||||
- `direct` (boolean, default: `true`): Send reactions in direct/DM chats.
|
||||
- `group` (string, default: `"mentions"`): Group chat behavior:
|
||||
- `"always"`: React to all group messages (even without @mention)
|
||||
- `"mentions"`: React only when bot is @mentioned
|
||||
- `"never"`: Never react in groups
|
||||
|
||||
**Per-account override:**
|
||||
```json
|
||||
{
|
||||
"whatsapp": {
|
||||
"accounts": {
|
||||
"work": {
|
||||
"ackReaction": {
|
||||
"emoji": "✅",
|
||||
"direct": false,
|
||||
"group": "always"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior notes:**
|
||||
- Reactions are sent **immediately** upon message receipt, before typing indicators or bot replies.
|
||||
- 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`).
|
||||
- Optional: `participant` (group sender), `fromMe` (reacting to your own message), `accountId` (multi-account).
|
||||
@@ -205,8 +254,10 @@ Behavior:
|
||||
- `whatsapp.selfChatMode` (same-phone setup; bot uses your personal WhatsApp number).
|
||||
- `whatsapp.allowFrom` (DM allowlist).
|
||||
- `whatsapp.mediaMaxMb` (inbound media save cap).
|
||||
- `whatsapp.ackReaction` (auto-reaction on message receipt: `{emoji, direct, group}`).
|
||||
- `whatsapp.accounts.<accountId>.*` (per-account settings + optional `authDir`).
|
||||
- `whatsapp.accounts.<accountId>.mediaMaxMb` (per-account inbound media cap).
|
||||
- `whatsapp.accounts.<accountId>.ackReaction` (per-account ack reaction override).
|
||||
- `whatsapp.groupAllowFrom` (group sender allowlist).
|
||||
- `whatsapp.groupPolicy` (group policy).
|
||||
- `whatsapp.historyLimit` / `whatsapp.accounts.<accountId>.historyLimit` (group history context; `0` disables).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -169,6 +169,21 @@ export type WhatsAppConfig = {
|
||||
requireMention?: boolean;
|
||||
}
|
||||
>;
|
||||
/** Acknowledgment reaction sent immediately upon message receipt. */
|
||||
ackReaction?: {
|
||||
/** Emoji to use for acknowledgment (e.g., "👀"). Empty = disabled. */
|
||||
emoji?: string;
|
||||
/** Send reactions in direct chats. Default: true. */
|
||||
direct?: boolean;
|
||||
/**
|
||||
* Send reactions in group chats:
|
||||
* - "always": react to all group messages
|
||||
* - "mentions": react only when bot is mentioned
|
||||
* - "never": never react in groups
|
||||
* Default: "mentions"
|
||||
*/
|
||||
group?: "always" | "mentions" | "never";
|
||||
};
|
||||
};
|
||||
|
||||
export type WhatsAppAccountConfig = {
|
||||
@@ -202,6 +217,21 @@ export type WhatsAppAccountConfig = {
|
||||
requireMention?: boolean;
|
||||
}
|
||||
>;
|
||||
/** Acknowledgment reaction sent immediately upon message receipt. */
|
||||
ackReaction?: {
|
||||
/** Emoji to use for acknowledgment (e.g., "👀"). Empty = disabled. */
|
||||
emoji?: string;
|
||||
/** Send reactions in direct chats. Default: true. */
|
||||
direct?: boolean;
|
||||
/**
|
||||
* Send reactions in group chats:
|
||||
* - "always": react to all group messages
|
||||
* - "mentions": react only when bot is mentioned
|
||||
* - "never": never react in groups
|
||||
* Default: "mentions"
|
||||
*/
|
||||
group?: "always" | "mentions" | "never";
|
||||
};
|
||||
};
|
||||
|
||||
export type BrowserProfileConfig = {
|
||||
|
||||
@@ -1375,6 +1375,16 @@ export const ClawdbotSchema = z
|
||||
.optional(),
|
||||
)
|
||||
.optional(),
|
||||
ackReaction: z
|
||||
.object({
|
||||
emoji: z.string().optional(),
|
||||
direct: z.boolean().optional().default(true),
|
||||
group: z
|
||||
.enum(["always", "mentions", "never"])
|
||||
.optional()
|
||||
.default("mentions"),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.dmPolicy !== "open") return;
|
||||
@@ -1421,6 +1431,16 @@ export const ClawdbotSchema = z
|
||||
.optional(),
|
||||
)
|
||||
.optional(),
|
||||
ackReaction: z
|
||||
.object({
|
||||
emoji: z.string().optional(),
|
||||
direct: z.boolean().optional().default(true),
|
||||
group: z
|
||||
.enum(["always", "mentions", "never"])
|
||||
.optional()
|
||||
.default("mentions"),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.dmPolicy !== "open") return;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
285
src/web/auto-reply.ack-reaction.test.ts
Normal file
285
src/web/auto-reply.ack-reaction.test.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ClawdbotConfig } from "../config/types.js";
|
||||
|
||||
describe("WhatsApp ack reaction logic", () => {
|
||||
// Helper to simulate the logic from auto-reply.ts
|
||||
function shouldSendReaction(
|
||||
cfg: ClawdbotConfig,
|
||||
msg: {
|
||||
id?: string;
|
||||
chatType: "direct" | "group";
|
||||
wasMentioned?: boolean;
|
||||
},
|
||||
groupActivation?: "always" | "mention",
|
||||
): boolean {
|
||||
const ackConfig = cfg.whatsapp?.ackReaction;
|
||||
const emoji = (ackConfig?.emoji ?? "").trim();
|
||||
const directEnabled = ackConfig?.direct ?? true;
|
||||
const groupMode = ackConfig?.group ?? "mentions";
|
||||
|
||||
if (!emoji) return false;
|
||||
if (!msg.id) 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") return true;
|
||||
if (groupMode === "mentions") {
|
||||
// If group activation is "always", always react
|
||||
if (groupActivation === "always") return true;
|
||||
// Otherwise, only react if bot was mentioned
|
||||
return msg.wasMentioned === true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
describe("direct chat", () => {
|
||||
it("should react when direct=true", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
whatsapp: { ackReaction: { emoji: "👀", direct: true } },
|
||||
};
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "msg1",
|
||||
chatType: "direct",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should not react when direct=false", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
whatsapp: { ackReaction: { emoji: "👀", direct: false } },
|
||||
};
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "msg1",
|
||||
chatType: "direct",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("should not react when emoji is empty", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
whatsapp: { ackReaction: { emoji: "", direct: true } },
|
||||
};
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "msg1",
|
||||
chatType: "direct",
|
||||
}),
|
||||
).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", () => {
|
||||
it("should react to all messages when group=always", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
whatsapp: { ackReaction: { emoji: "👀", group: "always" } },
|
||||
};
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "msg1",
|
||||
chatType: "group",
|
||||
wasMentioned: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should react even with mention when group=always", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
whatsapp: { ackReaction: { emoji: "👀", group: "always" } },
|
||||
};
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "msg1",
|
||||
chatType: "group",
|
||||
wasMentioned: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("group chat - mentions mode", () => {
|
||||
it("should react when mentioned", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
whatsapp: { ackReaction: { emoji: "👀", group: "mentions" } },
|
||||
};
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "msg1",
|
||||
chatType: "group",
|
||||
wasMentioned: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should not react when not mentioned", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
whatsapp: { ackReaction: { emoji: "👀", group: "mentions" } },
|
||||
};
|
||||
expect(
|
||||
shouldSendReaction(
|
||||
cfg,
|
||||
{
|
||||
id: "msg1",
|
||||
chatType: "group",
|
||||
wasMentioned: false,
|
||||
},
|
||||
"mention", // group activation
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("should react to all messages when group activation is always", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
whatsapp: { ackReaction: { emoji: "👀", group: "mentions" } },
|
||||
};
|
||||
expect(
|
||||
shouldSendReaction(
|
||||
cfg,
|
||||
{
|
||||
id: "msg1",
|
||||
chatType: "group",
|
||||
wasMentioned: false,
|
||||
},
|
||||
"always", // group has requireMention=false
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("group chat - never mode", () => {
|
||||
it("should not react even with mention", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
whatsapp: { ackReaction: { emoji: "👀", group: "never" } },
|
||||
};
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "msg1",
|
||||
chatType: "group",
|
||||
wasMentioned: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("should not react without mention", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
whatsapp: { ackReaction: { emoji: "👀", group: "never" } },
|
||||
};
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "msg1",
|
||||
chatType: "group",
|
||||
wasMentioned: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combinations", () => {
|
||||
it("direct=false, group=always: only groups", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
whatsapp: {
|
||||
ackReaction: { emoji: "✅", direct: false, group: "always" },
|
||||
},
|
||||
};
|
||||
|
||||
expect(shouldSendReaction(cfg, { id: "m1", chatType: "direct" })).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "m2",
|
||||
chatType: "group",
|
||||
wasMentioned: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("direct=true, group=never: only direct", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
whatsapp: {
|
||||
ackReaction: { emoji: "🤖", direct: true, group: "never" },
|
||||
},
|
||||
};
|
||||
|
||||
expect(shouldSendReaction(cfg, { id: "m1", chatType: "direct" })).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "m2",
|
||||
chatType: "group",
|
||||
wasMentioned: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaults", () => {
|
||||
it("should default direct=true", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
whatsapp: { ackReaction: { emoji: "👀" } },
|
||||
};
|
||||
expect(shouldSendReaction(cfg, { id: "m1", chatType: "direct" })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("should default group=mentions", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
whatsapp: { ackReaction: { emoji: "👀" } },
|
||||
};
|
||||
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "m1",
|
||||
chatType: "group",
|
||||
wasMentioned: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "m2",
|
||||
chatType: "group",
|
||||
wasMentioned: 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -68,7 +68,7 @@ import { resolveWhatsAppAccount } from "./accounts.js";
|
||||
import { setActiveWebListener } from "./active-listener.js";
|
||||
import { monitorWebInbox } from "./inbound.js";
|
||||
import { loadWebMedia } from "./media.js";
|
||||
import { sendMessageWhatsApp } from "./outbound.js";
|
||||
import { sendMessageWhatsApp, sendReactionWhatsApp } from "./outbound.js";
|
||||
import {
|
||||
computeBackoff,
|
||||
newConnectionId,
|
||||
@@ -826,6 +826,7 @@ export async function monitorWebProvider(
|
||||
...baseCfg,
|
||||
whatsapp: {
|
||||
...baseCfg.whatsapp,
|
||||
ackReaction: account.ackReaction,
|
||||
messagePrefix: account.messagePrefix,
|
||||
allowFrom: account.allowFrom,
|
||||
groupAllowFrom: account.groupAllowFrom,
|
||||
@@ -1199,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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user