From eab402493429aeba2bd3def2ebfe65e623df5130 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 30 Apr 2026 03:35:23 +0100 Subject: [PATCH] fix(whatsapp): track provider-accepted auto-replies --- CHANGELOG.md | 1 + docs/channels/whatsapp.md | 9 + ...to-reply.broadcast-groups.combined.test.ts | 5 +- .../whatsapp/src/auto-reply.test-harness.ts | 30 ++- ...compresses-common-formats-jpeg-cap.test.ts | 6 +- ...o-reply.connection-and-logging.e2e.test.ts | 7 +- ...to-reply.web-auto-reply.last-route.test.ts | 10 +- .../src/auto-reply/deliver-reply.test.ts | 58 +++++- .../whatsapp/src/auto-reply/deliver-reply.ts | 171 +++++++++++------- .../auto-reply/monitor/ack-reaction.test.ts | 15 +- .../monitor/inbound-context.test.ts | 15 +- .../monitor/inbound-dispatch.test.ts | 99 +++++++++- .../auto-reply/monitor/inbound-dispatch.ts | 20 +- .../monitor/process-message.test.ts | 15 +- .../auto-reply/web-auto-reply-monitor.test.ts | 15 +- .../auto-reply/web-auto-reply-utils.test.ts | 15 +- .../src/connection-controller.test.ts | 17 +- extensions/whatsapp/src/inbound/monitor.ts | 7 +- .../whatsapp/src/inbound/send-api.test.ts | 52 +++++- extensions/whatsapp/src/inbound/send-api.ts | 39 ++-- .../whatsapp/src/inbound/send-result.ts | 67 +++++++ extensions/whatsapp/src/inbound/types.ts | 14 +- extensions/whatsapp/src/send.test.ts | 17 +- 23 files changed, 554 insertions(+), 150 deletions(-) create mode 100644 extensions/whatsapp/src/inbound/send-result.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 84c71cfa32b..ddbfed2c2a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai - Agents/Codex: flush accepted debounced steering messages before normal app-server turn cleanup, so inbound follow-ups acknowledged as queued are not dropped when the turn completes before the debounce fires. Thanks @vincentkoc. - Slack/interactive replies: keep rendered buttons and selects within Slack Block Kit value and count limits, and align command argument select values with Slack's option limit, so overlong agent-authored choices no longer make Slack reject the whole block payload. Thanks @slackapi. - Slack/interactive replies: drop overlong Block Kit button URLs while preserving valid callback values, so malformed link buttons no longer make Slack reject the whole interactive reply. Thanks @slackapi. +- Channels/WhatsApp: require Baileys outbound message ids before marking auto-replies delivered, so transcript text and ack reactions no longer make failed group replies look sent. Fixes #49225. Thanks @TinyTb. - CLI/update: scope packaged Node compile caches by OpenClaw version and install metadata, so global installs no longer reuse stale compiled chunks after package updates. Thanks @pashpashpash. - Channels/Voice call: keep pre-auth webhook in-flight limiting active when socket remote address metadata is missing, so slow-body requests from stripped-IP proxy paths still share the fallback bucket. (#74453) Thanks @davidangularme. - Plugin SDK/testing: lazy-load TypeScript from the plugin test-contract runtime and add release checks for critical SDK contract entrypoint imports and bundle size, so published packages fail preflight before shipping ESM-incompatible or oversized contract helpers. Thanks @vincentkoc. diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index 32e31d5a6bc..53fb9341224 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -569,6 +569,15 @@ Behavior notes: + + Transcript rows record what the agent generated. WhatsApp delivery is checked separately: OpenClaw only treats an auto-reply as sent after Baileys returns an outbound message id for at least one visible text or media send. + + Ack reactions are independent pre-reply receipts. A successful reaction does not prove that the later text or media reply was accepted by WhatsApp. + + Check gateway logs for `auto-reply delivery failed` or `auto-reply was not accepted by WhatsApp provider`. + + + Check in this order: diff --git a/extensions/whatsapp/src/auto-reply.broadcast-groups.combined.test.ts b/extensions/whatsapp/src/auto-reply.broadcast-groups.combined.test.ts index dd1c2c5f5a5..c261f6d8f55 100644 --- a/extensions/whatsapp/src/auto-reply.broadcast-groups.combined.test.ts +++ b/extensions/whatsapp/src/auto-reply.broadcast-groups.combined.test.ts @@ -6,6 +6,7 @@ import { sendWebDirectInboundAndCollectSessionKeys, } from "./auto-reply.broadcast-groups.test-harness.js"; import { + createAcceptedWhatsAppSendResult, installWebAutoReplyTestHomeHooks, installWebAutoReplyUnitTestHooks, resetLoadConfigMock, @@ -202,8 +203,8 @@ describe("broadcast groups", () => { }, } satisfies OpenClawConfig); - const sendMedia = vi.fn(); - const reply = vi.fn().mockResolvedValue(undefined); + const sendMedia = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1")); + const reply = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1")); const sendComposing = vi.fn(); let started = 0; diff --git a/extensions/whatsapp/src/auto-reply.test-harness.ts b/extensions/whatsapp/src/auto-reply.test-harness.ts index e5fc5b5f228..573bc28510c 100644 --- a/extensions/whatsapp/src/auto-reply.test-harness.ts +++ b/extensions/whatsapp/src/auto-reply.test-harness.ts @@ -9,6 +9,7 @@ import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env"; import { afterAll, afterEach, beforeAll, beforeEach, vi, type Mock } from "vitest"; import type { WebChannelStatus } from "./auto-reply/types.js"; import type { WebInboundMessage, WebListenerCloseReason } from "./inbound.js"; +import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js"; import { resetBaileysMocks as _resetBaileysMocks, resetLoadConfigMock as _resetLoadConfigMock, @@ -27,9 +28,9 @@ type MockWebListener = { close: () => Promise; onClose: Promise; signalClose: () => void; - sendMessage: () => Promise<{ messageId: string }>; - sendPoll: () => Promise<{ messageId: string }>; - sendReaction: () => Promise; + sendMessage: () => Promise; + sendPoll: () => Promise; + sendReaction: () => Promise; sendComposingTo: () => Promise; }; type UnknownMock = Mock<(...args: unknown[]) => unknown>; @@ -253,13 +254,26 @@ export function createMockWebListener(): MockWebListener { close: vi.fn(async () => undefined), onClose: new Promise(() => {}), signalClose: vi.fn(), - sendMessage: vi.fn(async () => ({ messageId: "msg-1" })), - sendPoll: vi.fn(async () => ({ messageId: "poll-1" })), - sendReaction: vi.fn(async () => undefined), + sendMessage: vi.fn(async () => createAcceptedWhatsAppSendResult("text", "msg-1")), + sendPoll: vi.fn(async () => createAcceptedWhatsAppSendResult("poll", "poll-1")), + sendReaction: vi.fn(async () => createAcceptedWhatsAppSendResult("reaction", "reaction-1")), sendComposingTo: vi.fn(async () => undefined), }; } +export function createAcceptedWhatsAppSendResult( + kind: WhatsAppSendKind, + id: string, +): WhatsAppSendResult { + return { + kind, + messageId: id, + messageIds: [id], + keys: [{ id }], + providerAccepted: true, + }; +} + export function createScriptedWebListenerFactory(): AnyExport { const onMessages: Array<(msg: WebInboundMessage) => Promise> = []; const closeResolvers: Array<(reason: unknown) => void> = []; @@ -294,8 +308,8 @@ export function createScriptedWebListenerFactory(): AnyExport { export function createWebInboundDeliverySpies(): AnyExport { return { - sendMedia: vi.fn(), - reply: vi.fn().mockResolvedValue(undefined), + sendMedia: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1")), + reply: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1")), sendComposing: vi.fn(), }; } diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts index 22159af7556..8ffcd36a155 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts @@ -3,6 +3,7 @@ import sharp from "sharp"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { createMockWebListener, + createAcceptedWhatsAppSendResult, installWebAutoReplyTestHomeHooks, installWebAutoReplyUnitTestHooks, resetLoadConfigMock, @@ -29,7 +30,8 @@ describe("web auto-reply", () => { sendMedia: ReturnType; reply?: ReturnType; }) { - const reply = params.reply ?? vi.fn().mockResolvedValue(undefined); + const reply = + params.reply ?? vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1")); const sendComposing = vi.fn(async () => undefined); const resolver = vi.fn().mockResolvedValue(params.resolverValue); @@ -384,7 +386,7 @@ describe("web auto-reply", () => { fetchMock.mockRestore(); }); it("sends media with a caption when delivery succeeds", async () => { - const sendMedia = vi.fn().mockResolvedValue(undefined); + const sendMedia = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1")); const { reply, dispatch } = await setupSingleInboundMessage({ resolverValue: { text: "hi", diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts index 5ab9a3c0846..1ec5c60c298 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts @@ -12,6 +12,7 @@ import { WhatsAppAuthUnstableError, resolveWebCredsPath } from "./auth-store.js" import { resolveOAuthDir } from "./auth-store.runtime.js"; import { createWebInboundDeliverySpies, + createAcceptedWhatsAppSendResult, createMockWebListener, createScriptedWebListenerFactory, createWebListenerFactoryCapture, @@ -713,7 +714,7 @@ describe("web auto-reply connection", () => { try { const sendMedia = vi.fn(); - const reply = vi.fn().mockResolvedValue(undefined); + const reply = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1")); const sendComposing = vi.fn(); const resolver = vi.fn().mockResolvedValue({ text: "ok" }); @@ -861,9 +862,9 @@ describe("web auto-reply connection", () => { markDispatchIdle, cleanup: vi.fn(), }; - const reply = vi.fn().mockResolvedValue(undefined); + const reply = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1")); const sendComposing = vi.fn().mockResolvedValue(undefined); - const sendMedia = vi.fn().mockResolvedValue(undefined); + const sendMedia = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1")); const replyResolver = vi.fn().mockImplementation(async (ctx, opts) => { void ctx; diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts index dacf273b181..90f9cf31b22 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts @@ -1,7 +1,11 @@ import "./test-helpers.js"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { installWebAutoReplyUnitTestHooks, makeSessionStore } from "./auto-reply.test-harness.js"; +import { + createAcceptedWhatsAppSendResult, + installWebAutoReplyUnitTestHooks, + makeSessionStore, +} from "./auto-reply.test-harness.js"; const updateLastRouteInBackgroundMock = vi.hoisted(() => vi.fn()); let awaitBackgroundTasks: typeof import("./auto-reply/monitor/last-route.js").awaitBackgroundTasks; @@ -92,8 +96,8 @@ function buildInboundMessage(params: { senderName: params.senderName, selfE164: params.selfE164, sendComposing: vi.fn().mockResolvedValue(undefined), - reply: vi.fn().mockResolvedValue(undefined), - sendMedia: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1")), + sendMedia: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1")), }; } diff --git a/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts index 4fb754e1f71..315868a3f45 100644 --- a/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts +++ b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts @@ -48,6 +48,26 @@ vi.mock("../media.js", () => ({ let deliverWebReply: typeof import("./deliver-reply.js").deliverWebReply; let whatsappOutbound: typeof import("../outbound-adapter.js").whatsappOutbound; +function acceptedSendResult(kind: "media" | "text", id: string) { + return { + kind, + messageId: id, + messageIds: [id], + keys: [{ id }], + providerAccepted: true, + }; +} + +function unacceptedSendResult(kind: "media" | "text") { + return { + kind, + messageId: "unknown", + messageIds: [], + keys: [], + providerAccepted: false, + }; +} + function makeMsg(): WebInboundMsg { return { from: "+10000000000", @@ -58,8 +78,8 @@ function makeMsg(): WebInboundMsg { id: "msg-1", body: "latest batch body", senderJid: "222@s.whatsapp.net", - reply: vi.fn(async () => undefined), - sendMedia: vi.fn(async () => undefined), + reply: vi.fn(async () => acceptedSendResult("text", "reply-sent-1")), + sendMedia: vi.fn(async () => acceptedSendResult("media", "media-sent-1")), } as unknown as WebInboundMsg; } @@ -99,7 +119,7 @@ function expectFirstSendMediaPayload(msg: WebInboundMsg) { function mockSecondReplySuccess(msg: WebInboundMsg) { (msg.reply as unknown as { mockResolvedValueOnce: (v: unknown) => void }).mockResolvedValueOnce( - undefined, + acceptedSendResult("text", "reply-retry-2"), ); } @@ -162,7 +182,7 @@ describe("deliverWebReply", () => { it("sends chunked text replies and logs a summary", async () => { const msg = makeMsg(); - await deliverWebReply({ + const delivery = await deliverWebReply({ replyResult: { text: "aaaaaa" }, msg, maxMediaBytes: 1024 * 1024, @@ -175,6 +195,32 @@ describe("deliverWebReply", () => { expect(msg.reply).toHaveBeenNthCalledWith(1, "aaa", undefined); expect(msg.reply).toHaveBeenNthCalledWith(2, "aaa", undefined); expect(replyLogger.info).toHaveBeenCalledWith(expect.any(Object), "auto-reply sent (text)"); + expect(delivery.providerAccepted).toBe(true); + expect(delivery.messageIds).toEqual(["reply-sent-1"]); + }); + + it("reports text replies that Baileys did not accept", async () => { + const msg = makeMsg(); + vi.mocked(msg.reply).mockResolvedValueOnce(unacceptedSendResult("text")); + + const delivery = await deliverWebReply({ + replyResult: { text: "hello" }, + msg, + maxMediaBytes: 1024 * 1024, + textLimit: 200, + replyLogger, + skipLog: true, + }); + + expect(msg.reply).toHaveBeenCalledTimes(1); + expect(delivery).toMatchObject({ + messageIds: [], + providerAccepted: false, + }); + expect(replyLogger.warn).toHaveBeenCalledWith( + expect.any(Object), + "auto-reply text was not accepted by WhatsApp provider", + ); }); it("strips raw XML tool-call blocks before WhatsApp text delivery", async () => { @@ -421,7 +467,7 @@ describe("deliverWebReply", () => { mockFirstSendMediaFailure(msg, "socket reset"); ( msg.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void } - ).mockResolvedValueOnce(undefined); + ).mockResolvedValueOnce(acceptedSendResult("media", "media-retry-2")); await deliverWebReply({ replyResult: { text: "caption", mediaUrl: "http://example.com/img.jpg" }, @@ -484,7 +530,7 @@ describe("deliverWebReply", () => { mockFirstSendMediaFailure(msg, "boom"); ( msg.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void } - ).mockResolvedValueOnce(undefined); + ).mockResolvedValueOnce(acceptedSendResult("media", "media-second-1")); await deliverWebReply({ replyResult: { diff --git a/extensions/whatsapp/src/auto-reply/deliver-reply.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.ts index 2a88096e96b..a0aa5495203 100644 --- a/extensions/whatsapp/src/auto-reply/deliver-reply.ts +++ b/extensions/whatsapp/src/auto-reply/deliver-reply.ts @@ -6,6 +6,7 @@ import { sendMediaWithLeadingCaption, } from "openclaw/plugin-sdk/reply-payload"; import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; +import type { WhatsAppSendResult } from "../inbound/send-result.js"; import { loadWebMedia } from "../media.js"; import { type DeliverableWhatsAppOutboundPayload, @@ -23,6 +24,12 @@ import { whatsappOutboundLog } from "./loggers.js"; import type { WebInboundMsg } from "./types.js"; import { elide } from "./util.js"; +export type WhatsAppReplyDeliveryResult = { + results: WhatsAppSendResult[]; + messageIds: string[]; + providerAccepted: boolean; +}; + export async function deliverWebReply(params: { replyResult: ReplyPayload; normalizedReplyResult?: DeliverableWhatsAppOutboundPayload; @@ -38,12 +45,26 @@ export async function deliverWebReply(params: { connectionId?: string; skipLog?: boolean; tableMode?: MarkdownTableMode; -}) { +}): Promise { const { replyResult, msg, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } = params; const replyStarted = Date.now(); + const sendResults: WhatsAppSendResult[] = []; + const rememberSendResult = (result: WhatsAppSendResult | undefined) => { + if (result) { + sendResults.push(result); + } + }; + const finishDelivery = (): WhatsAppReplyDeliveryResult => { + const messageIds = [...new Set(sendResults.flatMap((result) => result.messageIds))]; + return { + results: sendResults, + messageIds, + providerAccepted: sendResults.some((result) => result.providerAccepted), + }; + }; if (isReasoningReplyPayload(replyResult)) { whatsappOutboundLog.debug(`Suppressed reasoning payload to ${msg.from}`); - return; + return finishDelivery(); } const tableMode = params.tableMode ?? "code"; const chunkMode = params.chunkMode ?? "length"; @@ -75,7 +96,7 @@ export async function deliverWebReply(params: { }); }; - const sendWithRetry = async (fn: () => Promise, label: string, maxAttempts = 3) => { + const sendWithRetry = async (fn: () => Promise, label: string, maxAttempts = 3) => { return await sendWhatsAppOutboundWithRetry({ send: fn, maxAttempts, @@ -93,7 +114,7 @@ export async function deliverWebReply(params: { for (const [index, chunk] of textChunks.entries()) { const chunkStarted = Date.now(); const quote = getQuote(); - await sendWithRetry(() => msg.reply(chunk, quote), "text"); + rememberSendResult(await sendWithRetry(() => msg.reply(chunk, quote), "text")); if (!skipLog) { const durationMs = Date.now() - chunkStarted; whatsappOutboundLog.debug( @@ -101,21 +122,24 @@ export async function deliverWebReply(params: { ); } } - replyLogger.info( - { - correlationId: msg.id ?? newConnectionId(), - connectionId: connectionId ?? null, - to: msg.from, - from: msg.to, - text: elide(replyResult.text, 240), - mediaUrl: null, - mediaSizeBytes: null, - mediaKind: null, - durationMs: Date.now() - replyStarted, - }, - "auto-reply sent (text)", - ); - return; + const delivery = finishDelivery(); + const logPayload = { + correlationId: msg.id ?? newConnectionId(), + connectionId: connectionId ?? null, + to: msg.from, + from: msg.to, + text: elide(replyResult.text, 240), + mediaUrl: null, + mediaSizeBytes: null, + mediaKind: null, + durationMs: Date.now() - replyStarted, + }; + if (delivery.providerAccepted) { + replyLogger.info(logPayload, "auto-reply sent (text)"); + } else { + replyLogger.warn(logPayload, "auto-reply text was not accepted by WhatsApp provider"); + } + return delivery; } const remainingText = [...textChunks]; @@ -141,63 +165,73 @@ export async function deliverWebReply(params: { } if (media.kind === "image") { const quote = getQuote(); - await sendWithRetry( - () => - msg.sendMedia( - { - image: media.buffer, - caption, - mimetype: media.mimetype, - }, - quote, - ), - "media:image", + rememberSendResult( + await sendWithRetry( + () => + msg.sendMedia( + { + image: media.buffer, + caption, + mimetype: media.mimetype, + }, + quote, + ), + "media:image", + ), ); } else if (media.kind === "audio") { const quote = getQuote(); - await sendWithRetry( - () => - msg.sendMedia( - { - audio: media.buffer, - ptt: true, - mimetype: media.mimetype, - }, - quote, - ), - "media:audio", + rememberSendResult( + await sendWithRetry( + () => + msg.sendMedia( + { + audio: media.buffer, + ptt: true, + mimetype: media.mimetype, + }, + quote, + ), + "media:audio", + ), ); if (caption) { - await sendWithRetry(() => msg.reply(caption, quote), "media:audio-text"); + rememberSendResult( + await sendWithRetry(() => msg.reply(caption, quote), "media:audio-text"), + ); } } else if (media.kind === "video") { const quote = getQuote(); - await sendWithRetry( - () => - msg.sendMedia( - { - video: media.buffer, - caption, - mimetype: media.mimetype, - }, - quote, - ), - "media:video", + rememberSendResult( + await sendWithRetry( + () => + msg.sendMedia( + { + video: media.buffer, + caption, + mimetype: media.mimetype, + }, + quote, + ), + "media:video", + ), ); } else { const quote = getQuote(); - await sendWithRetry( - () => - msg.sendMedia( - { - document: media.buffer, - fileName: media.fileName, - caption, - mimetype: media.mimetype, - }, - quote, - ), - "media:document", + rememberSendResult( + await sendWithRetry( + () => + msg.sendMedia( + { + document: media.buffer, + fileName: media.fileName, + caption, + mimetype: media.mimetype, + }, + quote, + ), + "media:document", + ), ); } whatsappOutboundLog.info( @@ -231,12 +265,15 @@ export async function deliverWebReply(params: { return; } whatsappOutboundLog.warn(`Media skipped; sent text-only to ${msg.from}`); - await msg.reply(fallbackText, getQuote()); + rememberSendResult( + await sendWithRetry(() => msg.reply(fallbackText, getQuote()), "media:fallback-text"), + ); }, }); // Remaining text chunks after media for (const chunk of remainingText) { - await msg.reply(chunk, getQuote()); + rememberSendResult(await sendWithRetry(() => msg.reply(chunk, getQuote()), "media:text")); } + return finishDelivery(); } 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 603975296c8..1b6f5e812ce 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { WhatsAppSendResult } from "../../inbound/send-result.js"; import type { WebInboundMessage } from "../../inbound/types.js"; import { maybeSendAckReaction } from "./ack-reaction.js"; @@ -11,6 +12,16 @@ vi.mock("../../send.js", () => ({ sendReactionWhatsApp: hoisted.sendReactionWhatsApp, })); +function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult { + return { + kind, + messageId: id, + messageIds: [id], + keys: [{ id }], + providerAccepted: true, + }; +} + function createMessage(overrides: Partial = {}): WebInboundMessage { return { id: "msg-1", @@ -22,8 +33,8 @@ function createMessage(overrides: Partial = {}): WebInboundMe chatType: "direct", chatId: "15551234567@s.whatsapp.net", sendComposing: async () => {}, - reply: async () => {}, - sendMedia: async () => {}, + reply: async () => acceptedSendResult("text", "r1"), + sendMedia: async () => acceptedSendResult("media", "m1"), ...overrides, }; } diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-context.test.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-context.test.ts index aaa84ca8a95..3628139e59d 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-context.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-context.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import type { WhatsAppSendResult } from "../../inbound/send-result.js"; import { resolveVisibleWhatsAppGroupHistory, resolveVisibleWhatsAppReplyContext, @@ -6,6 +7,16 @@ import { type ReplyContextParams = Parameters[0]; +function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult { + return { + kind, + messageId: id, + messageIds: [id], + keys: [{ id }], + providerAccepted: true, + }; +} + const makeBlockedQuotedReplyMessage = (id: string): ReplyContextParams["msg"] => ({ id, from: "123@g.us", @@ -24,8 +35,8 @@ const makeBlockedQuotedReplyMessage = (id: string): ReplyContextParams["msg"] => replyToSender: "Mallory (+999)", replyToSenderJid: "999@s.whatsapp.net", sendComposing: async () => {}, - reply: async () => {}, - sendMedia: async () => {}, + reply: async () => acceptedSendResult("text", "r1"), + sendMedia: async () => acceptedSendResult("media", "m1"), }); describe("whatsapp inbound context visibility", () => { diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts index 31c886ff852..74a1954b90c 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { WhatsAppSendResult } from "../../inbound/send-result.js"; let capturedDispatchParams: unknown; @@ -75,6 +76,16 @@ import { type TestRoute = Parameters[0]["route"]; type TestMsg = Parameters[0]["msg"]; +function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult { + return { + kind, + messageId: id, + messageIds: [id], + keys: [{ id }], + providerAccepted: true, + }; +} + function makeRoute(overrides: Partial = {}): TestRoute { return { agentId: "main", @@ -99,8 +110,8 @@ function makeMsg(overrides: Partial = {}): TestMsg { chatType: "direct", body: "hi", sendComposing: async () => {}, - reply: async () => {}, - sendMedia: async () => {}, + reply: async () => acceptedSendResult("text", "r1"), + sendMedia: async () => acceptedSendResult("media", "m1"), ...overrides, }; } @@ -139,13 +150,37 @@ function makeReplyLogger(): BufferedReplyParams["replyLogger"] { } as never; } +function acceptedDeliveryResult() { + return { + results: [ + { + kind: "text" as const, + messageId: "wa-sent-1", + messageIds: ["wa-sent-1"], + keys: [{ id: "wa-sent-1" }], + providerAccepted: true, + }, + ], + messageIds: ["wa-sent-1"], + providerAccepted: true, + }; +} + +function unacceptedDeliveryResult() { + return { + results: [], + messageIds: [], + providerAccepted: false, + }; +} + async function dispatchBufferedReply(overrides: Partial = {}) { const params: BufferedReplyParams = { cfg: { channels: { whatsapp: { blockStreaming: true } } } as never, connectionId: "conn", context: { Body: "hi" }, conversationId: "+1000", - deliverReply: async () => {}, + deliverReply: async () => acceptedDeliveryResult(), groupHistories: new Map(), groupHistoryKey: "+1000", maxMediaBytes: 1, @@ -390,7 +425,7 @@ describe("whatsapp inbound dispatch", () => { }); it("delivers block and final WhatsApp payloads; suppresses text-only tool payloads but delivers media", async () => { - const deliverReply = vi.fn(async () => undefined); + const deliverReply = vi.fn(async () => acceptedDeliveryResult()); const rememberSentText = vi.fn(); await dispatchBufferedReply({ @@ -446,7 +481,7 @@ describe("whatsapp inbound dispatch", () => { }); it("normalizes WhatsApp payload text before delivery and echo bookkeeping", async () => { - const deliverReply = vi.fn(async () => undefined); + const deliverReply = vi.fn(async () => acceptedDeliveryResult()); const rememberSentText = vi.fn(); await dispatchBufferedReply({ @@ -479,7 +514,7 @@ describe("whatsapp inbound dispatch", () => { }); it("suppresses reasoning and compaction payloads before WhatsApp delivery", async () => { - const deliverReply = vi.fn(async () => undefined); + const deliverReply = vi.fn(async () => acceptedDeliveryResult()); const rememberSentText = vi.fn(); await dispatchBufferedReply({ @@ -500,7 +535,7 @@ describe("whatsapp inbound dispatch", () => { }); it("suppresses payloads that normalize to no visible WhatsApp content", async () => { - const deliverReply = vi.fn(async () => undefined); + const deliverReply = vi.fn(async () => acceptedDeliveryResult()); const rememberSentText = vi.fn(); await dispatchBufferedReply({ @@ -523,7 +558,7 @@ describe("whatsapp inbound dispatch", () => { }); it("suppresses error payload text", async () => { - const deliverReply = vi.fn(async () => undefined); + const deliverReply = vi.fn(async () => acceptedDeliveryResult()); const rememberSentText = vi.fn(); await dispatchBufferedReply({ deliverReply, rememberSentText }); @@ -578,7 +613,7 @@ describe("whatsapp inbound dispatch", () => { }); it("treats block-only turns as visible replies instead of silent turns", async () => { - const deliverReply = vi.fn(async () => undefined); + const deliverReply = vi.fn(async () => acceptedDeliveryResult()); const rememberSentText = vi.fn(); dispatchReplyWithBufferedBlockDispatcherMock.mockImplementationOnce( async (params: { @@ -607,8 +642,52 @@ describe("whatsapp inbound dispatch", () => { expect(rememberSentText).toHaveBeenCalledTimes(1); }); + it("does not treat generated WhatsApp text as sent when the provider did not accept it", async () => { + const deliverReply = vi.fn(async () => unacceptedDeliveryResult()); + const rememberSentText = vi.fn(); + const replyLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as unknown as BufferedReplyParams["replyLogger"]; + dispatchReplyWithBufferedBlockDispatcherMock.mockImplementationOnce( + async (params: { + ctx: unknown; + dispatcherOptions?: { + deliver?: ( + payload: { text?: string }, + info: { kind: "tool" | "block" | "final" }, + ) => Promise; + }; + }) => { + capturedDispatchParams = params; + await params.dispatcherOptions?.deliver?.({ text: "final text" }, { kind: "final" }); + return { queuedFinal: false, counts: { tool: 0, block: 0, final: 1 } }; + }, + ); + + await expect( + dispatchBufferedReply({ + deliverReply, + rememberSentText, + replyLogger, + }), + ).resolves.toBe(false); + + expect(deliverReply).toHaveBeenCalledTimes(1); + expect(rememberSentText).not.toHaveBeenCalled(); + expect(replyLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + replyKind: "final", + conversationId: "+1000", + }), + "auto-reply was not accepted by WhatsApp provider", + ); + }); + it("returns true for tool-only media turns after delivering media", async () => { - const deliverReply = vi.fn(async () => undefined); + const deliverReply = vi.fn(async () => acceptedDeliveryResult()); const rememberSentText = vi.fn(); dispatchReplyWithBufferedBlockDispatcherMock.mockImplementationOnce( async (params: { diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts index f1e2cdc6a13..6bfde4c3fea 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts @@ -4,6 +4,7 @@ import { normalizeWhatsAppOutboundPayload, normalizeWhatsAppPayloadTextPreservingIndentation, } from "../../outbound-media-contract.js"; +import type { WhatsAppReplyDeliveryResult } from "../deliver-reply.js"; import type { WebInboundMsg } from "../types.js"; import { formatGroupMembers } from "./group-members.js"; import type { GroupHistoryEntry } from "./inbound-context.js"; @@ -283,7 +284,7 @@ export async function dispatchWhatsAppBufferedReply(params: { connectionId?: string; skipLog?: boolean; tableMode?: ReturnType; - }) => Promise; + }) => Promise; groupHistories: Map; groupHistoryKey: string; maxMediaBytes: number; @@ -344,7 +345,7 @@ export async function dispatchWhatsAppBufferedReply(params: { if (!reply.hasMedia && !reply.text.trim()) { return; } - await params.deliverReply({ + const delivery = await params.deliverReply({ replyResult: normalizedDeliveryPayload, normalizedReplyResult: normalizedDeliveryPayload, msg: params.msg, @@ -357,6 +358,21 @@ export async function dispatchWhatsAppBufferedReply(params: { skipLog: false, tableMode, }); + if (!delivery.providerAccepted) { + params.replyLogger.warn( + { + correlationId: params.msg.id ?? null, + connectionId: params.connectionId, + conversationId: params.conversationId, + chatId: params.msg.chatId, + to: params.msg.from, + from: params.msg.to, + replyKind: info.kind, + }, + "auto-reply was not accepted by WhatsApp provider", + ); + return; + } didSendReply = true; const shouldLog = normalizedDeliveryPayload.text ? true : undefined; params.rememberSentText(normalizedDeliveryPayload.text, { diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts index d972b3b375e..841323e6044 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { WhatsAppSendResult } from "../../inbound/send-result.js"; // Hoisted mocks used across tests so vi.mock factories can reference them. const { resolvePolicyMock, buildContextMock, runMessageReceivedMock, trackBackgroundTaskMock } = @@ -9,6 +10,16 @@ const { resolvePolicyMock, buildContextMock, runMessageReceivedMock, trackBackgr trackBackgroundTaskMock: vi.fn(), })); +function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult { + return { + kind, + messageId: id, + messageIds: [id], + keys: [{ id }], + providerAccepted: true, + }; +} + vi.mock("../../inbound-policy.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -169,8 +180,8 @@ const baseMsg = { chatType: "group" as const, body: "hi", sendComposing: async () => {}, - reply: async () => {}, - sendMedia: async () => {}, + reply: async () => acceptedSendResult("text", "r1"), + sendMedia: async () => acceptedSendResult("media", "m1"), }; const baseRoute = { diff --git a/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts b/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts index 70ba1f2e8b0..927c9d5c970 100644 --- a/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts +++ b/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { WhatsAppSendResult } from "../inbound/send-result.js"; import { buildMentionConfig } from "./mentions.js"; import { applyGroupGating, type GroupHistoryEntry } from "./monitor/group-gating.js"; import { buildInboundLine, formatReplyContext } from "./monitor/message-line.js"; @@ -11,6 +12,16 @@ import type { WebInboundMsg } from "./types.js"; let sessionDir: string | undefined; let sessionStorePath: string; +function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult { + return { + kind, + messageId: id, + messageIds: [id], + keys: [{ id }], + providerAccepted: true, + }; +} + beforeEach(async () => { sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-group-gating-")); sessionStorePath = path.join(sessionDir, "sessions.json"); @@ -82,8 +93,8 @@ function createGroupMessage(overrides: Partial = {}): WebInboundM senderName: "Alice", selfE164: "+999", sendComposing: async () => {}, - reply: async (_text, _options) => {}, - sendMedia: async (_payload, _options) => {}, + reply: async (_text, _options) => acceptedSendResult("text", "r1"), + sendMedia: async (_payload, _options) => acceptedSendResult("media", "m1"), ...overrides, }; } diff --git a/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts index 4e4ce176ddc..c690ac2699b 100644 --- a/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts +++ b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { saveSessionStore } from "openclaw/plugin-sdk/session-store-runtime"; import { withTempDir } from "openclaw/plugin-sdk/test-env"; import { describe, expect, it, vi } from "vitest"; +import type { WhatsAppSendResult } from "../inbound/send-result.js"; import { debugMention, isBotMentionedFromTargets, @@ -13,6 +14,16 @@ import { getSessionSnapshot } from "./session-snapshot.js"; import type { WebInboundMsg } from "./types.js"; import { elide, isLikelyWhatsAppCryptoError } from "./util.js"; +function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult { + return { + kind, + messageId: id, + messageIds: [id], + keys: [{ id }], + providerAccepted: true, + }; +} + const makeMsg = (overrides: Partial): WebInboundMsg => ({ id: "m1", @@ -24,8 +35,8 @@ const makeMsg = (overrides: Partial): WebInboundMsg => chatType: "group", chatId: "120363401234567890@g.us", sendComposing: async () => {}, - reply: async () => {}, - sendMedia: async () => {}, + reply: async () => acceptedSendResult("text", "r1"), + sendMedia: async () => acceptedSendResult("media", "m1"), ...overrides, }) as WebInboundMsg; diff --git a/extensions/whatsapp/src/connection-controller.test.ts b/extensions/whatsapp/src/connection-controller.test.ts index 7d017a0feb1..592eafeb09b 100644 --- a/extensions/whatsapp/src/connection-controller.test.ts +++ b/extensions/whatsapp/src/connection-controller.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from "node:events"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js"; import { WhatsAppConnectionController } from "./connection-controller.js"; +import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js"; import { createWaSocket, waitForWaConnection } from "./session.js"; vi.mock("./session.js", async () => { @@ -16,11 +17,21 @@ vi.mock("./session.js", async () => { const createWaSocketMock = vi.mocked(createWaSocket); const waitForWaConnectionMock = vi.mocked(waitForWaConnection); +function acceptedSendResult(kind: WhatsAppSendKind, id: string): WhatsAppSendResult { + return { + kind, + messageId: id, + messageIds: [id], + keys: [{ id }], + providerAccepted: true, + }; +} + function createListenerStub(messageId = "ok") { return { - sendMessage: vi.fn(async () => ({ messageId })), - sendPoll: vi.fn(async () => ({ messageId })), - sendReaction: vi.fn(async () => {}), + sendMessage: vi.fn(async () => acceptedSendResult("text", messageId)), + sendPoll: vi.fn(async () => acceptedSendResult("poll", messageId)), + sendReaction: vi.fn(async () => acceptedSendResult("reaction", messageId)), sendComposingTo: vi.fn(async () => {}), }; } diff --git a/extensions/whatsapp/src/inbound/monitor.ts b/extensions/whatsapp/src/inbound/monitor.ts index 497a5e4afc0..c7920e94a35 100644 --- a/extensions/whatsapp/src/inbound/monitor.ts +++ b/extensions/whatsapp/src/inbound/monitor.ts @@ -42,6 +42,7 @@ import { attachEmitterListener, closeInboundMonitorSocket } from "./lifecycle.js import { downloadInboundMedia } from "./media.js"; import { DisconnectReason, isJidGroup, saveMediaBuffer } from "./runtime-api.js"; import { createWebSendApi } from "./send-api.js"; +import { normalizeWhatsAppSendResult } from "./send-result.js"; import type { WebInboundMessage, WebListenerCloseReason } from "./types.js"; const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401; @@ -622,13 +623,15 @@ export async function attachWebInboxToSocket( } }; const reply = async (text: string, options?: MiscMessageGenerationOptions) => { - await sendTrackedMessage(chatJid, { text }, options); + const result = await sendTrackedMessage(chatJid, { text }, options); + return normalizeWhatsAppSendResult(result, "text"); }; const sendMedia = async ( payload: AnyMessageContent, options?: MiscMessageGenerationOptions, ) => { - await sendTrackedMessage(chatJid, payload, options); + const result = await sendTrackedMessage(chatJid, payload, options); + return normalizeWhatsAppSendResult(result, "media"); }; const timestamp = inbound.messageTimestampMs; const mentionedJids = extractMentionedJids(msg.message as proto.IMessage | undefined); diff --git a/extensions/whatsapp/src/inbound/send-api.test.ts b/extensions/whatsapp/src/inbound/send-api.test.ts index af84691a3dc..de27afeb263 100644 --- a/extensions/whatsapp/src/inbound/send-api.test.ts +++ b/extensions/whatsapp/src/inbound/send-api.test.ts @@ -1,3 +1,8 @@ +import type { + AnyMessageContent, + MiscMessageGenerationOptions, + WAMessage, +} from "@whiskeysockets/baileys"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createWebSendApi } from "./send-api.js"; @@ -14,7 +19,13 @@ vi.mock("openclaw/plugin-sdk/channel-activity-runtime", async () => { }); describe("createWebSendApi", () => { - const sendMessage = vi.fn(async () => ({ key: { id: "msg-1" } })); + const sendMessage = vi.fn( + async ( + _jid: string, + _content: AnyMessageContent, + _options?: MiscMessageGenerationOptions, + ): Promise => ({ key: { id: "msg-1" } }) as WAMessage, + ); const sendPresenceUpdate = vi.fn(async () => {}); let api: ReturnType; @@ -60,8 +71,14 @@ describe("createWebSendApi", () => { }); it("sends plain text messages", async () => { - await api.sendMessage("+1555", "hello"); + const res = await api.sendMessage("+1555", "hello"); expect(sendMessage).toHaveBeenCalledWith("1555@s.whatsapp.net", { text: "hello" }); + expect(res).toMatchObject({ + kind: "text", + messageId: "msg-1", + messageIds: ["msg-1"], + providerAccepted: true, + }); expect(recordChannelActivity).toHaveBeenCalledWith({ channel: "whatsapp", accountId: "main", @@ -102,7 +119,10 @@ describe("createWebSendApi", () => { it("sends visible text separately from push-to-talk voice notes", async () => { const payload = Buffer.from("aud"); - await api.sendMessage("+1555", "voice text", payload, "audio/ogg"); + sendMessage + .mockResolvedValueOnce({ key: { id: "voice-1" } }) + .mockResolvedValueOnce({ key: { id: "voice-text-1" } }); + const res = await api.sendMessage("+1555", "voice text", payload, "audio/ogg"); expect(sendMessage).toHaveBeenNthCalledWith( 1, "1555@s.whatsapp.net", @@ -115,6 +135,12 @@ describe("createWebSendApi", () => { expect(sendMessage).toHaveBeenNthCalledWith(2, "1555@s.whatsapp.net", { text: "voice text", }); + expect(res).toMatchObject({ + kind: "media", + messageId: "voice-1", + messageIds: ["voice-1", "voice-text-1"], + providerAccepted: true, + }); }); it("supports video media and gifPlayback option", async () => { @@ -158,7 +184,7 @@ describe("createWebSendApi", () => { }); it("sends reactions with participant JID normalization", async () => { - await api.sendReaction("+1555", "msg-2", "👍", false, "+1999"); + const res = await api.sendReaction("+1555", "msg-2", "👍", false, "+1999"); expect(sendMessage).toHaveBeenCalledWith( "1555@s.whatsapp.net", expect.objectContaining({ @@ -173,6 +199,24 @@ describe("createWebSendApi", () => { }, }), ); + expect(res).toMatchObject({ + kind: "reaction", + messageId: "msg-1", + providerAccepted: true, + }); + }); + + it("reports provider-unaccepted sends when Baileys returns no message", async () => { + sendMessage.mockResolvedValueOnce(undefined); + + const res = await api.sendMessage("+1555", "hello"); + + expect(res).toMatchObject({ + kind: "text", + messageId: "unknown", + messageIds: [], + providerAccepted: false, + }); }); it("keeps direct-chat reactions without a participant key", async () => { diff --git a/extensions/whatsapp/src/inbound/send-api.ts b/extensions/whatsapp/src/inbound/send-api.ts index 8b39c193213..607d7d35646 100644 --- a/extensions/whatsapp/src/inbound/send-api.ts +++ b/extensions/whatsapp/src/inbound/send-api.ts @@ -1,11 +1,17 @@ import type { AnyMessageContent, MiscMessageGenerationOptions, + WAMessage, WAPresence, } from "@whiskeysockets/baileys"; import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime"; import { buildQuotedMessageOptions } from "../quoted-message.js"; import { toWhatsappJid } from "../text-runtime.js"; +import { + combineWhatsAppSendResults, + normalizeWhatsAppSendResult, + type WhatsAppSendResult, +} from "./send-result.js"; import type { ActiveWebSendOptions } from "./types.js"; function recordWhatsAppOutbound(accountId: string) { @@ -16,19 +22,13 @@ function recordWhatsAppOutbound(accountId: string) { }); } -function resolveOutboundMessageId(result: unknown): string { - return typeof result === "object" && result && "key" in result - ? ((result as { key?: { id?: string } }).key?.id ?? "unknown") - : "unknown"; -} - export function createWebSendApi(params: { sock: { sendMessage: ( jid: string, content: AnyMessageContent, options?: MiscMessageGenerationOptions, - ) => Promise; + ) => Promise; sendPresenceUpdate: (presence: WAPresence, jid?: string) => Promise; }; defaultAccountId: string; @@ -40,7 +40,7 @@ export function createWebSendApi(params: { mediaBuffer?: Buffer, mediaType?: string, sendOptions?: ActiveWebSendOptions, - ): Promise<{ messageId: string }> => { + ): Promise => { const jid = toWhatsappJid(to); let payload: AnyMessageContent; if (mediaBuffer) { @@ -85,23 +85,22 @@ export function createWebSendApi(params: { const result = quotedOpts ? await params.sock.sendMessage(jid, payload, quotedOpts) : await params.sock.sendMessage(jid, payload); + const results = [normalizeWhatsAppSendResult(result, mediaBuffer ? "media" : "text")]; if (mediaBuffer && mediaType?.startsWith("audio/") && text.trim()) { const textPayload: AnyMessageContent = { text }; - if (quotedOpts) { - await params.sock.sendMessage(jid, textPayload, quotedOpts); - } else { - await params.sock.sendMessage(jid, textPayload); - } + const textResult = quotedOpts + ? await params.sock.sendMessage(jid, textPayload, quotedOpts) + : await params.sock.sendMessage(jid, textPayload); + results.push(normalizeWhatsAppSendResult(textResult, "text")); } const accountId = sendOptions?.accountId ?? params.defaultAccountId; recordWhatsAppOutbound(accountId); - const messageId = resolveOutboundMessageId(result); - return { messageId }; + return combineWhatsAppSendResults(mediaBuffer ? "media" : "text", results); }, sendPoll: async ( to: string, poll: { question: string; options: string[]; maxSelections?: number }, - ): Promise<{ messageId: string }> => { + ): Promise => { const jid = toWhatsappJid(to); const result = await params.sock.sendMessage(jid, { poll: { @@ -111,8 +110,7 @@ export function createWebSendApi(params: { }, } as AnyMessageContent); recordWhatsAppOutbound(params.defaultAccountId); - const messageId = resolveOutboundMessageId(result); - return { messageId }; + return normalizeWhatsAppSendResult(result, "poll"); }, sendReaction: async ( chatJid: string, @@ -120,9 +118,9 @@ export function createWebSendApi(params: { emoji: string, fromMe: boolean, participant?: string, - ): Promise => { + ): Promise => { const jid = toWhatsappJid(chatJid); - await params.sock.sendMessage(jid, { + const result = await params.sock.sendMessage(jid, { react: { text: emoji, key: { @@ -133,6 +131,7 @@ export function createWebSendApi(params: { }, }, } as AnyMessageContent); + return normalizeWhatsAppSendResult(result, "reaction"); }, sendComposingTo: async (to: string): Promise => { const jid = toWhatsappJid(to); diff --git a/extensions/whatsapp/src/inbound/send-result.ts b/extensions/whatsapp/src/inbound/send-result.ts new file mode 100644 index 00000000000..5ff428b0b7a --- /dev/null +++ b/extensions/whatsapp/src/inbound/send-result.ts @@ -0,0 +1,67 @@ +import type { WAMessage, WAMessageKey } from "@whiskeysockets/baileys"; + +export type WhatsAppSendKind = "media" | "poll" | "reaction" | "text"; + +export type WhatsAppSendKey = Omit< + Pick, + "id" +> & { + id: string; +}; + +export type WhatsAppSendResult = { + kind: WhatsAppSendKind; + messageId: string; + messageIds: string[]; + keys: WhatsAppSendKey[]; + providerAccepted: boolean; +}; + +function normalizeKey(key: WAMessageKey | undefined): WhatsAppSendKey | undefined { + const id = typeof key?.id === "string" ? key.id.trim() : ""; + if (!id) { + return undefined; + } + return { + id, + remoteJid: key?.remoteJid, + fromMe: key?.fromMe, + participant: key?.participant, + }; +} + +export function normalizeWhatsAppSendResult( + result: WAMessage | undefined, + kind: WhatsAppSendKind, +): WhatsAppSendResult { + const key = normalizeKey(result?.key); + const messageId = key?.id ?? "unknown"; + return { + kind, + messageId, + messageIds: key ? [key.id] : [], + keys: key ? [key] : [], + providerAccepted: Boolean(key), + }; +} + +export function combineWhatsAppSendResults( + kind: WhatsAppSendKind, + results: readonly WhatsAppSendResult[], +): WhatsAppSendResult { + const messageIds = [...new Set(results.flatMap((result) => result.messageIds))]; + const keys = results.flatMap((result) => result.keys); + return { + kind, + messageId: messageIds[0] ?? "unknown", + messageIds, + keys, + providerAccepted: results.some((result) => result.providerAccepted), + }; +} + +export function hasAcceptedWhatsAppSendResult( + result: WhatsAppSendResult | undefined, +): result is WhatsAppSendResult { + return result?.providerAccepted === true; +} diff --git a/extensions/whatsapp/src/inbound/types.ts b/extensions/whatsapp/src/inbound/types.ts index f30c333b9f4..0943138729a 100644 --- a/extensions/whatsapp/src/inbound/types.ts +++ b/extensions/whatsapp/src/inbound/types.ts @@ -2,6 +2,7 @@ import type { AnyMessageContent, MiscMessageGenerationOptions } from "@whiskeyso import type { NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound"; import type { PollInput } from "openclaw/plugin-sdk/poll-runtime"; import type { WhatsAppIdentity, WhatsAppReplyContext, WhatsAppSelfIdentity } from "../identity.js"; +import type { WhatsAppSendResult } from "./send-result.js"; export type WebListenerCloseReason = { status?: number; @@ -29,15 +30,15 @@ export type ActiveWebListener = { mediaBuffer?: Buffer, mediaType?: string, options?: ActiveWebSendOptions, - ) => Promise<{ messageId: string }>; - sendPoll: (to: string, poll: PollInput) => Promise<{ messageId: string }>; + ) => Promise; + sendPoll: (to: string, poll: PollInput) => Promise; sendReaction: ( chatJid: string, messageId: string, emoji: string, fromMe: boolean, participant?: string, - ) => Promise; + ) => Promise; sendComposingTo: (to: string) => Promise; close?: () => Promise; }; @@ -85,8 +86,11 @@ export type WebInboundMessage = { fromMe?: boolean; location?: NormalizedLocation; sendComposing: () => Promise; - reply: (text: string, options?: MiscMessageGenerationOptions) => Promise; - sendMedia: (payload: AnyMessageContent, options?: MiscMessageGenerationOptions) => Promise; + reply: (text: string, options?: MiscMessageGenerationOptions) => Promise; + sendMedia: ( + payload: AnyMessageContent, + options?: MiscMessageGenerationOptions, + ) => Promise; mediaPath?: string; mediaType?: string; mediaFileName?: string; diff --git a/extensions/whatsapp/src/send.test.ts b/extensions/whatsapp/src/send.test.ts index 383d76cb142..cb19e83e8ec 100644 --- a/extensions/whatsapp/src/send.test.ts +++ b/extensions/whatsapp/src/send.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { redactIdentifier } from "openclaw/plugin-sdk/logging-core"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js"; import type { ActiveWebListener } from "./inbound/types.js"; const hoisted = vi.hoisted(() => ({ @@ -23,6 +24,16 @@ const WHATSAPP_TEST_CFG: OpenClawConfig = { channels: { whatsapp: {} }, }; +function acceptedSendResult(kind: WhatsAppSendKind, id: string): WhatsAppSendResult { + return { + kind, + messageId: id, + messageIds: [id], + keys: [{ id }], + providerAccepted: true, + }; +} + vi.mock("./connection-controller-registry.js", async () => { const actual = await vi.importActual( "./connection-controller-registry.js", @@ -70,9 +81,9 @@ vi.mock("./text-runtime.js", async () => { describe("web outbound", () => { const sendComposingTo = vi.fn(async () => {}); - const sendMessage = vi.fn(async () => ({ messageId: "msg123" })); - const sendPoll = vi.fn(async () => ({ messageId: "poll123" })); - const sendReaction = vi.fn(async () => {}); + const sendMessage = vi.fn(async () => acceptedSendResult("text", "msg123")); + const sendPoll = vi.fn(async () => acceptedSendResult("poll", "poll123")); + const sendReaction = vi.fn(async () => acceptedSendResult("reaction", "reaction123")); beforeAll(async () => { ({ sendMessageWhatsApp, sendPollWhatsApp, sendReactionWhatsApp } = await import("./send.js"));