Clone Nabih Pure PHP Architecture

This commit is contained in:
Hamza-Ayed
2026-08-26 17:10:57 +03:00
parent 1e21847ed0
commit df5d8dfc60
28 changed files with 2791 additions and 267 deletions
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Core\Flows;
/**
* BaseFlow
* Abstract base class for all conversation flow states.
*/
abstract class BaseFlow
{
/**
* Process a step in the conversation flow.
*
* @param string $step The current step identifier ('start' for new flows)
* @param array $messageData The incoming WhatsApp message payload (body, phone, etc.)
* @param array $context Reference to the persistent JSON context array
* @return FlowResult
*/
abstract public function handleStep(string $step, array $messageData, array &$context): FlowResult;
}
+240
View File
@@ -0,0 +1,240 @@
<?php
namespace App\Core\Flows;
use App\Services\SiroService;
/**
* ComplaintFlow — Submit a trip complaint via WhatsApp
*
* Flow: start → await_description → await_ride → await_confirmation → finished
*
* Steps:
* start → resolve user, ask for problem description
* await_description → collect description text (or voice transcription)
* await_ride → fetch recent rides, let user pick one
* await_confirmation → show full details, ask to confirm
* finished → show AI analysis result
*/
class ComplaintFlow extends BaseFlow
{
public function handleStep(string $step, array $messageData, array &$context): FlowResult
{
$phone = $messageData['phone'] ?? '';
$text = $messageData['body'] ?? $messageData['text'] ?? '';
$country = $context['country'] ?? SiroService::detectCountry($phone);
switch ($step) {
// ─────────────────────────────────────────────────
// START: resolve user, ask for description
// ─────────────────────────────────────────────────
case 'start':
$context['country'] = $country;
// Resolve user type via Siro
try {
$driverData = SiroService::checkDriverStatus($phone, $country);
if ($driverData && !empty($driverData['data']['driver_id'])) {
$context['user_type'] = 'driver';
$context['user_id'] = $driverData['data']['driver_id'];
$context['user_name'] = $driverData['data']['name'] ?? '';
} else {
$context['user_type'] = 'passenger';
}
} catch (\Exception $e) {
$context['user_type'] = 'driver';
}
return new FlowResult(
"أهلاً بك في نظام الشكاوى.\n\n"
. "📝 يرجى وصف المشكلة التي حدثت بالتفصيل.\n"
. "مثال: \"السائق تأخر 20 دقيقة واتصلت فيه وما رد\"\n"
. "يمكنك إرسال النص أو تسجيل مقطع صوتي.\n\n"
. "🟡 للخروج اكتب: إلغاء",
"await_description"
);
// ─────────────────────────────────────────────────
// AWAIT_DESCRIPTION: collect complaint text
// ─────────────────────────────────────────────────
case 'await_description':
if (empty(trim($text))) {
return new FlowResult(
"الرجاء كتابة وصف المشكلة أو تسجيل مقطع صوتي:",
"await_description"
);
}
$context['complaint_text'] = trim($text);
// Fetch recent rides from Siro
try {
$rides = SiroService::getUserRides($country, $phone, 5);
$context['rides'] = $rides ?? [];
} catch (\Exception $e) {
$context['rides'] = [];
}
if (empty($context['rides'])) {
return new FlowResult(
"تم حفظ وصف المشكلة. ✅\n\n"
. "لم نتمكن من العثور على رحلات حديثة لحسابك.\n"
. "الرجاء إرسال رقم الرحلة (مثال: 831):",
"await_ride"
);
}
$rideList = "تم حفظ وصف المشكلة. ✅\n\n"
. "🚖 آخر رحلاتك:\n\n";
foreach ($context['rides'] as $i => $r) {
$num = $i + 1;
$date = $r['date'] ?? '';
$time = $r['time'] ?? '';
$from = $r['start_location'] ?? '---';
$to = $r['end_location'] ?? '---';
$price = $r['price'] ?? '0';
$status = $r['status'] ?? '';
$rideList .= "{$num}. رحلة #{$r['id']} | {$date} {$time}\n"
. " من: {$from} → إلى: {$to}\n"
. " السعر: {$price} | الحالة: {$status}\n\n";
}
$rideList .= "الرجاء إرسال رقم الرحلة من القائمة (1-{$num})\n"
. "أو اكتب رقم الرحلة كاملاً (مثال: 831):";
return new FlowResult($rideList, "await_ride");
// ─────────────────────────────────────────────────
// AWAIT_RIDE: let user pick a ride
// ─────────────────────────────────────────────────
case 'await_ride':
$selectedRide = null;
$rides = $context['rides'] ?? [];
// Check if user entered a list number (1, 2, 3...)
$cleanNum = preg_replace('/[^0-9]/', '', $text);
if (!empty($cleanNum) && is_numeric($cleanNum)) {
$index = (int)$cleanNum - 1;
if (isset($rides[$index])) {
$selectedRide = $rides[$index];
}
// If not found in list, try as ride_id directly
if (!$selectedRide) {
foreach ($rides as $r) {
if ((string)$r['id'] === $cleanNum) {
$selectedRide = $r;
break;
}
}
}
// If still not found, try the number as ride_id
if (!$selectedRide) {
$selectedRide = ['id' => $cleanNum];
}
}
if (!$selectedRide) {
return new FlowResult(
"لم نتعرف على الرقم. الرجاء إرسال رقم الرحلة من القائمة:",
"await_ride"
);
}
$context['ride_id'] = $selectedRide['id'];
$context['ride_details'] = $selectedRide;
$locFrom = $selectedRide['start_location'] ?? '---';
$locTo = $selectedRide['end_location'] ?? '---';
$date = $selectedRide['date'] ?? '---';
$time = $selectedRide['time'] ?? '---';
$price = $selectedRide['price'] ?? '---';
$status = $selectedRide['status'] ?? '---';
return new FlowResult(
"🚖 تفاصيل الرحلة المحددة:\n"
. "• رقم الرحلة: {$selectedRide['id']}\n"
. "• التاريخ: {$date} {$time}\n"
. "• من: {$locFrom}\n"
. "• إلى: {$locTo}\n"
. "• السعر: {$price}\n"
. "• الحالة: {$status}\n\n"
. "📋 وصف المشكلة:\n"
. "{$context['complaint_text']}\n\n"
. "هل تريد تأكيد إرسال الشكوى؟\n"
. "✅ أرسل: تأكيد\n"
. "🔄 أرسل: تعديل (لإعادة كتابة الوصف)\n"
. "🟡 أرسل: إلغاء",
"await_confirmation"
);
// ─────────────────────────────────────────────────
// AWAIT_CONFIRMATION: confirm and submit
// ─────────────────────────────────────────────────
case 'await_confirmation':
$clean = trim(mb_strtolower($text));
if (in_array($clean, ['تعديل', 'edit', 'تعديل الوصف'])) {
return new FlowResult(
"الرجاء إرسال وصف المشكلة الجديد:",
"await_description"
);
}
if (!in_array($clean, ['تأكيد', 'نعم', 'اكيد', 'ok', 'yes', 'confirm', 'تاكيد', 'okay'])) {
return new FlowResult(
"❌ لم يتم التأكيد.\n"
. "✅ للتأكيد أرسل: تأكيد\n"
. "🔄 للتعديل أرسل: تعديل\n"
. "🟡 للإلغاء أرسل: إلغاء",
"await_confirmation"
);
}
// Submit complaint via Siro
try {
$result = SiroService::submitComplaint(
$country,
$phone,
$context['ride_id'],
$context['complaint_text']
);
if ($result && ($result['status'] ?? '') === 'success') {
$aiResult = $result['ai_result'] ?? [];
$report = $result['report'] ?? [];
$reportTitle = $report['title'] ?? '';
$reportBody = $report['body'] ?? '';
$reply = "✅ تم إرسال شكواك بنجاح!\n\n"
. "📋 نتيجة التحليل:\n"
. "• تصنيف الشكوى: " . ($aiResult['complaint_type'] ?? '---') . "\n"
. "• الطرف المخطئ: " . ($aiResult['fault_determination'] ?? '---') . "\n"
. "• طبيعة الشكوى: " . ($aiResult['complaint_nature'] ?? '---') . "\n\n";
if ($reportBody) {
$reply .= "📄 {$reportTitle}\n{$reportBody}\n\n";
}
$reply .= "سيتم التواصل معك من قبل فريق الدعم إذا لزم الأمر.";
return new FlowResult($reply, "finished", true);
}
return new FlowResult(
"⚠️ حدث خطأ في إرسال الشكوى. يرجى المحاولة مرة أخرى أو التواصل مع الدعم الفني.",
"finished",
true
);
} catch (\Exception $e) {
error_log("[ComplaintFlow] Submit error: " . $e->getMessage());
return new FlowResult(
"⚠️ تعذر إرسال الشكوى حالياً. يرجى المحاولة لاحقاً.",
"finished",
true
);
}
default:
return new FlowResult("حدث خطأ في المسار.", "finished", true);
}
}
}
@@ -0,0 +1,322 @@
<?php
namespace App\Core\Flows;
use App\Models\ConversationState;
use App\Models\MessageLog;
/**
* ConversationFlowEngine
* Orchestrates multi-stage interactive conversation flows.
*/
class ConversationFlowEngine
{
/**
* Map of registered flow names to their classes
*/
private static array $flows = [
'test_flow' => TestFlow::class,
'driver_registration_flow' => DriverRegistrationFlow::class,
'payment_flow' => PaymentFlow::class,
'complaint_flow' => ComplaintFlow::class,
];
/**
* Map of keyword triggers to start a specific flow
*/
private static array $startTriggers = [
'test' => 'test_flow',
'اختبار' => 'test_flow',
'سجل' => 'driver_registration_flow',
'تسجيل' => 'driver_registration_flow',
'register' => 'driver_registration_flow',
'دفع' => 'payment_flow',
'وصل' => 'payment_flow',
'تسديد' => 'payment_flow',
'رصيد' => 'payment_flow',
'شكوى' => 'complaint_flow',
'مشكلة' => 'complaint_flow',
'بلاغ' => 'complaint_flow',
'تظلم' => 'complaint_flow',
'شكوي' => 'complaint_flow',
'complaint' => 'complaint_flow',
];
/**
* Process incoming message.
* Returns true if handled by the flow engine, false otherwise.
*/
public static function processMessage(array $session, array $msgData): bool
{
// 1. Housekeeping: remove expired sessions
ConversationState::cleanExpired();
$phone = $msgData['phone'];
$companyId = $session['company_id'];
$text = isset($msgData['body']) ? trim($msgData['body']) : '';
// If incoming message is audio, transcribe it via Gemini (if limits permit)
$isAudio = !empty($msgData['audio']) && !empty($msgData['mimeType']);
if ($isAudio) {
if ($companyId !== 1) {
$activeSub = \App\Models\CompanySubscription::findActiveByCompany($companyId);
if (!$activeSub) {
error_log("[Flow Engine Warning] Company {$companyId} has no active subscription for audio transcription.");
return false;
}
if (!\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'request')) {
error_log("[Flow Engine Warning] Company {$companyId} has exceeded its request limit for audio transcription.");
return false;
}
if (!\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'voice')) {
error_log("[Flow Engine Warning] Company {$companyId} has exceeded its voice limit for audio transcription.");
return false;
}
}
$rule = \App\Models\ChatbotRule::findActiveForRule($companyId);
$configuredGeminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : null;
$apiKey = \App\Services\GeminiService::getGeminiApiKey($configuredGeminiKey);
if (!empty($apiKey)) {
$transcription = \App\Services\GeminiService::transcribeAudio($apiKey, $msgData['audio'], $msgData['mimeType']);
if ($transcription) {
$text = $transcription;
$msgData['body'] = $transcription;
// Increment usage stats for successful transcription
if ($companyId !== 1) {
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'voice');
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'request');
}
}
}
}
// 2. Lookup existing active flow
$state = ConversationState::findActive($companyId, $phone);
$flowName = '';
$currentStep = '';
$context = [];
if ($state) {
$flowName = $state['flow_name'];
$currentStep = $state['current_step'];
$context = json_decode($state['context_data'] ?: '{}', true) ?: [];
} else {
// Check if message is a starting trigger for a new flow
$normalizedText = strtolower(trim($text));
if (isset(self::$startTriggers[$normalizedText])) {
$flowName = self::$startTriggers[$normalizedText];
$currentStep = 'start';
$context = [];
}
}
// If no active flow and no trigger matches, pass control back to normal bot rules
if (empty($flowName) || !isset(self::$flows[$flowName])) {
return false;
}
// Check subscription limits for active/starting flow
if ($companyId !== 1) {
$activeSub = \App\Models\CompanySubscription::findActiveByCompany($companyId);
if (!$activeSub) {
error_log("[Flow Engine Warning] Company {$companyId} has no active subscription.");
self::sendReply($session, $phone, "⚠️ عذراً، لا يوجد اشتراك نشط لهذا المتجر حالياً.");
return true;
}
if (!\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'request')) {
error_log("[Flow Engine Warning] Company {$companyId} has exceeded its general request limit.");
self::sendReply($session, $phone, "⚠️ عذراً، لقد استهلك هذا المتجر كامل الحد المسموح له من الرسائل والطلبات لهذا الشهر.");
return true;
}
}
// 3. User cancel flow option
$normalizedCancel = strtolower(trim($text));
if (in_array($normalizedCancel, ['إلغاء', 'خروج', 'cancel', 'exit'])) {
if ($state) {
ConversationState::deleteState($state['id']);
$cancelMsg = in_array($normalizedCancel, ['cancel', 'exit'])
? 'Interactive flow cancelled.'
: 'تم إلغاء المحادثة التفاعلية.';
self::sendReply($session, $phone, $cancelMsg);
return true;
}
}
// 4. Instantiate and execute the flow
$flowClass = self::$flows[$flowName];
/** @var BaseFlow $flowInstance */
$flowInstance = new $flowClass();
try {
$context['company_id'] = $companyId;
$result = $flowInstance->handleStep($currentStep, $msgData, $context);
if ($result->isFinished()) {
// Flow has reached terminal state: clean up DB record
if ($state) {
ConversationState::deleteState($state['id']);
}
} else {
// Flow has next step: save or update state record (TTL: 1 hour)
ConversationState::saveState([
'company_id' => $companyId,
'contact_phone' => $phone,
'flow_name' => $flowName,
'current_step' => $result->getNextStep(),
'context_data' => json_encode($context, JSON_UNESCAPED_UNICODE),
'expires_at' => date('Y-m-d H:i:s', strtotime('+1 hour'))
]);
}
// 5. Send reply if one is provided
$replySent = false;
if ($companyId === 1 && $flowName === 'driver_registration_flow' && $result->getReplyText() !== '') {
$rule = \App\Models\ChatbotRule::findActiveForRule($companyId);
$configuredGeminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : null;
$apiKey = \App\Services\GeminiService::getGeminiApiKey($configuredGeminiKey);
if (!empty($apiKey)) {
$configuredElKey = ($rule && !empty($rule['elevenlabs_api_key'])) ? $rule['elevenlabs_api_key'] : null;
$elApiKey = \App\Services\GeminiService::getElevenLabsApiKey($configuredElKey);
$configuredVoiceId = ($rule && !empty($rule['elevenlabs_voice_id'])) ? $rule['elevenlabs_voice_id'] : null;
$elVoiceId = \App\Services\GeminiService::getElevenLabsVoiceId($configuredVoiceId);
// Generate the audio voice note
$audioData = \App\Services\GeminiService::generateAudioResponse(
$apiKey,
"أنت سارة، مساعدة فريق خدمة العملاء في تطبيق سيرو (Siro) — أول منصة نقل ذكية وبنية تحتية رقمية للتنقل في سوريا والأردن ومصر. تتحدثين بلهجة سورية دافئة ومحببة ومهنية جداً كأنك إنسان حقيقي.
معلومات عن سيرو:
- التطبيق: سيرو (Siro) — من تطوير سيرو لنقل الركاب
- العمولة: 11% فقط (أقل عمولة في السوق السوري)
- التحميل: أندروید https://play.google.com/store/apps/details?id=com.Siro.siro | آيفون https://apps.apple.com/app/id6748075179
- طرق الشحن: سيريتيل كاش، شام كاش، المحفظة الداخلية
- أنواع الرحلات: 10 أنواع (مريح، سريع، سائقات، سكوتر، فان، VIP، اقتصاد، سعر ثابت، رايح جاي، كهربائي)
- المميزات: خرائط مملوكة SiroMaps، ترخيص حكومي NANS، 4 تطبيقات متكاملة، توثيق بالذكاء الاصطناعي، زر SOS، رحلات للسائقات
- الأمان: JWT + بصمة جهاز، تشفير AES-256-GCM، حماية من الاحتيال
- البلدان: سوريا (شغال)، مصر (جاهز)، الأردن (قريباً)
- الدعم: support@intaleqapp.com | support@siromove.com
ملاحظة: إذا المستخدم كتب بالإنجليزية، ردي بالإنجليزية. إذا كتب بالعربية، ردي بالعربية (اللهجة السورية).",
$result->getReplyText(),
$result->getReplyText(),
'Puck',
$elApiKey,
$elVoiceId
);
if ($audioData && !empty($audioData['audio'])) {
// Send the text message first
self::sendReply($session, $phone, $result->getReplyText(), $result->getMediaUrl());
// Then send the voice note
self::sendReply($session, $phone, '', null, $audioData['audio'], $audioData['mimeType']);
$replySent = true;
}
}
}
if (!$replySent && ($result->getReplyText() !== '' || $result->getMediaUrl() !== null)) {
self::sendReply($session, $phone, $result->getReplyText(), $result->getMediaUrl());
}
return true;
} catch (\Exception $e) {
error_log("[ConversationFlowEngine Exception] Flow '{$flowName}' failed: " . $e->getMessage());
return false;
}
}
/**
* Send outbound message reply via the Baileys Gateway
*/
public static function sendReply(
array $session,
string $phone,
string $message,
?string $mediaUrl = null,
?string $audioBase64 = null,
?string $mimetype = null,
?string $imageBase64 = null
): bool {
$gatewayUrl = rtrim(getenv('WHATSAPP_GATEWAY_URL') ?: 'http://localhost:3722', '/');
if (substr($gatewayUrl, -4) === '/api') {
$sendUrl = $gatewayUrl . '/messages/send';
} else {
$sendUrl = $gatewayUrl . '/api/messages/send';
}
$payloadData = [
'session_key' => $session['session_key'],
'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;
}
if ($imageBase64 !== null) {
$payloadData['image'] = $imageBase64;
}
$payload = json_encode($payloadData);
$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, 30);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$status = 'failed';
$errorMsg = null;
$waMsgId = null;
if ($httpCode === 200) {
$status = 'sent';
$resData = json_decode($response, true);
$waMsgId = $resData['data']['key']['id'] ?? null;
} else {
$resData = json_decode($response, true);
$errorMsg = $resData['error'] ?? 'HTTP Code ' . $httpCode;
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' => $msgType,
'message_body' => $message,
'media_url' => $mediaUrl,
'whatsapp_message_id' => $waMsgId,
'status' => $status,
'error_message' => $errorMsg
]);
return $status === 'sent';
}
}
@@ -0,0 +1,510 @@
<?php
namespace App\Core\Flows;
use App\Services\GeminiService;
use App\Services\SiroService;
use App\Models\DriverOcrData;
use App\Models\ChatbotRule;
use App\Models\DriverReminder;
use App\Core\Database;
/**
* DriverRegistrationFlow
* Handles step-by-step driver and vehicle registration using Gemini OCR.
* Integrates with Siro platform for driver registration across Syria, Jordan, and Egypt.
*/
class DriverRegistrationFlow extends BaseFlow
{
private array $prompts = [];
private string $country = 'syria';
private array $stepToDocType = [
'id_front' => '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])) {
error_log("[DriverRegistrationFlow] Missing or empty 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 = <<<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;
}
// Increment request limit on successful postponement check API call
if ($companyId !== 1) {
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'request');
}
// 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;
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Core\Flows;
/**
* FlowResult
* Holds response details after handling a single step in a conversation flow.
*/
class FlowResult
{
private string $replyText;
private string $nextStep;
private bool $finished;
private ?string $mediaUrl;
public function __construct(string $replyText, string $nextStep, bool $finished = false, ?string $mediaUrl = null)
{
$this->replyText = $replyText;
$this->nextStep = $nextStep;
$this->finished = $finished;
$this->mediaUrl = $mediaUrl;
}
/**
* Get the reply message text to send to the contact
*/
public function getReplyText(): string
{
return $this->replyText;
}
/**
* Get the next step key
*/
public function getNextStep(): string
{
return $this->nextStep;
}
/**
* Check if the flow is finished (to be destroyed)
*/
public function isFinished(): bool
{
return $this->finished;
}
/**
* Get target media URL (if any)
*/
public function getMediaUrl(): ?string
{
return $this->mediaUrl;
}
}
+194
View File
@@ -0,0 +1,194 @@
<?php
namespace App\Core\Flows;
use App\Services\SiroService;
/**
* PaymentFlow — Smart Payment Verification
*
* Flow: start → await_receipt → finished
*
* Smart features:
* • Auto-detects country + payment method (shamcash/cliq)
* • Auto-finds pending invoice by phone (no invoice number needed)
* • AI receipt verification via payment server Gemini
* • Postponement detection (keyword-based)
* • Image validation + retry (max 3 attempts)
* • Currency-aware messages (SYP/JOD)
*/
class PaymentFlow extends BaseFlow
{
private const MAX_RETRIES = 3;
public function handleStep(string $step, array $messageData, array &$context): FlowResult
{
$phone = $messageData['phone'] ?? '';
$text = isset($messageData['body']) ? trim($messageData['body']) : '';
$image = $messageData['image'] ?? '';
$imageMimeType = $messageData['imageMimeType'] ?? 'image/jpeg';
// ── Postponement check (only if flow is active, not on start/finished) ──
if ($step !== 'start' && $step !== 'finished' && !empty($text)) {
$postpone = $this->detectPostponement($text);
if ($postpone !== null) {
$context['previous_step'] = $step;
$hours = $postpone;
return new FlowResult(
"حاضر كابتن، تم تأجيل طلب الدفع. سأذكرك بعد {$hours} ساعات.\n"
. "للمتابعة لاحقاً، أرسل 'دفع' مرة أخرى.",
"postponed"
);
}
}
// ── Country + method detection ──
$country = $context['country'] ?? SiroService::detectCountry($phone);
$context['country'] = $country;
$paymentMethod = $context['payment_method'] ?? match ($country) {
'jordan' => 'cliq',
default => 'shamcash',
};
$context['payment_method'] = $paymentMethod;
$methodName = $paymentMethod === 'cliq' ? 'كليك (Cliq)' : 'شام كاش (ShamCash)';
$currency = $paymentMethod === 'cliq' ? 'دينار أردني' : 'ل.س';
$countryName = match ($country) {
'jordan' => 'الأردن',
'egypt' => 'مصر',
default => 'سوريا',
};
switch ($step) {
// ─────────────────────────────────────────────────
// START
// ─────────────────────────────────────────────────
case 'start':
$context['retry_count'] = 0;
return new FlowResult(
"أهلاً بك في خدمة التحقق من الدفع.\n\n"
. "📍 الدولة: {$countryName}\n"
. "💰 طريقة الدفع: {$methodName}\n"
. "💵 العملة: {$currency}\n\n"
. "📸 يرجى إرسال صورة واضحة لإيصال الدفع أو صورة الشاشة.\n"
. "سيتم التحقق من الفاتورة المعلقة تلقائياً.\n\n"
. "🟡 للخروج اكتب: إلغاء\n"
. "⏰ للتأجيل اكتب: بعدين",
"await_receipt"
);
// ─────────────────────────────────────────────────
// AWAIT_RECEIPT
// ─────────────────────────────────────────────────
case 'await_receipt':
// ── No image sent ──
if (empty($image)) {
return new FlowResult(
"📸 يرجى إرسال صورة الإيصال أو وصل التحويل.\n"
. "تأكد من أن الصورة واضحة وتظهر المبلغ وتفاصيل التحويل.",
"await_receipt"
);
}
// ── Validate image size ──
$decoded = base64_decode($image, true);
if ($decoded === false || strlen($decoded) < 1024) {
$retry = ($context['retry_count'] ?? 0) + 1;
$context['retry_count'] = $retry;
if ($retry >= self::MAX_RETRIES) {
return new FlowResult(
"عذراً، لم نتمكن من قراءة الصورة بعد {$retry} محاولات.\n"
. "يرجى التواصل مع خدمة العملاء لإتمام عملية الدفع يدوياً.",
"finished",
true
);
}
return new FlowResult(
"⚠️ الصورة غير واضحة أو صغيرة جداً.\n"
. "يرجى إرسال صورة واضحة وحجم أكبر (محاولة {$retry} من " . self::MAX_RETRIES . "):",
"await_receipt"
);
}
// ── Normalize MIME type ──
if (strpos($imageMimeType, ';') !== false) {
$imageMimeType = trim(explode(';', $imageMimeType)[0]);
}
// ── Send to payment server ──
$companyId = $context['company_id'] ?? 1;
$result = \App\Controllers\WhatsAppController::verifyPaymentSlipStatic(
companyId: $companyId,
phone: $phone,
jsonStr: '',
userType: 'driver',
paymentMethod: $paymentMethod,
invoiceNumber: '',
receiptImage: $image,
imageMimeType: $imageMimeType,
);
if ($result) {
return new FlowResult($result, "finished", true);
}
$retry = ($context['retry_count'] ?? 0) + 1;
$context['retry_count'] = $retry;
if ($retry >= self::MAX_RETRIES) {
return new FlowResult(
"عذراً، تعذر التحقق من الدفع بعد {$retry} محاولات.\n"
. "سيتم مراجعة العملية من قبل الإدارة.",
"finished",
true
);
}
return new FlowResult(
"لم نتمكن من التحقق من الدفع حالياً (محاولة {$retry} من " . self::MAX_RETRIES . ").\n"
. "يرجى إرسال صورة أوضح والإضاءة جيدة:",
"await_receipt"
);
case 'postponed':
// Resume from postponed
$step = $context['previous_step'] ?? 'await_receipt';
return new FlowResult(
"مرحباً بك مرة أخرى! 👋\n"
. "📸 أرسل صورة إيصال الدفع للمتابعة:",
$step
);
default:
return new FlowResult("حدث خطأ في المسار.", "finished", true);
}
}
/**
* Detect if user wants to postpone (keyword-based, no AI call)
*/
private function detectPostponement(string $text): ?int
{
$keywords = [
'بعدين' => 2, 'بكرا' => 12, 'بكرة' => 12, 'بعد' => 3,
'شوي' => 1, 'مشغول' => 4, 'تأجيل' => 6, 'لاحقاً' => 6,
'لاحقا' => 6, 'الحق' => 6, 'وقت ثاني' => 8, 'تعبان' => 6,
'بعدين برسل' => 3, 'بعدين ببعت' => 3, 'بعدين بكمل' => 4,
'ببعثها' => 3, 'ببعت' => 3, 'برسل' => 2,
];
$normalized = trim(mb_strtolower($text));
foreach ($keywords as $kw => $hours) {
if (mb_strpos($normalized, $kw) !== false) {
return $hours;
}
}
return null;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Core\Flows;
/**
* TestFlow
* A basic interactive flow for testing the multi-stage system.
*/
class TestFlow extends BaseFlow
{
public function handleStep(string $step, array $messageData, array &$context): FlowResult
{
$text = isset($messageData['body']) ? trim($messageData['body']) : '';
switch ($step) {
case 'start':
// Initiate step
return new FlowResult("أهلاً بك في اختبار المسار التفاعلي! ما هو اسمك الكريم؟", "ask_name");
case 'ask_name':
if (empty($text)) {
return new FlowResult("يرجى إدخال اسمك للاستمرار:", "ask_name");
}
$context['name'] = $text;
return new FlowResult("تشرفنا بك يا {$text}! من فضلك قم بتقييم خدمتنا من 1 إلى 5:", "ask_feedback");
case 'ask_feedback':
if (!preg_match('/^[1-5]$/', $text)) {
return new FlowResult("الرجاء إدخال رقم من 1 إلى 5 فقط للتقييم:", "ask_feedback");
}
$context['rating'] = (int)$text;
return new FlowResult("شكراً لك يا {$context['name']}! لقد تم تسجيل تقييمك ({$text}/5) بنجاح. يومك سعيد!", "finished", true);
default:
return new FlowResult("خطأ في تحديد خطوة المسار.", "finished", true);
}
}
}