'id_front', 'id_back' => 'id_back', 'driving_license_front' => 'driver_license_front', 'driving_license_back' => 'driver_license_back', 'vehicle_license_front' => 'car_license_front', 'vehicle_license_back' => 'car_license_back', 'criminal_record' => 'criminal_record', ]; public function __construct() { $this->prompts = SiroService::getDocumentPrompts($this->country); } 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; // Detect country from phone number $this->country = SiroService::detectCountry($phone); $context['country'] = $this->country; // Set country-specific prompts $this->prompts = SiroService::getDocumentPrompts($this->country); // Country name in Arabic for messages $countryNames = [ 'syria' => 'سوريا', 'jordan' => 'الأردن', 'egypt' => 'مصر', ]; $countryName = $countryNames[$this->country] ?? 'سوريا'; // App name based on country $appNames = [ 'syria' => 'سيرو', 'jordan' => 'سيرو', 'egypt' => 'سيرو', ]; $appName = $appNames[$this->country] ?? 'سيرو'; // If currently postponed and user sends a message, resume the flow if ($step === 'postponed') { $activeReminder = DriverReminder::findActive($companyId, $phone); if ($activeReminder) { DriverReminder::update($activeReminder['id'], ['status' => 'cancelled']); } $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); $configuredGeminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : null; $apiKey = GeminiService::getGeminiApiKey($configuredGeminiKey); if (!empty($apiKey)) { $postponeData = $this->detectPostponement($text, $apiKey, $companyId); 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; $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( "أهلاً بك كابتن في خدمة تسجيل كباتن تطبيق {$appName} في {$countryName} 🚖.\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"); } // Upload criminal record to Siro $fullPath = __DIR__ . '/../../../../public' . $imageUrl; $criminalSiroUrl = SiroService::uploadDocument( $this->country, SiroService::formatPhone($phone, $this->country), 'criminal_record', $fullPath, $messageData['imageMimeType'] ); $context['criminal_record_siro_url'] = $criminalSiroUrl; // 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"); } // Register driver in Siro with Siro-hosted URLs $docUrls = [ 'id_front' => $context['id_front_siro_url'] ?? '', 'id_back' => $context['id_back_siro_url'] ?? '', 'driving_license_front' => $context['driving_license_front_siro_url'] ?? '', 'driving_license_back' => $context['driving_license_back_siro_url'] ?? '', 'vehicle_license_front' => $context['vehicle_license_front_siro_url'] ?? '', 'vehicle_license_back' => $context['vehicle_license_back_siro_url'] ?? '', 'criminal_record' => $criminalSiroUrl ?? '', ]; $idOcr = $context['id_front_ocr'] ?? []; $vlOcr = $context['vehicle_license_front_ocr'] ?? []; $vlbOcr = $context['vehicle_license_back_ocr'] ?? []; $dlOcr = $context['driving_license_front_ocr'] ?? []; $formattedPhone = SiroService::formatPhone($phone, $this->country); $driverId = 'DRV' . date('YmdHis') . rand(100, 999); $driverData = [ 'phone' => $formattedPhone, 'password' => substr(md5($formattedPhone . time()), 0, 12), 'first_name' => explode(' ', $context['name'] ?? '')[0] ?? $context['name'], 'last_name' => implode(' ', array_slice(explode(' ', $context['name'] ?? ''), 1)) ?: $context['name'], 'name_arabic' => $context['name'] ?? '', 'national_number' => $idOcr['national_number'] ?? '', 'birthdate' => $idOcr['dob'] ?? '', 'address' => $idOcr['address'] ?? '', 'id' => $driverId, ]; $carData = [ 'vin' => $vlbOcr['chassis'] ?? $vlOcr['vin'] ?? '', 'car_plate' => $vlOcr['car_plate'] ?? '', 'make' => $vlbOcr['make'] ?? '', 'model' => $vlbOcr['model'] ?? '', 'year' => $vlbOcr['year'] ?? '', 'color' => $vlOcr['color'] ?? '', 'color_hex' => $vlOcr['color_hex'] ?? '#000000', 'owner' => $vlOcr['owner'] ?? '', 'fuel' => $vlbOcr['fuel'] ?? '', 'expiration_date' => $vlOcr['issue_date'] ?? '', 'vehicle_category_id' => 1, ]; $syroResult = SiroService::registerDriver($driverData, $carData, $docUrls, $this->country); $appNames = [ 'syria' => 'سيرو', 'jordan' => 'سيرو', 'egypt' => 'سيرو', ]; $appName = $appNames[$this->country] ?? 'سيرو'; if ($syroResult && ($syroResult['status'] ?? '') === 'success') { DriverOcrData::saveSecure([ 'company_id' => $companyId, 'phone' => $phone, 'name' => $context['name'], 'status' => 'registered' ]); return new FlowResult( "شكراً لك كابتن، تم تسجيلك بنجاح في تطبيق {$appName} 🚖✅\nسيتم مراجعة طلبك من قبل فريق الخدمة وتفعيل حسابك قريباً. يومك سعيد!", "finished", true ); } else { $errMsg = $syroResult['message'] ?? 'خطأ غير معروف'; error_log("[Registration Flow] Siro registration failed: " . json_encode($syroResult)); return new FlowResult( "تم حفظ مستنداتك بنجاح في نظامنا. لكن حدث تأخير في تسجيلك على تطبيق {$appName}. سيقوم فريقنا بمراجعة بياناتك وتفعيل حسابك في أقرب وقت. شكراً لصبرك! 🙏", "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); } // Upload to Siro and store the signed URL $fullPath = __DIR__ . '/../../../../public' . $imageUrl; $siroUrl = SiroService::uploadDocument( $this->country, SiroService::formatPhone($messageData['phone'], $this->country), $this->stepToDocType[$step] ?? $step, $fullPath, $messageData['imageMimeType'] ); if ($siroUrl) { $context[$step . '_siro_url'] = $siroUrl; error_log("[DriverRegistrationFlow] Uploaded {$step} to Siro: {$siroUrl}"); } else { $context[$step . '_siro_url'] = null; error_log("[DriverRegistrationFlow] Warning: Failed to upload {$step} to Siro, using local URL"); } $companyId = $context['company_id'] ?? 1; // Check subscription limit for OCR if ($companyId !== 1) { if (!\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'ocr')) { error_log("[DriverRegistrationFlow] Company {$companyId} has exceeded its OCR limit."); return new FlowResult("⚠️ عذراً، لقد استهلك هذا المتجر الحد المسموح له من تحليل الصور والوصولات لهذا الشهر. يرجى إرسال استفسارك نصياً.", $step); } } $rule = ChatbotRule::findActiveForRule($companyId); $configuredGeminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : null; $apiKey = GeminiService::getGeminiApiKey($configuredGeminiKey); 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); } // Increment stats on successful OCR processing if ($companyId !== 1) { \App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'ocr'); \App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'request'); } // 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, int $companyId): ?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 = << isset($data['hours_delay']) ? (int)$data['hours_delay'] : 12 ]; } return null; } }