Merge pull request #15 from ArnasDon/refactor/meta-api-named-params

refactor: Meta API helpers take named-params objects (prevents entire bug class)
This commit is contained in:
Arnas Donauskas 2026-04-16 22:00:38 +03:00 committed by GitHub
commit 0a9d9f2bb3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 160 additions and 73 deletions

View File

@ -95,14 +95,14 @@ export async function POST(request: Request) {
for (const variant of variants) { for (const variant of variants) {
try { try {
const result = await sendTemplateMessage( const result = await sendTemplateMessage({
config.phone_number_id, phoneNumberId: config.phone_number_id,
accessToken, accessToken,
variant, to: variant,
template_name, templateName: template_name,
template_language || 'en_US', language: template_language || 'en_US',
template_params || [] params: template_params || [],
) })
sentMessageId = result.messageId sentMessageId = result.messageId
lastError = null lastError = null
break break

View File

@ -75,10 +75,10 @@ export async function GET() {
// Validate credentials against Meta // Validate credentials against Meta
try { try {
const phoneInfo = await verifyPhoneNumber( const phoneInfo = await verifyPhoneNumber({
config.phone_number_id, phoneNumberId: config.phone_number_id,
accessToken accessToken,
) })
return NextResponse.json({ connected: true, phone_info: phoneInfo }) return NextResponse.json({ connected: true, phone_info: phoneInfo })
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Unknown Meta API error' 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 // Verify credentials with Meta BEFORE saving
// Signature: verifyPhoneNumber(phoneNumberId, accessToken)
let phoneInfo let phoneInfo
try { try {
phoneInfo = await verifyPhoneNumber(phone_number_id, access_token) phoneInfo = await verifyPhoneNumber({
phoneNumberId: phone_number_id,
accessToken: access_token,
})
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Unknown Meta API error' const message = err instanceof Error ? err.message : 'Unknown Meta API error'
console.error('Meta API verification failed during save:', message) console.error('Meta API verification failed during save:', message)

View File

@ -48,10 +48,13 @@ export async function GET(
const accessToken = decrypt(config.access_token) const accessToken = decrypt(config.access_token)
// Get the download URL from Meta // Get the download URL from Meta
const mediaInfo = await getMediaUrl(mediaId, accessToken) const mediaInfo = await getMediaUrl({ mediaId, accessToken })
// Download the binary data // 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), { return new Response(new Uint8Array(buffer), {
status: 200, status: 200,

View File

@ -114,22 +114,21 @@ export async function POST(request: Request) {
const attempt = async (phone: string): Promise<string> => { const attempt = async (phone: string): Promise<string> => {
if (message_type === 'template') { if (message_type === 'template') {
const result = await sendTemplateMessage( const result = await sendTemplateMessage({
config.phone_number_id, phoneNumberId: config.phone_number_id,
accessToken, accessToken,
phone, to: phone,
template_name, templateName: template_name,
undefined, // default language (en_US) params: template_params || [],
template_params || [] })
)
return result.messageId return result.messageId
} }
const result = await sendTextMessage( const result = await sendTextMessage({
config.phone_number_id, phoneNumberId: config.phone_number_id,
accessToken, accessToken,
phone, to: phone,
content_text text: content_text,
) })
return result.messageId return result.messageId
} }

View File

@ -298,7 +298,7 @@ async function parseMessageContent(
mediaId: string mediaId: string
): Promise<string | null> => { ): Promise<string | null> => {
try { try {
await getMediaUrl(mediaId, accessToken) await getMediaUrl({ mediaId, accessToken })
return `/api/whatsapp/media/${mediaId}` return `/api/whatsapp/media/${mediaId}`
} catch (error) { } catch (error) {
console.error( console.error(

View File

@ -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_VERSION = 'v21.0'
const META_API_BASE = `https://graph.facebook.com/${META_API_VERSION}` const META_API_BASE = `https://graph.facebook.com/${META_API_VERSION}`
@ -12,36 +23,73 @@ export interface MetaPhoneInfo {
quality_rating?: 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 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<MetaPhoneInfo> { 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 url = `${META_API_BASE}/${phoneNumberId}?fields=id,display_phone_number,verified_name,quality_rating`
const response = await fetch(url, { const response = await fetch(url, {
headers: { 'Authorization': `Bearer ${accessToken}` }, headers: { Authorization: `Bearer ${accessToken}` },
}) })
if (!response.ok) { if (!response.ok) {
const data = await response.json() await throwMetaError(response, `Meta API error: ${response.status}`)
throw new Error(data.error?.message || `Meta API error: ${response.status}`)
} }
return response.json() 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( export async function sendTextMessage(
phoneNumberId: string, args: SendTextMessageArgs
accessToken: string,
to: string,
text: string
): Promise<MetaSendResult> { ): Promise<MetaSendResult> {
const { phoneNumberId, accessToken, to, text } = args
const url = `${META_API_BASE}/${phoneNumberId}/messages` const url = `${META_API_BASE}/${phoneNumberId}/messages`
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
messaging_product: 'whatsapp', messaging_product: 'whatsapp',
@ -52,24 +100,36 @@ export async function sendTextMessage(
}), }),
}) })
if (!response.ok) { if (!response.ok) {
const data = await response.json() await throwMetaError(response, `Meta API error: ${response.status}`)
throw new Error(data.error?.message || `Meta API error: ${response.status}`)
} }
const data = await response.json() const data = await response.json()
return { messageId: data.messages[0].id } 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( export async function sendTemplateMessage(
phoneNumberId: string, args: SendTemplateMessageArgs
accessToken: string,
to: string,
templateName: string,
language: string = 'en_US',
params?: string[]
): Promise<MetaSendResult> { ): Promise<MetaSendResult> {
const {
phoneNumberId,
accessToken,
to,
templateName,
language = 'en_US',
params,
} = args
const url = `${META_API_BASE}/${phoneNumberId}/messages` const url = `${META_API_BASE}/${phoneNumberId}/messages`
const template: Record<string, unknown> = { const template: Record<string, unknown> = {
@ -78,17 +138,19 @@ export async function sendTemplateMessage(
} }
if (params && params.length > 0) { if (params && params.length > 0) {
template.components = [{ template.components = [
{
type: 'body', type: 'body',
parameters: params.map(p => ({ type: 'text', text: String(p) })), parameters: params.map((p) => ({ type: 'text', text: String(p) })),
}] },
]
} }
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
messaging_product: 'whatsapp', messaging_product: 'whatsapp',
@ -99,40 +161,61 @@ export async function sendTemplateMessage(
}), }),
}) })
if (!response.ok) { if (!response.ok) {
const data = await response.json() await throwMetaError(response, `Meta API error: ${response.status}`)
throw new Error(data.error?.message || `Meta API error: ${response.status}`)
} }
const data = await response.json() const data = await response.json()
return { messageId: data.messages[0].id } return { messageId: data.messages[0].id }
} }
/** // ============================================================
* Get media download URL from Meta // Media
*/ // ============================================================
export async function getMediaUrl(mediaId: string, accessToken: string): Promise<{ url: string; mimeType: string }> {
const response = await fetch(`${META_API_BASE}/${mediaId}`, { export interface GetMediaUrlArgs {
headers: { 'Authorization': `Bearer ${accessToken}` }, mediaId: string
}) accessToken: string
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' }
} }
/** /**
* 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, { const response = await fetch(downloadUrl, {
headers: { 'Authorization': `Bearer ${accessToken}` }, headers: { Authorization: `Bearer ${accessToken}` },
}) })
if (!response.ok) { if (!response.ok) {
throw new Error(`Media download failed: ${response.status}`) 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()) const buffer = Buffer.from(await response.arrayBuffer())
return { buffer, contentType } return { buffer, contentType }
} }