""" Copyright 2024-2026 ReplyPier Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ """Sending on an already-connected WhatsApp number: templates and outbound conversations. Separate from meta.py, which onboards accounts. These routes are inbox work done by support agents on a live number, not org-admin onboarding, and they are the only ones that care about WhatsApp Business Accounts and templates. They mount under the same /meta prefix, so the URLs are unchanged. """ from urllib.parse import urlencode from uuid import UUID from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from app.api.channels.accounts import get_org_account_or_404 from app.channels import get_adapter from app.channels.meta_base import fetch_message_templates, graph_auth_refused, graph_detail, graph_get from app.channels.constants import MessageSource from app.core.error_handlers import ClientSafeHTTPException from app.core.auth import ( CHAT_MANAGE_PERMISSIONS, INBOX_PERMISSIONS, get_current_organization, require_any_permission, require_permissions, ) from app.database import get_db from app.models.channels import ChannelType from app.models.organization import Organization from app.models.schemas.channel import ( OutboundConversationOut, OutboundConversationRequest, TemplateLibraryOut, TemplateOut, TemplateSendRequest, ) from app.models.user import User from app.repositories.channels import ChannelAccountRepository, ChannelConversationRepository from app.services.whatsapp_outbound import OutboundError, start_outbound_conversation router = APIRouter() # Meta's own template authoring UI, which is where we send people to write one. TEMPLATE_LIBRARY_URL = "https://business.facebook.com/latest/whatsapp_manager/template_library" # Reading the template list is inbox work — the picker needs it — so any role # that works the inbox qualifies. INBOX_PERMISSIONS is shared with the People # page so the two surfaces can't disagree about who that is. require_inbox_agent = require_any_permission(*INBOX_PERMISSIONS) # Sending is not: an outbound template is a billable Meta message to a phone # number, with opt-in obligations attached. It takes the same grant as taking # over or reassigning a chat, so a view-only role can browse templates without # being able to fire one. require_inbox_sender = require_any_permission(*CHAT_MANAGE_PERMISSIONS) def _whatsapp_account_or_404(db: Session, account_id: UUID, organization: Organization): account = get_org_account_or_404(db, account_id, organization) if account.channel_type != ChannelType.WHATSAPP.value: raise HTTPException(status_code=404, detail="Channel account not found") return account async def _fetch_all_templates(db: Session, account) -> list[TemplateOut]: """Every template on the account's WABA (transport lives in meta_base; this wrapper owns the HTTP error surface).""" waba_id, access_token = _waba_credentials(db, account) ok, data = await fetch_message_templates(waba_id, access_token) if not ok: if graph_auth_refused(data): ChannelAccountRepository(db).record_auth_failure( account, graph_detail(data, "Authentication Error")) raise ClientSafeHTTPException(status_code=502, detail=graph_detail(data, "Could not list templates")) return [TemplateOut(**template) for template in data] def _waba_credentials(db: Session, account) -> tuple[str, str]: """(waba_id, access_token) for a WhatsApp account. Templates live on the WhatsApp Business Account, not the phone number, and the WABA id is optional in the credential blob — accounts connected without it can send and receive but cannot manage templates. """ credentials = ChannelAccountRepository(db).get_credentials(account) waba_id = credentials.get("waba_id") if not waba_id: raise HTTPException( status_code=400, detail="Reconnect this number with its WhatsApp Business Account ID to manage templates", ) return waba_id, credentials["access_token"] @router.post("/whatsapp/{account_id}/conversations", response_model=OutboundConversationOut) async def start_whatsapp_conversation( account_id: UUID, request: OutboundConversationRequest, current_user: User = Depends(require_inbox_sender), organization: Organization = Depends(get_current_organization), db: Session = Depends(get_db), ): """Start a WhatsApp conversation with a phone number via an approved Utility/Authentication template. The thin route: policy lives in the service so the Phase-2 scheduler can call the same function.""" account = _whatsapp_account_or_404(db, account_id, organization) try: sent = await start_outbound_conversation( db, account, to=request.to, template_name=request.template_name, language=request.language, components=request.components, customer_id=request.customer_id, customer_name=request.customer_name, source=MessageSource.HUMAN, ) except OutboundError as e: # OutboundError details are written for the operator ("Recipient not on # WhatsApp"), so they survive the 5xx sanitizer. raise ClientSafeHTTPException(status_code=e.status_code, detail=e.detail) return OutboundConversationOut(session_id=sent.session_id) @router.post("/whatsapp/{account_id}/send-template") async def send_whatsapp_template( account_id: UUID, request: TemplateSendRequest, current_user: User = Depends(require_inbox_sender), organization: Organization = Depends(get_current_organization), db: Session = Depends(get_db), ): """Send an approved template message to reopen an expired 24h window.""" account = _whatsapp_account_or_404(db, account_id, organization) conversation = ChannelConversationRepository(db).get_by_session(request.session_id) if conversation is None or conversation.channel_account_id != account.id: raise HTTPException(status_code=404, detail="Conversation not found for this account") adapter = get_adapter(ChannelType.WHATSAPP.value) result = await adapter.send_template( account, conversation, template_name=request.template_name, language=request.language, components=request.components, ) ChannelAccountRepository(db).track_send_result(account, result) if not result.ok: raise ClientSafeHTTPException(status_code=502, detail=result.error or "Template send failed") return {"status": "sent", "external_message_id": result.external_message_id} @router.get("/whatsapp/{account_id}/templates", response_model=list[TemplateOut]) async def list_whatsapp_templates( account_id: UUID, # Inbox-agent, not org-admin: this is what the send-template picker and the # New-conversation modal read. Gating it tighter than the send it feeds made # the loosening of those endpoints a no-op — the agent could send a template # but never see one to pick. current_user: User = Depends(require_inbox_agent), organization: Organization = Depends(get_current_organization), db: Session = Depends(get_db), ): """List the message templates on this number's WhatsApp Business Account.""" account = _whatsapp_account_or_404(db, account_id, organization) return await _fetch_all_templates(db, account) @router.get("/whatsapp/{account_id}/template-library", response_model=TemplateLibraryOut) async def get_whatsapp_template_library( account_id: UUID, current_user: User = Depends(require_permissions("manage_organization")), organization: Organization = Depends(get_current_organization), db: Session = Depends(get_db), ): """Where to go to write a template: Meta's own Template Library, opened on this number's WhatsApp Business Account. Templates are not authored here. Meta's library holds ~150 pre-written, pre-localised utility templates plus its authentication ones, all shaped to pass its own review — a form of ours could only ever be a worse version of that, and every rule it encodes (categories, variable numbering, which buttons may be mixed) is Meta's to change without telling us. The link is built per account rather than hardcoded: business_id scopes the page to the right portfolio. It is best-effort — a business we cannot read gives a link that lands less precisely, not a dead button. """ account = _whatsapp_account_or_404(db, account_id, organization) waba_id, access_token = _waba_credentials(db, account) params = {"asset_id": waba_id} ok, data = await graph_get(waba_id, access_token, params={"fields": "owner_business_info"}) if ok: business_id = (data.get("owner_business_info") or {}).get("id") if business_id: params["business_id"] = business_id return TemplateLibraryOut(url=f"{TEMPLATE_LIBRARY_URL}?{urlencode(params)}")