invy/backend/whatsapp.py
dvirlabs b7ad1218ce
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Add robust button placeholder detection and Meta delivery diagnostics
2026-05-14 16:15:32 +03:00

764 lines
29 KiB
Python

"""
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 with minimal SSL configuration for compatibility.
"""
# Create SSL context with certifi's CA bundle
ssl_context = ssl.create_default_context(cafile=certifi.where())
# Ensure TLS 1.2+ but allow server to negotiate the version
try:
ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
except AttributeError:
# Older Python versions - just use defaults
pass
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", "")
# More robust check for placeholder - handle different string encodings
has_placeholder = "{{1}}" in button_url or "{" in button_url and "1" in button_url
logger.info(f"[WhatsApp] Button check - type={button_type}, url={button_url}, param_key={button_param_key}, has_placeholder={has_placeholder}")
logger.debug(f"[WhatsApp] Button URL bytes: {button_url.encode('utf-8')}, param_key value: {button_param_key}")
# Check if URL has {{1}} placeholder for dynamic parameter
if has_placeholder 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:
if button_type == "URL" and button_url and not has_placeholder:
logger.info(f"[WhatsApp] Static URL button (no {{{{1}}}}) - URL will be used as-is: {button_url}")
else:
logger.warning(f"[WhatsApp] Button not sent - has_placeholder={has_placeholder}, 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}"
)
logger.info(
f"[WhatsApp] NOTE: HTTP 200 OK only means Meta accepted it.\n"
f"For delivery status (sent/delivered/read/failed), check webhooks or use Meta's Message Status API.\n"
f"Common reasons for silent failure:\n"
f" - Template '{meta_name}' not APPROVED in Meta Business Manager\n"
f" - Phone number {to_e164} not on whitelist (for test mode)\n"
f" - Business account in test/development mode\n"
f" - Template format doesn't match approved structure"
)
# 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