Add whatsapp feature

This commit is contained in:
Urtzi Alfaro
2025-11-13 16:01:08 +01:00
parent d7df2b0853
commit 9bc048d360
74 changed files with 9765 additions and 533 deletions

View 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

View File

@@ -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)