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; } }