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 broadcast_failure_csv, 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.get("/accounts/{account_id}/broadcasts/jobs/{job_id}/failures.csv") def whatsapp_broadcast_failures( account_id: str, job_id: str, db: Session = Depends(get_db), user: UserAccount = Depends(current_user), ) -> Response: content, filename = broadcast_failure_csv(db, user, account_id, job_id) return Response( content=content, media_type="text/csv; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @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