"""WhatsApp webhook handler — Meta Graph API integration. P0 hardening: * ``POST /api/whatsapp/webhook`` requires the ``X-Webhook-Secret`` header to match ``WHATSAPP_WEBHOOK_SECRET``. Fail-closed: when the env var is unset every message is rejected (same posture as the SECRET_KEY guard). * ``GET /api/whatsapp/webhook`` (Meta handshake) validates ``hub.verify_token`` with a constant-time compare and returns 403 on mismatch. * ``GET /api/whatsapp/mock-log`` now requires authentication (was a public debug endpoint that could 500 and leak stack traces without auth). """ from __future__ import annotations import logging import secrets from datetime import datetime, timezone from typing import Annotated import httpx from fastapi import APIRouter, Depends, Header, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.core.database import get_db from app.core.security import get_current_user from app.models.user import User from app.models.whatsapp_log import WhatsAppLog from app.schemas.whatsapp import ( MetaWebhookRequest, MockWhatsAppLogEntry, WebhookVerificationResponse, WhatsAppReplyResponse, ) from app.services.ticket import create_ticket logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/whatsapp", tags=["whatsapp"]) REPLY_TEMPLATE = ( "Thank you for contacting Denya OneCare. " "Your ticket number is {ticket_number}. " "We will get back to you soon." ) # ── Meta Graph API helpers ────────────────────────────────────────── async def send_whatsapp_reply( to_phone: str, text: str, ) -> WhatsAppReplyResponse: """Send a text message via Meta Graph API.""" url = f"{settings.META_GRAPH_BASE}/{settings.WHATSAPP_PHONE_NUMBER_ID}/messages" headers = { "Authorization": f"Bearer {settings.WHATSAPP_ACCESS_TOKEN}", "Content-Type": "application/x-www-form-urlencoded", } data = { "messaging_product": "whatsapp", "to": to_phone, "type": "text", "text": {"body": text}, } # Encode nested dict as JSON string for form data (Meta requirement) data["text"] = '{"body":' + f'"{text}"' + "}" try: async with httpx.AsyncClient() as client: response = await client.post(url, headers=headers, data=data, timeout=15.0) response.raise_for_status() return WhatsAppReplyResponse(success=True, message="Reply sent") except httpx.HTTPError as exc: logger.error("Failed to send WhatsApp reply: %s", exc) return WhatsAppReplyResponse(success=False, message=str(exc)) # ── Webhook auth (fail-closed) ────────────────────────────────────── async def require_webhook_secret( x_webhook_secret: Annotated[str | None, Header(alias="X-Webhook-Secret")] = None, ) -> None: """Reject webhook messages unless X-Webhook-Secret matches the env secret. Reads ``settings.WHATSAPP_WEBHOOK_SECRET`` at request time so the value can be injected per-deployment. Empty/unset env ⇒ reject everything. """ expected = settings.WHATSAPP_WEBHOOK_SECRET if not expected: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Webhook disabled: WHATSAPP_WEBHOOK_SECRET is not configured", ) if not x_webhook_secret or not secrets.compare_digest(x_webhook_secret, expected): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Invalid webhook secret", ) # ── Webhook endpoint ──────────────────────────────────────────────── @router.get("/webhook") async def whatsapp_webhook_verify( mode: str | None = Query(None, alias="hub.mode"), verify_token: str | None = Query(None, alias="hub.verify_token"), challenge: str | None = Query(None, alias="hub.challenge"), ) -> WebhookVerificationResponse | dict: """Meta webhook handshake (GET): echo the challenge when the token matches.""" if mode != "subscribe" or not challenge: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Missing hub.mode / hub.challenge", ) expected = settings.WHATSAPP_VERIFY_TOKEN if not expected or not secrets.compare_digest(verify_token or "", expected): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Verify token mismatch", ) return WebhookVerificationResponse(challenge=challenge) async def _process_entries( body: MetaWebhookRequest, db: AsyncSession, ) -> dict: """Create tickets + logs for inbound messages. Shared by the POST handler.""" if not body.entry: return {"status": "no entry"} for entry in body.entry: if not entry.changes: continue for change in entry.changes: if not change.message or not change.message.text: logger.info("Non-text message received, skipping") continue message_text = change.message.text.text sender = change.message.from_field wa_msg_id = change.message.id or change.id # ── Create ticket ────────────────────────────────────── try: ticket = await create_ticket( db, data={ "description": message_text, "reporter": sender, "reported_via": "WhatsApp", }, ) except Exception as exc: logger.error("Failed to create ticket: %s", exc) # Still log the message even if ticket creation fails log = WhatsAppLog( from_number=sender, message_text=message_text, wa_message_id=wa_msg_id, ticket_id=None, ticket_number=None, received_at=datetime.now(timezone.utc), ) db.add(log) await db.flush() return {"status": "ticket creation failed, message logged"} # ── Store WhatsApp log ───────────────────────────────── log = WhatsAppLog( from_number=sender, message_text=message_text, wa_message_id=wa_msg_id, ticket_id=ticket.id, ticket_number=ticket.ticket_number, received_at=datetime.now(timezone.utc), ) db.add(log) await db.flush() # ── Send auto-reply ──────────────────────────────────── reply_text = REPLY_TEMPLATE.format(ticket_number=ticket.ticket_number) reply_result = await send_whatsapp_reply(sender, reply_text) if not reply_result.success: logger.warning( "Auto-reply failed for ticket %s: %s", ticket.ticket_number, reply_result.message, ) return { "status": "processed", "ticket_id": ticket.id, "ticket_number": ticket.ticket_number, "reply_sent": reply_result.success, } return {"status": "no messages"} @router.post("/webhook") async def whatsapp_webhook( body: MetaWebhookRequest, db: Annotated[AsyncSession, Depends(get_db)], _auth: None = Depends(require_webhook_secret), ) -> dict: """Process an inbound WhatsApp message (Meta POST). Requires webhook secret.""" return await _process_entries(body, db) # ── Debug endpoint (auth required) ────────────────────────────────── @router.get("/mock-log", response_model=list[MockWhatsAppLogEntry]) async def mock_whatsapp_log( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)], limit: int = Query(50, ge=1, le=200), ) -> list[MockWhatsAppLogEntry]: """Return recent WhatsApp webhook submissions (authenticated only).""" result = await db.execute( select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit) ) return list(result.scalars().all())