template
This commit is contained in:
parent
a109df2deb
commit
8901d99dd2
11 changed files with 380 additions and 13 deletions
|
|
@ -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