fix(whatsapp): allow media-only sends and normalize leading blank payloads (#14408)

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Karim Naguib
2026-02-11 21:21:21 -08:00
committed by GitHub
parent 186dc0363f
commit 7a0591ef87
11 changed files with 352 additions and 14 deletions

View File

@@ -15,7 +15,7 @@ export const AgentEventSchema = Type.Object(
export const SendParamsSchema = Type.Object(
{
to: NonEmptyString,
message: NonEmptyString,
message: Type.Optional(Type.String()),
mediaUrl: Type.Optional(Type.String()),
mediaUrls: Type.Optional(Type.Array(Type.String())),
gifPlayback: Type.Optional(Type.Boolean()),

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayRequestContext } from "./types.js";
import { sendHandlers } from "./send.js";
@@ -47,6 +47,67 @@ const makeContext = (): GatewayRequestContext =>
}) as unknown as GatewayRequestContext;
describe("gateway send mirroring", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("accepts media-only sends without message", async () => {
mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId: "m-media", channel: "slack" }]);
const respond = vi.fn();
await sendHandlers.send({
params: {
to: "channel:C1",
mediaUrl: "https://example.com/a.png",
channel: "slack",
idempotencyKey: "idem-media-only",
},
respond,
context: makeContext(),
req: { type: "req", id: "1", method: "send" },
client: null,
isWebchatConnect: () => false,
});
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
payloads: [{ text: "", mediaUrl: "https://example.com/a.png", mediaUrls: undefined }],
}),
);
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({ messageId: "m-media" }),
undefined,
expect.objectContaining({ channel: "slack" }),
);
});
it("rejects empty sends when neither text nor media is present", async () => {
const respond = vi.fn();
await sendHandlers.send({
params: {
to: "channel:C1",
message: " ",
channel: "slack",
idempotencyKey: "idem-empty",
},
respond,
context: makeContext(),
req: { type: "req", id: "1", method: "send" },
client: null,
isWebchatConnect: () => false,
});
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: expect.stringContaining("text or media is required"),
}),
);
});
it("does not mirror when delivery returns no results", async () => {
mocks.deliverOutboundPayloads.mockResolvedValue([]);

View File

@@ -58,7 +58,7 @@ export const sendHandlers: GatewayRequestHandlers = {
}
const request = p as {
to: string;
message: string;
message?: string;
mediaUrl?: string;
mediaUrls?: string[];
gifPlayback?: boolean;
@@ -85,8 +85,24 @@ export const sendHandlers: GatewayRequestHandlers = {
return;
}
const to = request.to.trim();
const message = request.message.trim();
const mediaUrls = Array.isArray(request.mediaUrls) ? request.mediaUrls : undefined;
const message = typeof request.message === "string" ? request.message.trim() : "";
const mediaUrl =
typeof request.mediaUrl === "string" && request.mediaUrl.trim().length > 0
? request.mediaUrl.trim()
: undefined;
const mediaUrls = Array.isArray(request.mediaUrls)
? request.mediaUrls
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter((entry) => entry.length > 0)
: undefined;
if (!message && !mediaUrl && (mediaUrls?.length ?? 0) === 0) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "invalid send params: text or media is required"),
);
return;
}
const channelInput = typeof request.channel === "string" ? request.channel : undefined;
const normalizedChannel = channelInput ? normalizeChannelId(channelInput) : null;
if (channelInput && !normalizedChannel) {
@@ -132,7 +148,7 @@ export const sendHandlers: GatewayRequestHandlers = {
}
const outboundDeps = context.deps ? createOutboundSendDeps(context.deps) : undefined;
const mirrorPayloads = normalizeReplyPayloadsForDelivery([
{ text: message, mediaUrl: request.mediaUrl, mediaUrls },
{ text: message, mediaUrl, mediaUrls },
]);
const mirrorText = mirrorPayloads
.map((payload) => payload.text)
@@ -170,7 +186,7 @@ export const sendHandlers: GatewayRequestHandlers = {
channel: outboundChannel,
to: resolved.to,
accountId,
payloads: [{ text: message, mediaUrl: request.mediaUrl, mediaUrls }],
payloads: [{ text: message, mediaUrl, mediaUrls }],
gifPlayback: request.gifPlayback,
deps: outboundDeps,
mirror: providedSessionKey