Deploy: 2026-05-25 00:29:42
This commit is contained in:
285
backend/app/Controllers/MetaWebhookController.php
Normal file
285
backend/app/Controllers/MetaWebhookController.php
Normal file
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Models\MetaSession;
|
||||
use App\Models\MessageLog;
|
||||
use App\Models\Contact;
|
||||
use App\Models\ChatbotRule;
|
||||
use App\Services\MetaService;
|
||||
use App\Services\GeminiService;
|
||||
|
||||
class MetaWebhookController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Verify Meta Webhook Token (GET /api/webhooks/meta)
|
||||
*/
|
||||
public function verify(Request $request, Response $response)
|
||||
{
|
||||
$mode = $_GET['hub_mode'] ?? $_GET['hub.mode'] ?? '';
|
||||
$token = $_GET['hub_verify_token'] ?? $_GET['hub.verify_token'] ?? '';
|
||||
$challenge = $_GET['hub_challenge'] ?? $_GET['hub.challenge'] ?? '';
|
||||
|
||||
$verifyToken = getenv('META_WEBHOOK_VERIFY_TOKEN') ?: 'nabeh_meta_secret_token';
|
||||
|
||||
if ($mode === 'subscribe' && $token === $verifyToken) {
|
||||
$response->status(200)->setHeader('Content-Type', 'text/plain');
|
||||
echo $challenge;
|
||||
exit;
|
||||
} else {
|
||||
$response->status(403)->json(['error' => 'Verification failed']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming Meta Webhook events (POST /api/webhooks/meta)
|
||||
*/
|
||||
public function webhook(Request $request, Response $response)
|
||||
{
|
||||
$rawPayload = file_get_contents('php://input');
|
||||
$payload = json_decode($rawPayload, true);
|
||||
|
||||
if (!$payload) {
|
||||
$response->status(400)->json(['error' => 'Invalid JSON payload']);
|
||||
return;
|
||||
}
|
||||
|
||||
$object = $payload['object'] ?? '';
|
||||
if ($object !== 'page' && $object !== 'instagram') {
|
||||
$response->status(200)->json(['status' => 'ignored', 'reason' => 'unsupported object type']);
|
||||
return;
|
||||
}
|
||||
|
||||
$channelType = ($object === 'page') ? 'messenger' : 'instagram';
|
||||
$entries = $payload['entry'] ?? [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$pageId = $entry['id'] ?? '';
|
||||
$messaging = $entry['messaging'] ?? [];
|
||||
|
||||
$session = MetaSession::findByPageId($pageId, $channelType);
|
||||
if (!$session || $session['status'] !== 'connected') {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($messaging as $messageEvent) {
|
||||
$senderId = $messageEvent['sender']['id'] ?? '';
|
||||
$message = $messageEvent['message'] ?? [];
|
||||
|
||||
if (empty($senderId) || empty($message)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deduplicate incoming messages
|
||||
$mid = $message['mid'] ?? '';
|
||||
if (!empty($mid)) {
|
||||
$alreadyLogged = \App\Core\Database::selectOne(
|
||||
"SELECT id FROM messages_log WHERE whatsapp_message_id = ? LIMIT 1",
|
||||
[$mid]
|
||||
);
|
||||
if ($alreadyLogged) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$text = $message['text'] ?? '';
|
||||
if (empty($text)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1. Find or create CRM Contact
|
||||
$contact = Contact::findByPhone($session['company_id'], $senderId);
|
||||
if (!$contact) {
|
||||
$prefix = ($channelType === 'messenger') ? 'FB-' : 'IG-';
|
||||
$contactName = $prefix . substr($senderId, -6);
|
||||
try {
|
||||
Contact::createSecure([
|
||||
'company_id' => $session['company_id'],
|
||||
'name' => $contactName,
|
||||
'phone' => $senderId,
|
||||
'notes' => 'Auto-created via Meta webhook'
|
||||
]);
|
||||
} catch (\PDOException $e) {
|
||||
// Ignore duplicate contact error if created concurrently
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Log inbound message in history log
|
||||
MessageLog::logMessage([
|
||||
'company_id' => $session['company_id'],
|
||||
'session_id' => null,
|
||||
'meta_session_id' => $session['id'],
|
||||
'contact_phone' => $senderId,
|
||||
'direction' => 'inbound',
|
||||
'message_type' => 'text',
|
||||
'message_body' => $text,
|
||||
'whatsapp_message_id' => $mid,
|
||||
'status' => 'read'
|
||||
]);
|
||||
|
||||
// 3. Trigger chatbot rules auto-reply in background
|
||||
$this->triggerMetaAutoReply($session, $senderId, $text);
|
||||
}
|
||||
}
|
||||
|
||||
$response->json(['status' => 'success']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger auto-reply based on keyword rules or Gemini AI
|
||||
*/
|
||||
private function triggerMetaAutoReply(array $session, string $senderId, string $incomingText)
|
||||
{
|
||||
$companyId = (int)$session['company_id'];
|
||||
|
||||
$rule = ChatbotRule::findActiveForRule($companyId);
|
||||
if (!$rule || !$rule['is_active']) {
|
||||
return;
|
||||
}
|
||||
|
||||
$replyText = '';
|
||||
|
||||
if ($rule['trigger_type'] === 'keyword') {
|
||||
$keywords = array_map('trim', explode(',', strtolower($rule['keyword'])));
|
||||
$incomingLower = strtolower(trim($incomingText));
|
||||
$matched = false;
|
||||
foreach ($keywords as $kw) {
|
||||
if ($kw !== '' && strpos($incomingLower, $kw) !== false) {
|
||||
$matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($matched) {
|
||||
$replyText = $rule['ai_prompt'];
|
||||
}
|
||||
} elseif ($rule['trigger_type'] === 'gemini_ai') {
|
||||
$configuredGeminiKey = !empty($rule['gemini_api_key']) ? $rule['gemini_api_key'] : null;
|
||||
$apiKey = GeminiService::getGeminiApiKey($configuredGeminiKey);
|
||||
|
||||
if (empty($apiKey)) {
|
||||
error_log("[Meta Chatbot Warning] Gemini API Key is not set.");
|
||||
return;
|
||||
}
|
||||
|
||||
$systemPrompt = $rule['ai_prompt'] ?: 'You are a helpful customer support assistant.';
|
||||
|
||||
// Enforce language matching rule
|
||||
$systemPrompt .= "\n\nIMPORTANT LANGUAGE RULE: Detect the language of the incoming message. If the incoming message is in English, you MUST reply in English. If the incoming message is in Arabic, you MUST reply in Arabic. Override any default language instruction to match the user's language.";
|
||||
|
||||
$replyText = GeminiService::generateResponse($apiKey, $systemPrompt, $incomingText);
|
||||
}
|
||||
|
||||
if (!empty($replyText)) {
|
||||
$success = MetaService::sendMessage($session['page_access_token'], $senderId, $replyText);
|
||||
|
||||
if ($success) {
|
||||
MessageLog::logMessage([
|
||||
'company_id' => $companyId,
|
||||
'session_id' => null,
|
||||
'meta_session_id' => $session['id'],
|
||||
'contact_phone' => $senderId,
|
||||
'direction' => 'outbound',
|
||||
'message_type' => 'text',
|
||||
'message_body' => $replyText,
|
||||
'status' => 'sent'
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Meta connection status for current company (GET /api/meta/sessions)
|
||||
*/
|
||||
public function listSessions(Request $request, Response $response)
|
||||
{
|
||||
$sessions = MetaSession::findAllByCompany($request->company_id);
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'data' => $sessions
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect Facebook page or Instagram profile (POST /api/meta/sessions/connect)
|
||||
*/
|
||||
public function connectSession(Request $request, Response $response)
|
||||
{
|
||||
$body = $request->getBody();
|
||||
$channelType = $body['channel_type'] ?? '';
|
||||
$pageId = $body['page_id'] ?? '';
|
||||
$pageName = $body['page_name'] ?? '';
|
||||
$pageAccessToken = $body['page_access_token'] ?? '';
|
||||
|
||||
if (empty($channelType) || empty($pageId) || empty($pageName) || empty($pageAccessToken)) {
|
||||
$response->status(400)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Missing channel_type, page_id, page_name, or page_access_token'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($channelType !== 'messenger' && $channelType !== 'instagram') {
|
||||
$response->status(400)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Invalid channel_type'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$id = MetaSession::connectSession(
|
||||
$request->company_id,
|
||||
$channelType,
|
||||
$pageId,
|
||||
$pageName,
|
||||
$pageAccessToken
|
||||
);
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'message' => 'Meta channel connected successfully',
|
||||
'session_id' => $id
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$response->status(500)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Failed to connect: ' . $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect Meta session (DELETE /api/meta/sessions)
|
||||
*/
|
||||
public function deleteSession(Request $request, Response $response)
|
||||
{
|
||||
$body = $request->getBody();
|
||||
$sessionId = $body['session_id'] ?? null;
|
||||
|
||||
if (!$sessionId) {
|
||||
$response->status(400)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Missing session_id'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$session = MetaSession::find($sessionId);
|
||||
if (!$session || (int)$session['company_id'] !== (int)$request->company_id) {
|
||||
$response->status(404)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Session not found'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
MetaSession::delete($sessionId);
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'message' => 'Meta channel disconnected successfully'
|
||||
]);
|
||||
}
|
||||
}
|
||||
81
backend/app/Models/MetaSession.php
Normal file
81
backend/app/Models/MetaSession.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Core\Security;
|
||||
use App\Core\Database;
|
||||
|
||||
/**
|
||||
* MetaSession Model
|
||||
* Handles the meta_sessions table with encryption for page_access_token.
|
||||
*/
|
||||
class MetaSession extends BaseModel
|
||||
{
|
||||
protected static string $table = 'meta_sessions';
|
||||
|
||||
/**
|
||||
* Get all connected Meta sessions for a company
|
||||
*/
|
||||
public static function findAllByCompany(int $companyId): array
|
||||
{
|
||||
$sessions = Database::select(
|
||||
"SELECT * FROM " . static::$table . " WHERE company_id = ? ORDER BY id ASC",
|
||||
[$companyId]
|
||||
);
|
||||
|
||||
foreach ($sessions as &$session) {
|
||||
$session['page_access_token'] = $session['page_access_token'] ? Security::decrypt($session['page_access_token']) : '';
|
||||
}
|
||||
|
||||
return $sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a specific session by page_id and channel_type (messenger/instagram)
|
||||
*/
|
||||
public static function findByPageId(string $pageId, string $channelType)
|
||||
{
|
||||
$session = Database::selectOne(
|
||||
"SELECT * FROM " . static::$table . " WHERE page_id = ? AND channel_type = ? LIMIT 1",
|
||||
[$pageId, $channelType]
|
||||
);
|
||||
|
||||
if ($session) {
|
||||
$session['page_access_token'] = $session['page_access_token'] ? Security::decrypt($session['page_access_token']) : '';
|
||||
}
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save/Connect Meta session
|
||||
*/
|
||||
public static function connectSession(int $companyId, string $channelType, string $pageId, string $pageName, string $pageAccessToken): int
|
||||
{
|
||||
$existing = Database::selectOne(
|
||||
"SELECT id FROM " . static::$table . " WHERE page_id = ? AND channel_type = ? LIMIT 1",
|
||||
[$pageId, $channelType]
|
||||
);
|
||||
|
||||
$encryptedToken = Security::encrypt($pageAccessToken);
|
||||
|
||||
if ($existing) {
|
||||
static::update($existing['id'], [
|
||||
'company_id' => $companyId,
|
||||
'page_name' => $pageName,
|
||||
'page_access_token' => $encryptedToken,
|
||||
'status' => 'connected'
|
||||
]);
|
||||
return (int)$existing['id'];
|
||||
} else {
|
||||
return (int)static::create([
|
||||
'company_id' => $companyId,
|
||||
'channel_type' => $channelType,
|
||||
'page_id' => $pageId,
|
||||
'page_name' => $pageName,
|
||||
'page_access_token' => $encryptedToken,
|
||||
'status' => 'connected'
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
44
backend/app/Services/MetaService.php
Normal file
44
backend/app/Services/MetaService.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/**
|
||||
* Meta Service
|
||||
* Wraps communication with Facebook Graph API for Messenger and Instagram Business.
|
||||
*/
|
||||
class MetaService
|
||||
{
|
||||
/**
|
||||
* Send a text message response to Meta Graph API
|
||||
*/
|
||||
public static function sendMessage(string $pageAccessToken, string $recipientPsid, string $messageText): bool
|
||||
{
|
||||
$url = "https://graph.facebook.com/v20.0/me/messages?access_token=" . urlencode($pageAccessToken);
|
||||
|
||||
$payload = json_encode([
|
||||
'recipient' => ['id' => $recipientPsid],
|
||||
'messaging_type' => 'RESPONSE',
|
||||
'message' => ['text' => $messageText]
|
||||
]);
|
||||
|
||||
$ch = curl_init($url);
|
||||
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'
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
error_log("[MetaService Error] Failed to send message. HTTP Code: " . $httpCode . ", Response: " . $response);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
54
backend/migrate_meta_sessions.php
Normal file
54
backend/migrate_meta_sessions.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
require 'vendor/autoload.php';
|
||||
|
||||
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
|
||||
$dotenv->safeLoad();
|
||||
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
"mysql:host=" . $_ENV['DB_HOST'] . ";dbname=" . $_ENV['DB_NAME'],
|
||||
$_ENV['DB_USER'],
|
||||
$_ENV['DB_PASS']
|
||||
);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
echo "=== Running Database Migrations: Meta Channel Integration ===\n";
|
||||
|
||||
// 1. Create meta_sessions table
|
||||
$createSessionsTableSql = "
|
||||
CREATE TABLE IF NOT EXISTS `meta_sessions` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`company_id` INT NOT NULL,
|
||||
`channel_type` ENUM('messenger', 'instagram') NOT NULL,
|
||||
`page_id` VARCHAR(255) NOT NULL,
|
||||
`page_name` VARCHAR(255) NOT NULL,
|
||||
`page_access_token` TEXT NOT NULL,
|
||||
`status` ENUM('connected', 'disconnected') DEFAULT 'connected',
|
||||
`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,
|
||||
UNIQUE KEY `page_channel_unique` (`page_id`, `channel_type`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
";
|
||||
$pdo->exec($createSessionsTableSql);
|
||||
echo "✅ Table 'meta_sessions' verified/created.\n";
|
||||
|
||||
// 2. Make session_id column in messages_log nullable
|
||||
// First check current column definition or just modify it
|
||||
$pdo->exec("ALTER TABLE `messages_log` MODIFY COLUMN `session_id` INT NULL");
|
||||
echo "✅ Modified 'session_id' in 'messages_log' to be nullable.\n";
|
||||
|
||||
// 3. Add meta_session_id column if not exists
|
||||
$result = $pdo->query("SHOW COLUMNS FROM `messages_log` LIKE 'meta_session_id'");
|
||||
if ($result->rowCount() === 0) {
|
||||
$pdo->exec("ALTER TABLE `messages_log` ADD COLUMN `meta_session_id` INT NULL AFTER `session_id`");
|
||||
$pdo->exec("ALTER TABLE `messages_log` ADD CONSTRAINT `fk_msg_meta_session` FOREIGN KEY (`meta_session_id`) REFERENCES `meta_sessions`(`id`) ON DELETE CASCADE");
|
||||
echo "✅ Added 'meta_session_id' column and foreign key constraint to 'messages_log'.\n";
|
||||
} else {
|
||||
echo "ℹ️ Column 'meta_session_id' already exists in 'messages_log'. Skipping.\n";
|
||||
}
|
||||
|
||||
echo "Migration completed successfully!\n";
|
||||
} catch (PDOException $e) {
|
||||
echo "❌ Database error: " . $e->getMessage() . "\n";
|
||||
}
|
||||
@@ -96,6 +96,13 @@ $router->post('/api/whatsapp/sessions', [\App\Controllers\WhatsAppController::
|
||||
$router->delete('/api/whatsapp/sessions', [\App\Controllers\WhatsAppController::class, 'deleteSession'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
$router->post('/api/whatsapp/webhook', [\App\Controllers\WhatsAppController::class, 'webhook']); // No AuthMiddleware (Protected by WEBHOOK_SECRET internally)
|
||||
|
||||
// Meta Channel Integration & Multi-Session Routes
|
||||
$router->get('/api/meta/sessions', [\App\Controllers\MetaWebhookController::class, 'listSessions'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
$router->post('/api/meta/sessions/connect', [\App\Controllers\MetaWebhookController::class, 'connectSession'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);
|
||||
$router->delete('/api/meta/sessions', [\App\Controllers\MetaWebhookController::class, 'deleteSession'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
$router->get('/api/webhooks/meta', [\App\Controllers\MetaWebhookController::class, 'verify']);
|
||||
$router->post('/api/webhooks/meta', [\App\Controllers\MetaWebhookController::class, 'webhook']);
|
||||
|
||||
// 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]);
|
||||
|
||||
Reference in New Issue
Block a user