""" WhatsApp Cloud API Service Handles sending WhatsApp messages via Meta's API """ import os import httpx import certifi import ssl 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 creates proper SSL context for handshake. """ # Create SSL context with proper certificate verification ssl_context = ssl.create_default_context(cafile=certifi.where()) ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2 ssl_context.maximum_version = ssl.TLSVersion.TLSv1_3 ssl_context.options |= ssl.OP_NO_COMPRESSION return httpx.AsyncClient( verify=ssl_context, timeout=httpx.Timeout(30.0, connect=10.0), http2=False, 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", "") logger.info(f"[WhatsApp] Button check - type={button_type}, url={button_url}, param_key={button_param_key}, has_placeholder={'{{{{1}}}}' in button_url}") # 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() logger.info(f"[WhatsApp] Dynamic button - param_key={button_param_key}, param_value={param_value}") if param_value: logger.info(f"[WhatsApp] Sending button component with value: {param_value}") components.append({ "type": "button", "sub_type": "url", "index": "0", "parameters": [{"type": "text", "text": param_value}], }) else: logger.warning(f"[WhatsApp] Button parameter '{button_param_key}' is empty! params keys: {list(params.keys())}") else: logger.warning(f"[WhatsApp] Button conditions not met - url has placeholder: {'{{{{1}}}}' in button_url}, has param_key: {bool(button_param_key)}") # 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