Coverage for src/lexigram/notification/backends/sms/twilio.py: 100%
50 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 02:32 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 02:32 +0800
1"""Twilio SMS backend."""
3from __future__ import annotations
5import base64
6from typing import TYPE_CHECKING
7import uuid
9from lexigram.contracts.core import HealthCheckResult, HealthStatus
10from lexigram.contracts.mailer import MessageDeliveryReceipt
11from lexigram.contracts.notification.types import SMSMessage
12from lexigram.logging import get_logger
13from lexigram.notification.constants import DEFAULT_TWILIO_TIMEOUT, TWILIO_API_BASE
14from lexigram.notification.exceptions import TwilioNotificationError
15from lexigram.result import Err, Ok, Result
17if TYPE_CHECKING:
18 from lexigram.contracts.notification.errors import NotificationError
20logger = get_logger(__name__)
23class TwilioSMS:
24 """SMS backend that sends via Twilio REST API.
26 Implements :class:`~lexigram.contracts.notification.protocols.SMSChannelProtocol`.
27 Requires the ``aiohttp`` optional extra.
29 Args:
30 account_sid: Twilio Account SID.
31 auth_token: Twilio Auth Token.
32 from_number: Twilio phone number in E.164 format (e.g., +15550000000).
33 timeout: HTTP request timeout in seconds.
34 """
36 def __init__(
37 self,
38 account_sid: str,
39 auth_token: str,
40 from_number: str | None = None,
41 timeout: int = DEFAULT_TWILIO_TIMEOUT,
42 ) -> None:
43 self._account_sid = account_sid
44 self._auth_token = auth_token
45 self._from_number = from_number
46 self._timeout = timeout
48 def _get_auth_header(self) -> str:
49 """Generate Basic Auth header for Twilio."""
50 credentials = f"{self._account_sid}:{self._auth_token}"
51 encoded = base64.b64encode(credentials.encode()).decode()
52 return f"Basic {encoded}"
54 async def send(
55 self, message: SMSMessage
56 ) -> Result[MessageDeliveryReceipt, NotificationError]:
57 """Send an SMS via the Twilio REST API.
59 Args:
60 message: The SMS to deliver.
62 Returns:
63 ``Ok(MessageDeliveryReceipt)`` on HTTP 201.
64 ``Err(TwilioNotificationError)`` for 4xx/5xx responses.
66 Raises:
67 aiohttp.ClientError: For network connectivity issues.
68 OSError: For low-level socket/infrastructure errors.
69 """
70 import aiohttp
72 url = f"{TWILIO_API_BASE}/Accounts/{self._account_sid}/Messages.json"
73 headers = {
74 "Authorization": self._get_auth_header(),
75 "Content-Type": "application/x-www-form-urlencoded",
76 }
78 # Build form data
79 from_number = message.from_number or self._from_number
80 data = {
81 "To": message.to[0] if message.to else "",
82 "From": from_number,
83 "Body": message.body,
84 }
86 async with aiohttp.ClientSession() as session:
87 async with session.post(
88 url,
89 data=data,
90 headers=headers,
91 timeout=aiohttp.ClientTimeout(total=self._timeout),
92 ) as resp:
93 body = await resp.json()
95 if resp.status == 201:
96 receipt = MessageDeliveryReceipt(
97 message_id=str(uuid.uuid4()),
98 backend="twilio",
99 channel="sms",
100 provider_reference=body.get("sid"),
101 )
102 logger.info("twilio_sent", to=message.to, sid=body.get("sid"))
103 return Ok(receipt)
105 # Handle error response
106 error_msg = body.get("message", f"HTTP {resp.status}")
107 twilio_code = body.get("code")
108 logger.warning(
109 "twilio_send_failed",
110 status=resp.status,
111 code=twilio_code,
112 message=error_msg,
113 )
114 return Err(TwilioNotificationError(error_msg, twilio_code=twilio_code))
116 async def health_check(self, timeout: float = 5.0) -> HealthCheckResult:
117 """Check Twilio API reachability.
119 Args:
120 timeout: Max seconds to wait.
122 Returns:
123 :class:`~lexigram.contracts.core.HealthCheckResult`.
124 """
125 import aiohttp
127 try:
128 url = f"{TWILIO_API_BASE}/Accounts/{self._account_sid}.json"
129 async with aiohttp.ClientSession() as session:
130 async with session.get(
131 url,
132 headers={"Authorization": self._get_auth_header()},
133 timeout=aiohttp.ClientTimeout(total=timeout),
134 ) as resp:
135 status = (
136 HealthStatus.HEALTHY
137 if resp.status < 500
138 else HealthStatus.UNHEALTHY
139 )
140 return HealthCheckResult(
141 component="twilio",
142 status=status,
143 details={"http_status": resp.status},
144 )
145 except OSError as exc:
146 return HealthCheckResult(
147 component="twilio",
148 status=HealthStatus.UNHEALTHY,
149 details={"error": str(exc)},
150 )
153__all__ = ["TwilioSMS"]