From a5ceb62d4401d311a4070b80dd9d61395d26c122 Mon Sep 17 00:00:00 2001 From: Luke <92253590+ImLukeF@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:32:04 +1100 Subject: [PATCH] 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. --- .../outbound/whatsapp.sendpayload.test.ts | 90 ++++++++++++++++++- src/channels/plugins/outbound/whatsapp.ts | 33 ++++++- src/web/outbound.test.ts | 28 ++++++ src/web/outbound.ts | 7 +- 4 files changed, 151 insertions(+), 7 deletions(-) diff --git a/src/channels/plugins/outbound/whatsapp.sendpayload.test.ts b/src/channels/plugins/outbound/whatsapp.sendpayload.test.ts index e98351cfa61..943c8a8ba9b 100644 --- a/src/channels/plugins/outbound/whatsapp.sendpayload.test.ts +++ b/src/channels/plugins/outbound/whatsapp.sendpayload.test.ts @@ -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(); + }); }); diff --git a/src/channels/plugins/outbound/whatsapp.ts b/src/channels/plugins/outbound/whatsapp.ts index e5de15241ae..58004676e6e 100644 --- a/src/channels/plugins/outbound/whatsapp.ts +++ b/src/channels/plugins/outbound/whatsapp.ts @@ -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, diff --git a/src/web/outbound.test.ts b/src/web/outbound.test.ts index e494392d750..506d7816630 100644 --- a/src/web/outbound.test.ts +++ b/src/web/outbound.test.ts @@ -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( diff --git a/src/web/outbound.ts b/src/web/outbound.ts index 43136c6f779..1fcaa807c37 100644 --- a/src/web/outbound.ts +++ b/src/web/outbound.ts @@ -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;