urmi-ai/backend/app/routes_whatsapp.py
2026-10-15 01:58:00 +05:30

474 lines
16 KiB
Python

import json
import logging
from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Query, Request, Response, UploadFile, status
from pydantic import BaseModel, Field
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from .auth import current_user
from .config import settings
from .database import get_db
from .models import (
UserAccount,
WhatsAppBusinessAccount,
WhatsAppMetaConnection,
WhatsAppPhoneAsset,
WhatsAppWebhookEvent,
)
from .whatsapp_broadcast_jobs import create_csv_broadcast, list_broadcast_jobs
from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError
from .whatsapp_config import resolved_whatsapp_app_secret
from .whatsapp_onboarding import (
RECOMMENDED_WEBHOOK_FIELDS,
account_detail,
account_logs,
complete_signup,
connect_with_token,
create_marketing_template,
customer_token_for_phone,
developer_config,
disconnect_account,
download_account_media,
enable_customer_delivery,
list_account_messages,
list_accounts,
mask_identifier,
register_phone_number,
send_account_broadcast,
send_account_message,
list_templates,
refresh_account,
signup_public_config,
)
from .whatsapp_security import verify_meta_signature
from .whatsapp_webhook import persist_webhook_payload
from .whatsapp_messages import record_inbound_messages
from .whatsapp_template_service import apply_template_status_webhook
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/whatsapp", tags=["whatsapp"])
def _webhook_secrets(db: Session) -> list[str]:
"""Platform secret for the admin app, plus each customer's own Meta app secret."""
found: list[str] = []
primary = resolved_whatsapp_app_secret(db).strip()
if primary:
found.append(primary)
extras = db.scalars(
select(WhatsAppMetaConnection.meta_app_secret).where(
WhatsAppMetaConnection.meta_app_secret.is_not(None),
WhatsAppMetaConnection.status != "disconnected",
)
).all()
for item in extras:
value = (item or "").strip()
if value and value not in found:
found.append(value)
return found
def _serialize_event(event: WhatsAppWebhookEvent) -> dict:
return {
"id": str(event.id),
"waba_id": mask_identifier(event.waba_id),
"phone_number_id": mask_identifier(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()
secrets = _webhook_secrets(db)
if secrets:
if not any(verify_meta_signature(secret, raw_body, x_hub_signature_256) for secret in secrets):
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)
apply_template_status_webhook(db, payload)
return {"status": "ok"}
@router.get("/setup")
def whatsapp_setup(
_: UserAccount = Depends(current_user),
) -> dict:
"""Customer-safe webhook address. Secrets stay on the server."""
return {
"webhook_url": settings.whatsapp_webhook_url,
"verify_token_configured": bool(settings.whatsapp_verify_token),
"verify_token_visible": False,
"api_version": settings.whatsapp_api_version,
"recommended_fields": RECOMMENDED_WEBHOOK_FIELDS,
"docs_url": "https://developers.facebook.com/documentation/business-messaging/whatsapp/embedded-signup",
}
@router.get("/events")
def list_webhook_events(
limit: int = Query(default=25, ge=1, le=100),
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> list[dict]:
waba_ids = list(
db.scalars(select(WhatsAppBusinessAccount.waba_id).where(WhatsAppBusinessAccount.user_id == user.id)).all()
)
phone_ids = list(
db.scalars(select(WhatsAppPhoneAsset.phone_number_id).where(WhatsAppPhoneAsset.user_id == user.id)).all()
)
if not waba_ids and not phone_ids:
return []
filters = []
if waba_ids:
filters.append(WhatsAppWebhookEvent.waba_id.in_(waba_ids))
if phone_ids:
filters.append(WhatsAppWebhookEvent.phone_number_id.in_(phone_ids))
events = db.scalars(
select(WhatsAppWebhookEvent)
.where(or_(*filters))
.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)
phone_number_id: str = Field(min_length=4, max_length=64)
class MarketingTemplateRequest(BaseModel):
name: str = Field(min_length=1, max_length=512)
language: str = Field(default="en", max_length=16)
body: str = Field(min_length=1, max_length=1024)
class BroadcastSendRequest(BaseModel):
template_name: str = Field(min_length=1, max_length=512)
template_language: str = Field(default="en", max_length=16)
recipients: list[str] = Field(min_length=1, max_length=20)
body_parameters: list[str] = Field(default_factory=list, max_length=10)
product_policy: str = "STRICT"
use_sample_header: bool = False
class CustomerSendRequest(BaseModel):
to: str = Field(min_length=8, max_length=20)
message_type: str = "text"
text: str | None = Field(default=None, max_length=4096)
reply_to: str | None = None
template_name: str | None = None
template_language: str = "en"
media_link: str | None = None
media_caption: str | None = None
media_filename: str | None = None
interactive_body: str | None = None
interactive_buttons: list[dict[str, str]] | None = None
reaction_message_id: str | None = None
reaction_emoji: str | None = None
class RegisterPhoneRequest(BaseModel):
pin: str | None = Field(default=None, max_length=6)
class DeliverySecretRequest(BaseModel):
app_secret: str = Field(min_length=8, max_length=256)
class ManualTokenRequest(BaseModel):
access_token: str = Field(min_length=1, max_length=4096)
waba_id: str = Field(min_length=1, max_length=64)
phone_number_id: str | None = Field(default=None, max_length=64)
app_secret: str | None = Field(default=None, max_length=256)
class SignupCompleteRequest(BaseModel):
event: str = Field(min_length=1, max_length=80)
code: str | None = Field(default=None, max_length=2048)
waba_id: str | None = Field(default=None, max_length=64)
phone_number_id: str | None = Field(default=None, max_length=64)
business_id: str | None = Field(default=None, max_length=64)
error_message: str | None = Field(default=None, max_length=500)
@router.get("/signup/config")
def whatsapp_signup_config(
db: Session = Depends(get_db),
_: UserAccount = Depends(current_user),
) -> dict:
return signup_public_config(db)
@router.get("/accounts")
def whatsapp_accounts(
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> list[dict]:
return list_accounts(db, user)
@router.post("/signup/complete")
async def whatsapp_signup_complete(
payload: SignupCompleteRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await complete_signup(db, user, payload.model_dump())
@router.post("/connections/token")
async def whatsapp_connect_token(
payload: ManualTokenRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await connect_with_token(
db,
user,
payload.access_token,
payload.waba_id,
payload.phone_number_id,
payload.app_secret,
)
@router.get("/accounts/{account_id}")
def whatsapp_account_detail(
account_id: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return account_detail(db, user, account_id)
@router.post("/accounts/{account_id}/refresh")
async def whatsapp_account_refresh(
account_id: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await refresh_account(db, user, account_id)
@router.get("/accounts/{account_id}/messages")
def whatsapp_account_messages(
account_id: str,
limit: int = Query(default=300, ge=1, le=500),
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> list[dict]:
return list_account_messages(db, user, account_id, limit)
@router.post("/accounts/{account_id}/delivery")
async def whatsapp_account_delivery(
account_id: str,
payload: DeliverySecretRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await enable_customer_delivery(db, user, account_id, payload.app_secret)
@router.post("/accounts/{account_id}/messages/send")
async def whatsapp_account_send(
account_id: str,
payload: CustomerSendRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await send_account_message(db, user, account_id, payload.model_dump())
@router.post("/accounts/{account_id}/templates")
async def whatsapp_account_create_template(
account_id: str,
payload: MarketingTemplateRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await create_marketing_template(db, user, account_id, payload.name, payload.language, payload.body)
@router.post("/accounts/{account_id}/broadcasts")
async def whatsapp_account_broadcast(
account_id: str,
payload: BroadcastSendRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await send_account_broadcast(
db,
user,
account_id,
payload.template_name,
payload.template_language,
payload.recipients,
payload.body_parameters,
payload.product_policy,
payload.use_sample_header,
)
@router.get("/accounts/{account_id}/broadcasts/jobs")
def whatsapp_broadcast_jobs(
account_id: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> list[dict]:
return list_broadcast_jobs(db, user, account_id)
@router.post("/accounts/{account_id}/broadcasts/csv")
async def whatsapp_broadcast_csv(
account_id: str,
file: UploadFile = File(),
country_code: str = Form(default="91"),
template_name: str = Form(),
template_language: str = Form(default="en"),
product_policy: str = Form(default="STRICT"),
use_sample_header: str = Form(default="false"),
body_parameters: str = Form(default=""),
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
raw = await file.read()
if len(raw) > 1_000_000:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="The CSV must be under 1 MB.")
parameters = [line.strip() for line in body_parameters.splitlines() if line.strip()]
return await create_csv_broadcast(
db,
user,
account_id,
file.filename or "broadcast.csv",
raw,
country_code=country_code,
template_name=template_name,
template_language=template_language,
body_parameters=parameters,
product_policy=product_policy,
use_sample_header=use_sample_header.strip().lower() in {"1", "true", "yes", "on"},
)
@router.get("/accounts/{account_id}/media/{media_id}")
async def whatsapp_account_media(
account_id: str,
media_id: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> Response:
content, mime_type = await download_account_media(db, user, account_id, media_id)
return Response(content=content, media_type=mime_type)
@router.post("/accounts/{account_id}/phones/{phone_id}/register")
async def whatsapp_register_phone(
account_id: str,
phone_id: str,
payload: RegisterPhoneRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await register_phone_number(db, user, account_id, phone_id, payload.pin)
@router.post("/accounts/{account_id}/disconnect")
async def whatsapp_account_disconnect(
account_id: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await disconnect_account(db, user, account_id)
@router.get("/accounts/{account_id}/templates")
async def whatsapp_account_templates(
account_id: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await list_templates(db, user, account_id)
@router.get("/accounts/{account_id}/logs")
def whatsapp_account_logs(
account_id: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return account_logs(db, user, account_id)
@router.get("/accounts/{account_id}/developer")
def whatsapp_account_developer(
account_id: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return developer_config(db, user, account_id)
@router.post("/messages/send")
async def send_whatsapp_text(
payload: SendWhatsAppTextRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
"""Send from a WhatsApp number this workspace has actually connected."""
token, phone_number_id = customer_token_for_phone(db, user, payload.phone_number_id.strip())
client = WhatsAppGraphClient(access_token=token, phone_number_id=phone_number_id)
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