WhatsApp demo path (relay #748): - WHATSAPP_DEMO_TO config under the WhatsApp section (env-based, .env-only; .env.example keeps an empty placeholder; real numbers never enter source). - build_demo_webhook_payload() in app/routers/whatsapp.py builds the Meta demo payload from it (fails closed when unset), so the webhook round trip logs from_number = demo number (surfaces in GET /api/whatsapp/mock-log) and the auto-reply targets the same number. - tests/test_whatsapp_demo_number.py: default empty + never committed in tracked files, payload builder from/to, 200/403/401 gates unchanged. Branding (logo-assets-v1, sha256-verified, same-origin app/static/branding): - Login header uses h96 full lockup; logged-in topbar (base.html) uses h48 on a light chip (logo ink is ~2:1 vs the dark nav); favicons 32x32 + 16x16 in <head>. img-src 'self' data: blob: already allows /static/branding/*. - tests/test_branding_assets.py: page placement + same-origin serving + CSP. - AGENTS.md synced.
263 lines
10 KiB
Python
263 lines
10 KiB
Python
"""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."
|
|
)
|
|
|
|
|
|
# ── WhatsApp demo round trip (WHATSAPP_DEMO_TO) ─────────────────────
|
|
def build_demo_webhook_payload(
|
|
text: str = "Demo message — Denya OneCare WhatsApp round trip",
|
|
wa_message_id: str = "wamid.demo.000001",
|
|
) -> dict:
|
|
"""Build a Meta webhook payload for the WhatsApp demo round trip.
|
|
|
|
The message ``from`` is ``settings.WHATSAPP_DEMO_TO`` (E.164), so the demo
|
|
surfaces the expected number end-to-end: the webhook logs it in
|
|
``whatsapp_log`` (visible via ``GET /api/whatsapp/mock-log``) and the
|
|
auto-reply is sent back to the same number. The demo number is configured
|
|
per deployment in .env (never committed); when it is unset this raises
|
|
rather than fabricating a sender (same fail-closed posture as the webhook
|
|
secret).
|
|
"""
|
|
demo_to = (settings.WHATSAPP_DEMO_TO or "").strip()
|
|
if not demo_to:
|
|
raise RuntimeError(
|
|
"WHATSAPP_DEMO_TO is not configured — set the demo sender number "
|
|
"in .env to run the WhatsApp demo round trip."
|
|
)
|
|
return {
|
|
"object": "whatsapp_business_account",
|
|
"entry": [
|
|
{
|
|
"id": "1",
|
|
"changes": [
|
|
{
|
|
"id": wa_message_id,
|
|
"message": {
|
|
"from": demo_to,
|
|
"id": wa_message_id,
|
|
"text": {"text": text},
|
|
},
|
|
}
|
|
],
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
# ── 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())
|