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
This commit is contained in:
2026-07-24 20:07:30 -04:00
parent 3920cf14a1
commit 4afdc36765
4 changed files with 212 additions and 34 deletions
+6
View File
@@ -31,6 +31,12 @@ class Settings(BaseSettings):
# ── CORS ───────────────────────────────────────────────────────── # ── CORS ─────────────────────────────────────────────────────────
CORS_ORIGINS: str = "*" 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 ──────────────────────────────────────────────────────── # ── Paths ────────────────────────────────────────────────────────
BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
+6 -4
View File
@@ -1,4 +1,4 @@
"""WhatsApp log model for mock endpoint.""" """WhatsApp log model for inbound webhook messages."""
from __future__ import annotations from __future__ import annotations
@@ -14,10 +14,12 @@ class WhatsAppLog(Base):
__tablename__ = "whatsapp_log" __tablename__ = "whatsapp_log"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
command: Mapped[str] = mapped_column(Text, nullable=False) from_number: Mapped[str] = mapped_column(String(50), nullable=False)
from_number: Mapped[str | None] = mapped_column(String(50), nullable=True) 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_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) received_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<WhatsAppLog {self.id}: {self.command[:50]}>" return f"<WhatsAppLog {self.id}: from={self.from_number} ticket={self.ticket_number}>"
+134 -13
View File
@@ -1,44 +1,165 @@
"""Mock WhatsApp endpoint for testing command parsing.""" """WhatsApp webhook handler — Meta Graph API integration."""
from __future__ import annotations from __future__ import annotations
import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends import httpx
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.models.whatsapp_log import WhatsAppLog 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"]) 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( # ── Meta Graph API helpers ──────────────────────────────────────────
body: MockWhatsAppRequest, 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)], db: Annotated[AsyncSession, Depends(get_db)],
) -> MockWhatsAppResponse: hub_verify_token: str | None = Query(None, alias="hub.verify_token"),
"""Accept a mock WhatsApp command and log it.""" 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( log = WhatsAppLog(
command=body.command, from_number=sender,
from_number=body.from_number, message_text=message_text,
ticket_id=body.ticket_id, wa_message_id=wa_msg_id,
ticket_id=None,
ticket_number=None,
received_at=datetime.now(timezone.utc), received_at=datetime.now(timezone.utc),
) )
db.add(log) db.add(log)
await db.flush() await db.flush()
return MockWhatsAppResponse() 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]) @router.get("/mock-log", response_model=list[MockWhatsAppLogEntry])
async def mock_whatsapp_log( async def mock_whatsapp_log(
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
limit: int = 50, limit: int = 50,
) -> list[MockWhatsAppLogEntry]: ) -> list[MockWhatsAppLogEntry]:
"""Return recent mock WhatsApp submissions.""" """Return recent WhatsApp webhook submissions for debugging."""
result = await db.execute( result = await db.execute(
select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit) select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit)
) )
+61 -12
View File
@@ -1,10 +1,69 @@
"""Pydantic schemas for mock WhatsApp endpoint.""" """Pydantic schemas for Meta WhatsApp webhook."""
from __future__ import annotations from __future__ import annotations
from datetime import datetime 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): class MockWhatsAppRequest(BaseModel):
@@ -16,13 +75,3 @@ class MockWhatsAppRequest(BaseModel):
class MockWhatsAppResponse(BaseModel): class MockWhatsAppResponse(BaseModel):
status: str = "received" status: str = "received"
message: str = "Command logged successfully" 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}