SMS limiter
This commit is contained in:
parent
369af34ad4
commit
20d4907fc5
|
|
@ -15,12 +15,112 @@ public function __construct(
|
||||||
protected SmsClient $client,
|
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.
|
* 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
|
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 {
|
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([
|
$log = new SmsLog([
|
||||||
'uuid' => (string) Str::uuid(),
|
'uuid' => (string) Str::uuid(),
|
||||||
'profile_id' => $profile->id,
|
'profile_id' => $profile->id,
|
||||||
|
|
|
||||||
|
|
@ -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 buildVarsFromSelectedContract = () => {
|
||||||
const uuid = selectedContractUuid.value;
|
const uuid = selectedContractUuid.value;
|
||||||
if (!uuid) return {};
|
if (!uuid) return {};
|
||||||
|
|
@ -912,6 +990,41 @@ const submitSms = () => {
|
||||||
class="w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
|
class="w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
|
||||||
placeholder="Vpišite SMS vsebino..."
|
placeholder="Vpišite SMS vsebino..."
|
||||||
></textarea>
|
></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 (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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 mt-1">
|
<label class="inline-flex items-center gap-2 text-sm text-gray-700 mt-1">
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user