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

@ -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: