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, 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, customer_token_for_phone, developer_config, disconnect_account, download_account_media, list_account_messages, list_accounts, mask_identifier, register_phone_number, 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 _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() 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: """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 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 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.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}/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.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