SMS service
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Sms;
|
||||
|
||||
use App\Models\SmsProfile;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class SmsApiSiClient implements SmsClient
|
||||
{
|
||||
public function send(SmsProfile $profile, SmsMessage $message): SmsResult
|
||||
{
|
||||
$baseUrl = config('services.sms.providers.smsapi_si.base_url');
|
||||
$endpoint = config('services.sms.providers.smsapi_si.send_endpoint', '/poslji-sms');
|
||||
$timeout = (int) config('services.sms.providers.smsapi_si.timeout', 10);
|
||||
|
||||
$url = rtrim($baseUrl, '/').$endpoint;
|
||||
|
||||
$payload = [
|
||||
'un' => $profile->api_username,
|
||||
'ps' => $profile->decryptApiPassword() ?? '',
|
||||
// provider requires phone number in `from`
|
||||
'from' => isset($message->senderPhone) && $message->senderPhone !== '' ? urlencode($message->senderPhone) : '',
|
||||
'to' => $message->to,
|
||||
'm' => $message->content,
|
||||
];
|
||||
|
||||
// Log payload safely (mask password) for debugging; uses context so it will appear on supported channels
|
||||
$logPayload = $payload;
|
||||
if (isset($logPayload['ps'])) {
|
||||
$logPayload['ps'] = '***';
|
||||
}
|
||||
\Log::info('sms.send payload', ['payload' => $logPayload]);
|
||||
|
||||
/*if (! empty($message->sender)) {
|
||||
$payload['sname'] = $message->sender;
|
||||
}*/
|
||||
// Default country code when not provided
|
||||
$payload['cc'] = urlencode(! empty($message->countryCode) ? $message->countryCode : '386');
|
||||
if ($message->deliveryReport) {
|
||||
$payload['dr'] = 1;
|
||||
}
|
||||
|
||||
$response = Http::asForm()
|
||||
->timeout($timeout)
|
||||
->post($url, $payload);
|
||||
|
||||
$body = trim((string) $response->body());
|
||||
$statusCode = $response->status();
|
||||
|
||||
// Parse according to provider docs: ID##SMS_PRICE##FROM##TO
|
||||
// Success: 123##0.03##040123456##040654321
|
||||
// Failure: -1##ERROR_ID##FROM##TO
|
||||
$parts = array_values(array_filter(array_map(static fn ($s) => trim((string) $s), explode('##', $body)), static fn ($s) => $s !== ''));
|
||||
|
||||
// Known error codes (Slovenian descriptions from docs)
|
||||
$errorMap = [
|
||||
1 => 'Napaka v validaciji.',
|
||||
2 => 'Sporočilo je predolgo ali prazno.',
|
||||
3 => 'Številka pošiljatelja ni pravilno tvorjena ali pa je prazna.',
|
||||
4 => 'Številka prejemnika ni pravilno tvorjena ali pa je prazna.',
|
||||
5 => 'Uporabnik nima dovolj kreditov.',
|
||||
6 => 'Napaka na serverju.',
|
||||
7 => 'Številka pošiljatelja ni registrirana.',
|
||||
8 => 'Referenca uporabnika ni veljavna.',
|
||||
9 => 'Koda države ni veljavna.',
|
||||
10 => 'ID pošiljatelja ni potrjen.',
|
||||
11 => 'Koda države ni podprta',
|
||||
12 => 'Številka pošiljatelja ni potrjena',
|
||||
13 => 'Številka oz. država pošiljatelja ne podpira MMS vsebin.',
|
||||
14 => 'MMS tip oblike (mime type) ni podprta',
|
||||
15 => 'MMS url je nedosegljiv ali datoteka ne obstaja.',
|
||||
16 => 'MMS vsebina je predolga',
|
||||
];
|
||||
|
||||
if ($response->successful() && ! empty($parts)) {
|
||||
// Failure shape
|
||||
if ($parts[0] === '-1') {
|
||||
$errorId = isset($parts[1]) ? (int) $parts[1] : null;
|
||||
$from = $parts[2] ?? null;
|
||||
$to = $parts[3] ?? null;
|
||||
|
||||
return new SmsResult(
|
||||
status: 'failed',
|
||||
providerMessageId: null,
|
||||
cost: null,
|
||||
currency: null,
|
||||
meta: [
|
||||
'status_code' => $statusCode,
|
||||
'body' => $body,
|
||||
'parts' => $parts,
|
||||
'error_id' => $errorId,
|
||||
'error_message' => $errorId ? ($errorMap[$errorId] ?? 'Neznana napaka') : 'Neznana napaka',
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Success shape
|
||||
if (preg_match('/^\d+$/', $parts[0])) {
|
||||
$providerId = $parts[0];
|
||||
$price = null;
|
||||
if (isset($parts[1])) {
|
||||
$priceStr = str_replace(',', '.', $parts[1]);
|
||||
$price = is_numeric($priceStr) ? (float) $priceStr : null;
|
||||
}
|
||||
$from = $parts[2] ?? null;
|
||||
$to = $parts[3] ?? null;
|
||||
|
||||
return new SmsResult(
|
||||
status: 'sent',
|
||||
providerMessageId: $providerId,
|
||||
cost: $price,
|
||||
currency: null,
|
||||
meta: [
|
||||
'status_code' => $statusCode,
|
||||
'body' => $body,
|
||||
'parts' => $parts,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP error or unexpected body
|
||||
return new SmsResult(
|
||||
status: 'failed',
|
||||
providerMessageId: null,
|
||||
cost: null,
|
||||
currency: null,
|
||||
meta: [
|
||||
'status_code' => $statusCode,
|
||||
'body' => $body,
|
||||
'parts' => $parts,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function getCreditBalance(SmsProfile $profile): int
|
||||
{
|
||||
$baseUrl = config('services.sms.providers.smsapi_si.base_url');
|
||||
$endpoint = config('services.sms.providers.smsapi_si.credits_endpoint', '/preveri-stanje-kreditov');
|
||||
$timeout = (int) config('services.sms.providers.smsapi_si.timeout', 10);
|
||||
$url = rtrim($baseUrl, '/').$endpoint;
|
||||
|
||||
$response = Http::asForm()
|
||||
->timeout($timeout)
|
||||
->post($url, [
|
||||
'un' => $profile->api_username,
|
||||
'ps' => $profile->decryptApiPassword() ?? '',
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
\Log::warning('SMS credits request failed', [
|
||||
'status' => $response->status(),
|
||||
'url' => $url,
|
||||
'username' => $profile->api_username,
|
||||
'body' => (string) $response->body(),
|
||||
]);
|
||||
throw new \RuntimeException('Credits endpoint returned HTTP '.$response->status());
|
||||
}
|
||||
|
||||
$body = trim((string) $response->body());
|
||||
if ($body === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Per provider docs, response is '##' separated: first token contains the balance
|
||||
$parts = explode('##', $body);
|
||||
$first = trim($parts[0] ?? $body);
|
||||
|
||||
// Fallback to the raw first token if no number found
|
||||
return intval($first);
|
||||
}
|
||||
|
||||
public function getPriceQuotes(SmsProfile $profile): array
|
||||
{
|
||||
$baseUrl = config('services.sms.providers.smsapi_si.base_url');
|
||||
$endpoint = config('services.sms.providers.smsapi_si.price_endpoint', '/dobi-ceno');
|
||||
$timeout = (int) config('services.sms.providers.smsapi_si.timeout', 10);
|
||||
$url = rtrim($baseUrl, '/').$endpoint;
|
||||
|
||||
$response = Http::asForm()
|
||||
->timeout($timeout)
|
||||
->post($url, [
|
||||
'un' => $profile->api_username,
|
||||
'ps' => $profile->decryptApiPassword() ?? '',
|
||||
]);
|
||||
$body = trim((string) $response->body());
|
||||
if ($body === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Provider returns '##' separated values; trim and drop empty tokens
|
||||
$parts = explode('##', $body);
|
||||
$parts = array_map(static fn ($s) => trim((string) $s), $parts);
|
||||
$parts = array_values(array_filter($parts, static fn ($s) => $s !== ''));
|
||||
|
||||
return $parts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Sms;
|
||||
|
||||
use App\Models\SmsProfile;
|
||||
|
||||
interface SmsClient
|
||||
{
|
||||
/**
|
||||
* Sends an SMS message using the given profile credentials.
|
||||
*/
|
||||
public function send(SmsProfile $profile, SmsMessage $message): SmsResult;
|
||||
|
||||
/**
|
||||
* Returns current credit balance as an integer (normalized provider value).
|
||||
*/
|
||||
public function getCreditBalance(SmsProfile $profile): int;
|
||||
|
||||
/**
|
||||
* Returns price quote(s) as an array of strings (provider can return multiple separated by ##).
|
||||
*/
|
||||
public function getPriceQuotes(SmsProfile $profile): array;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Sms;
|
||||
|
||||
class SmsMessage
|
||||
{
|
||||
public function __construct(
|
||||
public string $to,
|
||||
public string $content,
|
||||
public ?string $sender = null, // provider sname
|
||||
public ?string $senderPhone = null, // phone number for 'from'
|
||||
public ?string $countryCode = null, // cc param, optional
|
||||
public bool $deliveryReport = false, // dr=1 when true
|
||||
public ?string $clientReference = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Sms;
|
||||
|
||||
class SmsResult
|
||||
{
|
||||
public function __construct(
|
||||
public string $status, // sent|failed
|
||||
public ?string $providerMessageId = null,
|
||||
public ?float $cost = null,
|
||||
public ?string $currency = null,
|
||||
public array $meta = [],
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Sms;
|
||||
|
||||
use App\Models\SmsLog;
|
||||
use App\Models\SmsProfile;
|
||||
use App\Models\SmsSender;
|
||||
use App\Models\SmsTemplate;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SmsService
|
||||
{
|
||||
public function __construct(
|
||||
protected SmsClient $client,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Send a raw text message.
|
||||
*/
|
||||
public function sendRaw(SmsProfile $profile, string $to, string $content, ?SmsSender $sender = null, ?string $countryCode = null, bool $deliveryReport = false, ?string $clientReference = null): SmsLog
|
||||
{
|
||||
return DB::transaction(function () use ($profile, $to, $content, $sender, $countryCode, $deliveryReport, $clientReference): SmsLog {
|
||||
$log = new SmsLog([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'profile_id' => $profile->id,
|
||||
'to_number' => /*$to*/'',
|
||||
'sender' => $sender?->sname,
|
||||
'message' => $content,
|
||||
'status' => 'queued',
|
||||
'queued_at' => now(),
|
||||
]);
|
||||
$log->save();
|
||||
|
||||
$result = $this->client->send($profile, new SmsMessage(
|
||||
to: $to,
|
||||
content: $content,
|
||||
sender: $sender?->sname,
|
||||
senderPhone: /*$sender?->phone_number*/'',
|
||||
countryCode: $countryCode,
|
||||
deliveryReport: $deliveryReport,
|
||||
clientReference: $clientReference,
|
||||
));
|
||||
|
||||
if ($result->status === 'sent') {
|
||||
$log->status = 'sent';
|
||||
$log->sent_at = now();
|
||||
} else {
|
||||
$log->status = 'failed';
|
||||
$log->failed_at = now();
|
||||
}
|
||||
$log->provider_message_id = $result->providerMessageId;
|
||||
$log->cost = $result->cost;
|
||||
$log->currency = $result->currency;
|
||||
$log->meta = $result->meta;
|
||||
$log->save();
|
||||
|
||||
return $log;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an SMS from template and send.
|
||||
*/
|
||||
public function sendFromTemplate(SmsTemplate $template, string $to, array $variables = [], ?SmsProfile $profile = null, ?SmsSender $sender = null, ?string $countryCode = null, bool $deliveryReport = false, ?string $clientReference = null): SmsLog
|
||||
{
|
||||
$profile = $profile ?: $template->defaultProfile;
|
||||
if (! $profile) {
|
||||
throw new \InvalidArgumentException('SMS profile is required to send a message.');
|
||||
}
|
||||
|
||||
$sender = $sender ?: $template->defaultSender;
|
||||
$content = $this->renderContent($template->content, $variables);
|
||||
|
||||
$log = $this->sendRaw($profile, $to, $content, $sender, $countryCode, $deliveryReport, $clientReference);
|
||||
$log->template_id = $template->id;
|
||||
$log->save();
|
||||
|
||||
return $log;
|
||||
}
|
||||
|
||||
protected function renderContent(string $content, array $vars): string
|
||||
{
|
||||
// Simple token replacement: {token}
|
||||
return preg_replace_callback('/\{([a-zA-Z0-9_\.]+)\}/', function ($m) use ($vars) {
|
||||
$key = $m[1];
|
||||
|
||||
return array_key_exists($key, $vars) ? (string) $vars[$key] : $m[0];
|
||||
}, $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current credit balance from provider.
|
||||
*/
|
||||
public function getCreditBalance(SmsProfile $profile): string
|
||||
{
|
||||
return $this->client->getCreditBalance($profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get price quote(s) from provider.
|
||||
* Returns array of strings as provided by the API.
|
||||
*/
|
||||
public function getPriceQuotes(SmsProfile $profile): array
|
||||
{
|
||||
return $this->client->getPriceQuotes($profile);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user