This commit is contained in:
debu-2 2026-10-15 01:58:00 +05:30
parent 8901d99dd2
commit 29d867438c
7 changed files with 645 additions and 5 deletions

View file

@ -0,0 +1,368 @@
"""CSV broadcasts paced under Meta's per-number throughput limit."""
from __future__ import annotations
import asyncio
import csv
import io
import logging
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from .database import SessionLocal
from .models import UserAccount, WhatsAppBroadcastJob
from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError
from .whatsapp_messages import extract_wa_message_id, record_outbound_message
from .whatsapp_onboarding import _messaging_phone, _owned_account
logger = logging.getLogger(__name__)
# Meta Cloud API allows 80 messages per second on a Cloud API number, and 20 per
# second when that number is also signed into the WhatsApp Business app.
# Ten sequential sends, then a two second pause, stays under the lower limit.
BATCH_SIZE = 10
COOLDOWN_SECONDS = 2
RATE_LIMIT_PAUSE_SECONDS = 10
MAX_NUMBERS = 1000
COUNTRIES = (
{"code": "91", "label": "India"},
{"code": "1", "label": "United States"},
{"code": "44", "label": "United Kingdom"},
{"code": "971", "label": "United Arab Emirates"},
{"code": "65", "label": "Singapore"},
)
_COUNTRY_CODES = {item["code"] for item in COUNTRIES}
_running: set[UUID] = set()
def country_options() -> list[dict[str, str]]:
return [{"code": item["code"], "label": item["label"]} for item in COUNTRIES]
def prepare_phone_number(raw: str, country_code: str) -> tuple[str | None, str | None]:
"""Strip symbols, add the country code to a 10-digit number, then check the prefix.
Returns (number, None) when it can be sent, (None, None) for a blank cell,
or (None, reason) when the row should count as failed.
"""
digits = re.sub(r"\D", "", raw or "")
if not digits:
return None, None
full_length = 10 + len(country_code)
if len(digits) == 10:
digits = f"{country_code}{digits}"
elif len(digits) != full_length:
return None, "This number does not match the selected country code."
if not digits.startswith(country_code):
return None, f"After cleanup this number must start with {country_code}."
return digits, None
def _parse_csv(raw: bytes, country_code: str) -> tuple[list[str], int]:
try:
text = raw.decode("utf-8-sig")
except UnicodeDecodeError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="The CSV must be UTF-8.") from exc
reader = csv.DictReader(io.StringIO(text))
if not reader.fieldnames:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="The CSV needs a header row.")
fields = [(name or "").strip().lower() for name in reader.fieldnames]
if "phone_number" not in fields:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="The CSV needs a phone_number column.")
reader.fieldnames = fields
rows = list(reader)
filled = [row for row in rows if str((row or {}).get("phone_number") or "").strip()]
if not filled:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Add at least one phone number.")
if len(filled) > MAX_NUMBERS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"A CSV can include at most {MAX_NUMBERS} numbers.",
)
ready: list[str] = []
seen: set[str] = set()
failed = 0
for row in filled:
number, reason = prepare_phone_number(str(row.get("phone_number") or ""), country_code)
if reason or not number:
failed += 1
continue
if number in seen:
failed += 1
continue
seen.add(number)
ready.append(number)
return ready, failed
def _serialize_job(job: WhatsAppBroadcastJob) -> dict[str, Any]:
return {
"id": str(job.id),
"account_id": str(job.account_id),
"filename": job.filename,
"country_code": job.country_code,
"template_name": job.template_name,
"template_language": job.template_language,
"status": job.status,
"total": job.total,
"sent_count": job.sent_count,
"failed_count": job.failed_count,
"last_error": job.last_error,
"created_at": job.created_at,
"completed_at": job.completed_at,
}
def list_broadcast_jobs(db: Session, user: UserAccount, account_id: Any) -> list[dict[str, Any]]:
account = _owned_account(db, user, account_id)
rows = db.scalars(
select(WhatsAppBroadcastJob)
.where(WhatsAppBroadcastJob.user_id == user.id, WhatsAppBroadcastJob.account_id == account.id)
.order_by(WhatsAppBroadcastJob.created_at.desc())
.limit(50)
).all()
return [_serialize_job(row) for row in rows]
def _finish_if_idle(db: Session, job: WhatsAppBroadcastJob) -> None:
recipients = job.recipients if isinstance(job.recipients, list) else []
if job.next_index < len(recipients):
return
job.status = "completed"
job.completed_at = datetime.now(timezone.utc)
db.commit()
async def create_csv_broadcast(
db: Session,
user: UserAccount,
account_id: Any,
filename: str,
raw: bytes,
*,
country_code: str,
template_name: str,
template_language: str,
body_parameters: list[str],
product_policy: str,
use_sample_header: bool,
) -> dict[str, Any]:
code = "".join(ch for ch in str(country_code or "") if ch.isdigit())
if code not in _COUNTRY_CODES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Choose a country code from the list.")
name = template_name.strip()
if not name:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Choose a marketing template.")
account = _owned_account(db, user, account_id)
_messaging_phone(account)
if use_sample_header:
from .whatsapp_template_service import _find_sample, _header_dict, _template_for_send
template = _template_for_send(db, user, account, name, template_language.strip() or "en")
header = _header_dict(template.definition if template else None)
if template is None or _find_sample(user.id, str(header.get("sample_id") or "")) is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Add the sample file on the template page before this broadcast can attach it.",
)
ready, failed = _parse_csv(raw, code)
safe_name = Path(filename or "broadcast.csv").name[:200] or "broadcast.csv"
job = WhatsAppBroadcastJob(
user_id=user.id,
account_id=account.id,
filename=safe_name,
country_code=code,
template_name=name,
template_language=template_language.strip() or "en",
product_policy=product_policy if product_policy in {"STRICT", "CLOUD_API_FALLBACK"} else "STRICT",
use_sample_header=use_sample_header,
body_parameters=[str(item).strip() for item in body_parameters if str(item).strip()],
recipients=ready,
status="processing",
total=len(ready) + failed,
sent_count=0,
failed_count=failed,
next_index=0,
)
db.add(job)
db.commit()
db.refresh(job)
_finish_if_idle(db, job)
db.refresh(job)
if job.status == "processing":
schedule_broadcast_job(job.id)
return _serialize_job(job)
def schedule_broadcast_job(job_id: UUID) -> None:
if job_id in _running:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
_running.add(job_id)
loop.create_task(run_broadcast_job(job_id))
def resume_broadcast_jobs() -> None:
with SessionLocal() as db:
ids = db.scalars(
select(WhatsAppBroadcastJob.id).where(WhatsAppBroadcastJob.status == "processing")
).all()
for job_id in ids:
schedule_broadcast_job(job_id)
def _rate_limited(detail: str) -> bool:
text = detail.lower()
return "130429" in text or "rate limit" in text or "throughput" in text
async def _send_one(client: WhatsAppGraphClient, number: str, name: str, language: str, components: list[dict[str, Any]] | None, policy: str) -> tuple[str, dict[str, Any] | None, str | None]:
try:
result = await client.send_marketing_template(
number,
name,
language_code=language,
components=components,
product_policy=policy,
)
return "sent", result if isinstance(result, dict) else None, None
except WhatsAppGraphError as exc:
if not _rate_limited(exc.detail):
return "failed", None, exc.detail
await asyncio.sleep(RATE_LIMIT_PAUSE_SECONDS)
try:
result = await client.send_marketing_template(
number,
name,
language_code=language,
components=components,
product_policy=policy,
)
return "sent", result if isinstance(result, dict) else None, None
except WhatsAppGraphError as retry_exc:
return "failed", None, retry_exc.detail
async def run_broadcast_job(job_id: UUID) -> None:
client: WhatsAppGraphClient | None = None
components: list[dict[str, Any]] | None = None
from_number = ""
phone_number_id = ""
try:
while True:
with SessionLocal() as db:
job = db.get(WhatsAppBroadcastJob, job_id)
if job is None or job.status != "processing":
return
recipients = list(job.recipients or [])
if job.next_index >= len(recipients):
_finish_if_idle(db, job)
return
batch_start = job.next_index
batch = recipients[batch_start:batch_start + BATCH_SIZE]
user = db.get(UserAccount, job.user_id)
if user is None:
job.status = "failed"
job.last_error = "This broadcast has no workspace."
job.completed_at = datetime.now(timezone.utc)
db.commit()
return
if client is None:
account = _owned_account(db, user, job.account_id)
phone, token = _messaging_phone(account)
phone_number_id = phone.phone_number_id
from_number = phone.display_phone or phone.phone_number_id
pieces: list[dict[str, Any]] = []
if job.use_sample_header:
from .whatsapp_template_service import header_parameter_for_send
pieces.append(
await header_parameter_for_send(
db,
user,
account,
token,
phone.phone_number_id,
job.template_name,
job.template_language,
)
)
parameters = job.body_parameters if isinstance(job.body_parameters, list) else []
if parameters:
pieces.append(
{
"type": "body",
"parameters": [{"type": "text", "text": str(item)} for item in parameters],
}
)
components = pieces or None
client = WhatsAppGraphClient(
access_token=token,
phone_number_id=phone.phone_number_id,
business_account_id=account.waba_id,
)
template_name = job.template_name
template_language = job.template_language
policy = job.product_policy
user_id = job.user_id
if client is None:
return
for offset, number in enumerate(batch):
outcome, result, error = await _send_one(client, number, template_name, template_language, components, policy)
with SessionLocal() as db:
job = db.get(WhatsAppBroadcastJob, job_id)
if job is None or job.status != "processing":
return
expected = batch_start + offset
if job.next_index != expected:
return
job.next_index = expected + 1
if outcome == "sent":
job.sent_count += 1
record_outbound_message(
db,
to_number=number,
body=f"Broadcast: {template_name}",
message_type="template",
wa_message_id=extract_wa_message_id(result),
status="sent",
payload=result,
from_number=from_number,
user_id=user_id,
phone_number_id=phone_number_id,
)
else:
job.failed_count += 1
job.last_error = (error or "Meta did not accept this number.")[:500]
db.commit()
if len(batch) == BATCH_SIZE:
await asyncio.sleep(COOLDOWN_SECONDS)
except HTTPException as exc:
_mark_failed(job_id, str(exc.detail))
except Exception:
logger.exception("CSV broadcast %s stopped", job_id)
_mark_failed(job_id, "The broadcast stopped before it finished.")
finally:
_running.discard(job_id)
def _mark_failed(job_id: UUID, message: str) -> None:
with SessionLocal() as db:
job = db.get(WhatsAppBroadcastJob, job_id)
if job is None or job.status != "processing":
return
job.status = "failed"
job.last_error = message[:500]
job.completed_at = datetime.now(timezone.utc)
db.commit()