1231 lines
49 KiB
Python
1231 lines
49 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}
|
|
|
|
|
|
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 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 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
|