authUrl = (string)getenv('NABEH_AUTH_URL'); $this->sendUrl = (string)getenv('NABEH_SEND_URL'); $this->email = (string)getenv('NABEH_EMAIL'); $this->password = (string)getenv('NABEH_PASSWORD'); $this->defaultType = (string)(getenv('NABEH_OTP_TYPE') ?: 'image'); // Default: Luxury Image Card $missing = []; if (empty($this->authUrl)) $missing[] = 'NABEH_AUTH_URL'; if (empty($this->sendUrl)) $missing[] = 'NABEH_SEND_URL'; if (empty($this->email)) $missing[] = 'NABEH_EMAIL'; if (empty($this->password)) $missing[] = 'NABEH_PASSWORD'; if (!empty($missing)) { throw new \RuntimeException("Nabeh Service configuration error: Missing environment variable(s): " . implode(', ', $missing)); } } /** * Retrieve Nabeh JWT Bearer Token, caching it in Redis for 24 hours. */ public function getBearerToken(): ?string { // 1. Try fetching from Redis first try { $redis = RedisClient::getInstance(); $cachedToken = $redis->get('nabeh_bearer_token'); if ($cachedToken && strlen((string)$cachedToken) > 20) { return (string)$cachedToken; } } catch (\Exception $e) { error_log("⚠️ [Nabeh Auth Redis] Error reading token: " . $e->getMessage()); } // 2. Token not cached, authenticate via Nabeh Login API $payload = json_encode([ 'email' => $this->email, 'password' => $this->password, ]); $ch = curl_init($this->authUrl); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlError = curl_error($ch); curl_close($ch); if ($curlError) { error_log("❌ [Nabeh Auth cURL Error] " . $curlError); return null; } if ($httpCode === 200 && $response) { $decoded = json_decode($response, true); $token = $decoded['token'] ?? $decoded['message']['token'] ?? $decoded['jwt'] ?? $decoded['access_token'] ?? null; if ($token) { // Cache token in Redis for 24h (86400 seconds) try { $redis = RedisClient::getInstance(); $redis->setex('nabeh_bearer_token', 86400, (string)$token); error_log("✅ [Nabeh Auth] Token cached in Redis successfully."); } catch (\Exception $e) { error_log("⚠️ [Nabeh Auth Redis Cache Save] Error saving token: " . $e->getMessage()); } return (string)$token; } } error_log("❌ [Nabeh Auth Login Failed] Code: {$httpCode} | Response: {$response}"); return null; } /** * Send OTP via Nabeh JWT Auth Gateway (WhatsApp image/text OTP) */ public function sendOtp(string $receiver, string $otp, ?string $method = null, string $appName = 'منصة صَقِل'): array { $bearerToken = $this->getBearerToken(); if (!$bearerToken) { return [ 'success' => false, 'error' => 'فشل الحصول على توكن المصادقة من منصة نبيه. تأكد من صحة NABEH_EMAIL و NABEH_PASSWORD في .env.' ]; } // Clean phone format: ensure Jordan numbers are standardized (9627XXXXXXXX) $phoneRaw = preg_replace('/\D+/', '', $receiver); if (str_starts_with($phoneRaw, '07')) { $phoneRaw = '962' . substr($phoneRaw, 1); } elseif (str_starts_with($phoneRaw, '7') && strlen($phoneRaw) === 9) { $phoneRaw = '962' . $phoneRaw; } $selectedType = $method ?: $this->defaultType; if (!in_array($selectedType, ['text', 'voice', 'image'], true)) { $selectedType = 'image'; } // 1. Attempt with selected type (image card) $result = $this->attemptSend($phoneRaw, $selectedType, $otp, $appName, $bearerToken); if ($result['success']) { return $result; } // 2. If image failed, fallback to text mode if ($selectedType === 'image') { error_log("ℹ️ [Nabeh OTP Fallback] Image card attempt failed for {$phoneRaw}. Retrying with text mode..."); $textResult = $this->attemptSend($phoneRaw, 'text', $otp, $appName, $bearerToken); if ($textResult['success']) { $textResult['note'] = 'Delivered via text fallback'; return $textResult; } return $textResult; } return $result; } private function attemptSend(string $phone, string $type, string $otp, string $appName, string $bearerToken): array { $payload = json_encode([ 'phone' => $phone, 'type' => $type, 'code' => $otp, 'message' => "رمز التحقق الخاص بك لمنصة {$appName} هو: *{$otp}* \n الرجاء عدم مشاركته مع أي شخص لحماية حسابك.", ]); $timeout = ($type === 'image') ? 45 : 20; $ch = curl_init($this->sendUrl); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', "Authorization: Bearer {$bearerToken}", ], ]); $startTime = microtime(true); $response = curl_exec($ch); $duration = round(microtime(true) - $startTime, 2); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlError = curl_error($ch); curl_close($ch); if ($curlError) { error_log("❌ [Nabeh OTP cURL Error (type={$type}, duration={$duration}s)] " . $curlError); return [ 'success' => false, 'type_used' => $type, 'duration' => "{$duration}s", 'http_code' => $httpCode, 'error' => 'cURL Error: ' . $curlError, 'response' => null ]; } error_log("ℹ️ [Nabeh OTP Response (type={$type}, duration={$duration}s, HTTP {$httpCode})] " . $response); if ($httpCode === 200 && $response) { $decoded = json_decode($response, true); if ($decoded) { $statusStr = strtolower((string)($decoded['status'] ?? '')); $msgStr = strtolower((string)($decoded['message'] ?? '')); $errStr = strtolower((string)($decoded['error'] ?? '')); if ( !empty($decoded['success']) || in_array($statusStr, ['success', 'ok', 'true', '200', 'sent', 'queued', '1'], true) || ($decoded['status'] ?? false) === true || ($decoded['code'] ?? 0) === 200 || !empty($decoded['message_id']) || !empty($decoded['id']) || !empty($decoded['token']) || str_contains($msgStr, 'success') || str_contains($msgStr, 'sent') || str_contains($msgStr, 'تم') || str_contains($errStr, 'via gateway') ) { return [ 'success' => true, 'type_used' => $type, 'duration' => "{$duration}s", 'http_code' => 200, 'message' => 'تم إرسال رمز التحقق بنجاح عبر الواتساب', 'raw' => $decoded ]; } } } return [ 'success' => false, 'type_used' => $type, 'duration' => "{$duration}s", 'http_code' => $httpCode, 'error' => 'Nabeh Gateway rejected request (HTTP ' . $httpCode . ')', 'response' => $response ]; } /** * Send direct WhatsApp text notification via Nabeh Gateway */ public function sendMessage(string $receiver, string $message): array { $bearerToken = $this->getBearerToken(); if (!$bearerToken) { return [ 'success' => false, 'error' => 'فشل الحصول على توكن المصادقة من منصة نبيه.' ]; } $phoneRaw = preg_replace('/\D+/', '', $receiver); if (str_starts_with($phoneRaw, '07')) { $phoneRaw = '962' . substr($phoneRaw, 1); } elseif (str_starts_with($phoneRaw, '7') && strlen($phoneRaw) === 9) { $phoneRaw = '962' . $phoneRaw; } $payload = json_encode([ 'phone' => $phoneRaw, 'type' => 'text', 'message' => $message, ]); $ch = curl_init($this->sendUrl); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 25, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', "Authorization: Bearer {$bearerToken}", ], ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlError = curl_error($ch); curl_close($ch); if ($curlError) { return ['success' => false, 'error' => $curlError]; } return ['success' => $httpCode === 200, 'response' => $response]; } /** * Send Guardian Remedial Alert via WhatsApp */ public function sendGuardianRemedialAlert( string $guardianPhone, string $studentName, string $subjectName, string $weakTopic, string $portalUrl = 'https://saqel.intaleqapp.com/guardian' ): array { $message = "🔔 *تنبيه متابعة دراسية من منصة صَقِل* 🧠\n\n" . "ولي أمر الطالب/ـة: *{$studentName}* المحترم،\n" . "نحيطكم علماً بأنه أثناء فحص الاستيعاب السقراطي في مادة *{$subjectName}*، تم رصد تعثر في مفهوم:\n" . "📌 *[{$weakTopic}]*\n\n" . "✅ *الإجراء المتخذ من المنصة:* تم إرجاع الطالب تلقائياً لمشاهدة المقطع الشارح للمفهوم بدقة لترسيخ الفهم.\n\n" . "📊 يمكنكم متابعة جاهزية الطالب والتقرير التشخيصي المفصل عبر بوابة ولي الأمر:\n" . "{$portalUrl}\n\n" . "مع تحيات فريق الدعم الأكاديمي لمنصة صَقِل ✨"; return $this->sendMessage($guardianPhone, $message); } }