Initial commit - production deployment
This commit is contained in:
404
services/notification/app/api/whatsapp_webhooks.py
Normal file
404
services/notification/app/api/whatsapp_webhooks.py
Normal file
@@ -0,0 +1,404 @@
|
||||
# ================================================================
|
||||
# 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()
|
||||
}
|
||||
Reference in New Issue
Block a user