149 lines
5.5 KiB
Python
149 lines
5.5 KiB
Python
import json
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .auth import current_user
|
|
from .config import settings
|
|
from .database import get_db
|
|
from .models import UserAccount, WhatsAppWebhookEvent
|
|
from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError
|
|
from .whatsapp_config import resolved_whatsapp_app_secret
|
|
from .whatsapp_security import verify_meta_signature
|
|
from .whatsapp_webhook import persist_webhook_payload
|
|
from .whatsapp_messages import record_inbound_messages
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/v1/whatsapp", tags=["whatsapp"])
|
|
|
|
RECOMMENDED_WEBHOOK_FIELDS = [
|
|
"messages",
|
|
"message_template_status_update",
|
|
"message_template_quality_update",
|
|
"account_update",
|
|
"phone_number_name_update",
|
|
"phone_number_quality_update",
|
|
"business_capability_update",
|
|
]
|
|
|
|
|
|
def _serialize_event(event: WhatsAppWebhookEvent) -> dict:
|
|
return {
|
|
"id": str(event.id),
|
|
"waba_id": event.waba_id,
|
|
"phone_number_id": event.phone_number_id,
|
|
"field": event.field,
|
|
"event_type": event.event_type,
|
|
"from_wa_id": event.from_wa_id,
|
|
"message_id": event.message_id,
|
|
"status": event.status,
|
|
"created_at": event.created_at,
|
|
}
|
|
|
|
|
|
@router.get("/webhook")
|
|
def verify_webhook(
|
|
hub_mode: str | None = Query(default=None, alias="hub.mode"),
|
|
hub_verify_token: str | None = Query(default=None, alias="hub.verify_token"),
|
|
hub_challenge: str | None = Query(default=None, alias="hub.challenge"),
|
|
) -> Response:
|
|
"""
|
|
Meta webhook verification (Step 2 — Configure Webhooks).
|
|
Meta sends hub.mode=subscribe, hub.verify_token, and hub.challenge.
|
|
"""
|
|
if not settings.whatsapp_verify_token:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="WHATSAPP_VERIFY_TOKEN is not configured",
|
|
)
|
|
if hub_mode == "subscribe" and hub_verify_token == settings.whatsapp_verify_token:
|
|
logger.info("WhatsApp webhook verified by Meta")
|
|
return Response(content=hub_challenge or "", media_type="text/plain")
|
|
logger.warning("WhatsApp webhook verification failed mode=%s", hub_mode)
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Verification failed")
|
|
|
|
|
|
@router.post("/webhook")
|
|
async def receive_webhook(
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
x_hub_signature_256: str | None = Header(default=None, alias="X-Hub-Signature-256"),
|
|
) -> dict[str, str]:
|
|
"""Receive inbound WhatsApp Business Platform webhook payloads."""
|
|
raw_body = await request.body()
|
|
app_secret = resolved_whatsapp_app_secret(db)
|
|
|
|
if app_secret:
|
|
if not verify_meta_signature(app_secret, raw_body, x_hub_signature_256):
|
|
logger.warning("WhatsApp webhook signature validation failed")
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid signature")
|
|
elif settings.environment == "production":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="WHATSAPP_APP_SECRET is required in production",
|
|
)
|
|
|
|
try:
|
|
payload = json.loads(raw_body.decode("utf-8") or "{}")
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
payload = {}
|
|
|
|
persist_webhook_payload(db, payload)
|
|
record_inbound_messages(db, payload)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.get("/setup")
|
|
def whatsapp_setup(
|
|
_: UserAccount = Depends(current_user),
|
|
) -> dict:
|
|
"""Console setup info — paste these values into Meta Developer Console."""
|
|
return {
|
|
"webhook_url": settings.whatsapp_webhook_url,
|
|
"verify_token": settings.whatsapp_verify_token or None,
|
|
"verify_token_configured": bool(settings.whatsapp_verify_token),
|
|
"app_secret_configured": bool(settings.whatsapp_app_secret),
|
|
"access_token_configured": bool(settings.whatsapp_access_token),
|
|
"phone_number_id": settings.whatsapp_phone_number_id or None,
|
|
"business_account_id": settings.whatsapp_business_account_id or None,
|
|
"meta_app_id": settings.meta_app_id or None,
|
|
"api_version": settings.whatsapp_api_version,
|
|
"recommended_fields": RECOMMENDED_WEBHOOK_FIELDS,
|
|
"docs_url": "https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview/",
|
|
}
|
|
|
|
|
|
@router.get("/events")
|
|
def list_webhook_events(
|
|
limit: int = Query(default=25, ge=1, le=100),
|
|
db: Session = Depends(get_db),
|
|
_: UserAccount = Depends(current_user),
|
|
) -> list[dict]:
|
|
events = db.scalars(
|
|
select(WhatsAppWebhookEvent)
|
|
.order_by(WhatsAppWebhookEvent.created_at.desc())
|
|
.limit(limit)
|
|
).all()
|
|
return [_serialize_event(event) for event in events]
|
|
|
|
|
|
class SendWhatsAppTextRequest(BaseModel):
|
|
to: str = Field(min_length=8, max_length=20)
|
|
body: str = Field(min_length=1, max_length=4096)
|
|
|
|
|
|
@router.post("/messages/send")
|
|
async def send_whatsapp_text(
|
|
payload: SendWhatsAppTextRequest,
|
|
_: UserAccount = Depends(current_user),
|
|
) -> dict:
|
|
"""Send a test text message via Graph API (requires WHATSAPP_ACCESS_TOKEN)."""
|
|
client = WhatsAppGraphClient()
|
|
try:
|
|
return await client.send_text(payload.to.strip(), payload.body.strip())
|
|
except WhatsAppGraphError as exc:
|
|
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|