template
This commit is contained in:
parent
8037489503
commit
ad189b8568
15 changed files with 1989 additions and 4 deletions
|
|
@ -26,6 +26,7 @@ from .routes_numbers import router as numbers_router
|
|||
from .routes_urmiotp import router as urmiotp_router
|
||||
from .routes_voice import router as voice_router
|
||||
from .routes_whatsapp import router as whatsapp_router
|
||||
from .routes_whatsapp_templates import router as whatsapp_templates_router
|
||||
from .routes_meta import router as meta_router
|
||||
from .routes_agent_templates import router as agent_templates_router
|
||||
from .routes_agent_messages import router as agent_messages_router
|
||||
|
|
@ -109,6 +110,7 @@ app.include_router(numbers_router)
|
|||
app.include_router(integrations_router)
|
||||
app.include_router(voice_router)
|
||||
app.include_router(whatsapp_router)
|
||||
app.include_router(whatsapp_templates_router)
|
||||
app.include_router(meta_router)
|
||||
app.include_router(agent_templates_router)
|
||||
app.include_router(agent_messages_router)
|
||||
|
|
|
|||
|
|
@ -614,6 +614,36 @@ class WhatsAppPhoneAsset(Base):
|
|||
account: Mapped[WhatsAppBusinessAccount] = relationship(back_populates="phones")
|
||||
|
||||
|
||||
class WhatsAppMessageTemplate(Base):
|
||||
"""A WhatsApp message template owned by one WABA. Meta remains the source of approval."""
|
||||
|
||||
__tablename__ = "whatsapp_message_templates"
|
||||
__table_args__ = (UniqueConstraint("waba_id", "name", "language", name="uq_wa_template_name_lang"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("whatsapp_business_accounts.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
waba_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
phone_number_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
meta_template_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(512), index=True)
|
||||
language: Mapped[str] = mapped_column(String(16), default="en_US")
|
||||
category: Mapped[str] = mapped_column(String(32), default="MARKETING")
|
||||
subtype: Mapped[str] = mapped_column(String(40), default="default")
|
||||
status: Mapped[str] = mapped_column(String(32), default="PENDING", index=True)
|
||||
quality: Mapped[str | None] = mapped_column(String(24), nullable=True)
|
||||
rejected_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
parameter_format: Mapped[str] = mapped_column(String(16), default="POSITIONAL")
|
||||
definition: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
components: Mapped[list] = mapped_column(JSON, default=list)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class WhatsAppConnectionAudit(Base):
|
||||
__tablename__ = "whatsapp_connection_audits"
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ from .whatsapp_onboarding import (
|
|||
from .whatsapp_security import verify_meta_signature
|
||||
from .whatsapp_webhook import persist_webhook_payload
|
||||
from .whatsapp_messages import record_inbound_messages
|
||||
from .whatsapp_template_service import apply_template_status_webhook
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -131,6 +132,7 @@ async def receive_webhook(
|
|||
|
||||
persist_webhook_payload(db, payload)
|
||||
record_inbound_messages(db, payload)
|
||||
apply_template_status_webhook(db, payload)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
|
|
|||
98
backend/app/routes_whatsapp_templates.py
Normal file
98
backend/app/routes_whatsapp_templates.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .auth import current_user
|
||||
from .database import get_db
|
||||
from .models import UserAccount
|
||||
from .whatsapp_template_service import (
|
||||
create_managed_template,
|
||||
delete_managed_template,
|
||||
get_managed_template,
|
||||
language_catalog,
|
||||
list_managed_templates,
|
||||
resubmit_managed_template,
|
||||
upload_header_media,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/whatsapp/message-templates", tags=["whatsapp-templates"])
|
||||
|
||||
|
||||
class TemplateWriteRequest(BaseModel):
|
||||
account_id: str | None = None
|
||||
phone_number_id: str | None = None
|
||||
name: str = Field(min_length=1, max_length=512)
|
||||
language: str = "en_US"
|
||||
category: str
|
||||
subtype: str = "default"
|
||||
parameter_format: str = "POSITIONAL"
|
||||
header: dict[str, Any] = Field(default_factory=lambda: {"type": "NONE"})
|
||||
body: str = ""
|
||||
body_examples: list[str] = Field(default_factory=list)
|
||||
footer: str = ""
|
||||
buttons: list[dict[str, Any]] = Field(default_factory=list)
|
||||
auth: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@router.get("/languages")
|
||||
def whatsapp_template_languages(_: UserAccount = Depends(current_user)) -> list[dict[str, str]]:
|
||||
return language_catalog()
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def whatsapp_template_list(
|
||||
account_id: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
return await list_managed_templates(db, user, account_id)
|
||||
|
||||
|
||||
@router.post("/media")
|
||||
async def whatsapp_template_media(
|
||||
account_id: str = Form(),
|
||||
file: UploadFile = File(),
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
data = await file.read()
|
||||
return await upload_header_media(db, user, account_id, file.filename or "header", file.content_type or "", data)
|
||||
|
||||
|
||||
@router.get("/{template_id}")
|
||||
def whatsapp_template_detail(
|
||||
template_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
return get_managed_template(db, user, template_id)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def whatsapp_template_create(
|
||||
payload: TemplateWriteRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
return await create_managed_template(db, user, payload.model_dump())
|
||||
|
||||
|
||||
@router.post("/{template_id}/resubmit")
|
||||
async def whatsapp_template_resubmit(
|
||||
template_id: str,
|
||||
payload: TemplateWriteRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
return await resubmit_managed_template(db, user, template_id, payload.model_dump())
|
||||
|
||||
|
||||
@router.delete("/{template_id}")
|
||||
async def whatsapp_template_delete(
|
||||
template_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
return await delete_managed_template(db, user, template_id)
|
||||
703
backend/app/whatsapp_template_service.py
Normal file
703
backend/app/whatsapp_template_service.py
Normal file
|
|
@ -0,0 +1,703 @@
|
|||
"""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
|
||||
from .whatsapp_onboarding import _graph, _graph_error_message, _owned_account, _quality_label, _token_app_id
|
||||
|
||||
_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 _token(account: WhatsAppBusinessAccount) -> str:
|
||||
connection = account.connection
|
||||
token = connection.access_token if connection else None
|
||||
if connection is None or not token or connection.status == "disconnected":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Save the WhatsApp access token before managing templates.")
|
||||
return token
|
||||
|
||||
|
||||
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
|
||||
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]
|
||||
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 "",
|
||||
"phone_number_id": row.phone_number_id,
|
||||
"display_phone": phone.display_phone if phone else "",
|
||||
"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 sync_account_templates(db: Session, account: WhatsAppBusinessAccount) -> str | None:
|
||||
token = account.connection.access_token if account.connection else None
|
||||
if not token:
|
||||
return "This account has no access token, so templates could not be refreshed."
|
||||
seen: set[tuple[str, str]] = set()
|
||||
after = None
|
||||
try:
|
||||
for _ in range(5):
|
||||
params = {"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
|
||||
except HTTPException as exc:
|
||||
db.rollback()
|
||||
return str(exc.detail)
|
||||
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
|
||||
|
||||
|
||||
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(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(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(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(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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue