Deploy: 2026-05-22 21:52:51
This commit is contained in:
@@ -16,6 +16,7 @@ class ConversationFlowEngine
|
||||
*/
|
||||
private static array $flows = [
|
||||
'test_flow' => TestFlow::class,
|
||||
'driver_registration_flow' => DriverRegistrationFlow::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,9 @@ class ConversationFlowEngine
|
||||
private static array $startTriggers = [
|
||||
'test' => 'test_flow',
|
||||
'اختبار' => 'test_flow',
|
||||
'سجل' => 'driver_registration_flow',
|
||||
'تسجيل' => 'driver_registration_flow',
|
||||
'register' => 'driver_registration_flow',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -39,6 +43,20 @@ class ConversationFlowEngine
|
||||
$companyId = $session['company_id'];
|
||||
$text = isset($msgData['body']) ? trim($msgData['body']) : '';
|
||||
|
||||
// If incoming message is audio, transcribe it via Gemini
|
||||
$isAudio = !empty($msgData['audio']) && !empty($msgData['mimeType']);
|
||||
if ($isAudio) {
|
||||
$rule = \App\Models\ChatbotRule::findActiveForRule($companyId);
|
||||
$apiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : getenv('GEMINI_API_KEY');
|
||||
if (!empty($apiKey)) {
|
||||
$transcription = \App\Services\GeminiService::transcribeAudio($apiKey, $msgData['audio'], $msgData['mimeType']);
|
||||
if ($transcription) {
|
||||
$text = $transcription;
|
||||
$msgData['body'] = $transcription;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Lookup existing active flow
|
||||
$state = ConversationState::findActive($companyId, $phone);
|
||||
|
||||
@@ -84,6 +102,7 @@ class ConversationFlowEngine
|
||||
$flowInstance = new $flowClass();
|
||||
|
||||
try {
|
||||
$context['company_id'] = $companyId;
|
||||
$result = $flowInstance->handleStep($currentStep, $msgData, $context);
|
||||
|
||||
if ($result->isFinished()) {
|
||||
@@ -104,8 +123,8 @@ class ConversationFlowEngine
|
||||
}
|
||||
|
||||
// 5. Send reply if one is provided
|
||||
if ($result->getReplyText() !== '') {
|
||||
self::sendReply($session, $phone, $result->getReplyText());
|
||||
if ($result->getReplyText() !== '' || $result->getMediaUrl() !== null) {
|
||||
self::sendReply($session, $phone, $result->getReplyText(), $result->getMediaUrl());
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -118,8 +137,14 @@ class ConversationFlowEngine
|
||||
/**
|
||||
* Send outbound message reply via the Baileys Gateway
|
||||
*/
|
||||
private static function sendReply(array $session, string $phone, string $message): void
|
||||
{
|
||||
public static function sendReply(
|
||||
array $session,
|
||||
string $phone,
|
||||
string $message,
|
||||
?string $mediaUrl = null,
|
||||
?string $audioBase64 = null,
|
||||
?string $mimetype = null
|
||||
): void {
|
||||
$gatewayUrl = rtrim(getenv('WHATSAPP_GATEWAY_URL') ?: 'http://localhost:3722', '/');
|
||||
if (substr($gatewayUrl, -4) === '/api') {
|
||||
$sendUrl = $gatewayUrl . '/messages/send';
|
||||
@@ -127,11 +152,25 @@ class ConversationFlowEngine
|
||||
$sendUrl = $gatewayUrl . '/api/messages/send';
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
$payloadData = [
|
||||
'session_key' => $session['session_key'],
|
||||
'phone' => $phone,
|
||||
'message' => $message
|
||||
]);
|
||||
'phone' => $phone
|
||||
];
|
||||
|
||||
if ($message !== '') {
|
||||
$payloadData['message'] = $message;
|
||||
}
|
||||
if ($mediaUrl !== null) {
|
||||
$payloadData['media_url'] = $mediaUrl;
|
||||
}
|
||||
if ($audioBase64 !== null) {
|
||||
$payloadData['audio'] = $audioBase64;
|
||||
}
|
||||
if ($mimetype !== null) {
|
||||
$payloadData['mimetype'] = $mimetype;
|
||||
}
|
||||
|
||||
$payload = json_encode($payloadData);
|
||||
|
||||
$ch = curl_init($sendUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
@@ -161,14 +200,17 @@ class ConversationFlowEngine
|
||||
error_log("[Flow Engine Gateway Error] Failed to send: " . $errorMsg);
|
||||
}
|
||||
|
||||
$msgType = ($audioBase64 !== null || $mediaUrl !== null) ? 'audio' : 'text';
|
||||
|
||||
// Log the outbound auto-reply message
|
||||
MessageLog::logMessage([
|
||||
'company_id' => $session['company_id'],
|
||||
'session_id' => $session['id'],
|
||||
'contact_phone' => $phone,
|
||||
'direction' => 'outbound',
|
||||
'message_type' => 'text',
|
||||
'message_type' => $msgType,
|
||||
'message_body' => $message,
|
||||
'media_url' => $mediaUrl,
|
||||
'whatsapp_message_id' => $waMsgId,
|
||||
'status' => $status,
|
||||
'error_message' => $errorMsg
|
||||
|
||||
483
backend/app/Core/Flows/DriverRegistrationFlow.php
Normal file
483
backend/app/Core/Flows/DriverRegistrationFlow.php
Normal file
@@ -0,0 +1,483 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core\Flows;
|
||||
|
||||
use App\Services\GeminiService;
|
||||
use App\Models\DriverOcrData;
|
||||
use App\Models\ChatbotRule;
|
||||
use App\Models\DriverReminder;
|
||||
|
||||
/**
|
||||
* DriverRegistrationFlow
|
||||
* Handles step-by-step driver and vehicle registration using Gemini OCR.
|
||||
*/
|
||||
class DriverRegistrationFlow extends BaseFlow
|
||||
{
|
||||
private array $prompts = [
|
||||
"id_front" => <<<EOT
|
||||
You are an OCR expert for Syrian national ID cards (green card).
|
||||
|
||||
### TASK
|
||||
Analyse the **front side** of the ID and return **raw JSON only** with exactly these keys:
|
||||
|
||||
{
|
||||
"full_name": "", // الاسم الثلاثي أو الرباعي
|
||||
"national_number": "", // الرقم الوطني (LATIN digits only)
|
||||
"dob": "YYYY-MM-DD", // تاريخ الميلاد
|
||||
"address": "" // العنوان
|
||||
}
|
||||
|
||||
### RULES
|
||||
* Read the red number on the bottom of the card.
|
||||
* Convert any Eastern-Arabic digits (٠١٢٣٤٥٦٧٨٩) to Western-Arabic digits (0-9).
|
||||
* `national_number` must contain **Latin digits only, no spaces or other characters**.
|
||||
* If a field is missing, set it to **null**.
|
||||
* Convert the birth date to ISO `YYYY-MM-DD`.
|
||||
* Return valid JSON only — no extra keys, no markdown.
|
||||
EOT,
|
||||
"id_back" => <<<EOT
|
||||
أنت خبير OCR مختص ببطاقات الهوية السورية (الوجه الخلفي).
|
||||
|
||||
### المطلوب
|
||||
حلّل صورة الوجه الخلفي للهوية السورية وأعد **JSON صِرف** يحتوي المفاتيح التالية فقط:
|
||||
|
||||
{
|
||||
"governorate": "", // المحافظة (مثال: دمشق)
|
||||
"address": "", // العنوان التفصيلي (حيّ، بلدة …)
|
||||
"gender": "", //Male or Female
|
||||
"issue_date": "YYYY-MM-DD"// تاريخ الإصدار بصيغة ISO
|
||||
}
|
||||
|
||||
### القواعد
|
||||
1. حوّل أي أرقام عربية شرقية (٠١٢٣٤٥٦٧٨٩) إلى أرقام لاتينية (0-9).
|
||||
2. أعدّ تاريخ الإصدار بالتقويم الميلادي بصيغة `YYYY-MM-DD`.
|
||||
3. استخدم أحرف لاتينية كبيرة لزمرة الدم مع رمز `+` أو `-` فقط.
|
||||
4. إذا كان أحد الحقول غير موجود مطلقًا، أعد قيمته **null**.
|
||||
5. لا تُرجع أي مفاتيح إضافية أو شروح أو Markdown — JSON صالح فقط.
|
||||
EOT,
|
||||
"driving_license_front" => <<<EOT
|
||||
You are an OCR expert for Syrian documents.
|
||||
|
||||
### TASK
|
||||
Analyse the **front side of a Syrian driving licence** and return **clean JSON only** with the following keys (no extra keys, no markdown):
|
||||
|
||||
{
|
||||
"name_arabic": "", // الاسم الثلاثي أو الرباعي بالعربية
|
||||
"birth_place": "", // المحافظة أو المنطقة المكتوبة بعد كلمة الولادة
|
||||
"birth_year": "", // سنة الميلاد فقط (أربعة أرقام)
|
||||
"national_number": "",
|
||||
"civil_registry": "", // سطر "القيد" (مثال: سهوة 3)
|
||||
"blood_type": "" // زمرة الدم بالشكل: A+ , A- , B+ , B- , AB+ , AB- , O+ , O-
|
||||
}
|
||||
|
||||
### RULES
|
||||
* إذا كانت القيمة مفقودة تمامًا اكتب **null**.
|
||||
* لا تُغيّر ترتيب المفاتيح.
|
||||
* لا تُرسل أى شرح أو أسطر إضافية – JSON خالص فقط.
|
||||
EOT,
|
||||
"driving_license_back" => <<<EOT
|
||||
You are an OCR expert for Syrian driving licences.
|
||||
|
||||
### TASK
|
||||
Analyse the **back side** of a Syrian driving licence and return **raw JSON only** with exactly these keys:
|
||||
|
||||
{
|
||||
"issue_date": "YYYY-MM-DD", // تاريخ المنح
|
||||
"expiry_date": "YYYY-MM-DD", // صالحة لغاية
|
||||
"license_number": "", // رقم الإجازة
|
||||
"license_category": "" // D1, D2, D3 … (as printed after "UNIVERSAL DRIVING LICENCE")
|
||||
}
|
||||
|
||||
### RULES
|
||||
* If a value is totally absent, set it to **null**.
|
||||
* Convert all dates to ISO `YYYY-MM-DD` (Gregorian).
|
||||
* Do **NOT** add extra keys, comments, or markdown — return valid JSON only.
|
||||
EOT,
|
||||
"vehicle_license_front" => <<<EOT
|
||||
You are an OCR expert specialized in analyzing Syrian vehicle registration cards (الرخصة البرتقالية).
|
||||
|
||||
Your task is to extract structured data from the **front side** of the Syrian orange vehicle card and return **raw JSON only** with the following exact fields:
|
||||
|
||||
{
|
||||
"car_plate": "", // رقم المركبة الكامل مع اسم المحافظة، مأخوذ من الجهة اليسرى في السطر الأول (مثال: "155186 درعا")
|
||||
"owner": "", // اسم المالك الكامل
|
||||
"vin": "", // رقم الهيكل
|
||||
"color": "", // اللون بالعربية أو الإنجليزية (مثال: "أبيض" أو "White")
|
||||
"color_hex": "", // كود اللون بصيغة Hex (مثال: "#FFFFFF") أو #27332F إن تعذّر
|
||||
"issue_date": "YYYY-MM-DD", // تاريخ المنح بصيغة ISO
|
||||
"inspection_date": "YYYY-MM-DD" // تاريخ الفحص القادم بصيغة ISO
|
||||
}
|
||||
|
||||
### Instructions & Rules:
|
||||
|
||||
1. Do **not** extract the "رمز المركبة" (on the right side of the first line) — use only the **left side** of the first line for `car_plate`.
|
||||
2. Convert any Arabic dates (like `2024/05/13`) into ISO format `YYYY-MM-DD`.
|
||||
3. If any value is missing or unreadable, return `null` for it.
|
||||
4. Maintain Arabic encoding (e.g., owner name, city name, color).
|
||||
5. Never guess — extract only what's visually found on the card.
|
||||
6. Never include any explanation or extra output — return the JSON only.
|
||||
|
||||
Example of valid `car_plate`:
|
||||
- "155186 درعا"
|
||||
- "45291 دمشق"
|
||||
- "122334 حمص"
|
||||
EOT,
|
||||
"vehicle_license_back" => <<<EOT
|
||||
You are an OCR expert for Syrian vehicle registration cards (orange card).
|
||||
|
||||
### TASK
|
||||
Analyse the **back side** of the card and return **raw JSON only** with exactly these keys (no more, no less):
|
||||
|
||||
{
|
||||
"make": "", // الصانع (Hyundai …)
|
||||
"model": "", // الطراز (H1 …)
|
||||
"year": "", // سنة الصنع بالأرقام اللاتينية (e.g. "2019")
|
||||
"fuel": "", // نوع الوقود (بنزين، ديزل …) أو بالإنجليزية (Petrol, Diesel,electric)
|
||||
"chassis": "" // رقم الهيكل (VIN)
|
||||
}
|
||||
|
||||
### RULES
|
||||
* Convert any Eastern-Arabic digits (٠١٢٣٤٥٦٧٨٩) to Western digits (0-9).
|
||||
* Normalise color names to standard English if possible, then map to a common Hex code
|
||||
• "أبيض / White" → **#FFFFFF**
|
||||
• "أسود / Black" → **#000000**
|
||||
• "أحمر / Red" → **#FF0000**
|
||||
• "أزرق / Blue" → **#0000FF**
|
||||
• … (use the closest basic colour); if no match, set **color_hex = null**.
|
||||
* If any field is unreadable or absent, set its value to **null**.
|
||||
* Do **NOT** include extra keys, comments, or markdown — output valid JSON only.
|
||||
EOT
|
||||
];
|
||||
|
||||
public function handleStep(string $step, array $messageData, array &$context): FlowResult
|
||||
{
|
||||
$text = isset($messageData['body']) ? trim($messageData['body']) : '';
|
||||
$phone = $messageData['phone'];
|
||||
$companyId = $context['company_id'] ?? 1;
|
||||
|
||||
// If currently postponed and user sends a message, resume the flow
|
||||
if ($step === 'postponed') {
|
||||
// Cancel active reminder
|
||||
$activeReminder = DriverReminder::findActive($companyId, $phone);
|
||||
if ($activeReminder) {
|
||||
DriverReminder::update($activeReminder['id'], ['status' => 'cancelled']);
|
||||
}
|
||||
// Restore previous step
|
||||
$step = $context['previous_step'] ?? 'ask_name';
|
||||
}
|
||||
|
||||
// Check if user requests postponement/delay (only if already started and not finished)
|
||||
if ($step !== 'start' && $step !== 'finished' && !empty($text)) {
|
||||
$rule = ChatbotRule::findActiveForRule($companyId);
|
||||
$apiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : getenv('GEMINI_API_KEY');
|
||||
if (!empty($apiKey)) {
|
||||
$postponeData = $this->detectPostponement($text, $apiKey);
|
||||
if ($postponeData !== null) {
|
||||
$hours = $postponeData['hours'];
|
||||
$postponeCount = ($context['postpone_count'] ?? 0) + 1;
|
||||
|
||||
if ($postponeCount > 3) {
|
||||
return new FlowResult(
|
||||
"عذراً كابتن، لقد تجاوزت الحد الأقصى لمرات التأجيل (3 مرات). تم إلغاء طلب التسجيل الحالي. يمكنك البدء من جديد عندما تكون جاهزاً بكتابة 'تسجيل'.",
|
||||
"finished",
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
$context['postpone_count'] = $postponeCount;
|
||||
$context['previous_step'] = $step;
|
||||
|
||||
// Schedule reminder
|
||||
$scheduledAt = date('Y-m-d H:i:s', strtotime("+{$hours} hours"));
|
||||
DriverReminder::saveReminder([
|
||||
'company_id' => $companyId,
|
||||
'phone' => $phone,
|
||||
'scheduled_at' => $scheduledAt,
|
||||
'postpone_count' => $postponeCount,
|
||||
'status' => 'pending'
|
||||
]);
|
||||
|
||||
return new FlowResult(
|
||||
"حاضر كابتن، قمت بتأجيل التسجيل. سأقوم بتذكيرك بعد {$hours} ساعة لإكمال خطوات التسجيل. بالتوفيق!",
|
||||
"postponed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch ($step) {
|
||||
case 'start':
|
||||
return new FlowResult(
|
||||
"أهلاً بك كابتن في خدمة تسجيل كباتن تطبيق انطلق 🚖.\nيرجى إرسال اسمك الثلاثي الكامل للبدء:",
|
||||
"ask_name"
|
||||
);
|
||||
|
||||
case 'ask_name':
|
||||
if (empty($text)) {
|
||||
return new FlowResult("يرجى إدخال اسمك الثلاثي الكامل للاستمرار:", "ask_name");
|
||||
}
|
||||
$context['name'] = $text;
|
||||
return new FlowResult(
|
||||
"شكراً كابتن {$text}.\nالآن يرجى إرسال صورة **الوجه الأمامي للهوية الشخصية** (تأكد من أن الصورة واضحة والإضاءة جيدة):",
|
||||
"id_front"
|
||||
);
|
||||
|
||||
case 'id_front':
|
||||
return $this->processOcrStep(
|
||||
$step,
|
||||
$messageData,
|
||||
$context,
|
||||
"id_front_sy",
|
||||
"national_number",
|
||||
"عذراً كابتن، لم أتمكن من قراءة الرقم الوطني من الهوية بوضوح. يرجى إرسال صورة أخرى للوجه الأمامي للهوية الشخصية تكون أكثر وضوحاً:",
|
||||
"تم استخراج الرقم الوطني بنجاح ✅.\nالآن، يرجى إرسال صورة **الوجه الخلفي للهوية الشخصية**:",
|
||||
"id_back"
|
||||
);
|
||||
|
||||
case 'id_back':
|
||||
return $this->processOcrStep(
|
||||
$step,
|
||||
$messageData,
|
||||
$context,
|
||||
"id_back_sy",
|
||||
"gender",
|
||||
"عذراً كابتن، لم أتمكن من قراءة بيانات الوجه الخلفي للهوية بوضوح. يرجى إرسال صورة أخرى للوجه الخلفي للهوية الشخصية:",
|
||||
"تم استخراج البيانات بنجاح ✅.\nيرجى إرسال صورة **الوجه الأمامي لرخصة القيادة**:",
|
||||
"driving_license_front"
|
||||
);
|
||||
|
||||
case 'driving_license_front':
|
||||
return $this->processOcrStep(
|
||||
$step,
|
||||
$messageData,
|
||||
$context,
|
||||
"driving_license_sy_front",
|
||||
"national_number",
|
||||
"عذراً كابتن، لم أتمكن من قراءة رخصة القيادة بوضوح. يرجى إرسال صورة أخرى واضحة للوجه الأمامي لرخصة القيادة:",
|
||||
"تم استخراج بيانات رخصة القيادة بنجاح ✅.\nيرجى إرسال صورة **الوجه الخلفي لرخصة القيادة**:",
|
||||
"driving_license_back"
|
||||
);
|
||||
|
||||
case 'driving_license_back':
|
||||
return $this->processOcrStep(
|
||||
$step,
|
||||
$messageData,
|
||||
$context,
|
||||
"driving_license_sy_back",
|
||||
"license_number",
|
||||
"عذراً كابتن، لم أتمكن من قراءة الوجه الخلفي لرخصة القيادة بوضوح. يرجى إعادة إرسال الصورة بشكل أكثر وضوحاً:",
|
||||
"تم استخراج البيانات بنجاح ✅.\nيرجى إرسال صورة **الوجه الأمامي لرخصة السيارة (الرخصة البرتقالية)**:",
|
||||
"vehicle_license_front"
|
||||
);
|
||||
|
||||
case 'vehicle_license_front':
|
||||
return $this->processOcrStep(
|
||||
$step,
|
||||
$messageData,
|
||||
$context,
|
||||
"vehicle_license_sy_front",
|
||||
"car_plate",
|
||||
"عذراً كابتن، لم أتمكن من قراءة رقم لوحة السيارة بوضوح. يرجى إرسال صورة واضحة للوجه الأمامي لرخصة السيارة:",
|
||||
"تم استخراج رقم اللوحة بنجاح ✅.\nيرجى إرسال صورة **الوجه الخلفي لرخصة السيارة (الرخصة البرتقالية)**:",
|
||||
"vehicle_license_back"
|
||||
);
|
||||
|
||||
case 'vehicle_license_back':
|
||||
return $this->processOcrStep(
|
||||
$step,
|
||||
$messageData,
|
||||
$context,
|
||||
"vehicle_license_sy_back",
|
||||
"chassis",
|
||||
"عذراً كابتن، لم أتمكن من قراءة مواصفات السيارة بوضوح. يرجى إرسال صورة واضحة للوجه الخلفي لرخصة السيارة:",
|
||||
"تم استخراج مواصفات السيارة بنجاح ✅.\nيرجى إرسال صورة **وثيقة غير محكوم (لا حكم عليه)**:",
|
||||
"criminal_record"
|
||||
);
|
||||
|
||||
case 'criminal_record':
|
||||
if (empty($messageData['image']) || empty($messageData['imageMimeType'])) {
|
||||
return new FlowResult("الرجاء إرسال صورة وثيقة غير محكوم (لا حكم عليه) للاستمرار:", "criminal_record");
|
||||
}
|
||||
|
||||
// Save non-OCR criminal record image
|
||||
$imageUrl = $this->saveIncomingImage($step, $phone, $messageData);
|
||||
if (!$imageUrl) {
|
||||
return new FlowResult("عذراً، فشل حفظ الصورة. الرجاء إعادة إرسال صورة الوثيقة:", "criminal_record");
|
||||
}
|
||||
|
||||
// Securely save registration data to local database
|
||||
try {
|
||||
DriverOcrData::saveSecure([
|
||||
'company_id' => $companyId,
|
||||
'phone' => $phone,
|
||||
'name' => $context['name'],
|
||||
'id_front_url' => $context['id_front_url'] ?? null,
|
||||
'id_front_ocr' => $context['id_front_ocr'] ?? null,
|
||||
'id_back_url' => $context['id_back_url'] ?? null,
|
||||
'id_back_ocr' => $context['id_back_ocr'] ?? null,
|
||||
'driving_license_front_url' => $context['driving_license_front_url'] ?? null,
|
||||
'driving_license_front_ocr' => $context['driving_license_front_ocr'] ?? null,
|
||||
'driving_license_back_url' => $context['driving_license_back_url'] ?? null,
|
||||
'driving_license_back_ocr' => $context['driving_license_back_ocr'] ?? null,
|
||||
'vehicle_license_front_url' => $context['vehicle_license_front_url'] ?? null,
|
||||
'vehicle_license_front_ocr' => $context['vehicle_license_front_ocr'] ?? null,
|
||||
'vehicle_license_back_url' => $context['vehicle_license_back_url'] ?? null,
|
||||
'vehicle_license_back_ocr' => $context['vehicle_license_back_ocr'] ?? null,
|
||||
'criminal_record_url' => $imageUrl,
|
||||
'status' => 'ocr_completed'
|
||||
]);
|
||||
} catch (\Exception $dbEx) {
|
||||
error_log("[Registration Flow Error] DB Write Failed: " . $dbEx->getMessage());
|
||||
return new FlowResult("عذراً، حدث خطأ أثناء حفظ طلبك في قاعدة البيانات. يرجى المحاولة مرة أخرى لاحقاً.", "criminal_record");
|
||||
}
|
||||
|
||||
return new FlowResult(
|
||||
"شكراً لك كابتن، لقد تم استلام كافة المستندات والتحقق منها بنجاح. سيقوم فريق خدمة عملاء انطلق بمراجعة طلبك وتفعيل حسابك بأسرع وقت ممكن. يومك سعيد! 🚖",
|
||||
"finished",
|
||||
true
|
||||
);
|
||||
|
||||
default:
|
||||
return new FlowResult("خطأ في تحديد خطوة المسار.", "finished", true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process document upload and OCR extraction step
|
||||
*/
|
||||
private function processOcrStep(
|
||||
string $step,
|
||||
array $messageData,
|
||||
array &$context,
|
||||
string $promptKey,
|
||||
string $requiredJsonKey,
|
||||
string $failMessage,
|
||||
string $successMessage,
|
||||
string $nextStep
|
||||
): FlowResult {
|
||||
if (empty($messageData['image']) || empty($messageData['imageMimeType'])) {
|
||||
return new FlowResult("الرجاء إرسال الصورة المطلوبة للمتابعة:", $step);
|
||||
}
|
||||
|
||||
$imageUrl = $this->saveIncomingImage($step, $messageData['phone'], $messageData);
|
||||
if (!$imageUrl) {
|
||||
return new FlowResult("عذراً، فشل حفظ الصورة. الرجاء إعادة المحاولة وإرسال الصورة:", $step);
|
||||
}
|
||||
|
||||
$companyId = $context['company_id'] ?? 1;
|
||||
$rule = ChatbotRule::findActiveForRule($companyId);
|
||||
$apiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : getenv('GEMINI_API_KEY');
|
||||
|
||||
if (empty($apiKey)) {
|
||||
error_log("[DriverRegistrationFlow] Gemini API key not configured.");
|
||||
return new FlowResult("عذراً، عطل فني في خادم معالجة الصور بالذكاء الاصطناعي. يرجى المحاولة لاحقاً.", $step);
|
||||
}
|
||||
|
||||
$prompt = $this->prompts[$step] ?? '';
|
||||
$rawOcr = GeminiService::generateOcrFromImage($apiKey, $prompt, $messageData['image'], $messageData['imageMimeType']);
|
||||
|
||||
if (!$rawOcr) {
|
||||
error_log("[DriverRegistrationFlow] OCR response empty or model request failed.");
|
||||
return new FlowResult($failMessage, $step);
|
||||
}
|
||||
|
||||
$ocrData = json_decode($rawOcr, true);
|
||||
if (!$ocrData || (empty($ocrData[$requiredJsonKey]) && !array_key_exists($requiredJsonKey, $ocrData))) {
|
||||
error_log("[DriverRegistrationFlow] Missing required key '$requiredJsonKey' in OCR response: " . $rawOcr);
|
||||
return new FlowResult($failMessage, $step);
|
||||
}
|
||||
|
||||
// Save URL and OCR JSON string in the conversation context
|
||||
$context[$step . '_url'] = $imageUrl;
|
||||
$context[$step . '_ocr'] = $ocrData;
|
||||
|
||||
return new FlowResult($successMessage, $nextStep);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode base64 image data and save it to the public directory
|
||||
*/
|
||||
private function saveIncomingImage(string $step, string $phone, array $messageData): ?string
|
||||
{
|
||||
try {
|
||||
$extension = 'jpg';
|
||||
if (strpos($messageData['imageMimeType'], 'png') !== false) {
|
||||
$extension = 'png';
|
||||
}
|
||||
|
||||
$uniqueName = 'driver_' . $step . '_' . md5($phone . time()) . '.' . $extension;
|
||||
$uploadDir = __DIR__ . '/../../../../public/uploads/documents/';
|
||||
|
||||
if (!is_dir($uploadDir)) {
|
||||
mkdir($uploadDir, 0755, true);
|
||||
}
|
||||
|
||||
$uploadPath = $uploadDir . $uniqueName;
|
||||
$imgData = base64_decode($messageData['image']);
|
||||
|
||||
if (file_put_contents($uploadPath, $imgData) === false) {
|
||||
error_log("[DriverRegistrationFlow] Failed to write image file: " . $uploadPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
return '/uploads/documents/' . $uniqueName;
|
||||
} catch (\Exception $e) {
|
||||
error_log("[DriverRegistrationFlow Exception] Failed to save image: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if user wants to postpone, and return hours_delay if so.
|
||||
*/
|
||||
private function detectPostponement(string $text, string $apiKey): ?array
|
||||
{
|
||||
if (empty($text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Quick heuristic check: if the message is too long, or clearly doesn't contain postponement keywords, skip to save API costs
|
||||
$keywords = ['بعدين', 'بكرا', 'بكرة', 'بعد', 'شوي', 'ثانية', 'مشغول', 'المسا', 'الليل', 'تأجيل', 'وقت ثاني', 'تعبان', 'بعدين برسل', 'بعدين ببعت', 'ببعثهم بعدين', 'ببعتهم بعدين', 'بعدين بكمل'];
|
||||
$hasKeyword = false;
|
||||
foreach ($keywords as $kw) {
|
||||
if (mb_strpos($text, $kw) !== false) {
|
||||
$hasKeyword = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasKeyword && mb_strlen($text) > 100) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$systemPrompt = "You are an assistant that detects if a user wants to postpone, delay, or complete a registration flow later. Analyze the Arabic message.";
|
||||
$userMessage = <<<EOT
|
||||
Analyze the following Arabic message (often in Syrian dialect) to determine if the user wants to postpone/delay sending documents or complete the registration later.
|
||||
|
||||
Message: "{$text}"
|
||||
|
||||
Respond with ONLY a valid JSON object matching this schema:
|
||||
{
|
||||
"wants_postpone": true/false,
|
||||
"hours_delay": 12
|
||||
}
|
||||
Do not include any markdown, code blocks, or explanations.
|
||||
EOT;
|
||||
|
||||
$response = GeminiService::generateResponse($apiKey, $systemPrompt, $userMessage);
|
||||
if (empty($response)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Clean markdown block if present
|
||||
$response = trim(preg_replace('/```json|```/', '', $response));
|
||||
$data = json_decode($response, true);
|
||||
if (isset($data['wants_postpone']) && $data['wants_postpone'] === true) {
|
||||
return [
|
||||
'hours' => isset($data['hours_delay']) ? (int)$data['hours_delay'] : 12
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
144
backend/app/Models/DriverOcrData.php
Normal file
144
backend/app/Models/DriverOcrData.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Core\Database;
|
||||
use App\Core\Security;
|
||||
|
||||
/**
|
||||
* DriverOcrData Model
|
||||
* Manages collected driver documents and OCR results with binary field encryption.
|
||||
*/
|
||||
class DriverOcrData extends BaseModel
|
||||
{
|
||||
protected static string $table = 'driver_ocr_data';
|
||||
|
||||
/**
|
||||
* Find decrypted record for a company and contact phone
|
||||
*/
|
||||
public static function findByPhone(int $companyId, string $phone): ?array
|
||||
{
|
||||
self::ensureTableExists();
|
||||
$hash = Security::blindIndex($phone);
|
||||
$record = Database::selectOne(
|
||||
"SELECT * FROM " . static::$table . " WHERE company_id = ? AND (phone_hash = ? OR phone = ?) LIMIT 1",
|
||||
[$companyId, $hash, $phone]
|
||||
);
|
||||
return self::decryptRecord($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save or update driver registration OCR state securely
|
||||
*/
|
||||
public static function saveSecure(array $data): string
|
||||
{
|
||||
self::ensureTableExists();
|
||||
|
||||
$data = self::encryptRecord($data);
|
||||
|
||||
$existing = Database::selectOne(
|
||||
"SELECT id FROM " . static::$table . " WHERE company_id = ? AND phone_hash = ? LIMIT 1",
|
||||
[$data['company_id'], $data['phone_hash']]
|
||||
);
|
||||
|
||||
if ($existing) {
|
||||
self::update($existing['id'], $data);
|
||||
return $existing['id'];
|
||||
} else {
|
||||
return self::create($data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to encrypt sensitive fields in a record (simplified to plain text)
|
||||
*/
|
||||
public static function encryptRecord(array $data): array
|
||||
{
|
||||
if (!empty($data['phone'])) {
|
||||
$data['phone_hash'] = Security::blindIndex($data['phone']);
|
||||
}
|
||||
|
||||
$ocrFields = [
|
||||
'id_front_ocr', 'id_back_ocr',
|
||||
'driving_license_front_ocr', 'driving_license_back_ocr',
|
||||
'vehicle_license_front_ocr', 'vehicle_license_back_ocr'
|
||||
];
|
||||
|
||||
foreach ($ocrFields as $field) {
|
||||
if (isset($data[$field])) {
|
||||
if (is_array($data[$field])) {
|
||||
$jsonStr = json_encode($data[$field], JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
$jsonStr = $data[$field];
|
||||
}
|
||||
$data[$field] = $jsonStr ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to decrypt sensitive fields in a record (simplified to plain text)
|
||||
*/
|
||||
public static function decryptRecord(?array $record): ?array
|
||||
{
|
||||
if (!$record) return null;
|
||||
|
||||
$ocrFields = [
|
||||
'id_front_ocr', 'id_back_ocr',
|
||||
'driving_license_front_ocr', 'driving_license_back_ocr',
|
||||
'vehicle_license_front_ocr', 'vehicle_license_back_ocr'
|
||||
];
|
||||
|
||||
foreach ($ocrFields as $field) {
|
||||
if (!empty($record[$field])) {
|
||||
$record[$field] = json_decode($record[$field], true);
|
||||
}
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the driver_ocr_data table exists dynamically
|
||||
*/
|
||||
public static function ensureTableExists(): void
|
||||
{
|
||||
static $checked = false;
|
||||
if ($checked) return;
|
||||
|
||||
try {
|
||||
Database::execute("
|
||||
CREATE TABLE IF NOT EXISTS `driver_ocr_data` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`company_id` INT NOT NULL,
|
||||
`phone` VARCHAR(512) NOT NULL,
|
||||
`phone_hash` VARCHAR(64) NOT NULL,
|
||||
`name` VARCHAR(512) NOT NULL,
|
||||
`id_front_url` VARCHAR(512) DEFAULT NULL,
|
||||
`id_front_ocr` TEXT DEFAULT NULL,
|
||||
`id_back_url` VARCHAR(512) DEFAULT NULL,
|
||||
`id_back_ocr` TEXT DEFAULT NULL,
|
||||
`driving_license_front_url` VARCHAR(512) DEFAULT NULL,
|
||||
`driving_license_front_ocr` TEXT DEFAULT NULL,
|
||||
`driving_license_back_url` VARCHAR(512) DEFAULT NULL,
|
||||
`driving_license_back_ocr` TEXT DEFAULT NULL,
|
||||
`vehicle_license_front_url` VARCHAR(512) DEFAULT NULL,
|
||||
`vehicle_license_front_ocr` TEXT DEFAULT NULL,
|
||||
`vehicle_license_back_url` VARCHAR(512) DEFAULT NULL,
|
||||
`vehicle_license_back_ocr` TEXT DEFAULT NULL,
|
||||
`criminal_record_url` VARCHAR(512) DEFAULT NULL,
|
||||
`status` VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `unique_company_driver_phone` (`company_id`, `phone_hash`),
|
||||
FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
");
|
||||
$checked = true;
|
||||
} catch (\Exception $e) {
|
||||
error_log("Failed to ensure driver_ocr_data table: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
74
backend/app/Models/DriverReminder.php
Normal file
74
backend/app/Models/DriverReminder.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Core\Database;
|
||||
|
||||
/**
|
||||
* DriverReminder Model
|
||||
* Manages driver registration reminders for follow-up notifications.
|
||||
*/
|
||||
class DriverReminder extends BaseModel
|
||||
{
|
||||
protected static string $table = 'driver_registration_reminders';
|
||||
|
||||
/**
|
||||
* Find active reminder by company and contact phone
|
||||
*/
|
||||
public static function findActive(int $companyId, string $phone): ?array
|
||||
{
|
||||
self::ensureTableExists();
|
||||
return Database::selectOne(
|
||||
"SELECT * FROM " . static::$table . " WHERE company_id = ? AND phone = ? AND status = 'pending' LIMIT 1",
|
||||
[$companyId, $phone]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save or update driver reminder state
|
||||
*/
|
||||
public static function saveReminder(array $data): string
|
||||
{
|
||||
self::ensureTableExists();
|
||||
|
||||
$existing = Database::selectOne(
|
||||
"SELECT id FROM " . static::$table . " WHERE company_id = ? AND phone = ? AND status = 'pending' LIMIT 1",
|
||||
[$data['company_id'], $data['phone']]
|
||||
);
|
||||
|
||||
if ($existing) {
|
||||
self::update($existing['id'], $data);
|
||||
return $existing['id'];
|
||||
} else {
|
||||
return self::create($data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the driver_registration_reminders table exists dynamically
|
||||
*/
|
||||
public static function ensureTableExists(): void
|
||||
{
|
||||
static $checked = false;
|
||||
if ($checked) return;
|
||||
|
||||
try {
|
||||
Database::execute("
|
||||
CREATE TABLE IF NOT EXISTS `driver_registration_reminders` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`company_id` INT NOT NULL,
|
||||
`phone` VARCHAR(50) NOT NULL,
|
||||
`scheduled_at` DATETIME NOT NULL,
|
||||
`postpone_count` INT DEFAULT 0,
|
||||
`status` VARCHAR(20) DEFAULT 'pending',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
");
|
||||
$checked = true;
|
||||
} catch (\Exception $e) {
|
||||
error_log("Failed to ensure driver_registration_reminders table: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,59 @@ class GeminiService
|
||||
return $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcribe incoming audio voice note to text using gemini-2.0-flash-lite
|
||||
*/
|
||||
public static function transcribeAudio(string $apiKey, string $audioBase64, string $mimeType): ?string
|
||||
{
|
||||
$url = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-lite: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' => $audioBase64
|
||||
]
|
||||
],
|
||||
[
|
||||
'text' => "Transcribe the following audio message to Arabic text. Output only the transcription, no translation, no commentary, no markdown, and no code blocks."
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$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, 30);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
error_log("[Gemini Audio Transcription Error] HTTP " . $httpCode . " | Response: " . $response);
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
return trim($data['candidates'][0]['content']['parts'][0]['text'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Call Gemini API with image inline data and system instruction to generate a response text
|
||||
*/
|
||||
@@ -213,6 +266,64 @@ class GeminiService
|
||||
return $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call Gemini API with image inline data and custom prompt to extract structured OCR data
|
||||
*/
|
||||
public static function generateOcrFromImage(string $apiKey, string $prompt, string $imageBase64, string $mimeType): ?string
|
||||
{
|
||||
$url = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-lite: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' => [
|
||||
[
|
||||
'text' => $prompt
|
||||
],
|
||||
[
|
||||
'inlineData' => [
|
||||
'mimeType' => $mimeType,
|
||||
'data' => $imageBase64
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$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 OCR Image Response Error] HTTP " . $httpCode . " | Response: " . $response);
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
$textRaw = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
||||
if ($textRaw) {
|
||||
// Clean markdown block if present
|
||||
$textRaw = trim(preg_replace('/```json|```/', '', $textRaw));
|
||||
}
|
||||
return $textRaw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call ElevenLabs API to generate a native audio response from text
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user