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) {
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

View File

@ -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)

View File

@ -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,

View File

@ -114,22 +114,21 @@ export async function POST(request: Request) {
const attempt = async (phone: string): Promise<string> => {
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
}

View File

@ -298,7 +298,7 @@ async function parseMessageContent(
mediaId: string
): Promise<string | null> => {
try {
await getMediaUrl(mediaId, accessToken)
await getMediaUrl({ mediaId, accessToken })
return `/api/whatsapp/media/${mediaId}`
} catch (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_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<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 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<MetaSendResult> {
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<MetaSendResult> {
const {
phoneNumberId,
accessToken,
to,
templateName,
language = 'en_US',
params,
} = args
const url = `${META_API_BASE}/${phoneNumberId}/messages`
const template: Record<string, unknown> = {
@ -78,17 +138,19 @@ export async function sendTemplateMessage(
}
if (params && params.length > 0) {
template.components = [{
template.components = [
{
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, {
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 }
}