diff --git a/backend/app/routes_whatsapp.py b/backend/app/routes_whatsapp.py index 67ab57b..3718a70 100644 --- a/backend/app/routes_whatsapp.py +++ b/backend/app/routes_whatsapp.py @@ -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, ) diff --git a/backend/app/routes_whatsapp_templates.py b/backend/app/routes_whatsapp_templates.py index 47f0f30..43c2928 100644 --- a/backend/app/routes_whatsapp_templates.py +++ b/backend/app/routes_whatsapp_templates.py @@ -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, diff --git a/backend/app/whatsapp_graph_client.py b/backend/app/whatsapp_graph_client.py index c045156..36122e0 100644 --- a/backend/app/whatsapp_graph_client.py +++ b/backend/app/whatsapp_graph_client.py @@ -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, diff --git a/backend/app/whatsapp_onboarding.py b/backend/app/whatsapp_onboarding.py index 93f6800..77cf5ee 100644 --- a/backend/app/whatsapp_onboarding.py +++ b/backend/app/whatsapp_onboarding.py @@ -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: diff --git a/backend/app/whatsapp_template_service.py b/backend/app/whatsapp_template_service.py index f079ca0..af85c75 100644 --- a/backend/app/whatsapp_template_service.py +++ b/backend/app/whatsapp_template_service.py @@ -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: diff --git a/frontend/src/features/whatsapp/WhatsAppBroadcastPage.tsx b/frontend/src/features/whatsapp/WhatsAppBroadcastPage.tsx index 8b134ee..240522e 100644 --- a/frontend/src/features/whatsapp/WhatsAppBroadcastPage.tsx +++ b/frontend/src/features/whatsapp/WhatsAppBroadcastPage.tsx @@ -1,7 +1,7 @@ import { Loader2 } from 'lucide-react' import { useEffect, useState, type FormEvent } from 'react' import { Link, useSearchParams } from 'react-router-dom' -import { whatsappApi, type WhatsAppAccount, type WhatsAppTemplateRow } from '../../lib/api' +import { waTemplateApi, whatsappApi, type WaManagedTemplate, type WhatsAppAccount, type WhatsAppTemplateRow } from '../../lib/api' const TEAM_TEMPLATE_NAME = 'urmi_team_aircheck' const TEAM_TEMPLATE_BODY = "Team, the broadcast desk is on air. This is the first note Urmi is sending through Meta's marketing line, and it is written for the people building it. Read it on your phone, then bring it to standup so we can close the test." @@ -23,7 +23,9 @@ export function WhatsAppBroadcastPage() { const [accounts, setAccounts] = useState([]) const [accountId, setAccountId] = useState('') const [templates, setTemplates] = useState([]) + const [managed, setManaged] = useState([]) const [templatesReady, setTemplatesReady] = useState(false) + const [useSample, setUseSample] = useState(true) const [templateName, setTemplateName] = useState(TEAM_TEMPLATE_NAME) const [templateLanguage, setTemplateLanguage] = useState('en') const [draftName, setDraftName] = useState(TEAM_TEMPLATE_NAME) @@ -72,6 +74,9 @@ export function WhatsAppBroadcastPage() { setTemplates([]) setTemplatesReady(false) }) + waTemplateApi.list(accountId) + .then((payload) => setManaged(payload.templates)) + .catch(() => setManaged([])) }, [accountId, templateTick]) async function createTemplate(event: FormEvent) { @@ -103,6 +108,16 @@ export function WhatsAppBroadcastPage() { setResult(null) const numbers = recipients.split(/[\n,]/).map((item) => item.trim()).filter(Boolean) const parameters = bodyValues.split('\n').map((item) => item.trim()).filter(Boolean) + if (mediaHeader && !savedSample) { + setError(`Add the sample ${headerLabel} on the template page. Meta rejects this send without it.`) + setBusy(false) + return + } + if (mediaHeader && !useSample) { + setError(`Turn on the saved sample ${headerLabel}. This template’s header is a ${headerLabel}, and Meta rejects the send without it.`) + setBusy(false) + return + } try { const payload = await whatsappApi.sendBroadcast(accountId, { template_name: templateName.trim(), @@ -110,6 +125,7 @@ export function WhatsAppBroadcastPage() { recipients: numbers, body_parameters: parameters, product_policy: strict ? 'STRICT' : 'CLOUD_API_FALLBACK', + use_sample_header: Boolean(mediaHeader && useSample && savedSample), }) setResult(payload) } catch (reason) { @@ -121,6 +137,11 @@ export function WhatsAppBroadcastPage() { const approved = templates.filter((item) => (item.status || '').toUpperCase() === 'APPROVED' && item.name) const pending = templates.filter((item) => (item.status || '').toUpperCase() !== 'APPROVED' && item.name) + const selectedTemplate = managed.find((item) => item.name === templateName && item.language === templateLanguage) + || managed.find((item) => item.name === templateName) + const mediaHeader = ['IMAGE', 'VIDEO', 'DOCUMENT'].includes(selectedTemplate?.header_format || '') + const savedSample = Boolean(selectedTemplate?.sample_stored) + const headerLabel = selectedTemplate?.header_format === 'VIDEO' ? 'video' : selectedTemplate?.header_format === 'DOCUMENT' ? 'document' : 'image' return (
@@ -198,6 +219,7 @@ export function WhatsAppBroadcastPage() { const next = approved.find((item) => item.name === event.target.value) setTemplateName(event.target.value) if (next?.language) setTemplateLanguage(next.language) + setUseSample(true) }} > {approved.map((item) => ( @@ -220,6 +242,23 @@ export function WhatsAppBroadcastPage() { One WhatsApp number per line, up to 20. Include the country code.