invy/backend/whatsapp.py

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