urmi-ai/backend/app/routes_whatsapp.py
2026-10-13 03:14:52 +05:30

428 lines
14 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 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_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
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)
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"
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,
)
@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