""" WhatsApp Cloud API Service Handles sending WhatsApp messages via Meta's Cloud API. Template system: - All approved templates are declared in whatsapp_templates.py - Use send_by_template_key() to send any registered template - send_wedding_invitation() remains as a backward-compatible wrapper """ import os import httpx import re import logging from typing import Optional from datetime import datetime from whatsapp_templates import get_template, build_params_list, list_templates_for_frontend # 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 # ── Low-level: raw template sender ───────────────────────────────────── async def _send_raw_template( self, to_e164: str, meta_name: str, language_code: str, header_values: list, body_values: list, ) -> dict: """Build and POST a template message to Meta.""" components = [] if header_values: components.append({ "type": "header", "parameters": [{"type": "text", "text": v} for v in header_values], }) if body_values: components.append({ "type": "body", "parameters": [{"type": "text", "text": v} for v in body_values], }) payload = { "messaging_product": "whatsapp", "to": to_e164, "type": "template", "template": { "name": meta_name, "language": {"code": language_code}, "components": components, }, } def _mask(v: str) -> str: return f"{v[:30]}...{v[-10:]}" if len(v) > 50 and v.startswith("http") else v logger.info( "[WhatsApp] template=%s lang=%s to=%s header_params=%d body_params=%d", meta_name, language_code, to_e164, len(header_values), len(body_values), ) logger.debug( "[WhatsApp] params header=%s body=%s", [_mask(v) for v in header_values], [_mask(v) for v in body_values], ) 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") logger.error("[WhatsApp] API Error %d: %s", 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("[WhatsApp] Sent OK message_id=%s", message_id) return { "message_id": message_id, "status": "sent", "to": to_e164, "timestamp": datetime.utcnow().isoformat(), "type": "template", "template": meta_name, } except httpx.HTTPError as exc: logger.error("[WhatsApp] HTTP error: %s", exc) raise WhatsAppError(f"HTTP request failed: {exc}") from exc except WhatsAppError: raise except Exception as exc: logger.error("[WhatsApp] Unexpected error: %s", exc) raise WhatsAppError(f"Failed to send WhatsApp template: {exc}") from exc # ── Primary API: registry-driven ───────────────────────────────────────── async def send_by_template_key( self, template_key: str, to_phone: str, params: dict, ) -> dict: """ Send any registered template by its registry key. Args: template_key : key in TEMPLATES registry, e.g. "wedding_invitation" to_phone : recipient phone (normalized to E.164 automatically) params : dict {param_key: value}; missing keys use registry fallbacks Returns: dict with message_id, status, to, timestamp, template """ to_e164 = self.normalize_phone_to_e164(to_phone) if not self.validate_phone(to_e164): raise WhatsAppError(f"Invalid phone number: {to_phone}") tpl = get_template(template_key) header_values, body_values = build_params_list(template_key, params) expected = len(tpl["header_params"]) + len(tpl["body_params"]) actual = len(header_values) + len(body_values) if actual != expected: raise WhatsAppError( f"Template '{template_key}': expected {expected} params, got {actual}" ) for i, v in enumerate(header_values + body_values, 1): if not v.strip(): raise WhatsAppError( f"Template '{template_key}': param #{i} is empty after fallbacks" ) return await self._send_raw_template( to_e164=to_e164, meta_name=tpl["meta_name"], language_code=tpl["language_code"], header_values=header_values, body_values=body_values, ) # ── Plain text ──────────────────────────────────────────────────────────── async def send_text_message( self, to_phone: str, message_text: str, context_message_id: Optional[str] = None, ) -> dict: """Send a plain text message.""" 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": "text", "text": {"body": message_text}, } if context_message_id: payload["context"] = {"message_id": context_message_id} 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 exc: raise WhatsAppError(f"HTTP request failed: {exc}") from exc except WhatsAppError: raise except Exception as exc: raise WhatsAppError(f"Failed to send WhatsApp message: {exc}") from exc # ── Backward-compat wedding invitation ─────────────────────────────────── async def send_wedding_invitation( self, to_phone: str, guest_name: str, partner1_name: str, partner2_name: str, venue: str, event_date: str, event_time: str, guest_link: str, template_name: Optional[str] = None, language_code: Optional[str] = None, template_key: Optional[str] = None, ) -> dict: """ Send a wedding invitation using the template registry. template_key takes precedence; falls back to env WHATSAPP_TEMPLATE_KEY or "wedding_invitation". """ key = ( template_key or os.getenv("WHATSAPP_TEMPLATE_KEY", "wedding_invitation") ) params = { "contact_name": (guest_name or "").strip(), "groom_name": (partner1_name or "").strip(), "bride_name": (partner2_name or "").strip(), "venue": (venue or "").strip(), "event_date": (event_date or "").strip(), "event_time": (event_time or "").strip(), "guest_link": (guest_link or "").strip(), } return await self.send_by_template_key(key, to_phone, params) # ── Webhook helpers ──────────────────────────────────────────────────── def handle_webhook_verification(self, challenge: str) -> str: return challenge def verify_webhook_signature(self, body: str, signature: str) -> bool: import hmac import hashlib if not self.verify_token: return False try: _, hash_value = signature.split("=") except ValueError: return False expected = hmac.new( self.verify_token.encode(), body.encode(), hashlib.sha1 ).hexdigest() return hmac.compare_digest(hash_value, expected) # ── Singleton ───────────────────────────────────────────────────────────────── _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 def reset_whatsapp_service() -> None: """Force recreation of the singleton (useful after env-var changes in tests)""" global _whatsapp_service _whatsapp_service = None