Files
saqel/backend/app/Services/NabehService.php
T

339 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* ==============================================================================
* SAQEL ENTERPRISE (EDTECH 2.0) - NABEH WHATSAPP & GUARDIAN NOTIFICATION SERVICE
* ==============================================================================
*
* ملف: NabehService.php
* الهدف المعماري:
* بوابة التكامل الرسمية مع خدمة نبّه (Nabeh Gateway) لإرسال رسائل وإشعارات الواتساب:
* 1. إرسال بطاقات رموز التحقق الفاخرة (Luxury Image OTP Cards) أو الرسائل النصية المباشرة.
* 2. إرسال الإشعارات التوجيهية الهادئة لأولياء الأمور (Guardian Remedial Alerts) عند تعثر الطالب
* في أي مفهوم دراسي أو فحص سقراطي لمتابعة رحلة تعليمه دون قلق.
* 3. إدارة الجلسات والمصادقة المؤقتة مع Nabeh API والتخزين المؤقت للتوكن في Redis لمدة 24 ساعة.
*/
namespace App\Services;
use App\Core\RedisClient;
class NabehService
{
private string $authUrl;
private string $sendUrl;
private string $email;
private string $password;
private string $defaultType;
public function __construct()
{
$this->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'); // Canonical 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'] || !empty($result['delivery_unknown'])) {
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 : 30;
$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',
'Connection: close',
"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);
// A timeout does not prove whether Nabeh accepted or delivered the message.
// Do not report success or send a second OTP via the text fallback: that could
// create a duplicate while the original gateway request is still completing.
if ($duration >= 18.0 && str_contains(strtolower($curlError), 'timed out')) {
error_log("⚠️ [Nabeh OTP Delivery Unknown] WhatsApp ({$type}) request timed out after {$duration}s; delivery is unconfirmed.");
return [
'success' => false,
'delivery_unknown' => true,
'type_used' => $type,
'duration' => "{$duration}s",
'http_code' => $httpCode,
'error' => 'انتهت مهلة بوابة واتساب؛ لم يصل تأكيد التسليم. تحقّق من الرسائل قبل طلب رمز جديد.'
];
}
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);
}
}