diff --git a/src/app/api/whatsapp/broadcast/route.ts b/src/app/api/whatsapp/broadcast/route.ts index 6171d25..265bb14 100644 --- a/src/app/api/whatsapp/broadcast/route.ts +++ b/src/app/api/whatsapp/broadcast/route.ts @@ -95,14 +95,14 @@ export async function POST(request: Request) { for (const variant of variants) { try { - const result = await sendTemplateMessage( - config.phone_number_id, + const result = await sendTemplateMessage({ + phoneNumberId: config.phone_number_id, accessToken, - variant, - template_name, - template_language || 'en_US', - template_params || [] - ) + to: variant, + templateName: template_name, + language: template_language || 'en_US', + params: template_params || [], + }) sentMessageId = result.messageId lastError = null break diff --git a/src/app/api/whatsapp/config/route.ts b/src/app/api/whatsapp/config/route.ts index 52d8721..e0c3375 100644 --- a/src/app/api/whatsapp/config/route.ts +++ b/src/app/api/whatsapp/config/route.ts @@ -75,10 +75,10 @@ export async function GET() { // Validate credentials against Meta try { - const phoneInfo = await verifyPhoneNumber( - config.phone_number_id, - accessToken - ) + const phoneInfo = await verifyPhoneNumber({ + phoneNumberId: config.phone_number_id, + accessToken, + }) return NextResponse.json({ connected: true, phone_info: phoneInfo }) } catch (err) { const message = err instanceof Error ? err.message : 'Unknown Meta API error' @@ -131,10 +131,12 @@ export async function POST(request: Request) { } // Verify credentials with Meta BEFORE saving - // Signature: verifyPhoneNumber(phoneNumberId, accessToken) let phoneInfo try { - phoneInfo = await verifyPhoneNumber(phone_number_id, access_token) + phoneInfo = await verifyPhoneNumber({ + phoneNumberId: phone_number_id, + accessToken: access_token, + }) } catch (err) { const message = err instanceof Error ? err.message : 'Unknown Meta API error' console.error('Meta API verification failed during save:', message) diff --git a/src/app/api/whatsapp/media/[mediaId]/route.ts b/src/app/api/whatsapp/media/[mediaId]/route.ts index 3bfe46d..f5a0a3c 100644 --- a/src/app/api/whatsapp/media/[mediaId]/route.ts +++ b/src/app/api/whatsapp/media/[mediaId]/route.ts @@ -48,10 +48,13 @@ export async function GET( const accessToken = decrypt(config.access_token) // Get the download URL from Meta - const mediaInfo = await getMediaUrl(mediaId, accessToken) + const mediaInfo = await getMediaUrl({ mediaId, accessToken }) // Download the binary data - const { buffer, contentType } = await downloadMedia(mediaInfo.url, accessToken) + const { buffer, contentType } = await downloadMedia({ + downloadUrl: mediaInfo.url, + accessToken, + }) return new Response(new Uint8Array(buffer), { status: 200, diff --git a/src/app/api/whatsapp/send/route.ts b/src/app/api/whatsapp/send/route.ts index f8614fd..090b83e 100644 --- a/src/app/api/whatsapp/send/route.ts +++ b/src/app/api/whatsapp/send/route.ts @@ -114,22 +114,21 @@ export async function POST(request: Request) { const attempt = async (phone: string): Promise => { if (message_type === 'template') { - const result = await sendTemplateMessage( - config.phone_number_id, + const result = await sendTemplateMessage({ + phoneNumberId: config.phone_number_id, accessToken, - phone, - template_name, - undefined, // default language (en_US) - template_params || [] - ) + to: phone, + templateName: template_name, + params: template_params || [], + }) return result.messageId } - const result = await sendTextMessage( - config.phone_number_id, + const result = await sendTextMessage({ + phoneNumberId: config.phone_number_id, accessToken, - phone, - content_text - ) + to: phone, + text: content_text, + }) return result.messageId } diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts index 06591e8..aa81d4e 100644 --- a/src/app/api/whatsapp/webhook/route.ts +++ b/src/app/api/whatsapp/webhook/route.ts @@ -298,7 +298,7 @@ async function parseMessageContent( mediaId: string ): Promise => { try { - await getMediaUrl(mediaId, accessToken) + await getMediaUrl({ mediaId, accessToken }) return `/api/whatsapp/media/${mediaId}` } catch (error) { console.error( diff --git a/src/lib/whatsapp/meta-api.ts b/src/lib/whatsapp/meta-api.ts index 1bcbcad..89c1da9 100644 --- a/src/lib/whatsapp/meta-api.ts +++ b/src/lib/whatsapp/meta-api.ts @@ -1,3 +1,14 @@ +/** + * 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}` @@ -12,36 +23,73 @@ export interface MetaPhoneInfo { quality_rating?: string } +interface MetaErrorResponse { + error?: { message?: string; code?: number; type?: string } +} + +async function throwMetaError(response: Response, fallback: string): Promise { + 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 phone number ID with Meta Graph API + * Verify a Meta phone number ID by fetching its public metadata + * (display_phone_number, verified_name, quality_rating). */ -export async function verifyPhoneNumber(phoneNumberId: string, accessToken: string): Promise { +export async function verifyPhoneNumber( + args: VerifyPhoneNumberArgs +): Promise { + 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}` }, + headers: { Authorization: `Bearer ${accessToken}` }, }) if (!response.ok) { - const data = await response.json() - throw new Error(data.error?.message || `Meta API error: ${response.status}`) + await throwMetaError(response, `Meta API error: ${response.status}`) } return response.json() } +// ============================================================ +// Sending +// ============================================================ + +export interface SendTextMessageArgs { + phoneNumberId: string + accessToken: string + to: string + text: string +} + /** - * Send a text message via Meta WhatsApp API + * Send a free-form WhatsApp text message. + * Only works inside the 24-hour customer service window. */ export async function sendTextMessage( - phoneNumberId: string, - accessToken: string, - to: string, - text: string + args: SendTextMessageArgs ): Promise { + const { phoneNumberId, accessToken, to, text } = args const url = `${META_API_BASE}/${phoneNumberId}/messages` const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${accessToken}`, + Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ messaging_product: 'whatsapp', @@ -52,24 +100,36 @@ export async function sendTextMessage( }), }) if (!response.ok) { - const data = await response.json() - throw new Error(data.error?.message || `Meta API error: ${response.status}`) + 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[] +} + /** - * Send a template message via Meta WhatsApp API + * Send a pre-approved WhatsApp message template. Required outside + * the 24-hour window and for any first-touch messaging. */ export async function sendTemplateMessage( - phoneNumberId: string, - accessToken: string, - to: string, - templateName: string, - language: string = 'en_US', - params?: string[] + args: SendTemplateMessageArgs ): Promise { + const { + phoneNumberId, + accessToken, + to, + templateName, + language = 'en_US', + params, + } = args const url = `${META_API_BASE}/${phoneNumberId}/messages` const template: Record = { @@ -78,17 +138,19 @@ export async function sendTemplateMessage( } if (params && params.length > 0) { - template.components = [{ - type: 'body', - parameters: params.map(p => ({ type: 'text', text: String(p) })), - }] + template.components = [ + { + type: 'body', + parameters: params.map((p) => ({ type: 'text', text: String(p) })), + }, + ] } const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${accessToken}`, + Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ messaging_product: 'whatsapp', @@ -99,40 +161,61 @@ export async function sendTemplateMessage( }), }) if (!response.ok) { - const data = await response.json() - throw new Error(data.error?.message || `Meta API error: ${response.status}`) + await throwMetaError(response, `Meta API error: ${response.status}`) } const data = await response.json() return { messageId: data.messages[0].id } } -/** - * Get media download URL from Meta - */ -export async function getMediaUrl(mediaId: string, accessToken: string): Promise<{ url: string; mimeType: string }> { - const response = await fetch(`${META_API_BASE}/${mediaId}`, { - headers: { 'Authorization': `Bearer ${accessToken}` }, - }) - if (!response.ok) { - const data = await response.json() - throw new Error(data.error?.message || `Media fetch failed: ${response.status}`) - } - const data = await response.json() - if (!data.url) throw new Error('Media URL not found') - return { url: data.url, mimeType: data.mime_type || 'application/octet-stream' } +// ============================================================ +// Media +// ============================================================ + +export interface GetMediaUrlArgs { + mediaId: string + accessToken: string } /** - * Download media binary from Meta CDN + * 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 downloadMedia(downloadUrl: string, accessToken: string): Promise<{ buffer: Buffer; contentType: string }> { +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}` }, + 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 contentType = + response.headers.get('content-type') || 'application/octet-stream' const buffer = Buffer.from(await response.arrayBuffer()) return { buffer, contentType } }