Add whatsapp feature
This commit is contained in:
555
services/notification/app/services/whatsapp_business_service.py
Normal file
555
services/notification/app/services/whatsapp_business_service.py
Normal file
@@ -0,0 +1,555 @@
|
||||
# ================================================================
|
||||
# services/notification/app/services/whatsapp_business_service.py
|
||||
# ================================================================
|
||||
"""
|
||||
WhatsApp Business Cloud API Service
|
||||
Direct integration with Meta's WhatsApp Business Cloud API
|
||||
Supports template messages for proactive notifications
|
||||
"""
|
||||
|
||||
import structlog
|
||||
import httpx
|
||||
from typing import Optional, Dict, Any, List
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from app.core.config import settings
|
||||
from app.schemas.whatsapp import (
|
||||
SendWhatsAppMessageRequest,
|
||||
SendWhatsAppMessageResponse,
|
||||
TemplateComponent,
|
||||
WhatsAppMessageStatus,
|
||||
WhatsAppMessageType
|
||||
)
|
||||
from app.repositories.whatsapp_message_repository import (
|
||||
WhatsAppMessageRepository,
|
||||
WhatsAppTemplateRepository
|
||||
)
|
||||
from app.models.whatsapp_messages import WhatsAppMessage
|
||||
from shared.monitoring.metrics import MetricsCollector
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = structlog.get_logger()
|
||||
metrics = MetricsCollector("notification-service")
|
||||
|
||||
|
||||
class WhatsAppBusinessService:
|
||||
"""
|
||||
WhatsApp Business Cloud API Service
|
||||
Direct integration with Meta/Facebook WhatsApp Business Cloud API
|
||||
"""
|
||||
|
||||
def __init__(self, session: Optional[AsyncSession] = None, tenant_client=None):
|
||||
# Global configuration (fallback)
|
||||
self.global_access_token = settings.WHATSAPP_ACCESS_TOKEN
|
||||
self.global_phone_number_id = settings.WHATSAPP_PHONE_NUMBER_ID
|
||||
self.global_business_account_id = settings.WHATSAPP_BUSINESS_ACCOUNT_ID
|
||||
self.api_version = settings.WHATSAPP_API_VERSION or "v18.0"
|
||||
self.base_url = f"https://graph.facebook.com/{self.api_version}"
|
||||
self.enabled = settings.ENABLE_WHATSAPP_NOTIFICATIONS
|
||||
|
||||
# Tenant client for fetching per-tenant settings
|
||||
self.tenant_client = tenant_client
|
||||
|
||||
# Repository dependencies (will be injected)
|
||||
self.session = session
|
||||
self.message_repo = WhatsAppMessageRepository(session) if session else None
|
||||
self.template_repo = WhatsAppTemplateRepository(session) if session else None
|
||||
|
||||
async def _get_whatsapp_credentials(self, tenant_id: str) -> Dict[str, str]:
|
||||
"""
|
||||
Get WhatsApp credentials for a tenant
|
||||
|
||||
Tries tenant-specific settings first, falls back to global config
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
|
||||
Returns:
|
||||
Dictionary with access_token, phone_number_id, business_account_id
|
||||
"""
|
||||
# Try to fetch tenant-specific settings
|
||||
if self.tenant_client:
|
||||
try:
|
||||
notification_settings = await self.tenant_client.get_notification_settings(tenant_id)
|
||||
|
||||
if notification_settings and notification_settings.get('whatsapp_enabled'):
|
||||
tenant_access_token = notification_settings.get('whatsapp_access_token', '').strip()
|
||||
tenant_phone_id = notification_settings.get('whatsapp_phone_number_id', '').strip()
|
||||
tenant_business_id = notification_settings.get('whatsapp_business_account_id', '').strip()
|
||||
|
||||
# Use tenant credentials if all are configured
|
||||
if tenant_access_token and tenant_phone_id:
|
||||
logger.info(
|
||||
"Using tenant-specific WhatsApp credentials",
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
return {
|
||||
'access_token': tenant_access_token,
|
||||
'phone_number_id': tenant_phone_id,
|
||||
'business_account_id': tenant_business_id
|
||||
}
|
||||
else:
|
||||
logger.info(
|
||||
"Tenant WhatsApp enabled but credentials incomplete, falling back to global",
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to fetch tenant notification settings, using global config",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
|
||||
# Fallback to global configuration
|
||||
logger.info(
|
||||
"Using global WhatsApp credentials",
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
return {
|
||||
'access_token': self.global_access_token,
|
||||
'phone_number_id': self.global_phone_number_id,
|
||||
'business_account_id': self.global_business_account_id
|
||||
}
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
request: SendWhatsAppMessageRequest
|
||||
) -> SendWhatsAppMessageResponse:
|
||||
"""
|
||||
Send WhatsApp message via Cloud API
|
||||
|
||||
Args:
|
||||
request: Message request with all details
|
||||
|
||||
Returns:
|
||||
SendWhatsAppMessageResponse with status
|
||||
"""
|
||||
try:
|
||||
if not self.enabled:
|
||||
logger.info("WhatsApp notifications disabled")
|
||||
return SendWhatsAppMessageResponse(
|
||||
success=False,
|
||||
message_id=str(uuid.uuid4()),
|
||||
status=WhatsAppMessageStatus.FAILED,
|
||||
error_message="WhatsApp notifications are disabled"
|
||||
)
|
||||
|
||||
# Get tenant-specific or global credentials
|
||||
credentials = await self._get_whatsapp_credentials(request.tenant_id)
|
||||
access_token = credentials['access_token']
|
||||
phone_number_id = credentials['phone_number_id']
|
||||
|
||||
# Validate configuration
|
||||
if not access_token or not phone_number_id:
|
||||
logger.error("WhatsApp Cloud API not configured properly", tenant_id=request.tenant_id)
|
||||
return SendWhatsAppMessageResponse(
|
||||
success=False,
|
||||
message_id=str(uuid.uuid4()),
|
||||
status=WhatsAppMessageStatus.FAILED,
|
||||
error_message="WhatsApp Cloud API credentials not configured"
|
||||
)
|
||||
|
||||
# Create message record in database
|
||||
message_data = {
|
||||
"tenant_id": request.tenant_id,
|
||||
"notification_id": request.notification_id,
|
||||
"recipient_phone": request.recipient_phone,
|
||||
"recipient_name": request.recipient_name,
|
||||
"message_type": request.message_type,
|
||||
"status": WhatsAppMessageStatus.PENDING,
|
||||
"metadata": request.metadata,
|
||||
"created_at": datetime.utcnow(),
|
||||
"updated_at": datetime.utcnow()
|
||||
}
|
||||
|
||||
# Add template details if template message
|
||||
if request.message_type == WhatsAppMessageType.TEMPLATE and request.template:
|
||||
message_data["template_name"] = request.template.template_name
|
||||
message_data["template_language"] = request.template.language
|
||||
message_data["template_parameters"] = [
|
||||
comp.model_dump() for comp in request.template.components
|
||||
]
|
||||
|
||||
# Add text details if text message
|
||||
if request.message_type == WhatsAppMessageType.TEXT and request.text:
|
||||
message_data["message_body"] = request.text
|
||||
|
||||
# Save to database
|
||||
if self.message_repo:
|
||||
db_message = await self.message_repo.create_message(message_data)
|
||||
message_id = str(db_message.id)
|
||||
else:
|
||||
message_id = str(uuid.uuid4())
|
||||
|
||||
# Send message via Cloud API
|
||||
if request.message_type == WhatsAppMessageType.TEMPLATE:
|
||||
result = await self._send_template_message(
|
||||
recipient_phone=request.recipient_phone,
|
||||
template=request.template,
|
||||
message_id=message_id,
|
||||
access_token=access_token,
|
||||
phone_number_id=phone_number_id
|
||||
)
|
||||
elif request.message_type == WhatsAppMessageType.TEXT:
|
||||
result = await self._send_text_message(
|
||||
recipient_phone=request.recipient_phone,
|
||||
text=request.text,
|
||||
message_id=message_id,
|
||||
access_token=access_token,
|
||||
phone_number_id=phone_number_id
|
||||
)
|
||||
else:
|
||||
logger.error(f"Unsupported message type: {request.message_type}")
|
||||
result = {
|
||||
"success": False,
|
||||
"error_message": f"Unsupported message type: {request.message_type}"
|
||||
}
|
||||
|
||||
# Update database with result
|
||||
if self.message_repo and result.get("success"):
|
||||
await self.message_repo.update_message_status(
|
||||
message_id=message_id,
|
||||
status=WhatsAppMessageStatus.SENT,
|
||||
whatsapp_message_id=result.get("whatsapp_message_id"),
|
||||
provider_response=result.get("provider_response")
|
||||
)
|
||||
elif self.message_repo:
|
||||
await self.message_repo.update_message_status(
|
||||
message_id=message_id,
|
||||
status=WhatsAppMessageStatus.FAILED,
|
||||
error_message=result.get("error_message"),
|
||||
provider_response=result.get("provider_response")
|
||||
)
|
||||
|
||||
# Record metrics
|
||||
status = "success" if result.get("success") else "failed"
|
||||
metrics.increment_counter("whatsapp_sent_total", labels={"status": status})
|
||||
|
||||
return SendWhatsAppMessageResponse(
|
||||
success=result.get("success", False),
|
||||
message_id=message_id,
|
||||
whatsapp_message_id=result.get("whatsapp_message_id"),
|
||||
status=WhatsAppMessageStatus.SENT if result.get("success") else WhatsAppMessageStatus.FAILED,
|
||||
error_message=result.get("error_message")
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to send WhatsApp message", error=str(e))
|
||||
metrics.increment_counter("whatsapp_sent_total", labels={"status": "failed"})
|
||||
|
||||
return SendWhatsAppMessageResponse(
|
||||
success=False,
|
||||
message_id=str(uuid.uuid4()),
|
||||
status=WhatsAppMessageStatus.FAILED,
|
||||
error_message=str(e)
|
||||
)
|
||||
|
||||
async def _send_template_message(
|
||||
self,
|
||||
recipient_phone: str,
|
||||
template: Any,
|
||||
message_id: str,
|
||||
access_token: str,
|
||||
phone_number_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Send template message via WhatsApp Cloud API"""
|
||||
try:
|
||||
# Build template payload
|
||||
payload = {
|
||||
"messaging_product": "whatsapp",
|
||||
"to": recipient_phone,
|
||||
"type": "template",
|
||||
"template": {
|
||||
"name": template.template_name,
|
||||
"language": {
|
||||
"code": template.language
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"type": comp.type,
|
||||
"parameters": [
|
||||
param.model_dump() for param in (comp.parameters or [])
|
||||
]
|
||||
}
|
||||
for comp in template.components
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
# Send request to WhatsApp Cloud API
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/{phone_number_id}/messages",
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json=payload
|
||||
)
|
||||
|
||||
response_data = response.json()
|
||||
|
||||
if response.status_code == 200:
|
||||
whatsapp_message_id = response_data.get("messages", [{}])[0].get("id")
|
||||
|
||||
logger.info(
|
||||
"WhatsApp template message sent successfully",
|
||||
message_id=message_id,
|
||||
whatsapp_message_id=whatsapp_message_id,
|
||||
template=template.template_name,
|
||||
recipient=recipient_phone
|
||||
)
|
||||
|
||||
# Increment template usage count
|
||||
if self.template_repo:
|
||||
template_obj = await self.template_repo.get_by_template_name(
|
||||
template.template_name,
|
||||
template.language
|
||||
)
|
||||
if template_obj:
|
||||
await self.template_repo.increment_usage(str(template_obj.id))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"whatsapp_message_id": whatsapp_message_id,
|
||||
"provider_response": response_data
|
||||
}
|
||||
else:
|
||||
error_message = response_data.get("error", {}).get("message", "Unknown error")
|
||||
error_code = response_data.get("error", {}).get("code")
|
||||
|
||||
logger.error(
|
||||
"WhatsApp Cloud API error",
|
||||
status_code=response.status_code,
|
||||
error_code=error_code,
|
||||
error_message=error_message,
|
||||
template=template.template_name
|
||||
)
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"error_message": f"{error_code}: {error_message}",
|
||||
"provider_response": response_data
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to send template message",
|
||||
template=template.template_name,
|
||||
error=str(e)
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"error_message": str(e)
|
||||
}
|
||||
|
||||
async def _send_text_message(
|
||||
self,
|
||||
recipient_phone: str,
|
||||
text: str,
|
||||
message_id: str,
|
||||
access_token: str,
|
||||
phone_number_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Send text message via WhatsApp Cloud API"""
|
||||
try:
|
||||
payload = {
|
||||
"messaging_product": "whatsapp",
|
||||
"to": recipient_phone,
|
||||
"type": "text",
|
||||
"text": {
|
||||
"body": text
|
||||
}
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/{phone_number_id}/messages",
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json=payload
|
||||
)
|
||||
|
||||
response_data = response.json()
|
||||
|
||||
if response.status_code == 200:
|
||||
whatsapp_message_id = response_data.get("messages", [{}])[0].get("id")
|
||||
|
||||
logger.info(
|
||||
"WhatsApp text message sent successfully",
|
||||
message_id=message_id,
|
||||
whatsapp_message_id=whatsapp_message_id,
|
||||
recipient=recipient_phone
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"whatsapp_message_id": whatsapp_message_id,
|
||||
"provider_response": response_data
|
||||
}
|
||||
else:
|
||||
error_message = response_data.get("error", {}).get("message", "Unknown error")
|
||||
error_code = response_data.get("error", {}).get("code")
|
||||
|
||||
logger.error(
|
||||
"WhatsApp Cloud API error",
|
||||
status_code=response.status_code,
|
||||
error_code=error_code,
|
||||
error_message=error_message
|
||||
)
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"error_message": f"{error_code}: {error_message}",
|
||||
"provider_response": response_data
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to send text message", error=str(e))
|
||||
return {
|
||||
"success": False,
|
||||
"error_message": str(e)
|
||||
}
|
||||
|
||||
async def send_bulk_messages(
|
||||
self,
|
||||
requests: List[SendWhatsAppMessageRequest],
|
||||
batch_size: int = 20
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Send bulk WhatsApp messages with rate limiting
|
||||
|
||||
Args:
|
||||
requests: List of message requests
|
||||
batch_size: Number of messages to send per batch
|
||||
|
||||
Returns:
|
||||
Dict containing success/failure counts
|
||||
"""
|
||||
results = {
|
||||
"total": len(requests),
|
||||
"sent": 0,
|
||||
"failed": 0,
|
||||
"errors": []
|
||||
}
|
||||
|
||||
try:
|
||||
# Process in batches to respect WhatsApp rate limits
|
||||
for i in range(0, len(requests), batch_size):
|
||||
batch = requests[i:i + batch_size]
|
||||
|
||||
# Send messages concurrently within batch
|
||||
tasks = [self.send_message(req) for req in batch]
|
||||
batch_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for req, result in zip(batch, batch_results):
|
||||
if isinstance(result, Exception):
|
||||
results["failed"] += 1
|
||||
results["errors"].append({
|
||||
"phone": req.recipient_phone,
|
||||
"error": str(result)
|
||||
})
|
||||
elif result.success:
|
||||
results["sent"] += 1
|
||||
else:
|
||||
results["failed"] += 1
|
||||
results["errors"].append({
|
||||
"phone": req.recipient_phone,
|
||||
"error": result.error_message
|
||||
})
|
||||
|
||||
# Rate limiting delay between batches
|
||||
if i + batch_size < len(requests):
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
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 Cloud API is healthy
|
||||
|
||||
Returns:
|
||||
bool: True if service is healthy
|
||||
"""
|
||||
try:
|
||||
if not self.enabled:
|
||||
return True # Service is "healthy" if disabled
|
||||
|
||||
if not self.global_access_token or not self.global_phone_number_id:
|
||||
logger.warning("WhatsApp Cloud API not configured")
|
||||
return False
|
||||
|
||||
# Test API connectivity
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/{self.global_phone_number_id}",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.global_access_token}"
|
||||
},
|
||||
params={
|
||||
"fields": "verified_name,code_verification_status"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info("WhatsApp Cloud API health check passed")
|
||||
return True
|
||||
else:
|
||||
logger.error(
|
||||
"WhatsApp Cloud API health check failed",
|
||||
status_code=response.status_code
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error("WhatsApp Cloud API health check failed", error=str(e))
|
||||
return False
|
||||
|
||||
def _format_phone_number(self, phone: str) -> Optional[str]:
|
||||
"""
|
||||
Format phone number for WhatsApp (E.164 format)
|
||||
|
||||
Args:
|
||||
phone: Input phone number
|
||||
|
||||
Returns:
|
||||
Formatted phone number or None if invalid
|
||||
"""
|
||||
if not phone:
|
||||
return None
|
||||
|
||||
# If already in E.164 format, return as is
|
||||
if phone.startswith('+'):
|
||||
return phone
|
||||
|
||||
# Remove spaces, dashes, and other non-digit characters
|
||||
clean_phone = "".join(filter(str.isdigit, phone))
|
||||
|
||||
# Handle Spanish phone numbers
|
||||
if clean_phone.startswith("34"):
|
||||
return f"+{clean_phone}"
|
||||
elif clean_phone.startswith(("6", "7", "9")) and len(clean_phone) == 9:
|
||||
return f"+34{clean_phone}"
|
||||
else:
|
||||
# Try to add + if it looks like a complete international number
|
||||
if len(clean_phone) > 10:
|
||||
return f"+{clean_phone}"
|
||||
|
||||
logger.warning("Unrecognized phone format", phone=phone)
|
||||
return None
|
||||
@@ -3,60 +3,59 @@
|
||||
# ================================================================
|
||||
"""
|
||||
WhatsApp service for sending notifications
|
||||
Integrates with WhatsApp Business API via Twilio
|
||||
Integrates with WhatsApp Business Cloud API (Meta/Facebook)
|
||||
This is a backward-compatible wrapper around the new WhatsAppBusinessService
|
||||
"""
|
||||
|
||||
import structlog
|
||||
import httpx
|
||||
from typing import Optional, Dict, Any, List
|
||||
import asyncio
|
||||
from urllib.parse import quote
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.whatsapp_business_service import WhatsAppBusinessService
|
||||
from app.schemas.whatsapp import (
|
||||
SendWhatsAppMessageRequest,
|
||||
TemplateMessageRequest,
|
||||
TemplateComponent,
|
||||
TemplateParameter,
|
||||
WhatsAppMessageType
|
||||
)
|
||||
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
|
||||
WhatsApp service for sending notifications via WhatsApp Business Cloud API
|
||||
Backward-compatible wrapper for existing code
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = settings.WHATSAPP_API_KEY
|
||||
self.base_url = settings.WHATSAPP_BASE_URL
|
||||
self.from_number = settings.WHATSAPP_FROM_NUMBER
|
||||
|
||||
def __init__(self, session: Optional[AsyncSession] = None, tenant_client=None):
|
||||
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]
|
||||
self.business_service = WhatsAppBusinessService(session, tenant_client)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
to_phone: str,
|
||||
message: str,
|
||||
template_name: Optional[str] = None,
|
||||
template_params: Optional[List[str]] = None
|
||||
template_params: Optional[List[str]] = None,
|
||||
tenant_id: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Send WhatsApp message
|
||||
|
||||
Send WhatsApp message (backward-compatible wrapper)
|
||||
|
||||
Args:
|
||||
to_phone: Recipient phone number (with country code)
|
||||
message: Message text
|
||||
template_name: WhatsApp template name (optional)
|
||||
template_params: Template parameters (optional)
|
||||
|
||||
tenant_id: Tenant ID (optional, defaults to system tenant)
|
||||
|
||||
Returns:
|
||||
bool: True if message was sent successfully
|
||||
"""
|
||||
@@ -64,47 +63,71 @@ class WhatsAppService:
|
||||
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
|
||||
|
||||
# Format 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
|
||||
|
||||
# Use default tenant if not provided
|
||||
if not tenant_id:
|
||||
tenant_id = "00000000-0000-0000-0000-000000000000" # System tenant
|
||||
|
||||
# Build request
|
||||
if template_name:
|
||||
success = await self._send_template_message(
|
||||
phone, template_name, template_params or []
|
||||
# Template message
|
||||
components = []
|
||||
if template_params:
|
||||
# Build body component with parameters
|
||||
parameters = [
|
||||
TemplateParameter(type="text", text=param)
|
||||
for param in template_params
|
||||
]
|
||||
components.append(
|
||||
TemplateComponent(type="body", parameters=parameters)
|
||||
)
|
||||
|
||||
template_request = TemplateMessageRequest(
|
||||
template_name=template_name,
|
||||
language="es",
|
||||
components=components
|
||||
)
|
||||
|
||||
request = SendWhatsAppMessageRequest(
|
||||
tenant_id=tenant_id,
|
||||
recipient_phone=phone,
|
||||
message_type=WhatsAppMessageType.TEMPLATE,
|
||||
template=template_request
|
||||
)
|
||||
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
|
||||
|
||||
# Text message
|
||||
request = SendWhatsAppMessageRequest(
|
||||
tenant_id=tenant_id,
|
||||
recipient_phone=phone,
|
||||
message_type=WhatsAppMessageType.TEXT,
|
||||
text=message
|
||||
)
|
||||
|
||||
# Send via business service
|
||||
response = await self.business_service.send_message(request)
|
||||
|
||||
if response.success:
|
||||
logger.info(
|
||||
"WhatsApp message sent successfully",
|
||||
to=phone,
|
||||
template=template_name
|
||||
)
|
||||
|
||||
return response.success
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to send WhatsApp message",
|
||||
to=to_phone,
|
||||
error=str(e))
|
||||
|
||||
# Record failure metrics
|
||||
logger.error(
|
||||
"Failed to send WhatsApp message",
|
||||
to=to_phone,
|
||||
error=str(e)
|
||||
)
|
||||
metrics.increment_counter("whatsapp_sent_total", labels={"status": "failed"})
|
||||
|
||||
return False
|
||||
|
||||
async def send_bulk_messages(
|
||||
@@ -112,17 +135,21 @@ class WhatsAppService:
|
||||
recipients: List[str],
|
||||
message: str,
|
||||
template_name: Optional[str] = None,
|
||||
batch_size: int = 20
|
||||
template_params: Optional[List[str]] = None,
|
||||
batch_size: int = 20,
|
||||
tenant_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Send bulk WhatsApp messages with rate limiting
|
||||
|
||||
Send bulk WhatsApp messages with rate limiting (backward-compatible wrapper)
|
||||
|
||||
Args:
|
||||
recipients: List of recipient phone numbers
|
||||
message: Message text
|
||||
template_name: WhatsApp template name (optional)
|
||||
template_params: Template parameters (optional)
|
||||
batch_size: Number of messages to send per batch
|
||||
|
||||
tenant_id: Tenant ID (optional)
|
||||
|
||||
Returns:
|
||||
Dict containing success/failure counts
|
||||
"""
|
||||
@@ -132,45 +159,76 @@ class WhatsAppService:
|
||||
"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
|
||||
# Use default tenant if not provided
|
||||
if not tenant_id:
|
||||
tenant_id = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
# Build requests for all recipients
|
||||
requests = []
|
||||
for phone in recipients:
|
||||
formatted_phone = self._format_phone_number(phone)
|
||||
if not formatted_phone:
|
||||
results["failed"] += 1
|
||||
results["errors"].append({"phone": phone, "error": "Invalid phone format"})
|
||||
continue
|
||||
|
||||
if template_name:
|
||||
# Template message
|
||||
components = []
|
||||
if template_params:
|
||||
parameters = [
|
||||
TemplateParameter(type="text", text=param)
|
||||
for param in template_params
|
||||
]
|
||||
components.append(
|
||||
TemplateComponent(type="body", parameters=parameters)
|
||||
)
|
||||
|
||||
template_request = TemplateMessageRequest(
|
||||
template_name=template_name,
|
||||
language="es",
|
||||
components=components
|
||||
)
|
||||
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"])
|
||||
|
||||
|
||||
request = SendWhatsAppMessageRequest(
|
||||
tenant_id=tenant_id,
|
||||
recipient_phone=formatted_phone,
|
||||
message_type=WhatsAppMessageType.TEMPLATE,
|
||||
template=template_request
|
||||
)
|
||||
else:
|
||||
# Text message
|
||||
request = SendWhatsAppMessageRequest(
|
||||
tenant_id=tenant_id,
|
||||
recipient_phone=formatted_phone,
|
||||
message_type=WhatsAppMessageType.TEXT,
|
||||
text=message
|
||||
)
|
||||
|
||||
requests.append(request)
|
||||
|
||||
# Send via business service
|
||||
bulk_result = await self.business_service.send_bulk_messages(
|
||||
requests,
|
||||
batch_size=batch_size
|
||||
)
|
||||
|
||||
# Update results
|
||||
results["sent"] = bulk_result.get("sent", 0)
|
||||
results["failed"] += bulk_result.get("failed", 0)
|
||||
results["errors"].extend(bulk_result.get("errors", []))
|
||||
|
||||
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)})
|
||||
@@ -179,203 +237,20 @@ class WhatsAppService:
|
||||
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
|
||||
return await self.business_service.health_check()
|
||||
|
||||
def _format_phone_number(self, phone: str) -> Optional[str]:
|
||||
"""
|
||||
Format phone number for WhatsApp (Spanish format)
|
||||
|
||||
Format phone number for WhatsApp (E.164 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
|
||||
return self.business_service._format_phone_number(phone)
|
||||
Reference in New Issue
Block a user