415 lines
14 KiB
JavaScript
415 lines
14 KiB
JavaScript
/**
|
|
* CloudApiClient — WhatsApp Cloud API (Meta Official) Integration for Nabeh
|
|
*
|
|
* Handles communication with Meta's Graph API for WhatsApp Business:
|
|
* - Sending text, template, media, and interactive messages
|
|
* - Receiving and verifying incoming webhooks
|
|
* - Managing message templates
|
|
* - Uploading media assets
|
|
*/
|
|
|
|
const axios = require('axios');
|
|
|
|
const GRAPH_API_VERSION = 'v20.0';
|
|
const GRAPH_API_BASE = `https://graph.facebook.com/${GRAPH_API_VERSION}`;
|
|
|
|
class CloudApiClient {
|
|
/**
|
|
* Send a text message via WhatsApp Cloud API
|
|
*/
|
|
static async sendTextMessage(phoneNumberId, accessToken, to, text) {
|
|
const url = `${GRAPH_API_BASE}/${phoneNumberId}/messages`;
|
|
const payload = {
|
|
messaging_product: 'whatsapp',
|
|
recipient_type: 'individual',
|
|
to: to,
|
|
type: 'text',
|
|
text: { body: text }
|
|
};
|
|
|
|
return this._makeRequest(url, accessToken, payload);
|
|
}
|
|
|
|
/**
|
|
* Send a template message (pre-approved by Meta)
|
|
* @param {string} phoneNumberId - Meta Phone Number ID
|
|
* @param {string} accessToken - Meta Access Token
|
|
* @param {string} to - Recipient phone number (international format, no +)
|
|
* @param {string} templateName - Template name as registered in Meta
|
|
* @param {string} languageCode - e.g., 'ar', 'en'
|
|
* @param {Array} components - Template components (header, body, button params)
|
|
*/
|
|
static async sendTemplateMessage(phoneNumberId, accessToken, to, templateName, languageCode = 'ar', components = []) {
|
|
const url = `${GRAPH_API_BASE}/${phoneNumberId}/messages`;
|
|
const template = {
|
|
name: templateName,
|
|
language: { code: languageCode }
|
|
};
|
|
|
|
if (components.length > 0) {
|
|
template.components = components;
|
|
}
|
|
|
|
const payload = {
|
|
messaging_product: 'whatsapp',
|
|
recipient_type: 'individual',
|
|
to: to,
|
|
type: 'template',
|
|
template: template
|
|
};
|
|
|
|
return this._makeRequest(url, accessToken, payload);
|
|
}
|
|
|
|
/**
|
|
* Send an image message
|
|
*/
|
|
static async sendImageMessage(phoneNumberId, accessToken, to, imageUrl, caption = '') {
|
|
const url = `${GRAPH_API_BASE}/${phoneNumberId}/messages`;
|
|
const payload = {
|
|
messaging_product: 'whatsapp',
|
|
recipient_type: 'individual',
|
|
to: to,
|
|
type: 'image',
|
|
image: {
|
|
link: imageUrl,
|
|
caption: caption
|
|
}
|
|
};
|
|
|
|
return this._makeRequest(url, accessToken, payload);
|
|
}
|
|
|
|
/**
|
|
* Send a document message
|
|
*/
|
|
static async sendDocumentMessage(phoneNumberId, accessToken, to, documentUrl, filename = 'document', caption = '') {
|
|
const url = `${GRAPH_API_BASE}/${phoneNumberId}/messages`;
|
|
const payload = {
|
|
messaging_product: 'whatsapp',
|
|
recipient_type: 'individual',
|
|
to: to,
|
|
type: 'document',
|
|
document: {
|
|
link: documentUrl,
|
|
filename: filename,
|
|
caption: caption
|
|
}
|
|
};
|
|
|
|
return this._makeRequest(url, accessToken, payload);
|
|
}
|
|
|
|
/**
|
|
* Send an interactive message with buttons
|
|
*/
|
|
static async sendInteractiveButtons(phoneNumberId, accessToken, to, bodyText, buttons) {
|
|
const url = `${GRAPH_API_BASE}/${phoneNumberId}/messages`;
|
|
const payload = {
|
|
messaging_product: 'whatsapp',
|
|
recipient_type: 'individual',
|
|
to: to,
|
|
type: 'interactive',
|
|
interactive: {
|
|
type: 'button',
|
|
body: { text: bodyText },
|
|
action: {
|
|
buttons: buttons.slice(0, 3).map((btn, i) => ({
|
|
type: 'reply',
|
|
reply: {
|
|
id: btn.id || `btn_${i}`,
|
|
title: btn.title.substring(0, 20)
|
|
}
|
|
}))
|
|
}
|
|
}
|
|
};
|
|
|
|
return this._makeRequest(url, accessToken, payload);
|
|
}
|
|
|
|
/**
|
|
* Mark a message as read
|
|
*/
|
|
static async markAsRead(phoneNumberId, accessToken, messageId) {
|
|
const url = `${GRAPH_API_BASE}/${phoneNumberId}/messages`;
|
|
const payload = {
|
|
messaging_product: 'whatsapp',
|
|
status: 'read',
|
|
message_id: messageId
|
|
};
|
|
|
|
return this._makeRequest(url, accessToken, payload);
|
|
}
|
|
|
|
/**
|
|
* Get message templates for a WhatsApp Business Account
|
|
*/
|
|
static async getTemplates(wabaId, accessToken, limit = 100) {
|
|
const url = `${GRAPH_API_BASE}/${wabaId}/message_templates?limit=${limit}`;
|
|
|
|
try {
|
|
const response = await axios.get(url, {
|
|
headers: { 'Authorization': `Bearer ${accessToken}` },
|
|
timeout: 15000
|
|
});
|
|
return response.data;
|
|
} catch (err) {
|
|
console.error(`[CloudAPI] Failed to fetch templates:`, err.response?.data || err.message);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new message template
|
|
*/
|
|
static async createTemplate(wabaId, accessToken, templateData) {
|
|
const url = `${GRAPH_API_BASE}/${wabaId}/message_templates`;
|
|
|
|
try {
|
|
const response = await axios.post(url, templateData, {
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
timeout: 15000
|
|
});
|
|
return response.data;
|
|
} catch (err) {
|
|
console.error(`[CloudAPI] Failed to create template:`, err.response?.data || err.message);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the WhatsApp Business Profile
|
|
*/
|
|
static async getBusinessProfile(phoneNumberId, accessToken) {
|
|
const url = `${GRAPH_API_BASE}/${phoneNumberId}/whatsapp_business_profile?fields=about,address,description,email,profile_picture_url,websites,vertical`;
|
|
|
|
try {
|
|
const response = await axios.get(url, {
|
|
headers: { 'Authorization': `Bearer ${accessToken}` },
|
|
timeout: 10000
|
|
});
|
|
return response.data;
|
|
} catch (err) {
|
|
console.error(`[CloudAPI] Failed to get business profile:`, err.response?.data || err.message);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Upload media to WhatsApp servers
|
|
*/
|
|
static async uploadMedia(phoneNumberId, accessToken, filePath, mimeType) {
|
|
const fs = require('fs');
|
|
const FormData = require('form-data');
|
|
const url = `${GRAPH_API_BASE}/${phoneNumberId}/media`;
|
|
|
|
const form = new FormData();
|
|
form.append('messaging_product', 'whatsapp');
|
|
form.append('file', fs.createReadStream(filePath));
|
|
form.append('type', mimeType);
|
|
|
|
try {
|
|
const response = await axios.post(url, form, {
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
...form.getHeaders()
|
|
},
|
|
timeout: 30000
|
|
});
|
|
return response.data;
|
|
} catch (err) {
|
|
console.error(`[CloudAPI] Failed to upload media:`, err.response?.data || err.message);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify webhook callback from Meta (GET request verification)
|
|
* @param {object} query - Request query parameters
|
|
* @param {string} verifyToken - Your configured verify token
|
|
* @returns {string|null} - Challenge string if valid, null otherwise
|
|
*/
|
|
static verifyWebhook(query, verifyToken) {
|
|
const mode = query['hub.mode'];
|
|
const token = query['hub.verify_token'];
|
|
const challenge = query['hub.challenge'];
|
|
|
|
if (mode === 'subscribe' && token === verifyToken) {
|
|
console.log(`[CloudAPI Webhook] ✅ Verification successful`);
|
|
return challenge;
|
|
}
|
|
|
|
console.warn(`[CloudAPI Webhook] ❌ Verification failed. Mode: ${mode}, Token match: ${token === verifyToken}`);
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Parse incoming webhook payload from Meta
|
|
* Returns an array of message objects or empty array
|
|
*/
|
|
static parseIncomingWebhook(body) {
|
|
const messages = [];
|
|
|
|
if (!body || !body.entry) return messages;
|
|
|
|
for (const entry of body.entry) {
|
|
if (!entry.changes) continue;
|
|
|
|
for (const change of entry.changes) {
|
|
if (change.field !== 'messages') continue;
|
|
const value = change.value;
|
|
|
|
if (!value || !value.messages) continue;
|
|
|
|
const metadata = value.metadata || {};
|
|
const contacts = value.contacts || [];
|
|
|
|
for (const msg of value.messages) {
|
|
const contact = contacts.find(c => c.wa_id === msg.from) || {};
|
|
|
|
const parsed = {
|
|
id: msg.id,
|
|
from: msg.from,
|
|
name: contact.profile?.name || '',
|
|
timestamp: msg.timestamp,
|
|
type: msg.type,
|
|
phoneNumberId: metadata.phone_number_id,
|
|
displayPhone: metadata.display_phone_number,
|
|
body: '',
|
|
image: null,
|
|
audio: null,
|
|
document: null,
|
|
interactive: null
|
|
};
|
|
|
|
switch (msg.type) {
|
|
case 'text':
|
|
parsed.body = msg.text?.body || '';
|
|
break;
|
|
case 'image':
|
|
parsed.image = {
|
|
id: msg.image?.id,
|
|
mimeType: msg.image?.mime_type,
|
|
caption: msg.image?.caption || ''
|
|
};
|
|
parsed.body = msg.image?.caption || '';
|
|
break;
|
|
case 'audio':
|
|
parsed.audio = {
|
|
id: msg.audio?.id,
|
|
mimeType: msg.audio?.mime_type
|
|
};
|
|
break;
|
|
case 'document':
|
|
parsed.document = {
|
|
id: msg.document?.id,
|
|
mimeType: msg.document?.mime_type,
|
|
filename: msg.document?.filename
|
|
};
|
|
break;
|
|
case 'interactive':
|
|
parsed.interactive = {
|
|
type: msg.interactive?.type,
|
|
buttonReplyId: msg.interactive?.button_reply?.id,
|
|
buttonReplyTitle: msg.interactive?.button_reply?.title,
|
|
listReplyId: msg.interactive?.list_reply?.id,
|
|
listReplyTitle: msg.interactive?.list_reply?.title
|
|
};
|
|
parsed.body = msg.interactive?.button_reply?.title || msg.interactive?.list_reply?.title || '';
|
|
break;
|
|
case 'reaction':
|
|
parsed.body = `[Reaction: ${msg.reaction?.emoji || ''}]`;
|
|
break;
|
|
default:
|
|
parsed.body = `[${msg.type}]`;
|
|
}
|
|
|
|
messages.push(parsed);
|
|
}
|
|
|
|
// Also process status updates if needed
|
|
if (value.statuses) {
|
|
for (const status of value.statuses) {
|
|
messages.push({
|
|
type: 'status_update',
|
|
id: status.id,
|
|
from: status.recipient_id,
|
|
status: status.status, // sent, delivered, read, failed
|
|
timestamp: status.timestamp,
|
|
errors: status.errors || []
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return messages;
|
|
}
|
|
|
|
/**
|
|
* Download media from WhatsApp Cloud API by media ID
|
|
*/
|
|
static async downloadMedia(mediaId, accessToken) {
|
|
// Step 1: Get the media URL
|
|
const metaUrl = `${GRAPH_API_BASE}/${mediaId}`;
|
|
try {
|
|
const metaResponse = await axios.get(metaUrl, {
|
|
headers: { 'Authorization': `Bearer ${accessToken}` },
|
|
timeout: 10000
|
|
});
|
|
|
|
const mediaUrl = metaResponse.data.url;
|
|
if (!mediaUrl) throw new Error('No media URL returned');
|
|
|
|
// Step 2: Download the actual media
|
|
const mediaResponse = await axios.get(mediaUrl, {
|
|
headers: { 'Authorization': `Bearer ${accessToken}` },
|
|
responseType: 'arraybuffer',
|
|
timeout: 30000
|
|
});
|
|
|
|
return {
|
|
buffer: Buffer.from(mediaResponse.data),
|
|
mimeType: metaResponse.data.mime_type,
|
|
fileSize: metaResponse.data.file_size
|
|
};
|
|
} catch (err) {
|
|
console.error(`[CloudAPI] Failed to download media ${mediaId}:`, err.response?.data || err.message);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Internal HTTP request helper
|
|
*/
|
|
static async _makeRequest(url, accessToken, payload) {
|
|
try {
|
|
const response = await axios.post(url, payload, {
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
timeout: 15000
|
|
});
|
|
|
|
console.log(`[CloudAPI] ✅ Message sent successfully. ID: ${response.data?.messages?.[0]?.id || 'N/A'}`);
|
|
return response.data;
|
|
} catch (err) {
|
|
const errorData = err.response?.data?.error || {};
|
|
console.error(`[CloudAPI] ❌ Send failed:`, {
|
|
code: errorData.code,
|
|
type: errorData.type,
|
|
message: errorData.message,
|
|
fbtrace_id: errorData.fbtrace_id
|
|
});
|
|
throw new Error(errorData.message || err.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = CloudApiClient;
|