mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
refactor: Meta API helpers use named params (prevents swapped-args bugs)
Over the course of the project the same swapped-args bug was found four separate times against the Meta API helpers (config in PR #8, send in PR #12, broadcast in PR #13, webhook media in PR #14). Each time the compiler accepted the call because all the args were strings, so the bug only surfaced at runtime as an opaque Meta error. This refactor eliminates the entire bug class by converting every helper to a single options object. A typo like sendTextMessage({ accessToken: id, phoneNumberId: tok, ... }) is now a TypeScript error at the call site rather than a 400 from Meta two layers deep. Helpers updated in src/lib/whatsapp/meta-api.ts: - verifyPhoneNumber({ phoneNumberId, accessToken }) - sendTextMessage({ phoneNumberId, accessToken, to, text }) - sendTemplateMessage({ phoneNumberId, accessToken, to, templateName, language?, params? }) - getMediaUrl({ mediaId, accessToken }) - downloadMedia({ downloadUrl, accessToken }) Each exported Args interface so routes can type local variables holding partial argument sets (useful for the broadcast retry loop). Also factored the duplicated "parse Meta error JSON" block into a shared throwMetaError() helper — was repeated in 5 places. Call sites updated (all were already correct after prior fixes, but now the types enforce it): - src/app/api/whatsapp/config/route.ts (GET + POST) - src/app/api/whatsapp/send/route.ts (attempt() closure) - src/app/api/whatsapp/broadcast/route.ts (retry loop) - src/app/api/whatsapp/webhook/route.ts (verifyAndBuildUrl) - src/app/api/whatsapp/media/[mediaId]/route.ts (proxy pipeline) No behavior change. Build passes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
03b40e0e8e
commit
f3c68967f1
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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',
|
{
|
||||||
parameters: params.map(p => ({ type: 'text', text: String(p) })),
|
type: 'body',
|
||||||
}]
|
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 }
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user