245 lines
8.0 KiB
PHP
245 lines
8.0 KiB
PHP
<?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,
|
|
) {}
|
|
|
|
/**
|
|
* Normalize whitespace to avoid accidental Unicode switching and provider quirks.
|
|
* - Convert non-breaking space (U+00A0) and tabs to regular spaces
|
|
* - Preserve newlines
|
|
*/
|
|
protected function normalizeForSms(string $text): string
|
|
{
|
|
// Replace NBSP (\xC2\xA0 in UTF-8) and tabs with regular space
|
|
$text = str_replace(["\u{00A0}", "\t"], ' ', $text);
|
|
// Optionally collapse CRLF to LF (providers typically accept both); keep as-is otherwise
|
|
return $text;
|
|
}
|
|
|
|
/**
|
|
* Heuristic GSM-7 detection: treat any codepoint >= 0x80 as UCS-2 except € which is allowed via extension table.
|
|
*/
|
|
protected function isGsm7(string $text): bool
|
|
{
|
|
$len = mb_strlen($text, 'UTF-8');
|
|
for ($i = 0; $i < $len; $i++) {
|
|
$ch = mb_substr($text, $i, 1, 'UTF-8');
|
|
if ($ch === '€') {
|
|
continue;
|
|
}
|
|
// Fast ASCII check: multibyte UTF-8 means non-ASCII (>= 0x80)
|
|
if (strlen($ch) > 1) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Count GSM-7 units, where extension table chars cost 2 (ESC + char).
|
|
*/
|
|
protected function gsm7Length(string $text): int
|
|
{
|
|
static $extended = null;
|
|
if ($extended === null) {
|
|
$extended = ['^', '{', '}', '\\', '[', '~', ']', '|'];
|
|
}
|
|
|
|
$len = 0;
|
|
$strlen = mb_strlen($text, 'UTF-8');
|
|
for ($i = 0; $i < $strlen; $i++) {
|
|
$ch = mb_substr($text, $i, 1, 'UTF-8');
|
|
if ($ch === '€' || in_array($ch, $extended, true)) {
|
|
$len += 2;
|
|
} else {
|
|
$len += 1;
|
|
}
|
|
}
|
|
|
|
return $len;
|
|
}
|
|
|
|
/**
|
|
* Truncate text to provider hard limits: 640 GSM-7 units or 320 UCS-2 units.
|
|
*/
|
|
public function enforceLengthLimit(string $text): string
|
|
{
|
|
$text = $this->normalizeForSms($text);
|
|
$isGsm = $this->isGsm7($text);
|
|
$limit = $isGsm ? 640 : 320;
|
|
|
|
if ($isGsm) {
|
|
// Fast-path: if within limit, return
|
|
if ($this->gsm7Length($text) <= $limit) {
|
|
return $text;
|
|
}
|
|
// Truncate respecting extension char cost
|
|
$out = '';
|
|
$acc = 0;
|
|
$strlen = mb_strlen($text, 'UTF-8');
|
|
for ($i = 0; $i < $strlen; $i++) {
|
|
$ch = mb_substr($text, $i, 1, 'UTF-8');
|
|
$cost = ($ch === '€' || in_array($ch, ['^', '{', '}', '\\', '[', '~', ']', '|'], true)) ? 2 : 1;
|
|
if ($acc + $cost > $limit) {
|
|
break;
|
|
}
|
|
$out .= $ch;
|
|
$acc += $cost;
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
// UCS-2: count by UTF-16 code units; approximate via mb_substr slicing by codepoints
|
|
// We use mb_substr which handles Unicode correctly for most cases; providers count 1 per code unit.
|
|
// For BMP characters (most cases like Slovenian diacritics), 1 codepoint ~= 1 code unit.
|
|
if (mb_strlen($text, 'UTF-8') <= $limit) {
|
|
return $text;
|
|
}
|
|
|
|
return mb_substr($text, 0, $limit, 'UTF-8');
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
// Enforce provider hard length limits before logging/sending
|
|
$content = $this->enforceLengthLimit($content);
|
|
$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;
|
|
}
|
|
|
|
public function renderContent(string $content, array $vars): string
|
|
{
|
|
// Support {token} and {nested.keys} using dot-notation lookup
|
|
$resolver = function (array $arr, string $path) {
|
|
if (array_key_exists($path, $arr)) {
|
|
return $arr[$path];
|
|
}
|
|
$segments = explode('.', $path);
|
|
$cur = $arr;
|
|
foreach ($segments as $seg) {
|
|
if (is_array($cur) && array_key_exists($seg, $cur)) {
|
|
$cur = $cur[$seg];
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return $cur;
|
|
};
|
|
|
|
return preg_replace_callback('/\{([a-zA-Z0-9_\.]+)\}/', function ($m) use ($vars, $resolver) {
|
|
$key = $m[1];
|
|
$val = $resolver($vars, $key);
|
|
|
|
return $val !== null ? (string) $val : $m[0];
|
|
}, $content);
|
|
}
|
|
|
|
/**
|
|
* Format a number to EU style: thousands separated by '.', decimals by ','.
|
|
*/
|
|
public function formatAmountEu(mixed $value, int $decimals = 2): string
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return number_format(0, $decimals, ',', '.');
|
|
}
|
|
$str = (string) $value;
|
|
// Normalize possible EU-style input like "1.234,56" to standard for float casting
|
|
if (str_contains($str, ',')) {
|
|
$str = str_replace(['.', ','], ['', '.'], $str);
|
|
}
|
|
$num = (float) $str;
|
|
|
|
return number_format($num, $decimals, ',', '.');
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|