template
This commit is contained in:
parent
a109df2deb
commit
8901d99dd2
11 changed files with 380 additions and 13 deletions
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue