From 9b93b7df62c1ee8d2e4ef40ef665c7d43f4f1095 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 05:35:24 +0100 Subject: [PATCH] fix(whatsapp): remove ack reactions after replies --- CHANGELOG.md | 1 + docs/channels/whatsapp.md | 1 + docs/gateway/config-agents.md | 2 +- .../auto-reply/monitor/ack-reaction.test.ts | 45 ++++++++++- .../src/auto-reply/monitor/ack-reaction.ts | 46 ++++++----- .../src/auto-reply/monitor/broadcast.ts | 7 ++ .../on-message.audio-preflight.test.ts | 15 +++- .../src/auto-reply/monitor/on-message.ts | 12 ++- .../process-message.audio-preflight.test.ts | 76 ++++++++++++++++++- .../src/auto-reply/monitor/process-message.ts | 26 ++++++- src/channels/ack-reactions.test.ts | 61 +++++++++++++++ src/channels/ack-reactions.ts | 51 +++++++++++++ src/plugin-sdk/channel-feedback.ts | 3 + src/plugins/runtime/runtime-channel.ts | 9 ++- src/plugins/runtime/types-channel.ts | 2 + test/helpers/plugins/plugin-runtime-mock.ts | 4 + 16 files changed, 329 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d4d24d0f4e..cc927407b68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ Docs: https://docs.openclaw.ai `openclaw node start` command, and show an actionable browser-control error when the local control service is missing. Fixes #66637. - Gateway/update: fail package updates when the restarted managed gateway reports the wrong version, avoiding false-success mixed-version restarts after macOS LaunchAgent updates. Fixes #71835. Thanks @abhinas90 and @jsompis. +- WhatsApp: remove ack reactions after a visible reply when `messages.removeAckAfterReply` is enabled, matching other reaction-capable channels. Fixes #26183. Thanks @MrUnforsaken. - Providers/Z.AI: map OpenClaw thinking controls to Z.AI's `thinking` payload and add opt-in preserved thinking replay via `params.preserveThinking`, so GLM 5.x can keep prior `reasoning_content` when requested. Fixes #58680. Thanks @xuanmingguo. - Channels/status: keep read-only channel lists on manifest and package metadata by default, loading setup runtime only for explicit fallback callers. Thanks @shakkernerd. - Plugins/onboarding: defer onboarding install-record index writes until the guarded config commit so setup failures cannot leave the plugin index ahead of `openclaw.json`. Thanks @shakkernerd. diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index 1c75496f9ab..022ef1062a3 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -151,6 +151,7 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch - Direct chats use DM session rules (`session.dmScope`; default `main` collapses DMs to the agent main session). - Group sessions are isolated (`agent::whatsapp:group:`). - WhatsApp Web transport honors standard proxy environment variables on the gateway host (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY` / lowercase variants). Prefer host-level proxy config over channel-specific WhatsApp proxy settings. +- When `messages.removeAckAfterReply` is enabled, OpenClaw clears the WhatsApp ack reaction after a visible reply is delivered. ## Plugin hooks and privacy diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 2586567c3dc..240788da51e 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -1257,7 +1257,7 @@ Variables are case-insensitive. `{think}` is an alias for `{thinkingLevel}`. - Per-channel overrides: `channels..ackReaction`, `channels..accounts..ackReaction`. - Resolution order: account → channel → `messages.ackReaction` → identity fallback. - Scope: `group-mentions` (default), `group-all`, `direct`, `all`. -- `removeAckAfterReply`: removes ack after reply on Slack, Discord, and Telegram. +- `removeAckAfterReply`: removes ack after reply on reaction-capable channels such as Slack, Discord, Telegram, WhatsApp, and BlueBubbles. - `messages.statusReactions.enabled`: enables lifecycle status reactions on Slack, Discord, and Telegram. On Slack and Discord, unset keeps status reactions enabled when ack reactions are active. On Telegram, set it explicitly to `true` to enable lifecycle status reactions. diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts index 2ad7b82c0c4..1bfb5beaaed 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts @@ -71,7 +71,6 @@ const expectAckReactionSent = (accountId: string) => { expect.objectContaining({ verbose: false, fromMe: false, - participant: undefined, accountId, }), ); @@ -85,24 +84,27 @@ describe("maybeSendAckReaction", () => { it.each(["ack", "minimal", "extensive"] as const)( "sends ack reactions when reactionLevel is %s", async (reactionLevel) => { - await runAckReaction({ + const ackReaction = await runAckReaction({ cfg: createConfig(reactionLevel), }); + expect(ackReaction?.ackReactionValue).toBe("👀"); + await expect(ackReaction?.ackReactionPromise).resolves.toBe(true); expectAckReactionSent("default"); }, ); it("suppresses ack reactions when reactionLevel is off", async () => { - await runAckReaction({ + const ackReaction = await runAckReaction({ cfg: createConfig("off"), }); + expect(ackReaction).toBeNull(); expect(hoisted.sendReactionWhatsApp).not.toHaveBeenCalled(); }); it("uses the active account reactionLevel override for ack gating", async () => { - await runAckReaction({ + const ackReaction = await runAckReaction({ cfg: createConfig("off", { accounts: { work: { @@ -117,6 +119,41 @@ describe("maybeSendAckReaction", () => { accountId: "work", }); + expect(ackReaction?.ackReactionValue).toBe("👀"); expectAckReactionSent("work"); }); + + it("returns a handle that removes the ack with an empty reaction", async () => { + const ackReaction = await runAckReaction(); + + await ackReaction?.remove(); + + expect(hoisted.sendReactionWhatsApp).toHaveBeenLastCalledWith( + "15551234567@s.whatsapp.net", + "msg-1", + "", + expect.objectContaining({ + verbose: false, + fromMe: false, + accountId: "default", + }), + ); + }); + + it("records ack send failures on the handle", async () => { + const warn = vi.fn(); + hoisted.sendReactionWhatsApp.mockRejectedValueOnce(new Error("session down")); + + const ackReaction = await runAckReaction({ warn }); + + await expect(ackReaction?.ackReactionPromise).resolves.toBe(false); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ + error: "session down", + chatId: "15551234567@s.whatsapp.net", + messageId: "msg-1", + }), + "failed to send ack reaction", + ); + }); }); diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts index f71c992a4c4..6079eb85ac6 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts @@ -1,4 +1,8 @@ -import { shouldAckReactionForWhatsApp } from "openclaw/plugin-sdk/channel-feedback"; +import { + createAckReactionHandle, + shouldAckReactionForWhatsApp, + type AckReactionHandle, +} from "openclaw/plugin-sdk/channel-feedback"; import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { getSenderIdentity } from "../../identity.js"; @@ -18,9 +22,9 @@ export async function maybeSendAckReaction(params: { accountId?: string; info: (obj: unknown, msg: string) => void; warn: (obj: unknown, msg: string) => void; -}) { +}): Promise { if (!params.msg.id) { - return; + return null; } // Keep ackReaction as the emoji/scope control, while letting reactionLevel @@ -30,7 +34,7 @@ export async function maybeSendAckReaction(params: { accountId: params.accountId, }); if (reactionLevel.level === "off") { - return; + return null; } const ackConfig = params.cfg.channels?.whatsapp?.ackReaction; @@ -61,7 +65,7 @@ export async function maybeSendAckReaction(params: { }); if (!shouldSendReaction()) { - return; + return null; } params.info( @@ -69,21 +73,27 @@ export async function maybeSendAckReaction(params: { "sending ack reaction", ); const sender = getSenderIdentity(params.msg); - sendReactionWhatsApp(params.msg.chatId, params.msg.id, emoji, { + const reactionOptions = { verbose: params.verbose, fromMe: false, - participant: sender.jid ?? undefined, - accountId: params.accountId, + ...(sender.jid ? { participant: sender.jid } : {}), + ...(params.accountId ? { accountId: params.accountId } : {}), cfg: params.cfg, - }).catch((err) => { - params.warn( - { - error: formatError(err), - chatId: params.msg.chatId, - messageId: params.msg.id, - }, - "failed to send ack reaction", - ); - logVerbose(`WhatsApp ack reaction failed for chat ${params.msg.chatId}: ${formatError(err)}`); + }; + return createAckReactionHandle({ + ackReactionValue: emoji, + send: () => sendReactionWhatsApp(params.msg.chatId, params.msg.id!, emoji, reactionOptions), + remove: () => sendReactionWhatsApp(params.msg.chatId, params.msg.id!, "", reactionOptions), + onSendError: (err) => { + params.warn( + { + error: formatError(err), + chatId: params.msg.chatId, + messageId: params.msg.id, + }, + "failed to send ack reaction", + ); + logVerbose(`WhatsApp ack reaction failed for chat ${params.msg.chatId}: ${formatError(err)}`); + }, }); } diff --git a/extensions/whatsapp/src/auto-reply/monitor/broadcast.ts b/extensions/whatsapp/src/auto-reply/monitor/broadcast.ts index 4842fa998ab..7ceb697cdc0 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/broadcast.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/broadcast.ts @@ -1,3 +1,4 @@ +import type { AckReactionHandle } from "openclaw/plugin-sdk/channel-feedback"; import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import type { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { buildAgentSessionKey, deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing"; @@ -61,10 +62,12 @@ export async function maybeBroadcastMessage(params: { suppressGroupHistoryClear?: boolean; preflightAudioTranscript?: string | null; ackAlreadySent?: boolean; + ackReaction?: AckReactionHandle | null; }, ) => Promise; preflightAudioTranscript?: string | null; ackAlreadySent?: boolean; + ackReaction?: AckReactionHandle | null; }) { const broadcastAgents = params.cfg.broadcast?.[params.peerId]; if (!broadcastAgents || !Array.isArray(broadcastAgents)) { @@ -113,6 +116,7 @@ export async function maybeBroadcastMessage(params: { suppressGroupHistoryClear: true; preflightAudioTranscript?: string | null; ackAlreadySent?: boolean; + ackReaction?: AckReactionHandle | null; } = { groupHistory: groupHistorySnapshot, suppressGroupHistoryClear: true, @@ -123,6 +127,9 @@ export async function maybeBroadcastMessage(params: { if (params.ackAlreadySent === true) { opts.ackAlreadySent = true; } + if (params.ackReaction !== undefined) { + opts.ackReaction = params.ackReaction; + } return await params.processMessage(params.msg, agentRoute, params.groupHistoryKey, opts); } catch (err) { whatsappInboundLog.error(`Broadcast agent ${agentId} failed: ${formatError(err)}`); diff --git a/extensions/whatsapp/src/auto-reply/monitor/on-message.audio-preflight.test.ts b/extensions/whatsapp/src/auto-reply/monitor/on-message.audio-preflight.test.ts index 85a119c40de..41d7ffa873b 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/on-message.audio-preflight.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/on-message.audio-preflight.test.ts @@ -5,6 +5,11 @@ const transcribeFirstAudioMock = vi.fn(); const maybeSendAckReactionMock = vi.fn(); const processMessageMock = vi.fn(); const maybeBroadcastMessageMock = vi.fn(); +const ackReactionHandle = { + ackReactionPromise: Promise.resolve(true), + ackReactionValue: "👀", + remove: vi.fn(async () => undefined), +}; vi.mock("./audio-preflight.runtime.js", () => ({ transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args), @@ -113,6 +118,7 @@ describe("createWebOnMessageHandler audio preflight", () => { maybeSendAckReactionMock.mockReset(); maybeSendAckReactionMock.mockImplementation(async () => { events.push("ack"); + return ackReactionHandle; }); transcribeFirstAudioMock.mockReset(); transcribeFirstAudioMock.mockImplementation(async () => { @@ -158,12 +164,12 @@ describe("createWebOnMessageHandler audio preflight", () => { expect.objectContaining({ preflightAudioTranscript: "transcribed voice note", ackAlreadySent: true, + ackReaction: ackReactionHandle, }), ); }); it("skips early DM ack/preflight when access-control was not explicitly passed through", async () => { - const handler = createWebOnMessageHandler({ cfg: { channels: { @@ -206,9 +212,14 @@ describe("createWebOnMessageHandler audio preflight", () => { it("preserves per-agent ack checks for group broadcast voice notes", async () => { maybeBroadcastMessageMock.mockImplementation( - async (params: { ackAlreadySent?: boolean; preflightAudioTranscript?: string | null }) => { + async (params: { + ackAlreadySent?: boolean; + ackReaction?: unknown; + preflightAudioTranscript?: string | null; + }) => { expect(params.preflightAudioTranscript).toBe("transcribed voice note"); expect(params.ackAlreadySent).toBeUndefined(); + expect(params.ackReaction).toBeUndefined(); return true; }, ); diff --git a/extensions/whatsapp/src/auto-reply/monitor/on-message.ts b/extensions/whatsapp/src/auto-reply/monitor/on-message.ts index b90d972fe1f..10509eb3f80 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/on-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/on-message.ts @@ -1,3 +1,4 @@ +import type { AckReactionHandle } from "openclaw/plugin-sdk/channel-feedback"; import type { getReplyFromConfig } from "openclaw/plugin-sdk/reply-runtime"; import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; @@ -42,6 +43,7 @@ export function createWebOnMessageHandler(params: { suppressGroupHistoryClear?: boolean; preflightAudioTranscript?: string | null; ackAlreadySent?: boolean; + ackReaction?: AckReactionHandle | null; }, ) => { const processParams: Parameters[0] = { @@ -74,6 +76,9 @@ export function createWebOnMessageHandler(params: { if (opts?.ackAlreadySent === true) { processParams.ackAlreadySent = true; } + if (opts?.ackReaction !== undefined) { + processParams.ackReaction = opts.ackReaction; + } return processMessage(processParams); }; @@ -186,8 +191,9 @@ export function createWebOnMessageHandler(params: { msg.mediaType?.startsWith("audio/") === true && msg.body === ""; const canRunEarlyDmPreflight = msg.chatType === "group" || msg.accessControlPassed === true; let ackAlreadySent = false; + let ackReaction: AckReactionHandle | null = null; if (canRunEarlyDmPreflight && hasAudioBody && msg.mediaPath) { - await maybeSendAckReaction({ + ackReaction = await maybeSendAckReaction({ cfg: params.cfg, msg, agentId: route.agentId, @@ -198,7 +204,7 @@ export function createWebOnMessageHandler(params: { info: params.replyLogger.info.bind(params.replyLogger), warn: params.replyLogger.warn.bind(params.replyLogger), }); - ackAlreadySent = true; + ackAlreadySent = ackReaction !== null; try { const { transcribeFirstAudio } = await import("./audio-preflight.runtime.js"); // transcribeFirstAudio returns undefined on failure/disabled; store null so @@ -232,6 +238,7 @@ export function createWebOnMessageHandler(params: { // preflight ack attempt on the base route must not suppress downstream // per-agent checks during broadcast fan-out. ...(ackAlreadySent && msg.chatType !== "group" ? { ackAlreadySent: true } : {}), + ...(ackReaction && msg.chatType !== "group" ? { ackReaction } : {}), processMessage: (m, r, k, opts) => processForRoute(m, r, k, opts), }) ) { @@ -241,6 +248,7 @@ export function createWebOnMessageHandler(params: { await processForRoute(msg, route, groupHistoryKey, { ...(preflightAudioTranscript !== undefined ? { preflightAudioTranscript } : {}), ...(ackAlreadySent ? { ackAlreadySent: true } : {}), + ...(ackReaction ? { ackReaction } : {}), }); }; } diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.audio-preflight.test.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.audio-preflight.test.ts index ce11166d4a2..af3be034a44 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.audio-preflight.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.audio-preflight.test.ts @@ -117,6 +117,11 @@ import { processMessage } from "./process-message.js"; type WebInboundMsg = Parameters[0]["msg"]; type TestRoute = Parameters[0]["route"]; +const flushMicrotasks = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + function makeAudioMsg(overrides: Partial = {}): WebInboundMsg { return { id: "msg-1", @@ -172,11 +177,32 @@ function makeParams(msgOverrides: Partial = {}) { }; } +function makeAckReactionHandle() { + return { + ackReactionPromise: Promise.resolve(true), + ackReactionValue: "👀", + remove: vi.fn(async () => undefined), + }; +} + +function makeRemoveAckAfterReplyParams() { + return { + ...makeParams(), + cfg: { + tools: { media: { audio: { enabled: true } } }, + channels: { whatsapp: {} }, + commands: { useAccessGroups: false }, + messages: { removeAckAfterReply: true }, + } as never, + preflightAudioTranscript: "pre-computed transcript from caller", + }; +} + describe("processMessage audio preflight transcription", () => { beforeEach(() => { transcribeFirstAudioMock.mockReset(); maybeSendAckReactionMock.mockReset(); - maybeSendAckReactionMock.mockResolvedValue(undefined); + maybeSendAckReactionMock.mockResolvedValue(null); shouldComputeCommandResult = false; shouldComputeCommandBodies = []; vi.mocked(dispatchWhatsAppBufferedReply).mockClear(); @@ -317,11 +343,59 @@ describe("processMessage audio preflight transcription", () => { ...makeParams(), preflightAudioTranscript: "pre-computed transcript from caller", ackAlreadySent: true, + ackReaction: makeAckReactionHandle(), }); expect(maybeSendAckReactionMock).not.toHaveBeenCalled(); }); + it("removes caller-provided ack after a successful visible reply", async () => { + const ackReaction = makeAckReactionHandle(); + + await processMessage({ + ...makeRemoveAckAfterReplyParams(), + ackReaction, + }); + await flushMicrotasks(); + + expect(ackReaction.remove).toHaveBeenCalledTimes(1); + }); + + it("removes internally sent ack after a successful visible reply", async () => { + const ackReaction = makeAckReactionHandle(); + maybeSendAckReactionMock.mockResolvedValueOnce(ackReaction); + + await processMessage(makeRemoveAckAfterReplyParams()); + await flushMicrotasks(); + + expect(maybeSendAckReactionMock).toHaveBeenCalledTimes(1); + expect(ackReaction.remove).toHaveBeenCalledTimes(1); + }); + + it("keeps ack when no visible reply was delivered", async () => { + const ackReaction = makeAckReactionHandle(); + maybeSendAckReactionMock.mockResolvedValueOnce(ackReaction); + vi.mocked(dispatchWhatsAppBufferedReply).mockResolvedValueOnce(false); + + await processMessage(makeRemoveAckAfterReplyParams()); + await flushMicrotasks(); + + expect(ackReaction.remove).not.toHaveBeenCalled(); + }); + + it("keeps ack when the ack send failed", async () => { + const ackReaction = { + ...makeAckReactionHandle(), + ackReactionPromise: Promise.resolve(false), + }; + maybeSendAckReactionMock.mockResolvedValueOnce(ackReaction); + + await processMessage(makeRemoveAckAfterReplyParams()); + await flushMicrotasks(); + + expect(ackReaction.remove).not.toHaveBeenCalled(); + }); + it("skips internal STT when preflightAudioTranscript is null (failed preflight sentinel)", async () => { // null = caller already attempted preflight but got nothing (provider unavailable, // disabled, etc.). processMessage must NOT retry to avoid 1+N attempts in broadcast. diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts index c98dcb71837..18d6bcb3d72 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts @@ -1,3 +1,8 @@ +import { + logAckFailure, + removeAckReactionHandleAfterReply, + type AckReactionHandle, +} from "openclaw/plugin-sdk/channel-feedback"; import { createInternalHookEvent, deriveInboundMessageHookContext, @@ -192,6 +197,7 @@ export async function processMessage(params: { groupHistory?: GroupHistoryEntry[]; suppressGroupHistoryClear?: boolean; ackAlreadySent?: boolean; + ackReaction?: AckReactionHandle | null; /** Pre-computed audio transcript from a caller-level preflight, used to avoid * re-transcribing the same voice note once per broadcast agent. * - string → transcript obtained; use it directly, skip internal STT @@ -318,8 +324,9 @@ export async function processMessage(params: { // Send ack reaction immediately upon message receipt (post-gating). Callers // that do preflight work before processMessage can send it first and set // ackAlreadySent so slow STT does not delay user-visible receipt feedback. - if (params.ackAlreadySent !== true) { - await maybeSendAckReaction({ + let ackReaction = params.ackReaction ?? null; + if (!ackReaction && params.ackAlreadySent !== true) { + ackReaction = await maybeSendAckReaction({ cfg: params.cfg, msg: params.msg, agentId: params.route.agentId, @@ -463,7 +470,7 @@ export async function processMessage(params: { }); trackBackgroundTask(params.backgroundTasks, metaTask); - return dispatchWhatsAppBufferedReply({ + const didSendReply = await dispatchWhatsAppBufferedReply({ cfg: params.cfg, connectionId: params.connectionId, context: ctxPayload, @@ -485,6 +492,19 @@ export async function processMessage(params: { route: params.route, shouldClearGroupHistory, }); + removeAckReactionHandleAfterReply({ + removeAfterReply: Boolean(params.cfg.messages?.removeAckAfterReply && didSendReply), + ackReaction, + onError: (err) => { + logAckFailure({ + log: logVerbose, + channel: "whatsapp", + target: `${params.msg.chatId ?? conversationId}/${params.msg.id ?? "unknown"}`, + error: err, + }); + }, + }); + return didSendReply; } export const __testing = { diff --git a/src/channels/ack-reactions.test.ts b/src/channels/ack-reactions.test.ts index e964a895e46..20c82b2563b 100644 --- a/src/channels/ack-reactions.test.ts +++ b/src/channels/ack-reactions.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { + createAckReactionHandle, + removeAckReactionHandleAfterReply, removeAckReactionAfterReply, shouldAckReaction, shouldAckReactionForWhatsApp, @@ -178,6 +180,48 @@ describe("shouldAckReactionForWhatsApp", () => { }); }); +describe("createAckReactionHandle", () => { + it("tracks a successful ack send", async () => { + const send = vi.fn().mockResolvedValue(undefined); + const remove = vi.fn().mockResolvedValue(undefined); + + const handle = createAckReactionHandle({ + ackReactionValue: " 👀 ", + send, + remove, + }); + + expect(handle).toMatchObject({ ackReactionValue: "👀", remove }); + expect(send).toHaveBeenCalledTimes(1); + await expect(handle?.ackReactionPromise).resolves.toBe(true); + }); + + it("tracks a failed ack send without throwing", async () => { + const error = new Error("nope"); + const onSendError = vi.fn(); + + const handle = createAckReactionHandle({ + ackReactionValue: "👀", + send: vi.fn().mockRejectedValue(error), + remove: vi.fn().mockResolvedValue(undefined), + onSendError, + }); + + await expect(handle?.ackReactionPromise).resolves.toBe(false); + expect(onSendError).toHaveBeenCalledWith(error); + }); + + it("skips empty ack values", () => { + const handle = createAckReactionHandle({ + ackReactionValue: " ", + send: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined), + }); + + expect(handle).toBeNull(); + }); +}); + describe("removeAckReactionAfterReply", () => { it("removes only when ack succeeded", async () => { const remove = vi.fn().mockResolvedValue(undefined); @@ -206,3 +250,20 @@ describe("removeAckReactionAfterReply", () => { expect(remove).not.toHaveBeenCalled(); }); }); + +describe("removeAckReactionHandleAfterReply", () => { + it("removes through an ack handle", async () => { + const remove = vi.fn().mockResolvedValue(undefined); + removeAckReactionHandleAfterReply({ + removeAfterReply: true, + ackReaction: { + ackReactionPromise: Promise.resolve(true), + ackReactionValue: "👀", + remove, + }, + }); + + await flushMicrotasks(); + expect(remove).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/channels/ack-reactions.ts b/src/channels/ack-reactions.ts index 85bb141e0fa..5cc59fe0b09 100644 --- a/src/channels/ack-reactions.ts +++ b/src/channels/ack-reactions.ts @@ -2,6 +2,12 @@ export type AckReactionScope = "all" | "direct" | "group-all" | "group-mentions" export type WhatsAppAckReactionMode = "always" | "mentions" | "never"; +export type AckReactionHandle = { + ackReactionPromise: Promise; + ackReactionValue: string; + remove: () => Promise; +}; + export type AckReactionGateParams = { scope: AckReactionScope | undefined; isDirect: boolean; @@ -78,6 +84,37 @@ export function shouldAckReactionForWhatsApp(params: { }); } +export function createAckReactionHandle(params: { + ackReactionValue: string; + send: () => Promise; + remove: () => Promise; + onSendError?: (err: unknown) => void; +}): AckReactionHandle | null { + const ackReactionValue = params.ackReactionValue.trim(); + if (!ackReactionValue) { + return null; + } + + let sendPromise: Promise; + try { + sendPromise = params.send(); + } catch (err) { + sendPromise = Promise.reject(err); + } + + return { + ackReactionPromise: sendPromise.then( + () => true, + (err) => { + params.onSendError?.(err); + return false; + }, + ), + ackReactionValue, + remove: params.remove, + }; +} + export function removeAckReactionAfterReply(params: { removeAfterReply: boolean; ackReactionPromise: Promise | null; @@ -101,3 +138,17 @@ export function removeAckReactionAfterReply(params: { params.remove().catch((err) => params.onError?.(err)); }); } + +export function removeAckReactionHandleAfterReply(params: { + removeAfterReply: boolean; + ackReaction: AckReactionHandle | null | undefined; + onError?: (err: unknown) => void; +}) { + removeAckReactionAfterReply({ + removeAfterReply: params.removeAfterReply, + ackReactionPromise: params.ackReaction?.ackReactionPromise ?? null, + ackReactionValue: params.ackReaction?.ackReactionValue ?? null, + remove: params.ackReaction?.remove ?? (async () => {}), + onError: params.onError, + }); +} diff --git a/src/plugin-sdk/channel-feedback.ts b/src/plugin-sdk/channel-feedback.ts index 8c29f265807..367e2f778c6 100644 --- a/src/plugin-sdk/channel-feedback.ts +++ b/src/plugin-sdk/channel-feedback.ts @@ -1,8 +1,11 @@ export { resolveAckReaction } from "../agents/identity.js"; export { + createAckReactionHandle, + removeAckReactionHandleAfterReply, removeAckReactionAfterReply, shouldAckReaction, shouldAckReactionForWhatsApp, + type AckReactionHandle, type AckReactionGateParams, type AckReactionScope, type WhatsAppAckReactionMode, diff --git a/src/plugins/runtime/runtime-channel.ts b/src/plugins/runtime/runtime-channel.ts index 1e3131959e0..4fd895f9a5e 100644 --- a/src/plugins/runtime/runtime-channel.ts +++ b/src/plugins/runtime/runtime-channel.ts @@ -33,7 +33,12 @@ import { } from "../../auto-reply/reply/mentions.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js"; -import { removeAckReactionAfterReply, shouldAckReaction } from "../../channels/ack-reactions.js"; +import { + createAckReactionHandle, + removeAckReactionAfterReply, + removeAckReactionHandleAfterReply, + shouldAckReaction, +} from "../../channels/ack-reactions.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; import { implicitMentionKindWhen, @@ -232,8 +237,10 @@ export function createRuntimeChannel(): PluginRuntime["channel"] { resolveInboundMentionDecision, }, reactions: { + createAckReactionHandle, shouldAckReaction, removeAckReactionAfterReply, + removeAckReactionHandleAfterReply, }, groups: { resolveGroupPolicy: resolveChannelGroupPolicy, diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index 087fd528032..1f82d27c171 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -128,8 +128,10 @@ export type PluginRuntimeChannel = { resolveInboundMentionDecision: typeof import("../../channels/mention-gating.js").resolveInboundMentionDecision; }; reactions: { + createAckReactionHandle: typeof import("../../channels/ack-reactions.js").createAckReactionHandle; shouldAckReaction: typeof import("../../channels/ack-reactions.js").shouldAckReaction; removeAckReactionAfterReply: typeof import("../../channels/ack-reactions.js").removeAckReactionAfterReply; + removeAckReactionHandleAfterReply: typeof import("../../channels/ack-reactions.js").removeAckReactionHandleAfterReply; }; groups: { resolveGroupPolicy: typeof import("../../config/group-policy.js").resolveChannelGroupPolicy; diff --git a/test/helpers/plugins/plugin-runtime-mock.ts b/test/helpers/plugins/plugin-runtime-mock.ts index 74dac9913c9..aea9d3ac08a 100644 --- a/test/helpers/plugins/plugin-runtime-mock.ts +++ b/test/helpers/plugins/plugin-runtime-mock.ts @@ -1,6 +1,8 @@ import { vi } from "vitest"; import { + createAckReactionHandle, removeAckReactionAfterReply, + removeAckReactionHandleAfterReply, shouldAckReaction, } from "../../../src/channels/ack-reactions.js"; import { @@ -305,8 +307,10 @@ export function createPluginRuntimeMock(overrides: DeepPartial = resolveInboundMentionDecision, }, reactions: { + createAckReactionHandle, shouldAckReaction, removeAckReactionAfterReply, + removeAckReactionHandleAfterReply, }, groups: { resolveGroupPolicy: vi.fn(