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

1397 lines
55 KiB
Python

"""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 _waba_ids_from_debug(debug: dict[str, Any]) -> list[str]:
found: list[str] = []
scopes = debug.get("granular_scopes")
if not isinstance(scopes, list):
return found
preferred = {"whatsapp_business_management", "whatsapp_business_messaging"}
for item in scopes:
if not isinstance(item, dict):
continue
if item.get("scope") not in preferred:
continue
for target in item.get("target_ids") or []:
value = str(target)
if value and value not in found:
found.append(value)
return found
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()
waba_ids = [session_waba] if session_waba else _waba_ids_from_debug(debug)
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)
session_phone = str(payload.get("phone_number_id") or "").strip() or None
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