"""Customer WhatsApp Embedded Signup provisioning. Tokens and the platform verify token never leave this module's responses. Each WABA and phone number is stored against one Urmi user and cannot be addressed by another tenant. """ from __future__ import annotations import logging import re import secrets import uuid from datetime import datetime, timezone from typing import Any import httpx from fastapi import HTTPException, status from sqlalchemy import or_, select from sqlalchemy.orm import Session, selectinload from .config import settings from .models import ( UserAccount, WhatsAppBusinessAccount, WhatsAppConnectionAudit, WhatsAppMessage, WhatsAppMetaConnection, WhatsAppPhoneAsset, WhatsAppWebhookEvent, ) from .whatsapp_config import resolved_whatsapp_app_secret from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError from .whatsapp_messages import extract_wa_message_id, normalize_wa_number, record_outbound_message, serialize_message logger = logging.getLogger(__name__) 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 signup_public_config(db: Session) -> dict[str, Any]: app_id = settings.meta_app_id.strip() config_id = settings.meta_embedded_signup_config_id.strip() secret = resolved_whatsapp_app_secret(db) return { "app_id": app_id or None, "config_id": config_id or None, "api_version": settings.whatsapp_api_version, "sdk_version": "v26.0", "ready": bool(app_id and config_id and secret), } def _audit( db: Session, user: UserAccount, event: str, detail: str, *, connection_id: Any = None, waba_id: str | None = None, ) -> None: db.add( WhatsAppConnectionAudit( user_id=user.id, connection_id=connection_id, waba_id=waba_id, event=event, detail=detail[:500], ) ) def mask_identifier(value: str | None) -> str | None: if not value: return None tail = value[-4:] return f"••••••••{tail}" def _quality_label(value: str | None) -> str | None: if not value: return None normalized = value.strip().upper() return { "GREEN": "High", "HIGH": "High", "YELLOW": "Medium", "MEDIUM": "Medium", "RED": "Low", "LOW": "Low", "UNKNOWN": "Unknown", }.get(normalized, value.title()) def _graph_error_message(data: Any, fallback: str) -> str: message = fallback if isinstance(data, dict): error = data.get("error") if isinstance(error, dict) and error.get("message"): message = str(error["message"]) message = re.sub(r"(access_token|token|secret)=([^&\s]+)", r"\1=redacted", message, flags=re.I) return message[:500] async def _graph( method: str, path: str, *, token: str | None = None, params: dict[str, Any] | None = None, json_body: dict[str, Any] | None = None, ) -> dict[str, Any]: url = f"https://graph.facebook.com/{settings.whatsapp_api_version}/{path.lstrip('/')}" headers = {"Authorization": f"Bearer {token}"} if token else {} async with httpx.AsyncClient(timeout=60.0) as client: response = await client.request(method, url, params=params, json=json_body, headers=headers) try: data = response.json() except ValueError: data = {} if response.status_code >= 400: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=_graph_error_message(data, "Meta could not complete WhatsApp setup"), ) return data if isinstance(data, dict) else {} async def _exchange_code(db: Session, code: str) -> str: app_id = settings.meta_app_id.strip() secret = resolved_whatsapp_app_secret(db) if not app_id or not secret: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="WhatsApp Embedded Signup is not configured", ) data = await _graph( "GET", "oauth/access_token", params={"client_id": app_id, "client_secret": secret, "code": code}, ) token = str(data.get("access_token") or "") if not token: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail="Meta did not return an authorization token", ) return token async def _debug_token(db: Session, token: str) -> dict[str, Any]: app_id = settings.meta_app_id.strip() secret = resolved_whatsapp_app_secret(db) data = await _graph( "GET", "debug_token", params={"input_token": token, "access_token": f"{app_id}|{secret}"}, ) payload = data.get("data") return payload if isinstance(payload, dict) else {} def _target_ids(debug: dict[str, Any], scope: str) -> list[str]: found: list[str] = [] scopes = debug.get("granular_scopes") if not isinstance(scopes, list): return found for item in scopes: if not isinstance(item, dict) or item.get("scope") != scope: continue for target in item.get("target_ids") or []: value = str(target).strip() if value and value not in found: found.append(value) return found def _waba_ids_from_debug(debug: dict[str, Any]) -> list[str]: """whatsapp_business_management targets are WABA IDs.""" return _target_ids(debug, "whatsapp_business_management") def _phone_ids_from_debug(debug: dict[str, Any]) -> list[str]: """whatsapp_business_messaging targets are phone number IDs, not WABA IDs.""" return _target_ids(debug, "whatsapp_business_messaging") def _as_uuid(account_id: Any) -> uuid.UUID: try: return account_id if isinstance(account_id, uuid.UUID) else uuid.UUID(str(account_id)) except ValueError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="WhatsApp account not found") from exc def _owned_account(db: Session, user: UserAccount, account_id: Any) -> WhatsAppBusinessAccount: account = db.scalar( select(WhatsAppBusinessAccount) .options(selectinload(WhatsAppBusinessAccount.phones), selectinload(WhatsAppBusinessAccount.connection)) .where(WhatsAppBusinessAccount.id == _as_uuid(account_id), WhatsAppBusinessAccount.user_id == user.id) ) if account is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="WhatsApp account not found") return account def _serialize_phone(phone: WhatsAppPhoneAsset) -> dict[str, Any]: verified = (phone.code_verification_status or "").upper() return { "id": str(phone.id), "phone_number_id": phone.phone_number_id, "display_phone": phone.display_phone, "verified_name": phone.verified_name, "quality": phone.quality, "status": phone.status, "code_verification_status": phone.code_verification_status, "needs_otp": phone.status != "connected" and verified in {"NOT_VERIFIED", "EXPIRED"}, "needs_registration": phone.status != "connected", } def serialize_account(account: WhatsAppBusinessAccount) -> dict[str, Any]: phones = [_serialize_phone(phone) for phone in account.phones] primary = next((phone for phone in phones if phone["status"] == "connected"), phones[0] if phones else None) connection = account.connection usable = account.status == "connected" return { "id": str(account.id), "waba_id_masked": mask_identifier(account.waba_id), "name": account.name, "business_name": connection.business_name if connection else None, "business_id_masked": mask_identifier(connection.meta_business_id) if connection else None, "status": account.status, "usable": usable, "messaging_status": account.messaging_status if usable else "unavailable", "management_status": account.management_status if usable or account.status == "incomplete" else "unavailable", "templates_status": account.templates_status if account.templates_status == "active" else "unavailable", "webhooks_status": account.webhooks_status if account.webhooks_status == "active" else "unavailable", "template_count": account.template_count, "quality": primary["quality"] if primary else None, "primary_phone": primary, "phones": phones, "last_error": account.last_error, "updated_at": account.updated_at, } def list_accounts(db: Session, user: UserAccount) -> list[dict[str, Any]]: rows = db.scalars( select(WhatsAppBusinessAccount) .options(selectinload(WhatsAppBusinessAccount.phones), selectinload(WhatsAppBusinessAccount.connection)) .where(WhatsAppBusinessAccount.user_id == user.id) .order_by(WhatsAppBusinessAccount.created_at.desc()) ).all() return [serialize_account(row) for row in rows] async def _load_waba(token: str, waba_id: str) -> dict[str, Any]: return await _graph_fields(token, waba_id, ["id,name,owner_business_info", "id,name"]) async def _load_phones(token: str, waba_id: str) -> list[dict[str, Any]]: data = await _graph( "GET", f"{waba_id}/phone_numbers", token=token, params={ "fields": "id,display_phone_number,verified_name,quality_rating,code_verification_status,status", "limit": "50", }, ) rows = data.get("data") return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else [] async def _subscribe(token: str, waba_id: str) -> bool: try: await _graph("POST", f"{waba_id}/subscribed_apps", token=token) return True except HTTPException: logger.warning("WhatsApp subscribed_apps failed for waba %s", waba_id[-4:]) return False def _customer_callback() -> dict[str, str] | None: """Public address where a customer's own Meta app should deliver messages.""" url = settings.whatsapp_webhook_url verify = settings.whatsapp_verify_token.strip() if not url.startswith("https://") or not verify or len(url) > 200: return None return {"override_callback_uri": url, "verify_token": verify} async def _token_app_id(token: str) -> str | None: try: data = await _graph("GET", "app", token=token, params={"fields": "id"}) except HTTPException: return None app_id = str(data.get("id") or "").strip() return app_id or None async def _attach_customer_webhook(token: str, waba_id: str, phone_ids: list[str]) -> bool: """Send this account's messages to the workspace, using the customer's token.""" callback = _customer_callback() if callback is None: return False try: await _graph("POST", f"{waba_id}/subscribed_apps", token=token, json_body=callback) except HTTPException: logger.warning("WhatsApp webhook override failed for waba %s", waba_id[-4:]) return False for phone_id in phone_ids: try: await _graph( "POST", phone_id, token=token, json_body={"webhook_configuration": callback}, ) except HTTPException: logger.info("Phone webhook override failed for %s", phone_id[-4:]) return True async def _template_count(token: str, waba_id: str) -> tuple[int, bool]: try: data = await _graph( "GET", f"{waba_id}/message_templates", token=token, params={"limit": "1", "summary": "total_count"}, ) except HTTPException: return 0, False summary = data.get("summary") if isinstance(summary, dict) and isinstance(summary.get("total_count"), int): return summary["total_count"], True rows = data.get("data") return (len(rows) if isinstance(rows, list) else 0), True def _claim_waba(db: Session, user: UserAccount, waba_id: str) -> WhatsAppBusinessAccount | None: existing = db.scalar(select(WhatsAppBusinessAccount).where(WhatsAppBusinessAccount.waba_id == waba_id)) if existing is not None and existing.user_id != user.id: return None return existing def _apply_phones( db: Session, user: UserAccount, account: WhatsAppBusinessAccount, phones: list[dict[str, Any]], session_phone_id: str | None, ) -> None: seen = {str(item.get("id")) for item in phones if item.get("id")} if session_phone_id and session_phone_id not in seen: phones.append({"id": session_phone_id}) current = {phone.phone_number_id: phone for phone in account.phones} for item in phones: phone_id = str(item.get("id") or "") if not phone_id: continue other = db.scalar(select(WhatsAppPhoneAsset).where(WhatsAppPhoneAsset.phone_number_id == phone_id)) if other is not None and other.user_id != user.id: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="This phone number is already connected to another Urmi workspace", ) raw_status = str(item.get("status") or "PENDING").upper() phone_status = "connected" if raw_status == "CONNECTED" else raw_status.lower() row = current.get(phone_id) or other if row is None: row = WhatsAppPhoneAsset( user_id=user.id, account_id=account.id, phone_number_id=phone_id, ) db.add(row) if row not in account.phones: account.phones.append(row) row.user_id = user.id row.account_id = account.id row.display_phone = str(item.get("display_phone_number") or row.display_phone or "") row.verified_name = item.get("verified_name") or row.verified_name row.quality = _quality_label(item.get("quality_rating")) row.status = phone_status row.code_verification_status = item.get("code_verification_status") def _pin_for_phone(phone: WhatsAppPhoneAsset, requested: str | None = None) -> str: pin = (requested or phone.two_step_pin or "").strip() if pin.isdigit() and len(pin) == 6: phone.two_step_pin = pin return pin pin = "".join(secrets.choice("0123456789") for _ in range(6)) phone.two_step_pin = pin return pin async def _register_phone(token: str, phone: WhatsAppPhoneAsset, pin: str) -> str | None: try: await _graph( "POST", f"{phone.phone_number_id}/register", token=token, json_body={"messaging_product": "whatsapp", "pin": pin}, ) return None except HTTPException as exc: detail = str(exc.detail) lowered = detail.lower() if "already" in lowered and "register" in lowered: return None return detail[:500] def _set_account_health(db: Session, user: UserAccount, account: WhatsAppBusinessAccount) -> None: connected = any(phone.status == "connected" for phone in account.phones) otp_pending = any( phone.status != "connected" and (phone.code_verification_status or "").upper() in {"NOT_VERIFIED", "EXPIRED"} for phone in account.phones ) if connected and account.management_status == "active": account.status = "connected" account.messaging_status = "active" account.last_error = None _audit( db, user, "connected", "WhatsApp Business connection verified", connection_id=account.connection_id, waba_id=account.waba_id, ) return account.status = "incomplete" account.messaging_status = "unavailable" if otp_pending: account.last_error = ( "This number still needs the SMS or voice code from Meta. " "It cannot stay logged into the WhatsApp or WhatsApp Business app while Cloud API uses it." ) else: account.last_error = account.last_error or ( "This number is attached to the WhatsApp Business Account, but Meta has not registered it for Cloud API yet. " "Enter the 6-digit two-step PIN if this number already has one, then register it." ) _audit( db, user, "provision_incomplete", account.last_error, connection_id=account.connection_id, waba_id=account.waba_id, ) async def _provision_waba( db: Session, user: UserAccount, connection: WhatsAppMetaConnection, token: str, waba_id: str, session_phone_id: str | None, ) -> WhatsAppBusinessAccount: claimed = _claim_waba(db, user, waba_id) if claimed is None and db.scalar(select(WhatsAppBusinessAccount.id).where(WhatsAppBusinessAccount.waba_id == waba_id)): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="This WhatsApp Business Account is already connected to another Urmi workspace", ) try: waba = await _load_waba(token, waba_id) except HTTPException as exc: account = claimed or WhatsAppBusinessAccount( user_id=user.id, connection_id=connection.id, waba_id=waba_id, name="WhatsApp Business", ) if claimed is None: db.add(account) db.flush() detail = str(exc.detail)[:500] expired = "expired" in detail.lower() or "validating access token" in detail.lower() account.status = "expired" if expired else "failed" account.last_error = detail account.messaging_status = "unavailable" account.management_status = "unavailable" connection.status = "failed" connection.last_error = account.last_error _audit(db, user, "provision_failed", account.last_error or "", connection_id=connection.id, waba_id=waba_id) return account owner = waba.get("owner_business_info") if isinstance(waba.get("owner_business_info"), dict) else {} business_id = str(owner.get("id") or connection.meta_business_id or "") business_name = str(owner.get("name") or connection.business_name or waba.get("name") or "WhatsApp Business") connection.meta_business_id = business_id or connection.meta_business_id connection.business_name = business_name connection.status = "connected" connection.last_error = None connection.access_token = token account = claimed or WhatsAppBusinessAccount( user_id=user.id, connection_id=connection.id, waba_id=waba_id, ) if claimed is None: db.add(account) db.flush() previous_connection_id = account.connection_id if claimed is not None else None account.user_id = user.id account.connection_id = connection.id if previous_connection_id and previous_connection_id != connection.id: previous = db.get(WhatsAppMetaConnection, previous_connection_id) if previous is not None and previous.user_id == user.id: previous.access_token = None account.name = str(waba.get("name") or business_name) account.last_error = None account.management_status = "active" try: phones = await _load_phones(token, waba_id) except HTTPException as exc: phones = [] account.last_error = str(exc.detail)[:500] _apply_phones(db, user, account, phones, session_phone_id) for phone in account.phones: verified = (phone.code_verification_status or "").upper() if phone.status == "connected": continue if verified in {"NOT_VERIFIED", "EXPIRED"}: continue pin = _pin_for_phone(phone) register_error = await _register_phone(token, phone, pin) if register_error: account.last_error = register_error else: phone.status = "connected" try: phones = await _load_phones(token, waba_id) _apply_phones(db, user, account, phones, session_phone_id) except HTTPException: pass subscribed = await _subscribe(token, waba_id) template_count, templates_ok = await _template_count(token, waba_id) account.template_count = template_count account.templates_status = "active" if templates_ok else "unavailable" account.webhooks_status = "active" if subscribed else "unavailable" _set_account_health(db, user, account) return account async def complete_signup(db: Session, user: UserAccount, payload: dict[str, Any]) -> dict[str, Any]: event = str(payload.get("event") or "").upper() error_message = str(payload.get("error_message") or "").strip()[:500] if event == "CANCEL": _audit(db, user, "signup_cancelled", "Customer closed Embedded Signup") db.commit() return {"status": "cancelled", "message": "WhatsApp setup was cancelled. Nothing was connected.", "accounts": []} if event == "ERROR" or event == "DENIED": _audit(db, user, "signup_denied", error_message or "Meta denied the requested permissions") db.commit() return { "status": "denied", "message": error_message or "Meta did not grant the permissions Urmi needs. You can try again.", "accounts": [], } if not event.startswith("FINISH"): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unrecognized Embedded Signup result") code = str(payload.get("code") or "").strip() if not code: _audit(db, user, "provision_failed", "Embedded Signup finished without an authorization code") db.commit() return { "status": "failed", "message": "Meta finished without an authorization code. Start setup again.", "accounts": [], } token = await _exchange_code(db, code) debug = await _debug_token(db, token) if debug.get("is_valid") is False: _audit(db, user, "authorization_expired", "Meta authorization was not valid") db.commit() return { "status": "expired", "message": "The Meta authorization expired before Urmi could finish setup. Start setup again.", "accounts": [], } session_waba = str(payload.get("waba_id") or "").strip() session_phone = str(payload.get("phone_number_id") or "").strip() or None management_ids = _waba_ids_from_debug(debug) phone_ids = _phone_ids_from_debug(debug) if session_waba and session_waba in phone_ids and session_waba not in management_ids: session_phone = session_phone or session_waba session_waba = "" waba_ids = [session_waba] if session_waba else management_ids if not session_phone and len(phone_ids) == 1: session_phone = phone_ids[0] if not waba_ids: _audit(db, user, "provision_failed", "Embedded Signup did not return a WhatsApp Business Account") db.commit() return { "status": "failed", "message": "Meta did not return a WhatsApp Business Account. Finish business and number setup in the Meta window, then try again.", "accounts": [], } scopes = debug.get("scopes") granted = ",".join(str(item) for item in scopes) if isinstance(scopes, list) else None connection = WhatsAppMetaConnection( user_id=user.id, status="pending", meta_business_id=str(payload.get("business_id") or "") or None, granted_scopes=granted, access_token=token, ) db.add(connection) db.flush() _audit(db, user, "authorization_received", "Embedded Signup returned an authorization code", connection_id=connection.id) accounts: list[WhatsAppBusinessAccount] = [] for waba_id in waba_ids: accounts.append(await _provision_waba(db, user, connection, token, waba_id, session_phone)) db.commit() for account in accounts: db.refresh(account) statuses = {account.status for account in accounts} if "connected" in statuses and statuses <= {"connected"}: overall = "connected" message = "WhatsApp Business is connected." elif "connected" in statuses: overall = "connected" message = "Some WhatsApp Business accounts are connected. Others still need attention." elif "incomplete" in statuses: overall = "incomplete" message = "Meta authorization succeeded, but phone verification is not complete. Finish verification in Meta, then reconnect." else: overall = "failed" message = accounts[0].last_error or "Urmi could not finish WhatsApp setup." return {"status": overall, "message": message, "accounts": [serialize_account(account) for account in accounts]} _META_ID = re.compile(r"^\d{6,32}$") def _clean_token(value: str) -> str: token = "".join(str(value or "").split()) if token.lower().startswith("bearer"): token = token[6:] return token def _manual_connection(connection: WhatsAppMetaConnection | None) -> bool: return bool(connection and connection.granted_scopes == "manual") def _field_rejected(exc: HTTPException) -> bool: detail = str(exc.detail).lower() return "nonexisting field" in detail or "(#100)" in detail async def _graph_fields(token: str, path: str, field_sets: list[str], extra: dict[str, Any] | None = None) -> dict[str, Any]: """Try the smallest field set that this token's Graph version accepts.""" last: HTTPException | None = None for fields in field_sets: params = dict(extra or {}) params["fields"] = fields try: return await _graph("GET", path, token=token, params=params) except HTTPException as exc: last = exc if _field_rejected(exc): continue raise if last is not None: raise last return {} async def _phones_for_token(token: str, waba_id: str, phone_id: str | None) -> list[dict[str, Any]]: """Read numbers with the token the customer supplied. Never registers a number.""" rows: list[dict[str, Any]] = [] try: data = await _graph_fields( token, f"{waba_id}/phone_numbers", ["id,display_phone_number,verified_name", "id"], {"limit": "50"}, ) raw = data.get("data") rows = [row for row in raw if isinstance(row, dict)] if isinstance(raw, list) else [] except HTTPException: logger.info("Token connection could not list numbers for WhatsApp account %s", waba_id[-4:]) for row in rows: if not row.get("status"): row["status"] = "CONNECTED" seen = {str(row.get("id")) for row in rows if row.get("id")} if phone_id and phone_id not in seen: detail: dict[str, Any] = {"id": phone_id, "status": "CONNECTED"} try: node = await _graph_fields(token, phone_id, ["id,display_phone_number,verified_name", "id"]) if isinstance(node, dict): detail.update({key: value for key, value in node.items() if value}) detail["status"] = "CONNECTED" except HTTPException: logger.info("Token connection stored phone %s without extra fields", phone_id[-4:]) rows.append(detail) return rows async def _save_token_account( db: Session, user: UserAccount, connection: WhatsAppMetaConnection, token: str, waba_id: str, phone_id: str | None, ) -> WhatsAppBusinessAccount: """Store a pasted WhatsApp token without Embedded Signup fields or phone registration.""" claimed = _claim_waba(db, user, waba_id) if claimed is None and db.scalar(select(WhatsAppBusinessAccount.id).where(WhatsAppBusinessAccount.waba_id == waba_id)): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="This WhatsApp Business Account is already connected to another Urmi workspace", ) try: waba = await _graph_fields(token, waba_id, ["id,name"]) except HTTPException as exc: account = claimed or WhatsAppBusinessAccount( user_id=user.id, connection_id=connection.id, waba_id=waba_id, name="WhatsApp Business", ) if claimed is None: db.add(account) db.flush() detail = str(exc.detail)[:500] expired = "expired" in detail.lower() or "validating access token" in detail.lower() account.status = "expired" if expired else "failed" account.last_error = detail account.messaging_status = "unavailable" account.management_status = "unavailable" connection.status = "failed" connection.last_error = account.last_error _audit(db, user, "token_rejected", account.last_error or "", connection_id=connection.id, waba_id=waba_id) return account name = str(waba.get("name") or "WhatsApp Business") connection.business_name = name connection.status = "connected" connection.last_error = None connection.access_token = token connection.granted_scopes = "manual" account = claimed or WhatsAppBusinessAccount( user_id=user.id, connection_id=connection.id, waba_id=waba_id, ) if claimed is None: db.add(account) db.flush() previous_connection_id = account.connection_id if claimed is not None else None account.user_id = user.id account.connection_id = connection.id if previous_connection_id and previous_connection_id != connection.id: previous = db.get(WhatsAppMetaConnection, previous_connection_id) if previous is not None and previous.user_id == user.id: if not connection.meta_app_secret and previous.meta_app_secret: connection.meta_app_secret = previous.meta_app_secret if not connection.meta_app_id and previous.meta_app_id: connection.meta_app_id = previous.meta_app_id previous.access_token = None previous.meta_app_secret = None account.name = name account.last_error = None account.management_status = "active" phones = await _phones_for_token(token, waba_id, phone_id) if phones: _apply_phones(db, user, account, phones, phone_id) app_id = await _token_app_id(token) if app_id: connection.meta_app_id = app_id platform_app = settings.meta_app_id.strip() same_app = bool(app_id and platform_app and app_id == platform_app) phone_ids = [phone.phone_number_id for phone in account.phones] if same_app or connection.meta_app_secret: receiving = await _attach_customer_webhook(token, waba_id, phone_ids) if not receiving and same_app: receiving = await _subscribe(token, waba_id) else: await _subscribe(token, waba_id) receiving = False template_count, templates_ok = await _template_count(token, waba_id) account.template_count = template_count account.templates_status = "active" if templates_ok else "unavailable" account.webhooks_status = "active" if receiving else "unavailable" if account.phones: account.status = "connected" account.messaging_status = "active" if receiving: account.last_error = None elif same_app: account.last_error = "Sending is on. Meta did not accept the delivery address for replies." else: account.last_error = ( "Sending uses this token. Save it again with the App Secret from the Meta app that created the token " "so replies arrive in this workspace." ) _audit(db, user, "token_connected", "WhatsApp token saved for this account", connection_id=connection.id, waba_id=waba_id) else: account.status = "incomplete" account.messaging_status = "unavailable" account.last_error = "The token is saved. Add the phone number ID from Meta so this account has a number to use." _audit(db, user, "token_needs_phone", account.last_error, connection_id=connection.id, waba_id=waba_id) return account async def connect_with_token( db: Session, user: UserAccount, access_token: str, waba_id: str, phone_number_id: str | None = None, app_secret: str | None = None, ) -> dict[str, Any]: """Store a customer-supplied WhatsApp token. Does not use Embedded Signup.""" token = _clean_token(access_token) account_id = "".join(str(waba_id or "").split()) phone_id = "".join(str(phone_number_id or "").split()) or None if len(token) < 20: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Paste the access token from Meta.") if not _META_ID.match(account_id): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Enter the WhatsApp Business account ID from Meta. It is a number.", ) if phone_id and not _META_ID.match(phone_id): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Phone number ID should be the number Meta shows for that phone.", ) secret = "".join(str(app_secret or "").split()) if secret and len(secret) < 8: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="App secret should be the value from the Meta app that created this token.", ) connection = WhatsAppMetaConnection( user_id=user.id, status="pending", granted_scopes="manual", access_token=token, meta_app_secret=secret or None, ) db.add(connection) db.flush() _audit( db, user, "token_saved", "Customer saved a WhatsApp access token", connection_id=connection.id, waba_id=account_id, ) account = await _save_token_account(db, user, connection, token, account_id, phone_id) db.commit() db.refresh(account) if account.status == "connected": overall = "connected" message = account.last_error or "This WhatsApp account is saved. Messages send and arrive in this workspace." elif account.status == "incomplete": overall = "incomplete" message = account.last_error or "The token is saved. Add the phone number ID to finish." elif account.status == "expired": overall = "expired" message = account.last_error or "Meta did not accept this access token." else: overall = "failed" message = account.last_error or "Meta did not accept this WhatsApp token." return {"status": overall, "message": message, "accounts": [serialize_account(account)]} async def refresh_account(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]: account = _owned_account(db, user, account_id) connection = account.connection token = connection.access_token if connection else None if connection is None or not token or connection.status == "disconnected": account.status = "expired" account.messaging_status = "unavailable" account.last_error = "Authorization expired. Reconnect this WhatsApp account." _audit(db, user, "authorization_expired", account.last_error, connection_id=connection.id if connection else None, waba_id=account.waba_id) db.commit() db.refresh(account) return serialize_account(account) if _manual_connection(connection): await _save_token_account(db, user, connection, token, account.waba_id, None) else: await _provision_waba(db, user, connection, token, account.waba_id, None) db.commit() db.refresh(account) return serialize_account(account) async def register_phone_number( db: Session, user: UserAccount, account_id: Any, phone_id: Any, pin: str | None, ) -> dict[str, Any]: account = _owned_account(db, user, account_id) connection = account.connection token = connection.access_token if connection else None if connection is None or not token or connection.status == "disconnected": raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reconnect this WhatsApp account before registering the number") phone = next((item for item in account.phones if str(item.id) == str(phone_id)), None) if phone is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Phone number not found on this account") verified = (phone.code_verification_status or "").upper() if verified in {"NOT_VERIFIED", "EXPIRED"}: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Complete the SMS or voice code for this number in Meta first. Then register it for Cloud API.", ) submitted = (pin or "").strip() if submitted and (not submitted.isdigit() or len(submitted) != 6): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Enter the 6-digit two-step PIN for this number") register_pin = _pin_for_phone(phone, submitted or None) register_error = await _register_phone(token, phone, register_pin) if register_error: account.last_error = register_error _audit(db, user, "register_failed", register_error, connection_id=connection.id, waba_id=account.waba_id) db.commit() raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=register_error) try: phones = await _load_phones(token, account.waba_id) _apply_phones(db, user, account, phones, phone.phone_number_id) except HTTPException: phone.status = "connected" _audit(db, user, "number_registered", "Registered this number for Cloud API", connection_id=connection.id, waba_id=account.waba_id) _set_account_health(db, user, account) db.commit() db.refresh(account) return serialize_account(account) async def disconnect_account(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]: account = _owned_account(db, user, account_id) connection = account.connection token = connection.access_token if connection else None if token: try: await _graph("DELETE", f"{account.waba_id}/subscribed_apps", token=token) except HTTPException: logger.info("Could not unsubscribe WABA %s during disconnect", account.waba_id[-4:]) account.status = "disconnected" account.messaging_status = "unavailable" account.management_status = "unavailable" account.templates_status = "unavailable" account.webhooks_status = "unavailable" account.last_error = None for phone in account.phones: phone.status = "disconnected" if connection is not None: siblings_remain = ( db.scalar( select(WhatsAppBusinessAccount.id).where( WhatsAppBusinessAccount.connection_id == connection.id, WhatsAppBusinessAccount.id != account.id, WhatsAppBusinessAccount.status != "disconnected", ) ) is not None ) if not siblings_remain: connection.status = "disconnected" connection.access_token = None connection.disconnected_at = datetime.now(timezone.utc) _audit(db, user, "disconnected", "Customer disconnected this WhatsApp Business account", connection_id=connection.id if connection else None, waba_id=account.waba_id) db.commit() db.refresh(account) return serialize_account(account) def account_detail(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]: return serialize_account(_owned_account(db, user, account_id)) async def list_templates(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]: account = _owned_account(db, user, account_id) if account.templates_status != "active" or account.connection is None or not account.connection.access_token: return {"available": False, "templates": []} data = await _graph( "GET", f"{account.waba_id}/message_templates", token=account.connection.access_token, params={"fields": "name,category,language,status,quality_score", "limit": "50"}, ) rows = data.get("data") if isinstance(data.get("data"), list) else [] templates = [] for row in rows: if not isinstance(row, dict): continue quality = row.get("quality_score") score = quality.get("score") if isinstance(quality, dict) else None templates.append( { "name": row.get("name"), "category": row.get("category"), "language": row.get("language"), "status": row.get("status"), "quality": _quality_label(str(score) if score else None), } ) account.template_count = len(templates) db.commit() return {"available": True, "templates": templates} _TEMPLATE_NAME = re.compile(r"^[a-z0-9_]{1,512}$") async def create_marketing_template( db: Session, user: UserAccount, account_id: Any, name: str, language: str, body: str, ) -> dict[str, Any]: """Submit a marketing template to Meta for the connected WhatsApp account.""" account = _owned_account(db, user, account_id) connection = account.connection token = connection.access_token if connection else None if connection is None or not token or connection.status == "disconnected": raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Save the access token again before creating a template.") template_name = name.strip().lower() template_language = language.strip() or "en" text = body.strip() if not _TEMPLATE_NAME.match(template_name): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Template name should use lowercase letters, numbers, and underscores.", ) if not re.fullmatch(r"[a-z]{2}(_[A-Z]{2})?", template_language): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Language should look like en or en_US.") if not text or len(text) > 1024: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Template text should be 1 to 1024 characters.") if "{{" in text: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="This test template cannot include placeholders such as {{1}}.", ) data = await _graph( "POST", f"{account.waba_id}/message_templates", token=token, json_body={ "name": template_name, "language": template_language, "category": "MARKETING", "components": [{"type": "BODY", "text": text}], }, ) return { "id": data.get("id"), "name": template_name, "language": template_language, "category": str(data.get("category") or "MARKETING"), "status": str(data.get("status") or "PENDING"), } def account_logs(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]: account = _owned_account(db, user, account_id) audits = db.scalars( select(WhatsAppConnectionAudit) .where( WhatsAppConnectionAudit.user_id == user.id, or_( WhatsAppConnectionAudit.waba_id == account.waba_id, WhatsAppConnectionAudit.connection_id == account.connection_id, ), ) .order_by(WhatsAppConnectionAudit.created_at.desc()) .limit(50) ).all() phone_ids = [phone.phone_number_id for phone in account.phones] event_filter = [WhatsAppWebhookEvent.waba_id == account.waba_id] if phone_ids: event_filter.append(WhatsAppWebhookEvent.phone_number_id.in_(phone_ids)) events = db.scalars( select(WhatsAppWebhookEvent) .where(or_(*event_filter)) .order_by(WhatsAppWebhookEvent.created_at.desc()) .limit(25) ).all() return { "audits": [ { "id": str(item.id), "event": item.event, "detail": item.detail, "created_at": item.created_at, } for item in audits ], "events": [ { "id": str(item.id), "field": item.field, "event_type": item.event_type, "from_wa_id": item.from_wa_id, "status": item.status, "created_at": item.created_at, } for item in events ], } def developer_config(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]: account = _owned_account(db, user, account_id) return { "webhook_url": settings.whatsapp_webhook_url, "verify_token_configured": bool(settings.whatsapp_verify_token), "verify_token_visible": False, "recommended_fields": RECOMMENDED_WEBHOOK_FIELDS, "webhooks_status": account.webhooks_status, "docs_url": "https://developers.facebook.com/documentation/business-messaging/whatsapp/embedded-signup", } def customer_token_for_phone(db: Session, user: UserAccount, phone_number_id: str) -> tuple[str, str]: phone = db.scalar( select(WhatsAppPhoneAsset) .options(selectinload(WhatsAppPhoneAsset.account).selectinload(WhatsAppBusinessAccount.connection)) .where(WhatsAppPhoneAsset.phone_number_id == phone_number_id, WhatsAppPhoneAsset.user_id == user.id) ) if phone is None or phone.account.status != "connected" or phone.status != "connected": raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No connected WhatsApp number for this workspace") token = phone.account.connection.access_token if phone.account.connection else None if not token: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reconnect this WhatsApp account before sending") return token, phone.phone_number_id def list_account_messages(db: Session, user: UserAccount, account_id: Any, limit: int = 300) -> list[dict[str, Any]]: account = _owned_account(db, user, account_id) phone_ids = [phone.phone_number_id for phone in account.phones] if not phone_ids: return [] rows = db.scalars( select(WhatsAppMessage) .where(WhatsAppMessage.user_id == user.id, WhatsAppMessage.phone_number_id.in_(phone_ids)) .order_by(WhatsAppMessage.created_at.desc()) .limit(min(limit, 500)) ).all() return [serialize_message(row) for row in rows] def _messaging_phone(account: WhatsAppBusinessAccount) -> tuple[WhatsAppPhoneAsset, str]: connection = account.connection token = connection.access_token if connection else None phone = next((item for item in account.phones if item.status == "connected"), None) if phone is None or account.messaging_status != "active" or not token: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=account.last_error or "Messaging is unavailable until this number is connected on Cloud API", ) return phone, token async def enable_customer_delivery(db: Session, user: UserAccount, account_id: Any, app_secret: str) -> dict[str, Any]: """Point this account's replies and delivery updates at the workspace.""" account = _owned_account(db, user, account_id) connection = account.connection token = connection.access_token if connection else None if connection is None or not token or connection.status == "disconnected": raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Save the access token again before replies can arrive here.") secret = "".join(str(app_secret or "").split()) if len(secret) < 8: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Paste the App secret from the Meta app that created this token.", ) connection.meta_app_secret = secret phone_ids = [phone.phone_number_id for phone in account.phones] receiving = await _attach_customer_webhook(token, account.waba_id, phone_ids) account.webhooks_status = "active" if receiving else "unavailable" account.last_error = None if receiving else "Meta did not accept the delivery address for replies." db.commit() db.refresh(account) if not receiving: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=account.last_error) return serialize_account(account) async def send_account_message(db: Session, user: UserAccount, account_id: Any, payload: dict[str, Any]) -> dict[str, Any]: account = _owned_account(db, user, account_id) phone, token = _messaging_phone(account) try: recipient = normalize_wa_number(str(payload.get("to") or "")) except ValueError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc client = WhatsAppGraphClient(access_token=token, phone_number_id=phone.phone_number_id, business_account_id=account.waba_id) message_type = str(payload.get("message_type") or "text") reply_to = payload.get("reply_to") or None try: if message_type == "text": body = str(payload.get("text") or "").strip() if not body: raise HTTPException(status_code=422, detail="text is required") result = await client.send_text(recipient, body, reply_to=reply_to) elif message_type == "template": name = str(payload.get("template_name") or "").strip() if not name: raise HTTPException(status_code=422, detail="template_name is required") result = await client.send_template(recipient, name, language_code=str(payload.get("template_language") or "en")) body = f"Template: {name}" message_type = "template" elif message_type in {"image", "document", "audio", "video"}: link = str(payload.get("media_link") or "").strip() if not link: raise HTTPException(status_code=422, detail="media_link is required") result = await client.send_media( recipient, message_type, link=link, caption=payload.get("media_caption"), filename=payload.get("media_filename"), reply_to=reply_to, ) body = payload.get("media_caption") or f"[{message_type}]" elif message_type == "interactive_buttons": buttons = payload.get("interactive_buttons") or [] text = str(payload.get("interactive_body") or "").strip() if not text or not buttons: raise HTTPException(status_code=422, detail="interactive_body and interactive_buttons are required") result = await client.send_interactive_buttons(recipient, text, buttons, reply_to=reply_to) body = text message_type = "interactive" elif message_type == "reaction": target = str(payload.get("reaction_message_id") or "") emoji = str(payload.get("reaction_emoji") or "") if not target or not emoji: raise HTTPException(status_code=422, detail="reaction_message_id and reaction_emoji are required") result = await client.send_reaction(recipient, target, emoji) body = emoji else: raise HTTPException(status_code=422, detail="Unsupported message type") except WhatsAppGraphError as exc: raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc try: from_number = normalize_wa_number(phone.display_phone or "") except ValueError: from_number = phone.display_phone or phone.phone_number_id message = record_outbound_message( db, to_number=recipient, body=body, message_type=message_type, wa_message_id=extract_wa_message_id(result), status="sent", payload=result if isinstance(result, dict) else None, from_number=from_number, user_id=user.id, phone_number_id=phone.phone_number_id, ) return serialize_message(message) async def send_account_broadcast( db: Session, user: UserAccount, account_id: Any, template_name: str, template_language: str, recipients: list[str], body_parameters: list[str] | None = None, product_policy: str = "STRICT", ) -> dict[str, Any]: """Send a marketing template through Meta's marketing endpoint.""" account = _owned_account(db, user, account_id) phone, token = _messaging_phone(account) name = template_name.strip() language = template_language.strip() or "en" policy = product_policy if product_policy in {"STRICT", "CLOUD_API_FALLBACK"} else "STRICT" numbers = [] for raw in recipients: piece = str(raw or "").strip() if piece: numbers.append(piece) if not name: raise HTTPException(status_code=422, detail="Choose a marketing template.") if not numbers: raise HTTPException(status_code=422, detail="Add at least one recipient.") if len(numbers) > 20: raise HTTPException(status_code=422, detail="A test broadcast can include up to 20 numbers.") parameters = [str(item).strip() for item in (body_parameters or []) if str(item).strip()] components = None if parameters: components = [ { "type": "body", "parameters": [{"type": "text", "text": item} for item in parameters], } ] client = WhatsAppGraphClient( access_token=token, phone_number_id=phone.phone_number_id, business_account_id=account.waba_id, ) try: from_number = normalize_wa_number(phone.display_phone or "") except ValueError: from_number = phone.display_phone or phone.phone_number_id results: list[dict[str, Any]] = [] for raw in numbers: try: recipient = normalize_wa_number(raw) except ValueError: results.append({"to": raw, "status": "failed", "error": "Enter a valid WhatsApp number."}) continue try: result = await client.send_marketing_template( recipient, name, language_code=language, components=components, product_policy=policy, ) except WhatsAppGraphError as exc: results.append({"to": recipient, "status": "failed", "error": exc.detail}) continue message = record_outbound_message( db, to_number=recipient, body=f"Broadcast: {name}", message_type="template", wa_message_id=extract_wa_message_id(result), status="sent", payload=result if isinstance(result, dict) else None, from_number=from_number, user_id=user.id, phone_number_id=phone.phone_number_id, ) results.append({"to": recipient, "status": "sent", "message_id": message.wa_message_id}) sent = sum(1 for item in results if item["status"] == "sent") return { "template_name": name, "template_language": language, "product_policy": policy, "sent": sent, "failed": len(results) - sent, "results": results, } async def download_account_media(db: Session, user: UserAccount, account_id: Any, media_id: str) -> tuple[bytes, str]: account = _owned_account(db, user, account_id) token = account.connection.access_token if account.connection else None if not token: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reconnect this WhatsApp account before opening media") phone_ids = [phone.phone_number_id for phone in account.phones] if not phone_ids: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Media not found") rows = db.scalars( select(WhatsAppMessage).where( WhatsAppMessage.user_id == user.id, WhatsAppMessage.phone_number_id.in_(phone_ids), ) ).all() allowed = False for row in rows: payload = row.payload if isinstance(row.payload, dict) else {} media = payload.get(row.message_type) if isinstance(media, dict) and str(media.get("id") or "") == media_id: allowed = True break if not allowed: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Media not found") client = WhatsAppGraphClient(access_token=token, phone_number_id=phone_ids[0] if phone_ids else None) try: return await client.download_media(media_id) except WhatsAppGraphError as exc: raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc