Deploy: 2026-05-22 01:25:54

This commit is contained in:
Hamza-Ayed
2026-05-22 01:25:54 +03:00
parent da7d3571e0
commit e5c6c54ca0
5 changed files with 240 additions and 8 deletions

View File

@@ -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 "⏳ تم استلام وصل الدفع بنجاح. يجري الآن مراجعته وتدقيقه يدوياً من قبل الإدارة الفنية لتأكيد شحن رصيدك.";
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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
};

View File

@@ -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