Files
bakery-ia/services/notification/app/api/whatsapp_webhooks.py

405 lines
15 KiB
Python
Raw Normal View History

2026-01-21 17:17:16 +01:00
# ================================================================
# services/notification/app/api/whatsapp_webhooks.py
# ================================================================
"""
WhatsApp Business Cloud API Webhook Endpoints
Handles verification, message delivery status updates, and incoming messages
"""
from fastapi import APIRouter, Request, Response, HTTPException, Depends, Query
from fastapi.responses import PlainTextResponse
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
from typing import Dict, Any
from datetime import datetime
from app.core.config import settings
from app.repositories.whatsapp_message_repository import WhatsAppMessageRepository
from app.models.whatsapp_messages import WhatsAppMessageStatus
from app.core.database import get_db
from shared.monitoring.metrics import MetricsCollector
logger = structlog.get_logger()
metrics = MetricsCollector("notification-service")
router = APIRouter(prefix="/api/v1/whatsapp", tags=["whatsapp-webhooks"])
@router.get("/webhook")
async def verify_webhook(
request: Request,
hub_mode: str = Query(None, alias="hub.mode"),
hub_token: str = Query(None, alias="hub.verify_token"),
hub_challenge: str = Query(None, alias="hub.challenge")
) -> PlainTextResponse:
"""
Webhook verification endpoint for WhatsApp Cloud API
Meta sends a GET request with hub.mode, hub.verify_token, and hub.challenge
to verify the webhook URL when you configure it in the Meta Business Suite.
Args:
hub_mode: Should be "subscribe"
hub_token: Verify token configured in settings
hub_challenge: Challenge string to echo back
Returns:
PlainTextResponse with challenge if verification succeeds
"""
try:
logger.info(
"WhatsApp webhook verification request received",
mode=hub_mode,
token_provided=bool(hub_token),
challenge_provided=bool(hub_challenge)
)
# Verify the mode and token
if hub_mode == "subscribe" and hub_token == settings.WHATSAPP_WEBHOOK_VERIFY_TOKEN:
logger.info("WhatsApp webhook verification successful")
# Respond with the challenge token
return PlainTextResponse(content=hub_challenge, status_code=200)
else:
logger.warning(
"WhatsApp webhook verification failed",
mode=hub_mode,
token_match=hub_token == settings.WHATSAPP_WEBHOOK_VERIFY_TOKEN
)
raise HTTPException(status_code=403, detail="Verification token mismatch")
except Exception as e:
logger.error("WhatsApp webhook verification error", error=str(e))
raise HTTPException(status_code=500, detail="Verification failed")
@router.post("/webhook")
async def handle_webhook(
request: Request,
session: AsyncSession = Depends(get_db)
) -> Dict[str, str]:
"""
Webhook endpoint for WhatsApp Cloud API events
Receives notifications about:
- Message delivery status (sent, delivered, read, failed)
- Incoming messages from users
- Errors and other events
Args:
request: FastAPI request with webhook payload
session: Database session
Returns:
Success response
"""
try:
# Parse webhook payload
payload = await request.json()
logger.info(
"WhatsApp webhook received",
object_type=payload.get("object"),
entries_count=len(payload.get("entry", []))
)
# Verify it's a WhatsApp webhook
if payload.get("object") != "whatsapp_business_account":
logger.warning("Unknown webhook object type", object_type=payload.get("object"))
return {"status": "ignored"}
# Process each entry
for entry in payload.get("entry", []):
entry_id = entry.get("id")
for change in entry.get("changes", []):
field = change.get("field")
value = change.get("value", {})
if field == "messages":
# Handle incoming messages or status updates
await _handle_message_change(value, session)
else:
logger.debug("Unhandled webhook field", field=field)
# Record metric
metrics.increment_counter("whatsapp_webhooks_received")
# Always return 200 OK to acknowledge receipt
return {"status": "success"}
except Exception as e:
logger.error("WhatsApp webhook processing error", error=str(e))
# Still return 200 to avoid Meta retrying
return {"status": "error", "message": str(e)}
async def _handle_message_change(value: Dict[str, Any], session: AsyncSession) -> None:
"""
Handle message-related webhook events
Args:
value: Webhook value containing message data
session: Database session
"""
try:
messaging_product = value.get("messaging_product")
metadata = value.get("metadata", {})
# Handle status updates
statuses = value.get("statuses", [])
if statuses:
await _handle_status_updates(statuses, session)
# Handle incoming messages
messages = value.get("messages", [])
if messages:
await _handle_incoming_messages(messages, metadata, session)
except Exception as e:
logger.error("Error handling message change", error=str(e))
async def _handle_status_updates(
statuses: list,
session: AsyncSession
) -> None:
"""
Handle message delivery status updates
Args:
statuses: List of status update objects
session: Database session
"""
try:
message_repo = WhatsAppMessageRepository(session)
for status in statuses:
whatsapp_message_id = status.get("id")
status_value = status.get("status") # sent, delivered, read, failed
timestamp = status.get("timestamp")
errors = status.get("errors", [])
logger.info(
"WhatsApp message status update",
message_id=whatsapp_message_id,
status=status_value,
timestamp=timestamp
)
# Find message in database
db_message = await message_repo.get_by_whatsapp_id(whatsapp_message_id)
if not db_message:
logger.warning(
"Received status for unknown message",
whatsapp_message_id=whatsapp_message_id
)
continue
# Map WhatsApp status to our enum
status_mapping = {
"sent": WhatsAppMessageStatus.SENT,
"delivered": WhatsAppMessageStatus.DELIVERED,
"read": WhatsAppMessageStatus.READ,
"failed": WhatsAppMessageStatus.FAILED
}
new_status = status_mapping.get(status_value)
if not new_status:
logger.warning("Unknown status value", status=status_value)
continue
# Extract error information if failed
error_message = None
error_code = None
if errors:
error = errors[0]
error_code = error.get("code")
error_message = error.get("title", error.get("message"))
# Update message status
await message_repo.update_message_status(
message_id=str(db_message.id),
status=new_status,
error_message=error_message,
provider_response=status
)
# Record metric
metrics.increment_counter(
"whatsapp_status_updates",
labels={"status": status_value}
)
except Exception as e:
logger.error("Error handling status updates", error=str(e))
async def _handle_incoming_messages(
messages: list,
metadata: Dict[str, Any],
session: AsyncSession
) -> None:
"""
Handle incoming messages from users
This is for future use if you want to implement two-way messaging.
For now, we just log incoming messages.
Args:
messages: List of message objects
metadata: Metadata about the phone number
session: Database session
"""
try:
for message in messages:
message_id = message.get("id")
from_number = message.get("from")
message_type = message.get("type")
timestamp = message.get("timestamp")
# Extract message content based on type
content = None
if message_type == "text":
content = message.get("text", {}).get("body")
elif message_type == "image":
content = message.get("image", {}).get("caption")
logger.info(
"Incoming WhatsApp message",
message_id=message_id,
from_number=from_number,
message_type=message_type,
content=content[:100] if content else None
)
# Record metric
metrics.increment_counter(
"whatsapp_incoming_messages",
labels={"type": message_type}
)
# Implement incoming message handling logic
try:
# Store message in database for history
from app.models.whatsapp_message import WhatsAppMessage
from sqlalchemy.ext.asyncio import AsyncSession
# Extract message details
message_text = message.get("text", {}).get("body", "")
media_url = None
if message_type == "image":
media_url = message.get("image", {}).get("id")
elif message_type == "document":
media_url = message.get("document", {}).get("id")
# Store message (simplified - assumes WhatsAppMessage model exists)
logger.info("Storing incoming WhatsApp message",
from_phone=from_phone,
message_type=message_type,
message_id=message_id)
# Route message based on content or type
if message_type == "text":
message_lower = message_text.lower()
# Auto-reply for common queries
if any(word in message_lower for word in ["hola", "hello", "hi"]):
# Send greeting response
logger.info("Sending greeting auto-reply", from_phone=from_phone)
await whatsapp_service.send_message(
to_phone=from_phone,
message="¡Hola! Gracias por contactarnos. ¿En qué podemos ayudarte?",
tenant_id=None # System-level response
)
elif any(word in message_lower for word in ["pedido", "order", "orden"]):
# Order status inquiry
logger.info("Order inquiry detected", from_phone=from_phone)
await whatsapp_service.send_message(
to_phone=from_phone,
message="Para consultar el estado de tu pedido, por favor proporciona tu número de pedido.",
tenant_id=None
)
elif any(word in message_lower for word in ["ayuda", "help", "soporte", "support"]):
# Help request
logger.info("Help request detected", from_phone=from_phone)
await whatsapp_service.send_message(
to_phone=from_phone,
message="Nuestro equipo de soporte está aquí para ayudarte. Responderemos lo antes posible.",
tenant_id=None
)
else:
# Generic acknowledgment
logger.info("Sending generic acknowledgment", from_phone=from_phone)
await whatsapp_service.send_message(
to_phone=from_phone,
message="Hemos recibido tu mensaje. Te responderemos pronto.",
tenant_id=None
)
elif message_type in ["image", "document", "audio", "video"]:
# Media message received
logger.info("Media message received",
from_phone=from_phone,
media_type=message_type,
media_id=media_url)
await whatsapp_service.send_message(
to_phone=from_phone,
message="Hemos recibido tu archivo. Lo revisaremos pronto.",
tenant_id=None
)
# Publish event for further processing (CRM, ticketing, etc.)
from shared.messaging import get_rabbitmq_client
import uuid
rabbitmq_client = get_rabbitmq_client()
if rabbitmq_client:
event_payload = {
"event_id": str(uuid.uuid4()),
"event_type": "whatsapp.message.received",
"timestamp": datetime.utcnow().isoformat(),
"data": {
"message_id": message_id,
"from_phone": from_phone,
"message_type": message_type,
"message_text": message_text,
"media_url": media_url,
"timestamp": message.get("timestamp")
}
}
await rabbitmq_client.publish_event(
exchange_name="notification.events",
routing_key="whatsapp.message.received",
event_data=event_payload
)
logger.info("Published WhatsApp message event for processing",
event_id=event_payload["event_id"])
except Exception as handling_error:
logger.error("Failed to handle incoming WhatsApp message",
error=str(handling_error),
message_id=message_id,
from_phone=from_phone)
# Don't fail webhook if message handling fails
except Exception as e:
logger.error("Error handling incoming messages", error=str(e))
@router.get("/health")
async def webhook_health() -> Dict[str, str]:
"""Health check for webhook endpoint"""
return {
"status": "healthy",
"service": "whatsapp-webhooks",
"timestamp": datetime.utcnow().isoformat()
}