363 lines
14 KiB
Python
363 lines
14 KiB
Python
"""
|
|
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,
|
|
button_values: list = None,
|
|
) -> 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],
|
|
})
|
|
# button_values is a list of (sub_type, index, payload) tuples
|
|
# e.g. [("url", 0, "abc123token")]
|
|
if button_values:
|
|
for sub_type, btn_index, payload in button_values:
|
|
components.append({
|
|
"type": "button",
|
|
"sub_type": sub_type,
|
|
"index": str(btn_index),
|
|
"parameters": [{"type": "text", "text": payload}],
|
|
})
|
|
|
|
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 button_components=%d",
|
|
meta_name, language_code, to_e164, len(header_values), len(body_values),
|
|
len(button_values) if button_values else 0,
|
|
)
|
|
logger.debug(
|
|
"[WhatsApp] params header=%s body=%s buttons=%s",
|
|
[_mask(v) for v in header_values],
|
|
[_mask(v) for v in body_values],
|
|
button_values or [],
|
|
)
|
|
|
|
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"
|
|
)
|
|
|
|
# Build button components when the template has a url_button declaration
|
|
button_values = None
|
|
url_btn_cfg = tpl.get("url_button")
|
|
if url_btn_cfg and url_btn_cfg.get("enabled"):
|
|
param_key = url_btn_cfg.get("param_key", "rsvp_token")
|
|
btn_index = url_btn_cfg.get("index", 0)
|
|
btn_payload = params.get(param_key, "")
|
|
if btn_payload:
|
|
button_values = [("url", btn_index, str(btn_payload))]
|
|
|
|
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,
|
|
button_values=button_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
|