Clone Nabih Pure PHP Architecture

This commit is contained in:
Hamza-Ayed
2026-08-26 17:10:57 +03:00
parent 1e21847ed0
commit df5d8dfc60
28 changed files with 2791 additions and 267 deletions
+38
View File
@@ -0,0 +1,38 @@
# Application Settings
APP_NAME=Nabeh
APP_ENV=development
APP_DEBUG=true
APP_URL=http://localhost:8000
# Main Master Database Configuration
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=nabeh_master
DB_USERNAME=root
DB_PASSWORD=
# AI Model Configuration
GEMINI_API_KEY=
ELEVENLABS_API_KEY=
ELEVENLABS_VOICE_ID=EXAVITQu4vr4xnSDxMaL
# Messaging Gateway Settings
WHATSAPP_GATEWAY_URL=http://localhost:3722
# OWASP Security Settings
# Generate a secure 32-byte (256-bit) key for AES encryption
ENCRYPTION_KEY=d3b07384d113edec49eaa6238ad5ff00f898129dfdeca34289adcd11a00a89d1
# Secret key/salt for blind index hashes
HMAC_SALT=nabeh_secure_blind_index_salt_key_123
# Secret key for JWT signatures
JWT_SECRET=nabeh_jwt_secret_signature_key_987654
# Redis Settings (Optional caching - falls back to DB if disabled/unavailable)
REDIS_ENABLED=false
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
+144
View File
@@ -0,0 +1,144 @@
<?php
namespace App\Core;
/**
* Cache helper class providing Redis-based caching with automatic database fallback.
*/
class Cache
{
private static ?\Redis $client = null;
private static bool $connectionAttempted = false;
/**
* Get initialized Redis client or null if unavailable/disabled.
*/
private static function getClient(): ?\Redis
{
if (self::$connectionAttempted) {
return self::$client;
}
self::$connectionAttempted = true;
if (!Env::get('REDIS_ENABLED', false)) {
return null;
}
if (!class_exists('\Redis')) {
error_log('[Cache Warning] phpredis extension is not installed.');
return null;
}
try {
$redis = new \Redis();
$host = Env::get('REDIS_HOST', '127.0.0.1');
$port = (int)Env::get('REDIS_PORT', 6379);
$password = Env::get('REDIS_PASSWORD', null);
$db = (int)Env::get('REDIS_DB', 0);
// Connect with 1.0s timeout to prevent request blocking
$connected = $redis->connect($host, $port, 1.0);
if (!$connected) {
error_log("[Cache Warning] Failed to connect to Redis at {$host}:{$port}");
return null;
}
if ($password !== null && $password !== '' && strtolower($password) !== 'null') {
$redis->auth($password);
}
if ($db > 0) {
$redis->select($db);
}
self::$client = $redis;
} catch (\Exception $e) {
error_log('[Cache Error] Redis connection error: ' . $e->getMessage());
self::$client = null;
}
return self::$client;
}
/**
* Helper to get prefixed key
*/
private static function getPrefixedKey(string $key): string
{
return 'nabeh:' . $key;
}
/**
* Retrieve cache item by key.
*/
public static function get(string $key)
{
$client = self::getClient();
if (!$client) {
return null;
}
try {
$prefixedKey = self::getPrefixedKey($key);
$value = $client->get($prefixedKey);
return $value !== false ? json_decode($value, true) : null;
} catch (\Exception $e) {
error_log('[Cache Error] Redis get failed: ' . $e->getMessage());
return null;
}
}
/**
* Set cache item by key with TTL.
*/
public static function set(string $key, $value, int $ttl = 3600): bool
{
$client = self::getClient();
if (!$client) {
return false;
}
try {
$prefixedKey = self::getPrefixedKey($key);
return $client->setex($prefixedKey, $ttl, json_encode($value));
} catch (\Exception $e) {
error_log('[Cache Error] Redis set failed: ' . $e->getMessage());
return false;
}
}
/**
* Delete cache item by key.
*/
public static function delete(string $key): bool
{
$client = self::getClient();
if (!$client) {
return false;
}
try {
$prefixedKey = self::getPrefixedKey($key);
return $client->del($prefixedKey) > 0;
} catch (\Exception $e) {
error_log('[Cache Error] Redis delete failed: ' . $e->getMessage());
return false;
}
}
/**
* Retrieve cache item, or run callback and store results if cache misses.
*/
public static function remember(string $key, int $ttl, callable $callback)
{
$value = self::get($key);
if ($value !== null) {
return $value;
}
$value = $callback();
self::set($key, $value, $ttl);
return $value;
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace App\Core;
use PDO;
use PDOException;
/**
* PDO Database wrapper using Singleton pattern.
*/
class Database
{
private static ?PDO $instance = null;
/**
* Get active PDO database instance
*
* @return PDO
* @throws PDOException
*/
public static function getConnection(): PDO
{
if (self::$instance === null) {
$host = getenv('DB_HOST') ?: '127.0.0.1';
$port = getenv('DB_PORT') ?: '3306';
$dbName = getenv('DB_DATABASE') ?: 'nabeh_master';
$username = getenv('DB_USERNAME') ?: 'root';
$password = getenv('DB_PASSWORD') ?: '';
$dsn = "mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
self::$instance = new PDO($dsn, $username, $password, $options);
} catch (PDOException $e) {
// Log the exact error internally but hide sensitive DSN on production
error_log("Database Connection Error: " . $e->getMessage());
throw new PDOException("Could not connect to the database. Check database settings.");
}
}
return self::$instance;
}
/**
* Shorthand execute statement with parameters
*
* @param string $sql
* @param array $params
* @return \PDOStatement
*/
public static function query(string $sql, array $params = []): \PDOStatement
{
$pdo = self::getConnection();
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
return $stmt;
}
/**
* Retrieve all matching records
*/
public static function select(string $sql, array $params = []): array
{
return self::query($sql, $params)->fetchAll();
}
/**
* Retrieve single matching record
*/
public static function selectOne(string $sql, array $params = [])
{
return self::query($sql, $params)->fetch() ?: null;
}
/**
* Insert record and return last inserted ID
*/
public static function insert(string $sql, array $params = []): string
{
$pdo = self::getConnection();
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
return $pdo->lastInsertId();
}
/**
* Execute generic non-query SQL (Update/Delete) and return affected rows
*/
public static function execute(string $sql, array $params = []): int
{
return self::query($sql, $params)->rowCount();
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Core;
/**
* Lightweight secure environment variable loader
*/
class Env
{
/**
* Load environment variables from a file path
*
* @param string $path
* @throws \Exception
*/
public static function load(string $path): void
{
if (!file_exists($path)) {
// Create a default if it doesn't exist to prevent crash, or throw
if (file_exists($path . '.example')) {
copy($path . '.example', $path);
} else {
throw new \Exception("Environment file not found at: {$path}");
}
}
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
// Skip comments and empty lines
if (empty($line) || strpos($line, '#') === 0) {
continue;
}
// Split by the first equals sign
if (strpos($line, '=') !== false) {
list($key, $value) = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
// Strip surrounding quotes
if (preg_match('/^"(.+)"$/', $value, $matches) || preg_match("/^'(.+)'$/", $value, $matches)) {
$value = $matches[1];
}
// Inject into PHP superglobals and env
putenv("{$key}={$value}");
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
}
}
/**
* Retrieve environment variable with optional default value
*
* @param string $key
* @param mixed $default
* @return mixed
*/
public static function get(string $key, $default = null)
{
$val = getenv($key);
if ($val === false) {
return $default;
}
switch (strtolower($val)) {
case 'true':
case '(true)':
return true;
case 'false':
case '(false)':
return false;
case 'null':
case '(null)':
return null;
case 'empty':
case '(empty)':
return '';
}
return $val;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Core\Flows;
/**
* BaseFlow
* Abstract base class for all conversation flow states.
*/
abstract class BaseFlow
{
/**
* Process a step in the conversation flow.
*
* @param string $step The current step identifier ('start' for new flows)
* @param array $messageData The incoming WhatsApp message payload (body, phone, etc.)
* @param array $context Reference to the persistent JSON context array
* @return FlowResult
*/
abstract public function handleStep(string $step, array $messageData, array &$context): FlowResult;
}
+240
View File
@@ -0,0 +1,240 @@
<?php
namespace App\Core\Flows;
use App\Services\SiroService;
/**
* ComplaintFlow — Submit a trip complaint via WhatsApp
*
* Flow: start → await_description → await_ride → await_confirmation → finished
*
* Steps:
* start → resolve user, ask for problem description
* await_description → collect description text (or voice transcription)
* await_ride → fetch recent rides, let user pick one
* await_confirmation → show full details, ask to confirm
* finished → show AI analysis result
*/
class ComplaintFlow extends BaseFlow
{
public function handleStep(string $step, array $messageData, array &$context): FlowResult
{
$phone = $messageData['phone'] ?? '';
$text = $messageData['body'] ?? $messageData['text'] ?? '';
$country = $context['country'] ?? SiroService::detectCountry($phone);
switch ($step) {
// ─────────────────────────────────────────────────
// START: resolve user, ask for description
// ─────────────────────────────────────────────────
case 'start':
$context['country'] = $country;
// Resolve user type via Siro
try {
$driverData = SiroService::checkDriverStatus($phone, $country);
if ($driverData && !empty($driverData['data']['driver_id'])) {
$context['user_type'] = 'driver';
$context['user_id'] = $driverData['data']['driver_id'];
$context['user_name'] = $driverData['data']['name'] ?? '';
} else {
$context['user_type'] = 'passenger';
}
} catch (\Exception $e) {
$context['user_type'] = 'driver';
}
return new FlowResult(
"أهلاً بك في نظام الشكاوى.\n\n"
. "📝 يرجى وصف المشكلة التي حدثت بالتفصيل.\n"
. "مثال: \"السائق تأخر 20 دقيقة واتصلت فيه وما رد\"\n"
. "يمكنك إرسال النص أو تسجيل مقطع صوتي.\n\n"
. "🟡 للخروج اكتب: إلغاء",
"await_description"
);
// ─────────────────────────────────────────────────
// AWAIT_DESCRIPTION: collect complaint text
// ─────────────────────────────────────────────────
case 'await_description':
if (empty(trim($text))) {
return new FlowResult(
"الرجاء كتابة وصف المشكلة أو تسجيل مقطع صوتي:",
"await_description"
);
}
$context['complaint_text'] = trim($text);
// Fetch recent rides from Siro
try {
$rides = SiroService::getUserRides($country, $phone, 5);
$context['rides'] = $rides ?? [];
} catch (\Exception $e) {
$context['rides'] = [];
}
if (empty($context['rides'])) {
return new FlowResult(
"تم حفظ وصف المشكلة. ✅\n\n"
. "لم نتمكن من العثور على رحلات حديثة لحسابك.\n"
. "الرجاء إرسال رقم الرحلة (مثال: 831):",
"await_ride"
);
}
$rideList = "تم حفظ وصف المشكلة. ✅\n\n"
. "🚖 آخر رحلاتك:\n\n";
foreach ($context['rides'] as $i => $r) {
$num = $i + 1;
$date = $r['date'] ?? '';
$time = $r['time'] ?? '';
$from = $r['start_location'] ?? '---';
$to = $r['end_location'] ?? '---';
$price = $r['price'] ?? '0';
$status = $r['status'] ?? '';
$rideList .= "{$num}. رحلة #{$r['id']} | {$date} {$time}\n"
. " من: {$from} → إلى: {$to}\n"
. " السعر: {$price} | الحالة: {$status}\n\n";
}
$rideList .= "الرجاء إرسال رقم الرحلة من القائمة (1-{$num})\n"
. "أو اكتب رقم الرحلة كاملاً (مثال: 831):";
return new FlowResult($rideList, "await_ride");
// ─────────────────────────────────────────────────
// AWAIT_RIDE: let user pick a ride
// ─────────────────────────────────────────────────
case 'await_ride':
$selectedRide = null;
$rides = $context['rides'] ?? [];
// Check if user entered a list number (1, 2, 3...)
$cleanNum = preg_replace('/[^0-9]/', '', $text);
if (!empty($cleanNum) && is_numeric($cleanNum)) {
$index = (int)$cleanNum - 1;
if (isset($rides[$index])) {
$selectedRide = $rides[$index];
}
// If not found in list, try as ride_id directly
if (!$selectedRide) {
foreach ($rides as $r) {
if ((string)$r['id'] === $cleanNum) {
$selectedRide = $r;
break;
}
}
}
// If still not found, try the number as ride_id
if (!$selectedRide) {
$selectedRide = ['id' => $cleanNum];
}
}
if (!$selectedRide) {
return new FlowResult(
"لم نتعرف على الرقم. الرجاء إرسال رقم الرحلة من القائمة:",
"await_ride"
);
}
$context['ride_id'] = $selectedRide['id'];
$context['ride_details'] = $selectedRide;
$locFrom = $selectedRide['start_location'] ?? '---';
$locTo = $selectedRide['end_location'] ?? '---';
$date = $selectedRide['date'] ?? '---';
$time = $selectedRide['time'] ?? '---';
$price = $selectedRide['price'] ?? '---';
$status = $selectedRide['status'] ?? '---';
return new FlowResult(
"🚖 تفاصيل الرحلة المحددة:\n"
. "• رقم الرحلة: {$selectedRide['id']}\n"
. "• التاريخ: {$date} {$time}\n"
. "• من: {$locFrom}\n"
. "• إلى: {$locTo}\n"
. "• السعر: {$price}\n"
. "• الحالة: {$status}\n\n"
. "📋 وصف المشكلة:\n"
. "{$context['complaint_text']}\n\n"
. "هل تريد تأكيد إرسال الشكوى؟\n"
. "✅ أرسل: تأكيد\n"
. "🔄 أرسل: تعديل (لإعادة كتابة الوصف)\n"
. "🟡 أرسل: إلغاء",
"await_confirmation"
);
// ─────────────────────────────────────────────────
// AWAIT_CONFIRMATION: confirm and submit
// ─────────────────────────────────────────────────
case 'await_confirmation':
$clean = trim(mb_strtolower($text));
if (in_array($clean, ['تعديل', 'edit', 'تعديل الوصف'])) {
return new FlowResult(
"الرجاء إرسال وصف المشكلة الجديد:",
"await_description"
);
}
if (!in_array($clean, ['تأكيد', 'نعم', 'اكيد', 'ok', 'yes', 'confirm', 'تاكيد', 'okay'])) {
return new FlowResult(
"❌ لم يتم التأكيد.\n"
. "✅ للتأكيد أرسل: تأكيد\n"
. "🔄 للتعديل أرسل: تعديل\n"
. "🟡 للإلغاء أرسل: إلغاء",
"await_confirmation"
);
}
// Submit complaint via Siro
try {
$result = SiroService::submitComplaint(
$country,
$phone,
$context['ride_id'],
$context['complaint_text']
);
if ($result && ($result['status'] ?? '') === 'success') {
$aiResult = $result['ai_result'] ?? [];
$report = $result['report'] ?? [];
$reportTitle = $report['title'] ?? '';
$reportBody = $report['body'] ?? '';
$reply = "✅ تم إرسال شكواك بنجاح!\n\n"
. "📋 نتيجة التحليل:\n"
. "• تصنيف الشكوى: " . ($aiResult['complaint_type'] ?? '---') . "\n"
. "• الطرف المخطئ: " . ($aiResult['fault_determination'] ?? '---') . "\n"
. "• طبيعة الشكوى: " . ($aiResult['complaint_nature'] ?? '---') . "\n\n";
if ($reportBody) {
$reply .= "📄 {$reportTitle}\n{$reportBody}\n\n";
}
$reply .= "سيتم التواصل معك من قبل فريق الدعم إذا لزم الأمر.";
return new FlowResult($reply, "finished", true);
}
return new FlowResult(
"⚠️ حدث خطأ في إرسال الشكوى. يرجى المحاولة مرة أخرى أو التواصل مع الدعم الفني.",
"finished",
true
);
} catch (\Exception $e) {
error_log("[ComplaintFlow] Submit error: " . $e->getMessage());
return new FlowResult(
"⚠️ تعذر إرسال الشكوى حالياً. يرجى المحاولة لاحقاً.",
"finished",
true
);
}
default:
return new FlowResult("حدث خطأ في المسار.", "finished", true);
}
}
}
@@ -0,0 +1,322 @@
<?php
namespace App\Core\Flows;
use App\Models\ConversationState;
use App\Models\MessageLog;
/**
* ConversationFlowEngine
* Orchestrates multi-stage interactive conversation flows.
*/
class ConversationFlowEngine
{
/**
* Map of registered flow names to their classes
*/
private static array $flows = [
'test_flow' => TestFlow::class,
'driver_registration_flow' => DriverRegistrationFlow::class,
'payment_flow' => PaymentFlow::class,
'complaint_flow' => ComplaintFlow::class,
];
/**
* Map of keyword triggers to start a specific flow
*/
private static array $startTriggers = [
'test' => 'test_flow',
'اختبار' => 'test_flow',
'سجل' => 'driver_registration_flow',
'تسجيل' => 'driver_registration_flow',
'register' => 'driver_registration_flow',
'دفع' => 'payment_flow',
'وصل' => 'payment_flow',
'تسديد' => 'payment_flow',
'رصيد' => 'payment_flow',
'شكوى' => 'complaint_flow',
'مشكلة' => 'complaint_flow',
'بلاغ' => 'complaint_flow',
'تظلم' => 'complaint_flow',
'شكوي' => 'complaint_flow',
'complaint' => 'complaint_flow',
];
/**
* Process incoming message.
* Returns true if handled by the flow engine, false otherwise.
*/
public static function processMessage(array $session, array $msgData): bool
{
// 1. Housekeeping: remove expired sessions
ConversationState::cleanExpired();
$phone = $msgData['phone'];
$companyId = $session['company_id'];
$text = isset($msgData['body']) ? trim($msgData['body']) : '';
// If incoming message is audio, transcribe it via Gemini (if limits permit)
$isAudio = !empty($msgData['audio']) && !empty($msgData['mimeType']);
if ($isAudio) {
if ($companyId !== 1) {
$activeSub = \App\Models\CompanySubscription::findActiveByCompany($companyId);
if (!$activeSub) {
error_log("[Flow Engine Warning] Company {$companyId} has no active subscription for audio transcription.");
return false;
}
if (!\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'request')) {
error_log("[Flow Engine Warning] Company {$companyId} has exceeded its request limit for audio transcription.");
return false;
}
if (!\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'voice')) {
error_log("[Flow Engine Warning] Company {$companyId} has exceeded its voice limit for audio transcription.");
return false;
}
}
$rule = \App\Models\ChatbotRule::findActiveForRule($companyId);
$configuredGeminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : null;
$apiKey = \App\Services\GeminiService::getGeminiApiKey($configuredGeminiKey);
if (!empty($apiKey)) {
$transcription = \App\Services\GeminiService::transcribeAudio($apiKey, $msgData['audio'], $msgData['mimeType']);
if ($transcription) {
$text = $transcription;
$msgData['body'] = $transcription;
// Increment usage stats for successful transcription
if ($companyId !== 1) {
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'voice');
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'request');
}
}
}
}
// 2. Lookup existing active flow
$state = ConversationState::findActive($companyId, $phone);
$flowName = '';
$currentStep = '';
$context = [];
if ($state) {
$flowName = $state['flow_name'];
$currentStep = $state['current_step'];
$context = json_decode($state['context_data'] ?: '{}', true) ?: [];
} else {
// Check if message is a starting trigger for a new flow
$normalizedText = strtolower(trim($text));
if (isset(self::$startTriggers[$normalizedText])) {
$flowName = self::$startTriggers[$normalizedText];
$currentStep = 'start';
$context = [];
}
}
// If no active flow and no trigger matches, pass control back to normal bot rules
if (empty($flowName) || !isset(self::$flows[$flowName])) {
return false;
}
// Check subscription limits for active/starting flow
if ($companyId !== 1) {
$activeSub = \App\Models\CompanySubscription::findActiveByCompany($companyId);
if (!$activeSub) {
error_log("[Flow Engine Warning] Company {$companyId} has no active subscription.");
self::sendReply($session, $phone, "⚠️ عذراً، لا يوجد اشتراك نشط لهذا المتجر حالياً.");
return true;
}
if (!\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'request')) {
error_log("[Flow Engine Warning] Company {$companyId} has exceeded its general request limit.");
self::sendReply($session, $phone, "⚠️ عذراً، لقد استهلك هذا المتجر كامل الحد المسموح له من الرسائل والطلبات لهذا الشهر.");
return true;
}
}
// 3. User cancel flow option
$normalizedCancel = strtolower(trim($text));
if (in_array($normalizedCancel, ['إلغاء', 'خروج', 'cancel', 'exit'])) {
if ($state) {
ConversationState::deleteState($state['id']);
$cancelMsg = in_array($normalizedCancel, ['cancel', 'exit'])
? 'Interactive flow cancelled.'
: 'تم إلغاء المحادثة التفاعلية.';
self::sendReply($session, $phone, $cancelMsg);
return true;
}
}
// 4. Instantiate and execute the flow
$flowClass = self::$flows[$flowName];
/** @var BaseFlow $flowInstance */
$flowInstance = new $flowClass();
try {
$context['company_id'] = $companyId;
$result = $flowInstance->handleStep($currentStep, $msgData, $context);
if ($result->isFinished()) {
// Flow has reached terminal state: clean up DB record
if ($state) {
ConversationState::deleteState($state['id']);
}
} else {
// Flow has next step: save or update state record (TTL: 1 hour)
ConversationState::saveState([
'company_id' => $companyId,
'contact_phone' => $phone,
'flow_name' => $flowName,
'current_step' => $result->getNextStep(),
'context_data' => json_encode($context, JSON_UNESCAPED_UNICODE),
'expires_at' => date('Y-m-d H:i:s', strtotime('+1 hour'))
]);
}
// 5. Send reply if one is provided
$replySent = false;
if ($companyId === 1 && $flowName === 'driver_registration_flow' && $result->getReplyText() !== '') {
$rule = \App\Models\ChatbotRule::findActiveForRule($companyId);
$configuredGeminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : null;
$apiKey = \App\Services\GeminiService::getGeminiApiKey($configuredGeminiKey);
if (!empty($apiKey)) {
$configuredElKey = ($rule && !empty($rule['elevenlabs_api_key'])) ? $rule['elevenlabs_api_key'] : null;
$elApiKey = \App\Services\GeminiService::getElevenLabsApiKey($configuredElKey);
$configuredVoiceId = ($rule && !empty($rule['elevenlabs_voice_id'])) ? $rule['elevenlabs_voice_id'] : null;
$elVoiceId = \App\Services\GeminiService::getElevenLabsVoiceId($configuredVoiceId);
// Generate the audio voice note
$audioData = \App\Services\GeminiService::generateAudioResponse(
$apiKey,
"أنت سارة، مساعدة فريق خدمة العملاء في تطبيق سيرو (Siro) — أول منصة نقل ذكية وبنية تحتية رقمية للتنقل في سوريا والأردن ومصر. تتحدثين بلهجة سورية دافئة ومحببة ومهنية جداً كأنك إنسان حقيقي.
معلومات عن سيرو:
- التطبيق: سيرو (Siro) — من تطوير سيرو لنقل الركاب
- العمولة: 11% فقط (أقل عمولة في السوق السوري)
- التحميل: أندروید https://play.google.com/store/apps/details?id=com.Siro.siro | آيفون https://apps.apple.com/app/id6748075179
- طرق الشحن: سيريتيل كاش، شام كاش، المحفظة الداخلية
- أنواع الرحلات: 10 أنواع (مريح، سريع، سائقات، سكوتر، فان، VIP، اقتصاد، سعر ثابت، رايح جاي، كهربائي)
- المميزات: خرائط مملوكة SiroMaps، ترخيص حكومي NANS، 4 تطبيقات متكاملة، توثيق بالذكاء الاصطناعي، زر SOS، رحلات للسائقات
- الأمان: JWT + بصمة جهاز، تشفير AES-256-GCM، حماية من الاحتيال
- البلدان: سوريا (شغال)، مصر (جاهز)، الأردن (قريباً)
- الدعم: support@intaleqapp.com | support@siromove.com
ملاحظة: إذا المستخدم كتب بالإنجليزية، ردي بالإنجليزية. إذا كتب بالعربية، ردي بالعربية (اللهجة السورية).",
$result->getReplyText(),
$result->getReplyText(),
'Puck',
$elApiKey,
$elVoiceId
);
if ($audioData && !empty($audioData['audio'])) {
// Send the text message first
self::sendReply($session, $phone, $result->getReplyText(), $result->getMediaUrl());
// Then send the voice note
self::sendReply($session, $phone, '', null, $audioData['audio'], $audioData['mimeType']);
$replySent = true;
}
}
}
if (!$replySent && ($result->getReplyText() !== '' || $result->getMediaUrl() !== null)) {
self::sendReply($session, $phone, $result->getReplyText(), $result->getMediaUrl());
}
return true;
} catch (\Exception $e) {
error_log("[ConversationFlowEngine Exception] Flow '{$flowName}' failed: " . $e->getMessage());
return false;
}
}
/**
* Send outbound message reply via the Baileys Gateway
*/
public static function sendReply(
array $session,
string $phone,
string $message,
?string $mediaUrl = null,
?string $audioBase64 = null,
?string $mimetype = null,
?string $imageBase64 = null
): bool {
$gatewayUrl = rtrim(getenv('WHATSAPP_GATEWAY_URL') ?: 'http://localhost:3722', '/');
if (substr($gatewayUrl, -4) === '/api') {
$sendUrl = $gatewayUrl . '/messages/send';
} else {
$sendUrl = $gatewayUrl . '/api/messages/send';
}
$payloadData = [
'session_key' => $session['session_key'],
'phone' => $phone
];
if ($message !== '') {
$payloadData['message'] = $message;
}
if ($mediaUrl !== null) {
$payloadData['media_url'] = $mediaUrl;
}
if ($audioBase64 !== null) {
$payloadData['audio'] = $audioBase64;
}
if ($mimetype !== null) {
$payloadData['mimetype'] = $mimetype;
}
if ($imageBase64 !== null) {
$payloadData['image'] = $imageBase64;
}
$payload = json_encode($payloadData);
$ch = curl_init($sendUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'X-Webhook-Secret: ' . getenv('WEBHOOK_SECRET')
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$status = 'failed';
$errorMsg = null;
$waMsgId = null;
if ($httpCode === 200) {
$status = 'sent';
$resData = json_decode($response, true);
$waMsgId = $resData['data']['key']['id'] ?? null;
} else {
$resData = json_decode($response, true);
$errorMsg = $resData['error'] ?? 'HTTP Code ' . $httpCode;
error_log("[Flow Engine Gateway Error] Failed to send: " . $errorMsg);
}
$msgType = ($audioBase64 !== null || $mediaUrl !== null) ? 'audio' : 'text';
// Log the outbound auto-reply message
MessageLog::logMessage([
'company_id' => $session['company_id'],
'session_id' => $session['id'],
'contact_phone' => $phone,
'direction' => 'outbound',
'message_type' => $msgType,
'message_body' => $message,
'media_url' => $mediaUrl,
'whatsapp_message_id' => $waMsgId,
'status' => $status,
'error_message' => $errorMsg
]);
return $status === 'sent';
}
}
@@ -0,0 +1,510 @@
<?php
namespace App\Core\Flows;
use App\Services\GeminiService;
use App\Services\SiroService;
use App\Models\DriverOcrData;
use App\Models\ChatbotRule;
use App\Models\DriverReminder;
use App\Core\Database;
/**
* DriverRegistrationFlow
* Handles step-by-step driver and vehicle registration using Gemini OCR.
* Integrates with Siro platform for driver registration across Syria, Jordan, and Egypt.
*/
class DriverRegistrationFlow extends BaseFlow
{
private array $prompts = [];
private string $country = 'syria';
private array $stepToDocType = [
'id_front' => 'id_front',
'id_back' => 'id_back',
'driving_license_front' => 'driver_license_front',
'driving_license_back' => 'driver_license_back',
'vehicle_license_front' => 'car_license_front',
'vehicle_license_back' => 'car_license_back',
'criminal_record' => 'criminal_record',
];
public function __construct()
{
$this->prompts = SiroService::getDocumentPrompts($this->country);
}
public function handleStep(string $step, array $messageData, array &$context): FlowResult
{
$text = isset($messageData['body']) ? trim($messageData['body']) : '';
$phone = $messageData['phone'];
$companyId = $context['company_id'] ?? 1;
// Detect country from phone number
$this->country = SiroService::detectCountry($phone);
$context['country'] = $this->country;
// Set country-specific prompts
$this->prompts = SiroService::getDocumentPrompts($this->country);
// Country name in Arabic for messages
$countryNames = [
'syria' => 'سوريا',
'jordan' => 'الأردن',
'egypt' => 'مصر',
];
$countryName = $countryNames[$this->country] ?? 'سوريا';
// App name based on country
$appNames = [
'syria' => 'سيرو',
'jordan' => 'سيرو',
'egypt' => 'سيرو',
];
$appName = $appNames[$this->country] ?? 'سيرو';
// If currently postponed and user sends a message, resume the flow
if ($step === 'postponed') {
$activeReminder = DriverReminder::findActive($companyId, $phone);
if ($activeReminder) {
DriverReminder::update($activeReminder['id'], ['status' => 'cancelled']);
}
$step = $context['previous_step'] ?? 'ask_name';
}
// Check if user requests postponement/delay (only if already started and not finished)
if ($step !== 'start' && $step !== 'finished' && !empty($text)) {
$rule = ChatbotRule::findActiveForRule($companyId);
$configuredGeminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : null;
$apiKey = GeminiService::getGeminiApiKey($configuredGeminiKey);
if (!empty($apiKey)) {
$postponeData = $this->detectPostponement($text, $apiKey, $companyId);
if ($postponeData !== null) {
$hours = $postponeData['hours'];
$postponeCount = ($context['postpone_count'] ?? 0) + 1;
if ($postponeCount > 3) {
return new FlowResult(
"عذراً كابتن، لقد تجاوزت الحد الأقصى لمرات التأجيل (3 مرات). تم إلغاء طلب التسجيل الحالي. يمكنك البدء من جديد عندما تكون جاهزاً بكتابة 'تسجيل'.",
"finished",
true
);
}
$context['postpone_count'] = $postponeCount;
$context['previous_step'] = $step;
$scheduledAt = date('Y-m-d H:i:s', strtotime("+{$hours} hours"));
DriverReminder::saveReminder([
'company_id' => $companyId,
'phone' => $phone,
'scheduled_at' => $scheduledAt,
'postpone_count' => $postponeCount,
'status' => 'pending'
]);
return new FlowResult(
"حاضر كابتن، قمت بتأجيل التسجيل. سأقوم بتذكيرك بعد {$hours} ساعة لإكمال خطوات التسجيل. بالتوفيق!",
"postponed"
);
}
}
}
switch ($step) {
case 'start':
return new FlowResult(
"أهلاً بك كابتن في خدمة تسجيل كباتن تطبيق {$appName} في {$countryName} 🚖.\nيرجى إرسال اسمك الثلاثي الكامل للبدء:",
"ask_name"
);
case 'ask_name':
if (empty($text)) {
return new FlowResult("يرجى إدخال اسمك الثلاثي الكامل للاستمرار:", "ask_name");
}
$context['name'] = $text;
return new FlowResult(
"شكراً كابتن {$text}.\nالآن يرجى إرسال صورة **الوجه الأمامي للهوية الشخصية** (تأكد من أن الصورة واضحة والإضاءة جيدة):",
"id_front"
);
case 'id_front':
return $this->processOcrStep(
$step,
$messageData,
$context,
"id_front_sy",
"national_number",
"عذراً كابتن، لم أتمكن من قراءة الرقم الوطني من الهوية بوضوح. يرجى إرسال صورة أخرى للوجه الأمامي للهوية الشخصية تكون أكثر وضوحاً:",
"تم استخراج الرقم الوطني بنجاح ✅.\nالآن، يرجى إرسال صورة **الوجه الخلفي للهوية الشخصية**:",
"id_back"
);
case 'id_back':
return $this->processOcrStep(
$step,
$messageData,
$context,
"id_back_sy",
"gender",
"عذراً كابتن، لم أتمكن من قراءة بيانات الوجه الخلفي للهوية بوضوح. يرجى إرسال صورة أخرى للوجه الخلفي للهوية الشخصية:",
"تم استخراج البيانات بنجاح ✅.\nيرجى إرسال صورة **الوجه الأمامي لرخصة القيادة**:",
"driving_license_front"
);
case 'driving_license_front':
return $this->processOcrStep(
$step,
$messageData,
$context,
"driving_license_sy_front",
"national_number",
"عذراً كابتن، لم أتمكن من قراءة رخصة القيادة بوضوح. يرجى إرسال صورة أخرى واضحة للوجه الأمامي لرخصة القيادة:",
"تم استخراج بيانات رخصة القيادة بنجاح ✅.\nيرجى إرسال صورة **الوجه الخلفي لرخصة القيادة**:",
"driving_license_back"
);
case 'driving_license_back':
return $this->processOcrStep(
$step,
$messageData,
$context,
"driving_license_sy_back",
"license_number",
"عذراً كابتن، لم أتمكن من قراءة الوجه الخلفي لرخصة القيادة بوضوح. يرجى إعادة إرسال الصورة بشكل أكثر وضوحاً:",
"تم استخراج البيانات بنجاح ✅.\nيرجى إرسال صورة **الوجه الأمامي لرخصة السيارة (الرخصة البرتقالية)**:",
"vehicle_license_front"
);
case 'vehicle_license_front':
return $this->processOcrStep(
$step,
$messageData,
$context,
"vehicle_license_sy_front",
"car_plate",
"عذراً كابتن، لم أتمكن من قراءة رقم لوحة السيارة بوضوح. يرجى إرسال صورة واضحة للوجه الأمامي لرخصة السيارة:",
"تم استخراج رقم اللوحة بنجاح ✅.\nيرجى إرسال صورة **الوجه الخلفي لرخصة السيارة (الرخصة البرتقالية)**:",
"vehicle_license_back"
);
case 'vehicle_license_back':
return $this->processOcrStep(
$step,
$messageData,
$context,
"vehicle_license_sy_back",
"chassis",
"عذراً كابتن، لم أتمكن من قراءة مواصفات السيارة بوضوح. يرجى إرسال صورة واضحة للوجه الخلفي لرخصة السيارة:",
"تم استخراج مواصفات السيارة بنجاح ✅.\nيرجى إرسال صورة **وثيقة غير محكوم (لا حكم عليه)**:",
"criminal_record"
);
case 'criminal_record':
if (empty($messageData['image']) || empty($messageData['imageMimeType'])) {
return new FlowResult("الرجاء إرسال صورة وثيقة غير محكوم (لا حكم عليه) للاستمرار:", "criminal_record");
}
// Save non-OCR criminal record image
$imageUrl = $this->saveIncomingImage($step, $phone, $messageData);
if (!$imageUrl) {
return new FlowResult("عذراً، فشل حفظ الصورة. الرجاء إعادة إرسال صورة الوثيقة:", "criminal_record");
}
// Upload criminal record to Siro
$fullPath = __DIR__ . '/../../../../public' . $imageUrl;
$criminalSiroUrl = SiroService::uploadDocument(
$this->country,
SiroService::formatPhone($phone, $this->country),
'criminal_record',
$fullPath,
$messageData['imageMimeType']
);
$context['criminal_record_siro_url'] = $criminalSiroUrl;
// Securely save registration data to local database
try {
DriverOcrData::saveSecure([
'company_id' => $companyId,
'phone' => $phone,
'name' => $context['name'],
'id_front_url' => $context['id_front_url'] ?? null,
'id_front_ocr' => $context['id_front_ocr'] ?? null,
'id_back_url' => $context['id_back_url'] ?? null,
'id_back_ocr' => $context['id_back_ocr'] ?? null,
'driving_license_front_url' => $context['driving_license_front_url'] ?? null,
'driving_license_front_ocr' => $context['driving_license_front_ocr'] ?? null,
'driving_license_back_url' => $context['driving_license_back_url'] ?? null,
'driving_license_back_ocr' => $context['driving_license_back_ocr'] ?? null,
'vehicle_license_front_url' => $context['vehicle_license_front_url'] ?? null,
'vehicle_license_front_ocr' => $context['vehicle_license_front_ocr'] ?? null,
'vehicle_license_back_url' => $context['vehicle_license_back_url'] ?? null,
'vehicle_license_back_ocr' => $context['vehicle_license_back_ocr'] ?? null,
'criminal_record_url' => $imageUrl,
'status' => 'ocr_completed'
]);
} catch (\Exception $dbEx) {
error_log("[Registration Flow Error] DB Write Failed: " . $dbEx->getMessage());
return new FlowResult("عذراً، حدث خطأ أثناء حفظ طلبك في قاعدة البيانات. يرجى المحاولة مرة أخرى لاحقاً.", "criminal_record");
}
// Register driver in Siro with Siro-hosted URLs
$docUrls = [
'id_front' => $context['id_front_siro_url'] ?? '',
'id_back' => $context['id_back_siro_url'] ?? '',
'driving_license_front' => $context['driving_license_front_siro_url'] ?? '',
'driving_license_back' => $context['driving_license_back_siro_url'] ?? '',
'vehicle_license_front' => $context['vehicle_license_front_siro_url'] ?? '',
'vehicle_license_back' => $context['vehicle_license_back_siro_url'] ?? '',
'criminal_record' => $criminalSiroUrl ?? '',
];
$idOcr = $context['id_front_ocr'] ?? [];
$vlOcr = $context['vehicle_license_front_ocr'] ?? [];
$vlbOcr = $context['vehicle_license_back_ocr'] ?? [];
$dlOcr = $context['driving_license_front_ocr'] ?? [];
$formattedPhone = SiroService::formatPhone($phone, $this->country);
$driverId = 'DRV' . date('YmdHis') . rand(100, 999);
$driverData = [
'phone' => $formattedPhone,
'password' => substr(md5($formattedPhone . time()), 0, 12),
'first_name' => explode(' ', $context['name'] ?? '')[0] ?? $context['name'],
'last_name' => implode(' ', array_slice(explode(' ', $context['name'] ?? ''), 1)) ?: $context['name'],
'name_arabic' => $context['name'] ?? '',
'national_number' => $idOcr['national_number'] ?? '',
'birthdate' => $idOcr['dob'] ?? '',
'address' => $idOcr['address'] ?? '',
'id' => $driverId,
];
$carData = [
'vin' => $vlbOcr['chassis'] ?? $vlOcr['vin'] ?? '',
'car_plate' => $vlOcr['car_plate'] ?? '',
'make' => $vlbOcr['make'] ?? '',
'model' => $vlbOcr['model'] ?? '',
'year' => $vlbOcr['year'] ?? '',
'color' => $vlOcr['color'] ?? '',
'color_hex' => $vlOcr['color_hex'] ?? '#000000',
'owner' => $vlOcr['owner'] ?? '',
'fuel' => $vlbOcr['fuel'] ?? '',
'expiration_date' => $vlOcr['issue_date'] ?? '',
'vehicle_category_id' => 1,
];
$syroResult = SiroService::registerDriver($driverData, $carData, $docUrls, $this->country);
$appNames = [
'syria' => 'سيرو',
'jordan' => 'سيرو',
'egypt' => 'سيرو',
];
$appName = $appNames[$this->country] ?? 'سيرو';
if ($syroResult && ($syroResult['status'] ?? '') === 'success') {
DriverOcrData::saveSecure([
'company_id' => $companyId,
'phone' => $phone,
'name' => $context['name'],
'status' => 'registered'
]);
return new FlowResult(
"شكراً لك كابتن، تم تسجيلك بنجاح في تطبيق {$appName} 🚖✅\nسيتم مراجعة طلبك من قبل فريق الخدمة وتفعيل حسابك قريباً. يومك سعيد!",
"finished",
true
);
} else {
$errMsg = $syroResult['message'] ?? 'خطأ غير معروف';
error_log("[Registration Flow] Siro registration failed: " . json_encode($syroResult));
return new FlowResult(
"تم حفظ مستنداتك بنجاح في نظامنا. لكن حدث تأخير في تسجيلك على تطبيق {$appName}. سيقوم فريقنا بمراجعة بياناتك وتفعيل حسابك في أقرب وقت. شكراً لصبرك! 🙏",
"finished",
true
);
}
default:
return new FlowResult("خطأ في تحديد خطوة المسار.", "finished", true);
}
}
/**
* Process document upload and OCR extraction step
*/
private function processOcrStep(
string $step,
array $messageData,
array &$context,
string $promptKey,
string $requiredJsonKey,
string $failMessage,
string $successMessage,
string $nextStep
): FlowResult {
if (empty($messageData['image']) || empty($messageData['imageMimeType'])) {
return new FlowResult("الرجاء إرسال الصورة المطلوبة للمتابعة:", $step);
}
$imageUrl = $this->saveIncomingImage($step, $messageData['phone'], $messageData);
if (!$imageUrl) {
return new FlowResult("عذراً، فشل حفظ الصورة. الرجاء إعادة المحاولة وإرسال الصورة:", $step);
}
// Upload to Siro and store the signed URL
$fullPath = __DIR__ . '/../../../../public' . $imageUrl;
$siroUrl = SiroService::uploadDocument(
$this->country,
SiroService::formatPhone($messageData['phone'], $this->country),
$this->stepToDocType[$step] ?? $step,
$fullPath,
$messageData['imageMimeType']
);
if ($siroUrl) {
$context[$step . '_siro_url'] = $siroUrl;
error_log("[DriverRegistrationFlow] Uploaded {$step} to Siro: {$siroUrl}");
} else {
$context[$step . '_siro_url'] = null;
error_log("[DriverRegistrationFlow] Warning: Failed to upload {$step} to Siro, using local URL");
}
$companyId = $context['company_id'] ?? 1;
// Check subscription limit for OCR
if ($companyId !== 1) {
if (!\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'ocr')) {
error_log("[DriverRegistrationFlow] Company {$companyId} has exceeded its OCR limit.");
return new FlowResult("⚠️ عذراً، لقد استهلك هذا المتجر الحد المسموح له من تحليل الصور والوصولات لهذا الشهر. يرجى إرسال استفسارك نصياً.", $step);
}
}
$rule = ChatbotRule::findActiveForRule($companyId);
$configuredGeminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : null;
$apiKey = GeminiService::getGeminiApiKey($configuredGeminiKey);
if (empty($apiKey)) {
error_log("[DriverRegistrationFlow] Gemini API key not configured.");
return new FlowResult("عذراً، عطل فني في خادم معالجة الصور بالذكاء الاصطناعي. يرجى المحاولة لاحقاً.", $step);
}
$prompt = $this->prompts[$step] ?? '';
$rawOcr = GeminiService::generateOcrFromImage($apiKey, $prompt, $messageData['image'], $messageData['imageMimeType']);
if (!$rawOcr) {
error_log("[DriverRegistrationFlow] OCR response empty or model request failed.");
return new FlowResult($failMessage, $step);
}
$ocrData = json_decode($rawOcr, true);
if (!$ocrData || empty($ocrData[$requiredJsonKey])) {
error_log("[DriverRegistrationFlow] Missing or empty required key '$requiredJsonKey' in OCR response: " . $rawOcr);
return new FlowResult($failMessage, $step);
}
// Increment stats on successful OCR processing
if ($companyId !== 1) {
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'ocr');
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'request');
}
// Save URL and OCR JSON string in the conversation context
$context[$step . '_url'] = $imageUrl;
$context[$step . '_ocr'] = $ocrData;
return new FlowResult($successMessage, $nextStep);
}
/**
* Decode base64 image data and save it to the public directory
*/
private function saveIncomingImage(string $step, string $phone, array $messageData): ?string
{
try {
$extension = 'jpg';
if (strpos($messageData['imageMimeType'], 'png') !== false) {
$extension = 'png';
}
$uniqueName = 'driver_' . $step . '_' . md5($phone . time()) . '.' . $extension;
$uploadDir = __DIR__ . '/../../../../public/uploads/documents/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$uploadPath = $uploadDir . $uniqueName;
$imgData = base64_decode($messageData['image']);
if (file_put_contents($uploadPath, $imgData) === false) {
error_log("[DriverRegistrationFlow] Failed to write image file: " . $uploadPath);
return null;
}
return '/uploads/documents/' . $uniqueName;
} catch (\Exception $e) {
error_log("[DriverRegistrationFlow Exception] Failed to save image: " . $e->getMessage());
return null;
}
}
/**
* Detect if user wants to postpone, and return hours_delay if so.
*/
private function detectPostponement(string $text, string $apiKey, int $companyId): ?array
{
if (empty($text)) {
return null;
}
// Quick heuristic check: if the message is too long, or clearly doesn't contain postponement keywords, skip to save API costs
$keywords = ['بعدين', 'بكرا', 'بكرة', 'بعد', 'شوي', 'ثانية', 'مشغول', 'المسا', 'الليل', 'تأجيل', 'وقت ثاني', 'تعبان', 'بعدين برسل', 'بعدين ببعت', 'ببعثهم بعدين', 'ببعتهم بعدين', 'بعدين بكمل'];
$hasKeyword = false;
foreach ($keywords as $kw) {
if (mb_strpos($text, $kw) !== false) {
$hasKeyword = true;
break;
}
}
if (!$hasKeyword && mb_strlen($text) > 100) {
return null;
}
$systemPrompt = "You are an assistant that detects if a user wants to postpone, delay, or complete a registration flow later. Analyze the Arabic message.";
$userMessage = <<<EOT
Analyze the following Arabic message (often in Syrian dialect) to determine if the user wants to postpone/delay sending documents or complete the registration later.
Message: "{$text}"
Respond with ONLY a valid JSON object matching this schema:
{
"wants_postpone": true/false,
"hours_delay": 12
}
Do not include any markdown, code blocks, or explanations.
EOT;
$response = GeminiService::generateResponse($apiKey, $systemPrompt, $userMessage);
if (empty($response)) {
return null;
}
// Increment request limit on successful postponement check API call
if ($companyId !== 1) {
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'request');
}
// Clean markdown block if present
$response = trim(preg_replace('/```json|```/', '', $response));
$data = json_decode($response, true);
if (isset($data['wants_postpone']) && $data['wants_postpone'] === true) {
return [
'hours' => isset($data['hours_delay']) ? (int)$data['hours_delay'] : 12
];
}
return null;
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Core\Flows;
/**
* FlowResult
* Holds response details after handling a single step in a conversation flow.
*/
class FlowResult
{
private string $replyText;
private string $nextStep;
private bool $finished;
private ?string $mediaUrl;
public function __construct(string $replyText, string $nextStep, bool $finished = false, ?string $mediaUrl = null)
{
$this->replyText = $replyText;
$this->nextStep = $nextStep;
$this->finished = $finished;
$this->mediaUrl = $mediaUrl;
}
/**
* Get the reply message text to send to the contact
*/
public function getReplyText(): string
{
return $this->replyText;
}
/**
* Get the next step key
*/
public function getNextStep(): string
{
return $this->nextStep;
}
/**
* Check if the flow is finished (to be destroyed)
*/
public function isFinished(): bool
{
return $this->finished;
}
/**
* Get target media URL (if any)
*/
public function getMediaUrl(): ?string
{
return $this->mediaUrl;
}
}
+194
View File
@@ -0,0 +1,194 @@
<?php
namespace App\Core\Flows;
use App\Services\SiroService;
/**
* PaymentFlow — Smart Payment Verification
*
* Flow: start → await_receipt → finished
*
* Smart features:
* • Auto-detects country + payment method (shamcash/cliq)
* • Auto-finds pending invoice by phone (no invoice number needed)
* • AI receipt verification via payment server Gemini
* • Postponement detection (keyword-based)
* • Image validation + retry (max 3 attempts)
* • Currency-aware messages (SYP/JOD)
*/
class PaymentFlow extends BaseFlow
{
private const MAX_RETRIES = 3;
public function handleStep(string $step, array $messageData, array &$context): FlowResult
{
$phone = $messageData['phone'] ?? '';
$text = isset($messageData['body']) ? trim($messageData['body']) : '';
$image = $messageData['image'] ?? '';
$imageMimeType = $messageData['imageMimeType'] ?? 'image/jpeg';
// ── Postponement check (only if flow is active, not on start/finished) ──
if ($step !== 'start' && $step !== 'finished' && !empty($text)) {
$postpone = $this->detectPostponement($text);
if ($postpone !== null) {
$context['previous_step'] = $step;
$hours = $postpone;
return new FlowResult(
"حاضر كابتن، تم تأجيل طلب الدفع. سأذكرك بعد {$hours} ساعات.\n"
. "للمتابعة لاحقاً، أرسل 'دفع' مرة أخرى.",
"postponed"
);
}
}
// ── Country + method detection ──
$country = $context['country'] ?? SiroService::detectCountry($phone);
$context['country'] = $country;
$paymentMethod = $context['payment_method'] ?? match ($country) {
'jordan' => 'cliq',
default => 'shamcash',
};
$context['payment_method'] = $paymentMethod;
$methodName = $paymentMethod === 'cliq' ? 'كليك (Cliq)' : 'شام كاش (ShamCash)';
$currency = $paymentMethod === 'cliq' ? 'دينار أردني' : 'ل.س';
$countryName = match ($country) {
'jordan' => 'الأردن',
'egypt' => 'مصر',
default => 'سوريا',
};
switch ($step) {
// ─────────────────────────────────────────────────
// START
// ─────────────────────────────────────────────────
case 'start':
$context['retry_count'] = 0;
return new FlowResult(
"أهلاً بك في خدمة التحقق من الدفع.\n\n"
. "📍 الدولة: {$countryName}\n"
. "💰 طريقة الدفع: {$methodName}\n"
. "💵 العملة: {$currency}\n\n"
. "📸 يرجى إرسال صورة واضحة لإيصال الدفع أو صورة الشاشة.\n"
. "سيتم التحقق من الفاتورة المعلقة تلقائياً.\n\n"
. "🟡 للخروج اكتب: إلغاء\n"
. "⏰ للتأجيل اكتب: بعدين",
"await_receipt"
);
// ─────────────────────────────────────────────────
// AWAIT_RECEIPT
// ─────────────────────────────────────────────────
case 'await_receipt':
// ── No image sent ──
if (empty($image)) {
return new FlowResult(
"📸 يرجى إرسال صورة الإيصال أو وصل التحويل.\n"
. "تأكد من أن الصورة واضحة وتظهر المبلغ وتفاصيل التحويل.",
"await_receipt"
);
}
// ── Validate image size ──
$decoded = base64_decode($image, true);
if ($decoded === false || strlen($decoded) < 1024) {
$retry = ($context['retry_count'] ?? 0) + 1;
$context['retry_count'] = $retry;
if ($retry >= self::MAX_RETRIES) {
return new FlowResult(
"عذراً، لم نتمكن من قراءة الصورة بعد {$retry} محاولات.\n"
. "يرجى التواصل مع خدمة العملاء لإتمام عملية الدفع يدوياً.",
"finished",
true
);
}
return new FlowResult(
"⚠️ الصورة غير واضحة أو صغيرة جداً.\n"
. "يرجى إرسال صورة واضحة وحجم أكبر (محاولة {$retry} من " . self::MAX_RETRIES . "):",
"await_receipt"
);
}
// ── Normalize MIME type ──
if (strpos($imageMimeType, ';') !== false) {
$imageMimeType = trim(explode(';', $imageMimeType)[0]);
}
// ── Send to payment server ──
$companyId = $context['company_id'] ?? 1;
$result = \App\Controllers\WhatsAppController::verifyPaymentSlipStatic(
companyId: $companyId,
phone: $phone,
jsonStr: '',
userType: 'driver',
paymentMethod: $paymentMethod,
invoiceNumber: '',
receiptImage: $image,
imageMimeType: $imageMimeType,
);
if ($result) {
return new FlowResult($result, "finished", true);
}
$retry = ($context['retry_count'] ?? 0) + 1;
$context['retry_count'] = $retry;
if ($retry >= self::MAX_RETRIES) {
return new FlowResult(
"عذراً، تعذر التحقق من الدفع بعد {$retry} محاولات.\n"
. "سيتم مراجعة العملية من قبل الإدارة.",
"finished",
true
);
}
return new FlowResult(
"لم نتمكن من التحقق من الدفع حالياً (محاولة {$retry} من " . self::MAX_RETRIES . ").\n"
. "يرجى إرسال صورة أوضح والإضاءة جيدة:",
"await_receipt"
);
case 'postponed':
// Resume from postponed
$step = $context['previous_step'] ?? 'await_receipt';
return new FlowResult(
"مرحباً بك مرة أخرى! 👋\n"
. "📸 أرسل صورة إيصال الدفع للمتابعة:",
$step
);
default:
return new FlowResult("حدث خطأ في المسار.", "finished", true);
}
}
/**
* Detect if user wants to postpone (keyword-based, no AI call)
*/
private function detectPostponement(string $text): ?int
{
$keywords = [
'بعدين' => 2, 'بكرا' => 12, 'بكرة' => 12, 'بعد' => 3,
'شوي' => 1, 'مشغول' => 4, 'تأجيل' => 6, 'لاحقاً' => 6,
'لاحقا' => 6, 'الحق' => 6, 'وقت ثاني' => 8, 'تعبان' => 6,
'بعدين برسل' => 3, 'بعدين ببعت' => 3, 'بعدين بكمل' => 4,
'ببعثها' => 3, 'ببعت' => 3, 'برسل' => 2,
];
$normalized = trim(mb_strtolower($text));
foreach ($keywords as $kw => $hours) {
if (mb_strpos($normalized, $kw) !== false) {
return $hours;
}
}
return null;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Core\Flows;
/**
* TestFlow
* A basic interactive flow for testing the multi-stage system.
*/
class TestFlow extends BaseFlow
{
public function handleStep(string $step, array $messageData, array &$context): FlowResult
{
$text = isset($messageData['body']) ? trim($messageData['body']) : '';
switch ($step) {
case 'start':
// Initiate step
return new FlowResult("أهلاً بك في اختبار المسار التفاعلي! ما هو اسمك الكريم؟", "ask_name");
case 'ask_name':
if (empty($text)) {
return new FlowResult("يرجى إدخال اسمك للاستمرار:", "ask_name");
}
$context['name'] = $text;
return new FlowResult("تشرفنا بك يا {$text}! من فضلك قم بتقييم خدمتنا من 1 إلى 5:", "ask_feedback");
case 'ask_feedback':
if (!preg_match('/^[1-5]$/', $text)) {
return new FlowResult("الرجاء إدخال رقم من 1 إلى 5 فقط للتقييم:", "ask_feedback");
}
$context['rating'] = (int)$text;
return new FlowResult("شكراً لك يا {$context['name']}! لقد تم تسجيل تقييمك ({$text}/5) بنجاح. يومك سعيد!", "finished", true);
default:
return new FlowResult("خطأ في تحديد خطوة المسار.", "finished", true);
}
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
namespace App\Core;
/**
* Handles HTTP requests, extracting query params, body data, and headers.
*/
class Request
{
private string $method;
private string $path;
private array $queryParams;
private array $bodyParams;
private array $headers;
// Explicit properties to store authentication details to avoid deprecation warnings in PHP 8.2+
public ?int $user_id = null;
public ?int $company_id = null;
public ?string $role = null;
public bool $is_super_admin = false;
public function __construct()
{
$this->method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
// Extract clean path without query parameters
$uri = $_SERVER['REQUEST_URI'] ?? '/';
$path = explode('?', $uri)[0];
$this->path = '/' . trim($path, '/');
$this->queryParams = $_GET;
$this->headers = $this->extractHeaders();
$this->bodyParams = $this->parseBody();
}
public function getMethod(): string
{
return $this->method;
}
public function getPath(): string
{
return $this->path;
}
public function getQueryParams(): array
{
return $this->queryParams;
}
public function getQuery(string $key, $default = null)
{
return $this->queryParams[$key] ?? $default;
}
public function getBody(): array
{
return $this->bodyParams;
}
public function setBody(array $bodyParams): void
{
$this->bodyParams = $bodyParams;
}
public function setQueryParams(array $queryParams): void
{
$this->queryParams = $queryParams;
}
public function get(string $key, $default = null)
{
return $this->bodyParams[$key] ?? ($this->queryParams[$key] ?? $default);
}
public function getHeaders(): array
{
return $this->headers;
}
public function getHeader(string $key, $default = null): ?string
{
$keyLower = strtolower($key);
return $this->headers[$keyLower] ?? $default;
}
private function extractHeaders(): array
{
$headers = [];
foreach ($_SERVER as $name => $value) {
if (substr($name, 0, 5) == 'HTTP_') {
$headers[strtolower(str_replace('_', '-', substr($name, 5)))] = $value;
} elseif ($name == 'CONTENT_TYPE') {
$headers['content-type'] = $value;
} elseif ($name == 'CONTENT_LENGTH') {
$headers['content-length'] = $value;
}
}
return $headers;
}
private function parseBody(): array
{
if ($this->method === 'GET') {
return [];
}
$contentType = $this->getHeader('content-type', '');
if (strpos($contentType, 'application/json') !== false) {
$input = file_get_contents('php://input');
$data = json_decode($input, true);
return is_array($data) ? $data : [];
}
return $_POST;
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace App\Core;
/**
* Handles generating and sending consistent API and HTML responses.
*/
class Response
{
private int $statusCode = 200;
private array $headers = [];
public function setStatusCode(int $code): self
{
$this->statusCode = $code;
return $this;
}
public function status(int $code): self
{
return $this->setStatusCode($code);
}
public function getStatusCode(): int
{
return $this->statusCode;
}
public function setHeader(string $name, string $value): self
{
$this->headers[$name] = $value;
return $this;
}
/**
* Send JSON response and terminate execution
*
* @param mixed $data
* @param int $code
* @return void
*/
public function json($data, int $code = 200): void
{
$this->setStatusCode($code);
$this->setHeader('Content-Type', 'application/json; charset=utf-8');
// Setup CORS headers — restrict origin to the configured allowed domain
$allowedOrigin = getenv('ALLOWED_ORIGIN') ?: '*';
$this->setHeader('Access-Control-Allow-Origin', $allowedOrigin);
$this->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
$this->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
$this->setHeader('Vary', 'Origin'); // Required when Access-Control-Allow-Origin is not *
$this->sendHeaders();
http_response_code($this->statusCode);
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit;
}
/**
* Send HTML response and terminate execution
*
* @param string $html
* @param int $code
* @return void
*/
public function html(string $html, int $code = 200): void
{
$this->setStatusCode($code);
$this->setHeader('Content-Type', 'text/html; charset=utf-8');
$this->sendHeaders();
http_response_code($this->statusCode);
echo $html;
exit;
}
/**
* Send success JSON response
*/
public function success(string $message, array $data = [], int $code = 200): void
{
$this->json([
'status' => 'success',
'message' => $message,
'data' => $data
], $code);
}
/**
* Send error JSON response
*/
public function error(string $message, int $code = 400, array $errors = []): void
{
$response = [
'status' => 'error',
'message' => $message
];
if (!empty($errors)) {
$response['errors'] = $errors;
}
$this->json($response, $code);
}
public function sendHeaders(): void
{
if (headers_sent()) {
return;
}
foreach ($this->headers as $name => $value) {
header("{$name}: {$value}");
}
}
}
+131
View File
@@ -0,0 +1,131 @@
<?php
namespace App\Core;
/**
* Basic regex-based router supporting dynamic parameters, middlewares, and CORS OPTIONS.
*/
class Router
{
private array $routes = [];
private array $globalMiddleware = [];
/**
* Define a GET route
*/
public function get(string $path, $handler, array $middleware = []): void
{
$this->addRoute('GET', $path, $handler, $middleware);
}
/**
* Define a POST route
*/
public function post(string $path, $handler, array $middleware = []): void
{
$this->addRoute('POST', $path, $handler, $middleware);
}
/**
* Define a PUT route
*/
public function put(string $path, $handler, array $middleware = []): void
{
$this->addRoute('PUT', $path, $handler, $middleware);
}
/**
* Define a DELETE route
*/
public function delete(string $path, $handler, array $middleware = []): void
{
$this->addRoute('DELETE', $path, $handler, $middleware);
}
/**
* Add global middleware applied to all routes
*/
public function use($middleware): void
{
$this->globalMiddleware[] = $middleware;
}
private function addRoute(string $method, string $path, $handler, array $middleware): void
{
// Convert path matching expressions: /api/tickets/{id} -> regex
$pattern = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '(?P<$1>[^/]+)', $path);
$pattern = '#^' . $pattern . '$#';
$this->routes[] = [
'method' => $method,
'path' => $path,
'pattern' => $pattern,
'handler' => $handler,
'middleware' => $middleware
];
}
/**
* Match current request and execute middleware and controller action
*/
public function dispatch(Request $request, Response $response): void
{
$method = $request->getMethod();
$path = $request->getPath();
// Handle CORS Preflight Preemptively
if ($method === 'OPTIONS') {
$allowedOrigin = getenv('ALLOWED_ORIGIN') ?: '*';
$response->setHeader('Access-Control-Allow-Origin', $allowedOrigin);
$response->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
$response->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
$response->setHeader('Vary', 'Origin');
$response->setStatusCode(200);
exit;
}
foreach ($this->routes as $route) {
if ($route['method'] === $method && preg_match($route['pattern'], $path, $matches)) {
// Filter named captures from regex match
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
// Run global middleware first
foreach ($this->globalMiddleware as $mw) {
$mwInstance = new $mw();
$mwInstance->handle($request, $response);
}
// Run route specific middleware
foreach ($route['middleware'] as $mw) {
$mwInstance = new $mw();
$mwInstance->handle($request, $response);
}
// Execute Controller
$handler = $route['handler'];
if (is_array($handler) && count($handler) === 2) {
list($controllerClass, $action) = $handler;
if (class_exists($controllerClass)) {
$controller = new $controllerClass();
if (method_exists($controller, $action)) {
// Call action with Request, Response and URI dynamic parameters
call_user_func_array([$controller, $action], array_merge([$request, $response], $params));
return;
}
}
} elseif (is_callable($handler)) {
call_user_func_array($handler, array_merge([$request, $response], $params));
return;
}
error_log("Handler error for route: [{$method}] {$path}");
$response->error("Internal Server Error", 500);
return;
}
}
// Route not found
error_log("Route not found: [{$method}] {$path}");
$response->error("Not Found", 404);
}
}
+209
View File
@@ -0,0 +1,209 @@
<?php
namespace App\Core;
/**
* Advanced OWASP Security Helper
* Handles AES-256-GCM encryption/decryption, HMAC Blind Indexing,
* Bcrypt password hashing, and JWT validation.
*/
class Security
{
/**
* Get the encryption key from environment (must be 32 bytes for AES-256)
*/
private static function getEncryptionKey(): string
{
$key = getenv('ENCRYPTION_KEY');
if (!$key || strlen($key) < 16) {
throw new \RuntimeException("ENCRYPTION_KEY environment variable is empty or too short. Cryptographic operations aborted.");
}
return substr(hash('sha256', $key, true), 0, 32);
}
/**
* Get HMAC Salt for Blind Indexing
*/
private static function getHmacSalt(): string
{
$salt = getenv('HMAC_SALT');
if (!$salt) {
throw new \RuntimeException("HMAC_SALT environment variable is empty. Cryptographic operations aborted.");
}
return $salt;
}
/**
* Get JWT Secret
*/
private static function getJwtSecret(): string
{
$secret = getenv('JWT_SECRET');
if (!$secret) {
throw new \RuntimeException("JWT_SECRET environment variable is empty. Cryptographic operations aborted.");
}
return $secret;
}
/**
* Encrypt text using AES-256-GCM
*/
public static function encrypt(string $plainText): string
{
if ($plainText === '') {
return '';
}
$key = self::getEncryptionKey();
$iv = openssl_random_pseudo_bytes(12); // GCM standard IV is 12 bytes
$tag = '';
$ciphertext = openssl_encrypt(
$plainText,
'aes-256-gcm',
$key,
OPENSSL_RAW_DATA,
$iv,
$tag
);
if ($ciphertext === false) {
return '';
}
// Return combined iv + tag + ciphertext base64 encoded
return base64_encode($iv . $tag . $ciphertext);
}
/**
* Decrypt text using AES-256-GCM
*/
public static function decrypt(string $encryptedText): string
{
if ($encryptedText === '') {
return '';
}
$key = self::getEncryptionKey();
$data = base64_decode($encryptedText);
// AES-256-GCM tag is 16 bytes, IV is 12 bytes
if (strlen($data) < 28) {
return '';
}
$iv = substr($data, 0, 12);
$tag = substr($data, 12, 16);
$ciphertext = substr($data, 28);
$decrypted = openssl_decrypt(
$ciphertext,
'aes-256-gcm',
$key,
OPENSSL_RAW_DATA,
$iv,
$tag
);
return $decrypted !== false ? $decrypted : '';
}
/**
* Generate Blind Index (HMAC-SHA256) for secure database queries
*/
public static function blindIndex(string $value): string
{
if ($value === '') {
return '';
}
$normalized = strtolower(trim($value));
return hash_hmac('sha256', $normalized, self::getHmacSalt());
}
/**
* Hash password using bcrypt
*/
public static function hashPassword(string $password): string
{
return password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
}
/**
* Verify password
*/
public static function verifyPassword(string $password, string $hash): bool
{
return password_verify($password, $hash);
}
/**
* Generate JWT Token with HMAC-SHA256 signature
* Includes user_id, company_id, role, iss, aud, and jti.
*/
public static function generateJWT(array $payload, int $expirySeconds = 86400): string
{
$header = self::base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
// Standard OWASP Claims
$payload['iat'] = time();
$payload['exp'] = time() + $expirySeconds;
$payload['iss'] = getenv('APP_URL'); // Issuer
$payload['aud'] = 'nabeh_dashboard'; // Audience
$payload['jti'] = bin2hex(random_bytes(16)); // JWT ID to prevent Replay Attacks
$payloadEncoded = self::base64UrlEncode(json_encode($payload));
$secret = self::getJwtSecret();
$signature = hash_hmac('sha256', "$header.$payloadEncoded", $secret, true);
$signatureEncoded = self::base64UrlEncode($signature);
return "$header.$payloadEncoded.$signatureEncoded";
}
/**
* Verify JWT Token and return payload if valid, false otherwise
*/
public static function verifyJWT(string $token)
{
$parts = explode('.', $token);
if (count($parts) !== 3) {
return false;
}
list($headerEncoded, $payloadEncoded, $signatureEncoded) = $parts;
$secret = self::getJwtSecret();
if (!$secret) {
return false;
}
$signature = self::base64UrlDecode($signatureEncoded);
$expectedSignature = hash_hmac('sha256', "$headerEncoded.$payloadEncoded", $secret, true);
if (!hash_equals($signature, $expectedSignature)) {
return false;
}
$payload = json_decode(self::base64UrlDecode($payloadEncoded), true);
if (!$payload || !isset($payload['exp']) || time() >= $payload['exp']) {
return false;
}
// Validate Issuer
$expectedIssuer = getenv('APP_URL');
if (isset($payload['iss']) && $payload['iss'] !== $expectedIssuer) {
return false;
}
return $payload;
}
private static function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
private static function base64UrlDecode(string $data): string
{
return base64_decode(strtr($data, '-_', '+/') . str_repeat('=', (4 - strlen($data) % 4) % 4));
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
namespace App\Core;
/**
* Core Validation Engine
* Handles data validation before processing.
*/
class Validator
{
private array $errors = [];
/**
* Validate an array of data against rules.
* Example rules: ['email' => 'required|email', 'password' => 'required|min:8']
*/
public function validate(array $data, array $rules): bool
{
$this->errors = [];
foreach ($rules as $field => $ruleString) {
$rulesArray = explode('|', $ruleString);
$value = $data[$field] ?? null;
foreach ($rulesArray as $rule) {
$this->applyRule($field, $value, $rule);
}
}
return empty($this->errors);
}
/**
* Get validation errors.
*/
public function getErrors(): array
{
return $this->errors;
}
/**
* Apply a specific rule to a field's value.
*/
private function applyRule(string $field, $value, string $rule): void
{
// Parse rule with parameters (e.g., min:8)
$params = [];
if (strpos($rule, ':') !== false) {
list($rule, $paramStr) = explode(':', $rule, 2);
$params = explode(',', $paramStr);
}
switch ($rule) {
case 'required':
if ($value === null || trim((string)$value) === '') {
$this->addError($field, "The {$field} field is required.");
}
break;
case 'email':
if ($value && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
$this->addError($field, "The {$field} must be a valid email address.");
}
break;
case 'min':
$min = (int)($params[0] ?? 0);
if ($value && strlen((string)$value) < $min) {
$this->addError($field, "The {$field} must be at least {$min} characters.");
}
break;
case 'max':
$max = (int)($params[0] ?? 0);
if ($value && strlen((string)$value) > $max) {
$this->addError($field, "The {$field} must not exceed {$max} characters.");
}
break;
case 'numeric':
if ($value && !is_numeric($value)) {
$this->addError($field, "The {$field} must be a number.");
}
break;
case 'strong_password':
// At least 8 chars, 1 uppercase, 1 lowercase, 1 number
if ($value && !preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/', $value)) {
$this->addError($field, "The {$field} must be at least 8 characters long and contain uppercase, lowercase, and a number.");
}
break;
}
}
private function addError(string $field, string $message): void
{
if (!isset($this->errors[$field])) {
$this->errors[$field] = [];
}
$this->errors[$field][] = $message;
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Middlewares;
use App\Core\Request;
use App\Core\Response;
use App\Core\Security;
class AuthMiddleware
{
/**
* Verifies the JWT token and populates request properties.
*/
public function handle(Request $request, Response $response): void
{
$authHeader = $request->getHeader('authorization', '');
if (!$authHeader || !preg_match('/Bearer\s(\S+)/i', $authHeader, $matches)) {
$response->json(['error' => 'Unauthorized', 'message' => 'Token not provided or invalid format'], 401);
exit;
}
$token = $matches[1];
$payload = Security::verifyJWT($token);
if (!$payload) {
$response->json(['error' => 'Unauthorized', 'message' => 'Invalid or expired token'], 401);
exit;
}
// Validate required custom payload elements
if (!isset($payload['user_id']) || !isset($payload['company_id']) || !isset($payload['role'])) {
$response->json(['error' => 'Unauthorized', 'message' => 'Malformed token payload structure'], 401);
exit;
}
// Attach user info to the Request instance dynamically so controllers can use it
$request->user_id = $payload['user_id'];
$request->company_id = $payload['company_id'];
$request->role = $payload['role'];
$request->is_super_admin = (int)$payload['company_id'] === 1;
}
}
@@ -0,0 +1,94 @@
<?php
namespace App\Middlewares;
use App\Core\Request;
use App\Core\Response;
/**
* Rate Limit Middleware
* Limits the number of requests per IP address using file-based counters.
* Protects sensitive endpoints (login, register) from Brute Force attacks.
*/
class RateLimitMiddleware
{
/**
* Maximum allowed requests within the time window
*/
private int $maxAttempts;
/**
* Time window in seconds
*/
private int $decaySeconds;
public function __construct(int $maxAttempts = 5, int $decaySeconds = 60)
{
$this->maxAttempts = $maxAttempts;
$this->decaySeconds = $decaySeconds;
}
public function handle(Request $request, Response $response): void
{
$ip = $this->getClientIp();
$key = 'rate_' . md5($ip . '_' . $request->getPath());
$storageDir = APP_ROOT . '/storage/rate_limits';
if (!is_dir($storageDir)) {
mkdir($storageDir, 0750, true);
}
$filePath = $storageDir . '/' . $key . '.json';
$data = ['count' => 0, 'expires_at' => time() + $this->decaySeconds];
if (file_exists($filePath)) {
$raw = json_decode(file_get_contents($filePath), true);
if ($raw && isset($raw['expires_at']) && $raw['expires_at'] > time()) {
// Window still active — use existing data
$data = $raw;
}
// If window expired, fall through and reset (overwrite with fresh data below)
}
$data['count']++;
if ($data['count'] > $this->maxAttempts) {
$retryAfter = max(0, $data['expires_at'] - time());
$response->setHeader('Retry-After', (string)$retryAfter);
$response->json([
'error' => 'Too Many Requests',
'message' => "You have exceeded the maximum number of {$this->maxAttempts} attempts. Please try again in {$retryAfter} seconds."
], 429);
return;
}
// Persist the updated counter
file_put_contents($filePath, json_encode($data), LOCK_EX);
}
/**
* Get real client IP, accounting for proxies
*/
private function getClientIp(): string
{
$headers = [
'HTTP_CF_CONNECTING_IP', // Cloudflare
'HTTP_X_FORWARDED_FOR',
'HTTP_X_REAL_IP',
'REMOTE_ADDR'
];
foreach ($headers as $header) {
if (!empty($_SERVER[$header])) {
// X-Forwarded-For can be a comma-separated list; take first
$ip = trim(explode(',', $_SERVER[$header])[0]);
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
}
}
return '0.0.0.0';
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Middlewares;
use App\Core\Request;
use App\Core\Response;
class SecurityMiddleware
{
/**
* Applies OWASP security headers and sanitizes incoming body/query parameters
* to protect against XSS and basic Injection.
*/
public function handle(Request $request, Response $response): void
{
// 1. Set OWASP Security Headers
$response->setHeader('X-Frame-Options', 'DENY'); // Prevent Clickjacking
$response->setHeader('X-XSS-Protection', '1; mode=block'); // Prevent Cross-Site Scripting (XSS)
$response->setHeader('X-Content-Type-Options', 'nosniff'); // Prevent MIME-sniffing
$response->setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); // HSTS
$response->setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://unpkg.com https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; object-src 'none';"); // CSP
// 2. Input Sanitization to prevent XSS (Recursive)
$body = $request->getBody();
if (is_array($body)) {
$request->setBody($this->sanitizeArray($body));
}
$query = $request->getQueryParams();
if (is_array($query)) {
$request->setQueryParams($this->sanitizeArray($query));
}
}
/**
* Recursively trim input arrays
*/
private function sanitizeArray(array $data): array
{
$sanitized = [];
foreach ($data as $key => $value) {
if (is_array($value)) {
$sanitized[$key] = $this->sanitizeArray($value);
} elseif (is_string($value)) {
$sanitized[$key] = trim($value);
} else {
$sanitized[$key] = $value;
}
}
return $sanitized;
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Middlewares;
use App\Core\Request;
use App\Core\Response;
use App\Models\CompanySubscription;
use App\Models\CompanySubscriptionUsage;
/**
* SubscriptionMiddleware
* Validates company subscription validity and request quotas before processing operations.
*/
class SubscriptionMiddleware
{
public function handle(Request $request, Response $response): void
{
// 1. Get company ID (populated by AuthMiddleware)
$companyId = $request->company_id ?? null;
if (!$companyId) {
$response->json(['error' => 'Unauthorized', 'message' => 'Company details not found in request Context'], 401);
exit;
}
// Allow Company 1 (Intaleq admin/demo) to bypass limits temporarily or have unlimited
if ($companyId === 1) {
return;
}
// 2. Fetch active subscription
$activeSub = CompanySubscription::findActiveByCompany($companyId);
if (!$activeSub) {
$response->json([
'error' => 'Payment Required',
'message' => 'This account does not have an active subscription or the current subscription has expired. Please subscribe to a plan to continue.'
], 402);
exit;
}
// 3. Verify total requests limit
$hasQuota = CompanySubscriptionUsage::hasRemainingLimit($companyId, 'request');
if (!$hasQuota) {
$response->json([
'error' => 'Quota Exceeded',
'message' => 'You have exceeded the monthly request quota for your plan (' . $activeSub['max_requests'] . ' requests). Please upgrade your subscription.'
], 403);
exit;
}
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
/**
* Nabeh Application Bootstrap Loader
* Handles PSR-4 Autoloading, security settings, and error handling.
*/
// Define absolute path to application root
define('APP_ROOT', dirname(__DIR__));
// 1. PSR-4 Autoloader
spl_autoload_register(function ($class) {
// Namespace prefix
$prefix = 'App\\';
// Directory mapping for the prefix
$base_dir = APP_ROOT . '/app/';
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) {
return; // Move to next registered autoloader
}
$relative_class = substr($class, $len);
$file = $base_dir . str_replace('\\', '/', $relative_class) . '.php';
if (file_exists($file)) {
require $file;
}
});
// 2. Load Environment Variables
try {
// Find the closest .env file path (supporting local development and CloudPanel server directories)
$env_file = APP_ROOT . '/.env';
if (!file_exists($env_file)) {
if (file_exists(APP_ROOT . '/../../../.env')) {
$env_file = APP_ROOT . '/../../../.env';
} elseif (file_exists(APP_ROOT . '/../.env')) {
$env_file = APP_ROOT . '/../.env';
}
}
\App\Core\Env::load($env_file);
} catch (\Exception $e) {
// In production, log error; in development, print it
error_log('Env Load Error: ' . $e->getMessage());
}
// 3. Configure Error Reporting based on environment
$isDebug = filter_var(getenv('APP_DEBUG') ?: true, FILTER_VALIDATE_BOOLEAN);
if ($isDebug) {
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);
} else {
ini_set('display_errors', '0');
error_reporting(0);
}
// 4. Global Uncaught Exception Handler
// Catches any unhandled exception anywhere in the app and returns a clean JSON error
// instead of leaking PHP stack traces to the browser.
set_exception_handler(function (\Throwable $e) {
$isDebug = filter_var(getenv('APP_DEBUG') ?: true, FILTER_VALIDATE_BOOLEAN);
error_log('[EXCEPTION] ' . get_class($e) . ': ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
if (!headers_sent()) {
header('Content-Type: application/json; charset=utf-8');
http_response_code(500);
}
$body = ['error' => 'Internal Server Error'];
// In debug mode, expose details to the developer only
if ($isDebug) {
$body['debug'] = [
'exception' => get_class($e),
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
];
}
echo json_encode($body, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit(1);
});
+40
View File
@@ -0,0 +1,40 @@
<?php
/**
* Saqel API Front Controller
* Single entry point handling routing and application bootstrap.
*/
// 1. Boot the application (autoloader, env, errors)
require_once dirname(__DIR__) . '/app/bootstrap.php';
use App\Core\Request;
use App\Core\Response;
use App\Core\Router;
// 2. Initialize request and response objects
$request = new Request();
$response = new Response();
$router = new Router();
// 3. Define Global Middleware
$router->use(\App\Middlewares\SecurityMiddleware::class);
// 4. Define API Routes
// Health Check
$router->get('/api/health', function ($request, $response) {
$response->json([
'status' => 'success',
'message' => 'Saqel API is healthy and fast (Pure PHP)',
'app_name' => getenv('APP_NAME') ?: 'Saqel',
'time' => date('Y-m-d H:i:s')
]);
});
// Authentication Routes (Rate-limited: 5 attempts per 60 seconds per IP)
// $router->post('/api/auth/register', [\App\Controllers\AuthController::class, 'register'], [\App\Middlewares\RateLimitMiddleware::class]);
// $router->post('/api/auth/login', [\App\Controllers\AuthController::class, 'login'], [\App\Middlewares\RateLimitMiddleware::class]);
// $router->get('/api/auth/me', [\App\Controllers\AuthController::class, 'me'], [\App\Middlewares\AuthMiddleware::class]);
// 5. Dispatch the request
$router->dispatch($request, $response);
-8
View File
@@ -1,8 +0,0 @@
<?php
return [
'db_host' => '127.0.0.1',
'db_name' => 'saqel',
'db_user' => 'saqel',
'db_pass' => 'secret',
'db_charset' => 'utf8mb4',
];
-92
View File
@@ -1,92 +0,0 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>فحص الـ API - منصة صقل</title>
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f4f7f6; margin: 0; padding: 20px; color: #333; }
h1, h2 { text-align: center; }
.card { background: white; padding: 20px; margin: 20px auto; max-width: 600px; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; font-weight: bold; }
input, select { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
button { background-color: #27ae60; color: white; border: none; padding: 10px 15px; cursor: pointer; border-radius: 4px; font-weight: bold; margin-bottom: 5px; }
button:hover { background-color: #2ecc71; }
pre { background: #2c3e50; color: #ecf0f1; padding: 15px; border-radius: 4px; overflow-x: auto; direction: ltr; text-align: left; max-height: 300px; }
.btn-info { background-color: #3498db; }
.btn-info:hover { background-color: #2980b9; }
.nav { text-align: center; margin-bottom: 20px; }
</style>
</head>
<body>
<div class="nav">
<a href="index.html">العودة للرئيسية</a>
</div>
<h1>أداة فحص الواجهات البرمجية (Pure PHP API Tester)</h1>
<div class="card">
<h2>فحص الاتصال والسرعة (Ping)</h2>
<button onclick="testPing()" class="btn-info">فحص /api/ping</button>
<h3>النتيجة:</h3>
<pre id="ping-result">في انتظار التنفيذ...</pre>
</div>
<div class="card">
<h2>تسجيل الدخول (Login)</h2>
<div class="form-group">
<label>البريد الإلكتروني:</label>
<input type="email" id="login-email" value="test@example.com">
</div>
<div class="form-group">
<label>كلمة المرور:</label>
<input type="password" id="login-password" value="secret">
</div>
<button onclick="testLogin()">تسجيل الدخول</button>
<h3>النتيجة:</h3>
<pre id="login-result">في انتظار التنفيذ...</pre>
</div>
<script>
async function testPing() {
const resultBox = document.getElementById('ping-result');
resultBox.innerHTML = 'جاري الاتصال بـ /index.php/api/ping...';
try {
// Notice we route through index.php since we haven't set up htaccess rewrite rules yet
const response = await fetch('/index.php/api/ping');
const data = await response.json();
resultBox.innerHTML = JSON.stringify(data, null, 2);
} catch (error) {
resultBox.innerHTML = 'خطأ في الاتصال: ' + error.message;
}
}
async function testLogin() {
const email = document.getElementById('login-email').value;
const password = document.getElementById('login-password').value;
const resultBox = document.getElementById('login-result');
resultBox.innerHTML = 'جاري الاتصال بـ /index.php/api/auth/login...';
try {
const response = await fetch('/index.php/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({ email, password })
});
const data = await response.json();
resultBox.innerHTML = JSON.stringify(data, null, 2);
} catch (error) {
resultBox.innerHTML = 'خطأ في الاتصال: ' + error.message;
}
}
</script>
</body>
</html>
-28
View File
@@ -1,28 +0,0 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>منصة صقل - الرئيسية</title>
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; text-align: center; margin: 0; padding: 0; background-color: #f4f7f6; color: #333; }
header { background-color: #2c3e50; color: white; padding: 2rem 0; }
h1 { margin: 0; font-size: 2.5rem; }
p { font-size: 1.2rem; }
.container { padding: 3rem 1rem; }
.btn { display: inline-block; background-color: #3498db; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; font-weight: bold; margin-top: 20px; transition: 0.3s; }
.btn:hover { background-color: #2980b9; }
</style>
</head>
<body>
<header>
<h1>منصة صقل</h1>
<p>المنصة التعليمية الأولى لتطوير المهارات (Pure PHP)</p>
</header>
<div class="container">
<h2>مرحباً بك في منصة صقل</h2>
<p>النظام الأساسي (Backend) يعمل بنجاح تام وبسرعة فائقة!</p>
<a href="api-test.html" class="btn">الانتقال لصفحة فحص الـ API</a>
</div>
</body>
</html>
-78
View File
@@ -1,78 +0,0 @@
<?php
header("Content-Type: application/json; charset=UTF-8");
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Access-Control-Allow-Headers, Authorization, X-Requested-With");
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
// Auto-loader for classes (simple version)
spl_autoload_register(function ($class) {
$prefix = 'Saqel\\';
$base_dir = __DIR__ . '/../src/';
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) {
return;
}
$relative_class = substr($class, $len);
$file = $base_dir . str_replace('\\', '/', $relative_class) . '.php';
if (file_exists($file)) {
require $file;
}
});
use Saqel\Router;
use Saqel\Database;
$router = new Router();
$router->add('GET', '/', function() {
echo json_encode([
'status' => 'success',
'message' => 'Saqel API is running in Pure PHP mode!'
]);
});
$router->add('GET', '/api/ping', function() {
$time_start = microtime(true);
// Test DB connection
$db = Database::getInstance()->getConnection();
$stmt = $db->query("SELECT 1");
$dbStatus = $stmt ? 'connected' : 'failed';
$time_end = microtime(true);
$execution_time = ($time_end - $time_start) * 1000;
echo json_encode([
'status' => 'success',
'message' => 'Saqel Pure PHP Backend is running incredibly fast!',
'database' => $dbStatus,
'execution_time_ms' => round($execution_time, 2)
]);
});
// Example route for user login (Mock)
$router->add('POST', '/api/auth/login', function() {
$data = json_decode(file_get_contents("php://input"), true);
// In a real app, query Database here and verify password
if (isset($data['email']) && isset($data['password'])) {
echo json_encode([
'access_token' => bin2hex(random_bytes(16)),
'token_type' => 'Bearer',
'user' => [
'email' => $data['email'],
'role' => 'student'
]
]);
} else {
header("HTTP/1.0 400 Bad Request");
echo json_encode(['error' => 'Missing email or password']);
}
});
$router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
-38
View File
@@ -1,38 +0,0 @@
<?php
namespace Saqel;
use PDO;
use PDOException;
class Database {
private static $instance = null;
private $pdo;
private function __construct() {
$config = require __DIR__ . '/../config.php';
$dsn = "mysql:host={$config['db_host']};dbname={$config['db_name']};charset={$config['db_charset']}";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$this->pdo = new PDO($dsn, $config['db_user'], $config['db_pass'], $options);
} catch (PDOException $e) {
die(json_encode(['error' => 'Database Connection failed: ' . $e->getMessage()]));
}
}
public static function getInstance() {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function getConnection() {
return $this->pdo;
}
}
-23
View File
@@ -1,23 +0,0 @@
<?php
namespace Saqel;
class Router {
private $routes = [];
public function add($method, $path, $handler) {
$this->routes[] = compact('method', 'path', 'handler');
}
public function dispatch($method, $uri) {
$uri = parse_url($uri, PHP_URL_PATH);
foreach ($this->routes as $route) {
if ($route['method'] === $method && $route['path'] === $uri) {
return call_user_func($route['handler']);
}
}
header("HTTP/1.0 404 Not Found");
echo json_encode(['error' => 'Endpoint Not Found']);
}
}