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) {
|
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',
|
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 }
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user