urmi-ai/backend/app/whatsapp_template_service.py
2026-10-14 05:46:18 +05:30

846 lines
37 KiB
Python

"""WhatsApp message template lifecycle against the customer's WABA."""
from __future__ import annotations
import re
from typing import Any
import httpx
from fastapi import HTTPException, status
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload
from .config import settings
from .models import UserAccount, WhatsAppBusinessAccount, WhatsAppMessage, WhatsAppMessageTemplate, WhatsAppMetaConnection
from .whatsapp_onboarding import (
_apply_phones,
_debug_token,
_graph,
_graph_error_message,
_load_phones,
_load_waba,
_owned_account,
_phone_ids_from_debug,
_quality_label,
_token_app_id,
_waba_ids_from_debug,
)
_NAME = re.compile(r"^[a-z0-9_]{1,512}$")
_LANG = re.compile(r"^[a-z]{2}(_[A-Z]{2})?$")
_VAR = re.compile(r"\{\{([A-Za-z0-9_]+)\}\}")
_MEDIA = {
"image/jpeg": "IMAGE",
"image/png": "IMAGE",
"video/mp4": "VIDEO",
"application/pdf": "DOCUMENT",
}
LANGUAGES = [
("en", "English"),
("en_US", "English (US)"),
("en_GB", "English (UK)"),
("hi", "Hindi"),
("bn", "Bengali"),
("ta", "Tamil"),
("te", "Telugu"),
("mr", "Marathi"),
("gu", "Gujarati"),
("kn", "Kannada"),
("ml", "Malayalam"),
("pa", "Punjabi"),
("ur", "Urdu"),
("ar", "Arabic"),
("es", "Spanish"),
("es_MX", "Spanish (Mexico)"),
("pt_BR", "Portuguese (Brazil)"),
("fr", "French"),
("de", "German"),
("id", "Indonesian"),
("it", "Italian"),
("nl", "Dutch"),
("ru", "Russian"),
("tr", "Turkish"),
("ja", "Japanese"),
("ko", "Korean"),
("zh_CN", "Chinese (China)"),
("zh_HK", "Chinese (Hong Kong)"),
]
def language_catalog() -> list[dict[str, str]]:
return [{"code": code, "label": label} for code, label in LANGUAGES]
def _language_ok(code: str) -> bool:
return any(item[0] == code for item in LANGUAGES)
def _connection_method(connection: WhatsAppMetaConnection | None) -> str:
if connection is None:
return "WhatsApp"
if connection.granted_scopes == "manual":
return "Saved token"
if (connection.granted_scopes or "").strip():
return "Embedded signup"
return "WhatsApp"
def _stored_token(connection: WhatsAppMetaConnection | None) -> str | None:
if connection is None or connection.status == "disconnected":
return None
token = (connection.access_token or "").strip()
return token or None
def _candidate_tokens(db: Session, account: WhatsAppBusinessAccount) -> list[str]:
"""Embedded signup and a saved token are both stored on the connection."""
tokens: list[str] = []
own = _stored_token(account.connection)
if own:
tokens.append(own)
others = db.scalars(
select(WhatsAppMetaConnection).where(
WhatsAppMetaConnection.user_id == account.user_id,
WhatsAppMetaConnection.status != "disconnected",
)
).all()
for connection in others:
token = _stored_token(connection)
if token and token not in tokens:
tokens.append(token)
platform_waba = settings.whatsapp_business_account_id.strip()
platform_token = settings.whatsapp_access_token.strip()
if platform_token and account.waba_id == platform_waba and platform_token not in tokens:
tokens.append(platform_token)
return tokens
def _token(db: Session, account: WhatsAppBusinessAccount) -> str:
tokens = _candidate_tokens(db, account)
if not tokens:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Reconnect this WhatsApp account before managing templates.",
)
return tokens[0]
def _retry_with_another_token(exc: HTTPException) -> bool:
detail = str(exc.detail).lower()
return any(part in detail for part in ("access token", "oauth", "(#190)", "permission", "unsupported get", "(#10)"))
def _variables(text: str) -> list[str]:
found: list[str] = []
for match in _VAR.finditer(text or ""):
name = match.group(1)
if name not in found:
found.append(name)
return found
def _examples_for(text: str, examples: list[str], named: bool) -> list[str]:
names = _variables(text)
if len(examples) < len(names):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Every variable needs a sample value.")
if named and any(name.isdigit() for name in names):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Named variables cannot be only numbers. Use a name such as customer_name.")
if not named:
expected = [str(index) for index in range(1, len(names) + 1)]
if names != expected:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Numbered variables must be {{1}}, {{2}}, and so on, with no gaps.")
return examples[: len(names)]
def build_components(payload: dict[str, Any]) -> list[dict[str, Any]]:
category = str(payload.get("category") or "").upper()
named = str(payload.get("parameter_format") or "POSITIONAL").upper() == "NAMED"
if category == "AUTHENTICATION":
auth = payload.get("auth") if isinstance(payload.get("auth"), dict) else {}
minutes = int(auth.get("code_expiration_minutes") or 0)
components: list[dict[str, Any]] = [{
"type": "BODY",
"add_security_recommendation": bool(auth.get("add_security_recommendation", True)),
}]
if minutes:
if minutes < 1 or minutes > 90:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Code expiry should be between 1 and 90 minutes.")
components.append({"type": "FOOTER", "code_expiration_minutes": minutes})
components.append({
"type": "BUTTONS",
"buttons": [{
"type": "OTP",
"otp_type": "COPY_CODE",
"text": str(auth.get("copy_label") or "Copy code")[:25] or "Copy code",
}],
})
return components
header = payload.get("header") if isinstance(payload.get("header"), dict) else {"type": "NONE"}
body = str(payload.get("body") or "").strip()
footer = str(payload.get("footer") or "").strip()
buttons = payload.get("buttons") if isinstance(payload.get("buttons"), list) else []
if not body:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Message body is required.")
if len(body) > 1024:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Message body can be at most 1,024 characters.")
if len(footer) > 60:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Footer can be at most 60 characters.")
if _variables(footer):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Footer cannot contain variables.")
components = []
header_type = str(header.get("type") or "NONE").upper()
if header_type == "TEXT":
text = str(header.get("text") or "").strip()
if not text:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Header text is required.")
if len(text) > 60:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Header text can be at most 60 characters.")
samples = _examples_for(text, list(header.get("examples") or []), named)
block: dict[str, Any] = {"type": "HEADER", "format": "TEXT", "text": text}
if samples:
names = _variables(text)
if named:
block["example"] = {"header_text_named_params": [
{"param_name": name, "example": sample} for name, sample in zip(names, samples, strict=False)
]}
else:
block["example"] = {"header_text": samples}
components.append(block)
elif header_type in {"IMAGE", "VIDEO", "DOCUMENT"}:
handle = str(header.get("handle") or "").strip()
if not handle:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Upload a header file before submitting.")
components.append({
"type": "HEADER",
"format": header_type,
"example": {"header_handle": [handle]},
})
elif header_type == "LOCATION":
components.append({"type": "HEADER", "format": "LOCATION"})
elif header_type != "NONE":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Choose a supported header type.")
body_samples = _examples_for(body, [str(item) for item in (payload.get("body_examples") or [])], named)
body_block: dict[str, Any] = {"type": "BODY", "text": body}
if body_samples:
names = _variables(body)
if named:
body_block["example"] = {"body_text_named_params": [
{"param_name": name, "example": sample} for name, sample in zip(names, body_samples, strict=False)
]}
else:
body_block["example"] = {"body_text": [body_samples]}
components.append(body_block)
if footer:
components.append({"type": "FOOTER", "text": footer})
if buttons:
components.append({"type": "BUTTONS", "buttons": _buttons(buttons, category)})
return components
def _buttons(buttons: list[Any], category: str) -> list[dict[str, Any]]:
if len(buttons) > 10:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="A template can have at most 10 buttons.")
built: list[dict[str, Any]] = []
urls = phones = copies = 0
for raw in buttons:
if not isinstance(raw, dict):
continue
kind = str(raw.get("type") or "").upper()
label = str(raw.get("text") or "").strip()
if kind != "COPY_CODE" and (not label or len(label) > 25):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Each button label should be 1 to 25 characters.")
if kind == "URL":
urls += 1
url = str(raw.get("url") or "").strip()
if not url.startswith("https://"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Website buttons need an https URL.")
button: dict[str, Any] = {"type": "URL", "text": label, "url": url}
names = _variables(url)
if len(names) > 1 or (names and not url.endswith("{{" + names[0] + "}}")):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="A website button can use one variable, and it has to be at the end of the URL.")
if names:
example = str(raw.get("example") or "").strip()
if not example.startswith("https://"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Sample URL should be a full https address with the variable filled in.")
button["example"] = [example]
built.append(button)
elif kind == "PHONE_NUMBER":
phones += 1
phone = re.sub(r"\D", "", str(raw.get("phone") or ""))
if len(phone) < 8:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Enter a phone number with the country code.")
built.append({"type": "PHONE_NUMBER", "text": label, "phone_number": phone})
elif kind == "QUICK_REPLY":
built.append({"type": "QUICK_REPLY", "text": label})
elif kind == "COPY_CODE":
if category != "MARKETING":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Copy offer code is available on marketing templates.")
copies += 1
example = str(raw.get("example") or "").strip()
if not example or len(example) > 15:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Offer code sample should be 1 to 15 characters.")
built.append({"type": "COPY_CODE", "example": example})
elif kind == "CATALOG":
built.append({"type": "CATALOG", "text": label})
elif kind == "FLOW":
flow_id = str(raw.get("flow_id") or "").strip()
if not flow_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="A WhatsApp Flow button needs the Flow ID from Meta.")
built.append({
"type": "FLOW",
"text": label,
"flow_id": flow_id,
"flow_action": "navigate",
"navigate_screen": str(raw.get("screen") or "MAIN"),
})
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="That button type is not supported for this template.")
if urls > 2 or phones > 1 or copies > 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Use at most two website buttons, one phone button, and one offer code.",
)
kinds = [str(item.get("type") or "") for item in built]
quick = [index for index, kind in enumerate(kinds) if kind == "QUICK_REPLY"]
if quick and (quick != list(range(quick[0], quick[0] + len(quick))) or quick[0] not in {0, len(kinds) - len(quick)}):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Keep quick reply buttons together, either all at the start or all at the end.",
)
return built
def _definition_from_components(components: list[Any], category: str) -> dict[str, Any]:
definition: dict[str, Any] = {
"category": category,
"subtype": "otp" if category == "AUTHENTICATION" else "default",
"parameter_format": "POSITIONAL",
"header": {"type": "NONE"},
"body": "",
"body_examples": [],
"footer": "",
"buttons": [],
"auth": {
"otp_type": "COPY_CODE",
"add_security_recommendation": True,
"code_expiration_minutes": 0,
"copy_label": "Copy code",
},
}
for component in components:
if not isinstance(component, dict):
continue
kind = str(component.get("type") or "").upper()
if kind == "HEADER":
fmt = str(component.get("format") or "TEXT").upper()
example = component.get("example") if isinstance(component.get("example"), dict) else {}
if fmt == "TEXT":
samples = example.get("header_text") if isinstance(example.get("header_text"), list) else []
definition["header"] = {"type": "TEXT", "text": component.get("text") or "", "examples": samples}
elif fmt == "LOCATION":
definition["header"] = {"type": "LOCATION"}
else:
handles = example.get("header_handle") if isinstance(example.get("header_handle"), list) else []
definition["header"] = {"type": fmt, "handle": handles[0] if handles else "", "preview_name": fmt.title()}
elif kind == "BODY":
definition["body"] = component.get("text") or ""
example = component.get("example") if isinstance(component.get("example"), dict) else {}
if isinstance(example.get("body_text"), list) and example["body_text"] and isinstance(example["body_text"][0], list):
definition["body_examples"] = example["body_text"][0]
named = example.get("body_text_named_params")
if isinstance(named, list):
definition["parameter_format"] = "NAMED"
definition["body_examples"] = [item.get("example") for item in named if isinstance(item, dict)]
if "add_security_recommendation" in component:
definition["auth"]["add_security_recommendation"] = bool(component.get("add_security_recommendation"))
elif kind == "FOOTER":
definition["footer"] = component.get("text") or ""
if component.get("code_expiration_minutes"):
definition["auth"]["code_expiration_minutes"] = component.get("code_expiration_minutes")
elif kind == "BUTTONS":
for button in component.get("buttons") or []:
if not isinstance(button, dict):
continue
button_type = str(button.get("type") or "").upper()
if button_type == "OTP":
definition["auth"]["copy_label"] = button.get("text") or "Copy code"
continue
definition["buttons"].append({
"type": button_type,
"text": button.get("text") or "",
"url": button.get("url") or "",
"phone": button.get("phone_number") or "",
"example": (button.get("example") or [""])[0] if isinstance(button.get("example"), list) else button.get("example") or "",
"flow_id": button.get("flow_id") or "",
"screen": button.get("navigate_screen") or "",
})
return definition
def _sent_counts(db: Session, user: UserAccount) -> dict[str, int]:
rows = db.execute(
select(WhatsAppMessage.body, func.count())
.where(WhatsAppMessage.user_id == user.id, WhatsAppMessage.message_type == "template")
.group_by(WhatsAppMessage.body)
).all()
counts: dict[str, int] = {}
for body, count in rows:
text = str(body or "")
for prefix in ("Template: ", "Broadcast: "):
if text.startswith(prefix):
name = text[len(prefix):]
counts[name] = counts.get(name, 0) + int(count)
return counts
def _serialize(row: WhatsAppMessageTemplate, account: WhatsAppBusinessAccount | None, sent: int) -> dict[str, Any]:
phone = None
phones: list[str] = []
connection = account.connection if account is not None else None
if account is not None:
phone = next((item for item in account.phones if item.phone_number_id == row.phone_number_id), None)
if phone is None and account.phones:
phone = account.phones[0]
for item in account.phones:
label = item.display_phone or item.verified_name or ""
if label and label not in phones:
phones.append(label)
return {
"id": str(row.id),
"account_id": str(row.account_id),
"account_name": account.name if account else "WhatsApp Business",
"waba_name": account.name if account else "",
"connection_name": (connection.business_name if connection and connection.business_name else (account.name if account else "WhatsApp")),
"connection_method": _connection_method(connection),
"phone_number_id": row.phone_number_id,
"display_phone": phone.display_phone if phone else "",
"phones": phones,
"meta_template_id": row.meta_template_id,
"name": row.name,
"language": row.language,
"category": row.category,
"subtype": row.subtype,
"status": row.status,
"quality": row.quality,
"rejected_reason": row.rejected_reason,
"parameter_format": row.parameter_format,
"definition": row.definition or {},
"components": row.components or [],
"messages_sent": sent,
"messages_sent_source": "urmi",
"created_at": row.created_at,
"updated_at": row.updated_at,
}
def _upsert_remote(db: Session, account: WhatsAppBusinessAccount, remote: dict[str, Any]) -> WhatsAppMessageTemplate:
name = str(remote.get("name") or "")
language = str(remote.get("language") or "en")
row = db.scalar(
select(WhatsAppMessageTemplate).where(
WhatsAppMessageTemplate.waba_id == account.waba_id,
WhatsAppMessageTemplate.name == name,
WhatsAppMessageTemplate.language == language,
)
)
components = remote.get("components") if isinstance(remote.get("components"), list) else []
category = str(remote.get("category") or "MARKETING").upper()
quality = remote.get("quality_score")
score = quality.get("score") if isinstance(quality, dict) else None
reason = remote.get("rejected_reason")
if isinstance(reason, dict):
reason = reason.get("reason") or reason.get("message")
if row is None:
row = WhatsAppMessageTemplate(
user_id=account.user_id,
account_id=account.id,
waba_id=account.waba_id,
name=name,
language=language,
definition=_definition_from_components(components, category),
)
db.add(row)
row.meta_template_id = str(remote.get("id") or row.meta_template_id or "") or None
row.category = category
if components:
definition = _definition_from_components(components, category)
if row.subtype and row.subtype != "default":
definition["subtype"] = row.subtype
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]
row.status = str(remote.get("status") or "PENDING").upper()
row.quality = _quality_label(str(score) if score else None)
row.rejected_reason = str(reason)[:2000] if reason and str(reason).upper() not in {"NONE", "NULL"} else None
row.components = components
if not row.definition:
row.definition = _definition_from_components(components, category)
return row
async def _template_page(token: str, waba_id: str, params: dict[str, Any]) -> dict[str, Any]:
last: HTTPException | None = None
for fields in (
"id,name,language,status,category,components,quality_score,rejected_reason",
"id,name,language,status,category,components",
):
try:
return await _graph("GET", f"{waba_id}/message_templates", token=token, params={**params, "fields": fields})
except HTTPException as exc:
last = exc
detail = str(exc.detail).lower()
if "nonexisting field" in detail or "(#100)" in detail:
continue
raise
if last is not None:
raise last
return {}
async def _pull_templates(db: Session, account: WhatsAppBusinessAccount, token: str) -> set[tuple[str, str]]:
seen: set[tuple[str, str]] = set()
after = None
for _ in range(5):
params: dict[str, Any] = {"limit": "100"}
if after:
params["after"] = after
data = await _template_page(token, account.waba_id, params)
rows = data.get("data") if isinstance(data.get("data"), list) else []
for remote in rows:
if isinstance(remote, dict) and remote.get("name"):
_upsert_remote(db, account, remote)
seen.add((str(remote.get("name")), str(remote.get("language") or "")))
paging = data.get("paging") if isinstance(data.get("paging"), dict) else {}
cursors = paging.get("cursors") if isinstance(paging.get("cursors"), dict) else {}
after = cursors.get("after") if paging.get("next") else None
if not after:
break
return seen
def _wrong_template_node(exc: HTTPException) -> bool:
detail = str(exc.detail).lower()
return "message_templates" in detail and "nonexisting field" in detail
async def _correct_waba_id(db: Session, account: WhatsAppBusinessAccount, token: str) -> bool:
"""Replace a stored phone-number ID with the WABA ID from the embedded-signup token."""
try:
debug = await _debug_token(db, token)
except HTTPException:
return False
wabas = _waba_ids_from_debug(debug)
phone_ids = set(_phone_ids_from_debug(debug))
stored = str(account.waba_id)
if not wabas or stored in wabas:
return False
real = wabas[0] if len(wabas) == 1 else None
if real is None:
for waba in wabas:
try:
rows = await _load_phones(token, waba)
except HTTPException:
continue
ids = {str(row.get("id")) for row in rows if row.get("id")}
if stored in ids or phone_ids.intersection(ids):
real = waba
break
if not real or real == stored:
return False
taken = db.scalar(
select(WhatsAppBusinessAccount.id).where(
WhatsAppBusinessAccount.waba_id == real,
WhatsAppBusinessAccount.id != account.id,
)
)
if taken is not None:
return False
account.waba_id = real
try:
info = await _load_waba(token, real)
name = str(info.get("name") or "").strip()
if name:
account.name = name[:200]
except HTTPException:
pass
user = db.get(UserAccount, account.user_id)
if user is not None:
try:
rows = await _load_phones(token, real)
_apply_phones(db, user, account, rows, None)
except HTTPException:
pass
db.flush()
return True
async def sync_account_templates(db: Session, account: WhatsAppBusinessAccount) -> str | None:
tokens = _candidate_tokens(db, account)
if not tokens:
if account.status == "disconnected":
return None
return "Reconnect this WhatsApp account to refresh its templates."
last_error: str | None = None
for index, token in enumerate(tokens):
try:
try:
seen = await _pull_templates(db, account, token)
except HTTPException as exc:
db.rollback()
if not _wrong_template_node(exc) or not await _correct_waba_id(db, account, token):
raise
seen = await _pull_templates(db, account, token)
except HTTPException as exc:
db.rollback()
last_error = str(exc.detail)
if _retry_with_another_token(exc) and index < len(tokens) - 1:
continue
return last_error
stored = db.scalars(
select(WhatsAppMessageTemplate).where(WhatsAppMessageTemplate.waba_id == account.waba_id)
).all()
for row in stored:
if row.meta_template_id and (row.name, row.language) not in seen:
row.status = "DISABLED"
account.template_count = len(seen)
account.templates_status = "active"
db.commit()
return None
return last_error
async def list_managed_templates(db: Session, user: UserAccount, account_id: str | None) -> dict[str, Any]:
query = (
select(WhatsAppBusinessAccount)
.options(selectinload(WhatsAppBusinessAccount.phones), selectinload(WhatsAppBusinessAccount.connection))
.where(WhatsAppBusinessAccount.user_id == user.id)
)
accounts = db.scalars(query).all()
if account_id:
accounts = [account for account in accounts if str(account.id) == account_id]
warnings = []
for account in accounts:
warning = await sync_account_templates(db, account)
if warning:
warnings.append(f"{account.name}: {warning}")
rows = db.scalars(
select(WhatsAppMessageTemplate)
.where(
WhatsAppMessageTemplate.user_id == user.id,
WhatsAppMessageTemplate.account_id.in_([account.id for account in accounts] or [user.id]),
)
.order_by(WhatsAppMessageTemplate.updated_at.desc())
).all() if accounts else []
by_id = {account.id: account for account in accounts}
sent = _sent_counts(db, user)
templates = [_serialize(row, by_id.get(row.account_id), sent.get(row.name, 0)) for row in rows]
summary = {
"total": len(templates),
"approved": sum(1 for item in templates if item["status"] == "APPROVED"),
"pending": sum(1 for item in templates if item["status"] in {"PENDING", "IN_APPEAL"}),
"rejected": sum(1 for item in templates if item["status"] == "REJECTED"),
"paused": sum(1 for item in templates if item["status"] == "PAUSED"),
}
return {"templates": templates, "summary": summary, "warnings": warnings}
def _owned_template(db: Session, user: UserAccount, template_id: Any) -> tuple[WhatsAppMessageTemplate, WhatsAppBusinessAccount]:
row = db.scalar(
select(WhatsAppMessageTemplate).where(
WhatsAppMessageTemplate.id == template_id,
WhatsAppMessageTemplate.user_id == user.id,
)
)
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
account = _owned_account(db, user, row.account_id)
return row, account
def get_managed_template(db: Session, user: UserAccount, template_id: Any) -> dict[str, Any]:
row, account = _owned_template(db, user, template_id)
sent = _sent_counts(db, user).get(row.name, 0)
payload = _serialize(row, account, sent)
bodies = [f"Template: {row.name}", f"Broadcast: {row.name}"]
status_rows = db.execute(
select(WhatsAppMessage.status, func.count())
.where(
WhatsAppMessage.user_id == user.id,
WhatsAppMessage.message_type == "template",
or_(*[WhatsAppMessage.body == body for body in bodies]),
)
.group_by(WhatsAppMessage.status)
).all()
payload["analytics"] = {
"source": "urmi",
"note": "Counts are messages Urmi sent with this template name, plus delivery updates Urmi received. They are separate from Meta's template analytics.",
"sent": sent,
"by_status": {str(item or "unknown"): int(count) for item, count in status_rows},
}
return payload
def _validate_identity(payload: dict[str, Any]) -> tuple[str, str, str]:
name = str(payload.get("name") or "").strip().lower()
language = str(payload.get("language") or "en_US").strip()
category = str(payload.get("category") or "").upper()
if not _NAME.match(name):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Template name should use lowercase letters, numbers, and underscores.")
if not _language_ok(language):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Choose a language from the list.")
if category not in {"MARKETING", "UTILITY", "AUTHENTICATION"}:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Choose Marketing, Utility, or Authentication.")
return name, language, category
async def create_managed_template(db: Session, user: UserAccount, payload: dict[str, Any]) -> dict[str, Any]:
account = _owned_account(db, user, payload.get("account_id"))
token = _token(db, account)
name, language, category = _validate_identity(payload)
existing = db.scalar(
select(WhatsAppMessageTemplate).where(
WhatsAppMessageTemplate.waba_id == account.waba_id,
WhatsAppMessageTemplate.name == name,
WhatsAppMessageTemplate.language == language,
)
)
if existing is not None and existing.status != "DISABLED":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This template name and language already exist on the WhatsApp account.")
if existing is not None:
db.delete(existing)
db.flush()
components = build_components(payload)
body: dict[str, Any] = {"name": name, "language": language, "category": category, "components": components}
if category != "AUTHENTICATION":
body["parameter_format"] = "NAMED" if str(payload.get("parameter_format") or "").upper() == "NAMED" else "POSITIONAL"
data = await _graph("POST", f"{account.waba_id}/message_templates", token=token, json_body=body)
phone_id = str(payload.get("phone_number_id") or "").strip() or None
if phone_id and phone_id not in {phone.phone_number_id for phone in account.phones}:
phone_id = None
row = WhatsAppMessageTemplate(
user_id=user.id,
account_id=account.id,
waba_id=account.waba_id,
phone_number_id=phone_id,
meta_template_id=str(data.get("id") or "") or None,
name=name,
language=language,
category=str(data.get("category") or category).upper(),
subtype=str(payload.get("subtype") or "default")[:40],
status=str(data.get("status") or "PENDING").upper(),
parameter_format="NAMED" if str(payload.get("parameter_format") or "").upper() == "NAMED" else "POSITIONAL",
definition=payload,
components=components,
)
db.add(row)
db.commit()
db.refresh(row)
return _serialize(row, account, 0)
async def resubmit_managed_template(db: Session, user: UserAccount, template_id: Any, payload: dict[str, Any]) -> dict[str, Any]:
row, account = _owned_template(db, user, template_id)
if row.status not in {"REJECTED", "PAUSED", "APPROVED"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This template can be edited after Meta rejects it, pauses it, or approves it.")
if not row.meta_template_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Meta has no id for this template yet.")
_validate_identity({**payload, "name": row.name, "language": row.language, "category": payload.get("category") or row.category})
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
row.status = str(data.get("status") or "PENDING").upper()
row.rejected_reason = None
row.category = str(data.get("category") or row.category).upper()
db.commit()
db.refresh(row)
return _serialize(row, account, _sent_counts(db, user).get(row.name, 0))
async def delete_managed_template(db: Session, user: UserAccount, template_id: Any) -> dict[str, str]:
row, account = _owned_template(db, user, template_id)
params: dict[str, Any] = {"name": row.name}
if row.meta_template_id:
params["hsm_id"] = row.meta_template_id
await _graph("DELETE", f"{account.waba_id}/message_templates", token=_token(db, account), params=params)
db.delete(row)
db.commit()
return {"status": "deleted"}
async def upload_header_media(db: Session, user: UserAccount, account_id: Any, filename: str, content_type: str, data: bytes) -> dict[str, str]:
account = _owned_account(db, user, account_id)
token = _token(db, account)
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.")
if len(data) > 16 * 1024 * 1024:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Header files can be at most 16 MB.")
app_id = (account.connection.meta_app_id if account.connection else None) or settings.meta_app_id.strip()
if not app_id:
app_id = await _token_app_id(token) or ""
if not app_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This connection has no Meta app id, so the header file cannot be uploaded.")
session = await _graph(
"POST",
f"{app_id}/uploads",
token=token,
params={"file_name": filename or "header", "file_length": str(len(data)), "file_type": content_type},
)
upload_id = str(session.get("id") or "")
if not upload_id:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Meta did not start the header upload.")
url = f"https://graph.facebook.com/{settings.whatsapp_api_version}/{upload_id}"
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
url,
content=data,
headers={"Authorization": f"OAuth {token}", "file_offset": "0", "Content-Type": content_type},
)
try:
body = response.json()
except ValueError:
body = {}
if response.status_code >= 400:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=_graph_error_message(body, "Meta did not accept the header file."))
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}
def apply_template_status_webhook(db: Session, payload: dict[str, Any]) -> None:
changed = False
for entry in payload.get("entry") or []:
if not isinstance(entry, dict):
continue
for change in entry.get("changes") or []:
if not isinstance(change, dict) or change.get("field") != "message_template_status_update":
continue
value = change.get("value") if isinstance(change.get("value"), dict) else {}
name = str(value.get("message_template_name") or "")
language = str(value.get("message_template_language") or "")
event = str(value.get("event") or "").upper()
if not name or not event:
continue
rows = db.scalars(
select(WhatsAppMessageTemplate).where(
WhatsAppMessageTemplate.name == name,
or_(WhatsAppMessageTemplate.language == language, language == ""),
)
).all()
reason = value.get("reason")
for row in rows:
row.status = event
if reason and str(reason).upper() not in {"NONE", "NULL"}:
row.rejected_reason = str(reason)[:2000]
meta_id = str(value.get("message_template_id") or "")
if meta_id:
row.meta_template_id = meta_id
changed = True
if changed:
db.commit()