From 4afdc36765fafc7594097037fa866e7a1e06efa9 Mon Sep 17 00:00:00 2001 From: Jerome Tabiri Date: Fri, 24 Jul 2026 20:07:30 -0400 Subject: [PATCH] feat: Replace mock WhatsApp with real Meta Graph API webhook - POST /api/whatsapp/webhook handles Meta verification (hub.challenge) - Inbound text messages create tickets via create_ticket() - Auto-reply confirmation sent back via Meta Graph API - WhatsApp messages logged with ticket linkage in whatsapp_log - Added WHATSAPP_PHONE_NUMBER_ID, WHATSAPP_ACCESS_TOKEN, WHATSAPP_VERIFY_TOKEN config - Kept /mock-log debug endpoint for backward compatibility - Graceful degradation: logs message even if ticket/reply fails --- app/core/config.py | 6 ++ app/models/whatsapp_log.py | 10 ++- app/routers/whatsapp.py | 157 ++++++++++++++++++++++++++++++++----- app/schemas/whatsapp.py | 73 ++++++++++++++--- 4 files changed, 212 insertions(+), 34 deletions(-) diff --git a/app/core/config.py b/app/core/config.py index 3fd1605..6878b85 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -31,6 +31,12 @@ class Settings(BaseSettings): # ── CORS ───────────────────────────────────────────────────────── CORS_ORIGINS: str = "*" + # ── WhatsApp ───────────────────────────────────────────────────── + WHATSAPP_PHONE_NUMBER_ID: str = "" + WHATSAPP_ACCESS_TOKEN: str = "" + WHATSAPP_VERIFY_TOKEN: str = "" + META_GRAPH_BASE: str = "https://graph.facebook.com/v18.0" + # ── Paths ──────────────────────────────────────────────────────── BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent diff --git a/app/models/whatsapp_log.py b/app/models/whatsapp_log.py index 510dbf7..2e5ee2e 100644 --- a/app/models/whatsapp_log.py +++ b/app/models/whatsapp_log.py @@ -1,4 +1,4 @@ -"""WhatsApp log model for mock endpoint.""" +"""WhatsApp log model for inbound webhook messages.""" from __future__ import annotations @@ -14,10 +14,12 @@ class WhatsAppLog(Base): __tablename__ = "whatsapp_log" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) - command: Mapped[str] = mapped_column(Text, nullable=False) - from_number: Mapped[str | None] = mapped_column(String(50), nullable=True) + from_number: Mapped[str] = mapped_column(String(50), nullable=False) + message_text: Mapped[str] = mapped_column(Text, nullable=False) + wa_message_id: Mapped[str | None] = mapped_column(String(100), nullable=True) ticket_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + ticket_number: Mapped[str | None] = mapped_column(String(30), nullable=True) received_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) def __repr__(self) -> str: - return f"" + return f"" diff --git a/app/routers/whatsapp.py b/app/routers/whatsapp.py index cfaa55b..376d3fc 100644 --- a/app/routers/whatsapp.py +++ b/app/routers/whatsapp.py @@ -1,44 +1,165 @@ -"""Mock WhatsApp endpoint for testing command parsing.""" +"""WhatsApp webhook handler — Meta Graph API integration.""" from __future__ import annotations +import logging from datetime import datetime, timezone from typing import Annotated -from fastapi import APIRouter, Depends +import httpx +from fastapi import APIRouter, Depends, Query 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.models.whatsapp_log import WhatsAppLog -from app.schemas.whatsapp import MockWhatsAppLogEntry, MockWhatsAppRequest, MockWhatsAppResponse +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." +) -@router.post("/mock", response_model=MockWhatsAppResponse) -async def mock_whatsapp( - body: MockWhatsAppRequest, + +# ── 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 endpoint ──────────────────────────────────────────────── +@router.post("/webhook") +async def whatsapp_webhook( + body: MetaWebhookRequest, db: Annotated[AsyncSession, Depends(get_db)], -) -> MockWhatsAppResponse: - """Accept a mock WhatsApp command and log it.""" - log = WhatsAppLog( - command=body.command, - from_number=body.from_number, - ticket_id=body.ticket_id, - received_at=datetime.now(timezone.utc), - ) - db.add(log) - await db.flush() - return MockWhatsAppResponse() + hub_verify_token: str | None = Query(None, alias="hub.verify_token"), + mode: str | None = Query(None), + hub_challenge: str | None = Query(None), +) -> dict: + """Handle incoming WhatsApp webhook from Meta.""" + + # ── Verification GET request (Meta sends this on webhook setup) ── + if mode and hub_challenge: + if hub_verify_token != settings.WHATSAPP_VERIFY_TOKEN: + return {"error": "Verify token mismatch"} + return WebhookVerificationResponse(challenge=hub_challenge).model_dump() + + # ── Process inbound messages ──────────────────────────────────── + 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"} +# ── Legacy debug endpoint ────────────────────────────────────────── @router.get("/mock-log", response_model=list[MockWhatsAppLogEntry]) async def mock_whatsapp_log( db: Annotated[AsyncSession, Depends(get_db)], limit: int = 50, ) -> list[MockWhatsAppLogEntry]: - """Return recent mock WhatsApp submissions.""" + """Return recent WhatsApp webhook submissions for debugging.""" result = await db.execute( select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit) ) diff --git a/app/schemas/whatsapp.py b/app/schemas/whatsapp.py index 0972168..2d2f281 100644 --- a/app/schemas/whatsapp.py +++ b/app/schemas/whatsapp.py @@ -1,10 +1,69 @@ -"""Pydantic schemas for mock WhatsApp endpoint.""" +"""Pydantic schemas for Meta WhatsApp webhook.""" from __future__ import annotations from datetime import datetime -from pydantic import BaseModel +from pydantic import BaseModel, Field + + +# ── Meta Webhook Verification ──────────────────────────────────────── +class WebhookVerificationResponse(BaseModel): + challenge: str + + +# ── Inbound Message Parsing ───────────────────────────────────────── +class MessageText(BaseModel): + text: str + + +class Message(BaseModel): + from_field: str = Field(alias="from") + id: str | None = None + text: MessageText | None = None + type: str | None = None + + +class Contact(BaseModel): + wa_id: str | None = None + profile: dict | None = None + + +class EntryMessagesItem(BaseModel): + id: str | None = None + message: Message | None = None + contacts: list[Contact] | None = None + timestamp: str | None = None + + +class Entry(BaseModel): + id: str | None = None + changes: list[EntryMessagesItem] | None = None + metadata: dict | None = None + + +class MetaWebhookRequest(BaseModel): + object: str | None = None + entry: list[Entry] | None = None + + +# ── Auto-reply ────────────────────────────────────────────────────── +class WhatsAppReplyResponse(BaseModel): + success: bool + message: str + + +# ── Legacy mock schemas (kept for /mock-log endpoint) ─────────────── +class MockWhatsAppLogEntry(BaseModel): + id: int + from_number: str + message_text: str + wa_message_id: str | None + ticket_id: int | None + ticket_number: str | None + received_at: datetime + + model_config = {"from_attributes": True} class MockWhatsAppRequest(BaseModel): @@ -16,13 +75,3 @@ class MockWhatsAppRequest(BaseModel): class MockWhatsAppResponse(BaseModel): status: str = "received" message: str = "Command logged successfully" - - -class MockWhatsAppLogEntry(BaseModel): - id: int - command: str - from_number: str | None - ticket_id: int | None - received_at: datetime - - model_config = {"from_attributes": True}