155 lines
5.1 KiB
Python
155 lines
5.1 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import re
|
|
import string
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
import phonenumbers
|
|
|
|
from app.db import DeliveryStatus, SendStatus
|
|
|
|
GSM_BASIC = (
|
|
"@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ "
|
|
"!\"#¤%&'()*+,-./0123456789:;<=>?"
|
|
"¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà"
|
|
)
|
|
GSM_EXTENDED = "^{}\\[~]|€"
|
|
PHONE_RE = re.compile(r"^\+[1-9]\d{7,14}$")
|
|
|
|
|
|
class DomainError(Exception):
|
|
def __init__(
|
|
self, code: str, status: int, message: str, details: dict[str, Any] | None = None
|
|
) -> None:
|
|
self.code = code
|
|
self.status = status
|
|
self.message = message
|
|
self.details = details or {}
|
|
super().__init__(message)
|
|
|
|
|
|
def normalize_phone(value: str) -> tuple[str, str, str]:
|
|
if not PHONE_RE.fullmatch(value):
|
|
raise DomainError("sms_request_invalid", 422, "phone_e164 must be valid E.164")
|
|
try:
|
|
parsed = phonenumbers.parse(value, None)
|
|
except phonenumbers.NumberParseException:
|
|
raise DomainError("sms_request_invalid", 422, "phone_e164 must be valid E.164") from None
|
|
if not phonenumbers.is_valid_number(parsed):
|
|
raise DomainError("sms_request_invalid", 422, "phone_e164 must be valid E.164")
|
|
normalized = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
|
if normalized != value:
|
|
raise DomainError("sms_request_invalid", 422, "phone_e164 must be canonical E.164")
|
|
digits = normalized[1:]
|
|
masked = f"+{digits[:1]}{'*' * max(0, len(digits) - 5)}{digits[-4:]}"
|
|
return normalized, digits, masked
|
|
|
|
|
|
def request_fingerprint(payload: dict[str, Any]) -> str:
|
|
canonical = json.dumps(
|
|
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False
|
|
)
|
|
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def destination_hmac(phone_e164: str, key: bytes) -> str:
|
|
return hmac.new(key, phone_e164.encode(), hashlib.sha256).hexdigest()
|
|
|
|
|
|
def sms_parts(body: str) -> int:
|
|
if not body or "\ufeff" in body or "\x00" in body:
|
|
raise DomainError("sms_request_invalid", 422, "Rendered message contains invalid text")
|
|
gsm_units = 0
|
|
for char in body:
|
|
if char in GSM_BASIC:
|
|
gsm_units += 1
|
|
elif char in GSM_EXTENDED:
|
|
gsm_units += 2
|
|
else:
|
|
total = len(body.encode("utf-16-be")) // 2
|
|
return 1 if total <= 70 else (total + 66) // 67
|
|
return 1 if gsm_units <= 160 else (gsm_units + 152) // 153
|
|
|
|
|
|
def render_template(
|
|
body_template: str,
|
|
placeholders: list[str],
|
|
substitutions: dict[str, Any],
|
|
max_parts: int,
|
|
) -> str:
|
|
expected = set(placeholders)
|
|
supplied = set(substitutions)
|
|
if expected != supplied:
|
|
raise DomainError(
|
|
"sms_request_invalid",
|
|
422,
|
|
"Substitutions do not match template placeholders",
|
|
{"missing": sorted(expected - supplied), "unknown": sorted(supplied - expected)},
|
|
)
|
|
parsed = {
|
|
field_name
|
|
for _, field_name, format_spec, conversion in string.Formatter().parse(body_template)
|
|
if field_name is not None
|
|
and not format_spec
|
|
and not conversion
|
|
and field_name.isidentifier()
|
|
}
|
|
if parsed != expected or any(
|
|
format_spec or conversion
|
|
for _, field_name, format_spec, conversion in string.Formatter().parse(body_template)
|
|
if field_name is not None
|
|
):
|
|
raise DomainError("sms_request_invalid", 422, "Template placeholder contract is invalid")
|
|
body = body_template.format_map({key: str(value) for key, value in substitutions.items()})
|
|
if len(body.encode("utf-8")) > 2048 or sms_parts(body) > max_parts:
|
|
raise DomainError("sms_request_invalid", 422, "Rendered message exceeds template limit")
|
|
return body
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProviderResult:
|
|
send_status: SendStatus
|
|
http_status: int | None = None
|
|
message_uuid: str | None = None
|
|
external_id: str | None = None
|
|
error_code: str | None = None
|
|
error_message: str | None = None
|
|
retry_safe: bool = False
|
|
contract_violation: bool = False
|
|
|
|
|
|
DELIVERY_RANK = {
|
|
DeliveryStatus.UNKNOWN: 0,
|
|
DeliveryStatus.SENT: 1,
|
|
DeliveryStatus.DELIVERED: 2,
|
|
DeliveryStatus.UNDELIVERED: 2,
|
|
DeliveryStatus.UNSENT: 2,
|
|
}
|
|
|
|
|
|
def delivery_transition(current: DeliveryStatus, incoming: str) -> DeliveryStatus | None:
|
|
try:
|
|
target = DeliveryStatus(incoming.lower())
|
|
except ValueError:
|
|
return None
|
|
if DELIVERY_RANK[target] < DELIVERY_RANK[current]:
|
|
return current
|
|
if DELIVERY_RANK[target] == DELIVERY_RANK[current] and target != current:
|
|
return current
|
|
return target
|
|
|
|
|
|
def parse_status_time(value: str) -> datetime:
|
|
try:
|
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
raise DomainError("callback_invalid", 422, "Invalid callback status_time") from None
|
|
if parsed.tzinfo is None:
|
|
raise DomainError("callback_invalid", 422, "Callback status_time requires timezone")
|
|
return parsed
|