mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
commit
0a9d9f2bb3
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user