invy/backend/whatsapp.py

587 lines
21 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
)
async def send_by_template_key(
self,
template_key: str,
to_phone: str,
params: dict,
) -> 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(template_key)
meta_name = tpl["meta_name"]
language_code = tpl.get("language_code", "he")
header_values, body_values = build_params_list(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 = []
if header_values:
components.append({
"type": "header",
"parameters": [{"type": "text", "text": str(v)} for v in header_values],
})
if body_values:
components.append({
"type": "body",
"parameters": [{"type": "text", "text": str(v)} for v in body_values],
})
# Handle url_button component if defined in template
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,
},
}
import json
logger.info(
f"[WhatsApp] send_by_template_key '{template_key}' → meta='{meta_name}' "
f"lang={language_code} to={to_e164} "
f"header_params={header_values} body_params={body_values}"
)
logger.debug(
"[WhatsApp] payload: %s",
json.dumps(payload, ensure_ascii=False),
)
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(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 via template key. ID: {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 e:
raise WhatsAppError(f"HTTP request failed: {str(e)}")
except WhatsAppError:
raise
except Exception as e:
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() -> WhatsAppService:
"""Get or create WhatsApp service singleton"""
global _whatsapp_service
if _whatsapp_service is None:
_whatsapp_service = WhatsAppService()
return _whatsapp_service