from __future__ import annotations import uuid from dataclasses import dataclass from urllib.parse import quote, urlsplit, urlunsplit import httpx from app.db import SendStatus, SmsOutboundMessage from app.domain import ProviderResult @dataclass(frozen=True) class IdgtlConfig: base_url: str api_key: str callback_url: str callback_username: str callback_password: str connect_timeout_ms: int request_timeout_ms: int callback_enabled: bool def callback_url_with_credentials(config: IdgtlConfig) -> str: parts = urlsplit(config.callback_url) credentials = ( f"{quote(config.callback_username, safe='')}:{quote(config.callback_password, safe='')}" ) host = parts.hostname or "" if parts.port: host = f"{host}:{parts.port}" return urlunsplit((parts.scheme, f"{credentials}@{host}", parts.path, parts.query, "")) def build_payload(message: SmsOutboundMessage, config: IdgtlConfig) -> list[dict[str, object]]: item: dict[str, object] = { "channelType": "SMS", "senderName": message.sender_name, "destination": message.phone_digits, "content": message.body_rendered, "externalMessageId": str(message.id), "ttl": message.message_ttl_sec, } if config.callback_enabled: item["callbackUrl"] = callback_url_with_credentials(config) item["callbackEvents"] = ["delivered", "sent"] return [item] def classify_response(response: httpx.Response, expected_external_id: str) -> ProviderResult: if response.status_code != 200: if 400 <= response.status_code < 500: return ProviderResult( SendStatus.REJECTED, response.status_code, error_code=f"http_{response.status_code}", error_message="provider_rejected", ) return ProviderResult( SendStatus.UNCERTAIN, response.status_code, error_code=f"http_{response.status_code}", error_message="provider_result_uncertain", ) try: payload = response.json() except ValueError: return ProviderResult( SendStatus.REJECTED, 200, error_code="malformed_json", error_message="provider_contract_violation", contract_violation=True, ) items = payload.get("items") if isinstance(payload, dict) else None if isinstance(payload, dict) and items is None: items = payload.get("messages") or payload.get("results") or payload.get("response") errors = payload.get("errors") if isinstance(payload, dict) else None if errors is not False or not isinstance(items, list) or len(items) != 1: return ProviderResult( SendStatus.REJECTED, 200, error_code="invalid_response", error_message="provider_contract_violation", contract_violation=True, ) item = items[0] if not isinstance(item, dict): return ProviderResult( SendStatus.REJECTED, 200, error_code="invalid_item", error_message="provider_contract_violation", contract_violation=True, ) message_uuid = item.get("messageUuid") external_id = item.get("externalMessageId") try: uuid.UUID(str(message_uuid)) except (ValueError, TypeError, AttributeError): message_uuid = None valid = item.get("code") == 201 and message_uuid and external_id == expected_external_id if not valid: return ProviderResult( SendStatus.REJECTED, 200, error_code=str(item.get("code") or "invalid_item"), error_message="provider_contract_violation", contract_violation=True, ) return ProviderResult( SendStatus.ACCEPTED, 200, message_uuid=str(message_uuid), external_id=str(external_id), ) class IdgtlClient: def __init__(self, client: httpx.AsyncClient, config: IdgtlConfig) -> None: self.client = client self.config = config async def send(self, message: SmsOutboundMessage) -> ProviderResult: timeout = httpx.Timeout( self.config.request_timeout_ms / 1000, connect=self.config.connect_timeout_ms / 1000, ) try: headers = {"Authorization": f"Basic {self.config.api_key}"} if message.request_id: headers["X-Request-ID"] = message.request_id if message.traceparent: headers["traceparent"] = message.traceparent response = await self.client.post( f"{self.config.base_url.rstrip('/')}/api/v1/message", headers=headers, json=build_payload(message, self.config), timeout=timeout, ) except (httpx.ConnectError, httpx.ConnectTimeout): return ProviderResult( SendStatus.FAILED, error_code="connect_failure", error_message="provider_connect_failure", retry_safe=True, ) except (httpx.ReadTimeout, httpx.WriteError, httpx.ReadError, httpx.RemoteProtocolError): return ProviderResult( SendStatus.UNCERTAIN, error_code="ambiguous_transport_failure", error_message="provider_result_uncertain", ) except httpx.RequestError: return ProviderResult( SendStatus.UNCERTAIN, error_code="transport_failure", error_message="provider_result_uncertain", ) return classify_response(response, str(message.id))