From 66b2c0eaa9723400531e2019b17d163fd948f4c5 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Wed, 15 Jul 2026 05:08:07 +0300 Subject: [PATCH] Deploy: 2026-07-15 05:08:07 --- .../app/Controllers/CloudApiController.php | 235 ++++++++++ backend/migrate_whatsapp_cloud_api.php | 62 +++ backend/public/index.html | 115 +++++ backend/public/index.php | 8 + whatsapp-gateway/cloud-api-client.js | 414 ++++++++++++++++++ 5 files changed, 834 insertions(+) create mode 100644 backend/app/Controllers/CloudApiController.php create mode 100644 backend/migrate_whatsapp_cloud_api.php create mode 100644 whatsapp-gateway/cloud-api-client.js diff --git a/backend/app/Controllers/CloudApiController.php b/backend/app/Controllers/CloudApiController.php new file mode 100644 index 0000000..cd27ae7 --- /dev/null +++ b/backend/app/Controllers/CloudApiController.php @@ -0,0 +1,235 @@ +company_id; + + $session = \App\Core\Database::selectOne( + "SELECT * FROM meta_cloud_sessions WHERE company_id = ? LIMIT 1", + [$companyId] + ); + + if (!$session) { + $response->json([ + 'status' => 'success', + 'data' => null + ]); + return; + } + + // Hide token from frontend + unset($session['access_token']); + unset($session['verify_token']); + + $response->json([ + 'status' => 'success', + 'data' => $session + ]); + } + + /** + * Connect or update a Cloud API session + */ + public function connect(Request $request, Response $response) + { + $companyId = $request->company_id; + $body = $request->getBody(); + + if (empty($body['phone_number_id']) || empty($body['waba_id']) || empty($body['access_token'])) { + $response->status(400)->json(['error' => 'Missing required fields']); + return; + } + + $session = \App\Core\Database::selectOne( + "SELECT id FROM meta_cloud_sessions WHERE company_id = ? LIMIT 1", + [$companyId] + ); + + $verifyToken = 'nabeh_cloud_' . bin2hex(random_bytes(8)); + + if ($session) { + \App\Core\Database::execute( + "UPDATE meta_cloud_sessions SET phone_number_id = ?, waba_id = ?, access_token = ?, status = 'active' WHERE id = ?", + [$body['phone_number_id'], $body['waba_id'], $body['access_token'], $session['id']] + ); + } else { + \App\Core\Database::execute( + "INSERT INTO meta_cloud_sessions (company_id, phone_number_id, waba_id, access_token, verify_token, status) VALUES (?, ?, ?, ?, ?, 'active')", + [$companyId, $body['phone_number_id'], $body['waba_id'], $body['access_token'], $verifyToken] + ); + } + + $updated = \App\Core\Database::selectOne( + "SELECT * FROM meta_cloud_sessions WHERE company_id = ? LIMIT 1", + [$companyId] + ); + + unset($updated['access_token']); + + $response->json([ + 'status' => 'success', + 'message' => 'WhatsApp Cloud API connected successfully', + 'data' => $updated + ]); + } + + /** + * Disconnect Cloud API + */ + public function disconnect(Request $request, Response $response) + { + $companyId = $request->company_id; + + \App\Core\Database::execute( + "DELETE FROM meta_cloud_sessions WHERE company_id = ?", + [$companyId] + ); + + $response->json([ + 'status' => 'success', + 'message' => 'Disconnected successfully' + ]); + } + + /** + * Fetch templates from Meta and sync them locally + */ + public function syncTemplates(Request $request, Response $response) + { + $companyId = $request->company_id; + + $session = \App\Core\Database::selectOne( + "SELECT * FROM meta_cloud_sessions WHERE company_id = ? LIMIT 1", + [$companyId] + ); + + if (!$session || $session['status'] !== 'active') { + $response->status(400)->json(['error' => 'Cloud API not connected']); + return; + } + + try { + // Call the Node.js Cloud API Service via Gateway + $gatewayUrl = rtrim(getenv('WHATSAPP_GATEWAY_URL') ?: 'http://localhost:3722', '/'); + $syncUrl = $gatewayUrl . '/api/cloud/templates/sync'; + + $payload = json_encode([ + 'waba_id' => $session['waba_id'], + 'access_token' => $session['access_token'] + ]); + + $ch = curl_init($syncUrl); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'X-Webhook-Secret: ' . getenv('WEBHOOK_SECRET') + ]); + curl_setopt($ch, CURLOPT_TIMEOUT, 15); + $res = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode === 200) { + $resData = json_decode($res, true); + $templates = $resData['data'] ?? []; + + // Clear old templates and insert new ones + \App\Core\Database::execute("DELETE FROM message_templates WHERE company_id = ?", [$companyId]); + + foreach ($templates as $tpl) { + \App\Core\Database::execute( + "INSERT INTO message_templates (company_id, meta_template_id, name, language, category, status, body_text) VALUES (?, ?, ?, ?, ?, ?, ?)", + [$companyId, $tpl['id'], $tpl['name'], $tpl['language'], strtolower($tpl['category']), strtolower($tpl['status']), 'Synced from Meta'] + ); + } + + $response->json([ + 'status' => 'success', + 'message' => 'Templates synced successfully', + 'count' => count($templates) + ]); + } else { + $response->status($httpCode)->json(['error' => 'Failed to sync templates from Meta']); + } + } catch (\Exception $e) { + $response->status(500)->json(['error' => $e->getMessage()]); + } + } + + /** + * Webhook to receive incoming messages from Meta + */ + public function webhook(Request $request, Response $response) + { + // Meta GET verification request + if ($request->getMethod() === 'GET') { + $mode = $request->get('hub_mode'); + $token = $request->get('hub_verify_token'); + $challenge = $request->get('hub_challenge'); + + if ($mode === 'subscribe' && $token) { + $session = \App\Core\Database::selectOne( + "SELECT * FROM meta_cloud_sessions WHERE verify_token = ? LIMIT 1", + [$token] + ); + + if ($session) { + echo $challenge; + exit; + } + } + $response->status(403)->send('Forbidden'); + return; + } + + // Handle POST incoming messages + $body = $request->getBody(); + if (isset($body['entry'][0]['changes'][0]['value']['messages'])) { + $metaMessages = $body['entry'][0]['changes'][0]['value']['messages']; + $metadata = $body['entry'][0]['changes'][0]['value']['metadata']; + $phoneNumberId = $metadata['phone_number_id']; + + $session = \App\Core\Database::selectOne( + "SELECT * FROM meta_cloud_sessions WHERE phone_number_id = ? AND status = 'active' LIMIT 1", + [$phoneNumberId] + ); + + if ($session) { + foreach ($metaMessages as $msg) { + // Log incoming message + $msgText = $msg['text']['body'] ?? '[Media/Other]'; + \App\Models\MessageLog::logMessage([ + 'company_id' => $session['company_id'], + 'contact_phone' => $msg['from'], + 'direction' => 'inbound', + 'message_type' => $msg['type'], + 'message_body' => $msgText, + 'whatsapp_message_id' => $msg['id'], + 'status' => 'read' + ]); + + // Note: Here you can integrate Gemini AI for auto-replies over Cloud API + // Similar to triggerAutoReply() in WhatsAppController + } + } + } + + $response->status(200)->send('EVENT_RECEIVED'); + } +} diff --git a/backend/migrate_whatsapp_cloud_api.php b/backend/migrate_whatsapp_cloud_api.php new file mode 100644 index 0000000..245c359 --- /dev/null +++ b/backend/migrate_whatsapp_cloud_api.php @@ -0,0 +1,62 @@ +getMessage() . "\n"; +} diff --git a/backend/public/index.html b/backend/public/index.html index dd672bd..646fca8 100644 --- a/backend/public/index.html +++ b/backend/public/index.html @@ -901,6 +901,7 @@
+
@@ -978,6 +979,52 @@ + +
+
+

+
+

+ +
+ + +
+
+
@@ -1946,6 +1993,12 @@ whatsappMaxSessions: 1, newSessionName: '', metaSessions: [], + cloudSession: null, + newCloudSession: { + phone_number_id: '', + waba_id: '', + access_token: '' + }, newMetaSession: { channel_type: 'messenger', page_id: '', @@ -2375,6 +2428,68 @@ this.actionLoading = false; } }, + async fetchCloudSession() { + if (!this.token) return; + try { + const response = await fetch('/api/cloud/status', { + headers: { 'Authorization': `Bearer ${this.token}` } + }); + const data = await response.json(); + if (response.ok && data.status === 'success') { + this.cloudSession = data.data; + } + } catch (err) { + console.error('Failed to fetch cloud session', err); + } + }, + async connectCloudSession() { + if (!this.newCloudSession.phone_number_id || !this.newCloudSession.waba_id || !this.newCloudSession.access_token) { + alert(this.lang === 'ar' ? 'الرجاء تعبئة جميع الحقول' : 'Please fill all fields'); + return; + } + this.actionLoading = true; + try { + const response = await fetch('/api/cloud/connect', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.token}` + }, + body: JSON.stringify(this.newCloudSession) + }); + const data = await response.json(); + if (response.ok && data.status === 'success') { + this.cloudSession = data.data; + this.newCloudSession = { phone_number_id: '', waba_id: '', access_token: '' }; + } else { + alert(data.message || data.error || 'Connection failed'); + } + } catch (err) { + alert('Network error connecting Cloud API'); + } finally { + this.actionLoading = false; + } + }, + async disconnectCloudSession() { + if (!confirm(this.lang === 'ar' ? 'هل أنت متأكد من قطع الاتصال بحساب Meta؟' : 'Are you sure you want to disconnect Meta Account?')) { + return; + } + this.actionLoading = true; + try { + const response = await fetch('/api/cloud/disconnect', { + method: 'POST', + headers: { 'Authorization': `Bearer ${this.token}` } + }); + const data = await response.json(); + if (response.ok && data.status === 'success') { + this.cloudSession = null; + } + } catch (err) { + console.error('Failed to disconnect cloud session', err); + } finally { + this.actionLoading = false; + } + }, async deleteMetaSession(id) { if (!confirm(this.lang === 'ar' ? 'هل أنت متأكد من حذف هذه القناة؟' : 'Are you sure you want to disconnect this channel?')) { return; diff --git a/backend/public/index.php b/backend/public/index.php index a97f8b9..c566aff 100644 --- a/backend/public/index.php +++ b/backend/public/index.php @@ -103,6 +103,14 @@ $router->delete('/api/meta/sessions', [\App\Controllers\MetaWebhookContr $router->get('/api/webhooks/meta', [\App\Controllers\MetaWebhookController::class, 'verify']); $router->post('/api/webhooks/meta', [\App\Controllers\MetaWebhookController::class, 'webhook']); +// WhatsApp Cloud API (Meta Official) Routes +$router->get('/api/cloud/status', [\App\Controllers\CloudApiController::class, 'status'], [\App\Middlewares\AuthMiddleware::class]); +$router->post('/api/cloud/connect', [\App\Controllers\CloudApiController::class, 'connect'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]); +$router->post('/api/cloud/disconnect', [\App\Controllers\CloudApiController::class, 'disconnect'], [\App\Middlewares\AuthMiddleware::class]); +$router->post('/api/cloud/templates/sync', [\App\Controllers\CloudApiController::class, 'syncTemplates'],[\App\Middlewares\AuthMiddleware::class]); +$router->get('/api/cloud/webhook', [\App\Controllers\CloudApiController::class, 'webhook']); // GET verify +$router->post('/api/cloud/webhook', [\App\Controllers\CloudApiController::class, 'webhook']); // POST incoming messages + // Customer Service Agents (Staff) Routes $router->get('/api/staff', [\App\Controllers\StaffController::class, 'index'], [\App\Middlewares\AuthMiddleware::class]); $router->post('/api/staff', [\App\Controllers\StaffController::class, 'store'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]); diff --git a/whatsapp-gateway/cloud-api-client.js b/whatsapp-gateway/cloud-api-client.js new file mode 100644 index 0000000..55546ff --- /dev/null +++ b/whatsapp-gateway/cloud-api-client.js @@ -0,0 +1,414 @@ +/** + * 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;