This commit is contained in:
debu-2 2026-10-14 06:32:25 +05:30
parent a109df2deb
commit 8901d99dd2
11 changed files with 380 additions and 13 deletions

View file

@ -197,6 +197,7 @@ class BroadcastSendRequest(BaseModel):
recipients: list[str] = Field(min_length=1, max_length=20)
body_parameters: list[str] = Field(default_factory=list, max_length=10)
product_policy: str = "STRICT"
use_sample_header: bool = False
class CustomerSendRequest(BaseModel):
@ -354,6 +355,7 @@ async def whatsapp_account_broadcast(
payload.recipients,
payload.body_parameters,
payload.product_policy,
payload.use_sample_header,
)

View file

@ -1,6 +1,7 @@
from typing import Any
from fastapi import APIRouter, Depends, File, Form, UploadFile
from fastapi.responses import Response
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
@ -8,11 +9,13 @@ from .auth import current_user
from .database import get_db
from .models import UserAccount
from .whatsapp_template_service import (
attach_template_sample,
create_managed_template,
delete_managed_template,
get_managed_template,
language_catalog,
list_managed_templates,
read_template_sample,
resubmit_managed_template,
upload_header_media,
)
@ -79,6 +82,27 @@ async def whatsapp_template_create(
return await create_managed_template(db, user, payload.model_dump())
@router.post("/{template_id}/sample")
async def whatsapp_template_sample(
template_id: str,
file: UploadFile = File(),
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
data = await file.read()
return attach_template_sample(db, user, template_id, file.filename or "header", file.content_type or "", data)
@router.get("/{template_id}/sample")
def whatsapp_template_sample_file(
template_id: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> Response:
data, mime, _name = read_template_sample(db, user, template_id)
return Response(content=data, media_type=mime)
@router.post("/{template_id}/resubmit")
async def whatsapp_template_resubmit(
template_id: str,

View file

@ -104,6 +104,30 @@ class WhatsAppGraphClient:
payload["context"] = {"message_id": reply_to}
return await self._send_message(payload)
async def upload_media(self, filename: str, content_type: str, data: bytes) -> str:
"""Upload a file for a template header. Returns a media id that one send can use."""
if not self.configured:
raise WhatsAppGraphError("WhatsApp Graph API is not configured", status_code=503)
url = self._graph_url(f"{self.phone_number_id}/media")
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
url,
data={"messaging_product": "whatsapp", "type": content_type},
files={"file": (filename or "header", data, content_type)},
headers={"Authorization": f"Bearer {self.access_token}"},
)
try:
body = response.json()
except ValueError:
body = {}
if response.status_code >= 400:
detail = body.get("error", {}).get("message") if isinstance(body, dict) else response.text
raise WhatsAppGraphError(detail or "Meta did not accept the header file", status_code=response.status_code)
media_id = str(body.get("id") or "") if isinstance(body, dict) else ""
if not media_id:
raise WhatsAppGraphError("Meta did not return a media id for the header file", status_code=502)
return media_id
async def send_template(
self,
to: str,

View file

@ -1318,6 +1318,7 @@ async def send_account_broadcast(
recipients: list[str],
body_parameters: list[str] | None = None,
product_policy: str = "STRICT",
use_sample_header: bool = False,
) -> dict[str, Any]:
"""Send a marketing template through Meta's marketing endpoint."""
account = _owned_account(db, user, account_id)
@ -1337,14 +1338,20 @@ async def send_account_broadcast(
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
components: list[dict[str, Any]] = []
if use_sample_header:
from .whatsapp_template_service import header_parameter_for_send
components.append(
await header_parameter_for_send(db, user, account, token, phone.phone_number_id, name, language)
)
if parameters:
components = [
components.append(
{
"type": "body",
"parameters": [{"type": "text", "text": item} for item in parameters],
}
]
)
client = WhatsAppGraphClient(
access_token=token,
phone_number_id=phone.phone_number_id,
@ -1366,7 +1373,7 @@ async def send_account_broadcast(
recipient,
name,
language_code=language,
components=components,
components=components or None,
product_policy=policy,
)
except WhatsAppGraphError as exc:

View file

@ -3,6 +3,8 @@
from __future__ import annotations
import re
import uuid
from pathlib import Path
from typing import Any
import httpx
@ -11,6 +13,7 @@ from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload
from .config import settings
from .kyc_service import upload_root
from .models import UserAccount, WhatsAppBusinessAccount, WhatsAppMessage, WhatsAppMessageTemplate, WhatsAppMetaConnection
from .whatsapp_onboarding import (
_apply_phones,
@ -35,6 +38,13 @@ _MEDIA = {
"video/mp4": "VIDEO",
"application/pdf": "DOCUMENT",
}
_SAMPLE_EXT = {
"image/jpeg": ".jpg",
"image/png": ".png",
"video/mp4": ".mp4",
"application/pdf": ".pdf",
}
_SAMPLE_KEYS = ("sample_id", "sample_name", "sample_type")
LANGUAGES = [
("en", "English"),
@ -314,6 +324,67 @@ def _buttons(buttons: list[Any], category: str) -> list[dict[str, Any]]:
return built
def _media_type(filename: str, content_type: str) -> str:
raw = (content_type or "").split(";")[0].strip().lower()
if raw in _MEDIA:
return raw
suffix = Path(filename or "").suffix.lower()
for mime, ext in _SAMPLE_EXT.items():
if suffix == ext or (suffix == ".jpeg" and mime == "image/jpeg"):
return mime
return ""
def _sample_folder(user_id: Any) -> Path:
folder = upload_root(settings.upload_dir) / "whatsapp-template-samples" / str(user_id)
folder.mkdir(parents=True, exist_ok=True)
return folder
def _find_sample(user_id: Any, sample_id: str) -> Path | None:
if not re.fullmatch(r"[a-f0-9]{32}", sample_id or ""):
return None
folder = upload_root(settings.upload_dir) / "whatsapp-template-samples" / str(user_id)
if not folder.is_dir():
return None
matches = [path for path in folder.glob(f"{sample_id}.*") if path.is_file()]
return matches[0] if matches else None
def _store_sample(user_id: Any, filename: str, content_type: str, data: bytes) -> dict[str, str]:
mime = _media_type(filename, content_type)
kind = _MEDIA.get(mime)
if kind is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Upload a JPEG, PNG, MP4, or PDF.")
if not data:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="The file is empty.")
if len(data) > 16 * 1024 * 1024:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Header files can be at most 16 MB.")
sample_id = uuid.uuid4().hex
path = _sample_folder(user_id) / f"{sample_id}{_SAMPLE_EXT[mime]}"
path.write_bytes(data)
safe_name = Path(filename or "header").name[:180] or "header"
return {"sample_id": sample_id, "sample_name": safe_name, "sample_type": mime, "format": kind}
def _header_dict(definition: dict | None) -> dict[str, Any]:
header = definition.get("header") if isinstance(definition, dict) else None
return dict(header) if isinstance(header, dict) else {}
def _keep_sample(previous: dict | None, definition: dict[str, Any]) -> None:
"""Meta sync rebuilds the header and would otherwise drop the file Urmi saved."""
old = _header_dict(previous)
new = definition.get("header") if isinstance(definition.get("header"), dict) else None
if not isinstance(new, dict) or new.get("sample_id") or not old.get("sample_id"):
return
if str(new.get("type") or "").upper() != str(old.get("type") or "").upper():
return
for key in _SAMPLE_KEYS:
if old.get(key):
new[key] = old[key]
def _definition_from_components(components: list[Any], category: str) -> dict[str, Any]:
definition: dict[str, Any] = {
"category": category,
@ -430,6 +501,9 @@ def _serialize(row: WhatsAppMessageTemplate, account: WhatsAppBusinessAccount |
"parameter_format": row.parameter_format,
"definition": row.definition or {},
"components": row.components or [],
"header_format": str(_header_dict(row.definition).get("type") or "NONE").upper(),
"sample_stored": _find_sample(row.user_id, str(_header_dict(row.definition).get("sample_id") or "")) is not None,
"sample_name": str(_header_dict(row.definition).get("sample_name") or ""),
"messages_sent": sent,
"messages_sent_source": "urmi",
"created_at": row.created_at,
@ -470,6 +544,7 @@ def _upsert_remote(db: Session, account: WhatsAppBusinessAccount, remote: dict[s
definition = _definition_from_components(components, category)
if row.subtype and row.subtype != "default":
definition["subtype"] = row.subtype
_keep_sample(row.definition if isinstance(row.definition, dict) else None, definition)
row.definition = definition
row.parameter_format = str(definition.get("parameter_format") or row.parameter_format)
row.subtype = str(definition.get("subtype") or row.subtype or "default")[:40]
@ -812,7 +887,9 @@ async def resubmit_managed_template(db: Session, user: UserAccount, template_id:
components = build_components({**payload, "name": row.name, "language": row.language})
data = await _graph("POST", row.meta_template_id, token=_token(db, account), json_body={"components": components, "category": row.category})
row.components = components
row.definition = payload
definition = dict(payload)
_keep_sample(row.definition if isinstance(row.definition, dict) else None, definition)
row.definition = definition
row.status = str(data.get("status") or "PENDING").upper()
row.rejected_reason = None
row.category = str(data.get("category") or row.category).upper()
@ -837,6 +914,7 @@ async def upload_header_media(db: Session, user: UserAccount, account_id: Any, f
account = _owned_account(db, user, account_id)
_require_connected(account)
token = _token(db, account)
content_type = _media_type(filename, content_type)
kind = _MEDIA.get(content_type)
if kind is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Upload a JPEG, PNG, MP4, or PDF.")
@ -872,7 +950,106 @@ async def upload_header_media(db: Session, user: UserAccount, account_id: Any, f
handle = str(body.get("h") or "")
if not handle:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Meta did not return a header file handle.")
return {"handle": handle, "format": kind}
stored = _store_sample(user.id, filename, content_type, data)
return {"handle": handle, "format": kind, **stored}
def attach_template_sample(
db: Session,
user: UserAccount,
template_id: Any,
filename: str,
content_type: str,
data: bytes,
) -> dict[str, Any]:
"""Keep a sendable copy of the header file. This does not change the template at Meta."""
row, account = _owned_template(db, user, template_id)
header = _header_dict(row.definition)
kind = str(header.get("type") or "NONE").upper()
if kind not in {"IMAGE", "VIDEO", "DOCUMENT"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This template does not use an image, video, or document header.")
stored = _store_sample(user.id, filename, content_type, data)
if stored["format"] != kind:
orphan = _find_sample(user.id, stored["sample_id"])
if orphan is not None:
orphan.unlink(missing_ok=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"This template header is a {kind.lower()}. Upload that kind of file.",
)
header["sample_id"] = stored["sample_id"]
header["sample_name"] = stored["sample_name"]
header["sample_type"] = stored["sample_type"]
definition = dict(row.definition or {})
definition["header"] = header
row.definition = definition
db.commit()
db.refresh(row)
return _serialize(row, account, _sent_counts(db, user).get(row.name, 0))
def read_template_sample(db: Session, user: UserAccount, template_id: Any) -> tuple[bytes, str, str]:
row, _account = _owned_template(db, user, template_id)
header = _header_dict(row.definition)
path = _find_sample(user.id, str(header.get("sample_id") or ""))
if path is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No sample file is saved for this template.")
mime = str(header.get("sample_type") or "") or _media_type(path.name, "")
return path.read_bytes(), mime or "application/octet-stream", str(header.get("sample_name") or path.name)
def _template_for_send(db: Session, user: UserAccount, account: WhatsAppBusinessAccount, name: str, language: str) -> WhatsAppMessageTemplate | None:
rows = db.scalars(
select(WhatsAppMessageTemplate).where(
WhatsAppMessageTemplate.user_id == user.id,
WhatsAppMessageTemplate.account_id == account.id,
WhatsAppMessageTemplate.name == name,
)
).all()
exact = [row for row in rows if row.language == language]
if exact:
return exact[0]
prefix = language.split("_")[0]
loose = [row for row in rows if row.language.split("_")[0] == prefix]
return loose[0] if len(loose) == 1 else None
async def header_parameter_for_send(
db: Session,
user: UserAccount,
account: WhatsAppBusinessAccount,
token: str,
phone_number_id: str,
name: str,
language: str,
) -> dict[str, Any]:
"""Upload the saved sample and return the header parameter Meta requires on each send."""
from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError
row = _template_for_send(db, user, account, name, language)
if row is None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Open the template and save a sample file before sending it.")
header = _header_dict(row.definition)
kind = str(header.get("type") or "NONE").upper()
if kind not in {"IMAGE", "VIDEO", "DOCUMENT"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This template does not use a media header.")
path = _find_sample(user.id, str(header.get("sample_id") or ""))
if path is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Add the sample file on the template page. Meta does not keep the approval file for later sends.",
)
mime = str(header.get("sample_type") or "") or _media_type(path.name, "")
client = WhatsAppGraphClient(access_token=token, phone_number_id=phone_number_id, business_account_id=account.waba_id)
try:
media_id = await client.upload_media(str(header.get("sample_name") or path.name), mime, path.read_bytes())
except WhatsAppGraphError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
parameter_type = kind.lower()
media: dict[str, str] = {"id": media_id}
if kind == "DOCUMENT":
media["filename"] = str(header.get("sample_name") or path.name)
return {"type": "header", "parameters": [{"type": parameter_type, parameter_type: media}]}
def apply_template_status_webhook(db: Session, payload: dict[str, Any]) -> None: