""" WhatsApp Cloud API Service Handles sending WhatsApp messages via Meta's API """ import os import httpx import re import logging from typing import Optional from datetime import datetime # Setup logging logger = logging.getLogger(__name__) 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" - "0541234567" -> "+972541234567" (Israeli format: 0 means +972) """ # Remove all non-digit characters except leading + cleaned = re.sub(r"[^\d+]", "", phone) # If it starts with +, it might already have country code if cleaned.startswith("+"): return cleaned # Handle Israeli format (starts with 0) if cleaned.startswith("0"): # Israeli number starting with 0: convert to +972 # 0541234567 -> 972541234567 -> +972541234567 return f"+972{cleaned[1:]}" # 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 all(c.isdigit() for c in cleaned): return f"+{cleaned}" # Default: just prepend + 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 @staticmethod def validate_template_params(params: list, expected_count: int = 8) -> bool: """ Validate template parameters Args: params: List of parameters to send expected_count: Expected number of parameters (default: 8) Wedding template = 1 header param + 7 body params = 8 total Returns: True if valid, otherwise raises WhatsAppError """ if not params: raise WhatsAppError(f"Parameters list is empty, expected {expected_count}") if len(params) != expected_count: raise WhatsAppError( f"Parameter count mismatch: got {len(params)}, expected {expected_count}. " f"Parameters: {params}" ) # Ensure all params are strings and non-empty for i, param in enumerate(params, 1): param_str = str(param).strip() if not param_str: raise WhatsAppError( f"Parameter #{i} is empty or None. " f"All {expected_count} parameters must have values. Parameters: {params}" ) return True 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 (must be 7 for wedding template) 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}") # Validate parameters if not parameters: raise WhatsAppError("Parameters list is required for template messages") self.validate_template_params(parameters, expected_count=8) # Convert all parameters to strings param_list = [str(p).strip() for p in parameters] # Build payload with correct Meta structure (includes "components" array) # Template structure: Header (1 param) + Body (7 params) # param_list[0] = guest_name (header) # param_list[1] = guest_name (body {{1}} - repeated from header) # param_list[2] = groom_name # param_list[3] = bride_name # param_list[4] = hall_name # param_list[5] = event_date # param_list[6] = event_time # param_list[7] = guest_link payload = { "messaging_product": "whatsapp", "to": to_e164, "type": "template", "template": { "name": template_name, "language": { "code": language_code }, "components": [ { "type": "header", "parameters": [ {"type": "text", "text": param_list[0]} # {{1}} - guest_name (header) ] }, { "type": "body", "parameters": [ {"type": "text", "text": param_list[1]}, # {{1}} - guest_name (repeated) {"type": "text", "text": param_list[2]}, # {{2}} - groom_name {"type": "text", "text": param_list[3]}, # {{3}} - bride_name {"type": "text", "text": param_list[4]}, # {{4}} - hall_name {"type": "text", "text": param_list[5]}, # {{5}} - event_date {"type": "text", "text": param_list[6]}, # {{6}} - event_time {"type": "text", "text": param_list[7]} # {{7}} - guest_link ] } ] } } # DEBUG: Log what we're sending (mask long URLs) masked_params = [] for p in param_list: if len(p) > 50 and p.startswith("http"): masked_params.append(f"{p[:30]}...{p[-10:]}") else: masked_params.append(p) logger.info( f"[WhatsApp] Sending template '{template_name}' " f"Language: {language_code}, " f"To: {to_e164}, " f"Params ({len(param_list)}): {masked_params}" ) url = f"{self.base_url}/{self.phone_number_id}/messages" # DEBUG: Print the full payload import json print("\n" + "=" * 80) print("[DEBUG] Full Payload Being Sent to Meta:") print("=" * 80) print(json.dumps(payload, indent=2, ensure_ascii=False)) print("=" * 80 + "\n") 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") logger.error(f"[WhatsApp] API Error ({response.status_code}): {error_msg}") raise WhatsAppError( f"WhatsApp API error ({response.status_code}): {error_msg}" ) result = response.json() message_id = result.get("messages", [{}])[0].get("id") logger.info(f"[WhatsApp] Message sent successfully! ID: {message_id}") return { "message_id": message_id, "status": "sent", "to": to_e164, "timestamp": datetime.utcnow().isoformat(), "type": "template", "template": template_name } except httpx.HTTPError as e: logger.error(f"[WhatsApp] HTTP Error: {str(e)}") raise WhatsAppError(f"HTTP request failed: {str(e)}") except Exception as e: logger.error(f"[WhatsApp] Unexpected error: {str(e)}") raise WhatsAppError(f"Failed to send WhatsApp template: {str(e)}") async def send_wedding_invitation( self, to_phone: str, guest_name: str, partner1_name: str, partner2_name: str, venue: str, event_date: str, # Should be formatted as DD/MM event_time: str, # Should be formatted as HH:mm guest_link: str, template_name: Optional[str] = None, language_code: Optional[str] = None ) -> dict: """ Send wedding invitation template message IMPORTANT: Always sends exactly 7 parameters in this order: {{1}} = contact_name (guest first name or fallback) {{2}} = groom_name (partner1) {{3}} = bride_name (partner2) {{4}} = hall_name (venue) {{5}} = event_date (DD/MM format) {{6}} = event_time (HH:mm format) {{7}} = guest_link (RSVP link) Args: to_phone: Recipient phone number guest_name: Guest first name partner1_name: First partner name (groom) partner2_name: Second partner name (bride) venue: Wedding venue/hall name event_date: Event date in DD/MM format event_time: Event time in HH:mm format guest_link: RSVP/guest link template_name: Meta template name (uses env var if not provided) language_code: Language code (uses env var if not provided) Returns: dict with message_id and status """ # Use environment defaults if not provided template_name = template_name or os.getenv("WHATSAPP_TEMPLATE_NAME", "wedding_invitation") language_code = language_code or os.getenv("WHATSAPP_LANGUAGE_CODE", "he") # Build 8 parameters with safe fallbacks # The template requires: 1 header param + 7 body params # Body {{1}} = guest_name (same as header, repeated) param_1_contact_name = (guest_name or "").strip() or "חבר" param_2_groom_name = (partner1_name or "").strip() or "החתן" param_3_bride_name = (partner2_name or "").strip() or "הכלה" param_4_hall_name = (venue or "").strip() or "האולם" param_5_event_date = (event_date or "").strip() or "—" param_6_event_time = (event_time or "").strip() or "—" param_7_guest_link = (guest_link or "").strip() or f"{os.getenv('FRONTEND_URL', 'http://localhost:5174')}/guest?event_id=unknown" parameters = [ param_1_contact_name, # header {{1}} param_1_contact_name, # body {{1}} - guest name repeated param_2_groom_name, # body {{2}} param_3_bride_name, # body {{3}} param_4_hall_name, # body {{4}} param_5_event_date, # body {{5}} param_6_event_time, # body {{6}} param_7_guest_link # body {{7}} ] logger.info( f"[WhatsApp Invitation] Building params for {to_phone}: " f"guest={param_1_contact_name}, groom={param_2_groom_name}, " f"bride={param_3_bride_name}, venue={param_4_hall_name}, " f"date={param_5_event_date}, time={param_6_event_time}" ) # Use standard template sending with validated parameters return await self.send_template_message( to_phone=to_phone, template_name=template_name, language_code=language_code, parameters=parameters ) 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