agdar/integrations/sms_providers.py
2025-11-02 14:35:35 +03:00

732 lines
23 KiB
Python

"""
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)