Files
nabeh/backend/app/Controllers/WhatsAppController.php
2026-05-22 00:54:36 +03:00

384 lines
16 KiB
PHP

<?php
namespace App\Controllers;
use App\Core\Request;
use App\Core\Response;
use App\Models\WhatsAppSession;
/**
* Handles WhatsApp Session Management and communicates with Baileys Node.js Gateway
*/
class WhatsAppController extends BaseController
{
/**
* Get the current WhatsApp connection status for the company
*/
public function status(Request $request, Response $response)
{
$companyId = $request->company_id; // Added by AuthMiddleware
$session = WhatsAppSession::findOrCreate($companyId);
// Auto-heal logic: Check if the session is active in the Node.js gateway
if ($session['status'] === 'connected' && !empty($session['session_key'])) {
$gatewayUrl = rtrim(getenv('WHATSAPP_GATEWAY_URL') ?: 'http://localhost:3722', '/');
if (substr($gatewayUrl, -4) === '/api') {
$activeUrl = substr($gatewayUrl, 0, -4) . '/api/sessions/active';
} else {
$activeUrl = $gatewayUrl . '/api/sessions/active';
}
$ch = curl_init($activeUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 3);
$res = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$isActive = false;
if ($httpCode === 200 && !empty($res)) {
$resData = json_decode($res, true);
if (isset($resData['active_sessions']) && is_array($resData['active_sessions'])) {
if (in_array($session['session_key'], $resData['active_sessions'])) {
$isActive = true;
}
}
}
// If the DB says connected but the Gateway does not have it in memory, trigger auto-reconnect
if (!$isActive) {
error_log("[WhatsApp Auto-Heal] Session " . $session['session_key'] . " is not active in Gateway memory. Reconnecting...");
$appUrl = rtrim(getenv('APP_URL') ?: 'https://nabeh.intaleqapp.com', '/');
if (substr($gatewayUrl, -4) === '/api') {
$startUrl = substr($gatewayUrl, 0, -4) . '/api/sessions/start';
} else {
$startUrl = $gatewayUrl . '/api/sessions/start';
}
$payload = json_encode([
'session_key' => $session['session_key'],
'webhook_url' => $appUrl . '/api/whatsapp/webhook'
]);
$ch = curl_init($startUrl);
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, 5);
curl_exec($ch);
curl_close($ch);
}
}
// Strip sensitive/internal data before sending to frontend
unset($session['phone_hash']);
$response->json([
'status' => 'success',
'data' => $session
]);
}
/**
* Request a new connection/QR code from the Baileys service
*/
public function requestQr(Request $request, Response $response)
{
$companyId = $request->company_id;
$session = WhatsAppSession::findOrCreate($companyId);
// Temporarily set to connecting
WhatsAppSession::updateState($session['id'], ['status' => 'connecting']);
// Call Baileys Node.js Service on port 3722
$appUrl = rtrim(getenv('APP_URL') ?: 'https://nabeh.intaleqapp.com', '/');
$nodeUrl = 'http://127.0.0.1:3722/api/sessions/start';
$payload = json_encode([
'session_key' => $session['session_key'],
'webhook_url' => $appUrl . '/api/whatsapp/webhook'
]);
$ch = curl_init($nodeUrl);
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, 5);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// Note: Even if it fails immediately, the webhook will try to correct the state
if ($httpCode >= 200 && $httpCode < 300) {
$response->json([
'status' => 'success',
'message' => 'Connection requested. Please poll status to get QR code.'
]);
} else {
// Revert state on failure
WhatsAppSession::updateState($session['id'], ['status' => 'disconnected']);
$response->status(500)->json([
'status' => 'error',
'message' => 'Failed to reach WhatsApp Gateway.'
]);
}
}
/**
* Disconnect the current WhatsApp session
*/
public function disconnect(Request $request, Response $response)
{
$companyId = $request->company_id;
$session = WhatsAppSession::findByCompany($companyId);
if ($session && $session['status'] !== 'disconnected') {
// Call Baileys Node.js Service to disconnect
$nodeUrl = 'http://127.0.0.1:3722/api/sessions/disconnect';
$payload = json_encode(['session_key' => $session['session_key']]);
$ch = curl_init($nodeUrl);
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, 5);
curl_exec($ch);
curl_close($ch);
WhatsAppSession::updateState($session['id'], [
'status' => 'disconnected',
'qr_code' => null,
'phone' => null,
'phone_hash' => null
]);
}
$response->json(['status' => 'success', 'message' => 'Session disconnected']);
}
/**
* Webhook called by Baileys Node.js server to sync state
*/
public function webhook(Request $request, Response $response)
{
// Internal Security Check
$secret = $request->getHeader('X-Webhook-Secret');
if ($secret !== getenv('WEBHOOK_SECRET')) {
$response->status(403)->json(['error' => 'Unauthorized webhook access']);
return;
}
$body = $request->getBody();
if (empty($body['session_key']) || empty($body['state'])) {
$response->status(400)->json(['error' => 'Missing session_key or state']);
return;
}
$session = WhatsAppSession::findBySessionKey($body['session_key']);
if (!$session) {
$response->status(404)->json(['error' => 'Session not found']);
return;
}
// Handle message received events
if ($body['state'] === 'message_received') {
if (empty($body['message'])) {
$response->status(400)->json(['error' => 'Missing message payload']);
return;
}
$msgData = $body['message'];
// 1. Find or create the contact in the CRM
$contact = \App\Models\Contact::findByPhone($session['company_id'], $msgData['phone']);
if (!$contact) {
// Determine a fallback name
$contactName = !empty($msgData['name']) ? $msgData['name'] : 'WA-' . substr($msgData['phone'], -4);
\App\Models\Contact::createSecure([
'company_id' => $session['company_id'],
'name' => $contactName,
'phone' => $msgData['phone'],
'notes' => 'Auto-created via incoming WhatsApp message'
]);
}
// 2. Log the incoming message in history log
\App\Models\MessageLog::logMessage([
'company_id' => $session['company_id'],
'session_id' => $session['id'],
'contact_phone' => $msgData['phone'],
'direction' => 'inbound',
'message_type' => 'text',
'message_body' => $msgData['body'],
'whatsapp_message_id' => $msgData['id'],
'status' => 'read'
]);
// 3. Placeholder for Phase 5 Gemini AI auto-reply
if (function_exists('fastcgi_finish_request')) {
$response->status(200);
$response->setHeader('Content-Type', 'application/json; charset=utf-8');
$allowedOrigin = getenv('ALLOWED_ORIGIN') ?: '*';
$response->setHeader('Access-Control-Allow-Origin', $allowedOrigin);
$response->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
$response->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
$response->sendHeaders();
http_response_code(200);
echo json_encode([
'status' => 'success',
'message' => 'Incoming message logged and auto-reply queued'
], JSON_UNESCAPED_UNICODE);
fastcgi_finish_request();
$this->triggerAutoReply($session, $msgData);
exit;
} else {
// Fallback for environment without PHP-FPM
$this->triggerAutoReply($session, $msgData);
$response->json(['status' => 'success', 'message' => 'Incoming message logged']);
return;
}
}
// Handle connection state sync
$updateData = [
'status' => $body['state'] // 'waiting_qr', 'connected', 'disconnected'
];
if ($body['state'] === 'waiting_qr' && !empty($body['qr_code'])) {
$updateData['qr_code'] = $body['qr_code'];
} elseif ($body['state'] === 'connected') {
$updateData['qr_code'] = null; // Clear QR when connected
if (!empty($body['phone'])) {
$updateData['phone'] = $body['phone'];
}
} elseif ($body['state'] === 'disconnected') {
$updateData['qr_code'] = null;
}
WhatsAppSession::updateState($session['id'], $updateData);
$response->json(['status' => 'success']);
}
/**
* Trigger Gemini AI Auto-Replies or Keyword rules (Phase 5)
*/
private function triggerAutoReply(array $session, array $msgData)
{
try {
$rule = \App\Models\ChatbotRule::findActiveForRule($session['company_id'], $session['id']);
if (!$rule || !$rule['is_active']) {
return;
}
$incomingText = trim($msgData['body']);
if (empty($incomingText)) {
return;
}
$replyText = null;
if ($rule['trigger_type'] === 'keyword') {
$keywords = array_filter(array_map('trim', explode(',', $rule['keyword'])));
$matched = false;
foreach ($keywords as $kw) {
if (mb_stripos($incomingText, $kw) !== false) {
$matched = true;
break;
}
}
if ($matched) {
$replyText = $rule['ai_prompt']; // Under keyword rules, ai_prompt stores the predefined static reply
}
} elseif ($rule['trigger_type'] === 'gemini_ai') {
$apiKey = $rule['gemini_api_key'] ?: getenv('GEMINI_API_KEY');
if (empty($apiKey)) {
error_log("[Chatbot Warning] Gemini API Key is not set globally or for company " . $session['company_id']);
return;
}
$systemPrompt = $rule['ai_prompt'] ?: 'You are a helpful customer support assistant.';
// Enforce language matching rule dynamically
$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 = \App\Services\GeminiService::generateResponse($apiKey, $systemPrompt, $incomingText);
}
if (!empty($replyText)) {
// Send reply back to the contact via Node.js Gateway
$gatewayUrl = rtrim(getenv('WHATSAPP_GATEWAY_URL') ?: 'http://localhost:3722', '/');
if (substr($gatewayUrl, -4) === '/api') {
$sendUrl = $gatewayUrl . '/messages/send';
} else {
$sendUrl = $gatewayUrl . '/api/messages/send';
}
$payload = json_encode([
'session_key' => $session['session_key'],
'phone' => $msgData['phone'],
'message' => $replyText
]);
$ch = curl_init($sendUrl);
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, 10);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$status = 'failed';
$errorMsg = null;
$waMsgId = null;
if ($httpCode === 200) {
$status = 'sent';
$resData = json_decode($response, true);
$waMsgId = $resData['data']['key']['id'] ?? null;
} else {
$resData = json_decode($response, true);
$errorMsg = $resData['error'] ?? 'HTTP Code ' . $httpCode;
error_log("[Chatbot Gateway Error] failed to send auto-reply: " . $errorMsg);
}
// Log the outbound auto-reply message
\App\Models\MessageLog::logMessage([
'company_id' => $session['company_id'],
'session_id' => $session['id'],
'contact_phone' => $msgData['phone'],
'direction' => 'outbound',
'message_type' => 'text',
'message_body' => $replyText,
'whatsapp_message_id' => $waMsgId,
'status' => $status,
'error_message' => $errorMsg
]);
}
} catch (\Exception $e) {
error_log("[Chatbot Exception] Error: " . $e->getMessage() . " in " . $e->getFile() . ":" . $e->getLine());
}
}
}