381 lines
14 KiB
Python
381 lines
14 KiB
Python
# ================================================================
|
|
# services/notification/app/services/whatsapp_service.py
|
|
# ================================================================
|
|
"""
|
|
WhatsApp service for sending notifications
|
|
Integrates with WhatsApp Business API via Twilio
|
|
"""
|
|
|
|
import structlog
|
|
import httpx
|
|
from typing import Optional, Dict, Any, List
|
|
import asyncio
|
|
from urllib.parse import quote
|
|
|
|
from app.core.config import settings
|
|
from shared.monitoring.metrics import MetricsCollector
|
|
|
|
logger = structlog.get_logger()
|
|
metrics = MetricsCollector("notification-service")
|
|
|
|
class WhatsAppService:
|
|
"""
|
|
WhatsApp service for sending notifications via Twilio WhatsApp API
|
|
Supports text messages and template messages
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.api_key = settings.WHATSAPP_API_KEY
|
|
self.base_url = settings.WHATSAPP_BASE_URL
|
|
self.from_number = settings.WHATSAPP_FROM_NUMBER
|
|
self.enabled = settings.ENABLE_WHATSAPP_NOTIFICATIONS
|
|
|
|
def _parse_api_credentials(self):
|
|
"""Parse API key into username and password for Twilio basic auth"""
|
|
if not self.api_key or ":" not in self.api_key:
|
|
raise ValueError("WhatsApp API key must be in format 'username:password'")
|
|
|
|
api_parts = self.api_key.split(":", 1)
|
|
if len(api_parts) != 2:
|
|
raise ValueError("Invalid WhatsApp API key format")
|
|
|
|
return api_parts[0], api_parts[1]
|
|
|
|
async def send_message(
|
|
self,
|
|
to_phone: str,
|
|
message: str,
|
|
template_name: Optional[str] = None,
|
|
template_params: Optional[List[str]] = None
|
|
) -> bool:
|
|
"""
|
|
Send WhatsApp message
|
|
|
|
Args:
|
|
to_phone: Recipient phone number (with country code)
|
|
message: Message text
|
|
template_name: WhatsApp template name (optional)
|
|
template_params: Template parameters (optional)
|
|
|
|
Returns:
|
|
bool: True if message was sent successfully
|
|
"""
|
|
try:
|
|
if not self.enabled:
|
|
logger.info("WhatsApp notifications disabled")
|
|
return True # Return success to avoid blocking workflow
|
|
|
|
if not self.api_key:
|
|
logger.error("WhatsApp API key not configured")
|
|
return False
|
|
|
|
# Validate phone number
|
|
phone = self._format_phone_number(to_phone)
|
|
if not phone:
|
|
logger.error("Invalid phone number", phone=to_phone)
|
|
return False
|
|
|
|
# Send template message if template specified
|
|
if template_name:
|
|
success = await self._send_template_message(
|
|
phone, template_name, template_params or []
|
|
)
|
|
else:
|
|
# Send regular text message
|
|
success = await self._send_text_message(phone, message)
|
|
|
|
if success:
|
|
logger.info("WhatsApp message sent successfully",
|
|
to=phone,
|
|
template=template_name)
|
|
|
|
# Record success metrics
|
|
metrics.increment_counter("whatsapp_sent_total", labels={"status": "success"})
|
|
else:
|
|
# Record failure metrics
|
|
metrics.increment_counter("whatsapp_sent_total", labels={"status": "failed"})
|
|
|
|
return success
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to send WhatsApp message",
|
|
to=to_phone,
|
|
error=str(e))
|
|
|
|
# Record failure metrics
|
|
metrics.increment_counter("whatsapp_sent_total", labels={"status": "failed"})
|
|
|
|
return False
|
|
|
|
async def send_bulk_messages(
|
|
self,
|
|
recipients: List[str],
|
|
message: str,
|
|
template_name: Optional[str] = None,
|
|
batch_size: int = 20
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Send bulk WhatsApp messages with rate limiting
|
|
|
|
Args:
|
|
recipients: List of recipient phone numbers
|
|
message: Message text
|
|
template_name: WhatsApp template name (optional)
|
|
batch_size: Number of messages to send per batch
|
|
|
|
Returns:
|
|
Dict containing success/failure counts
|
|
"""
|
|
results = {
|
|
"total": len(recipients),
|
|
"sent": 0,
|
|
"failed": 0,
|
|
"errors": []
|
|
}
|
|
|
|
try:
|
|
# Process in batches to respect WhatsApp rate limits
|
|
for i in range(0, len(recipients), batch_size):
|
|
batch = recipients[i:i + batch_size]
|
|
|
|
# Send messages concurrently within batch
|
|
tasks = [
|
|
self.send_message(
|
|
to_phone=phone,
|
|
message=message,
|
|
template_name=template_name
|
|
)
|
|
for phone in batch
|
|
]
|
|
|
|
batch_results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
for phone, result in zip(batch, batch_results):
|
|
if isinstance(result, Exception):
|
|
results["failed"] += 1
|
|
results["errors"].append({"phone": phone, "error": str(result)})
|
|
elif result:
|
|
results["sent"] += 1
|
|
else:
|
|
results["failed"] += 1
|
|
results["errors"].append({"phone": phone, "error": "Unknown error"})
|
|
|
|
# Rate limiting delay between batches (WhatsApp has strict limits)
|
|
if i + batch_size < len(recipients):
|
|
await asyncio.sleep(2.0) # 2 second delay between batches
|
|
|
|
logger.info("Bulk WhatsApp completed",
|
|
total=results["total"],
|
|
sent=results["sent"],
|
|
failed=results["failed"])
|
|
|
|
return results
|
|
|
|
except Exception as e:
|
|
logger.error("Bulk WhatsApp failed", error=str(e))
|
|
results["errors"].append({"error": str(e)})
|
|
return results
|
|
|
|
async def health_check(self) -> bool:
|
|
"""
|
|
Check if WhatsApp service is healthy
|
|
|
|
Returns:
|
|
bool: True if service is healthy
|
|
"""
|
|
try:
|
|
if not self.enabled:
|
|
return True # Service is "healthy" if disabled
|
|
|
|
if not self.api_key:
|
|
logger.warning("WhatsApp API key not configured")
|
|
return False
|
|
|
|
# Test API connectivity with a simple request
|
|
# Parse API key (expected format: username:password for Twilio basic auth)
|
|
if ":" not in self.api_key:
|
|
logger.error("WhatsApp API key must be in format 'username:password'")
|
|
return False
|
|
|
|
api_parts = self.api_key.split(":", 1) # Split on first : only
|
|
if len(api_parts) != 2:
|
|
logger.error("Invalid WhatsApp API key format")
|
|
return False
|
|
|
|
username, password = api_parts
|
|
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
response = await client.get(
|
|
f"{self.base_url}/v1/Account", # Twilio account info endpoint
|
|
auth=(username, password)
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
logger.info("WhatsApp service health check passed")
|
|
return True
|
|
else:
|
|
logger.error("WhatsApp service health check failed",
|
|
status_code=response.status_code)
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error("WhatsApp service health check failed", error=str(e))
|
|
return False
|
|
|
|
# ================================================================
|
|
# PRIVATE HELPER METHODS
|
|
# ================================================================
|
|
|
|
async def _send_text_message(self, to_phone: str, message: str) -> bool:
|
|
"""Send regular text message via Twilio"""
|
|
try:
|
|
# Parse API credentials
|
|
try:
|
|
username, password = self._parse_api_credentials()
|
|
except ValueError as e:
|
|
logger.error(f"WhatsApp API key configuration error: {e}")
|
|
return False
|
|
|
|
# Prepare request data
|
|
data = {
|
|
"From": f"whatsapp:{self.from_number}",
|
|
"To": f"whatsapp:{to_phone}",
|
|
"Body": message
|
|
}
|
|
|
|
# Send via Twilio API
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
response = await client.post(
|
|
f"{self.base_url}/2010-04-01/Accounts/{username}/Messages.json",
|
|
data=data,
|
|
auth=(username, password)
|
|
)
|
|
|
|
if response.status_code == 201:
|
|
response_data = response.json()
|
|
logger.debug("WhatsApp message sent",
|
|
message_sid=response_data.get("sid"),
|
|
status=response_data.get("status"))
|
|
return True
|
|
else:
|
|
logger.error("WhatsApp API error",
|
|
status_code=response.status_code,
|
|
response=response.text)
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to send WhatsApp text message", error=str(e))
|
|
return False
|
|
|
|
async def _send_template_message(
|
|
self,
|
|
to_phone: str,
|
|
template_name: str,
|
|
parameters: List[str]
|
|
) -> bool:
|
|
"""Send WhatsApp template message via Twilio"""
|
|
try:
|
|
# Parse API credentials
|
|
try:
|
|
username, password = self._parse_api_credentials()
|
|
except ValueError as e:
|
|
logger.error(f"WhatsApp API key configuration error: {e}")
|
|
return False
|
|
|
|
# Prepare template data
|
|
content_variables = {str(i+1): param for i, param in enumerate(parameters)}
|
|
|
|
data = {
|
|
"From": f"whatsapp:{self.from_number}",
|
|
"To": f"whatsapp:{to_phone}",
|
|
"ContentSid": template_name, # Template SID in Twilio
|
|
"ContentVariables": str(content_variables) if content_variables else "{}"
|
|
}
|
|
|
|
# Send via Twilio API
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
response = await client.post(
|
|
f"{self.base_url}/2010-04-01/Accounts/{username}/Messages.json",
|
|
data=data,
|
|
auth=(username, password)
|
|
)
|
|
|
|
if response.status_code == 201:
|
|
response_data = response.json()
|
|
logger.debug("WhatsApp template message sent",
|
|
message_sid=response_data.get("sid"),
|
|
template=template_name)
|
|
return True
|
|
else:
|
|
logger.error("WhatsApp template API error",
|
|
status_code=response.status_code,
|
|
response=response.text,
|
|
template=template_name)
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to send WhatsApp template message",
|
|
template=template_name,
|
|
error=str(e))
|
|
return False
|
|
|
|
def _format_phone_number(self, phone: str) -> Optional[str]:
|
|
"""
|
|
Format phone number for WhatsApp (Spanish format)
|
|
|
|
Args:
|
|
phone: Input phone number
|
|
|
|
Returns:
|
|
Formatted phone number or None if invalid
|
|
"""
|
|
if not phone:
|
|
return None
|
|
|
|
# Remove spaces, dashes, and other non-digit characters
|
|
clean_phone = "".join(filter(str.isdigit, phone.replace("+", "")))
|
|
|
|
# Handle Spanish phone numbers
|
|
if clean_phone.startswith("34"):
|
|
# Already has country code
|
|
return f"+{clean_phone}"
|
|
elif clean_phone.startswith(("6", "7", "9")) and len(clean_phone) == 9:
|
|
# Spanish mobile/landline without country code
|
|
return f"+34{clean_phone}"
|
|
elif len(clean_phone) == 9 and clean_phone[0] in "679":
|
|
# Likely Spanish mobile
|
|
return f"+34{clean_phone}"
|
|
else:
|
|
logger.warning("Unrecognized phone format", phone=phone)
|
|
return None
|
|
|
|
async def _get_message_status(self, message_sid: str) -> Optional[str]:
|
|
"""Get message delivery status from Twilio"""
|
|
try:
|
|
# Parse API credentials
|
|
try:
|
|
username, password = self._parse_api_credentials()
|
|
except ValueError as e:
|
|
logger.error(f"WhatsApp API key configuration error: {e}")
|
|
return None
|
|
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
response = await client.get(
|
|
f"{self.base_url}/2010-04-01/Accounts/{username}/Messages/{message_sid}.json",
|
|
auth=(username, password)
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
return data.get("status")
|
|
else:
|
|
logger.error("Failed to get message status",
|
|
message_sid=message_sid,
|
|
status_code=response.status_code)
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to check message status",
|
|
message_sid=message_sid,
|
|
error=str(e))
|
|
return None |