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"));