Compare commits

..

2 Commits

6 changed files with 866 additions and 149 deletions

View File

@@ -3,166 +3,72 @@
namespace App\Core\Flows; namespace App\Core\Flows;
use App\Services\GeminiService; use App\Services\GeminiService;
use App\Services\SiroService;
use App\Models\DriverOcrData; use App\Models\DriverOcrData;
use App\Models\ChatbotRule; use App\Models\ChatbotRule;
use App\Models\DriverReminder; use App\Models\DriverReminder;
use App\Core\Database;
/** /**
* DriverRegistrationFlow * DriverRegistrationFlow
* Handles step-by-step driver and vehicle registration using Gemini OCR. * 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 class DriverRegistrationFlow extends BaseFlow
{ {
private array $prompts = [ private array $prompts = [];
"id_front" => <<<EOT private string $country = 'syria';
You are an OCR expert for Syrian national ID cards (green card).
### TASK private array $stepToDocType = [
Analyse the **front side** of the ID and return **raw JSON only** with exactly these keys: 'id_front' => 'id_front',
'id_back' => 'id_back',
{ 'driving_license_front' => 'driver_license_front',
"full_name": "", // الاسم الثلاثي أو الرباعي 'driving_license_back' => 'driver_license_back',
"national_number": "", // الرقم الوطني (LATIN digits only) 'vehicle_license_front' => 'car_license_front',
"dob": "YYYY-MM-DD", // تاريخ الميلاد 'vehicle_license_back' => 'car_license_back',
"address": "" // العنوان 'criminal_record' => 'criminal_record',
}
### 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 __construct()
{
$this->prompts = SiroService::getDocumentPrompts($this->country);
}
public function handleStep(string $step, array $messageData, array &$context): FlowResult public function handleStep(string $step, array $messageData, array &$context): FlowResult
{ {
$text = isset($messageData['body']) ? trim($messageData['body']) : ''; $text = isset($messageData['body']) ? trim($messageData['body']) : '';
$phone = $messageData['phone']; $phone = $messageData['phone'];
$companyId = $context['company_id'] ?? 1; $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 currently postponed and user sends a message, resume the flow
if ($step === 'postponed') { if ($step === 'postponed') {
// Cancel active reminder
$activeReminder = DriverReminder::findActive($companyId, $phone); $activeReminder = DriverReminder::findActive($companyId, $phone);
if ($activeReminder) { if ($activeReminder) {
DriverReminder::update($activeReminder['id'], ['status' => 'cancelled']); DriverReminder::update($activeReminder['id'], ['status' => 'cancelled']);
} }
// Restore previous step
$step = $context['previous_step'] ?? 'ask_name'; $step = $context['previous_step'] ?? 'ask_name';
} }
@@ -188,7 +94,6 @@ EOT
$context['postpone_count'] = $postponeCount; $context['postpone_count'] = $postponeCount;
$context['previous_step'] = $step; $context['previous_step'] = $step;
// Schedule reminder
$scheduledAt = date('Y-m-d H:i:s', strtotime("+{$hours} hours")); $scheduledAt = date('Y-m-d H:i:s', strtotime("+{$hours} hours"));
DriverReminder::saveReminder([ DriverReminder::saveReminder([
'company_id' => $companyId, 'company_id' => $companyId,
@@ -209,7 +114,7 @@ EOT
switch ($step) { switch ($step) {
case 'start': case 'start':
return new FlowResult( return new FlowResult(
"أهلاً بك كابتن في خدمة تسجيل كباتن تطبيق انطلق 🚖.\nيرجى إرسال اسمك الثلاثي الكامل للبدء:", "أهلاً بك كابتن في خدمة تسجيل كباتن تطبيق {$appName} في {$countryName} 🚖.\nيرجى إرسال اسمك الثلاثي الكامل للبدء:",
"ask_name" "ask_name"
); );
@@ -306,6 +211,17 @@ EOT
return new FlowResult("عذراً، فشل حفظ الصورة. الرجاء إعادة إرسال صورة الوثيقة:", "criminal_record"); 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 // Securely save registration data to local database
try { try {
DriverOcrData::saveSecure([ DriverOcrData::saveSecure([
@@ -332,11 +248,83 @@ EOT
return new FlowResult("عذراً، حدث خطأ أثناء حفظ طلبك في قاعدة البيانات. يرجى المحاولة مرة أخرى لاحقاً.", "criminal_record"); return new FlowResult("عذراً، حدث خطأ أثناء حفظ طلبك في قاعدة البيانات. يرجى المحاولة مرة أخرى لاحقاً.", "criminal_record");
} }
return new FlowResult( // Register driver in Siro with Siro-hosted URLs
"شكراً لك كابتن، لقد تم استلام كافة المستندات والتحقق منها بنجاح. سيقوم فريق خدمة عملاء انطلق بمراجعة طلبك وتفعيل حسابك بأسرع وقت ممكن. يومك سعيد! 🚖", $docUrls = [
"finished", 'id_front' => $context['id_front_siro_url'] ?? '',
true '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: default:
return new FlowResult("خطأ في تحديد خطوة المسار.", "finished", true); return new FlowResult("خطأ في تحديد خطوة المسار.", "finished", true);
@@ -365,6 +353,23 @@ EOT
return new FlowResult("عذراً، فشل حفظ الصورة. الرجاء إعادة المحاولة وإرسال الصورة:", $step); 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; $companyId = $context['company_id'] ?? 1;
// Check subscription limit for OCR // Check subscription limit for OCR

View File

@@ -130,6 +130,7 @@ class DriverOcrData extends BaseModel
`vehicle_license_back_ocr` TEXT DEFAULT NULL, `vehicle_license_back_ocr` TEXT DEFAULT NULL,
`criminal_record_url` VARCHAR(512) DEFAULT NULL, `criminal_record_url` VARCHAR(512) DEFAULT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'pending', `status` VARCHAR(50) NOT NULL DEFAULT 'pending',
`syro_driver_id` VARCHAR(100) DEFAULT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `unique_company_driver_phone` (`company_id`, `phone_hash`), UNIQUE KEY `unique_company_driver_phone` (`company_id`, `phone_hash`),

View File

@@ -0,0 +1,520 @@
<?php
namespace App\Services;
/**
* SiroService
* Handles communication between Nabeh and the Siro ride-hailing platform APIs.
* Supports Syria, Jordan, and Egypt with country detection from phone numbers.
*/
class SiroService
{
private const COUNTRY_SYRIA = 'syria';
private const COUNTRY_JORDAN = 'jordan';
private const COUNTRY_EGYPT = 'egypt';
private static array $countryPrefixes = [
'963' => self::COUNTRY_SYRIA,
'00963' => self::COUNTRY_SYRIA,
'962' => self::COUNTRY_JORDAN,
'00962' => self::COUNTRY_JORDAN,
'20' => self::COUNTRY_EGYPT,
'0020' => self::COUNTRY_EGYPT,
];
private static array $countryPhonePrefixes = [
self::COUNTRY_SYRIA => '963',
self::COUNTRY_JORDAN => '962',
self::COUNTRY_EGYPT => '20',
];
private static array $countryEnvKeys = [
self::COUNTRY_SYRIA => 'SIRO_API_URL_SYRIA',
self::COUNTRY_JORDAN => 'SIRO_API_URL_JORDAN',
self::COUNTRY_EGYPT => 'SIRO_API_URL_EGYPT',
];
/**
* Detect country from phone number
*/
public static function detectCountry(string $phone): string
{
$cleaned = preg_replace('/[^0-9]/', '', $phone);
foreach (self::$countryPrefixes as $prefix => $country) {
if (strpos($cleaned, $prefix) === 0) {
return $country;
}
}
return self::COUNTRY_SYRIA;
}
/**
* Get country-specific API base URL
*/
public static function getApiUrl(string $country): string
{
$envKey = self::$countryEnvKeys[$country] ?? self::$countryEnvKeys[self::COUNTRY_SYRIA];
return rtrim(getenv($envKey) ?: 'https://api-syria.siromove.com/siro', '/');
}
/**
* Format phone number to international format for the given country
*/
public static function formatPhone(string $phone, string $country): string
{
$cleaned = preg_replace('/[^0-9]/', '', $phone);
$prefix = self::$countryPhonePrefixes[$country] ?? '963';
if (strpos($cleaned, $prefix) === 0) {
return $cleaned;
}
if (strpos($cleaned, '00' . $prefix) === 0) {
return $prefix . substr($cleaned, strlen($prefix) + 2);
}
if (strpos($cleaned, '0') === 0) {
return $prefix . substr($cleaned, 1);
}
return $prefix . $cleaned;
}
/**
* Upload a document image to Siro and return the signed URL
*/
public static function uploadDocument(
string $country,
string $driverId,
string $docType,
string $imagePath,
string $imageMimeType
): ?string {
$apiUrl = self::getApiUrl($country);
$apiKey = getenv('NABEH_API_KEY') ?: '';
$uploadUrl = rtrim($apiUrl, '/') . '/nabeh/upload_document.php';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $uploadUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
$headers = [
'X-API-Key: ' . $apiKey,
];
$body = [
'driver_id' => $driverId,
'doc_type' => $docType,
];
if (file_exists($imagePath)) {
$body['file'] = new \CURLFile($imagePath, $imageMimeType);
} else {
error_log("[SiroService] Image not found: {$imagePath}");
return null;
}
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$res = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
error_log("[SiroService] Upload failed for {$docType}: HTTP {$httpCode} - {$res}");
return null;
}
$data = json_decode($res, true);
$fileUrl = $data['message']['file_url'] ?? null;
if (!$fileUrl) {
error_log("[SiroService] Upload response missing file_url: {$res}");
}
return $fileUrl;
}
/**
* Register a driver in Siro via the simplified Nabeh integration endpoint
*/
public static function registerDriver(array $driverData, array $carData, array $documentUrls, string $country): ?array
{
$apiUrl = self::getApiUrl($country);
$apiKey = getenv('NABEH_API_KEY') ?: '';
$registerUrl = $apiUrl . '/nabeh/register.php';
$payload = array_merge($driverData, $carData, [
'id_front_url' => $documentUrls['id_front'] ?? '',
'id_back_url' => $documentUrls['id_back'] ?? '',
'driver_license_front_url' => $documentUrls['driving_license_front'] ?? '',
'driver_license_back_url' => $documentUrls['driving_license_back'] ?? '',
'vehicle_license_front_url' => $documentUrls['vehicle_license_front'] ?? '',
'vehicle_license_back_url' => $documentUrls['vehicle_license_back'] ?? '',
'criminal_record_url' => $documentUrls['criminal_record'] ?? '',
'profile_picture' => $documentUrls['profile_picture'] ?? '',
]);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $registerUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'X-API-Key: ' . $apiKey,
'Content-Type: application/json',
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$res = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
error_log("[SiroService] Registration failed: HTTP {$httpCode} - {$res}");
return null;
}
return json_decode($res, true);
}
/**
* Check driver registration status in Siro
*/
public static function checkDriverStatus(string $phone, string $country): ?array
{
$apiUrl = self::getApiUrl($country);
$apiKey = getenv('NABEH_API_KEY') ?: '';
$statusUrl = $apiUrl . '/nabeh/driver_status.php';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $statusUrl . '?phone=' . urlencode($phone));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'X-API-Key: ' . $apiKey,
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$res = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
return null;
}
return json_decode($res, true);
}
/**
* Query Siro platform for driver info, trips, stats, or trip details
*/
public static function querySiro(string $country, array $params): ?array
{
$apiUrl = self::getApiUrl($country);
$apiKey = getenv('NABEH_API_KEY') ?: '';
$queryUrl = $apiUrl . '/nabeh/query.php';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $queryUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'X-API-Key: ' . $apiKey,
'Content-Type: application/json',
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$res = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
error_log("[SiroService] Query failed: HTTP {$httpCode} - {$res}");
return null;
}
return json_decode($res, true);
}
/**
* Get OCR prompts for a specific country
*/
public static function getDocumentPrompts(string $country): array
{
$syriaPrompts = [
"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": "",
"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.
* Return valid JSON only no extra keys, no markdown.
EOT,
"id_back" => <<<EOT
أنت خبير OCR مختص ببطاقات الهوية السورية (الوجه الخلفي).
### المطلوب
حلّل صورة الوجه الخلفي للهوية السورية وأعد **JSON صِرف**:
{
"governorate": "",
"address": "",
"gender": "",
"issue_date": "YYYY-MM-DD"
}
### القواعد
1. حوّل أي أرقام عربية شرقية إلى أرقام لاتينية.
2. لا تُرجع أي مفاتيح إضافية أو شروح.
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**:
{
"name_arabic": "",
"birth_place": "",
"birth_year": "",
"national_number": "",
"civil_registry": "",
"blood_type": ""
}
EOT,
"driving_license_back" => <<<EOT
You are an OCR expert for Syrian driving licences.
### TASK
Analyse the **back side** of a Syrian driving licence:
{
"issue_date": "YYYY-MM-DD",
"expiry_date": "YYYY-MM-DD",
"license_number": "",
"license_category": ""
}
EOT,
"vehicle_license_front" => <<<EOT
You are an OCR expert for Syrian orange vehicle cards.
### TASK
Analyse the **front side**:
{
"car_plate": "",
"owner": "",
"vin": "",
"color": "",
"color_hex": "",
"issue_date": "YYYY-MM-DD",
"inspection_date": "YYYY-MM-DD"
}
EOT,
"vehicle_license_back" => <<<EOT
You are an OCR expert for Syrian orange vehicle cards.
### TASK
Analyse the **back side**:
{
"make": "",
"model": "",
"year": "",
"fuel": "",
"chassis": ""
}
EOT,
];
$jordanPrompts = [
"id_front" => <<<EOT
You are an OCR expert for Jordanian national ID cards.
### TASK
Analyse the **front side** of the Jordanian ID and return **raw JSON only**:
{
"full_name": "",
"national_number": "",
"dob": "YYYY-MM-DD",
"address": ""
}
Extract the national number from the ID. Return valid JSON only.
EOT,
"id_back" => <<<EOT
You are an OCR expert for Jordanian national ID cards.
### TASK
Analyse the **back side** of the Jordanian ID:
{
"governorate": "",
"gender": "",
"issue_date": "YYYY-MM-DD",
"expiry_date": "YYYY-MM-DD"
}
EOT,
"driving_license_front" => <<<EOT
You are an OCR expert for Jordanian driving licences.
### TASK
Analyse the **front side**:
{
"name_arabic": "",
"license_number": "",
"issue_date": "YYYY-MM-DD",
"expiry_date": "YYYY-MM-DD",
"license_category": ""
}
EOT,
"driving_license_back" => <<<EOT
You are an OCR expert for Jordanian driving licences.
### TASK
Analyse the **back side**:
{
"blood_type": "",
"birth_place": "",
"notes": ""
}
EOT,
"vehicle_license_front" => <<<EOT
You are an OCR expert for Jordanian vehicle registration cards.
### TASK
Analyse the **front side**:
{
"car_plate": "",
"owner": "",
"vin": "",
"color": "",
"make": "",
"model": "",
"year": ""
}
EOT,
"vehicle_license_back" => <<<EOT
You are an OCR expert for Jordanian vehicle cards.
### TASK
Analyse the **back side**:
{
"fuel": "",
"chassis": "",
"inspection_date": "YYYY-MM-DD"
}
EOT,
];
$egyptPrompts = [
"id_front" => <<<EOT
You are an OCR expert for Egyptian national ID cards.
### TASK
Analyse the **front side** of the Egyptian ID:
{
"full_name": "",
"national_number": "",
"dob": "YYYY-MM-DD",
"address": ""
}
EOT,
"id_back" => <<<EOT
You are an OCR expert for Egyptian national ID cards.
### TASK
Analyse the **back side**:
{
"governorate": "",
"gender": "",
"issue_date": "YYYY-MM-DD",
"expiry_date": "YYYY-MM-DD"
}
EOT,
"driving_license_front" => <<<EOT
You are an OCR expert for Egyptian driving licences.
### TASK
Analyse the **front side**:
{
"name_arabic": "",
"license_number": "",
"issue_date": "YYYY-MM-DD",
"expiry_date": "YYYY-MM-DD",
"license_category": ""
}
EOT,
"driving_license_back" => <<<EOT
You are an OCR expert for Egyptian driving licences.
### TASK
Analyse the **back side**:
{
"blood_type": "",
"address": "",
"birth_place": ""
}
EOT,
"vehicle_license_front" => <<<EOT
You are an OCR expert for Egyptian vehicle registration cards.
### TASK
Analyse the **front side**:
{
"car_plate": "",
"owner": "",
"vin": "",
"color": "",
"make": "",
"model": "",
"year": ""
}
EOT,
"vehicle_license_back" => <<<EOT
You are an OCR expert for Egyptian vehicle cards.
### TASK
Analyse the **back side**:
{
"fuel": "",
"chassis": "",
"registration_date": "YYYY-MM-DD"
}
EOT,
];
return match ($country) {
self::COUNTRY_JORDAN => $jordanPrompts,
self::COUNTRY_EGYPT => $egyptPrompts,
default => $syriaPrompts,
};
}
}

View File

@@ -0,0 +1 @@
[{"/app/bootstrap.php":"use AppCoreDatabase;\n\ntry {\n $pdo = Database::getConnection();\n\n echo","Migrations":"Telegram Bot Integration ===","\n CREATE TABLE IF NOT EXISTS `telegram_sessions` (\n `id` INT AUTO_INCREMENT PRIMARY KEY,\n `company_id` INT NOT NULL,\n `name` VARCHAR(255) NOT NULL COMMENT 'e.g., Support Bot, Sales Bot',\n `bot_token` TEXT NOT NULL COMMENT 'Encrypted using AES-256-GCM',\n `bot_username` VARCHAR(255) DEFAULT NULL,\n `bot_id` BIGINT DEFAULT NULL COMMENT 'Telegram Bot ID from getMe',\n `webhook_url` TEXT DEFAULT NULL,\n `status` ENUM('connected', 'disconnected', 'error') DEFAULT 'disconnected',\n `allowed_updates` JSON DEFAULT NULL COMMENT 'Array of update types to receive',\n `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n FOREIGN KEY (`company_id`) REFERENCES `companies`(`id`) ON DELETE CASCADE,\n UNIQUE KEY `company_bot_unique` (`company_id`, `bot_username`)\n ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;":"Database::execute($createSessionsTableSql);\n echo","telegram_sessions":"erified/created.","Database":"select(","telegram_session_id":"if (empty($columns)) {\n Database::execute(","meta_session_id`":"Database::execute(","✅ Added 'telegram_session_id' column and foreign key constraint to 'messages_log'.":""},{" Column 'telegram_session_id' already exists in 'messages_log'. Skipping.":""},{"Database":"execute(","session_id`":"Database::execute(","✅ Added 'telegram_session_id' column to 'chatbot_rules'.":""},{" Column 'telegram_session_id' already exists in 'chatbot_rules'. Skipping.":""},{"Database":"execute(","company_id`":"Database::execute(","✅ Added 'telegram_session_id' column to 'conversation_states'.":""},{" Column 'telegram_session_id' already exists in 'conversation_states'. Skipping.":""},{"Database":"execute(","✅ Added 'telegram_requests' column to 'company_subscription_usage'.":""},{" Column 'telegram_requests' already exists in 'company_subscription_usage'. Skipping.":""},{"❌ Migration error: \" . $e->getMessage() . \"":""}]

View File

@@ -160,6 +160,155 @@ $router->post('/api/integrations/woocommerce/disconnect', [\App\Controllers\WooC
$router->post('/api/webhooks/woocommerce', [\App\Controllers\WooCommerceController::class, 'webhook']); $router->post('/api/webhooks/woocommerce', [\App\Controllers\WooCommerceController::class, 'webhook']);
// ============================================
// Siro Integration API Endpoints
// ============================================
// Siro Driver Info - Returns real-time driver data to Siro
$router->post('/api/siro/driver-info', function ($request, $response) {
$apiKey = getenv('NABEH_API_KEY');
$incomingKey = $request->getHeader('x-api-key') ?? '';
if (empty($apiKey) || $incomingKey !== $apiKey) {
$response->status(401)->json([
'status' => 'error',
'message' => 'Unauthorized'
]);
return;
}
$body = $request->getBody();
$phone = $body['phone'] ?? '';
if (empty($phone)) {
$response->status(400)->json([
'status' => 'error',
'message' => 'Missing phone number'
]);
return;
}
// Find driver OCR data
$hash = \App\Core\Security::blindIndex($phone);
$record = \App\Core\Database::selectOne(
"SELECT * FROM driver_ocr_data WHERE phone_hash = ? LIMIT 1",
[$hash]
);
$response->json([
'status' => 'success',
'data' => $record ? \App\Models\DriverOcrData::decryptRecord($record) : null
]);
});
// Siro Registration Status Check
$router->get('/api/siro/registration-status', function ($request, $response) {
$apiKey = getenv('NABEH_API_KEY');
$incomingKey = $request->getHeader('x-api-key') ?? '';
if (empty($apiKey) || $incomingKey !== $apiKey) {
$response->status(401)->json([
'status' => 'error',
'message' => 'Unauthorized'
]);
return;
}
$phone = $request->get('phone') ?? '';
if (empty($phone)) {
$response->status(400)->json([
'status' => 'error',
'message' => 'Missing phone parameter'
]);
return;
}
$hash = \App\Core\Security::blindIndex($phone);
$record = \App\Core\Database::selectOne(
"SELECT id, name, status, created_at, updated_at FROM driver_ocr_data WHERE phone_hash = ? LIMIT 1",
[$hash]
);
if (!$record) {
$response->json([
'status' => 'success',
'data' => null,
'message' => 'No registration found for this phone'
]);
return;
}
$response->json([
'status' => 'success',
'data' => $record
]);
});
// Siro Webhook - Receives driver activation confirmations from Siro
$router->post('/api/siro/webhook', function ($request, $response) {
$apiKey = getenv('NABEH_API_KEY');
$incomingKey = $request->getHeader('x-api-key') ?? '';
if (empty($apiKey) || $incomingKey !== $apiKey) {
$response->status(401)->json([
'status' => 'error',
'message' => 'Unauthorized'
]);
return;
}
$body = $request->getBody();
$phone = $body['phone'] ?? '';
$syroDriverId = $body['driver_id'] ?? '';
$event = $body['event'] ?? '';
$status = $body['status'] ?? '';
if (empty($phone) || empty($event)) {
$response->status(400)->json([
'status' => 'error',
'message' => 'Missing required fields: phone, event'
]);
return;
}
error_log("[Siro Webhook] Event: {$event}, Phone: {$phone}, DriverID: {$syroDriverId}, Status: {$status}");
$hash = \App\Core\Security::blindIndex($phone);
if ($event === 'driver_activated' && $status === 'actives') {
\App\Core\Database::execute(
"UPDATE driver_ocr_data SET status = 'registered', syro_driver_id = ? WHERE phone_hash = ?",
[$syroDriverId, $hash]
);
$response->json([
'status' => 'success',
'message' => 'Driver status updated'
]);
return;
}
if ($event === 'driver_rejected') {
\App\Core\Database::execute(
"UPDATE driver_ocr_data SET status = 'rejected' WHERE phone_hash = ?",
[$hash]
);
$response->json([
'status' => 'success',
'message' => 'Driver rejected'
]);
return;
}
$response->json([
'status' => 'success',
'message' => 'Event received'
]);
});
// Mock External API for Entaleq Driver Info (Used to fetch real-time driver data) // Mock External API for Entaleq Driver Info (Used to fetch real-time driver data)
$router->post('/api/external/driver-info', function ($request, $response) { $router->post('/api/external/driver-info', function ($request, $response) {
$body = $request->getBody(); $body = $request->getBody();

View File

@@ -23,6 +23,7 @@
--success: #34a853; /* Google Green */ --success: #34a853; /* Google Green */
--warning: #fbbc05; /* Google Yellow */ --warning: #fbbc05; /* Google Yellow */
--danger: #ea4335; /* Google Red */ --danger: #ea4335; /* Google Red */
--telegram: #229ED9; /* Telegram Blue */
--text-main: #202124; --text-main: #202124;
--text-muted: #5f6368; --text-muted: #5f6368;
@@ -200,6 +201,29 @@
display: block; display: block;
} }
/* Channel Pills */
.channel-pills {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
margin-bottom: 1.5rem;
}
.channel-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.85rem;
border-radius: 999px;
background: #f1f3f4;
color: var(--text-muted);
font-size: 0.8rem;
font-weight: 700;
}
.channel-pill.telegram { background: rgba(34, 158, 217, 0.1); color: var(--telegram); }
.channel-pill.whatsapp { background: rgba(52, 168, 83, 0.1); color: var(--success); }
.channel-pill.meta { background: rgba(26, 115, 232, 0.1); color: var(--primary); }
/* Features Section */ /* Features Section */
.features { .features {
padding: 5rem 0; padding: 5rem 0;
@@ -253,8 +277,9 @@
.feature-card:nth-child(2) .feature-icon { background: rgba(52, 168, 83, 0.1); color: var(--success); } .feature-card:nth-child(2) .feature-icon { background: rgba(52, 168, 83, 0.1); color: var(--success); }
.feature-card:nth-child(3) .feature-icon { background: rgba(251, 188, 5, 0.1); color: var(--warning); } .feature-card:nth-child(3) .feature-icon { background: rgba(251, 188, 5, 0.1); color: var(--warning); }
.feature-card:nth-child(4) .feature-icon { background: rgba(234, 67, 53, 0.1); color: var(--danger); } .feature-card:nth-child(4) .feature-icon { background: rgba(234, 67, 53, 0.1); color: var(--danger); }
.feature-card:nth-child(5) .feature-icon { background: rgba(26, 115, 232, 0.1); color: var(--primary); } .feature-card:nth-child(5) .feature-icon { background: rgba(34, 158, 217, 0.1); color: var(--telegram); }
.feature-card:nth-child(6) .feature-icon { background: rgba(52, 168, 83, 0.1); color: var(--success); } .feature-card:nth-child(6) .feature-icon { background: rgba(26, 115, 232, 0.1); color: var(--primary); }
.feature-card:nth-child(7) .feature-icon { background: rgba(52, 168, 83, 0.1); color: var(--success); }
.feature-card h3 { .feature-card h3 {
font-size: 1.25rem; font-size: 1.25rem;
@@ -597,7 +622,13 @@
<div class="container hero-grid"> <div class="container hero-grid">
<div class="hero-content"> <div class="hero-content">
<h1>أتمتة خدمة العملاء والردود الذكية المتكاملة</h1> <h1>أتمتة خدمة العملاء والردود الذكية المتكاملة</h1>
<p>منصة نبيه تمنحك إمكانية ربط قنوات الاتصال والواتساب وتشغيل مساعد ذكي مدعوم بـ AI يقوم بقراءة الرسائل والصور والإجابة على العملاء فورياً لزيادة مبيعات متجرك وتفعيل أتمتة الدعم والعمليات بمعدل 5 أضعاف.</p> <p>منصة نبيه تمنحك إمكانية ربط قنوات الاتصال (واتساب، تيليجرام، ماسنجر، إنستغرام) وتشغيل مساعد ذكي مدعوم بـ AI يقوم بقراءة الرسائل والصور والإجابة على العملاء فورياً لزيادة مبيعات متجرك وتفعيل أتمتة الدعم والعمليات بمعدل 5 أضعاف.</p>
<div class="channel-pills">
<span class="channel-pill whatsapp">💚 واتساب</span>
<span class="channel-pill telegram">✈️ تيليجرام</span>
<span class="channel-pill meta">💬 ماسنجر</span>
<span class="channel-pill meta">📷 إنستغرام</span>
</div>
<div class="hero-actions"> <div class="hero-actions">
<a href="/register" class="btn btn-primary">ابدأ تجربتك المجانية (14 يوم)</a> <a href="/register" class="btn btn-primary">ابدأ تجربتك المجانية (14 يوم)</a>
<a href="#demo" class="btn btn-secondary">جرب المحاكاة التفاعلية</a> <a href="#demo" class="btn btn-secondary">جرب المحاكاة التفاعلية</a>
@@ -638,6 +669,12 @@
<p>الربط المباشر مع سلة (Salla) و WooCommerce لإرسال تحديثات حالة الطلب تلقائياً وتذكير العملاء بالسلات المتروكة.</p> <p>الربط المباشر مع سلة (Salla) و WooCommerce لإرسال تحديثات حالة الطلب تلقائياً وتذكير العملاء بالسلات المتروكة.</p>
</div> </div>
<div class="feature-card">
<div class="feature-icon">✈️</div>
<h3>دعم بوتات تيليجرام (Telegram)</h3>
<p>ربط بوتات التيليجرام مع المنصة لإدارة المحادثات الخاصة والمجموعات والقنوات، وتشغيل الردود التلقائية وأتمتة الدعم عبر Telegram Bot API.</p>
</div>
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon">💬</div> <div class="feature-icon">💬</div>
<h3>ربط قنوات ماسنجر وإنستغرام</h3> <h3>ربط قنوات ماسنجر وإنستغرام</h3>
@@ -782,10 +819,10 @@
</div> </div>
<div class="roadmap-grid"> <div class="roadmap-grid">
<div class="roadmap-card"> <div class="roadmap-card" style="border-color: var(--telegram);">
<span class="badge-soon">قريباً</span> <span style="position: absolute; top: 1rem; left: 1rem; background: rgba(34, 158, 217, 0.15); border: 1px solid rgba(34, 158, 217, 0.3); color: #229ED9; padding: 0.2rem 0.6rem; border-radius: 20px; font-size: 0.75rem; font-weight: 700;">متاح الآن ✓</span>
<h3>بوتات تيليجرام (Telegram)</h3> <h3 style="margin-top: 2.25rem;">بوتات تيليجرام (Telegram)</h3>
<p>أتمتة المحادثات والرد التلقائي وإدارة مجموعات وقنوات التيليجرام بمساعد الذكاء الاصطناعي مباشرة.</p> <p>أتمتة المحادثات والرد التلقائي وإدارة مجموعات وقنوات التيليجرام بمساعد الذكاء الاصطناعي مباشرة. متاح للربط في جميع الباقات المدفوعة.</p>
</div> </div>
<div class="roadmap-card"> <div class="roadmap-card">
@@ -864,6 +901,10 @@
reply = 'يمكنك بدء التجربة المجانية لمدة 14 يوماً فوراً دون الحاجة لبطاقة ائتمان من خلال الضغط على زر "ابدأ مجاناً" أعلى الصفحة.'; reply = 'يمكنك بدء التجربة المجانية لمدة 14 يوماً فوراً دون الحاجة لبطاقة ائتمان من خلال الضغط على زر "ابدأ مجاناً" أعلى الصفحة.';
} else if (msgLower.includes('سلة') || msgLower.includes('woocommerce') || msgLower.includes('متجر')) { } else if (msgLower.includes('سلة') || msgLower.includes('woocommerce') || msgLower.includes('متجر')) {
reply = 'نعم! تدعم باقاتنا المتقدمة والاحترافية التكامل الفوري والمباشر مع متاجر سلة و WooCommerce لمتابعة الطلبات وتذكير السلات المتروكة.'; reply = 'نعم! تدعم باقاتنا المتقدمة والاحترافية التكامل الفوري والمباشر مع متاجر سلة و WooCommerce لمتابعة الطلبات وتذكير السلات المتروكة.';
} else if (msgLower.includes('تيليجرام') || msgLower.includes('telegram') || msgLower.includes('بوت')) {
reply = 'نعم! منصة نبيه تدعم بوتات تيليجرام بالكامل! يمكنك ربط بوت التيليجرام الخاص بك مع المنصة لإدارة المحادثات والمجموعات والقنوات وتشغيل الرد التلقائي بالذكاء الاصطناعي. الميزة متاحة في الباقات المدفوعة.';
} else if (msgLower.includes('قنوات') || msgLower.includes('channel') || msgLower.includes('واتساب') && msgLower.includes('تليجرام')) {
reply = 'منصة نبيه تدعم الربط مع واتساب، تيليجرام، ماسنجر، وإنستغرام من مكان واحد، مع تشغيل أتمتة موحدة بالذكاء الاصطناعي عبر جميع القنوات.';
} }
replyDiv.innerText = reply; replyDiv.innerText = reply;