283 lines
9.0 KiB
Python
283 lines
9.0 KiB
Python
"""
|
|
WhatsApp Cloud API Service
|
|
Handles sending WhatsApp messages via Meta's API
|
|
"""
|
|
import os
|
|
import httpx
|
|
import re
|
|
from typing import Optional
|
|
from datetime import datetime
|
|
|
|
|
|
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"
|
|
"""
|
|
# Remove all non-digit characters except leading +
|
|
cleaned = re.sub(r"[^\d+]", "", phone).lstrip("0")
|
|
|
|
# If it starts with +, assume it's already in correct format or close
|
|
if cleaned.startswith("+"):
|
|
return cleaned
|
|
|
|
# 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 cleaned[0] != "+":
|
|
return f"+{cleaned}"
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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}")
|
|
|
|
payload = {
|
|
"messaging_product": "whatsapp",
|
|
"to": to_e164,
|
|
"type": "template",
|
|
"template": {
|
|
"name": template_name,
|
|
"language": {
|
|
"code": language_code
|
|
}
|
|
}
|
|
}
|
|
|
|
if parameters:
|
|
payload["template"]["parameters"] = {
|
|
"body": {
|
|
"parameters": [{"type": "text", "text": str(p)} for p in parameters]
|
|
}
|
|
}
|
|
|
|
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": "template",
|
|
"template": template_name
|
|
}
|
|
|
|
except httpx.HTTPError as e:
|
|
raise WhatsAppError(f"HTTP request failed: {str(e)}")
|
|
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
|