735 lines
35 KiB
PHP
735 lines
35 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
|
|
$isAudioMsg = !empty($msgData['audio']) && !empty($msgData['mimeType']);
|
|
$isImageMsg = !empty($msgData['image']) && !empty($msgData['imageMimeType']);
|
|
|
|
$msgType = 'text';
|
|
if ($isAudioMsg) {
|
|
$msgType = 'audio';
|
|
} elseif ($isImageMsg) {
|
|
$msgType = 'image';
|
|
}
|
|
|
|
$msgBody = $msgData['body'];
|
|
if ($isAudioMsg) {
|
|
$msgBody = $msgData['body'] ?: '[Voice Note]';
|
|
} elseif ($isImageMsg) {
|
|
$msgBody = $msgData['body'] ?: '[Image]';
|
|
}
|
|
|
|
\App\Models\MessageLog::logMessage([
|
|
'company_id' => $session['company_id'],
|
|
'session_id' => $session['id'],
|
|
'contact_phone' => $msgData['phone'],
|
|
'direction' => 'inbound',
|
|
'message_type' => $msgType,
|
|
'message_body' => $msgBody,
|
|
'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 {
|
|
// Hook the Conversation Flow Engine to intercept messages for active or new flows
|
|
if (\App\Core\Flows\ConversationFlowEngine::processMessage($session, $msgData)) {
|
|
return;
|
|
}
|
|
|
|
$rule = \App\Models\ChatbotRule::findActiveForRule($session['company_id'], $session['id']);
|
|
if (!$rule || !$rule['is_active']) {
|
|
return;
|
|
}
|
|
|
|
$incomingText = isset($msgData['body']) ? trim($msgData['body']) : '';
|
|
$hasAudio = !empty($msgData['audio']) && !empty($msgData['mimeType']);
|
|
$hasImage = !empty($msgData['image']) && !empty($msgData['imageMimeType']);
|
|
|
|
if (empty($incomingText) && !$hasAudio && !$hasImage) {
|
|
return;
|
|
}
|
|
|
|
$replyText = null;
|
|
$replyAudio = null;
|
|
|
|
if ($rule['trigger_type'] === 'keyword') {
|
|
if (empty($incomingText)) {
|
|
return;
|
|
}
|
|
$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;
|
|
}
|
|
|
|
// Dynamically fetch customer/driver info from configured endpoints if set
|
|
$infoContext = "";
|
|
$infoEndpoint = \App\Models\CompanyEndpoint::findByAction($session['company_id'], 'fetch_user_info');
|
|
if ($infoEndpoint && !empty($msgData['phone'])) {
|
|
$infoContext = $this->fetchUserInfoFromEndpoint($infoEndpoint, $msgData['phone']);
|
|
}
|
|
|
|
// Dynamically fetch Salla order context if connected
|
|
$sallaContext = "";
|
|
if (!empty($msgData['phone'])) {
|
|
$sallaContext = $this->fetchSallaOrderContext($session['company_id'], $msgData['phone'], $incomingText);
|
|
}
|
|
|
|
$systemPrompt = $rule['ai_prompt'] ?: 'You are a helpful customer support assistant.';
|
|
|
|
// Append real-time info context to Gemini system prompt
|
|
if (!empty($infoContext)) {
|
|
$systemPrompt .= "\n\n" . $infoContext;
|
|
}
|
|
if (!empty($sallaContext)) {
|
|
$systemPrompt .= "\n\n" . $sallaContext;
|
|
}
|
|
|
|
// 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.";
|
|
|
|
if ($hasAudio) {
|
|
$duration = isset($msgData['duration']) ? intval($msgData['duration']) : null;
|
|
if ($duration !== null && $duration > 90) {
|
|
$replyText = "⚠️ عذراً، التسجيل الصوتي طويل جداً. يرجى إرسال تسجيل صوتي موجز (لا يتجاوز دقيقة واحدة) لتلخيص المشكلة لكي نتمكن من مساعدتك بشكل أفضل.";
|
|
} else {
|
|
$mimeType = $msgData['mimeType'];
|
|
if (strpos($mimeType, ';') !== false) {
|
|
$mimeType = trim(explode(';', $mimeType)[0]);
|
|
}
|
|
// Try generating native audio response first
|
|
$audioResponse = \App\Services\GeminiService::generateAudioResponseFromAudio($apiKey, $systemPrompt, $msgData['audio'], $mimeType);
|
|
if ($audioResponse && !empty($audioResponse['audio'])) {
|
|
$replyAudio = $audioResponse['audio'];
|
|
$replyText = '[صوت من الذكاء الاصطناعي]';
|
|
} else {
|
|
// Fallback to text output from audio
|
|
$replyText = \App\Services\GeminiService::generateResponseFromAudio($apiKey, $systemPrompt, $msgData['audio'], $mimeType);
|
|
}
|
|
}
|
|
} elseif ($hasImage) {
|
|
$mimeType = $msgData['imageMimeType'];
|
|
if (strpos($mimeType, ';') !== false) {
|
|
$mimeType = trim(explode(';', $mimeType)[0]);
|
|
}
|
|
|
|
// Instruct Gemini to identify payment slips and output a specific command format if found
|
|
$imageSystemPrompt = $systemPrompt . "\n\nإرشادات إضافية للصور والوصولات:\nإذا كانت الصورة المرفقة عبارة عن وصل دفع أو إيصال تحويل مالي (مثل زين كاش أو إيداع بنكي)، يرجى استخراج البيانات التالية بدقة بالغة وكتابتها في بداية ردك بصيغة JSON محاطة بـ [PAYMENT_RECEIPT: { ... }] كالتالي:\n[PAYMENT_RECEIPT: {\"transaction_id\": \"رقم المعاملة أو الحوالة هنا\", \"amount\": \"المبلغ المستخرج كأرقام فقط\", \"method\": \"طريقة الدفع مثل Zain Cash أو Bank\"}]\nثم أكمل ردك الطبيعي بالترحيب بالسائق/العميل وإخباره بأنه جاري التحقق من عملية الدفع الآن.";
|
|
|
|
$replyText = \App\Services\GeminiService::generateResponseFromImage($apiKey, $imageSystemPrompt, $msgData['image'], $mimeType);
|
|
} else {
|
|
$replyText = \App\Services\GeminiService::generateResponse($apiKey, $systemPrompt, $incomingText);
|
|
}
|
|
}
|
|
|
|
if (!empty($replyText) || !empty($replyAudio)) {
|
|
// Check if the reply contains [PAYMENT_RECEIPT: { ... }] tag from Gemini
|
|
if (!empty($replyText) && preg_match('/\[PAYMENT_RECEIPT:\s*(\{.*?\})\]/s', $replyText, $matches)) {
|
|
$jsonStr = $matches[1];
|
|
// Strip the tag from the final reply sent to user
|
|
$replyText = trim(str_replace($matches[0], '', $replyText));
|
|
|
|
// Call the payment verification API (passing company_id)
|
|
$verificationResult = $this->verifyPaymentSlip($session['company_id'], $msgData['phone'], $jsonStr);
|
|
if ($verificationResult) {
|
|
$replyText .= "\n\n" . $verificationResult;
|
|
}
|
|
}
|
|
|
|
// 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';
|
|
}
|
|
|
|
$payloadData = [
|
|
'session_key' => $session['session_key'],
|
|
'phone' => $msgData['phone']
|
|
];
|
|
if (!empty($replyAudio)) {
|
|
$payloadData['audio'] = $replyAudio;
|
|
} else {
|
|
$payloadData['message'] = $replyText;
|
|
}
|
|
$payload = json_encode($payloadData);
|
|
|
|
$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' => !empty($replyAudio) ? 'audio' : '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());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Call external API to verify payment slip
|
|
*/
|
|
private function verifyPaymentSlip(int $companyId, string $phone, string $jsonStr): ?string
|
|
{
|
|
try {
|
|
$data = json_decode($jsonStr, true);
|
|
if (!$data) {
|
|
return null;
|
|
}
|
|
|
|
$transactionId = $data['transaction_id'] ?? '';
|
|
$amount = $data['amount'] ?? '';
|
|
$method = $data['method'] ?? '';
|
|
|
|
if (empty($transactionId) || empty($amount)) {
|
|
return null;
|
|
}
|
|
|
|
// Find configured endpoint for verify_payment
|
|
$endpoint = \App\Models\CompanyEndpoint::findByAction($companyId, 'verify_payment');
|
|
$apiUrl = $endpoint ? $endpoint['endpoint_url'] : null;
|
|
|
|
if (empty($apiUrl)) {
|
|
// Fallback to local default mock
|
|
$apiUrl = getenv('ENTALEQ_PAYMENT_API_URL');
|
|
if (empty($apiUrl)) {
|
|
$appUrl = rtrim(getenv('APP_URL') ?: 'https://nabeh.intaleqapp.com', '/');
|
|
$apiUrl = $appUrl . '/api/external/verify-payment';
|
|
}
|
|
}
|
|
|
|
$payload = json_encode([
|
|
'phone' => $phone,
|
|
'transaction_id' => $transactionId,
|
|
'amount' => $amount,
|
|
'method' => $method
|
|
]);
|
|
|
|
$headers = ['Content-Type: application/json'];
|
|
if ($endpoint) {
|
|
if (!empty($endpoint['api_key'])) {
|
|
$headers[] = 'X-API-Key: ' . $endpoint['api_key'];
|
|
$headers[] = 'Authorization: Bearer ' . $endpoint['api_key'];
|
|
}
|
|
if (!empty($endpoint['headers'])) {
|
|
$customHeaders = json_decode($endpoint['headers'], true);
|
|
if (is_array($customHeaders)) {
|
|
foreach ($customHeaders as $key => $value) {
|
|
$headers[] = "$key: $value";
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
$headers[] = 'X-API-Key: ' . (getenv('ENTALEQ_API_KEY') ?: 'mock-key');
|
|
}
|
|
|
|
$ch = curl_init($apiUrl);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_POST, true);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
|
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
|
|
|
$response = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($httpCode !== 200) {
|
|
return "⏳ تم استلام وصل الدفع ويجري التحقق منه حالياً من قبل المحاسب يدوياً. سنقوم بشحن رصيدك وتنبيهك فور انتهاء العملية.";
|
|
}
|
|
|
|
$resData = json_decode($response, true);
|
|
if (isset($resData['status']) && $resData['status'] === 'success') {
|
|
$amtStr = $resData['data']['amount'] ?? $amount;
|
|
return "✅ تم التحقق من وصل الدفع تلقائياً بنجاح!\n• رقم العملية: " . $transactionId . "\n• القيمة: " . $amtStr . " دينار\n• تم تحديث رصيد حسابك بنجاح.";
|
|
} else {
|
|
$reason = $resData['message'] ?? 'العملية مسجلة مسبقاً أو غير صالحة';
|
|
return "⚠️ لم نتمكن من تأكيد العملية تلقائياً:\n• السبب: " . $reason . "\n\nيجري الآن تحويل المعاملة للمراجعة اليدوية من قبل الإدارة وسنقوم بالرد عليك قريباً.";
|
|
}
|
|
} catch (\Exception $e) {
|
|
error_log("[Payment Verification Exception] " . $e->getMessage());
|
|
return "⏳ تم استلام وصل الدفع بنجاح. يجري الآن مراجعته وتدقيقه يدوياً من قبل الإدارة الفنية لتأكيد شحن رصيدك.";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch user/driver info from external API endpoint configured by the company
|
|
*/
|
|
private function fetchUserInfoFromEndpoint(array $endpoint, string $phone): string
|
|
{
|
|
try {
|
|
$apiUrl = $endpoint['endpoint_url'];
|
|
$payload = json_encode([
|
|
'phone' => $phone
|
|
]);
|
|
|
|
$headers = ['Content-Type: application/json'];
|
|
if (!empty($endpoint['api_key'])) {
|
|
$headers[] = 'X-API-Key: ' . $endpoint['api_key'];
|
|
$headers[] = 'Authorization: Bearer ' . $endpoint['api_key'];
|
|
}
|
|
if (!empty($endpoint['headers'])) {
|
|
$customHeaders = json_decode($endpoint['headers'], true);
|
|
if (is_array($customHeaders)) {
|
|
foreach ($customHeaders as $key => $value) {
|
|
$headers[] = "$key: $value";
|
|
}
|
|
}
|
|
}
|
|
|
|
$ch = curl_init($apiUrl);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_POST, true);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
|
curl_setopt($ch, CURLOPT_TIMEOUT, 5); // Short timeout to avoid blocking chatbot flow
|
|
|
|
$response = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($httpCode === 200 && !empty($response)) {
|
|
return "\n\n[معلومات العميل المسترجعة من النظام الخارجي للمؤسسة:\n" . $response . "\nاستخدم هذه المعلومات للإجابة بدقة على أسئلة العميل المتعلقة بحسابه ورصيده وحالته]";
|
|
}
|
|
} catch (\Exception $e) {
|
|
error_log("[Fetch User Info Exception] " . $e->getMessage());
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* Fetch order info context from Salla e-commerce platform for the company
|
|
*/
|
|
private function fetchSallaOrderContext(int $companyId, string $phone, string $incomingText): string
|
|
{
|
|
try {
|
|
$accessToken = \App\Models\SallaMerchant::getOrRefreshAccessToken($companyId);
|
|
if (!$accessToken) {
|
|
return ""; // Salla is not integrated
|
|
}
|
|
|
|
// Standardize customer phone to compare last 9 digits
|
|
$cleanPhone = preg_replace('/\D/', '', $phone);
|
|
if (empty($cleanPhone)) {
|
|
return "";
|
|
}
|
|
|
|
// 1. Check if user is asking about a specific order ID (e.g. sequence of 5-12 digits)
|
|
if (preg_match('/\b(\d{5,12})\b/', $incomingText, $matches)) {
|
|
$orderId = $matches[1];
|
|
|
|
$ch = curl_init("https://api.salla.dev/admin/v2/orders/{$orderId}");
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
|
'Authorization: Bearer ' . $accessToken
|
|
]);
|
|
curl_setopt($ch, CURLOPT_TIMEOUT, 8);
|
|
$response = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($httpCode === 200) {
|
|
$orderRes = json_decode($response, true);
|
|
$order = $orderRes['data'] ?? null;
|
|
if ($order) {
|
|
$orderCustPhone = preg_replace('/\D/', '', $order['customer']['mobile'] ?? $order['customer']['phone'] ?? '');
|
|
// Security check: match last 9 digits of WhatsApp phone to order customer phone
|
|
if (substr($orderCustPhone, -9) === substr($cleanPhone, -9)) {
|
|
$status = $order['status']['name'] ?? 'غير معروف';
|
|
$total = $order['amounts']['total']['amount'] ?? $order['total'] ?? '';
|
|
$currency = $order['amounts']['total']['currency'] ?? 'SAR';
|
|
$courier = $order['shipment']['courier_name'] ?? '';
|
|
$tracking = $order['shipment']['tracking_link'] ?? '';
|
|
$itemsCount = count($order['items'] ?? []);
|
|
|
|
$context = "\n\n[تفاصيل طلب سلة المستعلم عنه للعميل:\n";
|
|
$context .= "- رقم الطلب: {$orderId}\n";
|
|
$context .= "- حالة الطلب الحالية: {$status}\n";
|
|
$context .= "- إجمالي الطلب: {$total} {$currency}\n";
|
|
$context .= "- عدد المنتجات: {$itemsCount}\n";
|
|
if (!empty($courier)) {
|
|
$context .= "- شركة الشحن: {$courier}\n";
|
|
}
|
|
if (!empty($tracking)) {
|
|
$context .= "- رابط تتبع الشحنة: {$tracking}\n";
|
|
}
|
|
$context .= "الرجاء صياغة رد ودود ومختصر باللغة العربية لإخبار العميل بحالة هذا الطلب بالتحديد]";
|
|
return $context;
|
|
} else {
|
|
// Order ID exists but phone mismatch
|
|
return "\n\n[تنبيه أمني للذكاء الاصطناعي: العميل سأل عن الطلب رقم {$orderId} ولكن هذا الطلب مسجل برقم هاتف مختلف في سلة. لحماية الخصوصية والأمان، يمنع منعاً باتاً عرض تفاصيل هذا الطلب له. أخبر العميل بلطف أن رقم الهاتف الحالي لا يتطابق مع رقم الهاتف المسجل في تفاصيل هذا الطلب ولا يمكنك كشف تفاصيله]";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Fetch list of recent orders for the merchant and search by customer phone number
|
|
$ch = curl_init("https://api.salla.dev/admin/v2/orders?page=1");
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
|
'Authorization: Bearer ' . $accessToken
|
|
]);
|
|
curl_setopt($ch, CURLOPT_TIMEOUT, 8);
|
|
$response = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($httpCode === 200) {
|
|
$ordersRes = json_decode($response, true);
|
|
$orders = $ordersRes['data'] ?? [];
|
|
if (is_array($orders)) {
|
|
foreach ($orders as $order) {
|
|
$orderCustPhone = preg_replace('/\D/', '', $order['customer']['mobile'] ?? $order['customer']['phone'] ?? '');
|
|
if (substr($orderCustPhone, -9) === substr($cleanPhone, -9)) {
|
|
// Found the most recent order for this customer
|
|
$orderId = $order['id'] ?? $order['reference_id'] ?? '';
|
|
$status = $order['status']['name'] ?? 'غير معروف';
|
|
$total = $order['amounts']['total']['amount'] ?? $order['total'] ?? '';
|
|
$currency = $order['amounts']['total']['currency'] ?? 'SAR';
|
|
$courier = $order['shipment']['courier_name'] ?? '';
|
|
$tracking = $order['shipment']['tracking_link'] ?? '';
|
|
$itemsCount = count($order['items'] ?? []);
|
|
|
|
$context = "\n\n[آخر طلب للعميل في متجر سلة:\n";
|
|
$context .= "- رقم الطلب: {$orderId}\n";
|
|
$context .= "- حالة الطلب الحالية: {$status}\n";
|
|
$context .= "- إجمالي الطلب: {$total} {$currency}\n";
|
|
$context .= "- عدد المنتجات: {$itemsCount}\n";
|
|
if (!empty($courier)) {
|
|
$context .= "- شركة الشحن: {$courier}\n";
|
|
}
|
|
if (!empty($tracking)) {
|
|
$context .= "- رابط تتبع الشحنة: {$tracking}\n";
|
|
}
|
|
$context .= "الرجاء استخدام هذه التفاصيل للإجابة على استفساره حول حالة طلبه الأخير بدقة وود باللغة العربية]";
|
|
return $context;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return "\n\n[سياق المتجر: العميل متصل بمتجر سلة ولكن لم يتم العثور على أي طلبات سابقة له برقم الهاتف هذا. أخبره بلطف أنه لا توجد طلبات سابقة مسجلة برقم هاتفه الحالي في المتجر، واطلب منه تزويدك برقم الطلب أو البريد الإلكتروني للبحث]";
|
|
|
|
} catch (\Exception $e) {
|
|
error_log("[Fetch Salla Order Exception] " . $e->getMessage());
|
|
}
|
|
return "";
|
|
}
|
|
}
|
|
|