470 lines
17 KiB
Python
470 lines
17 KiB
Python
"""
|
|
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
|