invy/backend/whatsapp.py

339 lines
13 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,
) -> 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