Files
nabeh/whatsapp-gateway/cloud-api-client.js
T

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;