""" WhatsApp Cloud API Service Handles sending WhatsApp messages via Meta's API """ import os import httpx import re from typing import Optional from datetime import datetime class WhatsAppError(Exception): """Custom exception for WhatsApp API errors""" pass class WhatsAppService: """Service for sending WhatsApp messages via Meta API""" def __init__(self): self.access_token = os.getenv("WHATSAPP_ACCESS_TOKEN") self.phone_number_id = os.getenv("WHATSAPP_PHONE_NUMBER_ID") self.api_version = os.getenv("WHATSAPP_API_VERSION", "v20.0") self.verify_token = os.getenv("WHATSAPP_VERIFY_TOKEN", "") if not self.access_token or not self.phone_number_id: raise WhatsAppError( "WHATSAPP_ACCESS_TOKEN and WHATSAPP_PHONE_NUMBER_ID must be set in environment" ) self.base_url = f"https://graph.facebook.com/{self.api_version}" self.headers = { "Authorization": f"Bearer {self.access_token}", "Content-Type": "application/json" } @staticmethod def normalize_phone_to_e164(phone: str) -> str: """ Normalize phone number to E.164 format E.164 format: +[country code][number] with no spaces or punctuation Examples: - "+1-555-123-4567" -> "+15551234567" - "555-123-4567" -> "+15551234567" (assumes US) - "+972541234567" -> "+972541234567" """ # Remove all non-digit characters except leading + cleaned = re.sub(r"[^\d+]", "", phone).lstrip("0") # If it starts with +, assume it's already in correct format or close if cleaned.startswith("+"): return cleaned # If it's a US number (10 digits), prepend +1 if len(cleaned) == 10 and all(c.isdigit() for c in cleaned): return f"+1{cleaned}" # If it's already got country code but no +, add it if len(cleaned) >= 11 and cleaned[0] != "+": return f"+{cleaned}" return f"+{cleaned}" def validate_phone(self, phone: str) -> bool: """ Validate that phone number is valid E.164 format """ try: e164 = self.normalize_phone_to_e164(phone) # E.164 should start with + and be 10-15 digits total return e164.startswith("+") and 10 <= len(e164) <= 15 and all(c.isdigit() for c in e164[1:]) except Exception: return False async def send_text_message( self, to_phone: str, message_text: str, context_message_id: Optional[str] = None ) -> dict: """ Send a text message via WhatsApp Cloud API Args: to_phone: Recipient phone number (will be normalized to E.164) message_text: Message body context_message_id: Optional message ID to reply to Returns: dict with message_id and status Raises: WhatsAppError: If message fails to send """ # Normalize phone number to_e164 = self.normalize_phone_to_e164(to_phone) if not self.validate_phone(to_e164): raise WhatsAppError(f"Invalid phone number: {to_phone}") # Build payload payload = { "messaging_product": "whatsapp", "to": to_e164, "type": "text", "text": { "body": message_text } } # Add context if provided (for replies) if context_message_id: payload["context"] = { "message_id": context_message_id } # Send to API url = f"{self.base_url}/{self.phone_number_id}/messages" try: async with httpx.AsyncClient() as client: response = await client.post( url, json=payload, headers=self.headers, timeout=30.0 ) if response.status_code not in (200, 201): error_data = response.json() error_msg = error_data.get("error", {}).get("message", "Unknown error") raise WhatsAppError( f"WhatsApp API error ({response.status_code}): {error_msg}" ) result = response.json() return { "message_id": result.get("messages", [{}])[0].get("id"), "status": "sent", "to": to_e164, "timestamp": datetime.utcnow().isoformat(), "type": "text" } except httpx.HTTPError as e: raise WhatsAppError(f"HTTP request failed: {str(e)}") except Exception as e: raise WhatsAppError(f"Failed to send WhatsApp message: {str(e)}") async def send_template_message( self, to_phone: str, template_name: str, language_code: str = "en", parameters: Optional[list] = None ) -> dict: """ Send a pre-approved template message via WhatsApp Cloud API Args: to_phone: Recipient phone number template_name: Template name (must be approved by Meta) language_code: Language code (default: en) parameters: List of parameter values for template placeholders Returns: dict with message_id and status """ to_e164 = self.normalize_phone_to_e164(to_phone) if not self.validate_phone(to_e164): raise WhatsAppError(f"Invalid phone number: {to_phone}") payload = { "messaging_product": "whatsapp", "to": to_e164, "type": "template", "template": { "name": template_name, "language": { "code": language_code } } } if parameters: payload["template"]["parameters"] = { "body": { "parameters": [{"type": "text", "text": str(p)} for p in parameters] } } url = f"{self.base_url}/{self.phone_number_id}/messages" try: async with httpx.AsyncClient() as client: response = await client.post( url, json=payload, headers=self.headers, timeout=30.0 ) if response.status_code not in (200, 201): error_data = response.json() error_msg = error_data.get("error", {}).get("message", "Unknown error") raise WhatsAppError( f"WhatsApp API error ({response.status_code}): {error_msg}" ) result = response.json() return { "message_id": result.get("messages", [{}])[0].get("id"), "status": "sent", "to": to_e164, "timestamp": datetime.utcnow().isoformat(), "type": "template", "template": template_name } except httpx.HTTPError as e: raise WhatsAppError(f"HTTP request failed: {str(e)}") except Exception as e: raise WhatsAppError(f"Failed to send WhatsApp template: {str(e)}") def handle_webhook_verification(self, challenge: str) -> str: """ Handle webhook verification challenge from Meta Args: challenge: The challenge string from Meta Returns: The challenge string to echo back """ return challenge def verify_webhook_signature(self, body: str, signature: str) -> bool: """ Verify webhook signature from Meta Args: body: Raw request body signature: x-hub-signature header value Returns: True if signature is valid """ import hmac import hashlib if not self.verify_token: return False # Extract signature from header (format: sha1=...) try: hash_algo, hash_value = signature.split("=") except ValueError: return False # Compute expected signature expected_signature = hmac.new( self.verify_token.encode(), body.encode(), hashlib.sha1 ).hexdigest() # Constant-time comparison return hmac.compare_digest(hash_value, expected_signature) # Singleton instance _whatsapp_service: Optional[WhatsAppService] = None def get_whatsapp_service() -> WhatsAppService: """Get or create WhatsApp service singleton""" global _whatsapp_service if _whatsapp_service is None: _whatsapp_service = WhatsAppService() return _whatsapp_service