mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Closes the second half of #147. Users can now create a template in wacrm and submit it to Meta for approval without leaving the app — no more dummy local rows that fail with Meta error #132001 on send. API surface ----------- POST /api/whatsapp/templates/submit Auth → fetch whatsapp_config → validate payload → build Meta components → POST to /{waba_id}/message_templates → persist row with meta_template_id + status + sample_values + last_submitted_at. Per-field validators throw with specific messages before the network call, so misconfigured templates fail with "button #3 missing url" instead of a 400 from Meta. 429s are detected and surfaced as "rate limit hit (100/hr)". WHATSAPP_TEMPLATES_DRY_RUN=true short-circuits the Meta call and issues a synthetic `dry-run-<uuid>` meta_template_id so CI and local dev can exercise the full form without a real WABA. New modules ----------- src/lib/whatsapp/template-validators.ts Pure functions encoding Meta's caps: 1024-char body, 60-char footer/header, 25-char button text, 10 buttons max, 2 URL / 1 PHONE_NUMBER / 1 COPY_CODE, QUICK_REPLY contiguity rule, URL example required when {{1}} is present, COPY_CODE example required, header media URL must be http(s), variables must be contiguous starting at {{1}}. 41 unit tests cover each rule. src/lib/whatsapp/template-components.ts Translates our local row shape into Meta's components array (HEADER → BODY → FOOTER → BUTTONS, body_text examples as a 2D array per spec, header_handle preferred over header_url when both are present). Snapshot tests assert the wire shape. src/lib/whatsapp/template-status-normalize.ts Single source of truth for upstream→local status mapping. Replaces the duplicated normalizer in sync/route.ts; the upcoming webhook handler will share it too. src/lib/whatsapp/meta-api.ts New submitMessageTemplate() helper following the existing named-args + throwMetaError pattern. Form rewrite ------------ src/components/settings/template-manager.tsx replaces the flat form with a component-based builder: * Header: format select (None / Text / Image / Video / Document) + conditional inputs — text + variable-aware sample row for TEXT, URL paste for media (file upload slot ready for the Resumable Upload follow-up) * Body: textarea with live {{N}} extraction → sample-value rows auto-appear and shrink as variables are added/removed * Footer: 60-char capped input * Buttons editor: add up to 10, per-row type picker (Quick Reply / URL / Phone / Copy Code), conditional URL + example + phone + code fields, X to remove * Card display now surfaces rejection_reason / submission_error (red banner) and quality_score (GREEN/YELLOW/RED chip) * AUTHENTICATION shows a "create in Meta Manager + Sync" banner and disables Submit — that category has a different shape (fixed body, mandatory OTP button) and lands in a follow-up Side effects ------------ * Broadcast Step 1 picker (step1-choose-template.tsx) now filters to status='APPROVED' to match the inbox picker — selecting a non-approved template would 400 on send. * .env.local.example documents META_APP_ID (Resumable Upload follow-up) and WHATSAPP_TEMPLATES_DRY_RUN. Out of scope (subsequent PRs): * Edit / resubmit / delete endpoints (PR 4) * Webhook handler for real-time status (PR 5) * sendTemplateMessage media + button param support (PR 6) * Resumable Upload + AUTHENTICATION builder (follow-up) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
618 lines
19 KiB
TypeScript
618 lines
19 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 }
|
||
}
|
||
|
||
// ============================================================
|
||
// Template submission (Business Management API)
|
||
// ============================================================
|
||
|
||
import type { MetaTemplateSubmitPayload } from './template-components'
|
||
|
||
export interface SubmitMessageTemplateArgs {
|
||
wabaId: string
|
||
accessToken: string
|
||
payload: MetaTemplateSubmitPayload
|
||
}
|
||
|
||
export interface SubmitMessageTemplateResult {
|
||
id: string
|
||
status: string
|
||
category?: string
|
||
}
|
||
|
||
/**
|
||
* Submit a message template to Meta for approval.
|
||
*
|
||
* Returns Meta's assigned template id + initial status (typically
|
||
* PENDING). Caller persists `id` as `meta_template_id` so the
|
||
* upcoming edit/delete flows can scope to this exact template (and
|
||
* language variant) via `hsm_id`, rather than nuking every variant
|
||
* with the same name.
|
||
*
|
||
* 429s from Meta (rate limit: 100 creates/hour/WABA) surface as a
|
||
* regular `Error('Meta API error: 429')`. The route handler
|
||
* distinguishes 429 and shows a more actionable toast.
|
||
*/
|
||
export async function submitMessageTemplate(
|
||
args: SubmitMessageTemplateArgs
|
||
): Promise<SubmitMessageTemplateResult> {
|
||
const { wabaId, accessToken, payload } = args
|
||
const url = `${META_API_BASE}/${wabaId}/message_templates`
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${accessToken}`,
|
||
},
|
||
body: JSON.stringify(payload),
|
||
})
|
||
if (!response.ok) {
|
||
await throwMetaError(response, `Meta API error: ${response.status}`)
|
||
}
|
||
const data = await response.json()
|
||
if (!data?.id) {
|
||
throw new Error('Meta accepted the template but returned no id.')
|
||
}
|
||
return {
|
||
id: String(data.id),
|
||
status: typeof data.status === 'string' ? data.status : 'PENDING',
|
||
category: typeof data.category === 'string' ? data.category : undefined,
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 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 }
|
||
}
|