Files
clinicpro/src/Sms/Provider/KavehNegarProvider.php
T

104 lines
4.5 KiB
PHP

<?php
namespace App\Sms\Provider;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class KavehNegarProvider implements SmsProviderInterface
{
private const BASE = 'https://api.kavenegar.com/v1';
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly LoggerInterface $logger,
// API key comes only from env `%env(default::KAVENEGAR_API_KEY)%`; null when unset.
private readonly ?string $apiKey = null,
private readonly ?string $sender = null,
) {}
private function key(): string { return $this->apiKey ?? ''; }
private function sender(): string { return $this->sender ?? ''; }
public function getName(): string { return 'kavenegar'; }
// Kavenegar فقط GET با query را می‌پذیرد (مطابق مستند رسمی). هیچ درخواست POST/body نباید ساخته شود.
// GET بدنه ندارد پس curl هدر `Expect: 100-continue` نمی‌فرستد (رفع idle timeout) و حجم درخواست کوچک می‌ماند.
private const MAX_ATTEMPTS = 3;
/** یک GET به کاوه‌نگار با query params و retry برای خطاهای گذرا؛ آرایهٔ پاسخ را برمی‌گرداند. */
private function get(string $path, array $query): array
{
$url = self::BASE . '/' . $this->key() . $path;
for ($attempt = 1; ; $attempt++) {
try {
return $this->httpClient->request('GET', $url, [
'query' => $query,
'timeout' => 15,
'max_duration' => 30,
'proxy' => null,
])->toArray();
} catch (TransportExceptionInterface $e) {
if ($attempt >= self::MAX_ATTEMPTS) {
throw $e;
}
usleep(500_000 * $attempt);
}
}
}
public function send(string $mobile, string $message): bool
{
try {
$data = $this->get('/sms/send.json', [
'receptor' => $mobile,
'message' => $message,
'sender' => $this->sender(),
]);
return ($data['return']['status'] ?? 0) === 200;
} catch (\Throwable $e) {
$this->logger->error(sprintf('SMS send failed (kavenegar): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'mobile' => $mobile]);
return false;
}
}
// token/token2/token3 مقدارِ دارای فاصله را رد می‌کنند؛ فاصله را با نیم‌فاصله (ZWNJ) جایگزین می‌کنیم.
private const NO_SPACE_SLOTS = ['token', 'token2', 'token3'];
private function forSlot(string $slot, string $value): string
{
if (!in_array($slot, self::NO_SPACE_SLOTS, true)) {
return $value;
}
return preg_replace('/\s+/u', "\u{200C}", $value) ?? $value;
}
public function sendTemplate(string $mobile, string $templateCode, array $vars): bool
{
try {
$params = ['receptor' => $mobile, 'template' => $templateCode];
// Kavenegar token slots: token/token2/token3 reject spaces; token10/token20 allow them.
// If every key is an explicit slot name, honor it (e.g. site with spaces → token10);
// otherwise fall back to positional mapping (token, token2, token3, ...).
$slots = ['token', 'token2', 'token3', 'token10', 'token20'];
if ($vars !== [] && array_keys($vars) !== range(0, count($vars) - 1)
&& array_diff(array_keys($vars), $slots) === []) {
foreach ($vars as $slot => $v) {
$params[$slot] = $this->forSlot($slot, (string) $v);
}
} else {
foreach (array_values($vars) as $i => $v) {
$slot = 'token' . ($i > 0 ? $i + 1 : '');
$params[$slot] = $this->forSlot($slot, (string) $v);
}
}
$data = $this->get('/verify/lookup.json', $params);
return ($data['return']['status'] ?? 0) === 200;
} catch (\Throwable $e) {
$this->logger->error(sprintf('SMS sendTemplate failed (kavenegar): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'mobile' => $mobile, 'template' => $templateCode]);
return false;
}
}
}