Реализована интеграция с СМС провайдером
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
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))
|
||||
Reference in New Issue
Block a user