Deploy: 2026-05-22 23:55:19
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
271
backend/app/Controllers/WooCommerceController.php
Normal file
271
backend/app/Controllers/WooCommerceController.php
Normal file
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Models\WooCommerceStore;
|
||||
use App\Models\WhatsAppSession;
|
||||
|
||||
class WooCommerceController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Connect WooCommerce store.
|
||||
* Protected by AuthMiddleware.
|
||||
* Accessible via POST /api/integrations/woocommerce/connect
|
||||
*/
|
||||
public function connect(Request $request, Response $response)
|
||||
{
|
||||
$body = $request->getBody();
|
||||
$storeUrl = $body['store_url'] ?? '';
|
||||
$consumerKey = $body['consumer_key'] ?? '';
|
||||
$consumerSecret = $body['consumer_secret'] ?? '';
|
||||
$webhookSecret = $body['webhook_secret'] ?? null;
|
||||
|
||||
if (empty($storeUrl) || empty($consumerKey) || empty($consumerSecret)) {
|
||||
$response->status(400)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Missing store_url, consumer_key, or consumer_secret'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$storeId = WooCommerceStore::saveStore(
|
||||
$request->company_id,
|
||||
$storeUrl,
|
||||
$consumerKey,
|
||||
$consumerSecret,
|
||||
$webhookSecret
|
||||
);
|
||||
|
||||
// Generate delivery URL for webhooks to display to the user
|
||||
$appUrl = rtrim(getenv('APP_URL') ?: 'https://nabeh.intaleqapp.com', '/');
|
||||
$webhookUrl = $appUrl . '/api/webhooks/woocommerce?company_id=' . $request->company_id;
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'message' => 'WooCommerce store connected successfully',
|
||||
'webhook_url' => $webhookUrl
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$response->status(500)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Connection failed: ' . $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WooCommerce connection status.
|
||||
* Protected by AuthMiddleware.
|
||||
* Accessible via GET /api/integrations/woocommerce/status
|
||||
*/
|
||||
public function status(Request $request, Response $response)
|
||||
{
|
||||
$store = WooCommerceStore::findByCompany($request->company_id);
|
||||
if ($store) {
|
||||
$appUrl = rtrim(getenv('APP_URL') ?: 'https://nabeh.intaleqapp.com', '/');
|
||||
$webhookUrl = $appUrl . '/api/webhooks/woocommerce?company_id=' . $request->company_id;
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'connected' => true,
|
||||
'store_url' => $store['store_url'],
|
||||
'webhook_url' => $webhookUrl,
|
||||
'has_webhook_secret' => !empty($store['webhook_secret'])
|
||||
]);
|
||||
} else {
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'connected' => false
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect WooCommerce integration.
|
||||
* Protected by AuthMiddleware.
|
||||
* Accessible via POST /api/integrations/woocommerce/disconnect
|
||||
*/
|
||||
public function disconnect(Request $request, Response $response)
|
||||
{
|
||||
WooCommerceStore::deleteByCompany($request->company_id);
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'message' => 'WooCommerce integration disconnected successfully'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming webhook from WooCommerce.
|
||||
* Accessible via POST /api/webhooks/woocommerce?company_id=XYZ
|
||||
*/
|
||||
public function webhook(Request $request, Response $response)
|
||||
{
|
||||
$companyId = (int)($request->get('company_id') ?? 0);
|
||||
if (empty($companyId)) {
|
||||
$response->status(400)->json(['error' => 'Missing company_id query parameter']);
|
||||
return;
|
||||
}
|
||||
|
||||
$store = WooCommerceStore::findByCompany($companyId);
|
||||
if (!$store) {
|
||||
$response->status(404)->json(['error' => 'Store connection details not found for this tenant']);
|
||||
return;
|
||||
}
|
||||
|
||||
$rawPayload = file_get_contents('php://input');
|
||||
|
||||
// 1. Verify signature if webhook_secret is configured
|
||||
if (!empty($store['webhook_secret'])) {
|
||||
$signatureHeader = $request->getHeader('x-wc-webhook-signature') ?: '';
|
||||
if (empty($signatureHeader)) {
|
||||
$response->status(401)->json(['error' => 'Missing x-wc-webhook-signature header']);
|
||||
return;
|
||||
}
|
||||
|
||||
$calculated = base64_encode(hash_hmac('sha256', $rawPayload, $store['webhook_secret'], true));
|
||||
if (!hash_equals($calculated, $signatureHeader)) {
|
||||
error_log("[WooCommerce Webhook Error] Signature mismatch for company {$companyId}");
|
||||
$response->status(401)->json(['error' => 'Signature verification failed']);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Parse payload
|
||||
$payload = json_decode($rawPayload, true);
|
||||
if (!$payload) {
|
||||
$response->status(400)->json(['error' => 'Invalid JSON payload']);
|
||||
return;
|
||||
}
|
||||
|
||||
$topic = $request->getHeader('x-wc-webhook-topic') ?: '';
|
||||
if (empty($topic)) {
|
||||
$response->status(400)->json(['error' => 'Missing x-wc-webhook-topic header']);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Process events
|
||||
if ($topic === 'order.created' || $topic === 'order.updated') {
|
||||
$this->handleOrderWebhook($companyId, $topic, $payload);
|
||||
}
|
||||
|
||||
$response->json(['status' => 'success']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process order webhook events and send automated WhatsApp message
|
||||
*/
|
||||
private function handleOrderWebhook(int $companyId, string $topic, array $order)
|
||||
{
|
||||
$orderId = $order['id'] ?? '';
|
||||
$customerName = trim(($order['billing']['first_name'] ?? '') . ' ' . ($order['billing']['last_name'] ?? ''));
|
||||
if (empty($customerName)) {
|
||||
$customerName = 'عميلنا العزيز';
|
||||
}
|
||||
$customerPhone = $order['billing']['phone'] ?? $order['shipping']['phone'] ?? '';
|
||||
$status = $order['status'] ?? '';
|
||||
$total = $order['total'] ?? '';
|
||||
$currency = $order['currency'] ?? 'USD';
|
||||
|
||||
if (empty($customerPhone) || empty($orderId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize phone number
|
||||
$customerPhone = preg_replace('/\D/', '', $customerPhone);
|
||||
if (substr($customerPhone, 0, 2) === '00') {
|
||||
$customerPhone = substr($customerPhone, 2);
|
||||
}
|
||||
// Normalize Saudi numbers starting with 05
|
||||
if (strlen($customerPhone) === 9 && $customerPhone[0] === '5') {
|
||||
$customerPhone = '966' . $customerPhone;
|
||||
} elseif (strlen($customerPhone) === 10 && substr($customerPhone, 0, 2) === '05') {
|
||||
$customerPhone = '966' . substr($customerPhone, 1);
|
||||
}
|
||||
|
||||
// Formulate Arabic message based on topic
|
||||
$message = '';
|
||||
if ($topic === 'order.created') {
|
||||
$message = "مرحباً {$customerName}،\nتم استلام طلبك رقم ({$orderId}) بنجاح! 🎉\nإجمالي الفاتورة: {$total} {$currency}\nحالة الطلب الحالية: *قيد المراجعة*.\nشكرًا لتسوقك معنا!";
|
||||
} elseif ($topic === 'order.updated') {
|
||||
$translatedStatus = $this->translateStatus($status);
|
||||
$message = "مرحباً {$customerName}،\nتم تحديث حالة طلبك رقم ({$orderId}) إلى: *{$translatedStatus}*.\n";
|
||||
|
||||
// If completed, add standard courier notice
|
||||
if ($status === 'completed') {
|
||||
$message .= "🚚 طلبك الآن في طريقه إليك! نتمنى لك تجربة ممتعة.";
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send WhatsApp Message via active session
|
||||
$session = WhatsAppSession::findByCompany($companyId);
|
||||
if (!$session || $session['status'] !== 'connected') {
|
||||
error_log("[WooCommerce Webhook Warning] Cannot send auto-notification: No active connected WhatsApp session for company: " . $companyId);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sendWhatsAppNotification($session['session_key'], $customerPhone, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a WhatsApp message through the gateway
|
||||
*/
|
||||
private function sendWhatsAppNotification(string $sessionKey, string $phone, string $message)
|
||||
{
|
||||
$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' => $sessionKey,
|
||||
'phone' => $phone,
|
||||
'message' => $message
|
||||
]);
|
||||
|
||||
$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);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
error_log("[WooCommerce Webhook Gateway Error] Failed to send WhatsApp notification. Gateway response: " . $response);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user