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

View File

@@ -569,6 +569,15 @@ Behavior notes:
</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">
Check in this order:

View File

@@ -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;

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 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<void>;
onClose: Promise<WebListenerCloseReason>;
signalClose: () => void;
sendMessage: () => Promise<{ messageId: string }>;
sendPoll: () => Promise<{ messageId: string }>;
sendReaction: () => Promise<void>;
sendMessage: () => Promise<WhatsAppSendResult>;
sendPoll: () => Promise<WhatsAppSendResult>;
sendReaction: () => Promise<WhatsAppSendResult>;
sendComposingTo: () => Promise<void>;
};
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
@@ -253,13 +254,26 @@ export function createMockWebListener(): MockWebListener {
close: vi.fn(async () => undefined),
onClose: new Promise<WebListenerCloseReason>(() => {}),
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<void>> = [];
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(),
};
}

View File

@@ -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<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 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",

View File

@@ -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;

View File

@@ -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")),
};
}

View File

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

View File

@@ -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<ReplyPayload>;
@@ -38,12 +45,26 @@ export async function deliverWebReply(params: {
connectionId?: string;
skipLog?: boolean;
tableMode?: MarkdownTableMode;
}) {
}): Promise<WhatsAppReplyDeliveryResult> {
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<unknown>, label: string, maxAttempts = 3) => {
const sendWithRetry = async <T>(fn: () => Promise<T>, 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();
}

View File

@@ -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> = {}): WebInboundMessage {
return {
id: "msg-1",
@@ -22,8 +33,8 @@ function createMessage(overrides: Partial<WebInboundMessage> = {}): 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,
};
}

View File

@@ -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<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"] => ({
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", () => {

View File

@@ -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<typeof buildWhatsAppInboundContext>[0]["route"];
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 {
return {
agentId: "main",
@@ -99,8 +110,8 @@ function makeMsg(overrides: Partial<TestMsg> = {}): 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<BufferedReplyParams> = {}) {
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<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 () => {
const deliverReply = vi.fn(async () => undefined);
const deliverReply = vi.fn(async () => acceptedDeliveryResult());
const rememberSentText = vi.fn();
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementationOnce(
async (params: {

View File

@@ -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<typeof resolveMarkdownTableMode>;
}) => Promise<void>;
}) => Promise<WhatsAppReplyDeliveryResult>;
groupHistories: Map<string, GroupHistoryEntry[]>;
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, {

View File

@@ -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<typeof import("../../inbound-policy.js")>();
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 = {

View File

@@ -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<WebInboundMsg> = {}): 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,
};
}

View File

@@ -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>): WebInboundMsg =>
({
id: "m1",
@@ -24,8 +35,8 @@ const makeMsg = (overrides: Partial<WebInboundMsg>): 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;

View File

@@ -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 () => {}),
};
}

View File

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

View File

@@ -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<WAMessage | undefined> => ({ key: { id: "msg-1" } }) as WAMessage,
);
const sendPresenceUpdate = vi.fn(async () => {});
let api: ReturnType<typeof createWebSendApi>;
@@ -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 () => {

View File

@@ -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<unknown>;
) => Promise<WAMessage | undefined>;
sendPresenceUpdate: (presence: WAPresence, jid?: string) => Promise<unknown>;
};
defaultAccountId: string;
@@ -40,7 +40,7 @@ export function createWebSendApi(params: {
mediaBuffer?: Buffer,
mediaType?: string,
sendOptions?: ActiveWebSendOptions,
): Promise<{ messageId: string }> => {
): Promise<WhatsAppSendResult> => {
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<WhatsAppSendResult> => {
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<void> => {
): Promise<WhatsAppSendResult> => {
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<void> => {
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 { 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<WhatsAppSendResult>;
sendPoll: (to: string, poll: PollInput) => Promise<WhatsAppSendResult>;
sendReaction: (
chatJid: string,
messageId: string,
emoji: string,
fromMe: boolean,
participant?: string,
) => Promise<void>;
) => Promise<WhatsAppSendResult>;
sendComposingTo: (to: string) => Promise<void>;
close?: () => Promise<void>;
};
@@ -85,8 +86,11 @@ export type WebInboundMessage = {
fromMe?: boolean;
location?: NormalizedLocation;
sendComposing: () => Promise<void>;
reply: (text: string, options?: MiscMessageGenerationOptions) => Promise<void>;
sendMedia: (payload: AnyMessageContent, options?: MiscMessageGenerationOptions) => Promise<void>;
reply: (text: string, options?: MiscMessageGenerationOptions) => Promise<WhatsAppSendResult>;
sendMedia: (
payload: AnyMessageContent,
options?: MiscMessageGenerationOptions,
) => Promise<WhatsAppSendResult>;
mediaPath?: string;
mediaType?: string;
mediaFileName?: string;

View File

@@ -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<typeof import("./connection-controller-registry.js")>(
"./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"));