diff --git a/backend/app/routes_whatsapp.py b/backend/app/routes_whatsapp.py index 1e5f1e9..fbbe22b 100644 --- a/backend/app/routes_whatsapp.py +++ b/backend/app/routes_whatsapp.py @@ -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, diff --git a/backend/app/whatsapp_graph_client.py b/backend/app/whatsapp_graph_client.py index 8a2d330..c045156 100644 --- a/backend/app/whatsapp_graph_client.py +++ b/backend/app/whatsapp_graph_client.py @@ -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, diff --git a/backend/app/whatsapp_onboarding.py b/backend/app/whatsapp_onboarding.py index 80e05c7..fdae147 100644 --- a/backend/app/whatsapp_onboarding.py +++ b/backend/app/whatsapp_onboarding.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 19b528e..c396c6b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,6 +19,7 @@ import { TestingCenterPage } from './features/testing/TestingCenterPage' import { LogsPage } from './features/logs/LogsPage' import { AnalyticsPage } from './features/analytics/AnalyticsPage' import { WhatsAppSetupPage } from './features/whatsapp/WhatsAppSetupPage' +import { WhatsAppBroadcastPage } from './features/whatsapp/WhatsAppBroadcastPage' import { WhatsAppTokenPage } from './features/whatsapp/WhatsAppTokenPage' import { MetaOverviewPage } from './features/meta/OverviewPage' import { MetaConnectionsPage } from './features/meta/ConnectionsPage' @@ -74,6 +75,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index d3fcd15..db2f008 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { Activity, BarChart3, Bell, BookOpen, Bot, Boxes, ChevronDown, CircleDollarSign, Code2, Cog, CreditCard, Cpu, FileCheck2, FileText, Gauge, Globe, Image, KeyRound, LayoutDashboard, - Link2, LogOut, Megaphone, Menu, MessageSquareText, Network, Phone, Search, Settings, ShieldCheck, + Link2, LogOut, Megaphone, Menu, MessageSquareText, Network, Phone, Search, Send, Settings, ShieldCheck, SlidersHorizontal, TestTube2, Users, Webhook, X, Zap, MessageCircle, } from 'lucide-react' @@ -31,6 +31,7 @@ const groups = [ { label: 'Messages', links: [ { label: 'Send a message', path: '/console/messaging', icon: MessageSquareText }, { label: 'WhatsApp', path: '/console/whatsapp', icon: MessageCircle, end: true }, + { label: 'WhatsApp broadcast', path: '/console/whatsapp/broadcast', icon: Send }, { label: 'WhatsApp token', path: '/console/whatsapp/token', icon: KeyRound }, { label: 'Campaigns', path: '/console/campaigns', icon: Zap }, { label: 'Templates', path: '/console/templates', icon: BookOpen }, diff --git a/frontend/src/features/whatsapp/CustomerWhatsAppInbox.tsx b/frontend/src/features/whatsapp/CustomerWhatsAppInbox.tsx index d81bf61..5800197 100644 --- a/frontend/src/features/whatsapp/CustomerWhatsAppInbox.tsx +++ b/frontend/src/features/whatsapp/CustomerWhatsAppInbox.tsx @@ -109,8 +109,26 @@ export function CustomerWhatsAppInbox({ account, onBack }: { account: WhatsAppAc const [reactFor, setReactFor] = useState(null) const [emojiOpen, setEmojiOpen] = useState(false) const chatEndRef = useRef(null) - const line = account.primary_phone?.display_phone || account.name - const canSend = account.messaging_status === 'active' && account.usable + const [deliveryAccount, setDeliveryAccount] = useState(account) + const [appSecret, setAppSecret] = useState('') + const [deliveryBusy, setDeliveryBusy] = useState(false) + const line = deliveryAccount.primary_phone?.display_phone || deliveryAccount.name + const canSend = deliveryAccount.messaging_status === 'active' && deliveryAccount.usable + const repliesLive = deliveryAccount.webhooks_status === 'active' + + async function saveDelivery(event: FormEvent) { + event.preventDefault() + setDeliveryBusy(true) + setError('') + try { + setDeliveryAccount(await whatsappApi.enableDelivery(account.id, appSecret.trim())) + setAppSecret('') + } catch (reason) { + setError(reason instanceof Error ? reason.message : 'Replies could not be turned on') + } finally { + setDeliveryBusy(false) + } + } async function loadMessages() { setMessages(await whatsappApi.messages(account.id)) @@ -272,7 +290,17 @@ export function CustomerWhatsAppInbox({ account, onBack }: { account: WhatsAppAc - {!canSend &&

{account.last_error || 'You can read messages already delivered here. Sending stays off until Meta connects this number.'}

} + {!canSend &&

{deliveryAccount.last_error || 'You can read messages already delivered here. Sending stays off until Meta connects this number.'}

} + {canSend && !repliesLive && ( +
void saveDelivery(event)}> +

WhatsApp accepted messages from this number, but delivery updates and replies are still going to the Meta app that created the token. Paste that app’s App secret so they arrive here. In Meta: the app → App settings → Basic → App secret.

+ + +
+ )}