""" SMS/WhatsApp Provider Adapters for Tenhal Healthcare Platform. This module provides unified interfaces for multiple SMS and WhatsApp providers: - Twilio (International) - Unifonic (Middle East focused) - Mock provider (for testing) All providers implement a common interface for easy switching and testing. """ import logging from abc import ABC, abstractmethod from typing import Dict, Optional, List from dataclasses import dataclass from enum import Enum from django.conf import settings from django.utils import timezone logger = logging.getLogger(__name__) class MessageStatus(Enum): """Unified message status across all providers.""" QUEUED = 'queued' SENT = 'sent' DELIVERED = 'delivered' FAILED = 'failed' UNDELIVERED = 'undelivered' READ = 'read' @dataclass class MessageResult: """ Unified result object returned by all providers. Attributes: success: Whether the message was successfully sent message_id: Provider's message ID status: Current message status error_code: Error code if failed error_message: Human-readable error message cost: Message cost (if available) metadata: Additional provider-specific data """ success: bool message_id: Optional[str] = None status: MessageStatus = MessageStatus.QUEUED error_code: Optional[str] = None error_message: Optional[str] = None cost: Optional[float] = None metadata: Dict = None def __post_init__(self): if self.metadata is None: self.metadata = {} class BaseSMSProvider(ABC): """ Abstract base class for SMS providers. All SMS providers must implement these methods. """ def __init__(self, config: Dict): """ Initialize provider with configuration. Args: config: Provider-specific configuration dict """ self.config = config self.validate_config() @abstractmethod def validate_config(self) -> None: """Validate that all required configuration is present.""" pass @abstractmethod def send_sms( self, to: str, message: str, from_number: Optional[str] = None ) -> MessageResult: """ Send an SMS message. Args: to: Recipient phone number (E.164 format recommended) message: Message body from_number: Sender phone number (optional, uses default if not provided) Returns: MessageResult object with send status """ pass @abstractmethod def get_message_status(self, message_id: str) -> MessageResult: """ Get the current status of a sent message. Args: message_id: Provider's message ID Returns: MessageResult with current status """ pass def format_phone_number(self, phone: str, country_code: str = '+966') -> str: """ Format phone number to E.164 format. Args: phone: Phone number (may include country code or not) country_code: Default country code (Saudi Arabia by default) Returns: Formatted phone number in E.164 format """ # Remove all non-digit characters except + phone = ''.join(c for c in phone if c.isdigit() or c == '+') # If already has +, return as is if phone.startswith('+'): return phone # If starts with 00, replace with + if phone.startswith('00'): return '+' + phone[2:] # If starts with 0, remove it and add country code if phone.startswith('0'): return country_code + phone[1:] # Otherwise, add country code return country_code + phone class BaseWhatsAppProvider(ABC): """ Abstract base class for WhatsApp providers. All WhatsApp providers must implement these methods. """ def __init__(self, config: Dict): """ Initialize provider with configuration. Args: config: Provider-specific configuration dict """ self.config = config self.validate_config() @abstractmethod def validate_config(self) -> None: """Validate that all required configuration is present.""" pass @abstractmethod def send_message( self, to: str, message: str, template_name: Optional[str] = None, template_params: Optional[List[str]] = None ) -> MessageResult: """ Send a WhatsApp message. Args: to: Recipient phone number (E.164 format) message: Message body (for non-template messages) template_name: WhatsApp template name (if using template) template_params: Template parameters (if using template) Returns: MessageResult object with send status """ pass @abstractmethod def get_message_status(self, message_id: str) -> MessageResult: """ Get the current status of a sent message. Args: message_id: Provider's message ID Returns: MessageResult with current status """ pass # ============================================================================ # Twilio Provider Implementation # ============================================================================ class TwilioSMSProvider(BaseSMSProvider): """ Twilio SMS provider implementation. Configuration required: - account_sid: Twilio account SID - auth_token: Twilio auth token - from_number: Default sender phone number """ def validate_config(self) -> None: """Validate Twilio configuration.""" required = ['account_sid', 'auth_token', 'from_number'] missing = [key for key in required if key not in self.config] if missing: raise ValueError(f"Missing Twilio configuration: {', '.join(missing)}") def send_sms( self, to: str, message: str, from_number: Optional[str] = None ) -> MessageResult: """Send SMS via Twilio.""" try: from twilio.rest import Client client = Client( self.config['account_sid'], self.config['auth_token'] ) from_num = from_number or self.config['from_number'] to_num = self.format_phone_number(to) twilio_message = client.messages.create( body=message, from_=from_num, to=to_num ) logger.info(f"Twilio SMS sent: {twilio_message.sid} to {to_num}") return MessageResult( success=True, message_id=twilio_message.sid, status=self._map_twilio_status(twilio_message.status), cost=float(twilio_message.price) if twilio_message.price else None, metadata={ 'provider': 'twilio', 'from': from_num, 'to': to_num, 'segments': twilio_message.num_segments, } ) except Exception as exc: logger.error(f"Twilio SMS failed: {exc}") return MessageResult( success=False, error_code='TWILIO_ERROR', error_message=str(exc), metadata={'provider': 'twilio'} ) def get_message_status(self, message_id: str) -> MessageResult: """Get message status from Twilio.""" try: from twilio.rest import Client client = Client( self.config['account_sid'], self.config['auth_token'] ) message = client.messages(message_id).fetch() return MessageResult( success=True, message_id=message.sid, status=self._map_twilio_status(message.status), cost=float(message.price) if message.price else None, metadata={ 'provider': 'twilio', 'error_code': message.error_code, 'error_message': message.error_message, } ) except Exception as exc: logger.error(f"Twilio status check failed: {exc}") return MessageResult( success=False, error_code='TWILIO_ERROR', error_message=str(exc), metadata={'provider': 'twilio'} ) def _map_twilio_status(self, twilio_status: str) -> MessageStatus: """Map Twilio status to unified status.""" status_map = { 'queued': MessageStatus.QUEUED, 'sending': MessageStatus.SENT, 'sent': MessageStatus.SENT, 'delivered': MessageStatus.DELIVERED, 'undelivered': MessageStatus.UNDELIVERED, 'failed': MessageStatus.FAILED, } return status_map.get(twilio_status.lower(), MessageStatus.QUEUED) class TwilioWhatsAppProvider(BaseWhatsAppProvider): """ Twilio WhatsApp provider implementation. Configuration required: - account_sid: Twilio account SID - auth_token: Twilio auth token - from_number: WhatsApp-enabled phone number (format: whatsapp:+1234567890) """ def validate_config(self) -> None: """Validate Twilio WhatsApp configuration.""" required = ['account_sid', 'auth_token', 'from_number'] missing = [key for key in required if key not in self.config] if missing: raise ValueError(f"Missing Twilio WhatsApp configuration: {', '.join(missing)}") def send_message( self, to: str, message: str, template_name: Optional[str] = None, template_params: Optional[List[str]] = None ) -> MessageResult: """Send WhatsApp message via Twilio.""" try: from twilio.rest import Client client = Client( self.config['account_sid'], self.config['auth_token'] ) # Format numbers for WhatsApp from_num = self.config['from_number'] if not from_num.startswith('whatsapp:'): from_num = f"whatsapp:{from_num}" to_num = to if to.startswith('+') else f"+{to}" to_num = f"whatsapp:{to_num}" twilio_message = client.messages.create( body=message, from_=from_num, to=to_num ) logger.info(f"Twilio WhatsApp sent: {twilio_message.sid} to {to}") return MessageResult( success=True, message_id=twilio_message.sid, status=MessageStatus.SENT, metadata={ 'provider': 'twilio_whatsapp', 'from': from_num, 'to': to_num, } ) except Exception as exc: logger.error(f"Twilio WhatsApp failed: {exc}") return MessageResult( success=False, error_code='TWILIO_WHATSAPP_ERROR', error_message=str(exc), metadata={'provider': 'twilio_whatsapp'} ) def get_message_status(self, message_id: str) -> MessageResult: """Get WhatsApp message status from Twilio.""" try: from twilio.rest import Client client = Client( self.config['account_sid'], self.config['auth_token'] ) message = client.messages(message_id).fetch() status_map = { 'queued': MessageStatus.QUEUED, 'sending': MessageStatus.SENT, 'sent': MessageStatus.SENT, 'delivered': MessageStatus.DELIVERED, 'read': MessageStatus.READ, 'undelivered': MessageStatus.UNDELIVERED, 'failed': MessageStatus.FAILED, } return MessageResult( success=True, message_id=message.sid, status=status_map.get(message.status.lower(), MessageStatus.QUEUED), metadata={ 'provider': 'twilio_whatsapp', 'error_code': message.error_code, 'error_message': message.error_message, } ) except Exception as exc: logger.error(f"Twilio WhatsApp status check failed: {exc}") return MessageResult( success=False, error_code='TWILIO_WHATSAPP_ERROR', error_message=str(exc), metadata={'provider': 'twilio_whatsapp'} ) # ============================================================================ # Unifonic Provider Implementation # ============================================================================ class UnifonicSMSProvider(BaseSMSProvider): """ Unifonic SMS provider implementation (Middle East focused). Configuration required: - app_sid: Unifonic application SID - sender_id: Sender ID (approved by Unifonic) """ def validate_config(self) -> None: """Validate Unifonic configuration.""" required = ['app_sid', 'sender_id'] missing = [key for key in required if key not in self.config] if missing: raise ValueError(f"Missing Unifonic configuration: {', '.join(missing)}") def send_sms( self, to: str, message: str, from_number: Optional[str] = None ) -> MessageResult: """Send SMS via Unifonic.""" try: import requests url = "https://api.unifonic.com/rest/SMS/messages" sender = from_number or self.config['sender_id'] to_num = self.format_phone_number(to) payload = { 'AppSid': self.config['app_sid'], 'SenderID': sender, 'Recipient': to_num, 'Body': message, } response = requests.post(url, data=payload, timeout=30) response.raise_for_status() data = response.json() if data.get('success'): message_id = data.get('data', {}).get('MessageID') logger.info(f"Unifonic SMS sent: {message_id} to {to_num}") return MessageResult( success=True, message_id=str(message_id), status=MessageStatus.SENT, cost=float(data.get('data', {}).get('Cost', 0)), metadata={ 'provider': 'unifonic', 'from': sender, 'to': to_num, 'currency': data.get('data', {}).get('CurrencyCode'), } ) else: error_msg = data.get('message', 'Unknown error') logger.error(f"Unifonic SMS failed: {error_msg}") return MessageResult( success=False, error_code='UNIFONIC_ERROR', error_message=error_msg, metadata={'provider': 'unifonic'} ) except Exception as exc: logger.error(f"Unifonic SMS failed: {exc}") return MessageResult( success=False, error_code='UNIFONIC_ERROR', error_message=str(exc), metadata={'provider': 'unifonic'} ) def get_message_status(self, message_id: str) -> MessageResult: """Get message status from Unifonic.""" try: import requests url = f"https://api.unifonic.com/rest/SMS/messages/{message_id}" params = { 'AppSid': self.config['app_sid'], } response = requests.get(url, params=params, timeout=30) response.raise_for_status() data = response.json() if data.get('success'): status_str = data.get('data', {}).get('Status', '').lower() status_map = { 'queued': MessageStatus.QUEUED, 'sent': MessageStatus.SENT, 'delivered': MessageStatus.DELIVERED, 'undelivered': MessageStatus.UNDELIVERED, 'failed': MessageStatus.FAILED, } return MessageResult( success=True, message_id=message_id, status=status_map.get(status_str, MessageStatus.QUEUED), metadata={ 'provider': 'unifonic', 'status_details': data.get('data', {}), } ) else: return MessageResult( success=False, error_code='UNIFONIC_ERROR', error_message=data.get('message', 'Unknown error'), metadata={'provider': 'unifonic'} ) except Exception as exc: logger.error(f"Unifonic status check failed: {exc}") return MessageResult( success=False, error_code='UNIFONIC_ERROR', error_message=str(exc), metadata={'provider': 'unifonic'} ) # ============================================================================ # Mock Provider (for testing) # ============================================================================ class MockSMSProvider(BaseSMSProvider): """ Mock SMS provider for testing. Always succeeds and logs messages instead of sending. """ def validate_config(self) -> None: """No configuration required for mock provider.""" pass def send_sms( self, to: str, message: str, from_number: Optional[str] = None ) -> MessageResult: """Mock send SMS.""" import uuid message_id = str(uuid.uuid4()) to_num = self.format_phone_number(to) logger.info(f"[MOCK SMS] To: {to_num}, Message: {message[:50]}...") return MessageResult( success=True, message_id=message_id, status=MessageStatus.DELIVERED, metadata={ 'provider': 'mock', 'to': to_num, 'message': message, 'timestamp': str(timezone.now()), } ) def get_message_status(self, message_id: str) -> MessageResult: """Mock get status.""" return MessageResult( success=True, message_id=message_id, status=MessageStatus.DELIVERED, metadata={'provider': 'mock'} ) class MockWhatsAppProvider(BaseWhatsAppProvider): """ Mock WhatsApp provider for testing. Always succeeds and logs messages instead of sending. """ def validate_config(self) -> None: """No configuration required for mock provider.""" pass def send_message( self, to: str, message: str, template_name: Optional[str] = None, template_params: Optional[List[str]] = None ) -> MessageResult: """Mock send WhatsApp message.""" import uuid message_id = str(uuid.uuid4()) logger.info(f"[MOCK WHATSAPP] To: {to}, Message: {message[:50]}...") if template_name: logger.info(f"[MOCK WHATSAPP] Template: {template_name}, Params: {template_params}") return MessageResult( success=True, message_id=message_id, status=MessageStatus.DELIVERED, metadata={ 'provider': 'mock_whatsapp', 'to': to, 'message': message, 'template': template_name, 'timestamp': str(timezone.now()), } ) def get_message_status(self, message_id: str) -> MessageResult: """Mock get status.""" return MessageResult( success=True, message_id=message_id, status=MessageStatus.READ, metadata={'provider': 'mock_whatsapp'} ) # ============================================================================ # Provider Factory # ============================================================================ class ProviderFactory: """ Factory for creating provider instances based on configuration. """ SMS_PROVIDERS = { 'twilio': TwilioSMSProvider, 'unifonic': UnifonicSMSProvider, 'mock': MockSMSProvider, } WHATSAPP_PROVIDERS = { 'twilio': TwilioWhatsAppProvider, 'mock': MockWhatsAppProvider, } @classmethod def create_sms_provider(cls, provider_name: str = None) -> BaseSMSProvider: """ Create an SMS provider instance. Args: provider_name: Provider name (twilio, unifonic, mock) If None, uses settings.SMS_PROVIDER Returns: SMS provider instance """ provider_name = provider_name or getattr(settings, 'SMS_PROVIDER', 'mock') provider_class = cls.SMS_PROVIDERS.get(provider_name.lower()) if not provider_class: raise ValueError(f"Unknown SMS provider: {provider_name}") config = getattr(settings, f'{provider_name.upper()}_SMS_CONFIG', {}) return provider_class(config) @classmethod def create_whatsapp_provider(cls, provider_name: str = None) -> BaseWhatsAppProvider: """ Create a WhatsApp provider instance. Args: provider_name: Provider name (twilio, mock) If None, uses settings.WHATSAPP_PROVIDER Returns: WhatsApp provider instance """ provider_name = provider_name or getattr(settings, 'WHATSAPP_PROVIDER', 'mock') provider_class = cls.WHATSAPP_PROVIDERS.get(provider_name.lower()) if not provider_class: raise ValueError(f"Unknown WhatsApp provider: {provider_name}") config = getattr(settings, f'{provider_name.upper()}_WHATSAPP_CONFIG', {}) return provider_class(config)