From 9338e76894132aca02f724e6204a5b45f32472b5 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Fri, 7 Aug 2026 15:50:04 +0300 Subject: [PATCH] Update: 2026-08-07 15:50:04 --- backend/bot/cron_sms_requests.php | 114 +++++++ .../2026_08_07_sms_ride_requests.sql | 80 +++++ backend/ride/rides/acceptRide.php | 36 +++ backend/ride/rides/add_ride.php | 81 ++++- backend/sms/gemini_parser.php | 177 ++++++++++ backend/sms/helpers.php | 212 ++++++++++++ backend/sms/outbox_pull.php | 147 +++++++++ backend/sms/parser.php | 155 +++++++++ backend/sms/ride_request.php | 304 ++++++++++++++++++ backend/webhook_sms/webhook.php | 64 +++- docker/crontab.production | 9 + 11 files changed, 1375 insertions(+), 4 deletions(-) create mode 100644 backend/bot/cron_sms_requests.php create mode 100644 backend/migrations/2026_08_07_sms_ride_requests.sql create mode 100644 backend/sms/gemini_parser.php create mode 100644 backend/sms/helpers.php create mode 100644 backend/sms/outbox_pull.php create mode 100644 backend/sms/parser.php create mode 100644 backend/sms/ride_request.php diff --git a/backend/bot/cron_sms_requests.php b/backend/bot/cron_sms_requests.php new file mode 100644 index 00000000..2b0e7994 --- /dev/null +++ b/backend/bot/cron_sms_requests.php @@ -0,0 +1,114 @@ +getMessage() . "\n"); + exit(1); +} + +// ═══════════════════════════════════════════════════════════════ +// ١) تحرير العالق +// ═══════════════════════════════════════════════════════════════ +$freed = 0; +try { + $st = $con->prepare(" + UPDATE sms_ride_requests + SET status = 'pending' + WHERE status = 'dispatching' + AND updated_at < NOW() - INTERVAL " . SMS_STUCK_MINUTES . " MINUTE + "); + $st->execute(); + $freed = $st->rowCount(); +} catch (PDOException $e) { + fwrite(STDERR, '[sms-cron] تعذّر تحرير العالق: ' . $e->getMessage() . "\n"); +} + +// ═══════════════════════════════════════════════════════════════ +// ٢) إعادة محاولة المعلّق +// ═══════════════════════════════════════════════════════════════ +$retried = 0; +$created = 0; +try { + $st = $con->query(" + SELECT id, sender_norm, passenger_id, start_location, end_location, + start_name, end_name, distance, duration, attempts + FROM sms_ride_requests + WHERE status = 'pending' AND passenger_id IS NOT NULL + ORDER BY id ASC + LIMIT 50 + "); + + foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $req) { + $retried++; + if (smsProcessRequest($con, $req)) $created++; + } +} catch (PDOException $e) { + fwrite(STDERR, '[sms-cron] فشل إعادة المحاولة: ' . $e->getMessage() . "\n"); +} + +// ═══════════════════════════════════════════════════════════════ +// ٣) إبلاغ من لم يجد سائقاً +// +// ‏نعتمد على حالة الرحلة نفسها لا على مؤقّت منفصل: ما دامت 'waiting' +// ‏فلا سائق قَبِل. والوسم في fail_reason يمنع تكرار الإبلاغ كل دقيقة. +// ═══════════════════════════════════════════════════════════════ +$notified = 0; +try { + $st = $con->prepare(" + SELECT s.id, s.sender_norm, s.ride_id + FROM sms_ride_requests s + JOIN ride r ON r.id = s.ride_id + WHERE s.status = 'dispatched' + AND (s.fail_reason IS NULL OR s.fail_reason <> 'no_driver_notified') + AND r.status IN ('waiting', 'wait') + AND s.updated_at < NOW() - INTERVAL " . SMS_NO_DRIVER_MINUTES . " MINUTE + LIMIT 50 + "); + $st->execute(); + + foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) { + // ‏الوسم أولاً ثم الإرسال: لو انقطع التنفيذ بينهما، أسوأ نتيجة + // ‏رسالة لم تصل — لا رسالة تصل كل دقيقة. + $con->prepare("UPDATE sms_ride_requests SET fail_reason='no_driver_notified' WHERE id=?") + ->execute([$row['id']]); + + smsEnqueue($con, $row['sender_norm'], + 'سيرو: ما زلنا نبحث عن سائق قريب منك. سنرسل لك رسالة فور قبول أحدهم،' + . ' أو أعد إرسال طلبك بعد قليل.', + 'no_driver_yet', (string) $row['ride_id']); + $notified++; + } +} catch (PDOException $e) { + fwrite(STDERR, '[sms-cron] تعذّر إبلاغ من بلا سائق: ' . $e->getMessage() . "\n"); +} + +echo "[sms-cron] حُرِّر {$freed}، أُعيدت محاولة {$retried}، أُنشئ {$created}،" + . " أُبلغ {$notified}\n"; diff --git a/backend/migrations/2026_08_07_sms_ride_requests.sql b/backend/migrations/2026_08_07_sms_ride_requests.sql new file mode 100644 index 00000000..a9b92a2b --- /dev/null +++ b/backend/migrations/2026_08_07_sms_ride_requests.sql @@ -0,0 +1,80 @@ +-- ============================================================ +-- طلب رحلة بلا إنترنت — عبر SMS (بند 3.1 من دراسة الفرص) +-- +-- ‏المسار: الراكب يرسل رسالة نصية لرقمنا الثابت ← تطبيق أندرويد على +-- ‏ذلك الهاتف يوجّهها إلى webhook_sms/webhook.php ← نحلّلها وننشئ رحلة +-- ‏← نرد عليه برسالة، وحين يقبل سائق نرد برسالة ثانية فيها اسمه ولوحته. +-- +-- ‏لا مشغّل اتصالات ولا ترخيص ولا رقم قصير — شريحة عادية في هاتف عادي. +-- ============================================================ + +-- ── ١) الوارد الخام ───────────────────────────────────────── +-- ‏نحفظ كل رسالة تصل كما وصلت، قبل أي تحليل. السبب: المحلّل سيخطئ في +-- ‏البداية حتماً (اللهجات، الأخطاء الإملائية، صيغ لم نتوقّعها)، وبلا +-- ‏الخام لن نعرف *لماذا* أخطأ ولا كيف نحسّنه. +CREATE TABLE IF NOT EXISTS `sms_inbox` ( + `id` INT NOT NULL AUTO_INCREMENT, + `sender` VARCHAR(32) NOT NULL COMMENT 'رقم المرسل كما وصل من الهاتف', + `sender_norm` VARCHAR(32) NOT NULL COMMENT 'الرقم بعد التوحيد (بلا رموز، بمقدمة دولية)', + `body` TEXT NOT NULL, + `intent` ENUM('ride','payment','unknown') NOT NULL DEFAULT 'unknown', + `received_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sender_norm` (`sender_norm`), + KEY `idx_received` (`received_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── ٢) طلبات الرحلة المستخرجة ─────────────────────────────── +-- ‏جدول وسيط بين الرسالة والرحلة، لا اختصار له. وظيفته الأمنية أساساً: +-- ‏add_ride.php يفتح مساراً داخلياً بمفتاح S2S لهذه الطلبات، وشرط المرور +-- ‏وجود صف هنا بحالة 'dispatching' يطابق الراكب ونقطة الانطلاق — تماماً +-- ‏كما فعلنا مع الحجز المسبق. مفتاح مسرّب وحده لا يخلق رحلة. +CREATE TABLE IF NOT EXISTS `sms_ride_requests` ( + `id` INT NOT NULL AUTO_INCREMENT, + `inbox_id` INT DEFAULT NULL, + `sender_norm` VARCHAR(32) NOT NULL, + `passenger_id` VARCHAR(100) DEFAULT NULL COMMENT 'يُملأ بعد إيجاد/إنشاء الحساب', + `raw_start` VARCHAR(255) DEFAULT NULL COMMENT 'النص كما كتبه الراكب', + `raw_end` VARCHAR(255) DEFAULT NULL, + `start_location` VARCHAR(100) DEFAULT NULL COMMENT '"lat,lng"', + `end_location` VARCHAR(100) DEFAULT NULL, + `start_name` VARCHAR(255) DEFAULT NULL COMMENT 'اسم المعلم المطابَق', + `end_name` VARCHAR(255) DEFAULT NULL, + `distance` DOUBLE DEFAULT 0, + `duration` INT DEFAULT 0, + `status` ENUM('pending','dispatching','dispatched','failed','unparsed') + NOT NULL DEFAULT 'pending', + `ride_id` VARCHAR(100) DEFAULT NULL, + `attempts` TINYINT NOT NULL DEFAULT 0, + `fail_reason` VARCHAR(255) DEFAULT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_status_created` (`status`, `created_at`), + KEY `idx_ride` (`ride_id`), + KEY `idx_sender` (`sender_norm`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── ٣) الصادر ─────────────────────────────────────────────── +-- ‏طابور لا إرسال مباشر. مزوّدونا الحاليون (Kazumi / Nabeh / Intaleq) +-- ‏مسارات OTP لا رسائل حرة، فالمُرسِل اليوم هو نفس الهاتف المستقبِل: +-- ‏يسحب من هنا كل بضع ثوانٍ ويرسل عبر شريحته. الطابور يجعل استبدال +-- ‏المُرسِل لاحقاً (مشغّل، أو بوابة تجارية) تغييراً في طرف واحد. +CREATE TABLE IF NOT EXISTS `sms_outbox` ( + `id` INT NOT NULL AUTO_INCREMENT, + `recipient` VARCHAR(32) NOT NULL, + `body` TEXT NOT NULL, + `context` VARCHAR(64) DEFAULT NULL COMMENT 'ride_created / driver_found / error ...', + `ref_id` VARCHAR(100) DEFAULT NULL COMMENT 'رقم الرحلة أو الطلب المرتبط', + `status` ENUM('pending','sending','sent','failed') NOT NULL DEFAULT 'pending', + `attempts` TINYINT NOT NULL DEFAULT 0, + `claimed_by` VARCHAR(64) DEFAULT NULL COMMENT 'معرّف الهاتف الذي سحب الرسالة', + `claimed_at` DATETIME DEFAULT NULL, + `sent_at` DATETIME DEFAULT NULL, + `last_error` VARCHAR(255) DEFAULT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + -- ‏الفهرس على (status, id) هو ما يجعل السحب رخيصاً مهما كبر الجدول. + KEY `idx_status_id` (`status`, `id`), + KEY `idx_claimed` (`status`, `claimed_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/ride/rides/acceptRide.php b/backend/ride/rides/acceptRide.php index b0647228..1b89d03b 100644 --- a/backend/ride/rides/acceptRide.php +++ b/backend/ride/rides/acceptRide.php @@ -391,6 +391,42 @@ try { 'passenger_id' => $passengerIdValue ?? '', ]); + // ═══════════════════════════════════════════════════════════ + // ‏الراكب الذي طلب برسالة نصية لا يملك إنترنت: السوكيت لن يصله، + // ‏والإشعار الصامت أعلاه يذهب إلى العدم. الرسالة النصية هي قناته + // ‏الوحيدة لمعرفة أن سائقاً قَبِل، ومن هو، وكيف يعرف سيارته. + // ═══════════════════════════════════════════════════════════ + try { + $stmtSms = $con->prepare(" + SELECT sender_norm FROM sms_ride_requests + WHERE ride_id = ? AND status = 'dispatched' LIMIT 1 + "); + $stmtSms->execute([$rideId]); + $smsSender = $stmtSms->fetchColumn(); + + if ($smsSender) { + require_once __DIR__ . '/../../sms/helpers.php'; + + $plate = trim((string) ($driverInfo['car_plate'] ?? '')); + $car = trim(($driverInfo['make'] ?? '') . ' ' . ($driverInfo['model'] ?? '')); + $color = trim((string) ($driverInfo['color'] ?? '')); + + $lines = ['سيرو: وجدنا لك سائقاً.']; + $lines[] = 'السائق: ' . ($driverInfo['driverName'] ?? '—'); + if (!empty($driverInfo['phone'])) $lines[] = 'هاتفه: ' . $driverInfo['phone']; + if ($car !== '' || $color !== '') $lines[] = 'السيارة: ' . trim("$car $color"); + if ($plate !== '') $lines[] = 'اللوحة: ' . $plate; + $lines[] = 'سيتصل بك عند وصوله.'; + + smsEnqueue($con, (string) $smsSender, implode("\n", $lines), + 'driver_found', (string) $rideId); + } + } catch (Throwable $eSms) { + // ‏لا نُسقط قبول الرحلة من أجل رسالة: السائق قَبِل فعلاً، + // ‏والرسالة الفائتة أهون من رحلة تُلغى بخطأ. + error_log('[accept_ride] تعذّر تجهيز رسالة SMS للراكب: ' . $eSms->getMessage()); + } + error_log("[accept_ride] SUCCESS. RideID=$rideId accepted by DriverID=$driverId"); // ═══════════════════════════════════════════════════════════ diff --git a/backend/ride/rides/add_ride.php b/backend/ride/rides/add_ride.php index 36b8f8b3..5c7144e6 100644 --- a/backend/ride/rides/add_ride.php +++ b/backend/ride/rides/add_ride.php @@ -122,6 +122,11 @@ error_log("[add_ride] Request started. passenger_id=" . ($_POST['passenger_id'] $isScheduledRide = filterRequest("source") === 'scheduled'; $scheduledAtLabel = filterRequest("scheduled_at") ?: ''; +// ‏وسم الطلب بالرسائل النصية: يمرّره sms/ride_request.php لراكب لا +// ‏يملك إنترنت. تطبيق السائق يقرأ الوسم ليعرف أن التواصل بالاتصال لا +// ‏بالمحادثة — الراكب لن يرى أي إشعار في التطبيق. +$isSmsRide = filterRequest("source") === 'sms'; + $start_location = filterRequest("start_location"); $end_location = filterRequest("end_location"); $price = filterRequest("price"); @@ -145,6 +150,11 @@ $price_token = filterRequest("price_token"); // ══════════════════════════════════════════════════════════════ $isInternalScheduled = false; +// ‏يُملأ أحدهما فقط حسب المصدر. التصريح بهما هنا يمنع تحذير «متغيّر +// ‏غير معرّف» في كتلة التسعير الداخلي التي تقرأ الاثنين. +$booking = null; +$smsReq = null; + if ($isScheduledRide) { $providedKey = $_SERVER['HTTP_X_S2S_API_KEY'] ?? ''; $expectedKey = getenv('S2S_SHARED_KEY') ?: ''; @@ -196,6 +206,62 @@ if ($isScheduledRide) { $end_location = $booking['end_location']; $carType = $booking['car_type']; $distance = $booking['distance']; +} elseif ($isSmsRide) { + // ══════════════════════════════════════════════════════════ + // ‏نفس البوّابة المزدوجة بالضبط، على جدول sms_ride_requests. + // + // ‏الفرق عن الحجز المسبق أن مصدر الطلب هنا رسالة نصية من رقم لا + // ‏يحمل JWT إطلاقاً — فالشرط الثاني هو كل الحماية: لا رحلة إلا + // ‏مقابل صف أنشأه الويب‌هوك من رسالة فعلية وقفله للتوّ. + // ══════════════════════════════════════════════════════════ + $providedKey = $_SERVER['HTTP_X_S2S_API_KEY'] ?? ''; + $expectedKey = getenv('S2S_SHARED_KEY') ?: ''; + $smsRequestId = (int) (filterRequest('sms_request_id', 'int') ?: 0); + $claimedPassenger = filterRequest('passenger_id'); + + if (empty($expectedKey) || !hash_equals($expectedKey, (string) $providedKey)) { + error_log('[add_ride] SECURITY: مسار SMS بمفتاح S2S غير صالح'); + printFailure('Unauthorized'); + exit; + } + + if ($smsRequestId <= 0 || empty($claimedPassenger)) { + printFailure('Missing sms_request_id or passenger_id'); + exit; + } + + try { + $stmtSms = $con->prepare(" + SELECT passenger_id, start_location, end_location, distance, duration + FROM sms_ride_requests + WHERE id = ? AND status = 'dispatching' LIMIT 1 + "); + $stmtSms->execute([$smsRequestId]); + $smsReq = $stmtSms->fetch(PDO::FETCH_ASSOC); + } catch (PDOException $eS) { + error_log('[add_ride] تعذّرت قراءة طلب SMS: ' . $eS->getMessage()); + printFailure('Server error'); + exit; + } + + if (!$smsReq) { + error_log("[add_ride] SECURITY: طلب SMS #$smsRequestId غير موجود أو ليس قيد الإطلاق"); + printFailure('Invalid sms request'); + exit; + } + + if ((string) $smsReq['passenger_id'] !== (string) $claimedPassenger + || !coordsMatch($smsReq['start_location'], $start_location)) { + error_log("[add_ride] SECURITY: طلب SMS #$smsRequestId لا يطابق الراكب أو النقطة"); + printFailure('SMS request mismatch'); + exit; + } + + // ‏نثق بالصف المخزَّن لا بما وصل في الطلب. + $isInternalScheduled = true; // ‏نفس الإعفاء من رمز السعر و JWT + $passenger_id = $smsReq['passenger_id']; + $end_location = $smsReq['end_location']; + $distance = $smsReq['distance']; } else { // Force passenger_id from JWT — never trust user-supplied passenger_id $passenger_id = $user_id; @@ -260,14 +326,19 @@ if ($isInternalScheduled) { exit; } + // ‏المدّة من الصف المخزَّن أيّاً كان مصدره — الحجز المسبق أو طلب + // ‏الرسائل. قراءتها من `$booking` وحده كانت تمرّر صفراً لكل رحلة + // ‏قادمة من SMS، فيسقط شقّ الزمن من السعر. + $internalDuration = (int) ($booking['duration'] ?? $smsReq['duration'] ?? 0); + $price = computeInternalRidePrice( - $kazanRow, (string) $carType, - (float) $distance, (int) ($booking['duration'] ?? 0) + $kazanRow, (string) $carType, (float) $distance, $internalDuration ); $price_for_driver = $price; $price_for_passenger = $price; - error_log("[add_ride] حجز #$scheduledId سُعِّر داخلياً: $price"); + $internalRef = $isSmsRide ? "طلب SMS #$smsRequestId" : "حجز #$scheduledId"; + error_log("[add_ride] $internalRef سُعِّر داخلياً: $price"); } // SECURE PRICE TOKEN VERIFICATION @@ -515,6 +586,10 @@ try { // ليست طلباً لحظياً بل موعد مضبوط، وأن الراكب ينتظره في وقت محدد. $isScheduledRide ? '1' : '', $isScheduledRide ? (string) $scheduledAtLabel : '', + // 🆕 Index 41: طلب وصل عبر رسالة نصية — الراكب بلا إنترنت. + // السائق يجب أن يعرف أن المحادثة داخل التطبيق لن تصل إليه، + // وأن الاتصال الهاتفي هو وسيلة التواصل الوحيدة معه. + $isSmsRide ? '1' : '', ]; // Direct dispatch للسائقين القريبين diff --git a/backend/sms/gemini_parser.php b/backend/sms/gemini_parser.php new file mode 100644 index 00000000..799beb00 --- /dev/null +++ b/backend/sms/gemini_parser.php @@ -0,0 +1,177 @@ +callGemini($prompt, SMS_GEMINI_MODEL); + } catch (Throwable $e) { + error_log('[sms/gemini] استثناء: ' . $e->getMessage()); + return null; + } + + if (!is_array($out) || !isset($out['intent'])) { + return null; + } + + // ‏لا نثق بشكل الرد: النموذج قد يرد بأنواع مختلفة عن المطلوب. + $str = static fn($v) => (is_string($v) && trim($v) !== '' && strtolower(trim($v)) !== 'null') + ? mb_substr(trim($v), 0, 150) : null; + + return [ + 'intent' => $out['intent'] === 'ride' ? 'ride' : 'other', + 'start' => $str($out['start'] ?? null), + 'end' => $str($out['end'] ?? null), + 'note' => $str($out['note'] ?? null), + 'confidence' => is_numeric($out['confidence'] ?? null) + ? max(0.0, min(1.0, (float) $out['confidence'])) : 0.0, + ]; +} + +/** + * ‏عتبة الثقة. تحت هذا الحد نسأل الراكب بدل أن نرسل له سائقاً إلى مكان + * ‏خمّنّاه — رسالة استيضاح تكلّف ثوانٍ، وسائق في المكان الخطأ يكلّف + * ‏رحلة ضائعة وثقة مفقودة. + */ +const SMS_GEMINI_MIN_CONFIDENCE = 0.55; + +/** + * ‏المسار الكامل بطبقتيه: القواعد ثم جيميني. + * + * ‏نفس عقد `smsParseRideRequest` مع حقل `via` ليُعرف مصدر الفهم — بلا + * ‏هذا الحقل لن نعرف لاحقاً أي الطبقتين تحمل العبء، ولا هل يستحق + * ‏جيميني كلفته. + */ +function smsParseRideRequestSmart(PDO $con, string $body): array +{ + // ── الطبقة الأولى: القواعد ────────────────────────────── + $rule = smsParseRideRequest($con, $body); + if ($rule['ok']) { + $rule['via'] = 'rules'; + return $rule; + } + + // ── الطبقة الثانية: جيميني ────────────────────────────── + $ai = smsGeminiExtract($body); + if (!$ai) { + return $rule; // ‏لا جيميني ⇐ نرد بنتيجة القواعد كما هي. + } + + if ($ai['intent'] !== 'ride') { + return ['ok' => false, 'reason' => 'not_ride', 'via' => 'gemini']; + } + + if ($ai['confidence'] < SMS_GEMINI_MIN_CONFIDENCE) { + return ['ok' => false, 'reason' => 'low_confidence', 'via' => 'gemini']; + } + + if (!$ai['start'] || !$ai['end']) { + return [ + 'ok' => false, + 'reason' => 'incomplete', + 'via' => 'gemini', + 'missing' => !$ai['start'] ? 'نقطة الانطلاق' : 'الوجهة', + ]; + } + + // ‏الأسماء التي فهمها النموذج تمرّ على مطابِق المعالم نفسه. + $start = smsResolvePoint($con, $ai['start']); + $end = smsResolvePoint($con, $ai['end']); + + if (!$start || !$end) { + return [ + 'ok' => false, + 'reason' => 'place', + 'via' => 'gemini', + 'raw_start' => $ai['start'], + 'raw_end' => $ai['end'], + 'missing' => !$start ? $ai['start'] : $ai['end'], + ]; + } + + return [ + 'ok' => true, + 'via' => 'gemini', + 'raw_start' => $ai['start'], + 'raw_end' => $ai['end'], + 'note' => $ai['note'], + 'start' => $start, + 'end' => $end, + ]; +} diff --git a/backend/sms/helpers.php b/backend/sms/helpers.php new file mode 100644 index 00000000..37b66ef6 --- /dev/null +++ b/backend/sms/helpers.php @@ -0,0 +1,212 @@ + '962', + 'Egypt' => '20', + 'Syria' => '963', +]; + +function smsNormalizePhone(string $raw): string +{ + // ‏الأرقام العربية-الهندية (٠١٢…) تصل من بعض الهواتف كما هي. + $raw = strtr($raw, [ + '٠'=>'0','١'=>'1','٢'=>'2','٣'=>'3','٤'=>'4', + '٥'=>'5','٦'=>'6','٧'=>'7','٨'=>'8','٩'=>'9', + '۰'=>'0','۱'=>'1','۲'=>'2','۳'=>'3','۴'=>'4', + '۵'=>'5','۶'=>'6','۷'=>'7','۸'=>'8','۹'=>'9', + ]); + + $digits = preg_replace('/\D+/', '', $raw) ?? ''; + if ($digits === '') return ''; + + $cc = SMS_COUNTRY_CODES[getenv('APP_COUNTRY') ?: 'Jordan'] ?? '962'; + + // 00962… → 962… + if (str_starts_with($digits, '00')) { + $digits = substr($digits, 2); + } + + // ‏محلي يبدأ بصفر (0791234567) → نستبدل الصفر بمقدمة الدولة. + if (str_starts_with($digits, '0')) { + $digits = $cc . ltrim($digits, '0'); + } elseif (!str_starts_with($digits, $cc)) { + // ‏بلا صفر وبلا مقدمة (791234567) → نفترض محلياً. + $digits = $cc . $digits; + } + + return $digits; +} + +// ═══════════════════════════════════════════════════════════════ +// الصادر +// ═══════════════════════════════════════════════════════════════ + +/** + * ‏يضع رسالة في الطابور. لا يرسل — الإرسال مسؤولية الهاتف الذي يسحب + * ‏من outbox_pull.php. الفصل مقصود: نداء شبكي فاشل داخل مسار قبول + * ‏الرحلة كان سيؤخّر السائق من أجل رسالة نصية. + */ +function smsEnqueue(PDO $con, string $recipient, string $body, + string $context = '', ?string $refId = null): bool +{ + $to = smsNormalizePhone($recipient); + if ($to === '' || trim($body) === '') return false; + + try { + $con->prepare(" + INSERT INTO sms_outbox (recipient, body, context, ref_id) + VALUES (?, ?, ?, ?) + ")->execute([$to, trim($body), $context ?: null, $refId]); + return true; + } catch (PDOException $e) { + error_log('[sms] تعذّر وضع رسالة في الطابور: ' . $e->getMessage()); + return false; + } +} + +// ═══════════════════════════════════════════════════════════════ +// مطابقة المعالم +// ═══════════════════════════════════════════════════════════════ + +/** جدول المعالم حسب السوق — الأسماء تاريخية ولا تتبع نمطاً واحداً. */ +function smsPlacesTable(): string +{ + return match (getenv('APP_COUNTRY') ?: 'Jordan') { + 'Egypt' => 'placesEgypt', + 'Syria' => 'palces11', + default => 'places', + }; +} + +/** + * ‏يجرّد النص العربي مما يعيق المطابقة: التشكيل، التطويل، واختلاف + * ‏رسم الألف والياء والتاء المربوطة. «مستشفى البشير» و«مستشفي البشير» + * ‏يكتبهما الناس بالتبادل، ولا يجوز أن يفشل الطلب لهذا السبب. + */ +function smsNormalizeArabic(string $s): string +{ + $s = trim($s); + $s = preg_replace('/[\x{064B}-\x{0652}\x{0640}]/u', '', $s) ?? $s; // تشكيل + تطويل + $s = strtr($s, ['أ'=>'ا','إ'=>'ا','آ'=>'ا','ى'=>'ي','ة'=>'ه','ؤ'=>'و','ئ'=>'ي']); + $s = preg_replace('/\s+/u', ' ', $s) ?? $s; + return trim($s); +} + +/** + * ‏نفس تجريد smsNormalizeArabic لكن كتعبير SQL على عمود. + * + * ‏ضروري لا تجميلي: نحن نوحّد نص الراكب («المستشفى» ⇒ «المستشفي»)، + * ‏فلو قارنّاه بالعمود الخام لما طابق شيئاً أبداً — الجدول يحمل الرسم + * ‏الأصلي بالتاء المربوطة والألف المقصورة. + * + * ‏نقبل ثمنه: التعبير يمنع استخدام الفهرس. الجدول عشرات الآلاف من + * ‏الصفوف والمسح كامله ميلي‌ثوانٍ، والقناة منخفضة التردد بطبعها. + */ +function smsSqlNormalize(string $column): string +{ + $expr = "TRIM($column)"; + foreach ([['أ','ا'], ['إ','ا'], ['آ','ا'], ['ى','ي'], + ['ة','ه'], ['ؤ','و'], ['ئ','ي'], ['ـ','']] as [$from, $to]) { + $expr = "REPLACE($expr, '$from', '$to')"; + } + return $expr; +} + +/** + * ‏يبحث عن معلم بالاسم. ثلاث محاولات بترتيب الثقة: تطابق تام، ثم + * ‏«يبدأ بـ»، ثم «يحتوي». الترتيب مهم — البحث بـ LIKE '%…%' وحده + * ‏يجعل «الجامعة» تطابق أول صف عشوائي من عشرات الجامعات. + * + * @return array{lat:float,lng:float,name:string}|null + */ +function smsFindPlace(PDO $con, string $query): ?array +{ + $q = smsNormalizeArabic($query); + if (mb_strlen($q) < 2) return null; + + $table = smsPlacesTable(); + + // ‏نبحث في الأعمدة الثلاثة: الاسم الأساسي والعربي والإنجليزي. + // ‏ثلاث محاولات بترتيب تنازلي في الثقة، ونتوقف عند أول نجاح. + $attempts = [ + ['op' => '=', 'value' => $q], + ['op' => 'LIKE', 'value' => $q . '%'], + ['op' => 'LIKE', 'value' => '%' . $q . '%'], + ]; + + $nName = smsSqlNormalize('name'); + $nNameAr = smsSqlNormalize('name_ar'); + $nNameEn = smsSqlNormalize('name_en'); + + foreach ($attempts as $a) { + try { + $stmt = $con->prepare(" + SELECT latitude, longitude, + COALESCE(NULLIF(name_ar,''), name) AS label + FROM `$table` + WHERE $nName {$a['op']} ? OR $nNameAr {$a['op']} ? OR $nNameEn {$a['op']} ? + -- ‏الأقصر أولاً: «مستشفى البشير» تسبق «موقف باص مستشفى + -- ‏البشير الخلفي» حين يكتب الراكب «البشير» فقط. + ORDER BY CHAR_LENGTH(name) ASC + LIMIT 1 + "); + // ‏معلَّمات موضعية لا مسمّاة: إعادة استخدام اسم واحد ثلاث + // ‏مرات تفشل حين تكون المحاكاة معطّلة. + $stmt->execute([$a['value'], $a['value'], $a['value']]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($row && is_numeric($row['latitude']) && is_numeric($row['longitude'])) { + return [ + 'lat' => (float) $row['latitude'], + 'lng' => (float) $row['longitude'], + 'name' => (string) ($row['label'] ?: $query), + ]; + } + } catch (PDOException $e) { + error_log('[sms] فشل البحث عن معلم: ' . $e->getMessage()); + return null; + } + } + + return null; +} + +// ═══════════════════════════════════════════════════════════════ +// المسافة والزمن +// ═══════════════════════════════════════════════════════════════ + +/** + * ‏مسافة هافرساين بالكيلومترات مضروبة بمعامل التواء الطرق. + * + * ‏لا نستدعي خدمة توجيه هنا عمداً: نداء خارجي داخل مسار الويب‌هوك يعني + * ‏أن انقطاعه يوقف كل الطلبات، والتسعير النهائي يتم في add_ride على أي + * ‏حال. هذا تقدير كافٍ لبدء الرحلة. + */ +function smsHaversineKm(float $lat1, float $lng1, float $lat2, float $lng2): float +{ + $R = 6371.0; + $dLat = deg2rad($lat2 - $lat1); + $dLng = deg2rad($lng2 - $lng1); + $a = sin($dLat / 2) ** 2 + + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng / 2) ** 2; + + // ‏1.3: الطريق ليس خطاً مستقيماً. المعامل تقريب متحفّظ شائع في + // ‏المدن العربية ذات الشبكة غير المنتظمة. + return $R * 2 * atan2(sqrt($a), sqrt(1 - $a)) * 1.3; +} diff --git a/backend/sms/outbox_pull.php b/backend/sms/outbox_pull.php new file mode 100644 index 00000000..0e8d38fc --- /dev/null +++ b/backend/sms/outbox_pull.php @@ -0,0 +1,147 @@ + 'error', 'message' => 'Unauthorized']); + exit; +} + +try { + $con = Database::get('main'); +} catch (Throwable $e) { + http_response_code(500); + echo json_encode(['status' => 'error', 'message' => 'DB unavailable']); + exit; +} + +$payload = json_decode(file_get_contents('php://input'), true) ?: []; +$action = $_GET['action'] ?? ($payload['action'] ?? 'pull'); +$device = mb_substr((string) ($payload['device'] ?? $_GET['device'] ?? 'phone-1'), 0, 64); + +// ‏عدد الرسائل في السحبة الواحدة. صغير عمداً: الهاتف يرسل بالتسلسل +// ‏عبر شريحة واحدة، وحجز خمسين رسالة يعني بقاء آخرها محجوزة دقائق. +const SMS_PULL_BATCH = 10; + +// ‏رسالة بقيت 'sending' أطول من هذا يُفترض أن الهاتف مات قبل تبليغنا، +// ‏فنعيدها للطابور. الرقم أكبر بكثير من زمن إرسال دفعة كاملة. +const SMS_CLAIM_TIMEOUT_MINUTES = 10; + +// ‏الحد الأقصى لمحاولات الرسالة الواحدة — رقم معطّل لن يستقبل مهما +// ‏كررنا، وإبقاؤه في الطابور يعطّل ما خلفه. +const SMS_MAX_ATTEMPTS = 3; + +if ($action === 'ack') { + // ═══ التبليغ بالنتيجة ═══════════════════════════════════ + $results = $payload['results'] ?? []; + if (!is_array($results)) $results = []; + + $ok = 0; $bad = 0; + foreach ($results as $r) { + $id = (int) ($r['id'] ?? 0); + if ($id <= 0) continue; + + if (!empty($r['sent'])) { + $con->prepare(" + UPDATE sms_outbox SET status='sent', sent_at=NOW() + WHERE id = ? AND status = 'sending' + ")->execute([$id]); + $ok++; + } else { + // ‏فشل: نعيدها للطابور ما لم تستنفد محاولاتها. + $con->prepare(" + UPDATE sms_outbox + SET status = IF(attempts >= " . SMS_MAX_ATTEMPTS . ", 'failed', 'pending'), + last_error = ?, claimed_by = NULL, claimed_at = NULL + WHERE id = ? AND status = 'sending' + ")->execute([mb_substr((string) ($r['error'] ?? 'unknown'), 0, 255), $id]); + $bad++; + } + } + + echo json_encode(['status' => 'success', 'acked' => $ok, 'failed' => $bad]); + exit; +} + +// ═══ السحب ══════════════════════════════════════════════════ + +// ‏أولاً: تحرير ما عَلِق. هاتف انطفأ بعد الحجز وقبل الإرسال كان +// ‏سيحبس رسائله إلى الأبد. +try { + $con->prepare(" + UPDATE sms_outbox + SET status='pending', claimed_by=NULL, claimed_at=NULL + WHERE status='sending' + AND claimed_at < NOW() - INTERVAL " . SMS_CLAIM_TIMEOUT_MINUTES . " MINUTE + ")->execute(); +} catch (PDOException $e) { + error_log('[sms/outbox] تعذّر تحرير العالق: ' . $e->getMessage()); +} + +// ‏الحجز ثم القراءة، لا العكس: قراءة أولاً ثم تحديث تسمح لهاتفين +// ‏(أو لسحبتين متتاليتين) بأخذ نفس الرسالة وإرسالها مرتين. +// ‏وسم فريد لكل سحبة، لا اسم الجهاز وحده: لو سحب الهاتف دفعةً ولم +// ‏يبلّغ عنها بعد، ثم سحب ثانيةً، لأعاد الاستعلام الدفعة الأولى معها +// ‏وأُرسلت رسائلها مرتين. +$claim = mb_substr($device . ':' . bin2hex(random_bytes(6)), 0, 64); + +try { + $con->prepare(" + UPDATE sms_outbox + SET status='sending', claimed_by=?, claimed_at=NOW(), attempts=attempts+1 + WHERE status='pending' + ORDER BY id ASC + LIMIT " . SMS_PULL_BATCH + )->execute([$claim]); + + $stmt = $con->prepare(" + SELECT id, recipient, body, context, ref_id + FROM sms_outbox + WHERE status='sending' AND claimed_by=? + ORDER BY id ASC + LIMIT " . SMS_PULL_BATCH + ); + $stmt->execute([$claim]); + $messages = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (PDOException $e) { + error_log('[sms/outbox] فشل السحب: ' . $e->getMessage()); + http_response_code(500); + echo json_encode(['status' => 'error', 'message' => 'Query failed']); + exit; +} + +echo json_encode([ + 'status' => 'success', + 'count' => count($messages), + 'messages' => $messages, +], JSON_UNESCAPED_UNICODE); diff --git a/backend/sms/parser.php b/backend/sms/parser.php new file mode 100644 index 00000000..3fa8501d --- /dev/null +++ b/backend/sms/parser.php @@ -0,0 +1,155 @@ + المستشفى + * من 31.95,35.91 الى المستشفى (إحداثيات مباشرة) + */ + +require_once __DIR__ . '/helpers.php'; + +/** + * ‏هل هذه الرسالة طلب رحلة أصلاً؟ + * + * ‏الويب‌هوك نفسه يستقبل إشعارات الدفع من البنوك، فلا بد من فرز قبل + * ‏التحليل. طلب الرحلة يحوي فاصلاً بين نقطتين؛ إشعار الدفع يحوي مبلغاً + * ‏وكلمات مالية. + */ +function smsLooksLikeRideRequest(string $body): bool +{ + $b = smsNormalizeArabic($body); + + // ‏كلمات الإشعارات المالية — وجودها يرجّح أنها ليست طلب رحلة. + if (preg_match('/(حواله|حوالة|رصيد|محفظتك|دينار|جنيه|ليره|مزود الخدمه|Orange Money)/ui', $b)) { + return false; + } + + // ‏الأنماط على النص الموحَّد: «الى/إلى» صارتا «الي» بعد التوحيد. + return (bool) preg_match('/(^|\s)من\s+.+\s+(الي|علي|ل)\s*\S/u', $b) + || (bool) preg_match('/\S\s+[-]\s+\S/u', $b) + || (bool) preg_match('/\S\s*[<>→]\s*\S/u', $b); +} + +/** + * ‏يستخرج نصّي النقطتين. لا يلمس قاعدة البيانات — الفصل يجعل اختباره + * ‏ممكناً بلا بيئة كاملة، وهذا المكوّن هو الأكثر عرضة للتعديل. + * + * @return array{start:string,end:string}|null + */ +function smsExtractEndpoints(string $body): ?array +{ + $b = smsNormalizeArabic($body); + + // ‏١) «من … إلى …» — الصيغة الأوضح، ونجرّبها أولاً. + // + // ‏تنبيه: النص هنا **بعد** التوحيد، والتوحيد يحوّل ى إلى ي — + // ‏فـ«الى» و«إلى» كلتاهما صارتا «الي». مطابقة «الى» الأصلية هنا + // ‏تفشل دائماً، وهو ما كان يُسقط أشيع صيغة يكتبها الناس. + // + // ‏و«ل» الملتصقة تُعالَج على حدة: «للمستشفى» = ل + المستشفى، + // ‏فابتلاع اللامين معاً يترك «مستشفى» بلا تعريف ويُفشل المطابقة. + if (preg_match('/من\s+(.+?)\s+(الي|علي|ل)\s*(.+)$/u', $b, $m)) { + $start = trim($m[1]); + $end = trim($m[3]); + + // ‏«لل…» وصلت مقسومة: الفاصل «ل» والباقي يبدأ بـ«ل». نعيد + // ‏بناء أداة التعريف بدل تركها مبتورة. + if ($m[2] === 'ل' && str_starts_with($end, 'ل')) { + $end = 'ا' . $end; + } + + // ‏«… لل الصويفية» بمسافة: يبقى «ال» معلّقاً وحده فنسقطه. + $end = preg_replace('/^ال\s+/u', '', $end) ?? $end; + + if ($start !== '' && $end !== '') { + return ['start' => $start, 'end' => $end]; + } + } + + // ‏٢) فاصل رمزي: «الجامعة - المستشفى». + // ‏ننتبه ألا نقطع إحداثيات سالبة أو تاريخاً — لذلك نشترط مسافة + // ‏حول الشرطة، بخلاف < و > اللذين لا يلتبسان بشيء. + if (preg_match('/^(.+?)\s+[-]\s+(.+)$/u', $b, $m) + || preg_match('/^(.+?)\s*[<>→]\s*(.+)$/u', $b, $m)) { + $start = trim($m[1]); + $end = trim($m[2]); + if ($start !== '' && $end !== '') { + return ['start' => $start, 'end' => $end]; + } + } + + return null; +} + +/** + * ‏يحوّل نصاً واحداً إلى نقطة: إمّا إحداثيات مكتوبة صراحةً، أو معلم + * ‏نبحث عنه في جدول المعالم. + * + * @return array{lat:float,lng:float,name:string}|null + */ +function smsResolvePoint(PDO $con, string $text): ?array +{ + $t = trim($text); + + // ‏إحداثيات صريحة — يرسلها من نسخ موقعه من تطبيق آخر. + if (preg_match('/^(-?\d{1,3}\.\d+)\s*,\s*(-?\d{1,3}\.\d+)$/', $t, $m)) { + $lat = (float) $m[1]; + $lng = (float) $m[2]; + if ($lat >= -90 && $lat <= 90 && $lng >= -180 && $lng <= 180) { + return ['lat' => $lat, 'lng' => $lng, 'name' => $t]; + } + } + + // ‏كلمات حشو شائعة في بداية اسم المكان — إزالتها ترفع نسبة المطابقة. + $t = preg_replace('/^(هنا|موقعي|عند|قرب|جنب|حد|في)\s+/ui', '', $t) ?? $t; + + return smsFindPlace($con, $t); +} + +/** + * ‏التحليل الكامل: نص خام ← نقطتان محلولتان. + * + * @return array{ + * ok: bool, reason?: string, + * raw_start?: string, raw_end?: string, + * start?: array, end?: array + * } + */ +function smsParseRideRequest(PDO $con, string $body): array +{ + $ends = smsExtractEndpoints($body); + if (!$ends) { + return ['ok' => false, 'reason' => 'format']; + } + + $start = smsResolvePoint($con, $ends['start']); + $end = smsResolvePoint($con, $ends['end']); + + // ‏نفرّق بين «لم أفهم الصيغة» و«لم أجد المكان»: الرسالتان الراجعتان + // ‏للراكب مختلفتان تماماً، والخلط بينهما يجعله يعيد نفس الخطأ. + if (!$start || !$end) { + return [ + 'ok' => false, + 'reason' => 'place', + 'raw_start' => $ends['start'], + 'raw_end' => $ends['end'], + 'missing' => !$start ? $ends['start'] : $ends['end'], + ]; + } + + return [ + 'ok' => true, + 'raw_start' => $ends['start'], + 'raw_end' => $ends['end'], + 'start' => $start, + 'end' => $end, + ]; +} diff --git a/backend/sms/ride_request.php b/backend/sms/ride_request.php new file mode 100644 index 00000000..25b77fd5 --- /dev/null +++ b/backend/sms/ride_request.php @@ -0,0 +1,304 @@ +index('passengers.phone', $v) : null; + $stmt = $con->prepare( + "SELECT id FROM passengers WHERE phone = ? OR (? IS NOT NULL AND phone_bidx = ?) LIMIT 1" + ); + $stmt->execute([ + $encryptionHelper ? $encryptionHelper->encryptData($v) : $v, + $bidx, $bidx, + ]); + $id = $stmt->fetchColumn(); + if ($id) return (string) $id; + } catch (PDOException $e) { + error_log('[sms] فشل البحث عن الراكب: ' . $e->getMessage()); + return null; + } + } + + // ── لم يُوجد: ننشئ حساباً بالحد الأدنى ────────────────── + // ‏نستعمل الصيغة المحلية للحفظ لتطابق ما يكتبه من يسجّل بالتطبيق. + $localPhone = str_starts_with($phoneNorm, $cc) + ? '0' . substr($phoneNorm, strlen($cc)) + : $phoneNorm; + + $id = substr(md5($localPhone), 0, 16); + + try { + $enc = static fn(string $v): string => + $encryptionHelper ? $encryptionHelper->encryptData($v) : $v; + + // ‏بريد اصطناعي: العمود NOT NULL وعليه مفتاح فريد مركّب مع + // ‏الهاتف. لا يُستعمل للمراسلة، ونطاق invalid محجوز قياسياً + // ‏(RFC 2606) فلا يصل منه شيء إلى أحد بالخطأ. + $email = $id . '@sms.invalid'; + + $con->prepare(" + INSERT INTO passengers + (id, first_name, last_name, email, phone, password, gender, birthdate, + site, sosPhone, education, employmentType, maritalStatus, status, + created_at, updated_at, phone_bidx, email_bidx, phone_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', NOW(), NOW(), ?, ?, ?) + ")->execute([ + $id, + $enc('راكب'), + $enc('SMS'), + $enc($email), + $enc($localPhone), + // ‏كلمة مرور عشوائية غير قابلة للاستعمال: الحساب لا يُدخَل + // ‏إليه بكلمة مرور، والدخول الوحيد الممكن عبر OTP لاحقاً. + password_hash(bin2hex(random_bytes(32)), PASSWORD_DEFAULT), + $enc('none'), $enc('none'), $enc('sms'), $enc('sos'), + $enc('none'), $enc('none'), $enc('none'), + $blindIndex ? $blindIndex->index('passengers.phone', $localPhone) : null, + $blindIndex ? $blindIndex->index('passengers.email', $email) : null, + function_exists('otpPhoneKey') ? otpPhoneKey($localPhone) : null, + ]); + + error_log("[sms] أُنشئ حساب راكب جديد #$id من رسالة نصية"); + return $id; + } catch (PDOException $e) { + // ‏سباق: رسالتان متتاليتان من نفس الرقم قد تنشئان الحساب معاً. + // ‏المعرّف مشتقّ من الرقم فالثانية تصطدم بالمفتاح الأساسي — + // ‏وهذا يعني أن الحساب صار موجوداً، وهو ما نريد. + if ($e->getCode() === '23000') { + return $id; + } + error_log('[sms] تعذّر إنشاء حساب الراكب: ' . $e->getMessage()); + return null; + } +} + +/** + * ‏ينشئ الرحلة عبر add_ride.php بمسار S2S. + * + * @return string|null ‏رقم الرحلة أو null. + */ +function smsCreateRide(array $req): ?string +{ + $url = (getenv('INTERNAL_API_BASE') ?: 'http://nginx') . '/ride/rides/add_ride.php'; + + $fields = [ + 'passenger_id' => $req['passenger_id'], + 'start_location' => $req['start_location'], + 'end_location' => $req['end_location'], + 'start_name' => $req['start_name'] ?? '', + 'end_name' => $req['end_name'] ?? '', + 'carType' => SMS_DEFAULT_CAR_TYPE, + 'distance' => $req['distance'], + 'duration' => $req['duration'], + // ‏قيمة رمزية: add_ride يسعّر داخلياً، لكن حارس الحقول المطلوبة + // ‏يرفض السعر الفارغ. + 'price' => '1', + 'source' => 'sms', + 'sms_request_id' => $req['id'], + ]; + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query($fields), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/x-www-form-urlencoded', + 'X-S2S-Api-Key: ' . getenv('S2S_SHARED_KEY'), + ], + ]); + $body = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code !== 200 || !$body) { + error_log("[sms] add_ride رمز=$code رد=" . substr((string) $body, 0, 200)); + return null; + } + + $json = json_decode($body, true); + $rideId = $json['message'] ?? $json['data'] ?? null; + + return ($rideId && is_scalar($rideId)) ? (string) $rideId : null; +} + +/** + * ‏المعالجة الكاملة لصف واحد من sms_ride_requests. + * + * ‏يُستدعى من الويب‌هوك مباشرةً (للسرعة) ومن الكرون (لإعادة المحاولة). + * ‏القفل التفاؤلي يمنع المعالجة المزدوجة حين يتزامن الاثنان. + */ +function smsProcessRequest(PDO $con, array $req): bool +{ + // ‏قفل: من ينجح في نقل الحالة إلى 'dispatching' هو من يعالج. + $lock = $con->prepare(" + UPDATE sms_ride_requests + SET status = 'dispatching', attempts = attempts + 1 + WHERE id = ? AND status = 'pending' + "); + $lock->execute([$req['id']]); + if ($lock->rowCount() === 0) { + return false; + } + + $rideId = smsCreateRide($req); + + if ($rideId) { + $con->prepare("UPDATE sms_ride_requests SET status='dispatched', ride_id=? WHERE id=?") + ->execute([$rideId, $req['id']]); + + smsEnqueue( + $con, + $req['sender_norm'], + "سيرو: استلمنا طلبك من {$req['start_name']} إلى {$req['end_name']}." + . " نبحث لك عن سائق الآن، وسنرسل لك اسمه ورقم لوحته حال قبوله.", + 'ride_created', + $rideId + ); + + error_log("[sms] طلب #{$req['id']} → رحلة #$rideId"); + return true; + } + + // ‏نعيده 'pending' لا 'failed' ما دامت المحاولات قليلة: فشل شبكي + // ‏عابر لا يجوز أن يحرم راكباً لا يملك وسيلة أخرى للطلب. + if ((int) $req['attempts'] >= 2) { + $con->prepare("UPDATE sms_ride_requests SET status='failed', fail_reason=? WHERE id=?") + ->execute(['add_ride failed', $req['id']]); + smsEnqueue($con, $req['sender_norm'], + 'سيرو: تعذّر إنشاء رحلتك حالياً. حاول بعد قليل أو اتصل بنا.', + 'error', (string) $req['id']); + } else { + $con->prepare("UPDATE sms_ride_requests SET status='pending' WHERE id=?") + ->execute([$req['id']]); + } + + return false; +} + +/** + * ‏من رسالة خام إلى طلب مسجَّل. يرد بالصف الجاهز للمعالجة أو null، + * ‏وقد أرسل للراكب رسالة الاستيضاح المناسبة إن تعذّر الفهم. + */ +function smsIntakeRideMessage(PDO $con, string $senderNorm, string $body, ?int $inboxId): ?array +{ + $parsed = smsParseRideRequestSmart($con, $body); + + if (!$parsed['ok']) { + // ‏رسالة الاستيضاح تختلف باختلاف سبب الفشل — «لم أفهم صيغتك» + // ‏و«لا أعرف هذا المكان» يحتاجان تصرّفين مختلفين من الراكب. + $reply = match ($parsed['reason'] ?? '') { + 'place', 'incomplete' => 'سيرو: لم نتعرّف على «' + . ($parsed['missing'] ?? 'المكان') + . '». جرّب اسماً أشهر قريباً منه، مثل: من الجامعة الأردنية إلى المستشفى التخصصي', + 'not_ride' => '', // ‏ليست طلب رحلة أصلاً — لا نزعج المرسل بردّ. + default => 'سيرو: اكتب طلبك هكذا: من [مكانك] إلى [وجهتك]' + . "\nمثال: من الجامعة الأردنية إلى المستشفى التخصصي", + }; + + if ($reply !== '') { + smsEnqueue($con, $senderNorm, $reply, 'parse_error'); + } + + if ($inboxId && ($parsed['reason'] ?? '') !== 'not_ride') { + $con->prepare(" + INSERT INTO sms_ride_requests (inbox_id, sender_norm, status, fail_reason) + VALUES (?, ?, 'unparsed', ?) + ")->execute([$inboxId, $senderNorm, $parsed['reason'] ?? 'unknown']); + } + return null; + } + + $passengerId = smsResolvePassenger($con, $senderNorm); + if (!$passengerId) { + smsEnqueue($con, $senderNorm, + 'سيرو: تعذّر تجهيز حسابك حالياً. حاول بعد قليل.', 'error'); + return null; + } + + $km = smsHaversineKm($parsed['start']['lat'], $parsed['start']['lng'], + $parsed['end']['lat'], $parsed['end']['lng']); + $secs = (int) round(($km / SMS_AVG_SPEED_KMH) * 3600); + + $startLoc = $parsed['start']['lat'] . ',' . $parsed['start']['lng']; + $endLoc = $parsed['end']['lat'] . ',' . $parsed['end']['lng']; + + try { + $con->prepare(" + INSERT INTO sms_ride_requests + (inbox_id, sender_norm, passenger_id, raw_start, raw_end, + start_location, end_location, start_name, end_name, + distance, duration, status) + VALUES (?,?,?,?,?,?,?,?,?,?,?, 'pending') + ")->execute([ + $inboxId, $senderNorm, $passengerId, + $parsed['raw_start'] ?? null, $parsed['raw_end'] ?? null, + $startLoc, $endLoc, + $parsed['start']['name'], $parsed['end']['name'], + round($km, 3), $secs, + ]); + + return [ + 'id' => (int) $con->lastInsertId(), + 'sender_norm' => $senderNorm, + 'passenger_id' => $passengerId, + 'start_location' => $startLoc, + 'end_location' => $endLoc, + 'start_name' => $parsed['start']['name'], + 'end_name' => $parsed['end']['name'], + 'distance' => round($km, 3), + 'duration' => $secs, + 'attempts' => 0, + ]; + } catch (PDOException $e) { + error_log('[sms] تعذّر تسجيل الطلب: ' . $e->getMessage()); + return null; + } +} diff --git a/backend/webhook_sms/webhook.php b/backend/webhook_sms/webhook.php index b8ca4455..b27163fc 100644 --- a/backend/webhook_sms/webhook.php +++ b/backend/webhook_sms/webhook.php @@ -1,5 +1,20 @@ prepare(" + INSERT INTO sms_inbox (sender, sender_norm, body, intent) + VALUES (?, ?, ?, ?) + "); + $ins->execute([ + mb_substr((string) $sender, 0, 32), + $senderNorm, + (string) $message_body, + $isRide ? 'ride' : 'payment', + ]); + $inboxId = (int) $con->lastInsertId(); +} catch (Throwable $e) { + error_log('[sms] تعذّر حفظ الوارد: ' . $e->getMessage()); + $con = null; +} + +if ($con && $isRide) { + // ‏معالجة فورية لا مؤجَّلة للكرون: الراكب واقف ينتظر رداً، ودقيقة + // ‏تأخير في قناة كهذه تعني أنه ظنّ أن الرقم لا يعمل. الكرون يبقى + // ‏شبكة أمان لما يفشل هنا فقط. + $request = smsIntakeRideMessage($con, $senderNorm, (string) $message_body, $inboxId); + if ($request) { + smsProcessRequest($con, $request); + } + + $log_entry .= ' | ROUTED: ride request' . PHP_EOL; + $logDir = __DIR__ . '/../../logs'; + if (!is_dir($logDir)) @mkdir($logDir, 0750, true); + @file_put_contents($logDir . '/sms_webhook_log.txt', $log_entry, FILE_APPEND); + + http_response_code(200); + echo json_encode(['status' => 'success', 'message' => 'Ride request processed'], + JSON_UNESCAPED_UNICODE); + exit(); +} + // --- 4. تحليل الرسالة (يركز على Orange Money فقط حالياً) --- // تعريف المتغيرات التي سنستخرجها diff --git a/docker/crontab.production b/docker/crontab.production index b912d764..f9e1995b 100644 --- a/docker/crontab.production +++ b/docker/crontab.production @@ -72,6 +72,15 @@ DEX=/usr/bin/docker compose -f $SIRO/docker-compose.yml exec -T php php # السائق أي طلب إطلاقاً. */2 * * * * $DEX /var/www/backend/bot/cron_scheduled_rides.php >> /var/log/scheduled_rides.log 2>&1 +# ============================================================ +# الطلب بلا إنترنت — SMS +# ============================================================ +# المعالجة الأصلية فورية داخل webhook_sms/webhook.php لأن الراكب ينتظر +# رداً. هذا الكرون شبكة أمان فقط: يحرّر الطلبات العالقة، ويعيد محاولة +# ما فشل شبكياً، ويبلّغ من لم يجد سائقاً — راكب الرسائل لا يرى خريطة، +# فصمتنا عنده يساوي عطلاً. +*/1 * * * * $DEX /var/www/backend/bot/cron_sms_requests.php >> /var/log/sms_requests.log 2>&1 + # ============================================================ # الإشعارات والتسويق # ============================================================