diff --git a/src/web/auto-reply.ack-reaction.test.ts b/src/web/auto-reply.ack-reaction.test.ts new file mode 100644 index 000000000..922c0b353 --- /dev/null +++ b/src/web/auto-reply.ack-reaction.test.ts @@ -0,0 +1,266 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ClawdbotConfig } from "../config/types.js"; + +describe("WhatsApp ack reaction", () => { + const mockSendReaction = vi.fn(async () => {}); + const mockGetReply = vi.fn(async () => ({ + payloads: [{ text: "test reply" }], + meta: {}, + })); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should send ack reaction in direct chat when scope is 'all'", async () => { + const cfg: ClawdbotConfig = { + messages: { + ackReaction: "👀", + ackReactionScope: "all", + }, + }; + + // Simulate the logic from auto-reply.ts + const msg = { + id: "msg123", + chatId: "123456789@s.whatsapp.net", + chatType: "direct" as const, + from: "+1234567890", + to: "+9876543210", + body: "hello", + }; + + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const didSendReply = true; + + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (!msg.id) return false; + if (!didSendReply) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return msg.chatType === "direct"; + if (ackReactionScope === "group-all") return msg.chatType === "group"; + if (ackReactionScope === "group-mentions") { + if (msg.chatType !== "group") return false; + return false; // Would check wasMentioned + } + return false; + }; + + expect(shouldAckReaction()).toBe(true); + }); + + it("should send ack reaction in direct chat when scope is 'direct'", async () => { + const cfg: ClawdbotConfig = { + messages: { + ackReaction: "👀", + ackReactionScope: "direct", + }, + }; + + const msg = { + id: "msg123", + chatId: "123456789@s.whatsapp.net", + chatType: "direct" as const, + from: "+1234567890", + to: "+9876543210", + body: "hello", + }; + + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const didSendReply = true; + + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (!msg.id) return false; + if (!didSendReply) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return msg.chatType === "direct"; + if (ackReactionScope === "group-all") return msg.chatType === "group"; + return false; + }; + + expect(shouldAckReaction()).toBe(true); + }); + + it("should NOT send ack reaction in group when scope is 'direct'", async () => { + const cfg: ClawdbotConfig = { + messages: { + ackReaction: "👀", + ackReactionScope: "direct", + }, + }; + + const msg = { + id: "msg123", + chatId: "123456789-group@g.us", + chatType: "group" as const, + from: "123456789-group@g.us", + to: "+9876543210", + body: "hello", + wasMentioned: true, + }; + + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const didSendReply = true; + + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (!msg.id) return false; + if (!didSendReply) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return msg.chatType === "direct"; + if (ackReactionScope === "group-all") return msg.chatType === "group"; + return false; + }; + + expect(shouldAckReaction()).toBe(false); + }); + + it("should send ack reaction in group when mentioned and scope is 'group-mentions'", async () => { + const cfg: ClawdbotConfig = { + messages: { + ackReaction: "👀", + ackReactionScope: "group-mentions", + }, + }; + + const msg = { + id: "msg123", + chatId: "123456789-group@g.us", + chatType: "group" as const, + from: "123456789-group@g.us", + to: "+9876543210", + body: "hello @bot", + wasMentioned: true, + }; + + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const didSendReply = true; + const requireMention = true; // Simulated from activation check + + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (!msg.id) return false; + if (!didSendReply) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return msg.chatType === "direct"; + if (ackReactionScope === "group-all") return msg.chatType === "group"; + if (ackReactionScope === "group-mentions") { + if (msg.chatType !== "group") return false; + if (!requireMention) return false; + return msg.wasMentioned === true; + } + return false; + }; + + expect(shouldAckReaction()).toBe(true); + }); + + it("should NOT send ack reaction in group when NOT mentioned and scope is 'group-mentions'", async () => { + const cfg: ClawdbotConfig = { + messages: { + ackReaction: "👀", + ackReactionScope: "group-mentions", + }, + }; + + const msg = { + id: "msg123", + chatId: "123456789-group@g.us", + chatType: "group" as const, + from: "123456789-group@g.us", + to: "+9876543210", + body: "hello", + wasMentioned: false, + }; + + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const didSendReply = true; + const requireMention = true; + + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (!msg.id) return false; + if (!didSendReply) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return msg.chatType === "direct"; + if (ackReactionScope === "group-all") return msg.chatType === "group"; + if (ackReactionScope === "group-mentions") { + if (msg.chatType !== "group") return false; + if (!requireMention) return false; + return msg.wasMentioned === true; + } + return false; + }; + + expect(shouldAckReaction()).toBe(false); + }); + + it("should NOT send ack reaction when no reply was sent", async () => { + const cfg: ClawdbotConfig = { + messages: { + ackReaction: "👀", + ackReactionScope: "all", + }, + }; + + const msg = { + id: "msg123", + chatId: "123456789@s.whatsapp.net", + chatType: "direct" as const, + from: "+1234567890", + to: "+9876543210", + body: "hello", + }; + + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const didSendReply = false; // No reply sent + + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (!msg.id) return false; + if (!didSendReply) return false; + return true; + }; + + expect(shouldAckReaction()).toBe(false); + }); + + it("should NOT send ack reaction when ackReaction is empty", async () => { + const cfg: ClawdbotConfig = { + messages: { + ackReaction: "", + ackReactionScope: "all", + }, + }; + + const msg = { + id: "msg123", + chatId: "123456789@s.whatsapp.net", + chatType: "direct" as const, + from: "+1234567890", + to: "+9876543210", + body: "hello", + }; + + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const didSendReply = true; + + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (!msg.id) return false; + if (!didSendReply) return false; + return true; + }; + + expect(shouldAckReaction()).toBe(false); + }); +}); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index c41f6de11..f3a77eed9 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -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, @@ -1387,6 +1387,45 @@ export async function monitorWebProvider( groupHistories.set(groupHistoryKey, []); } + // Send ack reaction after successful reply + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (!msg.id) return false; + if (!didSendReply) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return msg.chatType === "direct"; + if (ackReactionScope === "group-all") return msg.chatType === "group"; + if (ackReactionScope === "group-mentions") { + if (msg.chatType !== "group") return false; + const activation = resolveGroupActivationFor({ + agentId: route.agentId, + sessionKey: route.sessionKey, + conversationId, + }); + const requireMention = activation !== "always"; + if (!requireMention) return false; + return msg.wasMentioned === true; + } + return false; + }; + + if (shouldAckReaction() && msg.id) { + sendReactionWhatsApp(msg.chatId, msg.id, ackReaction, { + verbose, + fromMe: false, + }).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)}`, + ); + }); + } + return didSendReply; };