diff --git a/app/Services/Sms/SmsService.php b/app/Services/Sms/SmsService.php index eaf7d04..c6306e3 100644 --- a/app/Services/Sms/SmsService.php +++ b/app/Services/Sms/SmsService.php @@ -15,12 +15,112 @@ 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, diff --git a/resources/js/Components/PersonInfoGrid.vue b/resources/js/Components/PersonInfoGrid.vue index 572f9fe..8f8156d 100644 --- a/resources/js/Components/PersonInfoGrid.vue +++ b/resources/js/Components/PersonInfoGrid.vue @@ -268,6 +268,84 @@ const renderTokens = (text, vars) => { }); }; +// SMS length, encoding and credits +const GSM7_EXTENDED = new Set(["^", "{", "}", "\\", "[", "~", "]", "|"]); +const isGsm7 = (text) => { + for (const ch of text || "") { + if (ch === "€") continue; // Allowed via GSM 03.38 extension + const code = ch.charCodeAt(0); + if (code >= 0x80) return false; // Non-ASCII -> UCS-2 + } + return true; +}; +const gsm7Length = (text) => { + let len = 0; + for (const ch of text || "") { + if (ch === "€" || GSM7_EXTENDED.has(ch)) { + len += 2; // extension table uses ESC + char + } else { + len += 1; + } + } + return len; +}; +const ucs2Length = (text) => (text ? text.length : 0); // UTF-16 units ~ UCS-2 cost + +const smsEncoding = computed(() => (isGsm7(smsMessage.value) ? "GSM-7" : "UCS-2")); +const charCount = computed(() => + smsEncoding.value === "GSM-7" + ? gsm7Length(smsMessage.value) + : ucs2Length(smsMessage.value) +); +const perSegment = computed(() => { + const count = charCount.value; + if (smsEncoding.value === "GSM-7") { + return count <= 160 ? 160 : 153; // single vs concatenated + } + return count <= 70 ? 70 : 67; +}); +const segments = computed(() => { + const count = charCount.value; + const size = perSegment.value || 1; + return count > 0 ? Math.ceil(count / size) : 0; +}); +const creditsNeeded = computed(() => segments.value); + +// Provider hard limit: max 4 SMS parts +// - GSM-7: 640 units total +// - Unicode (UCS-2): 320 units total +const maxAllowed = computed(() => (smsEncoding.value === "GSM-7" ? 640 : 320)); +const remaining = computed(() => Math.max(0, maxAllowed.value - charCount.value)); + +// Truncate helper that respects GSM-7 extended char cost (2 units) and UCS-2 units (1 per code unit) +const truncateToLimit = (text, limit, encoding) => { + if (!text) return ""; + if (limit <= 0) return ""; + if (encoding === "UCS-2") { + // UCS-2: count per UTF-16 code unit + return text.slice(0, limit); + } + // GSM-7: iterate and sum per-char costs (extended table chars cost 2) + let acc = 0; + let out = ""; + for (const ch of text) { + const cost = ch === "€" || GSM7_EXTENDED.has(ch) ? 2 : 1; + if (acc + cost > limit) break; + out += ch; + acc += cost; + } + return out; +}; + +// Enforce hard limits by truncating user input as they type/paste +watch(smsMessage, (val) => { + const limit = maxAllowed.value; + // If currently over limit, truncate + if (charCount.value > limit) { + smsMessage.value = truncateToLimit(val, limit, smsEncoding.value); + } +}); + const buildVarsFromSelectedContract = () => { const uuid = selectedContractUuid.value; if (!uuid) return {}; @@ -912,6 +990,41 @@ const submitSms = () => { class="w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500" placeholder="Vpišite SMS vsebino..." > + +
+
+ Znakov: + {{ charCount }} + | + Kodiranje: + {{ smsEncoding }} + | + Deli SMS: + {{ segments }} + | + Krediti: + {{ creditsNeeded }} +
+
+ Omejitev: + {{ maxAllowed }} + | + Preostanek: + {{ + remaining + }} +
+

+ Dolžina 160 znakov velja samo pri pošiljanju sporočil, ki vsebujejo znake, ki + ne zahtevajo enkodiranja. Če npr. želite pošiljati šumnike, ki niso del + 7-bitne abecede GSM, morate uporabiti Unicode enkodiranje (UCS‑2). V tem + primeru je največja dolžina enega SMS sporočila 70 znakov (pri daljših + sporočilih 67 znakov na del), medtem ko je pri GSM‑7 160 znakov (pri daljših + sporočilih 153 znakov na del). Razširjeni znaki (^{{ "{" }}}}\\[]~| in €) + štejejo dvojno. Največja dovoljena dolžina po ponudniku: 640 (GSM‑7) oziroma + 320 (UCS‑2) znakov. +

+