162 lines
5.6 KiB
Python
162 lines
5.6 KiB
Python
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))
|