Coverage for src/lexigram/notification/backends/sms/whatsapp.py: 27%

116 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-26 07:17 +0800

1"""WhatsApp Business API notification backend. 

2 

3Supports two delivery providers: 

4 

5- **Twilio** — uses the Twilio Messaging API with the ``whatsapp:`` URI 

6 scheme. Requires the existing ``aiohttp`` dependency. 

7- **Meta** — calls the Meta (Facebook) WhatsApp Business API directly 

8 via ``httpx``. Requires the ``lexigram-notification[whatsapp]`` extra. 

9""" 

10 

11from __future__ import annotations 

12 

13import base64 

14from typing import TYPE_CHECKING, Any 

15import uuid 

16 

17from lexigram.contracts.core import HealthCheckResult, HealthStatus 

18from lexigram.contracts.mailer import MessageDeliveryReceipt 

19from lexigram.contracts.notification.types import SMSMessage 

20from lexigram.logging import get_logger 

21from lexigram.notification.constants import DEFAULT_TWILIO_TIMEOUT, TWILIO_API_BASE 

22from lexigram.notification.exceptions import TwilioNotificationError 

23from lexigram.result import Err, Ok, Result 

24 

25if TYPE_CHECKING: 

26 from lexigram.contracts.notification.errors import NotificationError 

27 

28logger = get_logger(__name__) 

29 

30# Meta Graph API base URL for WhatsApp Business messages. 

31_META_API_BASE = "https://graph.facebook.com/v19.0" 

32_DEFAULT_META_TIMEOUT = 30 

33 

34 

35class WhatsAppNotificationError(TwilioNotificationError): 

36 """WhatsApp delivery failure via Twilio provider.""" 

37 

38 _code = "LEX_ERR_NOTIF_012" 

39 

40 def __init__( 

41 self, 

42 message: str = "WhatsApp (Twilio) delivery error", 

43 *, 

44 twilio_code: int | None = None, 

45 **kwargs: Any, 

46 ) -> None: 

47 # Call NotificationError directly; super chain reaches TwilioNotificationError 

48 # but we override backend to "whatsapp_twilio". 

49 super().__init__(message, twilio_code=twilio_code, **kwargs) 

50 self.backend = "whatsapp_twilio" 

51 

52 

53class WhatsAppMetaNotificationError(TwilioNotificationError): 

54 """WhatsApp delivery failure via Meta provider.""" 

55 

56 _code = "LEX_ERR_NOTIF_013" 

57 

58 def __init__( 

59 self, 

60 message: str = "WhatsApp (Meta) delivery error", 

61 *, 

62 meta_code: str | None = None, 

63 **kwargs: Any, 

64 ) -> None: 

65 super().__init__(message, **kwargs) 

66 self.backend = "whatsapp_meta" 

67 self.meta_code = meta_code 

68 

69 

70class WhatsAppBackend: 

71 """WhatsApp Business API notification backend. 

72 

73 Supports two providers: 

74 

75 - ``"twilio"`` — Twilio Messaging API with ``whatsapp:`` prefix. 

76 Accepts the same ``account_sid`` / ``auth_token`` / ``from_number`` 

77 credentials as :class:`~lexigram.notification.backends.sms.twilio.TwilioSMS`. 

78 - ``"meta"`` — Meta (Facebook) WhatsApp Business API. 

79 Requires ``access_token`` and ``phone_number_id``. 

80 

81 Implements a compatible ``send`` / ``health_check`` interface. 

82 

83 Args: 

84 provider: Delivery provider. Either ``"twilio"`` or ``"meta"``. 

85 account_sid: Twilio Account SID (Twilio provider only). 

86 auth_token: Twilio Auth Token (Twilio provider only). 

87 from_number: Twilio WhatsApp-enabled number in ``whatsapp:+1…`` 

88 format, or just the E.164 number (prefix added automatically). 

89 access_token: Meta Graph API access token (Meta provider only). 

90 phone_number_id: Meta phone number object ID (Meta provider only). 

91 timeout: HTTP request timeout in seconds. 

92 """ 

93 

94 def __init__( 

95 self, 

96 *, 

97 provider: str = "twilio", 

98 account_sid: str | None = None, 

99 auth_token: str | None = None, 

100 from_number: str | None = None, 

101 access_token: str | None = None, 

102 phone_number_id: str | None = None, 

103 timeout: int = DEFAULT_TWILIO_TIMEOUT, 

104 ) -> None: 

105 if provider not in ("twilio", "meta"): 

106 raise ValueError( 

107 f"Unsupported WhatsApp provider: {provider!r}. " 

108 "Expected 'twilio' or 'meta'." 

109 ) 

110 self._provider = provider 

111 self._account_sid = account_sid 

112 self._auth_token = auth_token 

113 self._from_number = from_number 

114 self._access_token = access_token 

115 self._phone_number_id = phone_number_id 

116 self._timeout = timeout 

117 

118 # ────────────────────────────────────────────────────────────── 

119 # Public interface 

120 # ────────────────────────────────────────────────────────────── 

121 

122 async def send( 

123 self, message: SMSMessage 

124 ) -> Result[MessageDeliveryReceipt, NotificationError]: 

125 """Send a WhatsApp message. 

126 

127 Dispatches to the appropriate provider implementation. 

128 

129 Args: 

130 message: The :class:`~lexigram.contracts.notification.types.SMSMessage` 

131 to deliver. ``message.to`` must contain the recipient phone 

132 numbers in E.164 format. 

133 

134 Returns: 

135 ``Ok(MessageDeliveryReceipt)`` on success. 

136 ``Err(NotificationError)`` on delivery failure. 

137 """ 

138 if self._provider == "twilio": 

139 return await self._send_twilio(message) 

140 return await self._send_meta(message) 

141 

142 async def health_check(self, timeout: float = 5.0) -> HealthCheckResult: 

143 """Check provider API reachability. 

144 

145 Args: 

146 timeout: Max seconds to wait. 

147 

148 Returns: 

149 :class:`~lexigram.contracts.core.HealthCheckResult`. 

150 """ 

151 if self._provider == "twilio": 

152 return await self._health_twilio(timeout) 

153 return await self._health_meta(timeout) 

154 

155 # ────────────────────────────────────────────────────────────── 

156 # Twilio provider 

157 # ────────────────────────────────────────────────────────────── 

158 

159 def _twilio_auth_header(self) -> str: 

160 """Build Basic Auth header for Twilio.""" 

161 credentials = f"{self._account_sid}:{self._auth_token}" 

162 return "Basic " + base64.b64encode(credentials.encode()).decode() 

163 

164 def _whatsapp_number(self, number: str) -> str: 

165 """Ensure the number has the ``whatsapp:`` prefix.""" 

166 if number.startswith("whatsapp:"): 

167 return number 

168 return f"whatsapp:{number}" 

169 

170 async def _send_twilio( 

171 self, message: SMSMessage 

172 ) -> Result[MessageDeliveryReceipt, NotificationError]: 

173 """Send via Twilio Messaging API.""" 

174 import aiohttp 

175 

176 url = f"{TWILIO_API_BASE}/Accounts/{self._account_sid}/Messages.json" 

177 headers = { 

178 "Authorization": self._twilio_auth_header(), 

179 "Content-Type": "application/x-www-form-urlencoded", 

180 } 

181 from_wa = self._whatsapp_number(message.from_number or self._from_number or "") 

182 to_number = message.to[0] if message.to else "" 

183 data = { 

184 "To": self._whatsapp_number(to_number), 

185 "From": from_wa, 

186 "Body": message.body, 

187 } 

188 

189 async with aiohttp.ClientSession() as session: 

190 async with session.post( 

191 url, 

192 data=data, 

193 headers=headers, 

194 timeout=aiohttp.ClientTimeout(total=self._timeout), 

195 ) as resp: 

196 body = await resp.json() 

197 if resp.status == 201: 

198 receipt = MessageDeliveryReceipt( 

199 message_id=str(uuid.uuid4()), 

200 backend="whatsapp_twilio", 

201 channel="whatsapp", 

202 provider_reference=body.get("sid"), 

203 ) 

204 logger.info( 

205 "whatsapp.twilio_sent", 

206 to=message.to, 

207 sid=body.get("sid"), 

208 ) 

209 return Ok(receipt) 

210 

211 error_msg = body.get("message", f"HTTP {resp.status}") 

212 twilio_code = body.get("code") 

213 logger.warning( 

214 "whatsapp.twilio_send_failed", 

215 status=resp.status, 

216 code=twilio_code, 

217 message=error_msg, 

218 ) 

219 return Err( 

220 WhatsAppNotificationError(error_msg, twilio_code=twilio_code) 

221 ) 

222 

223 async def _health_twilio(self, timeout: float) -> HealthCheckResult: 

224 """Health check via Twilio Account API.""" 

225 import aiohttp 

226 

227 try: 

228 url = f"{TWILIO_API_BASE}/Accounts/{self._account_sid}.json" 

229 async with aiohttp.ClientSession() as session: 

230 async with session.get( 

231 url, 

232 headers={"Authorization": self._twilio_auth_header()}, 

233 timeout=aiohttp.ClientTimeout(total=timeout), 

234 ) as resp: 

235 status = ( 

236 HealthStatus.HEALTHY 

237 if resp.status < 500 

238 else HealthStatus.UNHEALTHY 

239 ) 

240 return HealthCheckResult( 

241 component="whatsapp_twilio", 

242 status=status, 

243 details={"http_status": resp.status}, 

244 ) 

245 except OSError as exc: 

246 return HealthCheckResult( 

247 component="whatsapp_twilio", 

248 status=HealthStatus.UNHEALTHY, 

249 details={"error": str(exc)}, 

250 ) 

251 

252 # ────────────────────────────────────────────────────────────── 

253 # Meta provider 

254 # ────────────────────────────────────────────────────────────── 

255 

256 async def _send_meta( 

257 self, message: SMSMessage 

258 ) -> Result[MessageDeliveryReceipt, NotificationError]: 

259 """Send via Meta WhatsApp Business API.""" 

260 try: 

261 import httpx 

262 except ImportError as exc: 

263 raise ImportError( 

264 "httpx is required for the Meta WhatsApp provider. " 

265 "Install with: pip install lexigram-notification[whatsapp]" 

266 ) from exc 

267 

268 url = f"{_META_API_BASE}/{self._phone_number_id}/messages" 

269 headers = { 

270 "Authorization": f"Bearer {self._access_token}", 

271 "Content-Type": "application/json", 

272 } 

273 to_number = message.to[0] if message.to else "" 

274 payload: dict[str, Any] = { 

275 "messaging_product": "whatsapp", 

276 "to": to_number, 

277 "type": "text", 

278 "text": {"body": message.body}, 

279 } 

280 

281 async with httpx.AsyncClient(timeout=self._timeout) as client: 

282 resp = await client.post(url, json=payload, headers=headers) 

283 body = resp.json() 

284 if resp.status_code == 200: 

285 messages = body.get("messages", [{}]) 

286 wa_id = messages[0].get("id") if messages else None 

287 receipt = MessageDeliveryReceipt( 

288 message_id=str(uuid.uuid4()), 

289 backend="whatsapp_meta", 

290 channel="whatsapp", 

291 provider_reference=wa_id, 

292 ) 

293 logger.info( 

294 "whatsapp.meta_sent", 

295 to=message.to, 

296 wa_id=wa_id, 

297 ) 

298 return Ok(receipt) 

299 

300 error = body.get("error", {}) 

301 error_msg = error.get("message", f"HTTP {resp.status_code}") 

302 meta_code = str(error.get("code", "")) or None 

303 logger.warning( 

304 "whatsapp.meta_send_failed", 

305 status=resp.status_code, 

306 error=error_msg, 

307 code=meta_code, 

308 ) 

309 return Err(WhatsAppMetaNotificationError(error_msg, meta_code=meta_code)) 

310 

311 async def _health_meta(self, timeout: float) -> HealthCheckResult: 

312 """Health check via Meta Graph API token debug endpoint.""" 

313 try: 

314 import httpx 

315 except ImportError: 

316 return HealthCheckResult( 

317 component="whatsapp_meta", 

318 status=HealthStatus.UNHEALTHY, 

319 message="httpx is not installed", 

320 ) 

321 

322 try: 

323 url = ( 

324 f"https://graph.facebook.com/debug_token" 

325 f"?input_token={self._access_token}" 

326 f"&access_token={self._access_token}" 

327 ) 

328 async with httpx.AsyncClient(timeout=timeout) as client: 

329 resp = await client.get(url) 

330 status = ( 

331 HealthStatus.HEALTHY 

332 if resp.status_code < 500 

333 else HealthStatus.UNHEALTHY 

334 ) 

335 return HealthCheckResult( 

336 component="whatsapp_meta", 

337 status=status, 

338 details={"http_status": resp.status_code}, 

339 ) 

340 except OSError as exc: 

341 return HealthCheckResult( 

342 component="whatsapp_meta", 

343 status=HealthStatus.UNHEALTHY, 

344 details={"error": str(exc)}, 

345 ) 

346 

347 

348__all__ = [ 

349 "WhatsAppBackend", 

350 "WhatsAppMetaNotificationError", 

351 "WhatsAppNotificationError", 

352]