SMS limiter

This commit is contained in:
Simon Pocrnjič 2025-10-27 19:00:00 +01:00
parent 369af34ad4
commit 20d4907fc5
2 changed files with 213 additions and 0 deletions

View File

@ -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,

View File

@ -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..."
></textarea>
<!-- Live counters -->
<div class="mt-1 text-xs text-gray-600 flex flex-col gap-1">
<div>
<span class="font-medium">Znakov:</span>
<span class="font-mono">{{ charCount }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Kodiranje:</span>
<span>{{ smsEncoding }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Deli SMS:</span>
<span class="font-mono">{{ segments }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Krediti:</span>
<span class="font-mono">{{ creditsNeeded }}</span>
</div>
<div>
<span class="font-medium">Omejitev:</span>
<span class="font-mono">{{ maxAllowed }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Preostanek:</span>
<span class="font-mono" :class="{ 'text-red-600': remaining === 0 }">{{
remaining
}}</span>
</div>
<p class="text-[11px] text-gray-500 leading-snug">
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 (UCS2). 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 GSM7 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 (GSM7) oziroma
320 (UCS2) znakov.
</p>
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 mt-1">
<input