/** * 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;