Deploy: 2026-05-22 23:55:19

This commit is contained in:
Hamza-Ayed
2026-05-22 23:55:19 +03:00
parent 7bf0933efb
commit 4860519f39
15 changed files with 1280 additions and 102 deletions

View File

@@ -342,101 +342,141 @@ class WhatsAppController extends BaseController
$replyAudio = null;
$replyAudioMimeType = null;
if ($rule['trigger_type'] === 'keyword') {
if (empty($incomingText)) {
$companyId = $session['company_id'];
// Limit enforcement for non-admin companies (company 1 is admin/demo)
if ($companyId !== 1) {
$activeSub = \App\Models\CompanySubscription::findActiveByCompany($companyId);
if (!$activeSub) {
error_log("[Chatbot Warning] Company {$companyId} has no active subscription.");
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;
// Check general request limit
if (!\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'request')) {
error_log("[Chatbot Warning] Company {$companyId} has exceeded its general request limit.");
return;
}
// Check voice limit if input is audio
if ($hasAudio && !\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'voice')) {
error_log("[Chatbot Warning] Company {$companyId} has exceeded its voice request limit.");
$replyText = "⚠️ عذراً، لقد استهلك هذا المتجر الحد المسموح له من الرسائل الصوتية لهذا الشهر. يرجى إرسال استفسارك نصياً لكي نتمكن من مساعدتك.";
}
// Check OCR limit if input is image
if ($hasImage && !\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'ocr')) {
error_log("[Chatbot Warning] Company {$companyId} has exceeded its OCR/image request limit.");
$replyText = "⚠️ عذراً، لقد استهلك هذا المتجر الحد المسموح له من تحليل الصور والوصولات لهذا الشهر. يرجى إرسال استفسارك نصياً.";
}
}
if ($replyText === 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') {
$configuredGeminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : null;
$apiKey = \App\Services\GeminiService::getGeminiApiKey($configuredGeminiKey);
if (empty($apiKey)) {
error_log("[Chatbot Warning] Gemini API Key is not set globally or for company " . $session['company_id']);
return;
}
}
if ($matched) {
$replyText = $rule['ai_prompt']; // Under keyword rules, ai_prompt stores the predefined static reply
}
} elseif ($rule['trigger_type'] === 'gemini_ai') {
$configuredGeminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : null;
$apiKey = \App\Services\GeminiService::getGeminiApiKey($configuredGeminiKey);
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 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);
}
// 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'];
// Dynamically fetch WooCommerce order context if connected
$wooContext = "";
if (!empty($msgData['phone'])) {
$wooContext = $this->fetchWooCommerceOrderContext($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;
}
if (!empty($wooContext)) {
$systemPrompt .= "\n\n" . $wooContext;
}
// 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]);
}
$configuredElKey = !empty($rule['elevenlabs_api_key']) ? $rule['elevenlabs_api_key'] : null;
$elApiKey = \App\Services\GeminiService::getElevenLabsApiKey($configuredElKey);
$configuredVoiceId = !empty($rule['elevenlabs_voice_id']) ? $rule['elevenlabs_voice_id'] : null;
$elVoiceId = \App\Services\GeminiService::getElevenLabsVoiceId($configuredVoiceId);
// Try generating native audio response first
$audioResponse = \App\Services\GeminiService::generateAudioResponseFromAudio(
$apiKey,
$systemPrompt,
$msgData['audio'],
$mimeType,
'Puck',
$elApiKey,
$elVoiceId
);
if ($audioResponse && !empty($audioResponse['audio'])) {
$replyAudio = $audioResponse['audio'];
$replyAudioMimeType = $audioResponse['mimeType'] ?? 'audio/mp4';
$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]);
}
$configuredElKey = !empty($rule['elevenlabs_api_key']) ? $rule['elevenlabs_api_key'] : null;
$elApiKey = \App\Services\GeminiService::getElevenLabsApiKey($configuredElKey);
$configuredVoiceId = !empty($rule['elevenlabs_voice_id']) ? $rule['elevenlabs_voice_id'] : null;
$elVoiceId = \App\Services\GeminiService::getElevenLabsVoiceId($configuredVoiceId);
// Try generating native audio response first
$audioResponse = \App\Services\GeminiService::generateAudioResponseFromAudio(
$apiKey,
$systemPrompt,
$msgData['audio'],
$mimeType,
'Puck',
$elApiKey,
$elVoiceId
);
if ($audioResponse && !empty($audioResponse['audio'])) {
$replyAudio = $audioResponse['audio'];
$replyAudioMimeType = $audioResponse['mimeType'] ?? 'audio/mp4';
$replyText = '[صوت من الذكاء الاصطناعي]';
} else {
// Fallback to text output from audio
$replyText = \App\Services\GeminiService::generateResponseFromAudio($apiKey, $systemPrompt, $msgData['audio'], $mimeType);
}
// 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);
}
} 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);
}
}
@@ -504,6 +544,17 @@ class WhatsAppController extends BaseController
error_log("[Chatbot Gateway Error] failed to send auto-reply: " . $errorMsg);
}
// Increment SaaS usage stats if successfully sent
if ($status === 'sent' && $companyId !== 1) {
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'request');
if ($hasAudio || !empty($replyAudio)) {
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'voice');
}
if ($hasImage) {
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'ocr');
}
}
// Log the outbound auto-reply message
\App\Models\MessageLog::logMessage([
'company_id' => $session['company_id'],
@@ -771,5 +822,100 @@ class WhatsAppController extends BaseController
}
return "";
}
/**
* Fetch order info context from WooCommerce store for the company
*/
private function fetchWooCommerceOrderContext(int $companyId, string $phone, string $incomingText): string
{
try {
$store = \App\Models\WooCommerceStore::findByCompany($companyId);
if (!$store) {
return ""; // WooCommerce is not integrated
}
// Standardize customer phone to compare trailing 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 3-12 digits)
if (preg_match('/\b(\d{3,12})\b/', $incomingText, $matches)) {
$orderId = (int)$matches[1];
$order = \App\Services\WooCommerceService::fetchOrder($store, $orderId, $phone);
if ($order) {
if (isset($order['unauthorized']) && $order['unauthorized'] === true) {
return "\n\n[تنبيه أمني للذكاء الاصطناعي: العميل سأل عن الطلب رقم {$orderId} ولكن هذا الطلب مسجل برقم هاتف مختلف في WooCommerce. لحماية الخصوصية والأمان، يمنع منعاً باتاً عرض تفاصيل هذا الطلب له. أخبر العميل بلطف أن رقم الهاتف الحالي لا يتطابق مع رقم الهاتف المسجل في تفاصيل هذا الطلب ولا يمكنك كشف تفاصيله]";
}
$status = $order['status'] ?? 'غير معروف';
$translatedStatus = $this->translateStatus($status);
$total = $order['total'] ?? '';
$currency = $order['currency'] ?? 'SAR';
$itemsCount = count($order['line_items'] ?? []);
$context = "\n\n[تفاصيل طلب WooCommerce المستعلم عنه للعميل:\n";
$context .= "- رقم الطلب: {$orderId}\n";
$context .= "- حالة الطلب الحالية: {$translatedStatus}\n";
$context .= "- إجمالي الطلب: {$total} {$currency}\n";
$context .= "- عدد المنتجات: {$itemsCount}\n";
$context .= "الرجاء صياغة رد ودود ومختصر باللغة العربية لإخبار العميل بحالة هذا الطلب بالتحديد]";
return $context;
}
}
// 2. Fetch list of recent orders for the merchant and search by customer phone number
$orders = \App\Services\WooCommerceService::fetchRecentOrders($store, 30);
if (is_array($orders)) {
foreach ($orders as $order) {
$orderPhone = $order['billing']['phone'] ?? $order['shipping']['phone'] ?? '';
if (\App\Services\WooCommerceService::comparePhones($phone, $orderPhone)) {
// Found the most recent order for this customer
$orderId = $order['id'] ?? '';
$status = $order['status'] ?? 'غير معروف';
$translatedStatus = $this->translateStatus($status);
$total = $order['total'] ?? '';
$currency = $order['currency'] ?? 'SAR';
$itemsCount = count($order['line_items'] ?? []);
$context = "\n\n[آخر طلب للعميل في متجر WooCommerce:\n";
$context .= "- رقم الطلب: {$orderId}\n";
$context .= "- حالة الطلب الحالية: {$translatedStatus}\n";
$context .= "- إجمالي الطلب: {$total} {$currency}\n";
$context .= "- عدد المنتجات: {$itemsCount}\n";
$context .= "الرجاء استخدام هذه التفاصيل للإجابة على استفساره حول حالة طلبه الأخير بدقة وود باللغة العربية]";
return $context;
}
}
}
return "\n\n[سياق المتجر: العميل متصل بمتجر WooCommerce ولكن لم يتم العثور على أي طلبات سابقة له برقم الهاتف هذا. أخبره بلطف أنه لا توجد طلبات سابقة مسجلة برقم هاتفه الحالي في المتجر، واطلب منه تزويدك برقم الطلب للبحث]";
} catch (\Exception $e) {
error_log("[Fetch WooCommerce Order Exception] " . $e->getMessage());
}
return "";
}
/**
* Translate WooCommerce order status to Arabic
*/
private function translateStatus(string $status): string
{
$translations = [
'pending' => 'بانتظار الدفع',
'processing' => 'قيد التجهيز',
'on-hold' => 'قيد الانتظار',
'completed' => 'مكتمل',
'cancelled' => 'ملغي',
'refunded' => 'مسترجع',
'failed' => 'فشل الدفع',
'checkout-draft' => 'مسودة السلة'
];
return $translations[$status] ?? $status;
}
}