diff --git a/.changeset/whatsapp-outbound-files.md b/.changeset/whatsapp-outbound-files.md new file mode 100644 index 00000000..6e68e7a7 --- /dev/null +++ b/.changeset/whatsapp-outbound-files.md @@ -0,0 +1,7 @@ +--- +"@chat-adapter/whatsapp": minor +--- + +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. 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/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..369febab 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,361 @@ 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("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); + + 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..310e6587 100644 --- a/packages/adapter-whatsapp/src/index.ts +++ b/packages/adapter-whatsapp/src/index.ts @@ -1,5 +1,13 @@ import { createHmac, timingSafeEqual } from "node:crypto"; -import { extractCard, ValidationError } from "@chat-adapter/shared"; +import { + cardToFallbackText, + extractCard, + extractFiles, + extractPostableAttachments, + type PlatformName, + toBuffer, + ValidationError, +} from "@chat-adapter/shared"; import type { Adapter, AdapterPostableMessage, @@ -9,6 +17,7 @@ import type { EmojiValue, FetchOptions, FetchResult, + FileUpload, FormattedContent, Logger, RawMessage, @@ -33,18 +42,124 @@ import type { WhatsAppInboundMessage, WhatsAppInteractiveMessage, WhatsAppMediaResponse, + WhatsAppMediaUploadResponse, WhatsAppRawMessage, WhatsAppSendResponse, WhatsAppThreadId, 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"; /** 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,6 +855,16 @@ 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); @@ -752,9 +877,11 @@ export class WhatsAppAdapter JSON.stringify(result.interactive), "whatsapp" ) - ); + ) as WhatsAppInteractiveMessage; + return this.sendInteractiveMessage(threadId, userWaId, interactive); } + return this.sendTextMessage( threadId, userWaId, @@ -767,9 +894,91 @@ export class WhatsAppAdapter 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 (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 +1356,242 @@ 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_BUFFER_PLATFORM, + }); + + 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_BUFFER_PLATFORM, + }); + + 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. */