whatsapp broadcast
This commit is contained in:
parent
63011f7d96
commit
70ccb720aa
9 changed files with 570 additions and 4 deletions
|
|
@ -24,14 +24,17 @@ from .whatsapp_onboarding import (
|
|||
account_logs,
|
||||
complete_signup,
|
||||
connect_with_token,
|
||||
create_marketing_template,
|
||||
customer_token_for_phone,
|
||||
developer_config,
|
||||
disconnect_account,
|
||||
download_account_media,
|
||||
enable_customer_delivery,
|
||||
list_account_messages,
|
||||
list_accounts,
|
||||
mask_identifier,
|
||||
register_phone_number,
|
||||
send_account_broadcast,
|
||||
send_account_message,
|
||||
list_templates,
|
||||
refresh_account,
|
||||
|
|
@ -180,6 +183,20 @@ class SendWhatsAppTextRequest(BaseModel):
|
|||
phone_number_id: str = Field(min_length=4, max_length=64)
|
||||
|
||||
|
||||
class MarketingTemplateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=512)
|
||||
language: str = Field(default="en", max_length=16)
|
||||
body: str = Field(min_length=1, max_length=1024)
|
||||
|
||||
|
||||
class BroadcastSendRequest(BaseModel):
|
||||
template_name: str = Field(min_length=1, max_length=512)
|
||||
template_language: str = Field(default="en", max_length=16)
|
||||
recipients: list[str] = Field(min_length=1, max_length=20)
|
||||
body_parameters: list[str] = Field(default_factory=list, max_length=10)
|
||||
product_policy: str = "STRICT"
|
||||
|
||||
|
||||
class CustomerSendRequest(BaseModel):
|
||||
to: str = Field(min_length=8, max_length=20)
|
||||
message_type: str = "text"
|
||||
|
|
@ -200,6 +217,10 @@ class RegisterPhoneRequest(BaseModel):
|
|||
pin: str | None = Field(default=None, max_length=6)
|
||||
|
||||
|
||||
class DeliverySecretRequest(BaseModel):
|
||||
app_secret: str = Field(min_length=8, max_length=256)
|
||||
|
||||
|
||||
class ManualTokenRequest(BaseModel):
|
||||
access_token: str = Field(min_length=1, max_length=4096)
|
||||
waba_id: str = Field(min_length=1, max_length=64)
|
||||
|
|
@ -285,6 +306,16 @@ def whatsapp_account_messages(
|
|||
return list_account_messages(db, user, account_id, limit)
|
||||
|
||||
|
||||
@router.post("/accounts/{account_id}/delivery")
|
||||
async def whatsapp_account_delivery(
|
||||
account_id: str,
|
||||
payload: DeliverySecretRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
return await enable_customer_delivery(db, user, account_id, payload.app_secret)
|
||||
|
||||
|
||||
@router.post("/accounts/{account_id}/messages/send")
|
||||
async def whatsapp_account_send(
|
||||
account_id: str,
|
||||
|
|
@ -295,6 +326,35 @@ async def whatsapp_account_send(
|
|||
return await send_account_message(db, user, account_id, payload.model_dump())
|
||||
|
||||
|
||||
@router.post("/accounts/{account_id}/templates")
|
||||
async def whatsapp_account_create_template(
|
||||
account_id: str,
|
||||
payload: MarketingTemplateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
return await create_marketing_template(db, user, account_id, payload.name, payload.language, payload.body)
|
||||
|
||||
|
||||
@router.post("/accounts/{account_id}/broadcasts")
|
||||
async def whatsapp_account_broadcast(
|
||||
account_id: str,
|
||||
payload: BroadcastSendRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
return await send_account_broadcast(
|
||||
db,
|
||||
user,
|
||||
account_id,
|
||||
payload.template_name,
|
||||
payload.template_language,
|
||||
payload.recipients,
|
||||
payload.body_parameters,
|
||||
payload.product_policy,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/accounts/{account_id}/media/{media_id}")
|
||||
async def whatsapp_account_media(
|
||||
account_id: str,
|
||||
|
|
|
|||
|
|
@ -123,6 +123,33 @@ class WhatsAppGraphClient:
|
|||
}
|
||||
)
|
||||
|
||||
async def send_marketing_template(
|
||||
self,
|
||||
to: str,
|
||||
name: str,
|
||||
language_code: str = "en",
|
||||
components: list[dict[str, Any]] | None = None,
|
||||
product_policy: str = "STRICT",
|
||||
) -> dict[str, Any]:
|
||||
"""Send a marketing template through Meta's marketing endpoint, not /messages."""
|
||||
if not self.configured:
|
||||
raise WhatsAppGraphError("WhatsApp Graph API is not configured", status_code=503)
|
||||
template: dict[str, Any] = {"name": name, "language": {"code": language_code}}
|
||||
if components:
|
||||
template["components"] = components
|
||||
return await self._request(
|
||||
"POST",
|
||||
self._graph_url(f"{self.phone_number_id}/marketing_messages"),
|
||||
json_payload={
|
||||
"messaging_product": "whatsapp",
|
||||
"recipient_type": "individual",
|
||||
"to": to,
|
||||
"type": "template",
|
||||
"template": template,
|
||||
"product_policy": product_policy,
|
||||
},
|
||||
)
|
||||
|
||||
async def send_interactive_buttons(
|
||||
self,
|
||||
to: str,
|
||||
|
|
|
|||
|
|
@ -1027,6 +1027,60 @@ async def list_templates(db: Session, user: UserAccount, account_id: Any) -> dic
|
|||
return {"available": True, "templates": templates}
|
||||
|
||||
|
||||
_TEMPLATE_NAME = re.compile(r"^[a-z0-9_]{1,512}$")
|
||||
|
||||
|
||||
async def create_marketing_template(
|
||||
db: Session,
|
||||
user: UserAccount,
|
||||
account_id: Any,
|
||||
name: str,
|
||||
language: str,
|
||||
body: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Submit a marketing template to Meta for the connected WhatsApp account."""
|
||||
account = _owned_account(db, user, account_id)
|
||||
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 access token again before creating a template.")
|
||||
template_name = name.strip().lower()
|
||||
template_language = language.strip() or "en"
|
||||
text = body.strip()
|
||||
if not _TEMPLATE_NAME.match(template_name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Template name should use lowercase letters, numbers, and underscores.",
|
||||
)
|
||||
if not re.fullmatch(r"[a-z]{2}(_[A-Z]{2})?", template_language):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Language should look like en or en_US.")
|
||||
if not text or len(text) > 1024:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Template text should be 1 to 1024 characters.")
|
||||
if "{{" in text:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This test template cannot include placeholders such as {{1}}.",
|
||||
)
|
||||
data = await _graph(
|
||||
"POST",
|
||||
f"{account.waba_id}/message_templates",
|
||||
token=token,
|
||||
json_body={
|
||||
"name": template_name,
|
||||
"language": template_language,
|
||||
"category": "MARKETING",
|
||||
"components": [{"type": "BODY", "text": text}],
|
||||
},
|
||||
)
|
||||
return {
|
||||
"id": data.get("id"),
|
||||
"name": template_name,
|
||||
"language": template_language,
|
||||
"category": str(data.get("category") or "MARKETING"),
|
||||
"status": str(data.get("status") or "PENDING"),
|
||||
}
|
||||
|
||||
|
||||
def account_logs(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]:
|
||||
account = _owned_account(db, user, account_id)
|
||||
audits = db.scalars(
|
||||
|
|
@ -1127,6 +1181,31 @@ def _messaging_phone(account: WhatsAppBusinessAccount) -> tuple[WhatsAppPhoneAss
|
|||
return phone, token
|
||||
|
||||
|
||||
async def enable_customer_delivery(db: Session, user: UserAccount, account_id: Any, app_secret: str) -> dict[str, Any]:
|
||||
"""Point this account's replies and delivery updates at the workspace."""
|
||||
account = _owned_account(db, user, account_id)
|
||||
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 access token again before replies can arrive here.")
|
||||
secret = "".join(str(app_secret or "").split())
|
||||
if len(secret) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Paste the App secret from the Meta app that created this token.",
|
||||
)
|
||||
connection.meta_app_secret = secret
|
||||
phone_ids = [phone.phone_number_id for phone in account.phones]
|
||||
receiving = await _attach_customer_webhook(token, account.waba_id, phone_ids)
|
||||
account.webhooks_status = "active" if receiving else "unavailable"
|
||||
account.last_error = None if receiving else "Meta did not accept the delivery address for replies."
|
||||
db.commit()
|
||||
db.refresh(account)
|
||||
if not receiving:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=account.last_error)
|
||||
return serialize_account(account)
|
||||
|
||||
|
||||
async def send_account_message(db: Session, user: UserAccount, account_id: Any, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
account = _owned_account(db, user, account_id)
|
||||
phone, token = _messaging_phone(account)
|
||||
|
|
@ -1201,6 +1280,93 @@ async def send_account_message(db: Session, user: UserAccount, account_id: Any,
|
|||
return serialize_message(message)
|
||||
|
||||
|
||||
async def send_account_broadcast(
|
||||
db: Session,
|
||||
user: UserAccount,
|
||||
account_id: Any,
|
||||
template_name: str,
|
||||
template_language: str,
|
||||
recipients: list[str],
|
||||
body_parameters: list[str] | None = None,
|
||||
product_policy: str = "STRICT",
|
||||
) -> dict[str, Any]:
|
||||
"""Send a marketing template through Meta's marketing endpoint."""
|
||||
account = _owned_account(db, user, account_id)
|
||||
phone, token = _messaging_phone(account)
|
||||
name = template_name.strip()
|
||||
language = template_language.strip() or "en"
|
||||
policy = product_policy if product_policy in {"STRICT", "CLOUD_API_FALLBACK"} else "STRICT"
|
||||
numbers = []
|
||||
for raw in recipients:
|
||||
piece = str(raw or "").strip()
|
||||
if piece:
|
||||
numbers.append(piece)
|
||||
if not name:
|
||||
raise HTTPException(status_code=422, detail="Choose a marketing template.")
|
||||
if not numbers:
|
||||
raise HTTPException(status_code=422, detail="Add at least one recipient.")
|
||||
if len(numbers) > 20:
|
||||
raise HTTPException(status_code=422, detail="A test broadcast can include up to 20 numbers.")
|
||||
parameters = [str(item).strip() for item in (body_parameters or []) if str(item).strip()]
|
||||
components = None
|
||||
if parameters:
|
||||
components = [
|
||||
{
|
||||
"type": "body",
|
||||
"parameters": [{"type": "text", "text": item} for item in parameters],
|
||||
}
|
||||
]
|
||||
client = WhatsAppGraphClient(
|
||||
access_token=token,
|
||||
phone_number_id=phone.phone_number_id,
|
||||
business_account_id=account.waba_id,
|
||||
)
|
||||
try:
|
||||
from_number = normalize_wa_number(phone.display_phone or "")
|
||||
except ValueError:
|
||||
from_number = phone.display_phone or phone.phone_number_id
|
||||
results: list[dict[str, Any]] = []
|
||||
for raw in numbers:
|
||||
try:
|
||||
recipient = normalize_wa_number(raw)
|
||||
except ValueError:
|
||||
results.append({"to": raw, "status": "failed", "error": "Enter a valid WhatsApp number."})
|
||||
continue
|
||||
try:
|
||||
result = await client.send_marketing_template(
|
||||
recipient,
|
||||
name,
|
||||
language_code=language,
|
||||
components=components,
|
||||
product_policy=policy,
|
||||
)
|
||||
except WhatsAppGraphError as exc:
|
||||
results.append({"to": recipient, "status": "failed", "error": exc.detail})
|
||||
continue
|
||||
message = record_outbound_message(
|
||||
db,
|
||||
to_number=recipient,
|
||||
body=f"Broadcast: {name}",
|
||||
message_type="template",
|
||||
wa_message_id=extract_wa_message_id(result),
|
||||
status="sent",
|
||||
payload=result if isinstance(result, dict) else None,
|
||||
from_number=from_number,
|
||||
user_id=user.id,
|
||||
phone_number_id=phone.phone_number_id,
|
||||
)
|
||||
results.append({"to": recipient, "status": "sent", "message_id": message.wa_message_id})
|
||||
sent = sum(1 for item in results if item["status"] == "sent")
|
||||
return {
|
||||
"template_name": name,
|
||||
"template_language": language,
|
||||
"product_policy": policy,
|
||||
"sent": sent,
|
||||
"failed": len(results) - sent,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
async def download_account_media(db: Session, user: UserAccount, account_id: Any, media_id: str) -> tuple[bytes, str]:
|
||||
account = _owned_account(db, user, account_id)
|
||||
token = account.connection.access_token if account.connection else None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue