Update: 2026-08-06 18:32:03

This commit is contained in:
Hamza-Ayed
2026-08-06 18:32:03 +03:00
parent c759c80c85
commit ca3d456795
8 changed files with 416 additions and 41 deletions
+243
View File
@@ -0,0 +1,243 @@
<?php
namespace App\Services;
use App\Core\Cache;
/**
* Nabeh OTP Gateway
*
* Replaces the self-hosted WhatsApp bots for OTP delivery. Those bots were a
* single point of failure: one dead PM2 process took login down for everyone.
*
* Mirrors the integration already proven in the Tripz/Siro backend
* (backend/auth/otp/providers.php):
* auth : POST /api/auth/login {email, password} -> 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,
];
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Services;
/**
* Chooses which channel delivers an OTP.
*
* OTP_PROVIDER=nabeh -> Nabeh gateway
* OTP_PROVIDER=whatsapp -> legacy self-hosted WhatsApp bots
*
* Callers pass the code, not a rendered message: Nabeh composes its own message
* around the code, while the WhatsApp bots need finished text. Keeping that
* difference here means call sites do not care which channel is active.
*
* With OTP_FALLBACK_ENABLED on, a failure is retried on the other channel
* before the login is refused - losing OTP delivery locks out every user.
*/
class OtpSender
{
public static function sendOtp(string $phone, string $otp): array
{
$provider = strtolower(trim((string)env('OTP_PROVIDER', 'whatsapp')));
$fallback = strtolower((string)env('OTP_FALLBACK_ENABLED', 'true')) === 'true';
$result = self::dispatch($provider, $phone, $otp);
$result['provider'] = $provider;
if ($result['success'] || !$fallback) {
return $result;
}
$alternate = $provider === 'nabeh' ? 'whatsapp' : 'nabeh';
error_log(sprintf(
'[OTP] primary channel "%s" failed, falling back to "%s" | error=%s',
$provider,
$alternate,
$result['error'] ?? substr((string)($result['raw_response'] ?? ''), 0, 200)
));
$fallbackResult = self::dispatch($alternate, $phone, $otp);
$fallbackResult['provider'] = $alternate;
$fallbackResult['primary_error'] = $result['error'] ?? null;
return $fallbackResult;
}
private static function dispatch(string $channel, string $phone, string $otp): array
{
if ($channel === 'nabeh') {
return (new NabehOtpService())->sendOtp($phone, $otp);
}
$appName = (string)env('NABEH_APP_NAME', 'مُصادَق');
$message = "رمز التحقق لتطبيق {$appName}:\n*{$otp}*\n\nصالح لمدة 5 دقائق.";
return (new WhatsAppProxyService())->sendMessage($phone, $message);
}
}
+12 -4
View File
@@ -88,12 +88,20 @@ if ($deviceId && !$isReviewer) {
fclose($fp);
}
$whatsappService = new \App\Services\WhatsAppProxyService();
$message = "رمز التحقق لتطبيق مُصادَق:\n*{$otp}*\n\nصالح لمدة 5 دقائق.";
$result = $whatsappService->sendMessage($phone, $message);
$result = \App\Services\OtpSender::sendOtp($phone, $otp);
if (!$result['success']) {
error_log("ERROR: Failed to send OTP WhatsApp to phone: {$phone}");
// Log why it failed, not just that it did - the proxy returns the
// transport error and the bot's own response body, and without them
// a failure here is undiagnosable from the logs alone.
error_log(sprintf(
'ERROR: Failed to send OTP to phone: %s | provider=%s | url=%s | curl_error=%s | response=%s',
$phone,
$result['provider'] ?? 'n/a',
$result['url'] ?? 'n/a',
$result['error'] ?? 'none',
substr((string)($result['raw_response'] ?? ''), 0, 500)
));
json_error('عذراً، فشل في إرسال رمز التحقق. يرجى المحاولة مرة أخرى.', 500);
}
+3 -5
View File
@@ -100,14 +100,12 @@ try {
fclose($fp);
}
// 5. Send OTP via WhatsApp Proxy
$whatsappService = new \App\Services\WhatsAppProxyService();
$message = "رمز التحقق لتطبيق مُصادَق:\n*{$otp}*\n\nصالح لمدة 5 دقائق.";
$result = $whatsappService->sendMessage($phone, $message);
// 5. Send OTP via the configured channel (Nabeh gateway or WhatsApp bots)
$result = \App\Services\OtpSender::sendOtp($phone, $otp);
if (!$result['success']) {
// Internal provider details stay in the log, not in the HTTP response.
error_log("ERROR: Failed to send OTP WhatsApp to phone: {$phone} - " . json_encode($result));
error_log("ERROR: Failed to send OTP to phone: {$phone} - " . json_encode($result));
json_error('عذراً، فشل في إرسال رمز التحقق. الرجاء التأكد من صحة رقم الواتساب الخاص بك والمحاولة مرة أخرى.', 500);
}