template
This commit is contained in:
parent
ad189b8568
commit
dcae960928
5 changed files with 129 additions and 49 deletions
|
|
@ -11,7 +11,7 @@ from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.orm import Session, selectinload
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .models import UserAccount, WhatsAppBusinessAccount, WhatsAppMessage, WhatsAppMessageTemplate
|
from .models import UserAccount, WhatsAppBusinessAccount, WhatsAppMessage, WhatsAppMessageTemplate, WhatsAppMetaConnection
|
||||||
from .whatsapp_onboarding import _graph, _graph_error_message, _owned_account, _quality_label, _token_app_id
|
from .whatsapp_onboarding import _graph, _graph_error_message, _owned_account, _quality_label, _token_app_id
|
||||||
|
|
||||||
_NAME = re.compile(r"^[a-z0-9_]{1,512}$")
|
_NAME = re.compile(r"^[a-z0-9_]{1,512}$")
|
||||||
|
|
@ -64,12 +64,59 @@ def _language_ok(code: str) -> bool:
|
||||||
return any(item[0] == code for item in LANGUAGES)
|
return any(item[0] == code for item in LANGUAGES)
|
||||||
|
|
||||||
|
|
||||||
def _token(account: WhatsAppBusinessAccount) -> str:
|
def _connection_method(connection: WhatsAppMetaConnection | None) -> str:
|
||||||
connection = account.connection
|
if connection is None:
|
||||||
token = connection.access_token if connection else None
|
return "WhatsApp"
|
||||||
if connection is None or not token or connection.status == "disconnected":
|
if connection.granted_scopes == "manual":
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Save the WhatsApp access token before managing templates.")
|
return "Saved token"
|
||||||
return 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]:
|
def _variables(text: str) -> list[str]:
|
||||||
|
|
@ -340,17 +387,26 @@ def _sent_counts(db: Session, user: UserAccount) -> dict[str, int]:
|
||||||
|
|
||||||
def _serialize(row: WhatsAppMessageTemplate, account: WhatsAppBusinessAccount | None, sent: int) -> dict[str, Any]:
|
def _serialize(row: WhatsAppMessageTemplate, account: WhatsAppBusinessAccount | None, sent: int) -> dict[str, Any]:
|
||||||
phone = None
|
phone = None
|
||||||
|
phones: list[str] = []
|
||||||
|
connection = account.connection if account is not None else None
|
||||||
if account is not None:
|
if account is not None:
|
||||||
phone = next((item for item in account.phones if item.phone_number_id == row.phone_number_id), 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:
|
if phone is None and account.phones:
|
||||||
phone = account.phones[0]
|
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 {
|
return {
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
"account_id": str(row.account_id),
|
"account_id": str(row.account_id),
|
||||||
"account_name": account.name if account else "WhatsApp Business",
|
"account_name": account.name if account else "WhatsApp Business",
|
||||||
"waba_name": account.name if account else "",
|
"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,
|
"phone_number_id": row.phone_number_id,
|
||||||
"display_phone": phone.display_phone if phone else "",
|
"display_phone": phone.display_phone if phone else "",
|
||||||
|
"phones": phones,
|
||||||
"meta_template_id": row.meta_template_id,
|
"meta_template_id": row.meta_template_id,
|
||||||
"name": row.name,
|
"name": row.name,
|
||||||
"language": row.language,
|
"language": row.language,
|
||||||
|
|
@ -433,15 +489,11 @@ async def _template_page(token: str, waba_id: str, params: dict[str, Any]) -> di
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
async def sync_account_templates(db: Session, account: WhatsAppBusinessAccount) -> str | None:
|
async def _pull_templates(db: Session, account: WhatsAppBusinessAccount, token: str) -> set[tuple[str, str]]:
|
||||||
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()
|
seen: set[tuple[str, str]] = set()
|
||||||
after = None
|
after = None
|
||||||
try:
|
|
||||||
for _ in range(5):
|
for _ in range(5):
|
||||||
params = {"limit": "100"}
|
params: dict[str, Any] = {"limit": "100"}
|
||||||
if after:
|
if after:
|
||||||
params["after"] = after
|
params["after"] = after
|
||||||
data = await _template_page(token, account.waba_id, params)
|
data = await _template_page(token, account.waba_id, params)
|
||||||
|
|
@ -455,9 +507,25 @@ async def sync_account_templates(db: Session, account: WhatsAppBusinessAccount)
|
||||||
after = cursors.get("after") if paging.get("next") else None
|
after = cursors.get("after") if paging.get("next") else None
|
||||||
if not after:
|
if not after:
|
||||||
break
|
break
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
seen = await _pull_templates(db, account, token)
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
return str(exc.detail)
|
last_error = str(exc.detail)
|
||||||
|
if _retry_with_another_token(exc) and index < len(tokens) - 1:
|
||||||
|
continue
|
||||||
|
return last_error
|
||||||
stored = db.scalars(
|
stored = db.scalars(
|
||||||
select(WhatsAppMessageTemplate).where(WhatsAppMessageTemplate.waba_id == account.waba_id)
|
select(WhatsAppMessageTemplate).where(WhatsAppMessageTemplate.waba_id == account.waba_id)
|
||||||
).all()
|
).all()
|
||||||
|
|
@ -468,6 +536,7 @@ async def sync_account_templates(db: Session, account: WhatsAppBusinessAccount)
|
||||||
account.templates_status = "active"
|
account.templates_status = "active"
|
||||||
db.commit()
|
db.commit()
|
||||||
return None
|
return None
|
||||||
|
return last_error
|
||||||
|
|
||||||
|
|
||||||
async def list_managed_templates(db: Session, user: UserAccount, account_id: str | None) -> dict[str, Any]:
|
async def list_managed_templates(db: Session, user: UserAccount, account_id: str | None) -> dict[str, Any]:
|
||||||
|
|
@ -556,7 +625,7 @@ def _validate_identity(payload: dict[str, Any]) -> tuple[str, str, str]:
|
||||||
|
|
||||||
async def create_managed_template(db: Session, user: UserAccount, payload: dict[str, Any]) -> dict[str, Any]:
|
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"))
|
account = _owned_account(db, user, payload.get("account_id"))
|
||||||
token = _token(account)
|
token = _token(db, account)
|
||||||
name, language, category = _validate_identity(payload)
|
name, language, category = _validate_identity(payload)
|
||||||
existing = db.scalar(
|
existing = db.scalar(
|
||||||
select(WhatsAppMessageTemplate).where(
|
select(WhatsAppMessageTemplate).where(
|
||||||
|
|
@ -607,7 +676,7 @@ async def resubmit_managed_template(db: Session, user: UserAccount, template_id:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Meta has no id for this template yet.")
|
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})
|
_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})
|
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})
|
data = await _graph("POST", row.meta_template_id, token=_token(db, account), json_body={"components": components, "category": row.category})
|
||||||
row.components = components
|
row.components = components
|
||||||
row.definition = payload
|
row.definition = payload
|
||||||
row.status = str(data.get("status") or "PENDING").upper()
|
row.status = str(data.get("status") or "PENDING").upper()
|
||||||
|
|
@ -623,7 +692,7 @@ async def delete_managed_template(db: Session, user: UserAccount, template_id: A
|
||||||
params: dict[str, Any] = {"name": row.name}
|
params: dict[str, Any] = {"name": row.name}
|
||||||
if row.meta_template_id:
|
if row.meta_template_id:
|
||||||
params["hsm_id"] = 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)
|
await _graph("DELETE", f"{account.waba_id}/message_templates", token=_token(db, account), params=params)
|
||||||
db.delete(row)
|
db.delete(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|
@ -631,7 +700,7 @@ async def delete_managed_template(db: Session, user: UserAccount, template_id: A
|
||||||
|
|
||||||
async def upload_header_media(db: Session, user: UserAccount, account_id: Any, filename: str, content_type: str, data: bytes) -> dict[str, str]:
|
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)
|
account = _owned_account(db, user, account_id)
|
||||||
token = _token(account)
|
token = _token(db, account)
|
||||||
kind = _MEDIA.get(content_type)
|
kind = _MEDIA.get(content_type)
|
||||||
if kind is None:
|
if kind is None:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Upload a JPEG, PNG, MP4, or PDF.")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Upload a JPEG, PNG, MP4, or PDF.")
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,8 @@ export function WhatsAppTemplateDetailPage() {
|
||||||
<dl className="wa-tpl-review">
|
<dl className="wa-tpl-review">
|
||||||
<div><dt>Meta template ID</dt><dd>{row.meta_template_id || 'Not assigned yet'}</dd></div>
|
<div><dt>Meta template ID</dt><dd>{row.meta_template_id || 'Not assigned yet'}</dd></div>
|
||||||
<div><dt>Account</dt><dd>{row.account_name}</dd></div>
|
<div><dt>Account</dt><dd>{row.account_name}</dd></div>
|
||||||
<div><dt>Phone</dt><dd>{row.display_phone || 'Shared on the account'}</dd></div>
|
<div><dt>Connection</dt><dd>{row.connection_name || row.account_name} · {row.connection_method || 'WhatsApp'}</dd></div>
|
||||||
|
<div><dt>Numbers</dt><dd>{(row.phones && row.phones.length ? row.phones : [row.display_phone]).filter(Boolean).join(', ') || 'Shared on the account'}</dd></div>
|
||||||
<div><dt>Category</dt><dd>{row.category.toLowerCase()}</dd></div>
|
<div><dt>Category</dt><dd>{row.category.toLowerCase()}</dd></div>
|
||||||
<div><dt>Language</dt><dd>{row.language}</dd></div>
|
<div><dt>Language</dt><dd>{row.language}</dd></div>
|
||||||
<div><dt>Quality</dt><dd>{row.quality || 'Not rated yet'}</dd></div>
|
<div><dt>Quality</dt><dd>{row.quality || 'Not rated yet'}</dd></div>
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ export function WhatsAppTemplatesPage() {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const filtered = useMemo(() => rows.filter((row) => {
|
const filtered = useMemo(() => rows.filter((row) => {
|
||||||
const haystack = `${row.name} ${row.meta_template_id || ''}`.toLowerCase()
|
const haystack = `${row.name} ${row.meta_template_id || ''} ${(row.phones || []).join(' ')} ${row.display_phone || ''} ${row.connection_name || ''} ${row.account_name || ''}`.toLowerCase()
|
||||||
if (search && !haystack.includes(search.toLowerCase())) return false
|
if (search && !haystack.includes(search.toLowerCase())) return false
|
||||||
if (category && row.category !== category) return false
|
if (category && row.category !== category) return false
|
||||||
if (language && row.language !== language) return false
|
if (language && row.language !== language) return false
|
||||||
|
|
@ -129,7 +129,10 @@ export function WhatsAppTemplatesPage() {
|
||||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search by name or ID" aria-label="Search templates" />
|
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search by name or ID" aria-label="Search templates" />
|
||||||
<select value={accountId} onChange={(event) => { setAccountId(event.target.value); load(event.target.value) }} aria-label="WhatsApp account">
|
<select value={accountId} onChange={(event) => { setAccountId(event.target.value); load(event.target.value) }} aria-label="WhatsApp account">
|
||||||
<option value="">All accounts</option>
|
<option value="">All accounts</option>
|
||||||
{accounts.map((account) => <option key={account.id} value={account.id}>{account.name}</option>)}
|
{accounts.map((account) => {
|
||||||
|
const numbers = account.phones.map((phone) => phone.display_phone).filter(Boolean).join(', ')
|
||||||
|
return <option key={account.id} value={account.id}>{numbers ? `${account.name} · ${numbers}` : account.name}</option>
|
||||||
|
})}
|
||||||
</select>
|
</select>
|
||||||
<select value={category} onChange={(event) => setCategory(event.target.value)} aria-label="Category">
|
<select value={category} onChange={(event) => setCategory(event.target.value)} aria-label="Category">
|
||||||
<option value="">Category</option>
|
<option value="">Category</option>
|
||||||
|
|
@ -156,11 +159,13 @@ export function WhatsAppTemplatesPage() {
|
||||||
{!loading && filtered.length > 0 && (
|
{!loading && filtered.length > 0 && (
|
||||||
<div className="wa-tpl-table">
|
<div className="wa-tpl-table">
|
||||||
<div className="wa-tpl-row wa-tpl-head">
|
<div className="wa-tpl-row wa-tpl-head">
|
||||||
<span>Template name</span><span>Category</span><span>Language</span><span>Status</span><span>Quality</span><span>Messages sent</span><span>Last updated</span><span>Actions</span>
|
<span>Template name</span><span>Number</span><span>Connection</span><span>Category</span><span>Language</span><span>Status</span><span>Quality</span><span>Messages sent</span><span>Last updated</span><span>Actions</span>
|
||||||
</div>
|
</div>
|
||||||
{filtered.map((row) => (
|
{filtered.map((row) => (
|
||||||
<div className="wa-tpl-row" key={row.id}>
|
<div className="wa-tpl-row" key={row.id}>
|
||||||
<button type="button" className="wa-tpl-name" onClick={() => navigate(`/console/whatsapp/templates/${row.id}`)}>{row.name}</button>
|
<button type="button" className="wa-tpl-name" onClick={() => navigate(`/console/whatsapp/templates/${row.id}`)}>{row.name}</button>
|
||||||
|
<span className="wa-tpl-number">{(row.phones && row.phones.length ? row.phones : [row.display_phone]).filter(Boolean).join(', ') || 'Shared on the account'}</span>
|
||||||
|
<span className="wa-tpl-connection"><strong>{row.connection_name || row.account_name}</strong><small>{row.connection_method}</small></span>
|
||||||
<span>{row.category.toLowerCase()}</span>
|
<span>{row.category.toLowerCase()}</span>
|
||||||
<span>{row.language}</span>
|
<span>{row.language}</span>
|
||||||
<span className={`wa-tpl-status is-${row.status.toLowerCase()}`}>{statusLabel(row.status)}</span>
|
<span className={`wa-tpl-status is-${row.status.toLowerCase()}`}>{statusLabel(row.status)}</span>
|
||||||
|
|
|
||||||
|
|
@ -2014,7 +2014,9 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
|
||||||
.wa-tpl-cards strong { display: block; margin-top: 6px; font-size: 26px; color: #14233b; }
|
.wa-tpl-cards strong { display: block; margin-top: 6px; font-size: 26px; color: #14233b; }
|
||||||
.wa-tpl-filters input, .wa-tpl-filters select, .wa-tpl-button input, .wa-tpl-button select, .wa-tpl-samples input { border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; background: white; }
|
.wa-tpl-filters input, .wa-tpl-filters select, .wa-tpl-button input, .wa-tpl-button select, .wa-tpl-samples input { border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; background: white; }
|
||||||
.wa-tpl-table { margin-top: 14px; overflow-x: auto; }
|
.wa-tpl-table { margin-top: 14px; overflow-x: auto; }
|
||||||
.wa-tpl-row { display: grid; grid-template-columns: 1.4fr .8fr .6fr .8fr .6fr .7fr .8fr 1.3fr; gap: 8px; align-items: center; min-width: 860px; padding: 12px 4px; border-top: 1px solid #edf0f4; font-size: 13px; }
|
.wa-tpl-row { display: grid; grid-template-columns: 1.15fr 1fr 1.15fr .7fr .55fr .7fr .55fr .5fr .7fr 1.15fr; gap: 8px; align-items: center; min-width: 1180px; padding: 12px 4px; border-top: 1px solid #edf0f4; font-size: 13px; }
|
||||||
|
.wa-tpl-connection { display: grid; gap: 2px; }
|
||||||
|
.wa-tpl-connection small, .wa-tpl-number { color: #526176; }
|
||||||
.wa-tpl-head { border-top: 0; color: #8a95a7; font-size: 11px; font-weight: 700; text-transform: uppercase; }
|
.wa-tpl-head { border-top: 0; color: #8a95a7; font-size: 11px; font-weight: 700; text-transform: uppercase; }
|
||||||
.wa-tpl-name, .wa-tpl-actions button, .wa-tpl-format button, .wa-tpl-tabs button { border: 0; background: transparent; color: #1475ff; padding: 0; font-weight: 600; }
|
.wa-tpl-name, .wa-tpl-actions button, .wa-tpl-format button, .wa-tpl-tabs button { border: 0; background: transparent; color: #1475ff; padding: 0; font-weight: 600; }
|
||||||
.wa-tpl-actions { display: flex; gap: 8px; }
|
.wa-tpl-actions { display: flex; gap: 8px; }
|
||||||
|
|
|
||||||
|
|
@ -1377,8 +1377,11 @@ export type WaManagedTemplate = {
|
||||||
account_id: string
|
account_id: string
|
||||||
account_name: string
|
account_name: string
|
||||||
waba_name: string
|
waba_name: string
|
||||||
|
connection_name: string
|
||||||
|
connection_method: string
|
||||||
phone_number_id: string | null
|
phone_number_id: string | null
|
||||||
display_phone: string
|
display_phone: string
|
||||||
|
phones: string[]
|
||||||
meta_template_id: string | null
|
meta_template_id: string | null
|
||||||
name: string
|
name: string
|
||||||
language: string
|
language: string
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue