File size: 4,142 Bytes
582520a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 | """WhatsApp Business Cloud API webhook."""
from __future__ import annotations
import logging
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.db import get_db
from app.models import Conversation
from app.services.chat import handle_user_message, start_conversation
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/whatsapp", tags=["whatsapp"])
@router.get("/webhook")
async def verify_webhook(
hub_mode: str = Query(alias="hub.mode", default=""),
hub_verify_token: str = Query(
alias="hub.verify_token", default=""
),
hub_challenge: str = Query(
alias="hub.challenge", default=""
),
) -> Any:
"""Meta webhook verification handshake."""
settings = get_settings()
if (
hub_mode == "subscribe"
and hub_verify_token == settings.whatsapp_verify_token
):
return int(hub_challenge) if hub_challenge.isdigit() else hub_challenge
raise HTTPException(status_code=403, detail="Verification failed")
@router.post("/webhook")
async def receive_webhook(
request: Request,
db: AsyncSession = Depends(get_db),
) -> dict[str, str]:
"""Receive inbound WhatsApp messages."""
payload = await request.json()
settings = get_settings()
try:
entries = payload.get("entry", [])
for entry in entries:
for change in entry.get("changes", []):
value = change.get("value", {})
messages = value.get("messages", [])
for msg in messages:
if msg.get("type") != "text":
continue
wa_from = msg.get("from", "")
text = msg.get("text", {}).get("body", "")
if not wa_from or not text:
continue
await _process_incoming(
db,
wa_from=wa_from,
text=text,
settings=settings,
)
except Exception as exc: # noqa: BLE001
logger.exception("WhatsApp webhook error: %s", exc)
return {"status": "ok"}
async def _process_incoming(
db: AsyncSession,
*,
wa_from: str,
text: str,
settings: Any,
) -> None:
result = await db.execute(
select(Conversation).where(
Conversation.channel == "whatsapp",
Conversation.external_id == wa_from,
Conversation.status == "active",
)
)
conv = result.scalar_one_or_none()
if conv is None:
conv, _greeting = await start_conversation(
db,
channel="whatsapp",
ui_language="en",
external_id=wa_from,
)
data = await handle_user_message(
db,
conversation_id=conv.id,
message=text,
ui_language=conv.language or "en",
)
await send_whatsapp_text(wa_from, data["reply"])
async def send_whatsapp_text(to: str, body: str) -> None:
"""Send a WhatsApp text message if credentials exist."""
settings = get_settings()
if not settings.has_whatsapp:
logger.info(
"WhatsApp demo send to=%s body=%s", to, body[:120]
)
return
url = (
f"https://graph.facebook.com/v20.0/"
f"{settings.whatsapp_phone_number_id}/messages"
)
headers = {
"Authorization": f"Bearer {settings.whatsapp_token}",
"Content-Type": "application/json",
}
payload = {
"messaging_product": "whatsapp",
"to": to,
"type": "text",
"text": {"body": body[:4000]},
}
async with httpx.AsyncClient(timeout=20) as client:
response = await client.post(
url, headers=headers, json=payload
)
if response.status_code >= 400:
logger.error(
"WhatsApp send failed: %s %s",
response.status_code,
response.text,
)
|