Add whatsapp feature
This commit is contained in:
@@ -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