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