fix(whatsapp): trim leading whitespace in direct outbound sends (#43539)

Trim leading whitespace from direct WhatsApp text and media caption sends.

Also guard empty text-only web sends after trimming.
This commit is contained in:
Luke
2026-03-12 11:32:04 +11:00
committed by GitHub
parent 7e3787517f
commit a5ceb62d44
4 changed files with 151 additions and 7 deletions

View File

@@ -1,4 +1,4 @@
import { describe, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { ReplyPayload } from "../../../auto-reply/types.js";
import {
installSendPayloadContractSuite,
@@ -34,4 +34,92 @@ describe("whatsappOutbound sendPayload", () => {
chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 },
createHarness,
});
it("trims leading whitespace for direct text sends", async () => {
const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" }));
await whatsappOutbound.sendText!({
cfg: {},
to: "5511999999999@c.us",
text: "\n \thello",
deps: { sendWhatsApp },
});
expect(sendWhatsApp).toHaveBeenCalledWith("5511999999999@c.us", "hello", {
verbose: false,
cfg: {},
accountId: undefined,
gifPlayback: undefined,
});
});
it("trims leading whitespace for direct media captions", async () => {
const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" }));
await whatsappOutbound.sendMedia!({
cfg: {},
to: "5511999999999@c.us",
text: "\n \tcaption",
mediaUrl: "/tmp/test.png",
deps: { sendWhatsApp },
});
expect(sendWhatsApp).toHaveBeenCalledWith("5511999999999@c.us", "caption", {
verbose: false,
cfg: {},
mediaUrl: "/tmp/test.png",
mediaLocalRoots: undefined,
accountId: undefined,
gifPlayback: undefined,
});
});
it("trims leading whitespace for sendPayload text and caption delivery", async () => {
const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" }));
await whatsappOutbound.sendPayload!({
cfg: {},
to: "5511999999999@c.us",
text: "",
payload: { text: "\n\nhello" },
deps: { sendWhatsApp },
});
await whatsappOutbound.sendPayload!({
cfg: {},
to: "5511999999999@c.us",
text: "",
payload: { text: "\n\ncaption", mediaUrl: "/tmp/test.png" },
deps: { sendWhatsApp },
});
expect(sendWhatsApp).toHaveBeenNthCalledWith(1, "5511999999999@c.us", "hello", {
verbose: false,
cfg: {},
accountId: undefined,
gifPlayback: undefined,
});
expect(sendWhatsApp).toHaveBeenNthCalledWith(2, "5511999999999@c.us", "caption", {
verbose: false,
cfg: {},
mediaUrl: "/tmp/test.png",
mediaLocalRoots: undefined,
accountId: undefined,
gifPlayback: undefined,
});
});
it("skips whitespace-only text payloads", async () => {
const sendWhatsApp = vi.fn();
const result = await whatsappOutbound.sendPayload!({
cfg: {},
to: "5511999999999@c.us",
text: "",
payload: { text: "\n \t" },
deps: { sendWhatsApp },
});
expect(result).toEqual({ channel: "whatsapp", messageId: "" });
expect(sendWhatsApp).not.toHaveBeenCalled();
});
});

View File

@@ -5,6 +5,10 @@ import { resolveWhatsAppOutboundTarget } from "../../../whatsapp/resolve-outboun
import type { ChannelOutboundAdapter } from "../types.js";
import { sendTextMediaPayload } from "./direct-text-media.js";
function trimLeadingWhitespace(text: string | undefined): string {
return text?.trimStart() ?? "";
}
export const whatsappOutbound: ChannelOutboundAdapter = {
deliveryMode: "gateway",
chunker: chunkText,
@@ -13,12 +17,32 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
pollMaxOptions: 12,
resolveTarget: ({ to, allowFrom, mode }) =>
resolveWhatsAppOutboundTarget({ to, allowFrom, mode }),
sendPayload: async (ctx) =>
await sendTextMediaPayload({ channel: "whatsapp", ctx, adapter: whatsappOutbound }),
sendPayload: async (ctx) => {
const text = trimLeadingWhitespace(ctx.payload.text);
const hasMedia = Boolean(ctx.payload.mediaUrl) || (ctx.payload.mediaUrls?.length ?? 0) > 0;
if (!text && !hasMedia) {
return { channel: "whatsapp", messageId: "" };
}
return await sendTextMediaPayload({
channel: "whatsapp",
ctx: {
...ctx,
payload: {
...ctx.payload,
text,
},
},
adapter: whatsappOutbound,
});
},
sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => {
const normalizedText = trimLeadingWhitespace(text);
if (!normalizedText) {
return { channel: "whatsapp", messageId: "" };
}
const send =
deps?.sendWhatsApp ?? (await import("../../../web/outbound.js")).sendMessageWhatsApp;
const result = await send(to, text, {
const result = await send(to, normalizedText, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
@@ -27,9 +51,10 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
return { channel: "whatsapp", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, gifPlayback }) => {
const normalizedText = trimLeadingWhitespace(text);
const send =
deps?.sendWhatsApp ?? (await import("../../../web/outbound.js")).sendMessageWhatsApp;
const result = await send(to, text, {
const result = await send(to, normalizedText, {
verbose: false,
cfg,
mediaUrl,

View File

@@ -48,6 +48,34 @@ describe("web outbound", () => {
expect(sendMessage).toHaveBeenCalledWith("+1555", "hi", undefined, undefined);
});
it("trims leading whitespace before sending text and captions", async () => {
await sendMessageWhatsApp("+1555", "\n \thello", { verbose: false });
expect(sendMessage).toHaveBeenLastCalledWith("+1555", "hello", undefined, undefined);
const buf = Buffer.from("img");
loadWebMediaMock.mockResolvedValueOnce({
buffer: buf,
contentType: "image/jpeg",
kind: "image",
});
await sendMessageWhatsApp("+1555", "\n \tcaption", {
verbose: false,
mediaUrl: "/tmp/pic.jpg",
});
expect(sendMessage).toHaveBeenLastCalledWith("+1555", "caption", buf, "image/jpeg");
});
it("skips whitespace-only text sends without media", async () => {
const result = await sendMessageWhatsApp("+1555", "\n \t", { verbose: false });
expect(result).toEqual({
messageId: "",
toJid: "1555@s.whatsapp.net",
});
expect(sendComposingTo).not.toHaveBeenCalled();
expect(sendMessage).not.toHaveBeenCalled();
});
it("throws a helpful error when no active listener exists", async () => {
setActiveWebListener(null);
await expect(

View File

@@ -26,7 +26,11 @@ export async function sendMessageWhatsApp(
accountId?: string;
},
): Promise<{ messageId: string; toJid: string }> {
let text = body;
let text = body.trimStart();
const jid = toWhatsappJid(to);
if (!text && !options.mediaUrl) {
return { messageId: "", toJid: jid };
}
const correlationId = generateSecureUuid();
const startedAt = Date.now();
const { listener: active, accountId: resolvedAccountId } = requireActiveWebListener(
@@ -51,7 +55,6 @@ export async function sendMessageWhatsApp(
to: redactedTo,
});
try {
const jid = toWhatsappJid(to);
const redactedJid = redactIdentifier(jid);
let mediaBuffer: Buffer | undefined;
let mediaType: string | undefined;