mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Foundation for the upcoming WhatsApp chatbot flow builder. No runner,
no UI yet — just the plumbing that lets the codebase SEND interactive
messages (button replies + lists) and RECEIVE the taps as structured
message rows. Plan: ~/.claude/plans/understood-research-such-functionality-binary-lake.md
## What's in this PR
### Migration 010_flows.sql
- New tables: flows, flow_nodes, flow_runs, flow_run_events
- Linchpin partial unique index on flow_runs(user_id, contact_id) WHERE status='active' — at most one active run per contact, makes concurrent webhook deliveries idempotent at the DB layer
- Widens messages.content_type CHECK to allow 'interactive'
- New messages.interactive_reply_id column for the tapped button/row id
- RLS on every table; flow_runs added to supabase_realtime publication
- Idempotent throughout — matches the 009_message_actions.sql template
### Interactive Meta senders (src/lib/whatsapp/meta-api.ts)
- sendInteractiveButtons: type='interactive', interactive.type='button', up to 3 reply buttons
- sendInteractiveList: type='interactive', interactive.type='list', up to 10 rows across sections
- Limits exported as INTERACTIVE_LIMITS so flow validation can share the constants
- All Meta caps validated BEFORE the network call (button title ≤20 chars, list row title ≤24 chars, ≤10 total rows, no duplicate row ids, body ≤1024 chars, etc.) — misconfigured flows fail at save time instead of as a customer-facing 400 mid-conversation
### Webhook (src/app/api/whatsapp/webhook/route.ts)
- Extends WhatsAppMessage interface with `interactive?: { type, button_reply?, list_reply? }`
- parseMessageContent returns a new `interactiveReplyId` field; refactored to use a shared `empty` base so every return site picks up the new field without 9 explicit copies
- New `case 'interactive'`: uses the tapped option's TITLE as content_text (so the inbox bubble reads "Existing customer", not a UUID) and stashes the id separately
- 'interactive' added to ALLOWED_CONTENT_TYPES so the messages insert no longer coerces it to 'text'
### Inbox bubble (src/components/inbox/message-bubble.tsx)
- New `case "interactive"` in MessageContent renders the tap with a small "↩ Button reply" affordance so agents reading the inbox can tell at a glance that this was a tap rather than the customer typing the same words
### Types (src/types/index.ts)
- ContentType union extended with 'interactive'
- Message.interactive_reply_id field
### Engine helpers (src/lib/flows/)
- admin-client.ts: thin service-role client wrapper, mirrors automations/admin-client.ts
- meta-send.ts: engineSendInteractiveButtons / engineSendInteractiveList — adapts the Meta senders for service-role use, persists messages with sender_type='bot' content_type='interactive', uses the same phone-variant retry as automations/meta-send.ts. Unused in PR #1; ready for the runner in PR #2
### Tests
- 13 new vitest cases in meta-api.test.ts covering Meta's limit validation + payload-shape assertions for both interactive senders
## Validation
- npm test → 102/102 (was 89; 13 new)
- npm run lint → 0 errors
- npm run typecheck → clean
- npm run build → clean
## What's NOT in this PR
- The runner (dispatchInboundToFlows) — PR #2
- Builder UI / API surface — PR #3
- collect_input / condition / set_tag nodes + templates — PR #4
- react-flow canvas + http_fetch — v2 (PR #5)
## Risks tracked from the plan
- messages.content_type CHECK widening: grep'd every consumer; message-bubble.tsx, reply-quote.tsx, send/route.ts, automations/meta-send.ts all handle the new variant correctly (or accept it as a defaulted fallthrough)
- engineSendText / engineSendInteractive share ~80% of code; deliberately not pre-factored yet — let both stabilize, extract a shared base in PR #2 once the shape settles
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
560 lines
17 KiB
TypeScript
560 lines
17 KiB
TypeScript
/**
|
||
* Meta WhatsApp Cloud API helpers.
|
||
*
|
||
* Every function takes a single options object (named parameters) instead
|
||
* of positional arguments. This was a deliberate choice after the same
|
||
* swapped-args bug was found four times in a row with the positional form
|
||
* (e.g. `(accessToken, phoneNumberId)` vs `(phoneNumberId, accessToken)`).
|
||
* With named params, a typo surfaces immediately as a TypeScript error
|
||
* instead of a runtime rejection from Meta.
|
||
*/
|
||
|
||
const META_API_VERSION = 'v21.0'
|
||
const META_API_BASE = `https://graph.facebook.com/${META_API_VERSION}`
|
||
|
||
export interface MetaSendResult {
|
||
messageId: string
|
||
}
|
||
|
||
export interface MetaPhoneInfo {
|
||
id: string
|
||
display_phone_number: string
|
||
verified_name?: string
|
||
quality_rating?: string
|
||
}
|
||
|
||
interface MetaErrorResponse {
|
||
error?: { message?: string; code?: number; type?: string }
|
||
}
|
||
|
||
async function throwMetaError(response: Response, fallback: string): Promise<never> {
|
||
let message = fallback
|
||
try {
|
||
const data = (await response.json()) as MetaErrorResponse
|
||
if (data.error?.message) message = data.error.message
|
||
} catch {
|
||
// response body wasn't JSON — keep the fallback
|
||
}
|
||
throw new Error(message)
|
||
}
|
||
|
||
// ============================================================
|
||
// Phone number / account
|
||
// ============================================================
|
||
|
||
export interface VerifyPhoneNumberArgs {
|
||
phoneNumberId: string
|
||
accessToken: string
|
||
}
|
||
|
||
/**
|
||
* Verify a Meta phone number ID by fetching its public metadata
|
||
* (display_phone_number, verified_name, quality_rating).
|
||
*/
|
||
export async function verifyPhoneNumber(
|
||
args: VerifyPhoneNumberArgs
|
||
): Promise<MetaPhoneInfo> {
|
||
const { phoneNumberId, accessToken } = args
|
||
const url = `${META_API_BASE}/${phoneNumberId}?fields=id,display_phone_number,verified_name,quality_rating`
|
||
const response = await fetch(url, {
|
||
headers: { Authorization: `Bearer ${accessToken}` },
|
||
})
|
||
if (!response.ok) {
|
||
await throwMetaError(response, `Meta API error: ${response.status}`)
|
||
}
|
||
return response.json()
|
||
}
|
||
|
||
// ============================================================
|
||
// Sending
|
||
// ============================================================
|
||
|
||
export interface SendTextMessageArgs {
|
||
phoneNumberId: string
|
||
accessToken: string
|
||
to: string
|
||
text: string
|
||
/** Meta's message_id of the message being replied to. Adds a `context` field
|
||
* so WhatsApp renders the new message as a reply with a quote preview. */
|
||
contextMessageId?: string
|
||
}
|
||
|
||
/**
|
||
* Send a free-form WhatsApp text message.
|
||
* Only works inside the 24-hour customer service window.
|
||
*/
|
||
export async function sendTextMessage(
|
||
args: SendTextMessageArgs
|
||
): Promise<MetaSendResult> {
|
||
const { phoneNumberId, accessToken, to, text, contextMessageId } = args
|
||
const url = `${META_API_BASE}/${phoneNumberId}/messages`
|
||
const body: Record<string, unknown> = {
|
||
messaging_product: 'whatsapp',
|
||
recipient_type: 'individual',
|
||
to,
|
||
type: 'text',
|
||
text: { body: text },
|
||
}
|
||
if (contextMessageId) {
|
||
body.context = { message_id: contextMessageId }
|
||
}
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${accessToken}`,
|
||
},
|
||
body: JSON.stringify(body),
|
||
})
|
||
if (!response.ok) {
|
||
await throwMetaError(response, `Meta API error: ${response.status}`)
|
||
}
|
||
const data = await response.json()
|
||
return { messageId: data.messages[0].id }
|
||
}
|
||
|
||
export interface SendTemplateMessageArgs {
|
||
phoneNumberId: string
|
||
accessToken: string
|
||
to: string
|
||
templateName: string
|
||
language?: string
|
||
params?: string[]
|
||
/** Meta's message_id of the message being replied to. */
|
||
contextMessageId?: string
|
||
}
|
||
|
||
/**
|
||
* Send a pre-approved WhatsApp message template. Required outside
|
||
* the 24-hour window and for any first-touch messaging.
|
||
*/
|
||
export async function sendTemplateMessage(
|
||
args: SendTemplateMessageArgs
|
||
): Promise<MetaSendResult> {
|
||
const {
|
||
phoneNumberId,
|
||
accessToken,
|
||
to,
|
||
templateName,
|
||
language = 'en_US',
|
||
params,
|
||
contextMessageId,
|
||
} = args
|
||
const url = `${META_API_BASE}/${phoneNumberId}/messages`
|
||
|
||
const template: Record<string, unknown> = {
|
||
name: templateName,
|
||
language: { code: language },
|
||
}
|
||
|
||
if (params && params.length > 0) {
|
||
template.components = [
|
||
{
|
||
type: 'body',
|
||
parameters: params.map((p) => ({ type: 'text', text: String(p) })),
|
||
},
|
||
]
|
||
}
|
||
|
||
const body: Record<string, unknown> = {
|
||
messaging_product: 'whatsapp',
|
||
recipient_type: 'individual',
|
||
to,
|
||
type: 'template',
|
||
template,
|
||
}
|
||
if (contextMessageId) {
|
||
body.context = { message_id: contextMessageId }
|
||
}
|
||
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${accessToken}`,
|
||
},
|
||
body: JSON.stringify(body),
|
||
})
|
||
if (!response.ok) {
|
||
await throwMetaError(response, `Meta API error: ${response.status}`)
|
||
}
|
||
const data = await response.json()
|
||
return { messageId: data.messages[0].id }
|
||
}
|
||
|
||
// ============================================================
|
||
// Reactions
|
||
// ============================================================
|
||
|
||
export interface SendReactionMessageArgs {
|
||
phoneNumberId: string
|
||
accessToken: string
|
||
to: string
|
||
/** Meta's message_id of the message being reacted to. */
|
||
targetMessageId: string
|
||
/** Single emoji, or empty string to remove an existing reaction. */
|
||
emoji: string
|
||
}
|
||
|
||
/**
|
||
* Send a reaction (or removal) to a previously-exchanged message.
|
||
* Empty `emoji` removes the reaction per Meta's spec.
|
||
*/
|
||
export async function sendReactionMessage(
|
||
args: SendReactionMessageArgs
|
||
): Promise<MetaSendResult> {
|
||
const { phoneNumberId, accessToken, to, targetMessageId, emoji } = args
|
||
const url = `${META_API_BASE}/${phoneNumberId}/messages`
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${accessToken}`,
|
||
},
|
||
body: JSON.stringify({
|
||
messaging_product: 'whatsapp',
|
||
recipient_type: 'individual',
|
||
to,
|
||
type: 'reaction',
|
||
reaction: { message_id: targetMessageId, emoji },
|
||
}),
|
||
})
|
||
if (!response.ok) {
|
||
await throwMetaError(response, `Meta API error: ${response.status}`)
|
||
}
|
||
const data = await response.json()
|
||
return { messageId: data.messages[0].id }
|
||
}
|
||
|
||
// ============================================================
|
||
// Interactive (button replies + list messages)
|
||
// ============================================================
|
||
//
|
||
// Meta's two flavours of interactive message — used by the Flows
|
||
// engine to drive scripted chatbot menus. Caller passes plain
|
||
// JS values; helpers shape the Meta payload and enforce Meta's
|
||
// limits BEFORE the network call so the failure mode is a
|
||
// developer-facing error rather than a customer-facing one.
|
||
|
||
/**
|
||
* Meta limits for interactive messages, hard-coded so violations
|
||
* fail at build/save time rather than as a 400 from the Meta API
|
||
* mid-conversation. See:
|
||
* https://developers.facebook.com/docs/whatsapp/cloud-api/messages/interactive-reply-buttons-messages
|
||
* https://developers.facebook.com/docs/whatsapp/cloud-api/messages/interactive-list-messages
|
||
*/
|
||
export const INTERACTIVE_LIMITS = {
|
||
maxButtons: 3,
|
||
buttonTitleMaxLength: 20,
|
||
maxListSections: 10,
|
||
maxListRowsTotal: 10,
|
||
listRowTitleMaxLength: 24,
|
||
listRowDescriptionMaxLength: 72,
|
||
bodyMaxLength: 1024,
|
||
footerMaxLength: 60,
|
||
headerTextMaxLength: 60,
|
||
} as const
|
||
|
||
export interface InteractiveButton {
|
||
/** Stable id sent back in the webhook when tapped (≤ 256 chars). */
|
||
id: string
|
||
/** Visible label (≤ 20 chars per Meta). */
|
||
title: string
|
||
}
|
||
|
||
export interface SendInteractiveButtonsArgs {
|
||
phoneNumberId: string
|
||
accessToken: string
|
||
to: string
|
||
/** The body text — what the customer reads above the buttons. */
|
||
bodyText: string
|
||
/** Optional plain-text header (≤ 60 chars). */
|
||
headerText?: string
|
||
/** Optional grey footer line under the buttons (≤ 60 chars). */
|
||
footerText?: string
|
||
/** 1–3 buttons. Validated against Meta's limits before sending. */
|
||
buttons: InteractiveButton[]
|
||
/** Meta's message_id of the message being replied to (quote preview). */
|
||
contextMessageId?: string
|
||
}
|
||
|
||
/**
|
||
* Send an interactive message with up to 3 inline reply buttons. The
|
||
* customer taps one and Meta delivers a webhook with
|
||
* `messages[0].interactive.button_reply.id` set to the matching button.id.
|
||
*
|
||
* Validation throws BEFORE the network call so misconfigured flows
|
||
* fail at save time, not during a live conversation.
|
||
*/
|
||
export async function sendInteractiveButtons(
|
||
args: SendInteractiveButtonsArgs
|
||
): Promise<MetaSendResult> {
|
||
const {
|
||
phoneNumberId, accessToken, to,
|
||
bodyText, headerText, footerText, buttons, contextMessageId,
|
||
} = args
|
||
validateInteractiveBody(bodyText)
|
||
validateInteractiveHeaderFooter(headerText, footerText)
|
||
if (buttons.length < 1 || buttons.length > INTERACTIVE_LIMITS.maxButtons) {
|
||
throw new Error(
|
||
`Interactive button message requires 1-${INTERACTIVE_LIMITS.maxButtons} buttons (got ${buttons.length}).`
|
||
)
|
||
}
|
||
for (const btn of buttons) {
|
||
if (!btn.id) throw new Error('Interactive button missing id.')
|
||
if (!btn.title) throw new Error(`Interactive button "${btn.id}" missing title.`)
|
||
if (btn.title.length > INTERACTIVE_LIMITS.buttonTitleMaxLength) {
|
||
throw new Error(
|
||
`Interactive button title "${btn.title}" exceeds ${INTERACTIVE_LIMITS.buttonTitleMaxLength} chars.`
|
||
)
|
||
}
|
||
}
|
||
|
||
const interactive: Record<string, unknown> = {
|
||
type: 'button',
|
||
body: { text: bodyText },
|
||
action: {
|
||
buttons: buttons.map((b) => ({
|
||
type: 'reply',
|
||
reply: { id: b.id, title: b.title },
|
||
})),
|
||
},
|
||
}
|
||
if (headerText) interactive.header = { type: 'text', text: headerText }
|
||
if (footerText) interactive.footer = { text: footerText }
|
||
|
||
const body: Record<string, unknown> = {
|
||
messaging_product: 'whatsapp',
|
||
recipient_type: 'individual',
|
||
to,
|
||
type: 'interactive',
|
||
interactive,
|
||
}
|
||
if (contextMessageId) body.context = { message_id: contextMessageId }
|
||
|
||
const url = `${META_API_BASE}/${phoneNumberId}/messages`
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${accessToken}`,
|
||
},
|
||
body: JSON.stringify(body),
|
||
})
|
||
if (!response.ok) {
|
||
await throwMetaError(response, `Meta API error: ${response.status}`)
|
||
}
|
||
const data = await response.json()
|
||
return { messageId: data.messages[0].id }
|
||
}
|
||
|
||
export interface InteractiveListRow {
|
||
/** Stable id sent back in the webhook when tapped (≤ 200 chars). */
|
||
id: string
|
||
/** Visible row title (≤ 24 chars per Meta). */
|
||
title: string
|
||
/** Optional secondary line shown under the title (≤ 72 chars). */
|
||
description?: string
|
||
}
|
||
|
||
export interface InteractiveListSection {
|
||
/** Optional section header shown above its rows. */
|
||
title?: string
|
||
rows: InteractiveListRow[]
|
||
}
|
||
|
||
export interface SendInteractiveListArgs {
|
||
phoneNumberId: string
|
||
accessToken: string
|
||
to: string
|
||
bodyText: string
|
||
/** Label of the tap-to-expand button on the message bubble. */
|
||
buttonLabel: string
|
||
headerText?: string
|
||
footerText?: string
|
||
/**
|
||
* 1–10 rows TOTAL across all sections. Meta caps the *total*, not
|
||
* per-section. Validation enforces this before send.
|
||
*/
|
||
sections: InteractiveListSection[]
|
||
contextMessageId?: string
|
||
}
|
||
|
||
/**
|
||
* Send an interactive message with a tap-to-expand list of selectable
|
||
* rows. Use when there are more options than the 3-button limit allows.
|
||
* Webhook arrives with `messages[0].interactive.list_reply.id` set to
|
||
* the matching row.id.
|
||
*/
|
||
export async function sendInteractiveList(
|
||
args: SendInteractiveListArgs
|
||
): Promise<MetaSendResult> {
|
||
const {
|
||
phoneNumberId, accessToken, to,
|
||
bodyText, buttonLabel, headerText, footerText, sections, contextMessageId,
|
||
} = args
|
||
validateInteractiveBody(bodyText)
|
||
validateInteractiveHeaderFooter(headerText, footerText)
|
||
if (!buttonLabel) throw new Error('Interactive list requires a buttonLabel.')
|
||
if (buttonLabel.length > INTERACTIVE_LIMITS.buttonTitleMaxLength) {
|
||
throw new Error(
|
||
`Interactive list buttonLabel "${buttonLabel}" exceeds ${INTERACTIVE_LIMITS.buttonTitleMaxLength} chars.`
|
||
)
|
||
}
|
||
if (sections.length < 1 || sections.length > INTERACTIVE_LIMITS.maxListSections) {
|
||
throw new Error(
|
||
`Interactive list requires 1-${INTERACTIVE_LIMITS.maxListSections} sections (got ${sections.length}).`
|
||
)
|
||
}
|
||
const totalRows = sections.reduce((sum, s) => sum + s.rows.length, 0)
|
||
if (totalRows < 1 || totalRows > INTERACTIVE_LIMITS.maxListRowsTotal) {
|
||
throw new Error(
|
||
`Interactive list requires 1-${INTERACTIVE_LIMITS.maxListRowsTotal} rows total across all sections (got ${totalRows}).`
|
||
)
|
||
}
|
||
const seenIds = new Set<string>()
|
||
for (const section of sections) {
|
||
for (const row of section.rows) {
|
||
if (!row.id) throw new Error('Interactive list row missing id.')
|
||
if (seenIds.has(row.id)) {
|
||
throw new Error(`Interactive list has duplicate row id "${row.id}".`)
|
||
}
|
||
seenIds.add(row.id)
|
||
if (!row.title) throw new Error(`Interactive list row "${row.id}" missing title.`)
|
||
if (row.title.length > INTERACTIVE_LIMITS.listRowTitleMaxLength) {
|
||
throw new Error(
|
||
`Interactive list row title "${row.title}" exceeds ${INTERACTIVE_LIMITS.listRowTitleMaxLength} chars.`
|
||
)
|
||
}
|
||
if (
|
||
row.description &&
|
||
row.description.length > INTERACTIVE_LIMITS.listRowDescriptionMaxLength
|
||
) {
|
||
throw new Error(
|
||
`Interactive list row description for "${row.id}" exceeds ${INTERACTIVE_LIMITS.listRowDescriptionMaxLength} chars.`
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
const interactive: Record<string, unknown> = {
|
||
type: 'list',
|
||
body: { text: bodyText },
|
||
action: {
|
||
button: buttonLabel,
|
||
sections: sections.map((s) => ({
|
||
...(s.title ? { title: s.title } : {}),
|
||
rows: s.rows.map((r) => ({
|
||
id: r.id,
|
||
title: r.title,
|
||
...(r.description ? { description: r.description } : {}),
|
||
})),
|
||
})),
|
||
},
|
||
}
|
||
if (headerText) interactive.header = { type: 'text', text: headerText }
|
||
if (footerText) interactive.footer = { text: footerText }
|
||
|
||
const body: Record<string, unknown> = {
|
||
messaging_product: 'whatsapp',
|
||
recipient_type: 'individual',
|
||
to,
|
||
type: 'interactive',
|
||
interactive,
|
||
}
|
||
if (contextMessageId) body.context = { message_id: contextMessageId }
|
||
|
||
const url = `${META_API_BASE}/${phoneNumberId}/messages`
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${accessToken}`,
|
||
},
|
||
body: JSON.stringify(body),
|
||
})
|
||
if (!response.ok) {
|
||
await throwMetaError(response, `Meta API error: ${response.status}`)
|
||
}
|
||
const data = await response.json()
|
||
return { messageId: data.messages[0].id }
|
||
}
|
||
|
||
function validateInteractiveBody(bodyText: string): void {
|
||
if (!bodyText) throw new Error('Interactive message requires bodyText.')
|
||
if (bodyText.length > INTERACTIVE_LIMITS.bodyMaxLength) {
|
||
throw new Error(
|
||
`Interactive bodyText exceeds ${INTERACTIVE_LIMITS.bodyMaxLength} chars.`
|
||
)
|
||
}
|
||
}
|
||
|
||
function validateInteractiveHeaderFooter(
|
||
headerText: string | undefined,
|
||
footerText: string | undefined,
|
||
): void {
|
||
if (headerText && headerText.length > INTERACTIVE_LIMITS.headerTextMaxLength) {
|
||
throw new Error(
|
||
`Interactive headerText exceeds ${INTERACTIVE_LIMITS.headerTextMaxLength} chars.`
|
||
)
|
||
}
|
||
if (footerText && footerText.length > INTERACTIVE_LIMITS.footerMaxLength) {
|
||
throw new Error(
|
||
`Interactive footerText exceeds ${INTERACTIVE_LIMITS.footerMaxLength} chars.`
|
||
)
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Media
|
||
// ============================================================
|
||
|
||
export interface GetMediaUrlArgs {
|
||
mediaId: string
|
||
accessToken: string
|
||
}
|
||
|
||
/**
|
||
* Resolve a media ID to Meta's (short-lived, authenticated) CDN URL
|
||
* plus the MIME type. Step one of the media-proxy flow.
|
||
*/
|
||
export async function getMediaUrl(
|
||
args: GetMediaUrlArgs
|
||
): Promise<{ url: string; mimeType: string }> {
|
||
const { mediaId, accessToken } = args
|
||
const response = await fetch(`${META_API_BASE}/${mediaId}`, {
|
||
headers: { Authorization: `Bearer ${accessToken}` },
|
||
})
|
||
if (!response.ok) {
|
||
await throwMetaError(response, `Media fetch failed: ${response.status}`)
|
||
}
|
||
const data = await response.json()
|
||
if (!data.url) throw new Error('Media URL not found in Meta response')
|
||
return { url: data.url, mimeType: data.mime_type || 'application/octet-stream' }
|
||
}
|
||
|
||
export interface DownloadMediaArgs {
|
||
downloadUrl: string
|
||
accessToken: string
|
||
}
|
||
|
||
/**
|
||
* Fetch the binary bytes for a media URL obtained from getMediaUrl.
|
||
* Step two of the media-proxy flow.
|
||
*/
|
||
export async function downloadMedia(
|
||
args: DownloadMediaArgs
|
||
): Promise<{ buffer: Buffer; contentType: string }> {
|
||
const { downloadUrl, accessToken } = args
|
||
const response = await fetch(downloadUrl, {
|
||
headers: { Authorization: `Bearer ${accessToken}` },
|
||
})
|
||
if (!response.ok) {
|
||
throw new Error(`Media download failed: ${response.status}`)
|
||
}
|
||
const contentType =
|
||
response.headers.get('content-type') || 'application/octet-stream'
|
||
const buffer = Buffer.from(await response.arrayBuffer())
|
||
return { buffer, contentType }
|
||
}
|