432 lines
14 KiB
Python
432 lines
14 KiB
Python
import json
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import or_, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .auth import current_user
|
|
from .config import settings
|
|
from .database import get_db
|
|
from .models import (
|
|
UserAccount,
|
|
WhatsAppBusinessAccount,
|
|
WhatsAppMetaConnection,
|
|
WhatsAppPhoneAsset,
|
|
WhatsAppWebhookEvent,
|
|
)
|
|
from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError
|
|
from .whatsapp_config import resolved_whatsapp_app_secret
|
|
from .whatsapp_onboarding import (
|
|
RECOMMENDED_WEBHOOK_FIELDS,
|
|
account_detail,
|
|
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,
|
|
signup_public_config,
|
|
)
|
|
from .whatsapp_security import verify_meta_signature
|
|
from .whatsapp_webhook import persist_webhook_payload
|
|
from .whatsapp_messages import record_inbound_messages
|
|
from .whatsapp_template_service import apply_template_status_webhook
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/v1/whatsapp", tags=["whatsapp"])
|
|
|
|
|
|
def _webhook_secrets(db: Session) -> list[str]:
|
|
"""Platform secret for the admin app, plus each customer's own Meta app secret."""
|
|
found: list[str] = []
|
|
primary = resolved_whatsapp_app_secret(db).strip()
|
|
if primary:
|
|
found.append(primary)
|
|
extras = db.scalars(
|
|
select(WhatsAppMetaConnection.meta_app_secret).where(
|
|
WhatsAppMetaConnection.meta_app_secret.is_not(None),
|
|
WhatsAppMetaConnection.status != "disconnected",
|
|
)
|
|
).all()
|
|
for item in extras:
|
|
value = (item or "").strip()
|
|
if value and value not in found:
|
|
found.append(value)
|
|
return found
|
|
|
|
|
|
def _serialize_event(event: WhatsAppWebhookEvent) -> dict:
|
|
return {
|
|
"id": str(event.id),
|
|
"waba_id": mask_identifier(event.waba_id),
|
|
"phone_number_id": mask_identifier(event.phone_number_id),
|
|
"field": event.field,
|
|
"event_type": event.event_type,
|
|
"from_wa_id": event.from_wa_id,
|
|
"message_id": event.message_id,
|
|
"status": event.status,
|
|
"created_at": event.created_at,
|
|
}
|
|
|
|
|
|
@router.get("/webhook")
|
|
def verify_webhook(
|
|
hub_mode: str | None = Query(default=None, alias="hub.mode"),
|
|
hub_verify_token: str | None = Query(default=None, alias="hub.verify_token"),
|
|
hub_challenge: str | None = Query(default=None, alias="hub.challenge"),
|
|
) -> Response:
|
|
"""
|
|
Meta webhook verification (Step 2 — Configure Webhooks).
|
|
Meta sends hub.mode=subscribe, hub.verify_token, and hub.challenge.
|
|
"""
|
|
if not settings.whatsapp_verify_token:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="WHATSAPP_VERIFY_TOKEN is not configured",
|
|
)
|
|
if hub_mode == "subscribe" and hub_verify_token == settings.whatsapp_verify_token:
|
|
logger.info("WhatsApp webhook verified by Meta")
|
|
return Response(content=hub_challenge or "", media_type="text/plain")
|
|
logger.warning("WhatsApp webhook verification failed mode=%s", hub_mode)
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Verification failed")
|
|
|
|
|
|
@router.post("/webhook")
|
|
async def receive_webhook(
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
x_hub_signature_256: str | None = Header(default=None, alias="X-Hub-Signature-256"),
|
|
) -> dict[str, str]:
|
|
"""Receive inbound WhatsApp Business Platform webhook payloads."""
|
|
raw_body = await request.body()
|
|
secrets = _webhook_secrets(db)
|
|
|
|
if secrets:
|
|
if not any(verify_meta_signature(secret, raw_body, x_hub_signature_256) for secret in secrets):
|
|
logger.warning("WhatsApp webhook signature validation failed")
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid signature")
|
|
elif settings.environment == "production":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="WHATSAPP_APP_SECRET is required in production",
|
|
)
|
|
|
|
try:
|
|
payload = json.loads(raw_body.decode("utf-8") or "{}")
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
payload = {}
|
|
|
|
persist_webhook_payload(db, payload)
|
|
record_inbound_messages(db, payload)
|
|
apply_template_status_webhook(db, payload)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.get("/setup")
|
|
def whatsapp_setup(
|
|
_: UserAccount = Depends(current_user),
|
|
) -> dict:
|
|
"""Customer-safe webhook address. Secrets stay on the server."""
|
|
return {
|
|
"webhook_url": settings.whatsapp_webhook_url,
|
|
"verify_token_configured": bool(settings.whatsapp_verify_token),
|
|
"verify_token_visible": False,
|
|
"api_version": settings.whatsapp_api_version,
|
|
"recommended_fields": RECOMMENDED_WEBHOOK_FIELDS,
|
|
"docs_url": "https://developers.facebook.com/documentation/business-messaging/whatsapp/embedded-signup",
|
|
}
|
|
|
|
|
|
@router.get("/events")
|
|
def list_webhook_events(
|
|
limit: int = Query(default=25, ge=1, le=100),
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_user),
|
|
) -> list[dict]:
|
|
waba_ids = list(
|
|
db.scalars(select(WhatsAppBusinessAccount.waba_id).where(WhatsAppBusinessAccount.user_id == user.id)).all()
|
|
)
|
|
phone_ids = list(
|
|
db.scalars(select(WhatsAppPhoneAsset.phone_number_id).where(WhatsAppPhoneAsset.user_id == user.id)).all()
|
|
)
|
|
if not waba_ids and not phone_ids:
|
|
return []
|
|
filters = []
|
|
if waba_ids:
|
|
filters.append(WhatsAppWebhookEvent.waba_id.in_(waba_ids))
|
|
if phone_ids:
|
|
filters.append(WhatsAppWebhookEvent.phone_number_id.in_(phone_ids))
|
|
events = db.scalars(
|
|
select(WhatsAppWebhookEvent)
|
|
.where(or_(*filters))
|
|
.order_by(WhatsAppWebhookEvent.created_at.desc())
|
|
.limit(limit)
|
|
).all()
|
|
return [_serialize_event(event) for event in events]
|
|
|
|
|
|
class SendWhatsAppTextRequest(BaseModel):
|
|
to: str = Field(min_length=8, max_length=20)
|
|
body: str = Field(min_length=1, max_length=4096)
|
|
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"
|
|
use_sample_header: bool = False
|
|
|
|
|
|
class CustomerSendRequest(BaseModel):
|
|
to: str = Field(min_length=8, max_length=20)
|
|
message_type: str = "text"
|
|
text: str | None = Field(default=None, max_length=4096)
|
|
reply_to: str | None = None
|
|
template_name: str | None = None
|
|
template_language: str = "en"
|
|
media_link: str | None = None
|
|
media_caption: str | None = None
|
|
media_filename: str | None = None
|
|
interactive_body: str | None = None
|
|
interactive_buttons: list[dict[str, str]] | None = None
|
|
reaction_message_id: str | None = None
|
|
reaction_emoji: str | None = None
|
|
|
|
|
|
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)
|
|
phone_number_id: str | None = Field(default=None, max_length=64)
|
|
app_secret: str | None = Field(default=None, max_length=256)
|
|
|
|
|
|
class SignupCompleteRequest(BaseModel):
|
|
event: str = Field(min_length=1, max_length=80)
|
|
code: str | None = Field(default=None, max_length=2048)
|
|
waba_id: str | None = Field(default=None, max_length=64)
|
|
phone_number_id: str | None = Field(default=None, max_length=64)
|
|
business_id: str | None = Field(default=None, max_length=64)
|
|
error_message: str | None = Field(default=None, max_length=500)
|
|
|
|
|
|
@router.get("/signup/config")
|
|
def whatsapp_signup_config(
|
|
db: Session = Depends(get_db),
|
|
_: UserAccount = Depends(current_user),
|
|
) -> dict:
|
|
return signup_public_config(db)
|
|
|
|
|
|
@router.get("/accounts")
|
|
def whatsapp_accounts(
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_user),
|
|
) -> list[dict]:
|
|
return list_accounts(db, user)
|
|
|
|
|
|
@router.post("/signup/complete")
|
|
async def whatsapp_signup_complete(
|
|
payload: SignupCompleteRequest,
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_user),
|
|
) -> dict:
|
|
return await complete_signup(db, user, payload.model_dump())
|
|
|
|
|
|
@router.post("/connections/token")
|
|
async def whatsapp_connect_token(
|
|
payload: ManualTokenRequest,
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_user),
|
|
) -> dict:
|
|
return await connect_with_token(
|
|
db,
|
|
user,
|
|
payload.access_token,
|
|
payload.waba_id,
|
|
payload.phone_number_id,
|
|
payload.app_secret,
|
|
)
|
|
|
|
|
|
@router.get("/accounts/{account_id}")
|
|
def whatsapp_account_detail(
|
|
account_id: str,
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_user),
|
|
) -> dict:
|
|
return account_detail(db, user, account_id)
|
|
|
|
|
|
@router.post("/accounts/{account_id}/refresh")
|
|
async def whatsapp_account_refresh(
|
|
account_id: str,
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_user),
|
|
) -> dict:
|
|
return await refresh_account(db, user, account_id)
|
|
|
|
|
|
@router.get("/accounts/{account_id}/messages")
|
|
def whatsapp_account_messages(
|
|
account_id: str,
|
|
limit: int = Query(default=300, ge=1, le=500),
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_user),
|
|
) -> list[dict]:
|
|
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,
|
|
payload: CustomerSendRequest,
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_user),
|
|
) -> dict:
|
|
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,
|
|
payload.use_sample_header,
|
|
)
|
|
|
|
|
|
@router.get("/accounts/{account_id}/media/{media_id}")
|
|
async def whatsapp_account_media(
|
|
account_id: str,
|
|
media_id: str,
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_user),
|
|
) -> Response:
|
|
content, mime_type = await download_account_media(db, user, account_id, media_id)
|
|
return Response(content=content, media_type=mime_type)
|
|
|
|
|
|
@router.post("/accounts/{account_id}/phones/{phone_id}/register")
|
|
async def whatsapp_register_phone(
|
|
account_id: str,
|
|
phone_id: str,
|
|
payload: RegisterPhoneRequest,
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_user),
|
|
) -> dict:
|
|
return await register_phone_number(db, user, account_id, phone_id, payload.pin)
|
|
|
|
|
|
@router.post("/accounts/{account_id}/disconnect")
|
|
async def whatsapp_account_disconnect(
|
|
account_id: str,
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_user),
|
|
) -> dict:
|
|
return await disconnect_account(db, user, account_id)
|
|
|
|
|
|
@router.get("/accounts/{account_id}/templates")
|
|
async def whatsapp_account_templates(
|
|
account_id: str,
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_user),
|
|
) -> dict:
|
|
return await list_templates(db, user, account_id)
|
|
|
|
|
|
@router.get("/accounts/{account_id}/logs")
|
|
def whatsapp_account_logs(
|
|
account_id: str,
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_user),
|
|
) -> dict:
|
|
return account_logs(db, user, account_id)
|
|
|
|
|
|
@router.get("/accounts/{account_id}/developer")
|
|
def whatsapp_account_developer(
|
|
account_id: str,
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_user),
|
|
) -> dict:
|
|
return developer_config(db, user, account_id)
|
|
|
|
|
|
@router.post("/messages/send")
|
|
async def send_whatsapp_text(
|
|
payload: SendWhatsAppTextRequest,
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_user),
|
|
) -> dict:
|
|
"""Send from a WhatsApp number this workspace has actually connected."""
|
|
token, phone_number_id = customer_token_for_phone(db, user, payload.phone_number_id.strip())
|
|
client = WhatsAppGraphClient(access_token=token, phone_number_id=phone_number_id)
|
|
try:
|
|
return await client.send_text(payload.to.strip(), payload.body.strip())
|
|
except WhatsAppGraphError as exc:
|
|
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|