diff --git a/backend/app/Controllers/WhatsAppController.php b/backend/app/Controllers/WhatsAppController.php index 0d7ecd5..e7b5f40 100644 --- a/backend/app/Controllers/WhatsAppController.php +++ b/backend/app/Controllers/WhatsAppController.php @@ -219,13 +219,29 @@ class WhatsAppController extends BaseController // 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' => $isAudioMsg ? 'audio' : 'text', - 'message_body' => $isAudioMsg ? ($msgData['body'] ?: '[Voice Note]') : $msgData['body'], + 'message_type' => $msgType, + 'message_body' => $msgBody, 'whatsapp_message_id' => $msgData['id'], 'status' => 'read' ]); @@ -290,8 +306,9 @@ class WhatsAppController extends BaseController $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) { + if (empty($incomingText) && !$hasAudio && !$hasImage) { return; } @@ -329,12 +346,35 @@ class WhatsAppController extends BaseController $mimeType = trim(explode(';', $mimeType)[0]); } $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)) { + // Check if the reply contains [PAYMENT_RECEIPT: { ... }] tag from Gemini + if (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 + $verificationResult = $this->verifyPaymentSlip($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') { @@ -394,4 +434,69 @@ class WhatsAppController extends BaseController error_log("[Chatbot Exception] Error: " . $e->getMessage() . " in " . $e->getFile() . ":" . $e->getLine()); } } + + /** + * Call external Entaleq API to verify payment slip + */ + private function verifyPaymentSlip(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; + } + + // Determine API URL (default to localhost mock endpoint) + $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 + ]); + + $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, [ + 'Content-Type: application/json', + 'X-API-Key: ' . (getenv('ENTALEQ_API_KEY') ?: 'mock-key') + ]); + 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 "⏳ تم استلام وصل الدفع بنجاح. يجري الآن مراجعته وتدقيقه يدوياً من قبل الإدارة الفنية لتأكيد شحن رصيدك."; + } + } } diff --git a/backend/app/Services/GeminiService.php b/backend/app/Services/GeminiService.php index 65757a2..684d0f5 100644 --- a/backend/app/Services/GeminiService.php +++ b/backend/app/Services/GeminiService.php @@ -154,4 +154,62 @@ class GeminiService $data = json_decode($response, true); return $data['candidates'][0]['content']['parts'][0]['text'] ?? null; } + + /** + * Call Gemini API with image inline data and system instruction to generate a response text + */ + public static function generateResponseFromImage(string $apiKey, string $systemPrompt, string $imageBase64, string $mimeType): ?string + { + $url = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-lite-latest:generateContent?key=' . $apiKey; + + // Clean mimeType if it contains codec info + if (strpos($mimeType, ';') !== false) { + $mimeType = trim(explode(';', $mimeType)[0]); + } + + $payload = json_encode([ + 'contents' => [ + [ + 'role' => 'user', + 'parts' => [ + [ + 'inlineData' => [ + 'mimeType' => $mimeType, + 'data' => $imageBase64 + ] + ], + [ + 'text' => "حلل الصورة المرفقة وأجب عليها باللغة المناسبة بناءً على الإرشادات المحددة." + ] + ] + ] + ], + 'systemInstruction' => [ + 'parts' => [ + ['text' => $systemPrompt] + ] + ] + ]); + + $ch = curl_init($url); + 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' + ]); + curl_setopt($ch, CURLOPT_TIMEOUT, 35); // 35 seconds timeout for image analysis + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200) { + error_log("[Gemini Image Response Error] HTTP " . $httpCode . " | Response: " . $response); + return null; + } + + $data = json_decode($response, true); + return $data['candidates'][0]['content']['parts'][0]['text'] ?? null; + } } diff --git a/backend/public/index.php b/backend/public/index.php index afb0798..89b3fe9 100644 --- a/backend/public/index.php +++ b/backend/public/index.php @@ -68,5 +68,44 @@ $router->get('/api/chatbot/rules', [\App\Controllers\ChatbotController::class, ' $router->post('/api/chatbot/rules',[\App\Controllers\ChatbotController::class, 'store'], [\App\Middlewares\AuthMiddleware::class]); $router->post('/api/chatbot/generate-prompt-from-audio', [\App\Controllers\ChatbotController::class, 'generatePromptFromAudio'], [\App\Middlewares\AuthMiddleware::class]); +// Mock External API for Entaleq Payment Verification (Used to demo automated slip validation) +$router->post('/api/external/verify-payment', function ($request, $response) { + $body = $request->getBody(); + $phone = $body['phone'] ?? ''; + $transactionId = $body['transaction_id'] ?? ''; + $amount = $body['amount'] ?? ''; + $method = $body['method'] ?? ''; + + if (empty($transactionId) || empty($amount)) { + $response->status(400)->json([ + 'status' => 'error', + 'message' => 'Missing transaction_id or amount' + ]); + return; + } + + // Mock validation rules: + // If transaction_id contains 'fail' or starts with '9', mock verification failure + if (strpos(strtolower($transactionId), 'fail') !== false || (is_string($transactionId) && $transactionId[0] === '9')) { + $response->json([ + 'status' => 'failed', + 'message' => 'هذا الرقم التعريفي للعملية تم استخدامه مسبقاً أو غير صحيح' + ]); + return; + } + + $response->json([ + 'status' => 'success', + 'message' => 'Payment verified and driver balance updated', + 'data' => [ + 'driver_phone' => $phone, + 'transaction_id' => $transactionId, + 'amount' => $amount, + 'method' => $method, + 'new_balance' => 150.00 + ] + ]); +}); + // 4. Dispatch the request $router->dispatch($request, $response); diff --git a/whatsapp-gateway/baileys-client.js b/whatsapp-gateway/baileys-client.js index d27878f..784b90a 100644 --- a/whatsapp-gateway/baileys-client.js +++ b/whatsapp-gateway/baileys-client.js @@ -104,12 +104,15 @@ async function startSession(session_key, webhook_url) { msg.message?.videoMessage?.caption || ''; const isAudio = !!msg.message?.audioMessage; + const isImage = !!msg.message?.imageMessage; - // Only process messages that have text content OR are audio messages - if (!body && !isAudio) continue; + // Only process messages that have text content OR are audio/image messages + if (!body && !isAudio && !isImage) continue; let audioBase64 = null; let audioMimeType = null; + let imageBase64 = null; + let imageMimeType = null; if (isAudio) { try { @@ -129,6 +132,24 @@ async function startSession(session_key, webhook_url) { console.error('[Baileys] Failed to download audio message:', e.message); continue; // Skip if audio download fails to prevent empty processing } + } else if (isImage) { + try { + console.log(`[Baileys] Downloading image message for ${remoteJid}`); + const buffer = await downloadMediaMessage( + msg, + 'buffer', + {}, + { + logger: pino({ level: 'silent' }), + rekey: true + } + ); + imageBase64 = buffer.toString('base64'); + imageMimeType = msg.message.imageMessage.mimetype || 'image/jpeg'; + } catch (e) { + console.error('[Baileys] Failed to download image message:', e.message); + continue; // Skip if image download fails + } } // Extract sender phone number (handle LID privacy scheme) @@ -147,6 +168,8 @@ async function startSession(session_key, webhook_url) { if (isAudio) { console.log(`[Message] Received audio voice note from ${senderPhone} (JID: ${remoteJid})`); + } else if (isImage) { + console.log(`[Message] Received image from ${senderPhone} (JID: ${remoteJid})`); } else { console.log(`[Message] Received from ${senderPhone} (JID: ${remoteJid}): ${body}`); } @@ -161,6 +184,8 @@ async function startSession(session_key, webhook_url) { body: body, audio: audioBase64, mimeType: audioMimeType, + image: imageBase64, + imageMimeType: imageMimeType, timestamp: msg.messageTimestamp } }); @@ -285,9 +310,14 @@ async function sendMessage(session_key, phone, message, mediaUrl = null) { return await sock.sendMessage(jid, { text: message }); } +function getActiveSessions() { + return Array.from(sessions.keys()); +} + module.exports = { startSession, disconnectSession, - sendMessage + sendMessage, + getActiveSessions }; diff --git a/whatsapp-gateway/server.js b/whatsapp-gateway/server.js index 91d9fa7..6157cba 100644 --- a/whatsapp-gateway/server.js +++ b/whatsapp-gateway/server.js @@ -20,7 +20,7 @@ for (const p of envPaths) { const express = require('express'); const cors = require('cors'); -const { startSession, disconnectSession, sendMessage } = require('./baileys-client'); +const { startSession, disconnectSession, sendMessage, getActiveSessions } = require('./baileys-client'); const app = express(); app.use(cors()); @@ -78,7 +78,7 @@ app.post('/api/sessions/disconnect', async (req, res) => { // Get list of active session keys in memory app.get('/api/sessions/active', (req, res) => { - res.json({ status: 'success', active_sessions: Array.from(sessions.keys()) }); + res.json({ status: 'success', active_sessions: getActiveSessions() }); }); // Send outbound message