Deploy: 2026-07-15 05:08:07
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user