Files
denya-onecare/app/routers/whatsapp.py
root a6eb799efa feat(whatsapp,branding): WhatsApp demo-number wiring + Denya logo assets
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.
2026-09-09 19:32:21 +00:00

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())