This commit is contained in:
debu-2 2026-10-14 05:38:10 +05:30
parent ad189b8568
commit dcae960928
5 changed files with 129 additions and 49 deletions

View file

@ -11,7 +11,7 @@ from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload
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
_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)
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 _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]:
@ -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]:
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,
@ -433,41 +489,54 @@ async def _template_page(token: str, waba_id: str, params: dict[str, Any]) -> di
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."
async def _pull_templates(db: Session, account: WhatsAppBusinessAccount, token: str) -> set[tuple[str, str]]:
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
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
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:
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]:
@ -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]:
account = _owned_account(db, user, payload.get("account_id"))
token = _token(account)
token = _token(db, account)
name, language, category = _validate_identity(payload)
existing = db.scalar(
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.")
_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})
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()
@ -623,7 +692,7 @@ async def delete_managed_template(db: Session, user: UserAccount, template_id: A
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)
await _graph("DELETE", f"{account.waba_id}/message_templates", token=_token(db, account), params=params)
db.delete(row)
db.commit()
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]:
account = _owned_account(db, user, account_id)
token = _token(account)
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.")