*/ private const DEFAULT_BASES = [ 'https://adm.montzar.ir/api/remote/sms', 'https://adm.montech.ir/api/remote/sms', 'https://adm.montest.ir/api/remote/sms', ]; public function enabled(): bool { if ($this->baseUrls() === []) { return false; } return $this->sharedSecret() !== '' || $this->bearerToken() !== ''; } /** * @return array{ * allowed: bool, * blocked: bool, * reason: ?string, * balanceRials: int, * pricePerPartRials: int, * estimatedCostRials: int, * balanceLevel: string * }|null */ public function status(int $parts = 1, bool $isAlert = false): ?array { $data = $this->requestJson('GET', '/api/remote/sms/status', 'status', [ 'parts' => max(1, $parts), 'isAlert' => $isAlert ? '1' : '0', ], null, 4, 'status'); if ($data === null) { return null; } return [ 'allowed' => (bool) ($data['allowed'] ?? false), 'blocked' => (bool) ($data['blocked'] ?? false), 'reason' => isset($data['reason']) ? (string) $data['reason'] : null, 'balanceRials' => (int) ($data['balanceRials'] ?? 0), 'pricePerPartRials' => (int) ($data['pricePerPartRials'] ?? 0), 'estimatedCostRials' => (int) ($data['estimatedCostRials'] ?? 0), 'balanceLevel' => (string) ($data['balanceLevel'] ?? 'unknown'), ]; } /** * @return array{ok: bool, balanceRials?: int, costRials?: int}|null */ public function consume( string $receiver, string $body, int $parts, ?string $provider = null, ?string $source = null, bool $isAlert = false ): ?array { $data = $this->requestJson('POST', '/api/remote/sms/consume', 'consume', [], [ 'receiver' => $receiver, 'body' => mb_substr($body, 0, 1000), 'parts' => max(1, $parts), 'provider' => $provider, 'source' => $source, 'isAlert' => $isAlert, ], 6, 'consume'); if ($data === null || !($data['ok'] ?? false)) { return null; } return [ 'ok' => true, 'balanceRials' => (int) ($data['balanceRials'] ?? 0), 'costRials' => (int) ($data['costRials'] ?? 0), 'pricePerPartRials' => (int) ($data['pricePerPartRials'] ?? 0), ]; } public function overview(): ?array { return $this->requestJson('GET', '/api/remote/sms/overview', 'overview', [], null, 6, 'overview'); } public function balance(): ?array { return $this->requestJson('GET', '/api/remote/sms/balance', 'balance', [], null, 4, 'balance'); } public function chargeInfo(): ?array { return $this->requestJson('GET', '/api/remote/sms/charge-info', 'charge-info', [], null, 4, 'charge-info'); } public function updateChargeInfo(array $payload): ?array { return $this->requestJson('PUT', '/api/remote/sms/charge-info', 'charge-info', [], $payload, 6, 'charge-info-update'); } public function charge(int $amount, ?string $note = null): ?array { return $this->requestJson('POST', '/api/remote/sms/charge', 'charge', [], [ 'amount' => $amount, 'note' => $note, ], 8, 'charge'); } public function updatePrice(int $pricePerPart): ?array { return $this->requestJson('PUT', '/api/remote/sms/price', 'price', [], [ 'price_per_part' => max(0, $pricePerPart), ], 6, 'price'); } public function setBlocked(bool $isBlocked): ?array { return $this->requestJson('PUT', '/api/remote/sms/block', 'block', [], [ 'is_blocked' => $isBlocked, ], 6, 'block'); } public function logsTable(array $payload): ?array { return $this->requestJson('POST', '/api/remote/sms/logs/table', 'logs/table', [], $payload, 10, 'logs-table'); } public function ledgerTable(array $payload): ?array { return $this->requestJson('POST', '/api/remote/sms/ledger/table', 'ledger/table', [], $payload, 10, 'ledger-table'); } public function report(?string $fromDate = null, ?string $toDate = null): ?array { $query = array_filter([ 'from_date' => $fromDate, 'to_date' => $toDate, ], static fn ($v) => $v !== null && $v !== ''); return $this->requestJson('GET', '/api/remote/sms/report', 'report', $query, null, 8, 'report'); } public function chargeReport(?string $fromDate = null, ?string $toDate = null): ?array { $query = array_filter([ 'from_date' => $fromDate, 'to_date' => $toDate, ], static fn ($v) => $v !== null && $v !== ''); return $this->requestJson('GET', '/api/remote/sms/report/charges', 'report/charges', $query, null, 8, 'charge-report'); } /** * @param array $query * @param array|null $body * @return array|null */ private function requestJson( string $method, string $canonicalPath, string $relativeUrl, array $query = [], ?array $body = null, int $timeout = 6, string $label = 'request' ): ?array { if (!$this->enabled()) { return null; } $bases = $this->orderedBaseUrls(); $method = strtoupper($method); $lastError = null; if ($method === 'GET' && count($bases) > 1) { $parallel = $this->requestJsonParallel( $bases, $canonicalPath, $relativeUrl, $query, $timeout, $label ); if ($parallel !== null) { return $parallel; } $this->forgetSuccessfulBase(); Log::warning("SuperAdmin SMS {$label} unavailable on all bases (parallel GET)", [ 'bases' => $bases, ]); return null; } foreach ($bases as $index => $base) { try { $pending = Http::withHeaders($this->authHeaders($method, $canonicalPath)) ->acceptJson() ->timeout($timeout) ->withOptions(Controller::httpClientOptions([], (float) $timeout)); $url = $this->urlForBase($base, $relativeUrl); /** @var Response $response */ $response = match ($method) { 'GET' => $pending->get($url, $query), 'POST' => $pending->post($url, $body ?? []), 'PUT' => $pending->put($url, $body ?? []), 'PATCH' => $pending->patch($url, $body ?? []), default => throw new \InvalidArgumentException("Unsupported method {$method}"), }; if (!$response->successful()) { $lastError = 'HTTP '.$response->status(); Log::warning("SuperAdmin SMS {$label} failed", [ 'base' => $base, 'candidate' => $index + 1, 'total' => count($bases), 'status' => $response->status(), 'body' => $response->body(), ]); continue; } $data = $response->json(); if (!is_array($data)) { $lastError = 'invalid JSON'; Log::warning("SuperAdmin SMS {$label} invalid JSON", [ 'base' => $base, ]); continue; } $this->rememberSuccessfulBase($base); return $data; } catch (\Throwable $e) { $lastError = $e->getMessage(); Log::warning("SuperAdmin SMS {$label} exception", [ 'base' => $base, 'candidate' => $index + 1, 'total' => count($bases), 'error' => $e->getMessage(), ]); } } $this->forgetSuccessfulBase(); Log::warning("SuperAdmin SMS {$label} unavailable on all bases", [ 'bases' => $bases, 'last_error' => $lastError, ]); return null; } /** * Fan-out GET to all SuperAdmin hosts in parallel (max wait = one timeout, not NĂ—timeout). * * @param list $bases * @param array $query * @return array|null */ private function requestJsonParallel( array $bases, string $canonicalPath, string $relativeUrl, array $query, int $timeout, string $label ): ?array { $headers = $this->authHeaders('GET', $canonicalPath); $options = Controller::httpClientOptions([], (float) $timeout); try { /** @var array $responses */ $responses = Http::pool(function (Pool $pool) use ($bases, $headers, $relativeUrl, $query, $timeout, $options) { foreach ($bases as $index => $base) { $pool->as((string) $index) ->withHeaders($headers) ->acceptJson() ->connectTimeout(2) ->timeout($timeout) ->withOptions($options) ->get($this->urlForBase($base, $relativeUrl), $query); } }); } catch (\Throwable $e) { Log::warning("SuperAdmin SMS {$label} parallel pool failed", [ 'error' => $e->getMessage(), ]); return null; } foreach ($bases as $index => $base) { $key = (string) $index; if (! array_key_exists($key, $responses)) { continue; } $response = $responses[$key]; if ($response instanceof \Throwable) { Log::warning("SuperAdmin SMS {$label} parallel exception", [ 'base' => $base, 'error' => $response->getMessage(), ]); continue; } if (! $response->successful()) { Log::warning("SuperAdmin SMS {$label} parallel failed", [ 'base' => $base, 'status' => $response->status(), 'body' => $response->body(), ]); continue; } $data = $response->json(); if (! is_array($data)) { Log::warning("SuperAdmin SMS {$label} parallel invalid JSON", [ 'base' => $base, ]); continue; } $this->rememberSuccessfulBase($base); return $data; } return null; } /** * @return list */ private function orderedBaseUrls(): array { $bases = $this->baseUrls(); if ($bases === []) { return []; } $sticky = $this->readSuccessfulBase(); if ($sticky === null || !in_array($sticky, $bases, true)) { return $bases; } return array_values(array_unique(array_merge([$sticky], $bases))); } /** * Env base (if set) first, then the three known SuperAdmin hosts. * No .env change required when SUPERADMIN_SMS_BASE_URL already points at montzar. * * @return list */ private function baseUrls(): array { $out = []; $fromEnv = trim((string) config('services.superadmin.sms_base_url', '')); if ($fromEnv !== '') { foreach (preg_split('/[\s,;]+/', $fromEnv) ?: [] as $piece) { $normalized = $this->normalizeBaseUrl((string) $piece); if ($normalized !== '') { $out[] = $normalized; } } } foreach (self::DEFAULT_BASES as $default) { $out[] = $this->normalizeBaseUrl($default); } return array_values(array_unique(array_filter($out))); } private function normalizeBaseUrl(string $value): string { $base = rtrim(trim($value), '/'); if ($base === '') { return ''; } // Accept origin-only values like https://adm.montzar.ir if (!str_contains($base, '/api/remote/sms')) { $base .= '/api/remote/sms'; } return rtrim($base, '/'); } private function urlForBase(string $base, string $path): string { return rtrim($base, '/').'/'.ltrim($path, '/'); } private function rememberSuccessfulBase(string $base): void { try { Cache::put(self::SUCCESS_BASE_CACHE_KEY, $base, self::SUCCESS_BASE_CACHE_TTL_SECONDS); } catch (\Throwable $e) { Log::debug('SuperAdmin SMS sticky base cache write failed', [ 'error' => $e->getMessage(), ]); } } private function forgetSuccessfulBase(): void { try { Cache::forget(self::SUCCESS_BASE_CACHE_KEY); } catch (\Throwable $e) { Log::debug('SuperAdmin SMS sticky base cache forget failed', [ 'error' => $e->getMessage(), ]); } } private function readSuccessfulBase(): ?string { try { $cached = Cache::get(self::SUCCESS_BASE_CACHE_KEY); if (!is_string($cached) || trim($cached) === '') { return null; } return $this->normalizeBaseUrl($cached); } catch (\Throwable $e) { return null; } } /** * @return array */ private function authHeaders(string $method, string $canonicalPath): array { $secret = $this->sharedSecret(); if ($secret !== '') { $domain = $this->domain(); $timestamp = (string) time(); $payload = $timestamp.'.'.strtoupper($method).'.'.$canonicalPath.'.'.$domain; $signature = hash_hmac('sha256', $payload, $secret); return [ 'X-Abshode-Domain' => $domain, 'X-Abshode-Timestamp' => $timestamp, 'X-Abshode-Signature' => $signature, ]; } $token = $this->bearerToken(); if ($token !== '') { return [ 'Authorization' => 'Bearer '.$token, ]; } return []; } private function sharedSecret(): string { return trim((string) config('services.superadmin.sms_shared_secret', '')); } private function bearerToken(): string { return trim((string) config('services.superadmin.remote_token', '')); } private function domain(): string { $override = trim((string) config('services.superadmin.sms_domain', '')); if ($override !== '') { return $this->normalizeHost($override); } $fromApp = parse_url((string) config('app.url'), PHP_URL_HOST); return $this->normalizeHost(is_string($fromApp) ? $fromApp : ''); } private function normalizeHost(string $value): string { $host = strtolower(trim($value)); $host = preg_replace('#^https?://#', '', $host) ?? $host; $host = explode('/', $host)[0] ?? $host; $host = explode(':', $host)[0] ?? $host; return $host; } }