From 6f3be89db26ca656f279fe000c75a041d002936f Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 19 May 2026 18:47:19 +0300 Subject: [PATCH 1/3] feat(whatsapp): send outbound files and attachments via Cloud API Implement media upload, MIME mapping, caption fallbacks, and card+file sequencing. Add shared PlatformName support for whatsapp buffer utilities. --- .changeset/whatsapp-outbound-files.md | 8 + apps/docs/content/docs/files.mdx | 2 + packages/adapter-shared/src/card-utils.ts | 3 +- packages/adapter-whatsapp/AGENTS.md | 29 +- packages/adapter-whatsapp/src/index.test.ts | 324 +++++++++++++- packages/adapter-whatsapp/src/index.ts | 444 +++++++++++++++++++- packages/adapter-whatsapp/src/types.ts | 9 + 7 files changed, 807 insertions(+), 12 deletions(-) create mode 100644 .changeset/whatsapp-outbound-files.md diff --git a/.changeset/whatsapp-outbound-files.md b/.changeset/whatsapp-outbound-files.md new file mode 100644 index 00000000..8b2e9f78 --- /dev/null +++ b/.changeset/whatsapp-outbound-files.md @@ -0,0 +1,8 @@ +--- +"@chat-adapter/whatsapp": minor +"@chat-adapter/shared": patch +--- + +Implement outbound file and attachment sending for the WhatsApp adapter. + +Supports binary `FileUpload` uploads, typed `Attachment` payloads (binary or HTTPS link passthrough), multi-file sequential sends, smart MIME-to-message-type mapping, caption placement with audio/long-text fallbacks, and card+file sequencing. Adds `whatsapp` to shared `PlatformName` for buffer utilities. diff --git a/apps/docs/content/docs/files.mdx b/apps/docs/content/docs/files.mdx index 257ae1fe..96591294 100644 --- a/apps/docs/content/docs/files.mdx +++ b/apps/docs/content/docs/files.mdx @@ -45,6 +45,8 @@ await thread.post({ Outgoing `attachments` are available on `{ raw }`, `{ markdown }`, and `{ ast }` messages. Card messages use `files` for uploads. Use `files` for generic uploads. On Telegram, `files` always upload as documents, while `attachments` preserve image, audio, video, or file media type. Use `data` or `fetchData` for private/authenticated files; URL-only attachments must be public URLs Telegram can fetch directly. +On WhatsApp, each file or attachment is sent as its own message (one media object per API call). Markdown text becomes a caption on the first media message when supported; audio messages and captions over 1024 characters send the text as a separate message first. URL-only attachments must use HTTPS. + ### Multiple files ```typescript title="lib/bot.ts" lineNumbers diff --git a/packages/adapter-shared/src/card-utils.ts b/packages/adapter-shared/src/card-utils.ts index 9d0fd9eb..1b084b95 100644 --- a/packages/adapter-shared/src/card-utils.ts +++ b/packages/adapter-shared/src/card-utils.ts @@ -15,7 +15,7 @@ import { /** * Supported platform names for adapter utilities. */ -export type PlatformName = "slack" | "gchat" | "teams" | "discord"; +export type PlatformName = "slack" | "gchat" | "teams" | "discord" | "whatsapp"; /** * Button style mappings per platform. @@ -31,6 +31,7 @@ export const BUTTON_STYLE_MAPPINGS: Record< gchat: { primary: "primary", danger: "danger" }, // Colors handled via buttonColor teams: { primary: "positive", danger: "destructive" }, discord: { primary: "primary", danger: "danger" }, + whatsapp: { primary: "primary", danger: "danger" }, }; /** diff --git a/packages/adapter-whatsapp/AGENTS.md b/packages/adapter-whatsapp/AGENTS.md index f5562268..68e77de2 100644 --- a/packages/adapter-whatsapp/AGENTS.md +++ b/packages/adapter-whatsapp/AGENTS.md @@ -185,11 +185,30 @@ is closed. ## File uploads -`postMessage` accepts `FileUpload`. The adapter: - -1. Calls `POST /{phoneNumberId}/media` with the binary content to - obtain a `media_id`. -2. References the `media_id` in the outbound message. +`postMessage` accepts both `files` (`FileUpload[]`) and `attachments` +(`Attachment[]`). Binary payloads upload via +`POST /{phoneNumberId}/media` to obtain a `media_id`; URL-only +attachments use WhatsApp link passthrough (HTTPS required, no upload). + +**One media object per API message.** Multiple files or attachments +in a single `post()` call are sent as sequential messages. The last +message ID is returned (same convention as long-text chunking). + +**Captions.** Markdown or card fallback text is attached as a caption +on the first media message when possible (max 1024 characters). A +separate leading text message is sent when the caption is too long, +or when the first media is `audio` (audio messages do not support +captions). + +**MIME mapping.** `image/jpeg` and `image/png` map to `image`; +other `image/*` types (e.g. GIF) map to `document`. `video/mp4` and +`video/3gpp` map to `video`; `audio/*` maps to `audio`; everything +else maps to `document`. Pre-flight size checks throw +`ValidationError` when binary size is known (image 5 MB, audio/video +16 MB, document 100 MB). + +**Cards + files.** Media is sent first (caption from card fallback +text), then an interactive card message when the card has buttons. Media IDs expire after 30 days. For inbound media, the adapter exposes a lazy `fetchData()` that downloads the binary on demand. diff --git a/packages/adapter-whatsapp/src/index.test.ts b/packages/adapter-whatsapp/src/index.test.ts index f05e2959..67b8049c 100644 --- a/packages/adapter-whatsapp/src/index.test.ts +++ b/packages/adapter-whatsapp/src/index.test.ts @@ -1,4 +1,5 @@ import { createHmac } from "node:crypto"; +import type { CardElement } from "chat"; import { afterEach, beforeEach, @@ -8,11 +9,17 @@ import { type MockInstance, vi, } from "vitest"; -import { createWhatsAppAdapter, splitMessage, WhatsAppAdapter } from "./index"; +import { + createWhatsAppAdapter, + getWhatsAppMediaType, + splitMessage, + WhatsAppAdapter, +} from "./index"; const NOT_SUPPORTED_PATTERN = /not support/i; const ACCESS_TOKEN_PATTERN = /accessToken/i; const APP_SECRET_PATTERN = /appSecret/i; +const WHATSAPP_IMAGE_SIZE_LIMIT_PATTERN = /exceeds WhatsApp image limit/; /** * Create a minimal WhatsAppAdapter for testing thread ID methods. @@ -864,6 +871,321 @@ describe("postMessage", () => { }); }); +// --------------------------------------------------------------------------- +// postMessage - file uploads +// --------------------------------------------------------------------------- + +describe("postMessage - file uploads", () => { + const THREAD_ID = "whatsapp:123456789:15551234567"; + + let fetchSpy: MockInstance; + let messageCounter: number; + + function createMediaFetchMock() { + let mediaCounter = 0; + messageCounter = 0; + + return (url: string | URL | Request) => { + const urlStr = String(url); + + if (urlStr.includes("/media")) { + mediaCounter += 1; + + return Promise.resolve( + new Response(JSON.stringify({ id: `media-${mediaCounter}` }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + } + + messageCounter += 1; + + return Promise.resolve( + new Response( + JSON.stringify({ messages: [{ id: `wamid.msg${messageCounter}` }] }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ); + }; + } + + function getMessageCalls(): [unknown, RequestInit | undefined][] { + return fetchSpy.mock.calls.filter(([url]) => + String(url).includes("/messages") + ) as [unknown, RequestInit | undefined][]; + } + + function getMediaCalls(): [unknown, RequestInit | undefined][] { + return fetchSpy.mock.calls.filter(([url]) => + String(url).includes("/media") + ) as [unknown, RequestInit | undefined][]; + } + + function parseMessageBody(index: number): Record { + const [, init] = getMessageCalls()[index] ?? []; + return JSON.parse(init?.body as string) as Record; + } + + beforeEach(() => { + fetchSpy = vi + .spyOn(global, "fetch") + .mockImplementation(createMediaFetchMock() as never); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it("single PDF with markdown caption uploads then sends document", async () => { + const adapter = createTestAdapter(); + const result = await adapter.postMessage(THREAD_ID, { + markdown: "Here is the report", + files: [ + { + data: Buffer.from("pdf-content"), + filename: "report.pdf", + mimeType: "application/pdf", + }, + ], + }); + + expect(getMediaCalls()).toHaveLength(1); + expect(getMessageCalls()).toHaveLength(1); + + const sent = parseMessageBody(0); + expect(sent.type).toBe("document"); + expect((sent.document as { id: string }).id).toBe("media-1"); + expect((sent.document as { caption: string }).caption).toBe( + "Here is the report" + ); + expect((sent.document as { filename: string }).filename).toBe("report.pdf"); + expect(result.id).toBe("wamid.msg1"); + }); + + it("single JPEG maps to image message type", async () => { + const adapter = createTestAdapter(); + await adapter.postMessage(THREAD_ID, { + markdown: "Photo", + files: [ + { + data: Buffer.from("jpeg"), + filename: "photo.jpg", + mimeType: "image/jpeg", + }, + ], + }); + + const sent = parseMessageBody(0); + expect(sent.type).toBe("image"); + expect((sent.image as { id: string }).id).toBe("media-1"); + }); + + it("audio with text sends leading text message without audio caption", async () => { + const adapter = createTestAdapter(); + await adapter.postMessage(THREAD_ID, { + markdown: "Listen to this", + files: [ + { + data: Buffer.from("audio"), + filename: "clip.mp3", + mimeType: "audio/mpeg", + }, + ], + }); + + expect(getMessageCalls()).toHaveLength(2); + + const textMessage = parseMessageBody(0); + const audioMessage = parseMessageBody(1); + + expect(textMessage.type).toBe("text"); + expect((textMessage.text as { body: string }).body).toBe("Listen to this"); + expect(audioMessage.type).toBe("audio"); + expect( + (audioMessage.audio as { caption?: string }).caption + ).toBeUndefined(); + }); + + it("long text with image sends text first then image without caption", async () => { + const adapter = createTestAdapter(); + const longText = "a".repeat(1025); + + await adapter.postMessage(THREAD_ID, { + markdown: longText, + files: [ + { + data: Buffer.from("jpeg"), + filename: "photo.jpg", + mimeType: "image/jpeg", + }, + ], + }); + + expect(getMessageCalls()).toHaveLength(2); + + const textMessage = parseMessageBody(0); + const imageMessage = parseMessageBody(1); + + expect(textMessage.type).toBe("text"); + expect(imageMessage.type).toBe("image"); + expect( + (imageMessage.image as { caption?: string }).caption + ).toBeUndefined(); + }); + + it("multiple files send sequentially with caption only on first", async () => { + const adapter = createTestAdapter(); + const result = await adapter.postMessage(THREAD_ID, { + markdown: "Two files", + files: [ + { + data: Buffer.from("a"), + filename: "first.pdf", + mimeType: "application/pdf", + }, + { + data: Buffer.from("b"), + filename: "second.pdf", + mimeType: "application/pdf", + }, + ], + }); + + expect(getMediaCalls()).toHaveLength(2); + expect(getMessageCalls()).toHaveLength(2); + + const first = parseMessageBody(0); + const second = parseMessageBody(1); + + expect((first.document as { caption: string }).caption).toBe("Two files"); + expect((second.document as { caption?: string }).caption).toBeUndefined(); + expect(result.id).toBe("wamid.msg2"); + }); + + it("attachment with HTTPS url uses link passthrough without upload", async () => { + const adapter = createTestAdapter(); + + await adapter.postMessage(THREAD_ID, { + markdown: "Remote doc", + attachments: [ + { + type: "file", + url: "https://example.com/report.pdf", + mimeType: "application/pdf", + }, + ], + }); + + expect(getMediaCalls()).toHaveLength(0); + expect(getMessageCalls()).toHaveLength(1); + + const sent = parseMessageBody(0); + expect((sent.document as { link: string }).link).toBe( + "https://example.com/report.pdf" + ); + expect((sent.document as { id?: string }).id).toBeUndefined(); + }); + + it("attachment with fetchData uploads binary", async () => { + const adapter = createTestAdapter(); + const fetchData = vi.fn().mockResolvedValue(Buffer.from("png-bytes")); + + await adapter.postMessage(THREAD_ID, { + markdown: "", + attachments: [ + { + type: "image", + mimeType: "image/png", + fetchData, + }, + ], + }); + + expect(fetchData).toHaveBeenCalledOnce(); + expect(getMediaCalls()).toHaveLength(1); + + const sent = parseMessageBody(0); + expect(sent.type).toBe("image"); + expect((sent.image as { id: string }).id).toBe("media-1"); + }); + + it("card with files sends media then interactive message", async () => { + const adapter = createTestAdapter(); + const card: CardElement = { + type: "card", + title: "Approve?", + children: [ + { + type: "actions", + children: [ + { type: "button", id: "yes", label: "Yes" }, + { type: "button", id: "no", label: "No" }, + ], + }, + ], + }; + + await adapter.postMessage(THREAD_ID, { + card, + files: [ + { + data: Buffer.from("png"), + filename: "proof.png", + mimeType: "image/png", + }, + ], + }); + + expect(getMediaCalls()).toHaveLength(1); + expect(getMessageCalls()).toHaveLength(2); + + const mediaMessage = parseMessageBody(0); + const interactiveMessage = parseMessageBody(1); + + expect(mediaMessage.type).toBe("image"); + expect((mediaMessage.image as { caption: string }).caption).toContain( + "Approve" + ); + expect(interactiveMessage.type).toBe("interactive"); + }); + + it("oversize image throws ValidationError before upload", async () => { + const adapter = createTestAdapter(); + const oversized = Buffer.alloc(6 * 1024 * 1024); + + await expect( + adapter.postMessage(THREAD_ID, { + markdown: "", + files: [ + { + data: oversized, + filename: "huge.png", + mimeType: "image/png", + }, + ], + }) + ).rejects.toThrow(WHATSAPP_IMAGE_SIZE_LIMIT_PATTERN); + + expect(getMediaCalls()).toHaveLength(0); + expect(getMessageCalls()).toHaveLength(0); + }); +}); + +describe("getWhatsAppMediaType", () => { + it.each([ + ["image/png", "image"], + ["image/jpeg", "image"], + ["image/gif", "document"], + ["video/mp4", "video"], + ["video/3gpp", "video"], + ["audio/mpeg", "audio"], + ["application/pdf", "document"], + ] as const)("maps %s to %s", (mimeType, expected) => { + expect(getWhatsAppMediaType(mimeType)).toBe(expected); + }); +}); + // --------------------------------------------------------------------------- // editMessage // --------------------------------------------------------------------------- diff --git a/packages/adapter-whatsapp/src/index.ts b/packages/adapter-whatsapp/src/index.ts index 745007c6..be88e141 100644 --- a/packages/adapter-whatsapp/src/index.ts +++ b/packages/adapter-whatsapp/src/index.ts @@ -1,5 +1,12 @@ import { createHmac, timingSafeEqual } from "node:crypto"; -import { extractCard, ValidationError } from "@chat-adapter/shared"; +import { + cardToFallbackText, + extractCard, + extractFiles, + extractPostableAttachments, + toBuffer, + ValidationError, +} from "@chat-adapter/shared"; import type { Adapter, AdapterPostableMessage, @@ -9,6 +16,7 @@ import type { EmojiValue, FetchOptions, FetchResult, + FileUpload, FormattedContent, Logger, RawMessage, @@ -33,6 +41,7 @@ import type { WhatsAppInboundMessage, WhatsAppInteractiveMessage, WhatsAppMediaResponse, + WhatsAppMediaUploadResponse, WhatsAppRawMessage, WhatsAppSendResponse, WhatsAppThreadId, @@ -45,6 +54,108 @@ const DEFAULT_API_VERSION = "v21.0"; /** Maximum message length for WhatsApp Cloud API */ const WHATSAPP_MESSAGE_LIMIT = 4096; +/** Maximum caption length for WhatsApp media messages */ +const WHATSAPP_CAPTION_LIMIT = 1024; + +/** WhatsApp media message types supported for outbound sends */ +export type WhatsAppMediaType = "image" | "document" | "video" | "audio"; + +/** Per-type upload size limits (bytes) from WhatsApp Cloud API */ +const WHATSAPP_MEDIA_SIZE_LIMITS: Record = { + image: 5 * 1024 * 1024, + audio: 16 * 1024 * 1024, + video: 16 * 1024 * 1024, + document: 100 * 1024 * 1024, +}; + +interface ResolvedWhatsAppMedia { + captionEligible: boolean; + filename?: string; + mimeType: string; + payload: { id?: string; link?: string }; + type: WhatsAppMediaType; +} + +const EXTENSION_MIME_TYPES: Record = { + ".gif": "image/gif", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".mp3": "audio/mpeg", + ".mp4": "video/mp4", + ".ogg": "audio/ogg", + ".pdf": "application/pdf", + ".png": "image/png", + ".webp": "image/webp", +}; + +/** + * Map a MIME type to a WhatsApp outbound media message type. + */ +export function getWhatsAppMediaType(mimeType: string): WhatsAppMediaType { + const normalized = mimeType.toLowerCase().split(";")[0]?.trim() ?? ""; + + if (normalized === "image/jpeg" || normalized === "image/png") { + return "image"; + } + + if (normalized.startsWith("image/")) { + return "document"; + } + + if (normalized === "video/mp4" || normalized === "video/3gpp") { + return "video"; + } + + if (normalized.startsWith("audio/")) { + return "audio"; + } + + return "document"; +} + +/** + * Validate binary size against WhatsApp per-type limits. + */ +export function validateFileSize(type: WhatsAppMediaType, size: number): void { + const limit = WHATSAPP_MEDIA_SIZE_LIMITS[type]; + + if (size > limit) { + throw new ValidationError( + "whatsapp", + `File size ${size} bytes exceeds WhatsApp ${type} limit of ${limit} bytes` + ); + } +} + +function inferMimeType(filename: string, mimeType?: string): string { + if (mimeType) { + return mimeType; + } + + const extension = filename.includes(".") + ? filename.slice(filename.lastIndexOf(".")).toLowerCase() + : ""; + + return EXTENSION_MIME_TYPES[extension] ?? "application/octet-stream"; +} + +function attachmentToWhatsAppType(attachment: Attachment): WhatsAppMediaType { + if (attachment.mimeType) { + return getWhatsAppMediaType(attachment.mimeType); + } + + switch (attachment.type) { + case "image": + return "image"; + case "video": + return "video"; + case "audio": + return "audio"; + default: + return "document"; + } +} + /** * Split text into chunks that fit within WhatsApp's message limit, * breaking on paragraph boundaries (\n\n) when possible, then line @@ -740,21 +851,31 @@ export class WhatsAppAdapter message: AdapterPostableMessage ): Promise> { const { userWaId } = this.decodeThreadId(threadId); + const files = extractFiles(message); + const attachments = extractPostableAttachments(message); + const mediaItems: Array = [ + ...files, + ...attachments, + ]; + + if (mediaItems.length > 0) { + return this.postMessageWithMedia(threadId, userWaId, message, mediaItems); + } - // Check if this is a card with interactive buttons const card = extractCard(message); if (card) { const result = cardToWhatsApp(card); if (result.type === "interactive") { - // Convert emoji placeholders in interactive message fields const interactive = JSON.parse( convertEmojiPlaceholders( JSON.stringify(result.interactive), "whatsapp" ) - ); + ) as WhatsAppInteractiveMessage; + return this.sendInteractiveMessage(threadId, userWaId, interactive); } + return this.sendTextMessage( threadId, userWaId, @@ -762,14 +883,95 @@ export class WhatsAppAdapter ); } - // Regular text message const body = convertEmojiPlaceholders( this.formatConverter.renderPostable(message), "whatsapp" ); + return this.sendTextMessage(threadId, userWaId, body); } + /** + * Send one or more media messages, optionally followed by a card. + */ + protected async postMessageWithMedia( + threadId: string, + userWaId: string, + message: AdapterPostableMessage, + mediaItems: Array + ): Promise> { + const card = extractCard(message); + const text = card + ? convertEmojiPlaceholders(cardToFallbackText(card), "whatsapp") + : convertEmojiPlaceholders(this.renderPostableText(message), "whatsapp"); + + const resolved = await Promise.all( + mediaItems.map((item) => this.resolveMedia(item)) + ); + + const firstMedia = resolved[0]; + const useSeparateText = + text.length > 0 && + (text.length > WHATSAPP_CAPTION_LIMIT || + firstMedia?.type === "audio" || + !firstMedia?.captionEligible); + + if (useSeparateText) { + await this.sendTextMessage(threadId, userWaId, text); + } + + let result: RawMessage | undefined; + + for (const [index, media] of resolved.entries()) { + const caption = + index === 0 && + !useSeparateText && + text.length > 0 && + media.captionEligible + ? text + : undefined; + + result = await this.sendMediaMessage( + threadId, + userWaId, + media.type, + media.payload, + caption, + media.filename + ); + } + + if (card) { + const cardResult = cardToWhatsApp(card); + if (cardResult.type === "interactive") { + const interactive = JSON.parse( + convertEmojiPlaceholders( + JSON.stringify(cardResult.interactive), + "whatsapp" + ) + ) as WhatsAppInteractiveMessage; + + result = await this.sendInteractiveMessage( + threadId, + userWaId, + interactive + ); + } else if (!(useSeparateText && text.length > 0)) { + result = await this.sendTextMessage( + threadId, + userWaId, + convertEmojiPlaceholders(cardResult.text, "whatsapp") + ); + } + } + + if (!result) { + throw new Error("WhatsApp media message did not return a result"); + } + + return result; + } + /** * Split text into chunks that fit within WhatsApp's message limit, * breaking on paragraph boundaries (\n\n) when possible, then line @@ -1147,6 +1349,238 @@ export class WhatsAppAdapter // Private helpers // ============================================================================= + /** + * Render optional text from a postable message (empty for files-only payloads). + */ + protected renderPostableText(message: AdapterPostableMessage): string { + if (typeof message === "string") { + return message; + } + + if (typeof message !== "object" || message === null) { + return ""; + } + + if ("markdown" in message || "raw" in message || "ast" in message) { + return this.formatConverter.renderPostable(message); + } + + return ""; + } + + /** + * Upload binary media to the Cloud API and return a media ID. + * + * @see https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media#upload-media + */ + protected async uploadMedia(file: { + data: Buffer; + filename: string; + mimeType: string; + }): Promise { + const formData = new FormData(); + formData.append("messaging_product", "whatsapp"); + formData.append( + "file", + new Blob([new Uint8Array(file.data)], { type: file.mimeType }), + file.filename + ); + + const response = await this.graphApiUpload( + `/${this.phoneNumberId}/media`, + formData + ); + + if (!response.id) { + throw new Error("WhatsApp API did not return a media ID for upload"); + } + + return response.id; + } + + /** + * Send a media message (image, document, video, or audio). + */ + protected async sendMediaMessage( + threadId: string, + to: string, + type: WhatsAppMediaType, + payload: { id?: string; link?: string }, + caption?: string, + filename?: string + ): Promise> { + const mediaObject: Record = {}; + + if (payload.id) { + mediaObject.id = payload.id; + } + + if (payload.link) { + mediaObject.link = payload.link; + } + + if (caption && type !== "audio") { + mediaObject.caption = caption; + } + + if (filename && type === "document") { + mediaObject.filename = filename; + } + + const response = await this.graphApiRequest( + `/${this.phoneNumberId}/messages`, + { + messaging_product: "whatsapp", + recipient_type: "individual", + to, + type, + [type]: mediaObject, + } + ); + + if (!(response.messages?.length && response.messages[0]?.id)) { + throw new Error( + `WhatsApp API did not return a message ID for ${type} message` + ); + } + + const messageId = response.messages[0].id; + + return { + id: messageId, + threadId, + raw: { + message: { + id: messageId, + from: this.phoneNumberId, + timestamp: String(Math.floor(Date.now() / 1000)), + type, + }, + phoneNumberId: this.phoneNumberId, + }, + }; + } + + /** + * Normalize a FileUpload or Attachment into a WhatsApp media payload. + */ + protected async resolveMedia( + item: FileUpload | Attachment + ): Promise { + if ("filename" in item) { + const mimeType = inferMimeType(item.filename, item.mimeType); + const type = getWhatsAppMediaType(mimeType); + const buffer = await toBuffer(item.data, { platform: "whatsapp" }); + + if (!buffer) { + throw new ValidationError("whatsapp", "File upload data is empty"); + } + + validateFileSize(type, buffer.length); + + const mediaId = await this.uploadMedia({ + data: buffer, + filename: item.filename, + mimeType, + }); + + return { + captionEligible: type !== "audio", + filename: item.filename, + mimeType, + payload: { id: mediaId }, + type, + }; + } + + const type = attachmentToWhatsAppType(item); + const filename = item.name ?? "attachment"; + const mimeType = inferMimeType(filename, item.mimeType); + + const data = + item.data ?? (item.fetchData ? await item.fetchData() : undefined); + + if (data) { + const buffer = await toBuffer(data, { platform: "whatsapp" }); + + if (!buffer) { + throw new ValidationError("whatsapp", "Attachment data is empty"); + } + + validateFileSize(type, buffer.length); + + const mediaId = await this.uploadMedia({ + data: buffer, + filename, + mimeType, + }); + + return { + captionEligible: type !== "audio", + filename, + mimeType, + payload: { id: mediaId }, + type, + }; + } + + if (!item.url) { + throw new ValidationError( + "whatsapp", + "Attachment requires data, fetchData, or a public HTTPS url" + ); + } + + if (!item.url.startsWith("https://")) { + throw new ValidationError( + "whatsapp", + "Attachment URL must use HTTPS for WhatsApp link passthrough" + ); + } + + if (typeof item.size === "number") { + validateFileSize(type, item.size); + } + + return { + captionEligible: type !== "audio", + filename, + mimeType, + payload: { link: item.url }, + type, + }; + } + + /** + * Make a multipart upload request to the Meta Graph API. + */ + protected async graphApiUpload( + path: string, + formData: FormData + ): Promise { + const response = await fetch(`${this.graphApiUrl}${path}`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.accessToken}`, + }, + body: formData, + }); + + if (!response.ok) { + const errorBody = await response.text(); + this.logger.error("WhatsApp API upload error", { + status: response.status, + body: errorBody, + path, + }); + throw new Error( + `WhatsApp API upload error: ${response.status} ${errorBody}` + ); + } + + return response.json() as Promise; + } + /** * Make a request to the Meta Graph API. */ diff --git a/packages/adapter-whatsapp/src/types.ts b/packages/adapter-whatsapp/src/types.ts index 66d2957d..c6413b66 100644 --- a/packages/adapter-whatsapp/src/types.ts +++ b/packages/adapter-whatsapp/src/types.ts @@ -235,6 +235,15 @@ export interface WhatsAppMediaResponse { url: string; } +/** + * Response from uploading media via the Cloud API. + * + * @see https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media#upload-media + */ +export interface WhatsAppMediaUploadResponse { + id: string; +} + /** * Message delivery/read status update. */ From 0a91b0de80644556c3664357cdde993e34660d5a Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 19 May 2026 19:02:01 +0300 Subject: [PATCH 2/3] whatsapp: add outbound file support and docs Implement outbound file and attachment sending for the WhatsApp adapter and update docs. Changes include: adding WhatsApp file/attachment handling in the adapter (binary uploads, sequential multi-file sends, caption rules, card+file sequencing), introducing a typed WHATSAPP_BUFFER_PLATFORM constant to pass to toBuffer, and minor code flow/comments around interactive cards and text messages. Documentation updated with a WhatsApp file uploads section and removed an earlier WhatsApp note from the shared files guide. Also remove WhatsApp from the shared PlatformName/button-style mapping so the adapter asserts the platform label where needed. --- .changeset/whatsapp-outbound-files.md | 3 +-- .../content/adapters/official/whatsapp.mdx | 24 +++++++++++++++++++ apps/docs/content/docs/files.mdx | 2 -- packages/adapter-shared/src/card-utils.ts | 3 +-- packages/adapter-whatsapp/src/index.ts | 15 ++++++++++-- 5 files changed, 39 insertions(+), 8 deletions(-) diff --git a/.changeset/whatsapp-outbound-files.md b/.changeset/whatsapp-outbound-files.md index 8b2e9f78..6e68e7a7 100644 --- a/.changeset/whatsapp-outbound-files.md +++ b/.changeset/whatsapp-outbound-files.md @@ -1,8 +1,7 @@ --- "@chat-adapter/whatsapp": minor -"@chat-adapter/shared": patch --- Implement outbound file and attachment sending for the WhatsApp adapter. -Supports binary `FileUpload` uploads, typed `Attachment` payloads (binary or HTTPS link passthrough), multi-file sequential sends, smart MIME-to-message-type mapping, caption placement with audio/long-text fallbacks, and card+file sequencing. Adds `whatsapp` to shared `PlatformName` for buffer utilities. +Supports binary `FileUpload` uploads, typed `Attachment` payloads (binary or HTTPS link passthrough), multi-file sequential sends, smart MIME-to-message-type mapping, caption placement with audio/long-text fallbacks, and card+file sequencing. diff --git a/apps/docs/content/adapters/official/whatsapp.mdx b/apps/docs/content/adapters/official/whatsapp.mdx index d479352f..0cad50a8 100644 --- a/apps/docs/content/adapters/official/whatsapp.mdx +++ b/apps/docs/content/adapters/official/whatsapp.mdx @@ -181,6 +181,30 @@ Example: `whatsapp:1234567890:15551234567`. Outgoing messages longer than 4096 characters are automatically chunked. +### File uploads + +`postMessage` accepts both `files` and `attachments` (typed media with optional `data`, `fetchData`, or a public URL). See the [file uploads guide](/docs/files) for the shared API. + +WhatsApp-specific behavior: + +- **One media per message** — multiple `files` or `attachments` in a single `post()` are sent as sequential messages (the last message ID is returned). +- **Captions** — markdown (or card fallback text) is attached as a caption on the first media message when supported (max 1024 characters). Text is sent as a separate message first when the caption is too long or when the first media is audio (audio does not support captions). +- **Binary vs link** — buffers are uploaded via the Cloud API `/media` endpoint; `attachments` with only an `url` use HTTPS link passthrough (no upload). URLs must use `https://`. +- **Cards + files** — media is sent first (caption from card fallback text), then an interactive button message when the card has valid reply buttons. To send a photo with buttons, pass the image via `files` or `attachments` — card-embedded images (`imageUrl` or `` children) are not sent as native media. + +```typescript title="lib/bot.ts" lineNumbers +await thread.post({ + markdown: "Here's the report:", + files: [ + { + data: reportBuffer, + filename: "report.pdf", + mimeType: "application/pdf", + }, + ], +}); +``` + ## Feature support diff --git a/apps/docs/content/docs/files.mdx b/apps/docs/content/docs/files.mdx index 96591294..257ae1fe 100644 --- a/apps/docs/content/docs/files.mdx +++ b/apps/docs/content/docs/files.mdx @@ -45,8 +45,6 @@ await thread.post({ Outgoing `attachments` are available on `{ raw }`, `{ markdown }`, and `{ ast }` messages. Card messages use `files` for uploads. Use `files` for generic uploads. On Telegram, `files` always upload as documents, while `attachments` preserve image, audio, video, or file media type. Use `data` or `fetchData` for private/authenticated files; URL-only attachments must be public URLs Telegram can fetch directly. -On WhatsApp, each file or attachment is sent as its own message (one media object per API call). Markdown text becomes a caption on the first media message when supported; audio messages and captions over 1024 characters send the text as a separate message first. URL-only attachments must use HTTPS. - ### Multiple files ```typescript title="lib/bot.ts" lineNumbers diff --git a/packages/adapter-shared/src/card-utils.ts b/packages/adapter-shared/src/card-utils.ts index 1b084b95..9d0fd9eb 100644 --- a/packages/adapter-shared/src/card-utils.ts +++ b/packages/adapter-shared/src/card-utils.ts @@ -15,7 +15,7 @@ import { /** * Supported platform names for adapter utilities. */ -export type PlatformName = "slack" | "gchat" | "teams" | "discord" | "whatsapp"; +export type PlatformName = "slack" | "gchat" | "teams" | "discord"; /** * Button style mappings per platform. @@ -31,7 +31,6 @@ export const BUTTON_STYLE_MAPPINGS: Record< gchat: { primary: "primary", danger: "danger" }, // Colors handled via buttonColor teams: { primary: "positive", danger: "destructive" }, discord: { primary: "primary", danger: "danger" }, - whatsapp: { primary: "primary", danger: "danger" }, }; /** diff --git a/packages/adapter-whatsapp/src/index.ts b/packages/adapter-whatsapp/src/index.ts index be88e141..b03af6b4 100644 --- a/packages/adapter-whatsapp/src/index.ts +++ b/packages/adapter-whatsapp/src/index.ts @@ -4,6 +4,7 @@ import { extractCard, extractFiles, extractPostableAttachments, + type PlatformName, toBuffer, ValidationError, } from "@chat-adapter/shared"; @@ -48,6 +49,9 @@ import type { WhatsAppWebhookPayload, } from "./types"; +/** Platform label for shared buffer utilities (not yet in PlatformName union). */ +const WHATSAPP_BUFFER_PLATFORM = "whatsapp" as PlatformName; + /** Default Graph API version */ const DEFAULT_API_VERSION = "v21.0"; @@ -862,10 +866,12 @@ export class WhatsAppAdapter return this.postMessageWithMedia(threadId, userWaId, message, mediaItems); } + // Check if this is a card with interactive buttons const card = extractCard(message); if (card) { const result = cardToWhatsApp(card); if (result.type === "interactive") { + // Convert emoji placeholders in interactive message fields const interactive = JSON.parse( convertEmojiPlaceholders( JSON.stringify(result.interactive), @@ -883,6 +889,7 @@ export class WhatsAppAdapter ); } + // Regular text message const body = convertEmojiPlaceholders( this.formatConverter.renderPostable(message), "whatsapp" @@ -1470,7 +1477,9 @@ export class WhatsAppAdapter if ("filename" in item) { const mimeType = inferMimeType(item.filename, item.mimeType); const type = getWhatsAppMediaType(mimeType); - const buffer = await toBuffer(item.data, { platform: "whatsapp" }); + const buffer = await toBuffer(item.data, { + platform: WHATSAPP_BUFFER_PLATFORM, + }); if (!buffer) { throw new ValidationError("whatsapp", "File upload data is empty"); @@ -1501,7 +1510,9 @@ export class WhatsAppAdapter item.data ?? (item.fetchData ? await item.fetchData() : undefined); if (data) { - const buffer = await toBuffer(data, { platform: "whatsapp" }); + const buffer = await toBuffer(data, { + platform: WHATSAPP_BUFFER_PLATFORM, + }); if (!buffer) { throw new ValidationError("whatsapp", "Attachment data is empty"); From 04d9848b1b2ce637f2e4cf02b68cddea33fad595 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 19 May 2026 19:04:18 +0300 Subject: [PATCH 3/3] Prevent duplicate text for card+file uploads Fix logic that caused duplicate text to be sent when posting a card with an attached file by changing the conditional to only skip sending text when the text is empty. Add a unit test that posts a card with a file and verifies only one media upload and one message are sent and that the media caption contains the card title. This ensures the adapter does not send duplicate textual fallbacks alongside media. --- packages/adapter-whatsapp/src/index.test.ts | 40 +++++++++++++++++++++ packages/adapter-whatsapp/src/index.ts | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/adapter-whatsapp/src/index.test.ts b/packages/adapter-whatsapp/src/index.test.ts index 67b8049c..369febab 100644 --- a/packages/adapter-whatsapp/src/index.test.ts +++ b/packages/adapter-whatsapp/src/index.test.ts @@ -1150,6 +1150,46 @@ describe("postMessage - file uploads", () => { expect(interactiveMessage.type).toBe("interactive"); }); + it("card with text fallback and file does not send duplicate text", async () => { + const adapter = createTestAdapter(); + const card: CardElement = { + type: "card", + title: "Order update", + children: [ + { + type: "actions", + children: [ + { + type: "link-button", + url: "https://example.com/track", + label: "Track", + }, + ], + }, + ], + }; + + await adapter.postMessage(THREAD_ID, { + card, + files: [ + { + data: Buffer.from("png"), + filename: "receipt.png", + mimeType: "image/png", + }, + ], + }); + + expect(getMediaCalls()).toHaveLength(1); + expect(getMessageCalls()).toHaveLength(1); + + const mediaMessage = parseMessageBody(0); + expect(mediaMessage.type).toBe("image"); + expect((mediaMessage.image as { caption: string }).caption).toContain( + "Order update" + ); + }); + it("oversize image throws ValidationError before upload", async () => { const adapter = createTestAdapter(); const oversized = Buffer.alloc(6 * 1024 * 1024); diff --git a/packages/adapter-whatsapp/src/index.ts b/packages/adapter-whatsapp/src/index.ts index b03af6b4..310e6587 100644 --- a/packages/adapter-whatsapp/src/index.ts +++ b/packages/adapter-whatsapp/src/index.ts @@ -963,7 +963,7 @@ export class WhatsAppAdapter userWaId, interactive ); - } else if (!(useSeparateText && text.length > 0)) { + } else if (text.length === 0) { result = await this.sendTextMessage( threadId, userWaId,