fix(whatsapp): track provider-accepted auto-replies

This commit is contained in:
Peter Steinberger
2026-04-30 03:35:23 +01:00
parent b07c7f6ab3
commit eab4024934
23 changed files with 554 additions and 150 deletions

View File

@@ -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. - 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: 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. - 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. - 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. - 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. - 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.

View File

@@ -569,6 +569,15 @@ Behavior notes:
</Accordion> </Accordion>
<Accordion title="Reply appears in transcript but not in WhatsApp">
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`.
</Accordion>
<Accordion title="Group messages unexpectedly ignored"> <Accordion title="Group messages unexpectedly ignored">
Check in this order: Check in this order:

View File

@@ -6,6 +6,7 @@ import {
sendWebDirectInboundAndCollectSessionKeys, sendWebDirectInboundAndCollectSessionKeys,
} from "./auto-reply.broadcast-groups.test-harness.js"; } from "./auto-reply.broadcast-groups.test-harness.js";
import { import {
createAcceptedWhatsAppSendResult,
installWebAutoReplyTestHomeHooks, installWebAutoReplyTestHomeHooks,
installWebAutoReplyUnitTestHooks, installWebAutoReplyUnitTestHooks,
resetLoadConfigMock, resetLoadConfigMock,
@@ -202,8 +203,8 @@ describe("broadcast groups", () => {
}, },
} satisfies OpenClawConfig); } satisfies OpenClawConfig);
const sendMedia = vi.fn(); const sendMedia = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1"));
const reply = vi.fn().mockResolvedValue(undefined); const reply = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1"));
const sendComposing = vi.fn(); const sendComposing = vi.fn();
let started = 0; let started = 0;

View File

@@ -9,6 +9,7 @@ import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env";
import { afterAll, afterEach, beforeAll, beforeEach, vi, type Mock } from "vitest"; import { afterAll, afterEach, beforeAll, beforeEach, vi, type Mock } from "vitest";
import type { WebChannelStatus } from "./auto-reply/types.js"; import type { WebChannelStatus } from "./auto-reply/types.js";
import type { WebInboundMessage, WebListenerCloseReason } from "./inbound.js"; import type { WebInboundMessage, WebListenerCloseReason } from "./inbound.js";
import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js";
import { import {
resetBaileysMocks as _resetBaileysMocks, resetBaileysMocks as _resetBaileysMocks,
resetLoadConfigMock as _resetLoadConfigMock, resetLoadConfigMock as _resetLoadConfigMock,
@@ -27,9 +28,9 @@ type MockWebListener = {
close: () => Promise<void>; close: () => Promise<void>;
onClose: Promise<WebListenerCloseReason>; onClose: Promise<WebListenerCloseReason>;
signalClose: () => void; signalClose: () => void;
sendMessage: () => Promise<{ messageId: string }>; sendMessage: () => Promise<WhatsAppSendResult>;
sendPoll: () => Promise<{ messageId: string }>; sendPoll: () => Promise<WhatsAppSendResult>;
sendReaction: () => Promise<void>; sendReaction: () => Promise<WhatsAppSendResult>;
sendComposingTo: () => Promise<void>; sendComposingTo: () => Promise<void>;
}; };
type UnknownMock = Mock<(...args: unknown[]) => unknown>; type UnknownMock = Mock<(...args: unknown[]) => unknown>;
@@ -253,13 +254,26 @@ export function createMockWebListener(): MockWebListener {
close: vi.fn(async () => undefined), close: vi.fn(async () => undefined),
onClose: new Promise<WebListenerCloseReason>(() => {}), onClose: new Promise<WebListenerCloseReason>(() => {}),
signalClose: vi.fn(), signalClose: vi.fn(),
sendMessage: vi.fn(async () => ({ messageId: "msg-1" })), sendMessage: vi.fn(async () => createAcceptedWhatsAppSendResult("text", "msg-1")),
sendPoll: vi.fn(async () => ({ messageId: "poll-1" })), sendPoll: vi.fn(async () => createAcceptedWhatsAppSendResult("poll", "poll-1")),
sendReaction: vi.fn(async () => undefined), sendReaction: vi.fn(async () => createAcceptedWhatsAppSendResult("reaction", "reaction-1")),
sendComposingTo: vi.fn(async () => undefined), 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 { export function createScriptedWebListenerFactory(): AnyExport {
const onMessages: Array<(msg: WebInboundMessage) => Promise<void>> = []; const onMessages: Array<(msg: WebInboundMessage) => Promise<void>> = [];
const closeResolvers: Array<(reason: unknown) => void> = []; const closeResolvers: Array<(reason: unknown) => void> = [];
@@ -294,8 +308,8 @@ export function createScriptedWebListenerFactory(): AnyExport {
export function createWebInboundDeliverySpies(): AnyExport { export function createWebInboundDeliverySpies(): AnyExport {
return { return {
sendMedia: vi.fn(), sendMedia: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1")),
reply: vi.fn().mockResolvedValue(undefined), reply: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1")),
sendComposing: vi.fn(), sendComposing: vi.fn(),
}; };
} }

View File

@@ -3,6 +3,7 @@ import sharp from "sharp";
import { beforeAll, describe, expect, it, vi } from "vitest"; import { beforeAll, describe, expect, it, vi } from "vitest";
import { import {
createMockWebListener, createMockWebListener,
createAcceptedWhatsAppSendResult,
installWebAutoReplyTestHomeHooks, installWebAutoReplyTestHomeHooks,
installWebAutoReplyUnitTestHooks, installWebAutoReplyUnitTestHooks,
resetLoadConfigMock, resetLoadConfigMock,
@@ -29,7 +30,8 @@ describe("web auto-reply", () => {
sendMedia: ReturnType<typeof vi.fn>; sendMedia: ReturnType<typeof vi.fn>;
reply?: ReturnType<typeof vi.fn>; reply?: ReturnType<typeof vi.fn>;
}) { }) {
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 sendComposing = vi.fn(async () => undefined);
const resolver = vi.fn().mockResolvedValue(params.resolverValue); const resolver = vi.fn().mockResolvedValue(params.resolverValue);
@@ -384,7 +386,7 @@ describe("web auto-reply", () => {
fetchMock.mockRestore(); fetchMock.mockRestore();
}); });
it("sends media with a caption when delivery succeeds", async () => { 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({ const { reply, dispatch } = await setupSingleInboundMessage({
resolverValue: { resolverValue: {
text: "hi", text: "hi",

View File

@@ -12,6 +12,7 @@ import { WhatsAppAuthUnstableError, resolveWebCredsPath } from "./auth-store.js"
import { resolveOAuthDir } from "./auth-store.runtime.js"; import { resolveOAuthDir } from "./auth-store.runtime.js";
import { import {
createWebInboundDeliverySpies, createWebInboundDeliverySpies,
createAcceptedWhatsAppSendResult,
createMockWebListener, createMockWebListener,
createScriptedWebListenerFactory, createScriptedWebListenerFactory,
createWebListenerFactoryCapture, createWebListenerFactoryCapture,
@@ -713,7 +714,7 @@ describe("web auto-reply connection", () => {
try { try {
const sendMedia = vi.fn(); const sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(undefined); const reply = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1"));
const sendComposing = vi.fn(); const sendComposing = vi.fn();
const resolver = vi.fn().mockResolvedValue({ text: "ok" }); const resolver = vi.fn().mockResolvedValue({ text: "ok" });
@@ -861,9 +862,9 @@ describe("web auto-reply connection", () => {
markDispatchIdle, markDispatchIdle,
cleanup: vi.fn(), cleanup: vi.fn(),
}; };
const reply = vi.fn().mockResolvedValue(undefined); const reply = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1"));
const sendComposing = vi.fn().mockResolvedValue(undefined); 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) => { const replyResolver = vi.fn().mockImplementation(async (ctx, opts) => {
void ctx; void ctx;

View File

@@ -1,7 +1,11 @@
import "./test-helpers.js"; import "./test-helpers.js";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { beforeEach, describe, expect, it, vi } from "vitest"; 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()); const updateLastRouteInBackgroundMock = vi.hoisted(() => vi.fn());
let awaitBackgroundTasks: typeof import("./auto-reply/monitor/last-route.js").awaitBackgroundTasks; let awaitBackgroundTasks: typeof import("./auto-reply/monitor/last-route.js").awaitBackgroundTasks;
@@ -92,8 +96,8 @@ function buildInboundMessage(params: {
senderName: params.senderName, senderName: params.senderName,
selfE164: params.selfE164, selfE164: params.selfE164,
sendComposing: vi.fn().mockResolvedValue(undefined), sendComposing: vi.fn().mockResolvedValue(undefined),
reply: vi.fn().mockResolvedValue(undefined), reply: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1")),
sendMedia: vi.fn().mockResolvedValue(undefined), sendMedia: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1")),
}; };
} }

View File

@@ -48,6 +48,26 @@ vi.mock("../media.js", () => ({
let deliverWebReply: typeof import("./deliver-reply.js").deliverWebReply; let deliverWebReply: typeof import("./deliver-reply.js").deliverWebReply;
let whatsappOutbound: typeof import("../outbound-adapter.js").whatsappOutbound; 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 { function makeMsg(): WebInboundMsg {
return { return {
from: "+10000000000", from: "+10000000000",
@@ -58,8 +78,8 @@ function makeMsg(): WebInboundMsg {
id: "msg-1", id: "msg-1",
body: "latest batch body", body: "latest batch body",
senderJid: "222@s.whatsapp.net", senderJid: "222@s.whatsapp.net",
reply: vi.fn(async () => undefined), reply: vi.fn(async () => acceptedSendResult("text", "reply-sent-1")),
sendMedia: vi.fn(async () => undefined), sendMedia: vi.fn(async () => acceptedSendResult("media", "media-sent-1")),
} as unknown as WebInboundMsg; } as unknown as WebInboundMsg;
} }
@@ -99,7 +119,7 @@ function expectFirstSendMediaPayload(msg: WebInboundMsg) {
function mockSecondReplySuccess(msg: WebInboundMsg) { function mockSecondReplySuccess(msg: WebInboundMsg) {
(msg.reply as unknown as { mockResolvedValueOnce: (v: unknown) => void }).mockResolvedValueOnce( (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 () => { it("sends chunked text replies and logs a summary", async () => {
const msg = makeMsg(); const msg = makeMsg();
await deliverWebReply({ const delivery = await deliverWebReply({
replyResult: { text: "aaaaaa" }, replyResult: { text: "aaaaaa" },
msg, msg,
maxMediaBytes: 1024 * 1024, maxMediaBytes: 1024 * 1024,
@@ -175,6 +195,32 @@ describe("deliverWebReply", () => {
expect(msg.reply).toHaveBeenNthCalledWith(1, "aaa", undefined); expect(msg.reply).toHaveBeenNthCalledWith(1, "aaa", undefined);
expect(msg.reply).toHaveBeenNthCalledWith(2, "aaa", undefined); expect(msg.reply).toHaveBeenNthCalledWith(2, "aaa", undefined);
expect(replyLogger.info).toHaveBeenCalledWith(expect.any(Object), "auto-reply sent (text)"); 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 () => { it("strips raw XML tool-call blocks before WhatsApp text delivery", async () => {
@@ -421,7 +467,7 @@ describe("deliverWebReply", () => {
mockFirstSendMediaFailure(msg, "socket reset"); mockFirstSendMediaFailure(msg, "socket reset");
( (
msg.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void } msg.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
).mockResolvedValueOnce(undefined); ).mockResolvedValueOnce(acceptedSendResult("media", "media-retry-2"));
await deliverWebReply({ await deliverWebReply({
replyResult: { text: "caption", mediaUrl: "http://example.com/img.jpg" }, replyResult: { text: "caption", mediaUrl: "http://example.com/img.jpg" },
@@ -484,7 +530,7 @@ describe("deliverWebReply", () => {
mockFirstSendMediaFailure(msg, "boom"); mockFirstSendMediaFailure(msg, "boom");
( (
msg.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void } msg.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
).mockResolvedValueOnce(undefined); ).mockResolvedValueOnce(acceptedSendResult("media", "media-second-1"));
await deliverWebReply({ await deliverWebReply({
replyResult: { replyResult: {

View File

@@ -6,6 +6,7 @@ import {
sendMediaWithLeadingCaption, sendMediaWithLeadingCaption,
} from "openclaw/plugin-sdk/reply-payload"; } from "openclaw/plugin-sdk/reply-payload";
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { WhatsAppSendResult } from "../inbound/send-result.js";
import { loadWebMedia } from "../media.js"; import { loadWebMedia } from "../media.js";
import { import {
type DeliverableWhatsAppOutboundPayload, type DeliverableWhatsAppOutboundPayload,
@@ -23,6 +24,12 @@ import { whatsappOutboundLog } from "./loggers.js";
import type { WebInboundMsg } from "./types.js"; import type { WebInboundMsg } from "./types.js";
import { elide } from "./util.js"; import { elide } from "./util.js";
export type WhatsAppReplyDeliveryResult = {
results: WhatsAppSendResult[];
messageIds: string[];
providerAccepted: boolean;
};
export async function deliverWebReply(params: { export async function deliverWebReply(params: {
replyResult: ReplyPayload; replyResult: ReplyPayload;
normalizedReplyResult?: DeliverableWhatsAppOutboundPayload<ReplyPayload>; normalizedReplyResult?: DeliverableWhatsAppOutboundPayload<ReplyPayload>;
@@ -38,12 +45,26 @@ export async function deliverWebReply(params: {
connectionId?: string; connectionId?: string;
skipLog?: boolean; skipLog?: boolean;
tableMode?: MarkdownTableMode; tableMode?: MarkdownTableMode;
}) { }): Promise<WhatsAppReplyDeliveryResult> {
const { replyResult, msg, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } = params; const { replyResult, msg, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } = params;
const replyStarted = Date.now(); 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)) { if (isReasoningReplyPayload(replyResult)) {
whatsappOutboundLog.debug(`Suppressed reasoning payload to ${msg.from}`); whatsappOutboundLog.debug(`Suppressed reasoning payload to ${msg.from}`);
return; return finishDelivery();
} }
const tableMode = params.tableMode ?? "code"; const tableMode = params.tableMode ?? "code";
const chunkMode = params.chunkMode ?? "length"; const chunkMode = params.chunkMode ?? "length";
@@ -75,7 +96,7 @@ export async function deliverWebReply(params: {
}); });
}; };
const sendWithRetry = async (fn: () => Promise<unknown>, label: string, maxAttempts = 3) => { const sendWithRetry = async <T>(fn: () => Promise<T>, label: string, maxAttempts = 3) => {
return await sendWhatsAppOutboundWithRetry({ return await sendWhatsAppOutboundWithRetry({
send: fn, send: fn,
maxAttempts, maxAttempts,
@@ -93,7 +114,7 @@ export async function deliverWebReply(params: {
for (const [index, chunk] of textChunks.entries()) { for (const [index, chunk] of textChunks.entries()) {
const chunkStarted = Date.now(); const chunkStarted = Date.now();
const quote = getQuote(); const quote = getQuote();
await sendWithRetry(() => msg.reply(chunk, quote), "text"); rememberSendResult(await sendWithRetry(() => msg.reply(chunk, quote), "text"));
if (!skipLog) { if (!skipLog) {
const durationMs = Date.now() - chunkStarted; const durationMs = Date.now() - chunkStarted;
whatsappOutboundLog.debug( whatsappOutboundLog.debug(
@@ -101,21 +122,24 @@ export async function deliverWebReply(params: {
); );
} }
} }
replyLogger.info( const delivery = finishDelivery();
{ const logPayload = {
correlationId: msg.id ?? newConnectionId(), correlationId: msg.id ?? newConnectionId(),
connectionId: connectionId ?? null, connectionId: connectionId ?? null,
to: msg.from, to: msg.from,
from: msg.to, from: msg.to,
text: elide(replyResult.text, 240), text: elide(replyResult.text, 240),
mediaUrl: null, mediaUrl: null,
mediaSizeBytes: null, mediaSizeBytes: null,
mediaKind: null, mediaKind: null,
durationMs: Date.now() - replyStarted, durationMs: Date.now() - replyStarted,
}, };
"auto-reply sent (text)", if (delivery.providerAccepted) {
); replyLogger.info(logPayload, "auto-reply sent (text)");
return; } else {
replyLogger.warn(logPayload, "auto-reply text was not accepted by WhatsApp provider");
}
return delivery;
} }
const remainingText = [...textChunks]; const remainingText = [...textChunks];
@@ -141,63 +165,73 @@ export async function deliverWebReply(params: {
} }
if (media.kind === "image") { if (media.kind === "image") {
const quote = getQuote(); const quote = getQuote();
await sendWithRetry( rememberSendResult(
() => await sendWithRetry(
msg.sendMedia( () =>
{ msg.sendMedia(
image: media.buffer, {
caption, image: media.buffer,
mimetype: media.mimetype, caption,
}, mimetype: media.mimetype,
quote, },
), quote,
"media:image", ),
"media:image",
),
); );
} else if (media.kind === "audio") { } else if (media.kind === "audio") {
const quote = getQuote(); const quote = getQuote();
await sendWithRetry( rememberSendResult(
() => await sendWithRetry(
msg.sendMedia( () =>
{ msg.sendMedia(
audio: media.buffer, {
ptt: true, audio: media.buffer,
mimetype: media.mimetype, ptt: true,
}, mimetype: media.mimetype,
quote, },
), quote,
"media:audio", ),
"media:audio",
),
); );
if (caption) { 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") { } else if (media.kind === "video") {
const quote = getQuote(); const quote = getQuote();
await sendWithRetry( rememberSendResult(
() => await sendWithRetry(
msg.sendMedia( () =>
{ msg.sendMedia(
video: media.buffer, {
caption, video: media.buffer,
mimetype: media.mimetype, caption,
}, mimetype: media.mimetype,
quote, },
), quote,
"media:video", ),
"media:video",
),
); );
} else { } else {
const quote = getQuote(); const quote = getQuote();
await sendWithRetry( rememberSendResult(
() => await sendWithRetry(
msg.sendMedia( () =>
{ msg.sendMedia(
document: media.buffer, {
fileName: media.fileName, document: media.buffer,
caption, fileName: media.fileName,
mimetype: media.mimetype, caption,
}, mimetype: media.mimetype,
quote, },
), quote,
"media:document", ),
"media:document",
),
); );
} }
whatsappOutboundLog.info( whatsappOutboundLog.info(
@@ -231,12 +265,15 @@ export async function deliverWebReply(params: {
return; return;
} }
whatsappOutboundLog.warn(`Media skipped; sent text-only to ${msg.from}`); 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 // Remaining text chunks after media
for (const chunk of remainingText) { for (const chunk of remainingText) {
await msg.reply(chunk, getQuote()); rememberSendResult(await sendWithRetry(() => msg.reply(chunk, getQuote()), "media:text"));
} }
return finishDelivery();
} }

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WhatsAppSendResult } from "../../inbound/send-result.js";
import type { WebInboundMessage } from "../../inbound/types.js"; import type { WebInboundMessage } from "../../inbound/types.js";
import { maybeSendAckReaction } from "./ack-reaction.js"; import { maybeSendAckReaction } from "./ack-reaction.js";
@@ -11,6 +12,16 @@ vi.mock("../../send.js", () => ({
sendReactionWhatsApp: hoisted.sendReactionWhatsApp, 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> = {}): WebInboundMessage { function createMessage(overrides: Partial<WebInboundMessage> = {}): WebInboundMessage {
return { return {
id: "msg-1", id: "msg-1",
@@ -22,8 +33,8 @@ function createMessage(overrides: Partial<WebInboundMessage> = {}): WebInboundMe
chatType: "direct", chatType: "direct",
chatId: "15551234567@s.whatsapp.net", chatId: "15551234567@s.whatsapp.net",
sendComposing: async () => {}, sendComposing: async () => {},
reply: async () => {}, reply: async () => acceptedSendResult("text", "r1"),
sendMedia: async () => {}, sendMedia: async () => acceptedSendResult("media", "m1"),
...overrides, ...overrides,
}; };
} }

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { WhatsAppSendResult } from "../../inbound/send-result.js";
import { import {
resolveVisibleWhatsAppGroupHistory, resolveVisibleWhatsAppGroupHistory,
resolveVisibleWhatsAppReplyContext, resolveVisibleWhatsAppReplyContext,
@@ -6,6 +7,16 @@ import {
type ReplyContextParams = Parameters<typeof resolveVisibleWhatsAppReplyContext>[0]; type ReplyContextParams = Parameters<typeof resolveVisibleWhatsAppReplyContext>[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"] => ({ const makeBlockedQuotedReplyMessage = (id: string): ReplyContextParams["msg"] => ({
id, id,
from: "123@g.us", from: "123@g.us",
@@ -24,8 +35,8 @@ const makeBlockedQuotedReplyMessage = (id: string): ReplyContextParams["msg"] =>
replyToSender: "Mallory (+999)", replyToSender: "Mallory (+999)",
replyToSenderJid: "999@s.whatsapp.net", replyToSenderJid: "999@s.whatsapp.net",
sendComposing: async () => {}, sendComposing: async () => {},
reply: async () => {}, reply: async () => acceptedSendResult("text", "r1"),
sendMedia: async () => {}, sendMedia: async () => acceptedSendResult("media", "m1"),
}); });
describe("whatsapp inbound context visibility", () => { describe("whatsapp inbound context visibility", () => {

View File

@@ -1,4 +1,5 @@
import { describe, expect, it, vi, beforeEach } from "vitest"; import { describe, expect, it, vi, beforeEach } from "vitest";
import type { WhatsAppSendResult } from "../../inbound/send-result.js";
let capturedDispatchParams: unknown; let capturedDispatchParams: unknown;
@@ -75,6 +76,16 @@ import {
type TestRoute = Parameters<typeof buildWhatsAppInboundContext>[0]["route"]; type TestRoute = Parameters<typeof buildWhatsAppInboundContext>[0]["route"];
type TestMsg = Parameters<typeof buildWhatsAppInboundContext>[0]["msg"]; type TestMsg = Parameters<typeof buildWhatsAppInboundContext>[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> = {}): TestRoute { function makeRoute(overrides: Partial<TestRoute> = {}): TestRoute {
return { return {
agentId: "main", agentId: "main",
@@ -99,8 +110,8 @@ function makeMsg(overrides: Partial<TestMsg> = {}): TestMsg {
chatType: "direct", chatType: "direct",
body: "hi", body: "hi",
sendComposing: async () => {}, sendComposing: async () => {},
reply: async () => {}, reply: async () => acceptedSendResult("text", "r1"),
sendMedia: async () => {}, sendMedia: async () => acceptedSendResult("media", "m1"),
...overrides, ...overrides,
}; };
} }
@@ -139,13 +150,37 @@ function makeReplyLogger(): BufferedReplyParams["replyLogger"] {
} as never; } 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<BufferedReplyParams> = {}) { async function dispatchBufferedReply(overrides: Partial<BufferedReplyParams> = {}) {
const params: BufferedReplyParams = { const params: BufferedReplyParams = {
cfg: { channels: { whatsapp: { blockStreaming: true } } } as never, cfg: { channels: { whatsapp: { blockStreaming: true } } } as never,
connectionId: "conn", connectionId: "conn",
context: { Body: "hi" }, context: { Body: "hi" },
conversationId: "+1000", conversationId: "+1000",
deliverReply: async () => {}, deliverReply: async () => acceptedDeliveryResult(),
groupHistories: new Map(), groupHistories: new Map(),
groupHistoryKey: "+1000", groupHistoryKey: "+1000",
maxMediaBytes: 1, 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 () => { 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(); const rememberSentText = vi.fn();
await dispatchBufferedReply({ await dispatchBufferedReply({
@@ -446,7 +481,7 @@ describe("whatsapp inbound dispatch", () => {
}); });
it("normalizes WhatsApp payload text before delivery and echo bookkeeping", async () => { 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(); const rememberSentText = vi.fn();
await dispatchBufferedReply({ await dispatchBufferedReply({
@@ -479,7 +514,7 @@ describe("whatsapp inbound dispatch", () => {
}); });
it("suppresses reasoning and compaction payloads before WhatsApp delivery", async () => { 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(); const rememberSentText = vi.fn();
await dispatchBufferedReply({ await dispatchBufferedReply({
@@ -500,7 +535,7 @@ describe("whatsapp inbound dispatch", () => {
}); });
it("suppresses payloads that normalize to no visible WhatsApp content", async () => { 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(); const rememberSentText = vi.fn();
await dispatchBufferedReply({ await dispatchBufferedReply({
@@ -523,7 +558,7 @@ describe("whatsapp inbound dispatch", () => {
}); });
it("suppresses error payload text", async () => { it("suppresses error payload text", async () => {
const deliverReply = vi.fn(async () => undefined); const deliverReply = vi.fn(async () => acceptedDeliveryResult());
const rememberSentText = vi.fn(); const rememberSentText = vi.fn();
await dispatchBufferedReply({ deliverReply, rememberSentText }); 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 () => { 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(); const rememberSentText = vi.fn();
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementationOnce( dispatchReplyWithBufferedBlockDispatcherMock.mockImplementationOnce(
async (params: { async (params: {
@@ -607,8 +642,52 @@ describe("whatsapp inbound dispatch", () => {
expect(rememberSentText).toHaveBeenCalledTimes(1); 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<void>;
};
}) => {
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 () => { 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(); const rememberSentText = vi.fn();
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementationOnce( dispatchReplyWithBufferedBlockDispatcherMock.mockImplementationOnce(
async (params: { async (params: {

View File

@@ -4,6 +4,7 @@ import {
normalizeWhatsAppOutboundPayload, normalizeWhatsAppOutboundPayload,
normalizeWhatsAppPayloadTextPreservingIndentation, normalizeWhatsAppPayloadTextPreservingIndentation,
} from "../../outbound-media-contract.js"; } from "../../outbound-media-contract.js";
import type { WhatsAppReplyDeliveryResult } from "../deliver-reply.js";
import type { WebInboundMsg } from "../types.js"; import type { WebInboundMsg } from "../types.js";
import { formatGroupMembers } from "./group-members.js"; import { formatGroupMembers } from "./group-members.js";
import type { GroupHistoryEntry } from "./inbound-context.js"; import type { GroupHistoryEntry } from "./inbound-context.js";
@@ -283,7 +284,7 @@ export async function dispatchWhatsAppBufferedReply(params: {
connectionId?: string; connectionId?: string;
skipLog?: boolean; skipLog?: boolean;
tableMode?: ReturnType<typeof resolveMarkdownTableMode>; tableMode?: ReturnType<typeof resolveMarkdownTableMode>;
}) => Promise<void>; }) => Promise<WhatsAppReplyDeliveryResult>;
groupHistories: Map<string, GroupHistoryEntry[]>; groupHistories: Map<string, GroupHistoryEntry[]>;
groupHistoryKey: string; groupHistoryKey: string;
maxMediaBytes: number; maxMediaBytes: number;
@@ -344,7 +345,7 @@ export async function dispatchWhatsAppBufferedReply(params: {
if (!reply.hasMedia && !reply.text.trim()) { if (!reply.hasMedia && !reply.text.trim()) {
return; return;
} }
await params.deliverReply({ const delivery = await params.deliverReply({
replyResult: normalizedDeliveryPayload, replyResult: normalizedDeliveryPayload,
normalizedReplyResult: normalizedDeliveryPayload, normalizedReplyResult: normalizedDeliveryPayload,
msg: params.msg, msg: params.msg,
@@ -357,6 +358,21 @@ export async function dispatchWhatsAppBufferedReply(params: {
skipLog: false, skipLog: false,
tableMode, 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; didSendReply = true;
const shouldLog = normalizedDeliveryPayload.text ? true : undefined; const shouldLog = normalizedDeliveryPayload.text ? true : undefined;
params.rememberSentText(normalizedDeliveryPayload.text, { params.rememberSentText(normalizedDeliveryPayload.text, {

View File

@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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. // Hoisted mocks used across tests so vi.mock factories can reference them.
const { resolvePolicyMock, buildContextMock, runMessageReceivedMock, trackBackgroundTaskMock } = const { resolvePolicyMock, buildContextMock, runMessageReceivedMock, trackBackgroundTaskMock } =
@@ -9,6 +10,16 @@ const { resolvePolicyMock, buildContextMock, runMessageReceivedMock, trackBackgr
trackBackgroundTaskMock: vi.fn(), 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) => { vi.mock("../../inbound-policy.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../inbound-policy.js")>(); const actual = await importOriginal<typeof import("../../inbound-policy.js")>();
return { return {
@@ -169,8 +180,8 @@ const baseMsg = {
chatType: "group" as const, chatType: "group" as const,
body: "hi", body: "hi",
sendComposing: async () => {}, sendComposing: async () => {},
reply: async () => {}, reply: async () => acceptedSendResult("text", "r1"),
sendMedia: async () => {}, sendMedia: async () => acceptedSendResult("media", "m1"),
}; };
const baseRoute = { const baseRoute = {

View File

@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { WhatsAppSendResult } from "../inbound/send-result.js";
import { buildMentionConfig } from "./mentions.js"; import { buildMentionConfig } from "./mentions.js";
import { applyGroupGating, type GroupHistoryEntry } from "./monitor/group-gating.js"; import { applyGroupGating, type GroupHistoryEntry } from "./monitor/group-gating.js";
import { buildInboundLine, formatReplyContext } from "./monitor/message-line.js"; import { buildInboundLine, formatReplyContext } from "./monitor/message-line.js";
@@ -11,6 +12,16 @@ import type { WebInboundMsg } from "./types.js";
let sessionDir: string | undefined; let sessionDir: string | undefined;
let sessionStorePath: string; let sessionStorePath: string;
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
return {
kind,
messageId: id,
messageIds: [id],
keys: [{ id }],
providerAccepted: true,
};
}
beforeEach(async () => { beforeEach(async () => {
sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-group-gating-")); sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-group-gating-"));
sessionStorePath = path.join(sessionDir, "sessions.json"); sessionStorePath = path.join(sessionDir, "sessions.json");
@@ -82,8 +93,8 @@ function createGroupMessage(overrides: Partial<WebInboundMsg> = {}): WebInboundM
senderName: "Alice", senderName: "Alice",
selfE164: "+999", selfE164: "+999",
sendComposing: async () => {}, sendComposing: async () => {},
reply: async (_text, _options) => {}, reply: async (_text, _options) => acceptedSendResult("text", "r1"),
sendMedia: async (_payload, _options) => {}, sendMedia: async (_payload, _options) => acceptedSendResult("media", "m1"),
...overrides, ...overrides,
}; };
} }

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { saveSessionStore } from "openclaw/plugin-sdk/session-store-runtime"; import { saveSessionStore } from "openclaw/plugin-sdk/session-store-runtime";
import { withTempDir } from "openclaw/plugin-sdk/test-env"; import { withTempDir } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { WhatsAppSendResult } from "../inbound/send-result.js";
import { import {
debugMention, debugMention,
isBotMentionedFromTargets, isBotMentionedFromTargets,
@@ -13,6 +14,16 @@ import { getSessionSnapshot } from "./session-snapshot.js";
import type { WebInboundMsg } from "./types.js"; import type { WebInboundMsg } from "./types.js";
import { elide, isLikelyWhatsAppCryptoError } from "./util.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>): WebInboundMsg => const makeMsg = (overrides: Partial<WebInboundMsg>): WebInboundMsg =>
({ ({
id: "m1", id: "m1",
@@ -24,8 +35,8 @@ const makeMsg = (overrides: Partial<WebInboundMsg>): WebInboundMsg =>
chatType: "group", chatType: "group",
chatId: "120363401234567890@g.us", chatId: "120363401234567890@g.us",
sendComposing: async () => {}, sendComposing: async () => {},
reply: async () => {}, reply: async () => acceptedSendResult("text", "r1"),
sendMedia: async () => {}, sendMedia: async () => acceptedSendResult("media", "m1"),
...overrides, ...overrides,
}) as WebInboundMsg; }) as WebInboundMsg;

View File

@@ -2,6 +2,7 @@ import { EventEmitter } from "node:events";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js"; import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js";
import { WhatsAppConnectionController } from "./connection-controller.js"; import { WhatsAppConnectionController } from "./connection-controller.js";
import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js";
import { createWaSocket, waitForWaConnection } from "./session.js"; import { createWaSocket, waitForWaConnection } from "./session.js";
vi.mock("./session.js", async () => { vi.mock("./session.js", async () => {
@@ -16,11 +17,21 @@ vi.mock("./session.js", async () => {
const createWaSocketMock = vi.mocked(createWaSocket); const createWaSocketMock = vi.mocked(createWaSocket);
const waitForWaConnectionMock = vi.mocked(waitForWaConnection); 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") { function createListenerStub(messageId = "ok") {
return { return {
sendMessage: vi.fn(async () => ({ messageId })), sendMessage: vi.fn(async () => acceptedSendResult("text", messageId)),
sendPoll: vi.fn(async () => ({ messageId })), sendPoll: vi.fn(async () => acceptedSendResult("poll", messageId)),
sendReaction: vi.fn(async () => {}), sendReaction: vi.fn(async () => acceptedSendResult("reaction", messageId)),
sendComposingTo: vi.fn(async () => {}), sendComposingTo: vi.fn(async () => {}),
}; };
} }

View File

@@ -42,6 +42,7 @@ import { attachEmitterListener, closeInboundMonitorSocket } from "./lifecycle.js
import { downloadInboundMedia } from "./media.js"; import { downloadInboundMedia } from "./media.js";
import { DisconnectReason, isJidGroup, saveMediaBuffer } from "./runtime-api.js"; import { DisconnectReason, isJidGroup, saveMediaBuffer } from "./runtime-api.js";
import { createWebSendApi } from "./send-api.js"; import { createWebSendApi } from "./send-api.js";
import { normalizeWhatsAppSendResult } from "./send-result.js";
import type { WebInboundMessage, WebListenerCloseReason } from "./types.js"; import type { WebInboundMessage, WebListenerCloseReason } from "./types.js";
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401; const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401;
@@ -622,13 +623,15 @@ export async function attachWebInboxToSocket(
} }
}; };
const reply = async (text: string, options?: MiscMessageGenerationOptions) => { 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 ( const sendMedia = async (
payload: AnyMessageContent, payload: AnyMessageContent,
options?: MiscMessageGenerationOptions, options?: MiscMessageGenerationOptions,
) => { ) => {
await sendTrackedMessage(chatJid, payload, options); const result = await sendTrackedMessage(chatJid, payload, options);
return normalizeWhatsAppSendResult(result, "media");
}; };
const timestamp = inbound.messageTimestampMs; const timestamp = inbound.messageTimestampMs;
const mentionedJids = extractMentionedJids(msg.message as proto.IMessage | undefined); const mentionedJids = extractMentionedJids(msg.message as proto.IMessage | undefined);

View File

@@ -1,3 +1,8 @@
import type {
AnyMessageContent,
MiscMessageGenerationOptions,
WAMessage,
} from "@whiskeysockets/baileys";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { createWebSendApi } from "./send-api.js"; import { createWebSendApi } from "./send-api.js";
@@ -14,7 +19,13 @@ vi.mock("openclaw/plugin-sdk/channel-activity-runtime", async () => {
}); });
describe("createWebSendApi", () => { describe("createWebSendApi", () => {
const sendMessage = vi.fn(async () => ({ key: { id: "msg-1" } })); const sendMessage = vi.fn(
async (
_jid: string,
_content: AnyMessageContent,
_options?: MiscMessageGenerationOptions,
): Promise<WAMessage | undefined> => ({ key: { id: "msg-1" } }) as WAMessage,
);
const sendPresenceUpdate = vi.fn(async () => {}); const sendPresenceUpdate = vi.fn(async () => {});
let api: ReturnType<typeof createWebSendApi>; let api: ReturnType<typeof createWebSendApi>;
@@ -60,8 +71,14 @@ describe("createWebSendApi", () => {
}); });
it("sends plain text messages", async () => { 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(sendMessage).toHaveBeenCalledWith("1555@s.whatsapp.net", { text: "hello" });
expect(res).toMatchObject({
kind: "text",
messageId: "msg-1",
messageIds: ["msg-1"],
providerAccepted: true,
});
expect(recordChannelActivity).toHaveBeenCalledWith({ expect(recordChannelActivity).toHaveBeenCalledWith({
channel: "whatsapp", channel: "whatsapp",
accountId: "main", accountId: "main",
@@ -102,7 +119,10 @@ describe("createWebSendApi", () => {
it("sends visible text separately from push-to-talk voice notes", async () => { it("sends visible text separately from push-to-talk voice notes", async () => {
const payload = Buffer.from("aud"); 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( expect(sendMessage).toHaveBeenNthCalledWith(
1, 1,
"1555@s.whatsapp.net", "1555@s.whatsapp.net",
@@ -115,6 +135,12 @@ describe("createWebSendApi", () => {
expect(sendMessage).toHaveBeenNthCalledWith(2, "1555@s.whatsapp.net", { expect(sendMessage).toHaveBeenNthCalledWith(2, "1555@s.whatsapp.net", {
text: "voice text", 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 () => { it("supports video media and gifPlayback option", async () => {
@@ -158,7 +184,7 @@ describe("createWebSendApi", () => {
}); });
it("sends reactions with participant JID normalization", async () => { 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( expect(sendMessage).toHaveBeenCalledWith(
"1555@s.whatsapp.net", "1555@s.whatsapp.net",
expect.objectContaining({ 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 () => { it("keeps direct-chat reactions without a participant key", async () => {

View File

@@ -1,11 +1,17 @@
import type { import type {
AnyMessageContent, AnyMessageContent,
MiscMessageGenerationOptions, MiscMessageGenerationOptions,
WAMessage,
WAPresence, WAPresence,
} from "@whiskeysockets/baileys"; } from "@whiskeysockets/baileys";
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime"; import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
import { buildQuotedMessageOptions } from "../quoted-message.js"; import { buildQuotedMessageOptions } from "../quoted-message.js";
import { toWhatsappJid } from "../text-runtime.js"; import { toWhatsappJid } from "../text-runtime.js";
import {
combineWhatsAppSendResults,
normalizeWhatsAppSendResult,
type WhatsAppSendResult,
} from "./send-result.js";
import type { ActiveWebSendOptions } from "./types.js"; import type { ActiveWebSendOptions } from "./types.js";
function recordWhatsAppOutbound(accountId: string) { 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: { export function createWebSendApi(params: {
sock: { sock: {
sendMessage: ( sendMessage: (
jid: string, jid: string,
content: AnyMessageContent, content: AnyMessageContent,
options?: MiscMessageGenerationOptions, options?: MiscMessageGenerationOptions,
) => Promise<unknown>; ) => Promise<WAMessage | undefined>;
sendPresenceUpdate: (presence: WAPresence, jid?: string) => Promise<unknown>; sendPresenceUpdate: (presence: WAPresence, jid?: string) => Promise<unknown>;
}; };
defaultAccountId: string; defaultAccountId: string;
@@ -40,7 +40,7 @@ export function createWebSendApi(params: {
mediaBuffer?: Buffer, mediaBuffer?: Buffer,
mediaType?: string, mediaType?: string,
sendOptions?: ActiveWebSendOptions, sendOptions?: ActiveWebSendOptions,
): Promise<{ messageId: string }> => { ): Promise<WhatsAppSendResult> => {
const jid = toWhatsappJid(to); const jid = toWhatsappJid(to);
let payload: AnyMessageContent; let payload: AnyMessageContent;
if (mediaBuffer) { if (mediaBuffer) {
@@ -85,23 +85,22 @@ export function createWebSendApi(params: {
const result = quotedOpts const result = quotedOpts
? await params.sock.sendMessage(jid, payload, quotedOpts) ? await params.sock.sendMessage(jid, payload, quotedOpts)
: await params.sock.sendMessage(jid, payload); : await params.sock.sendMessage(jid, payload);
const results = [normalizeWhatsAppSendResult(result, mediaBuffer ? "media" : "text")];
if (mediaBuffer && mediaType?.startsWith("audio/") && text.trim()) { if (mediaBuffer && mediaType?.startsWith("audio/") && text.trim()) {
const textPayload: AnyMessageContent = { text }; const textPayload: AnyMessageContent = { text };
if (quotedOpts) { const textResult = quotedOpts
await params.sock.sendMessage(jid, textPayload, quotedOpts); ? await params.sock.sendMessage(jid, textPayload, quotedOpts)
} else { : await params.sock.sendMessage(jid, textPayload);
await params.sock.sendMessage(jid, textPayload); results.push(normalizeWhatsAppSendResult(textResult, "text"));
}
} }
const accountId = sendOptions?.accountId ?? params.defaultAccountId; const accountId = sendOptions?.accountId ?? params.defaultAccountId;
recordWhatsAppOutbound(accountId); recordWhatsAppOutbound(accountId);
const messageId = resolveOutboundMessageId(result); return combineWhatsAppSendResults(mediaBuffer ? "media" : "text", results);
return { messageId };
}, },
sendPoll: async ( sendPoll: async (
to: string, to: string,
poll: { question: string; options: string[]; maxSelections?: number }, poll: { question: string; options: string[]; maxSelections?: number },
): Promise<{ messageId: string }> => { ): Promise<WhatsAppSendResult> => {
const jid = toWhatsappJid(to); const jid = toWhatsappJid(to);
const result = await params.sock.sendMessage(jid, { const result = await params.sock.sendMessage(jid, {
poll: { poll: {
@@ -111,8 +110,7 @@ export function createWebSendApi(params: {
}, },
} as AnyMessageContent); } as AnyMessageContent);
recordWhatsAppOutbound(params.defaultAccountId); recordWhatsAppOutbound(params.defaultAccountId);
const messageId = resolveOutboundMessageId(result); return normalizeWhatsAppSendResult(result, "poll");
return { messageId };
}, },
sendReaction: async ( sendReaction: async (
chatJid: string, chatJid: string,
@@ -120,9 +118,9 @@ export function createWebSendApi(params: {
emoji: string, emoji: string,
fromMe: boolean, fromMe: boolean,
participant?: string, participant?: string,
): Promise<void> => { ): Promise<WhatsAppSendResult> => {
const jid = toWhatsappJid(chatJid); const jid = toWhatsappJid(chatJid);
await params.sock.sendMessage(jid, { const result = await params.sock.sendMessage(jid, {
react: { react: {
text: emoji, text: emoji,
key: { key: {
@@ -133,6 +131,7 @@ export function createWebSendApi(params: {
}, },
}, },
} as AnyMessageContent); } as AnyMessageContent);
return normalizeWhatsAppSendResult(result, "reaction");
}, },
sendComposingTo: async (to: string): Promise<void> => { sendComposingTo: async (to: string): Promise<void> => {
const jid = toWhatsappJid(to); const jid = toWhatsappJid(to);

View File

@@ -0,0 +1,67 @@
import type { WAMessage, WAMessageKey } from "@whiskeysockets/baileys";
export type WhatsAppSendKind = "media" | "poll" | "reaction" | "text";
export type WhatsAppSendKey = Omit<
Pick<WAMessageKey, "fromMe" | "id" | "participant" | "remoteJid">,
"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;
}

View File

@@ -2,6 +2,7 @@ import type { AnyMessageContent, MiscMessageGenerationOptions } from "@whiskeyso
import type { NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound"; import type { NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound";
import type { PollInput } from "openclaw/plugin-sdk/poll-runtime"; import type { PollInput } from "openclaw/plugin-sdk/poll-runtime";
import type { WhatsAppIdentity, WhatsAppReplyContext, WhatsAppSelfIdentity } from "../identity.js"; import type { WhatsAppIdentity, WhatsAppReplyContext, WhatsAppSelfIdentity } from "../identity.js";
import type { WhatsAppSendResult } from "./send-result.js";
export type WebListenerCloseReason = { export type WebListenerCloseReason = {
status?: number; status?: number;
@@ -29,15 +30,15 @@ export type ActiveWebListener = {
mediaBuffer?: Buffer, mediaBuffer?: Buffer,
mediaType?: string, mediaType?: string,
options?: ActiveWebSendOptions, options?: ActiveWebSendOptions,
) => Promise<{ messageId: string }>; ) => Promise<WhatsAppSendResult>;
sendPoll: (to: string, poll: PollInput) => Promise<{ messageId: string }>; sendPoll: (to: string, poll: PollInput) => Promise<WhatsAppSendResult>;
sendReaction: ( sendReaction: (
chatJid: string, chatJid: string,
messageId: string, messageId: string,
emoji: string, emoji: string,
fromMe: boolean, fromMe: boolean,
participant?: string, participant?: string,
) => Promise<void>; ) => Promise<WhatsAppSendResult>;
sendComposingTo: (to: string) => Promise<void>; sendComposingTo: (to: string) => Promise<void>;
close?: () => Promise<void>; close?: () => Promise<void>;
}; };
@@ -85,8 +86,11 @@ export type WebInboundMessage = {
fromMe?: boolean; fromMe?: boolean;
location?: NormalizedLocation; location?: NormalizedLocation;
sendComposing: () => Promise<void>; sendComposing: () => Promise<void>;
reply: (text: string, options?: MiscMessageGenerationOptions) => Promise<void>; reply: (text: string, options?: MiscMessageGenerationOptions) => Promise<WhatsAppSendResult>;
sendMedia: (payload: AnyMessageContent, options?: MiscMessageGenerationOptions) => Promise<void>; sendMedia: (
payload: AnyMessageContent,
options?: MiscMessageGenerationOptions,
) => Promise<WhatsAppSendResult>;
mediaPath?: string; mediaPath?: string;
mediaType?: string; mediaType?: string;
mediaFileName?: string; mediaFileName?: string;

View File

@@ -5,6 +5,7 @@ import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { redactIdentifier } from "openclaw/plugin-sdk/logging-core"; import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; 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"; import type { ActiveWebListener } from "./inbound/types.js";
const hoisted = vi.hoisted(() => ({ const hoisted = vi.hoisted(() => ({
@@ -23,6 +24,16 @@ const WHATSAPP_TEST_CFG: OpenClawConfig = {
channels: { whatsapp: {} }, 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 () => { vi.mock("./connection-controller-registry.js", async () => {
const actual = await vi.importActual<typeof import("./connection-controller-registry.js")>( const actual = await vi.importActual<typeof import("./connection-controller-registry.js")>(
"./connection-controller-registry.js", "./connection-controller-registry.js",
@@ -70,9 +81,9 @@ vi.mock("./text-runtime.js", async () => {
describe("web outbound", () => { describe("web outbound", () => {
const sendComposingTo = vi.fn(async () => {}); const sendComposingTo = vi.fn(async () => {});
const sendMessage = vi.fn(async () => ({ messageId: "msg123" })); const sendMessage = vi.fn(async () => acceptedSendResult("text", "msg123"));
const sendPoll = vi.fn(async () => ({ messageId: "poll123" })); const sendPoll = vi.fn(async () => acceptedSendResult("poll", "poll123"));
const sendReaction = vi.fn(async () => {}); const sendReaction = vi.fn(async () => acceptedSendResult("reaction", "reaction123"));
beforeAll(async () => { beforeAll(async () => {
({ sendMessageWhatsApp, sendPollWhatsApp, sendReactionWhatsApp } = await import("./send.js")); ({ sendMessageWhatsApp, sendPollWhatsApp, sendReactionWhatsApp } = await import("./send.js"));