feat: استيراد كود سيرو إلى تريبز (سيرو @ecfe7568) — بلا تعديل
قرار المالك 2026-07-27: باك إند سيرو PHP هو المعتمد، وتطبيقاته المجرّبة ميدانياً تحل محل إعادة البناء المؤرشفة. سيرو نفسه لم يُمسّ. الخريطة: backend · payment_server · loction_server · ride_server · passenger_server · docker · dashboard · stress_test → الجذر siro_rider → apps/rider siro_driver → apps/driver siro_admin → dashboards/admin siro_service → dashboards/service android_bot → apps/android_bot socialBot → apps/socialBot نُسخ المتعقَّب في git سيرو فقط عبر `git archive` (3,198 ملفاً / ~169 م.ب) لا `cp -r` — فاستُثنيت مخلفات البناء تلقائياً. بلا أي تعديل محتوى عمداً: كل ما يلي يصير فرقاً مقروءاً مقابل المصدر. لم يُستورد وسببه: siromove.com (الموقع التسويقي يبقى marketing/ في تريبز، سيرو فيه 8 ملفات) · docs و planning (تريبز له docs/ الخاص) · deploy.sh (ليس نشراً على سيرفر بل `git add . && git push origin --all` — فخّ في مستودع آخر) · transit_dashboard (بانتظار قرار مصير backend-transit و dashboards/transit-web). ⚠️ لا يبني بعد — ثلاثة نواقص متوقعة ومقصودة: 1. `.env` و `lib/env/env.g.dart` غير متعقَّبين في سيرو (أسرار لكل مستأجر): كل تطبيق فلاتر يحتاج .env خاصاً ثم توليد env.g.dart بـ build_runner. 2. إعدادات Firebase (9 ملفات google-services.json و GoogleService-Info.plist) يستبعدها .gitignore تريبز — ولكل مستأجر مشروع Firebase خاص أصلاً. 3. apps/driver في سيرو يشير إلى `../../Intaleq/packages/get` خارج المستودع → يجب ضمّ الحزم داخله أسوة بـ apps/rider. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9909d9b4c1
commit
4d8414c96b
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// core/Services/FcmService.php
|
||||
// إرسال FCM مع كاش توكن في Redis (بدل ملف)
|
||||
// ============================================================
|
||||
|
||||
class FcmService
|
||||
{
|
||||
private ?Redis $redis;
|
||||
private string $serviceAccountFile;
|
||||
|
||||
public function __construct(?Redis $redis = null)
|
||||
{
|
||||
$this->redis = $redis;
|
||||
$envPath = getenv('FIREBASE_SERVICE_ACCOUNT_PATH') ?: '';
|
||||
if (!empty($envPath) && file_exists($envPath)) {
|
||||
$this->serviceAccountFile = $envPath;
|
||||
} elseif (file_exists('/keys/firebase_service_account.json')) {
|
||||
$this->serviceAccountFile = '/keys/firebase_service_account.json';
|
||||
} elseif (file_exists('/keys/service-account.json')) {
|
||||
$this->serviceAccountFile = '/keys/service-account.json';
|
||||
} elseif (file_exists(__DIR__ . '/../../keys/firebase_service_account.json')) {
|
||||
$this->serviceAccountFile = __DIR__ . '/../../keys/firebase_service_account.json';
|
||||
} elseif (file_exists(__DIR__ . '/../../ride/firebase/service-account.json')) {
|
||||
$this->serviceAccountFile = __DIR__ . '/../../ride/firebase/service-account.json';
|
||||
} else {
|
||||
$this->serviceAccountFile = __DIR__ . '/../../keys/firebase_service_account.json'; // Default fallback
|
||||
}
|
||||
}
|
||||
|
||||
// ── إرسال إشعار ────────────────────────────────────────
|
||||
public function send(
|
||||
string $token,
|
||||
string $title,
|
||||
string $body,
|
||||
array $data = [],
|
||||
string $category = 'Order',
|
||||
string $tone = 'ding'
|
||||
): array {
|
||||
$accessToken = $this->getAccessToken();
|
||||
if (!$accessToken) {
|
||||
return ['status' => 'error', 'message' => 'No access token'];
|
||||
}
|
||||
|
||||
if (!file_exists($this->serviceAccountFile)) {
|
||||
return ['status' => 'error', 'message' => 'Service account file missing'];
|
||||
}
|
||||
|
||||
$creds = json_decode(file_get_contents($this->serviceAccountFile), true);
|
||||
$projectId = $creds['project_id'];
|
||||
$fcmUrl = "https://fcm.googleapis.com/v1/projects/$projectId/messages:send";
|
||||
|
||||
$finalData = array_merge($data, [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'tone' => $tone,
|
||||
'category' => $category,
|
||||
'type' => $category,
|
||||
]);
|
||||
|
||||
// FCM يشترط أن تكون كل القيم strings
|
||||
$processedData = array_map(
|
||||
fn($v) => is_array($v) || is_object($v)
|
||||
? json_encode($v, JSON_UNESCAPED_UNICODE)
|
||||
: (string)$v,
|
||||
$finalData
|
||||
);
|
||||
|
||||
$payload = [
|
||||
'message' => [
|
||||
'token' => $token,
|
||||
'data' => $processedData,
|
||||
'android' => [
|
||||
'priority' => 'HIGH',
|
||||
'notification' => [
|
||||
'sound' => $tone === 'ding' ? 'default' : $tone,
|
||||
'channel_id' => 'high_importance_channel'
|
||||
]
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
if (!empty($title) && !empty($body)) {
|
||||
$payload['message']['notification'] = [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
];
|
||||
$iosSound = $tone === 'ding' || $tone === 'default' ? 'default' : (str_ends_with($tone, '.caf') ? $tone : $tone . '.caf');
|
||||
$payload['message']['apns'] = [
|
||||
'payload' => [
|
||||
'aps' => [
|
||||
'sound' => $iosSound
|
||||
]
|
||||
]
|
||||
];
|
||||
} else {
|
||||
$payload['message']['apns'] = [
|
||||
'headers' => [
|
||||
'apns-priority' => '5',
|
||||
'apns-push-type' => 'background'
|
||||
],
|
||||
'payload' => [
|
||||
'aps' => [
|
||||
'content-available' => 1
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
$ch = curl_init($fcmUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: Bearer $accessToken",
|
||||
'Content-Type: application/json; charset=UTF-8',
|
||||
],
|
||||
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_FRESH_CONNECT => false, // إعادة استخدام الاتصال
|
||||
CURLOPT_FORBID_REUSE => false,
|
||||
CURLOPT_TCP_KEEPALIVE => 1,
|
||||
]);
|
||||
|
||||
$result = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlErr = curl_errno($ch);
|
||||
curl_close($ch);
|
||||
|
||||
error_log("[FCM_DEBUG] Token: " . substr($token, 0, 10) . "... Payload: " . json_encode($payload, JSON_UNESCAPED_UNICODE) . " | Result: $httpCode - $result");
|
||||
|
||||
if ($curlErr) {
|
||||
return ['status' => 'error', 'message' => 'CURL error'];
|
||||
}
|
||||
|
||||
return $httpCode === 200
|
||||
? ['status' => 'success']
|
||||
: ['status' => 'error', 'code' => $httpCode, 'response' => $result];
|
||||
}
|
||||
|
||||
// ── إرسال إشعار لـ FCM Topic (قناة المواصلاتي وغيرها) ──
|
||||
public function sendToTopic(
|
||||
string $topic,
|
||||
string $title,
|
||||
string $body,
|
||||
array $data = []
|
||||
): array {
|
||||
$accessToken = $this->getAccessToken();
|
||||
if (!$accessToken) return ['status' => 'error', 'message' => 'No access token'];
|
||||
|
||||
if (!file_exists($this->serviceAccountFile)) {
|
||||
return ['status' => 'error', 'message' => 'Service account file missing'];
|
||||
}
|
||||
|
||||
$creds = json_decode(file_get_contents($this->serviceAccountFile), true);
|
||||
$projectId = $creds['project_id'];
|
||||
$fcmUrl = "https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send";
|
||||
|
||||
$processedData = array_map(
|
||||
fn($v) => is_array($v) || is_object($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string)$v,
|
||||
array_merge($data, ['title' => $title, 'body' => $body])
|
||||
);
|
||||
|
||||
$payload = [
|
||||
'message' => [
|
||||
'topic' => $topic,
|
||||
'notification' => ['title' => $title, 'body' => $body],
|
||||
'data' => $processedData,
|
||||
'android' => ['priority' => 'HIGH'],
|
||||
],
|
||||
];
|
||||
|
||||
$ch = curl_init($fcmUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: Bearer $accessToken", 'Content-Type: application/json; charset=UTF-8'],
|
||||
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
]);
|
||||
$result = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return $httpCode === 200
|
||||
? ['status' => 'success']
|
||||
: ['status' => 'error', 'code' => $httpCode, 'response' => $result];
|
||||
}
|
||||
|
||||
// ── Access Token مع Redis Cache ─────────────────────────
|
||||
private function getAccessToken(): ?string
|
||||
{
|
||||
// 1. من Redis
|
||||
if ($this->redis) {
|
||||
$cached = $this->redis->get('google_fcm_access_token');
|
||||
if ($cached) return $cached;
|
||||
}
|
||||
|
||||
// 2. طلب جديد
|
||||
$token = $this->fetchGoogleToken();
|
||||
|
||||
if ($token && $this->redis) {
|
||||
$this->redis->setex('google_fcm_access_token', 3500, $token);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function fetchGoogleToken(): ?string
|
||||
{
|
||||
if (!file_exists($this->serviceAccountFile)) return null;
|
||||
|
||||
$creds = json_decode(file_get_contents($this->serviceAccountFile), true);
|
||||
$clientEmail = $creds['client_email'];
|
||||
$privateKey = $creds['private_key'];
|
||||
$now = time();
|
||||
|
||||
$header = rtrim(strtr(base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])), '+/', '-_'), '=');
|
||||
$claim = rtrim(strtr(base64_encode(json_encode([
|
||||
'iss' => $clientEmail,
|
||||
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
|
||||
'aud' => 'https://oauth2.googleapis.com/token',
|
||||
'exp' => $now + 3600,
|
||||
'iat' => $now,
|
||||
])), '+/', '-_'), '=');
|
||||
|
||||
$signature = '';
|
||||
openssl_sign("$header.$claim", $signature, $privateKey, 'SHA256');
|
||||
$jwt = "$header.$claim." . rtrim(strtr(base64_encode($signature), '+/', '-_'), '=');
|
||||
|
||||
$ch = curl_init('https://oauth2.googleapis.com/token');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query([
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt,
|
||||
]),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
|
||||
$res = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return json_decode($res, true)['access_token'] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
/**
|
||||
* LocationIntelligenceEngine.php
|
||||
* Core engine for processing passenger location updates from various sources
|
||||
* (App Usage, Geofencing, Silent Push) and making automated decisions.
|
||||
*/
|
||||
|
||||
class LocationIntelligenceEngine {
|
||||
private $db;
|
||||
|
||||
public function __construct($dbConnection) {
|
||||
$this->db = $dbConnection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a location update from any source.
|
||||
*
|
||||
* @param int|string $passengerId
|
||||
* @param float $lat
|
||||
* @param float $lng
|
||||
* @param string $source Enum: 'app_usage', 'geofence', 'silent_push'
|
||||
* @return array Optional new geofence regions to register on user's device
|
||||
*/
|
||||
public function processLocationUpdate($passengerId, $lat, $lng, $source = 'app_usage', $batteryLevel = null) {
|
||||
if (!function_exists('sendFCM_Internal')) {
|
||||
require_once __DIR__ . '/../../functions.php';
|
||||
}
|
||||
|
||||
// 1. Update Database
|
||||
$this->updateLocationDatabase($passengerId, $lat, $lng, $source, $batteryLevel);
|
||||
|
||||
$zone = null;
|
||||
// 2. Check for Geofence intersections (always evaluate what zone they are in)
|
||||
$zone = $this->checkGeofenceZone($lat, $lng);
|
||||
|
||||
if ($zone) {
|
||||
// 3. Trigger Campaigns / Notifications
|
||||
$this->evaluateCampaignOpportunity($passengerId, $zone, $source);
|
||||
}
|
||||
|
||||
// 4. Update Driver Demand Map
|
||||
$this->updateDemandMap($passengerId, $lat, $lng);
|
||||
|
||||
// 5. Get Geofencing regions for the user's device (closest ones)
|
||||
// iOS allows 20, Android 100. We'll return top 20 by default.
|
||||
return $this->getUpdatedGeofencesForUser($lat, $lng);
|
||||
}
|
||||
|
||||
private function updateLocationDatabase($passengerId, $lat, $lng, $source, $batteryLevel) {
|
||||
try {
|
||||
$sql = "INSERT INTO passenger_opening_locations (passenger_id, latitude, longitude, source, battery_level)
|
||||
VALUES (:pid, :lat, :lng, :source, :battery)";
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([
|
||||
':pid' => $passengerId,
|
||||
':lat' => $lat,
|
||||
':lng' => $lng,
|
||||
':source' => $source,
|
||||
':battery' => $batteryLevel
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
error_log("[LocationIntelligenceEngine] DB Error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function checkGeofenceZone($lat, $lng) {
|
||||
// Find if the user's lat/lng is within the radius of any active geofence zone
|
||||
// Using Haversine formula
|
||||
$sql = "SELECT id, zone_name, country_code, radius_meters,
|
||||
(6371000 * acos(cos(radians(:lat)) * cos(radians(latitude)) * cos(radians(longitude) - radians(:lng)) + sin(radians(:lat)) * sin(radians(latitude)))) AS distance
|
||||
FROM geofence_zones
|
||||
WHERE is_active = 1
|
||||
HAVING distance <= radius_meters
|
||||
ORDER BY distance ASC LIMIT 1";
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([':lat' => $lat, ':lng' => $lng]);
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
private function evaluateCampaignOpportunity($passengerId, $zone, $source) {
|
||||
// 1. Check if passenger received a campaign recently (Anti-Spam)
|
||||
$sqlSpamCheck = "SELECT COUNT(*) FROM marketing_campaigns_log
|
||||
WHERE passenger_id = :pid
|
||||
AND message_type = 'push'
|
||||
AND sent_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)";
|
||||
$stmtSpam = $this->db->prepare($sqlSpamCheck);
|
||||
$stmtSpam->execute([':pid' => $passengerId]);
|
||||
$spamCount = intval($stmtSpam->fetchColumn());
|
||||
|
||||
if ($spamCount == 0) {
|
||||
// 2. Fetch Active Campaign (Placeholder for real campaign DB logic)
|
||||
// Currently, we just send a generic welcome to the zone if priority is high.
|
||||
if ($zone['priority'] >= 1) {
|
||||
$this->sendCampaignNotification($passengerId, $zone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function sendCampaignNotification($passengerId, $zone) {
|
||||
// Get passenger token
|
||||
$sql = "SELECT t.token as users_token, p.country_code
|
||||
FROM passengers p
|
||||
JOIN tokens t ON p.id = t.passengerID
|
||||
WHERE p.id = :pid";
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([':pid' => $passengerId]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($user && !empty($user['users_token'])) {
|
||||
$title = "مرحباً بك في " . $zone['zone_name'] . " 🎉";
|
||||
$body = "اطلب رحلتك الآن من " . $zone['zone_name'] . " واستمتع بتجربة سيرو!";
|
||||
|
||||
// Decrypt token
|
||||
require_once __DIR__ . '/../Security/EncryptionHelper.php';
|
||||
$encryptionHelper = new EncryptionHelper();
|
||||
$decryptedToken = $encryptionHelper->decryptData($user['users_token']);
|
||||
|
||||
if ($decryptedToken) {
|
||||
// Send Push
|
||||
sendFCM_Internal($decryptedToken, $title, $body, ['type' => 'geofence_promo'], 'Marketing');
|
||||
}
|
||||
|
||||
// Log it
|
||||
$logSql = "INSERT INTO marketing_campaigns_log (passenger_id, message_type, country_code, region_name, triggered_by)
|
||||
VALUES (:pid, 'push', :country, :region, 'geofence_trigger')";
|
||||
$logStmt = $this->db->prepare($logSql);
|
||||
$logStmt->execute([
|
||||
':pid' => $passengerId,
|
||||
':country' => $user['country_code'] ?? 'JO',
|
||||
':region' => $zone['zone_name']
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function updateDemandMap($passengerId, $lat, $lng) {
|
||||
// Broadcast this location to drivers or update a demand heat map cache
|
||||
// Here we insert into passengerlocation to log the hotspot.
|
||||
try {
|
||||
$sql = "INSERT INTO passengerlocation (passengerId, lat, lng, rideId) VALUES (:pid, :lat, :lng, '0')";
|
||||
$stmt = $this->db->prepare($sql);
|
||||
// $stmt->execute([':pid' => $passengerId, ':lat' => $lat, ':lng' => $lng]); // Suppressed for now to avoid db constraints errors
|
||||
} catch (Exception $e) {
|
||||
error_log("[LocationIntelligenceEngine] Demand Map Error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function getUpdatedGeofencesForUser($lat, $lng, $limit = 20) {
|
||||
// Return top nearest geofences to update on the user's device
|
||||
$sql = "SELECT id, zone_name, latitude, longitude, radius_meters,
|
||||
(6371000 * acos(cos(radians(:lat)) * cos(radians(latitude)) * cos(radians(longitude) - radians(:lng)) + sin(radians(:lat)) * sin(radians(latitude)))) AS distance
|
||||
FROM geofence_zones
|
||||
WHERE is_active = 1
|
||||
ORDER BY distance ASC LIMIT :limit";
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->bindParam(':lat', $lat);
|
||||
$stmt->bindParam(':lng', $lng);
|
||||
$stmt->bindValue(':limit', (int) $limit, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// core/Services/OtpService.php
|
||||
// تخزين OTP في Redis بدلاً من MySQL (أسرع وأخف)
|
||||
// ============================================================
|
||||
|
||||
class OtpService
|
||||
{
|
||||
private ?Redis $redis;
|
||||
private const OTP_TTL = 300; // 5 دقائق
|
||||
private const MAX_ATTEMPTS = 3;
|
||||
private const LOCKOUT_TTL = 1800; // 30 دقيقة إذا تجاوز المحاولات
|
||||
|
||||
public function __construct(?Redis $redis)
|
||||
{
|
||||
$this->redis = $redis;
|
||||
}
|
||||
|
||||
// ── توليد وحفظ OTP ─────────────────────────────────────
|
||||
// $digits: عدد الأرقام — الافتراضي 6، يمكن تمرير 3 لـ transit (100–999)
|
||||
public function generate(string $phone, int $digits = 3): string
|
||||
{
|
||||
$min = (int)str_pad('1', $digits, '0'); // digits=3 → 100 | digits=6 → 100000
|
||||
$max = (int)str_pad('9', $digits, '9'); // digits=3 → 999 | digits=6 → 999999
|
||||
$otp = str_pad((string)random_int($min, $max), $digits, '0', STR_PAD_LEFT);
|
||||
|
||||
if ($this->redis) {
|
||||
$key = "otp:{$phone}";
|
||||
$this->redis->setex($key, self::OTP_TTL, password_hash($otp, PASSWORD_BCRYPT));
|
||||
$this->redis->del("otp:attempts:{$phone}");
|
||||
}
|
||||
|
||||
return $otp;
|
||||
}
|
||||
|
||||
// ── التحقق من OTP ───────────────────────────────────────
|
||||
public function verify(string $phone, string $inputOtp): bool
|
||||
{
|
||||
if (!$this->redis) return false;
|
||||
|
||||
// فحص الـ lockout
|
||||
if ($this->redis->exists("otp:locked:{$phone}")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$key = "otp:{$phone}";
|
||||
$stored = $this->redis->get($key);
|
||||
|
||||
if (!$stored) {
|
||||
return false; // انتهت صلاحية الـ OTP
|
||||
}
|
||||
|
||||
$attemptsKey = "otp:attempts:{$phone}";
|
||||
|
||||
if (!password_verify($inputOtp, $stored)) {
|
||||
$attempts = $this->redis->incr($attemptsKey);
|
||||
$this->redis->expire($attemptsKey, self::OTP_TTL);
|
||||
|
||||
if ($attempts >= self::MAX_ATTEMPTS) {
|
||||
// قفل لمدة 30 دقيقة
|
||||
$this->redis->setex("otp:locked:{$phone}", self::LOCKOUT_TTL, '1');
|
||||
$this->redis->del($key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// نجح التحقق — احذف الـ OTP
|
||||
$this->redis->del($key);
|
||||
$this->redis->del($attemptsKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── فحص هل الرقم مقفل ──────────────────────────────────
|
||||
public function isLocked(string $phone): bool
|
||||
{
|
||||
return $this->redis && (bool)$this->redis->exists("otp:locked:{$phone}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// core/Services/SiroGeminiService.php
|
||||
// Siro AI Market Analysis & Marketing Content Generation Service
|
||||
// ============================================================
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class SiroGeminiService {
|
||||
private ?string $apiKey;
|
||||
private string $baseUrl;
|
||||
|
||||
public function __construct() {
|
||||
$this->apiKey = getenv('GEMINI_API_KEY') ?: null;
|
||||
$this->baseUrl = "https://generativelanguage.googleapis.com/v1beta/models/";
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze market prices and generate target promotion response
|
||||
*
|
||||
* @param array $competitorPrices Array of competitor pricing information
|
||||
* @param float $siroBasePrice Current Siro base pricing for this region/country
|
||||
* @param string $regionName Name of the region/city
|
||||
* @param string $countryCode Country code (e.g. SY, JO, EG, IQ)
|
||||
* @param string $model Override AI model to use (default: gemini-1.5-flash)
|
||||
* @return array|null Decoded JSON response from Gemini or null on failure
|
||||
*/
|
||||
public function analyzeMarketAndDraftCampaign(
|
||||
array $competitorPrices,
|
||||
float $siroBasePrice,
|
||||
string $regionName,
|
||||
string $countryCode,
|
||||
string $model = 'gemini-flash-lite-latest'
|
||||
): ?array {
|
||||
if (!$this->apiKey) {
|
||||
error_log("[SiroGeminiService] API Key is missing.");
|
||||
return null;
|
||||
}
|
||||
|
||||
$dialect = match (strtoupper($countryCode)) {
|
||||
'SY' => 'السورية (الشامية)',
|
||||
'JO' => 'الأردنية',
|
||||
'EG' => 'المصرية',
|
||||
'IQ' => 'العراقية',
|
||||
default => 'العربية الفصحى البسيطة'
|
||||
};
|
||||
|
||||
$prompt = "
|
||||
أنت خبير تسويق ذكي ومحلل أسعار لتطبيق Siro لخدمات نقل الركاب.
|
||||
قم بتحليل أسعار المنافسين في منطقة '$regionName' وصياغة حملة تسويقية وعرض ترويجي منافس.
|
||||
|
||||
بيانات الإدخال:
|
||||
1. أسعار المنافسين الحالية: " . json_encode($competitorPrices, JSON_UNESCAPED_UNICODE) . "
|
||||
2. سعر رحلة Siro الأساسي الحالي: $siroBasePrice
|
||||
|
||||
المطلوب:
|
||||
1. دراسة الأسعار وتحديد هل توجد فرصة تسويقية واضحة لجذب الركاب (opportunity_detected: true/false).
|
||||
2. تحديد معامل التخفيض المقترح أو قيمة خصم مناسبة (discount_value) والنسبة المئوية (discount_percentage).
|
||||
3. كتابة رسالة تسويقية إعلانية جذابة وقصيرة جداً ومقنعة لإرسالها كإشعار (Push Notification) للركاب النشطين بالهجة $dialect.
|
||||
4. كتابة رسالة استعادة جذابة ومغرية وقصيرة لإرسالها عبر SMS أو WhatsApp للركاب المنقطعين بالهجة $dialect مع ذكر كود الخصم المقترح.
|
||||
5. اقتراح كود خصم مناسب للحملة (promo_code) ليكون سهل الحفظ ومناسباً للحدث.
|
||||
|
||||
الخرج المطلوب (يجب أن يكون JSON صالحاً تماماً وخالياً من أي شرح خارجي، باللغة العربية):
|
||||
{
|
||||
\"opportunity_detected\": true/false,
|
||||
\"recommended_price\": 12000,
|
||||
\"discount_percentage\": 15,
|
||||
\"promo_code\": \"SIROGO15\",
|
||||
\"push_title\": \"عنوان الإشعار (بحد أقصى 5 كلمات)\",
|
||||
\"push_body\": \"محتوى الإشعار القصير والمثير للاهتمام (بحد أقصى 15 كلمة)\",
|
||||
\"sms_body\": \"محتوى رسالة الاستعادة (بحد أقصى 20 كلمة)\"
|
||||
}
|
||||
";
|
||||
|
||||
return $this->callGemini($prompt, $model);
|
||||
}
|
||||
|
||||
/**
|
||||
* يُولّد إشعاراً مخصصاً لحدث دخول الجيوفينس بناءً على بيانات أسعار حقيقية.
|
||||
*
|
||||
* @param string $zoneName اسم منطقة السياج الجغرافي
|
||||
* @param string $countryCode رمز الدولة (SY, JO, EG, IQ)
|
||||
* @param float $savingsPct نسبة التوفير (مثال: 8.5)
|
||||
* @param string $topCompetitor اسم أبرز منافس في المنطقة
|
||||
* @param string $model
|
||||
* @return array|null
|
||||
*/
|
||||
public function generateGeofenceMessage(
|
||||
string $zoneName,
|
||||
string $countryCode,
|
||||
float $savingsPct,
|
||||
string $topCompetitor = 'كريم',
|
||||
string $model = 'gemini-flash-lite-latest'
|
||||
): ?array {
|
||||
if (!$this->apiKey) return null;
|
||||
|
||||
$dialect = match (strtoupper($countryCode)) {
|
||||
'SY' => 'السورية الشامية',
|
||||
'JO' => 'الأردنية',
|
||||
'EG' => 'المصرية العامية',
|
||||
'IQ' => 'العراقية',
|
||||
default => 'العربية الفصحى'
|
||||
};
|
||||
|
||||
$savingsFormatted = number_format($savingsPct, 1);
|
||||
|
||||
$prompt = "
|
||||
أنت كاتب إشعارات تسويقية ذكية لتطبيق Siro لخدمات نقل الركاب.
|
||||
المستخدم الآن موجود بالقرب من '$zoneName'.
|
||||
سعر سيرو أقل بـ $savingsFormatted% من $topCompetitor وبقية التطبيقات في هذه المنطقة.
|
||||
|
||||
المطلوب: اكتب إشعاراً push قصيراً جداً (عنوان + جسم) باللهجة $dialect.
|
||||
- العنوان: لا يتجاوز 5 كلمات، مثير للاهتمام
|
||||
- الجسم: لا يتجاوز 12 كلمة، يذكر التوفير الفعلي ويحفّز على الطلب الآن
|
||||
- اجعله طبيعياً كأنه يكتبه شخص حقيقي وليس روبوت
|
||||
|
||||
الخرج (JSON فقط، بدون أي شرح):
|
||||
{
|
||||
\"push_title\": \"...\",
|
||||
\"push_body\": \"...\"
|
||||
}
|
||||
";
|
||||
|
||||
return $this->callGemini($prompt, $model);
|
||||
}
|
||||
|
||||
/**
|
||||
* تقرأ معادلات المنافسين المكتشفة أسبوعياً وتقدم استراتيجية تسويق (Weekly Advisor).
|
||||
*
|
||||
* @param array $formulas المصفوفة المستخرجة من competitor_secret_formulas
|
||||
* @param string $model
|
||||
* @return array|null
|
||||
*/
|
||||
public function analyzeCompetitorFormulas(
|
||||
array $formulas,
|
||||
string $model = 'gemini-flash-lite-latest'
|
||||
): ?array {
|
||||
if (!$this->apiKey) return null;
|
||||
|
||||
$formulasJson = json_encode($formulas, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$prompt = "
|
||||
أنت المستشار التسويقي المالي لتطبيق 'سيرو' لنقل الركاب.
|
||||
لقد قمنا بعمل هندسة عكسية لرحلات منافسينا واكتشفنا معادلات التسعير السرية الخاصة بهم لهذا الأسبوع.
|
||||
|
||||
البيانات المكتشفة:
|
||||
$formulasJson
|
||||
|
||||
المطلوب منك (كمستشار أعمال خبير):
|
||||
1. تحليل هذه الأرقام، وتوضيح أين تكمن نقاط قوة المنافسين وأين نقاط ضعفهم في التسعير (مثلاً من يركز على المسافات القصيرة برفع فتح العداد؟).
|
||||
2. اقتراح 3 استراتيجيات تسويقية أو رسائل إعلانية (Push Notifications/Social Media) موجهة للركاب، تستغل نقاط ضعف المنافسين المكتشفة.
|
||||
|
||||
قم بصياغة تقريرك بتنسيق HTML مرتب وجاهز للعرض في لوحة الإدارة (بدون علامات ```html)، واستخدم ألوان خفيفة في العناوين <h3>.
|
||||
تحدث بلغة عربية احترافية ومباشرة لصناع القرار.
|
||||
";
|
||||
|
||||
// بما أن الإخراج سيكون نص HTML، سنستخدم API جيميناي العادي بدون تقييد JSON
|
||||
$url = $this->baseUrl . "{$model}:generateContent?key={$this->apiKey}";
|
||||
|
||||
$postData = [
|
||||
'contents' => [
|
||||
[
|
||||
'parts' => [
|
||||
['text' => $prompt]
|
||||
]
|
||||
]
|
||||
],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0.7,
|
||||
'maxOutputTokens' => 2000
|
||||
]
|
||||
];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postData));
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode === 200) {
|
||||
$data = json_decode($response, true);
|
||||
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
return ['status' => 'success', 'html_report' => $text];
|
||||
} else {
|
||||
error_log("[SiroGeminiService] HTTP $httpCode in analyzeCompetitorFormulas: $response");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: يُرسل prompt لـ Gemini ويُعيد JSON مُفكَّك
|
||||
*/
|
||||
public function callGemini(string $prompt, string $model): ?array {
|
||||
$apiUrl = $this->baseUrl . $model . ":generateContent?key=" . $this->apiKey;
|
||||
|
||||
$payload = [
|
||||
'contents' => [
|
||||
[
|
||||
'parts' => [
|
||||
['text' => $prompt]
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$ch = curl_init($apiUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CONNECTTIMEOUT => 5
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$curlErr = curl_error($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($curlErr) {
|
||||
error_log("[SiroGeminiService] Curl Error: $curlErr");
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
error_log("[SiroGeminiService] HTTP Error $httpCode. Response: $response");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$responseData = json_decode($response, true);
|
||||
$rawText = $responseData['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
// تنظيف أي علامات كود ماركداون محتملة من الموديل
|
||||
$cleanJson = trim(preg_replace('/```json|```/', '', $rawText));
|
||||
|
||||
$decoded = json_decode($cleanJson, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
error_log("[SiroGeminiService] JSON Decode Error: " . json_last_error_msg() . " | Raw text: $rawText");
|
||||
return null;
|
||||
}
|
||||
return $decoded;
|
||||
} catch (Exception $e) {
|
||||
error_log("[SiroGeminiService] Exception: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function evaluatePostsForReporting(array $posts, string $model = 'gemini-flash-lite-latest'): ?string {
|
||||
if (!$this->apiKey || empty($posts)) return null;
|
||||
|
||||
$postsJson = json_encode($posts, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$prompt = "
|
||||
أنت محلل بيانات استخباراتية للسوق لتطبيق 'سيرو' لنقل الركاب.
|
||||
إليك مجموعة من المنشورات والتعليقات التي جمعها الروبوت الخاص بنا من مجموعات فيسبوك اليوم:
|
||||
$postsJson
|
||||
|
||||
المطلوب منك:
|
||||
1. قراءة جميع هذه المنشورات واستخراج أي شكاوى، أسئلة، أو نقاشات تتعلق بـ (تطبيقات النقل الذكي، أسعار المحروقات، باقات الإنترنت، مشاكل السيارات).
|
||||
ملاحظة هامة عن بنية البيانات:
|
||||
- أي نص يبدأ بـ [FEED]: هو عبارة عن المنشور الأصلي (البوست).
|
||||
- أي نص يبدأ بـ [COMMENT]: هو تعليق تابع للمنشور الذي يسبقه مباشرة.
|
||||
2. تلخيص أهم هذه النقاشات في تقرير قصير ومفيد (News Report). يرجى التمييز بين المنشور الأصلي والتعليقات عليه لفهم السياق.
|
||||
3. تجاهل المنشورات العشوائية أو الشخصية التي لا تفيد السوق.
|
||||
|
||||
هام جداً:
|
||||
- يجب أن يكون التقرير باللغة العربية بالكامل.
|
||||
- يجب أن تغلف التقرير بالكامل بوسم <div dir=\"rtl\" align=\"right\"> في البداية و </div> في النهاية لضمان القراءة من اليمين لليسار.
|
||||
- استخدم <h3> للعناوين و <ul> للقوائم داخل التقرير.
|
||||
- لا تقم بتضمين علامات ```html في المخرجات.
|
||||
إذا لم يكن هناك أي شيء مفيد، اكتب فقط: <div dir=\"rtl\" align=\"right\"><p>لا توجد بيانات مفيدة في هذه الدفعة.</p></div>
|
||||
";
|
||||
|
||||
$url = $this->baseUrl . "{$model}:generateContent?key={$this->apiKey}";
|
||||
|
||||
$postData = [
|
||||
'contents' => [
|
||||
[
|
||||
'parts' => [
|
||||
['text' => $prompt]
|
||||
]
|
||||
]
|
||||
],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0.5,
|
||||
'maxOutputTokens' => 1500
|
||||
]
|
||||
];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postData));
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode === 200) {
|
||||
$data = json_decode($response, true);
|
||||
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
return trim(preg_replace('/```html|```/', '', $text));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function evaluateTelegramForReporting(array $messages, string $model = 'gemini-flash-lite-latest'): ?string {
|
||||
if (!$this->apiKey || empty($messages)) return null;
|
||||
|
||||
$messagesJson = json_encode($messages, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$prompt = "
|
||||
أنت محلل بيانات استخباراتية للسوق لتطبيق 'سيرو' لنقل الركاب.
|
||||
إليك مجموعة من الرسائل التي جمعها الروبوت الخاص بنا من قنوات ومجموعات السائقين على تليغرام اليوم:
|
||||
$messagesJson
|
||||
|
||||
المطلوب منك:
|
||||
1. قراءة جميع هذه الرسائل واستخراج أي شكاوى، أسئلة، أو نقاشات تتعلق بـ (تطبيقات النقل الذكي مثل كريم، أوبر، جيني، يلاغو، تكسي إف، أسعار المحروقات، مشاكل التطبيقات، الإضرابات).
|
||||
ملاحظة هامة عن بنية البيانات:
|
||||
- كل رسالة تبدأ بـ [TELEGRAM]: هي عبارة عن رسالة مرسلة في مجموعة تليغرام.
|
||||
2. تلخيص أهم هذه النقاشات في تقرير قصير ومفيد (News Report).
|
||||
3. تجاهل رسائل الانضمام للمجموعات، الملصقات، الإعلانات العشوائية، أو الرسائل القصيرة جداً التي لا تحتوي على معنى مفيد للسوق.
|
||||
|
||||
هام جداً:
|
||||
- يجب أن يكون التقرير باللغة العربية بالكامل.
|
||||
- يجب أن تغلف التقرير بالكامل بوسم <div dir=\"rtl\" align=\"right\"> في البداية و </div> في النهاية لضمان القراءة من اليمين لليسار.
|
||||
- استخدم <h3> للعناوين و <ul> للقوائم داخل التقرير.
|
||||
- لا تقم بتضمين علامات ```html في المخرجات.
|
||||
إذا لم يكن هناك أي شيء مفيد، اكتب فقط: <div dir=\"rtl\" align=\"right\"><p>لا توجد بيانات مفيدة في هذه الدفعة.</p></div>
|
||||
";
|
||||
|
||||
$url = $this->baseUrl . "{$model}:generateContent?key={$this->apiKey}";
|
||||
|
||||
$postData = [
|
||||
'contents' => [
|
||||
[
|
||||
'parts' => [
|
||||
['text' => $prompt]
|
||||
]
|
||||
]
|
||||
],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0.5,
|
||||
'maxOutputTokens' => 1500
|
||||
]
|
||||
];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postData));
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
echo "\n[GEMINI API ERROR] HTTP Code: $httpCode\n";
|
||||
echo "Response: $response\n\n";
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
return trim(preg_replace('/```html|```/', '', $text));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user