Deploy: 2026-07-15 05:08:07

This commit is contained in:
Hamza-Ayed
2026-07-15 05:08:07 +03:00
parent 3de7764cdd
commit 66b2c0eaa9
5 changed files with 834 additions and 0 deletions
@@ -0,0 +1,235 @@
<?php
namespace App\Controllers;
use App\Core\Request;
use App\Core\Response;
use App\Models\Company;
/**
* Handles WhatsApp Cloud API (Meta Official) routes
*/
class CloudApiController extends BaseController
{
/**
* Get the Cloud API connection status for the company
*/
public function status(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) {
$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');
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
if (php_sapi_name() !== 'cli') {
http_response_code(403);
exit('Access denied.');
}
require_once __DIR__ . '/app/bootstrap.php';
use App\Core\Database;
try {
$pdo = Database::getConnection();
echo "=== Running Database Migrations: WhatsApp Cloud API Integration ===\n";
// 1. Create meta_cloud_sessions table
$createSessionsTableSql = "
CREATE TABLE IF NOT EXISTS `meta_cloud_sessions` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`company_id` INT NOT NULL,
`phone_number_id` VARCHAR(50) NOT NULL COMMENT 'Meta Phone Number ID',
`waba_id` VARCHAR(50) NOT NULL COMMENT 'WhatsApp Business Account ID',
`access_token` VARCHAR(1024) NOT NULL COMMENT 'Encrypted Meta Access Token',
`display_phone` VARCHAR(20) DEFAULT NULL COMMENT 'Display phone number',
`status` ENUM('active', 'inactive', 'pending') DEFAULT 'pending',
`verify_token` VARCHAR(100) DEFAULT NULL COMMENT 'Webhook verification token',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE,
INDEX `idx_meta_company` (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
";
Database::execute($createSessionsTableSql);
echo "✅ Table 'meta_cloud_sessions' verified/created.\n";
// 2. Create message_templates table
$createTemplatesTableSql = "
CREATE TABLE IF NOT EXISTS `message_templates` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`company_id` INT NOT NULL,
`meta_template_id` VARCHAR(100) DEFAULT NULL,
`name` VARCHAR(255) NOT NULL,
`language` VARCHAR(10) DEFAULT 'ar',
`category` ENUM('marketing', 'utility', 'authentication') DEFAULT 'utility',
`header_type` ENUM('none', 'text', 'image', 'video', 'document') DEFAULT 'none',
`header_content` TEXT DEFAULT NULL,
`body_text` TEXT NOT NULL,
`footer_text` VARCHAR(60) DEFAULT NULL,
`buttons` JSON DEFAULT NULL,
`status` ENUM('draft', 'pending', 'approved', 'rejected') DEFAULT 'draft',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE,
INDEX `idx_template_company` (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
";
Database::execute($createTemplatesTableSql);
echo "✅ Table 'message_templates' verified/created.\n";
echo "Migration completed successfully!\n";
} catch (\Exception $e) {
echo "❌ Migration error: " . $e->getMessage() . "\n";
}
+115
View File
@@ -901,6 +901,7 @@
<!-- Sub-tabs for Channels -->
<div style="display: flex; gap: 1rem; border-bottom: 1px solid var(--card-border); margin-bottom: 1.5rem;" :style="lang === 'ar' ? 'flex-direction: row-reverse' : ''">
<button class="tab-btn" :class="{ 'active': channelTab === 'whatsapp' }" @click="channelTab = 'whatsapp'" x-text="lang === 'ar' ? 'واتساب' : 'WhatsApp'"></button>
<button class="tab-btn" :class="{ 'active': channelTab === 'whatsapp_cloud' }" @click="channelTab = 'whatsapp_cloud'; fetchCloudSession()" x-text="lang === 'ar' ? 'واتساب بزنس API (رسمي)' : 'WhatsApp Business API'"></button>
<button class="tab-btn" :class="{ 'active': channelTab === 'messenger' }" @click="channelTab = 'messenger'; fetchMetaSessions()" x-text="lang === 'ar' ? 'فيسبوك ماسنجر' : 'Facebook Messenger'"></button>
<button class="tab-btn" :class="{ 'active': channelTab === 'instagram' }" @click="channelTab = 'instagram'; fetchMetaSessions()" x-text="lang === 'ar' ? 'إنستغرام' : 'Instagram'"></button>
</div>
@@ -978,6 +979,52 @@
</template>
</div>
<!-- WhatsApp Cloud API Tab Content -->
<div x-show="channelTab === 'whatsapp_cloud'">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;" :style="lang === 'ar' ? 'flex-direction: row-reverse' : ''">
<h2 style="font-size: 1.4rem; margin: 0;" x-text="lang === 'ar' ? 'واتساب بزنس API (الربط الرسمي بـ Meta)' : 'WhatsApp Business API Integration'"></h2>
</div>
<p class="text-muted" style="margin-bottom: 1.5rem; font-size: 0.9rem;" x-text="lang === 'ar' ? 'اربط حساب شركتك الرسمي مع Meta مباشرة. هذا الخيار مثالي لإرسال حملات دعائية ضخمة وقوالب معتمدة بدون خطر حظر الرقم.' : 'Connect your official WhatsApp Business account with Meta. Best for massive broadcast campaigns using approved templates without ban risks.'"></p>
<div class="card" style="margin-bottom: 2rem;">
<template x-if="!cloudSession">
<div style="display: flex; flex-direction: column; gap: 1.5rem;" :style="lang === 'ar' ? 'align-items: flex-end;' : 'align-items: flex-start;'">
<div style="width: 100%; max-width: 500px;">
<label class="form-label" x-text="lang === 'ar' ? 'رقم الهاتف (Phone Number ID)' : 'Phone Number ID'"></label>
<input type="text" x-model="newCloudSession.phone_number_id" class="form-input" placeholder="e.g. 123456789012345">
</div>
<div style="width: 100%; max-width: 500px;">
<label class="form-label" x-text="lang === 'ar' ? 'رقم حساب الأعمال (WABA ID)' : 'WhatsApp Business Account ID'"></label>
<input type="text" x-model="newCloudSession.waba_id" class="form-input" placeholder="e.g. 987654321098765">
</div>
<div style="width: 100%; max-width: 500px;">
<label class="form-label" x-text="lang === 'ar' ? 'رمز الوصول الدائم (Access Token)' : 'Permanent Access Token'"></label>
<input type="password" x-model="newCloudSession.access_token" class="form-input" placeholder="EA...XXXX">
</div>
<button @click="connectCloudSession()" class="btn btn-primary" :disabled="actionLoading" style="align-self: flex-start;">
<span x-text="lang === 'ar' ? 'ربط حساب Meta' : 'Connect Meta Account'"></span>
</button>
</div>
</template>
<template x-if="cloudSession">
<div style="display: flex; flex-direction: column; gap: 1rem;" :style="lang === 'ar' ? 'align-items: flex-end;' : 'align-items: flex-start;'">
<div style="display: flex; align-items: center; gap: 1rem;">
<div style="font-size: 2rem; color: var(--success);">✓</div>
<div>
<h3 style="margin: 0; color: var(--success);" x-text="lang === 'ar' ? 'الحساب مربوط بنجاح' : 'Account Connected Successfully'"></h3>
<p class="text-muted" style="margin: 0.5rem 0 0 0;" x-text="lang === 'ar' ? 'رقم الهاتف ID: ' + cloudSession.phone_number_id : 'Phone ID: ' + cloudSession.phone_number_id"></p>
</div>
</div>
<div style="display: flex; gap: 1rem; margin-top: 1rem;">
<button @click="disconnectCloudSession()" class="btn btn-danger" :disabled="actionLoading">
<span x-text="lang === 'ar' ? 'قطع الاتصال وإزالة الحساب' : 'Disconnect Account'"></span>
</button>
</div>
</div>
</template>
</div>
</div>
<div class="grid-two" style="align-items: start;">
<!-- QR Display Card (Active selected session) -->
<div class="card" style="margin: 0;" x-show="whatsappSession && (whatsappSession.status === 'connecting' || whatsappSession.status === 'waiting_qr' || whatsappSession.status === 'connected')">
@@ -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;
+8
View File
@@ -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]);
+414
View File
@@ -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;