JWT bearer token * send : POST /api/otp/send {phone, type, code, message} * * The bearer token is valid for 24h, so it is cached in Redis rather than * refetched on every login. Delivery defaults to Nabeh's rendered image card * and falls back to plain text, which is the ordering Siro settled on. */ class NabehOtpService { private const TOKEN_CACHE_KEY = 'nabeh_bearer_token'; private const TOKEN_TTL = 86400; private string $baseUrl; private int $timeout; public function __construct() { $this->baseUrl = rtrim((string)env('NABEH_BASE_URL', 'https://nabeh.intaleqapp.com'), '/'); $this->timeout = (int)env('NABEH_TIMEOUT', 15); } /** * Deliver an OTP code. * * Nabeh renders the message itself, so the code is passed as a field and * the template carries a literal {code} placeholder for it to substitute. * * @return array{success: bool, ...} */ public function sendOtp(string $phone, string $otp): array { $token = $this->getBearerToken(); if ($token === null) { return ['success' => false, 'error' => 'Failed to obtain Nabeh bearer token.']; } // Nabeh expects digits only. $phoneRaw = preg_replace('/\D+/', '', $phone); $preferred = strtolower((string)env('NABEH_OTP_TYPE', 'image')); if (!in_array($preferred, ['image', 'text', 'voice'], true)) { $preferred = 'image'; } $result = $this->attempt($phoneRaw, $preferred, $otp, $token); if ($result['success']) { return $result; } // The image renderer fails more often than plain text; retry before // refusing the login. if ($preferred === 'image') { error_log('[Nabeh OTP] image type failed, retrying as text.'); $retry = $this->attempt($phoneRaw, 'text', $otp, $token); if ($retry['success']) { return $retry; } $result = $retry; } return $result; } /** * A single send attempt for one delivery type. */ private function attempt(string $phone, string $type, string $otp, string $token): array { $url = $this->baseUrl . '/api/otp/send'; $appName = (string)env('NABEH_APP_NAME', 'مُصادَق'); $payload = [ 'phone' => $phone, 'type' => $type, 'code' => $otp, // {code} is substituted by Nabeh, not by us. 'message' => "رمز التحقق الخاص بك لتطبيق {$appName} هو: *{code}*\nصالح لمدة 5 دقائق. الرجاء عدم مشاركته مع أي شخص.", ]; $response = $this->request($url, $payload, [ 'Content-Type: application/json', 'Authorization: Bearer ' . $token, ]); if ($response['error'] !== null) { return ['success' => false, 'error' => $response['error'], 'url' => $url, 'type' => $type]; } $decoded = json_decode($response['body'], true); return [ 'success' => $this->looksSuccessful($decoded, $response['status']), 'status' => $response['status'], 'type' => $type, 'response' => $decoded, 'raw_response' => $response['body'], 'url' => $url, ]; } /** * Nabeh is inconsistent about how it signals success, so accept any of the * shapes the Siro integration observed in production. */ private function looksSuccessful(mixed $decoded, int $status): bool { if (!is_array($decoded)) { return false; } if (!empty($decoded['success'])) { return true; } $statusStr = strtolower((string)($decoded['status'] ?? '')); if (in_array($statusStr, ['success', 'ok', 'true', '200', 'sent', 'queued', '1'], true)) { return true; } if (($decoded['status'] ?? false) === true || ($decoded['code'] ?? 0) === 200) { return true; } if (!empty($decoded['message_id']) || !empty($decoded['id'])) { return true; } $msgStr = strtolower((string)($decoded['message'] ?? '')); if (str_contains($msgStr, 'success') || str_contains($msgStr, 'sent') || str_contains($msgStr, 'تم')) { return true; } // Observed quirk: the gateway reports delivery inside an "error" field. $errStr = strtolower((string)($decoded['error'] ?? '')); if (str_contains($errStr, 'via gateway')) { return true; } return false; } /** * Return a valid bearer token, from Redis when one is cached. */ private function getBearerToken(bool $forceRefresh = false): ?string { $redis = Cache::getInstance(); if (!$forceRefresh && $redis) { try { $cached = $redis->get(self::TOKEN_CACHE_KEY); if (is_string($cached) && $cached !== '') { return $cached; } } catch (\Throwable $e) { error_log('[Nabeh Auth] Redis read failed: ' . $e->getMessage()); } } $email = (string)env('NABEH_EMAIL', ''); $password = (string)env('NABEH_PASSWORD', ''); if ($email === '' || $password === '') { error_log('[Nabeh Auth] Missing NABEH_EMAIL or NABEH_PASSWORD in .env'); return null; } $url = $this->baseUrl . '/api/auth/login'; $response = $this->request($url, ['email' => $email, 'password' => $password], [ 'Content-Type: application/json', ]); if ($response['error'] !== null) { error_log('[Nabeh Auth] Transport error: ' . $response['error']); return null; } $decoded = json_decode($response['body'], true); $token = $decoded['token'] ?? $decoded['message']['token'] ?? $decoded['jwt'] ?? $decoded['access_token'] ?? null; if (!is_string($token) || $token === '') { // Never log the response body here - it is the reply to a request // that carried our gateway credentials. error_log('[Nabeh Auth] Login failed, no token in response. HTTP ' . $response['status']); return null; } if ($redis) { try { $redis->setex(self::TOKEN_CACHE_KEY, self::TOKEN_TTL, $token); } catch (\Throwable $e) { error_log('[Nabeh Auth] Redis write failed: ' . $e->getMessage()); } } return $token; } /** * @return array{status: int, body: string, error: ?string} */ private function request(string $url, array $payload, array $headers): array { $curl = curl_init(); curl_setopt_array($curl, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE), CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => $this->timeout, ]); $body = curl_exec($curl); $err = curl_error($curl); $status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); curl_close($curl); return [ 'status' => $status, 'body' => is_string($body) ? $body : '', 'error' => $err !== '' ? $err : null, ]; } }