732 lines
23 KiB
Python
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)
|