Deploy: 2026-05-25 00:29:42

This commit is contained in:
Hamza-Ayed
2026-05-25 00:29:42 +03:00
parent b20f457eaf
commit 7359206eb3
14 changed files with 1126 additions and 213 deletions

View 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'
]);
}
}

View 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'
]);
}
}
}

View 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;
}
}