""" WhatsApp Cloud API Service Handles sending WhatsApp messages via Meta's API """ import os import httpx import certifi import re import logging from typing import Optional from datetime import datetime # Setup logging logger = logging.getLogger(__name__) async def create_http_client() -> httpx.AsyncClient: """ Create an httpx client with proper certificate verification. Uses certifi for CA bundle and explicit TLS 1.2+ negotiation. """ import ssl # Create a default SSL context that prefers TLS 1.2 and higher ssl_context = ssl.create_default_context(cafile=certifi.where()) ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2 ssl_context.check_hostname = True return httpx.AsyncClient( verify=ssl_context, timeout=httpx.Timeout(30.0, connect=10.0), http2=False, # Disable HTTP/2 to avoid compatibility issues limits=httpx.Limits(max_keepalive_connections=5, max_connections=10) ) class WhatsAppError(Exception): """Custom exception for WhatsApp API errors""" pass class WhatsAppService: """Service for sending WhatsApp messages via Meta API""" def __init__(self, db=None): 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", "") self.db = db # Database session for template lookups 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 await create_http_client() 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 await create_http_client() 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 ) async def send_by_template_key( self, template_key: str, to_phone: str, params: dict, event_id: Optional[str] = None, guest_id: Optional[str] = None, ) -> dict: """ Send a WhatsApp template message using the template registry. Looks up *template_key* in whatsapp_templates.py, resolves header and body parameter lists (with fallbacks) from *params*, then builds and sends the Meta API payload dynamically. Args: template_key: Registry key (e.g. "wedding_invitation"). to_phone: Recipient phone number (normalized to E.164). params: Dict of {param_key: value} for all placeholders. Returns: dict with message_id and status. """ from whatsapp_templates import get_template, build_params_list tpl = get_template(self.db, template_key) meta_name = tpl["meta_name"] language_code = tpl.get("language_code", "he") header_type = tpl.get("header_type", "TEXT") header_handle = tpl.get("header_handle", "") header_handle_key = tpl.get("header_handle_key", "") # For dynamic image/video/doc URLs button_type = tpl.get("button_type", "") button_url = tpl.get("button_url", "") # If header_handle_key is specified, get the dynamic URL from params if header_handle_key and not header_handle: header_handle = str(params.get(header_handle_key, "")).strip() header_values, body_values = build_params_list(self.db, template_key, params) to_e164 = self.normalize_phone_to_e164(to_phone) if not self.validate_phone(to_e164): raise WhatsAppError(f"Invalid phone number: {to_phone}") components = [] # Build header component based on type if header_type == "IMAGE" and header_handle: components.append({ "type": "header", "parameters": [{ "type": "image", "image": {"link": header_handle} }], }) elif header_type == "VIDEO" and header_handle: components.append({ "type": "header", "parameters": [{ "type": "video", "video": {"link": header_handle} }], }) elif header_type == "DOCUMENT" and header_handle: components.append({ "type": "header", "parameters": [{ "type": "document", "document": {"link": header_handle} }], }) elif header_type == "TEXT" and header_values: components.append({ "type": "header", "parameters": [{"type": "text", "text": str(v)} for v in header_values], }) # Build body component if body_values: components.append({ "type": "body", "parameters": [{"type": "text", "text": str(v)} for v in body_values], }) # Handle URL button with dynamic parameters # Meta WhatsApp supports dynamic URL suffixes like: https://example.com/guest/{{1}} # where {{1}} is replaced by a dynamic parameter if button_type == "URL" and button_url: button_param_key = tpl.get("button_param_key", "") # Check if URL has {{1}} placeholder for dynamic parameter if "{{1}}" in button_url and button_param_key: # Dynamic URL button - need to send the parameter value param_value = str(params.get(button_param_key, "")).strip() if param_value: components.append({ "type": "button", "sub_type": "url", "index": "0", "parameters": [{"type": "text", "text": param_value}], }) # else: Static URL button - no parameters needed in the API call # Handle url_button component if defined in template (legacy dynamic buttons) url_btn = tpl.get("url_button", {}) if url_btn and url_btn.get("enabled"): param_key = url_btn.get("param_key", "event_id") btn_value = str(params.get(param_key, "")).strip() if btn_value: components.append({ "type": "button", "sub_type": "url", "index": str(url_btn.get("button_index", 0)), "parameters": [{"type": "text", "text": btn_value}], }) payload = { "messaging_product": "whatsapp", "to": to_e164, "type": "template", "template": { "name": meta_name, "language": {"code": language_code}, "components": components, }, } # Validate payload before sending if not components: logger.warning( f"[WhatsApp] Warning: No components being sent. " f"Header type: {header_type}, body_values: {body_values}" ) if not body_values: logger.warning( f"[WhatsApp] Warning: No body parameters. Template expects {len(tpl.get('body_params', []))} params." ) import json logger.info( f"[WhatsApp] send_by_template_key '{template_key}' → meta='{meta_name}' " f"lang={language_code} to={to_e164} header_type={header_type} " f"header_params={header_values} body_params={body_values}" ) logger.info( f"[WhatsApp] Complete payload before sending:\n{json.dumps(payload, indent=2, ensure_ascii=False)}" ) logger.debug( "[WhatsApp] payload: %s", json.dumps(payload, ensure_ascii=False), ) url = f"{self.base_url}/{self.phone_number_id}/messages" try: async with await create_http_client() as client: response = await client.post( url, json=payload, headers=self.headers, timeout=30.0, ) result = response.json() # Check for HTTP errors if response.status_code not in (200, 201): error_msg = result.get("error", {}).get("message", "Unknown error") error_code = result.get("error", {}).get("code", "UNKNOWN") logger.error( f"[WhatsApp] API error ({response.status_code}) - Code: {error_code} - Message: {error_msg}\n" f"Full response: {result}" ) raise WhatsAppError( f"WhatsApp API error ({response.status_code}): {error_msg}" ) # Check for warnings or errors in successful response if "error" in result: error_msg = result["error"].get("message", "Unknown error") logger.error(f"[WhatsApp] Error in response: {error_msg}\nFull response: {result}") raise WhatsAppError(f"WhatsApp message rejected: {error_msg}") # Validate message was actually created messages = result.get("messages", []) if not messages: logger.error(f"[WhatsApp] No message ID in response: {result}") raise WhatsAppError("No message ID returned from WhatsApp API") message_id = messages[0].get("id") if not message_id: logger.error(f"[WhatsApp] Message ID missing from response: {result}") raise WhatsAppError("Message ID missing from WhatsApp API response") logger.info( f"[WhatsApp] Message sent successfully! ID: {message_id}\n" f"Template: {meta_name}, To: {to_e164}, Status: {response.status_code}" ) # Save message to database for status tracking if self.db: try: from models import WhatsAppMessage from uuid import UUID msg = WhatsAppMessage( wamid=message_id, event_id=UUID(event_id) if event_id else None, guest_id=UUID(guest_id) if guest_id else None, to_phone=to_e164, template_key=template_key, template_name=meta_name, status="sent", ) self.db.add(msg) self.db.commit() logger.info(f"[WhatsApp] ✓ Saved message {message_id} to database") except Exception as db_error: logger.warning( f"[WhatsApp] Failed to save message to database: {db_error}" ) # Don't fail the whole send operation if DB save fails if self.db: self.db.rollback() return { "message_id": message_id, "status": "sent", "to": to_e164, "timestamp": datetime.utcnow().isoformat(), "type": "template", "template": meta_name, } except httpx.HTTPError as e: logger.error(f"[WhatsApp] HTTP request failed: {str(e)}") raise WhatsAppError(f"HTTP request failed: {str(e)}") except WhatsAppError: raise except Exception as e: logger.error(f"[WhatsApp] Unexpected error: {str(e)}", exc_info=True) 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(db=None) -> WhatsAppService: """Get or create WhatsApp service singleton. Pass db if you need template lookups.""" global _whatsapp_service if _whatsapp_service is None: _whatsapp_service = WhatsAppService(db=db) # Update db if provided (for template lookups) if db is not None: _whatsapp_service.db = db return _whatsapp_service