From 141ca9fda3253c4fc5bf37e5e2bdae06b57bd8d4 Mon Sep 17 00:00:00 2001 From: debu-2 Date: Fri, 2 Oct 2026 12:51:46 +0530 Subject: [PATCH] whatsapp integration --- .env.example | 10 ++ backend/.env.example | 17 +++ backend/app/config.py | 18 +++ backend/app/main.py | 2 + backend/app/models.py | 17 +++ backend/app/routes_whatsapp.py | 144 ++++++++++++++++++ backend/app/whatsapp_graph_client.py | 56 +++++++ backend/app/whatsapp_security.py | 13 ++ backend/app/whatsapp_webhook.py | 97 ++++++++++++ docker-compose.yml | 8 + frontend/src/App.tsx | 2 + frontend/src/components/AppShell.tsx | 4 +- .../features/whatsapp/WhatsAppSetupPage.tsx | 139 +++++++++++++++++ frontend/src/index.css | 17 +++ frontend/src/lib/api.ts | 35 +++++ 15 files changed, 578 insertions(+), 1 deletion(-) create mode 100644 backend/app/routes_whatsapp.py create mode 100644 backend/app/whatsapp_graph_client.py create mode 100644 backend/app/whatsapp_security.py create mode 100644 backend/app/whatsapp_webhook.py create mode 100644 frontend/src/features/whatsapp/WhatsAppSetupPage.tsx diff --git a/.env.example b/.env.example index f550ad1..0396249 100644 --- a/.env.example +++ b/.env.example @@ -37,3 +37,13 @@ BONVOICE_API_URL=https://backend.pbx.bonvoice.com/autoDialManagement/autoCallBri BONVOICE_MEDIA_ALLOWLIST=103.131.25.5 # Public WSS base; Bonvoice media endpoint = {VOICE_RELAY_PUBLIC_URL}/ws/media VOICE_RELAY_PUBLIC_URL=ws://localhost:8000/api/v1/voice + +# --- WhatsApp Business Platform (Meta tech provider / BSP) --- +PUBLIC_API_BASE_URL=http://localhost:8000 +WHATSAPP_VERIFY_TOKEN=replace-with-a-long-random-string +WHATSAPP_APP_SECRET= +WHATSAPP_ACCESS_TOKEN= +WHATSAPP_PHONE_NUMBER_ID= +WHATSAPP_BUSINESS_ACCOUNT_ID= +META_APP_ID= +WHATSAPP_API_VERSION=v21.0 diff --git a/backend/.env.example b/backend/.env.example index 15d1684..7ae7af9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -48,3 +48,20 @@ BONVOICE_MEDIA_ALLOWLIST=103.131.25.5 VOICE_RELAY_PUBLIC_URL=ws://localhost:8000/api/v1/voice # Production: VOICE_RELAY_PUBLIC_URL=wss://api.urmiai.com/api/v1/voice # Bonvoice portal voicebot endpoint: wss://api.urmiai.com/api/v1/voice/ws/media + +# --- WhatsApp Business Platform (Meta tech provider / BSP) --- +# Public HTTPS API base — used to build the webhook callback URL shown in console +PUBLIC_API_BASE_URL=https://api.urmiai.com +# Callback verify token — paste the SAME value in Meta → Configure Webhooks → Verify token +WHATSAPP_VERIFY_TOKEN=replace-with-a-long-random-string +# App secret from Meta App Dashboard → App settings → Basic +WHATSAPP_APP_SECRET= +# System user or long-lived token with whatsapp_business_messaging + whatsapp_business_management +WHATSAPP_ACCESS_TOKEN= +# From Meta → WhatsApp → API Setup (Phone number ID) +WHATSAPP_PHONE_NUMBER_ID= +# WhatsApp Business Account ID (WABA) +WHATSAPP_BUSINESS_ACCOUNT_ID= +# Meta App ID +META_APP_ID= +WHATSAPP_API_VERSION=v21.0 diff --git a/backend/app/config.py b/backend/app/config.py index 31f31a3..f72f65d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -30,6 +30,15 @@ class Settings(BaseSettings): bonvoice_api_url: str = "https://backend.pbx.bonvoice.com/autoDialManagement/autoCallBridging/" bonvoice_media_allowlist: str = "103.131.25.5" voice_session_ttl_seconds: int = 600 + whatsapp_api_version: str = "v21.0" + whatsapp_verify_token: str = "" + whatsapp_app_secret: str = "" + whatsapp_access_token: str = "" + whatsapp_phone_number_id: str = "" + whatsapp_business_account_id: str = "" + meta_app_id: str = "" + # Public HTTPS base for webhook callback (no trailing slash), e.g. https://api.urmiai.com + public_api_base_url: str = "" model_config = SettingsConfigDict( env_file=(".env", "../.env"), @@ -46,4 +55,13 @@ class Settings(BaseSettings): return [self.frontend_origin] + @computed_field # type: ignore[prop-decorator] + @property + def whatsapp_webhook_url(self) -> str: + base = self.public_api_base_url.rstrip("/") if self.public_api_base_url else "" + if base: + return f"{base}/api/v1/whatsapp/webhook" + return "/api/v1/whatsapp/webhook" + + settings = Settings() diff --git a/backend/app/main.py b/backend/app/main.py index 6bd036e..ad672b7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -25,6 +25,7 @@ from .routes_integrations import router as integrations_router from .routes_numbers import router as numbers_router from .routes_urmiotp import router as urmiotp_router from .routes_voice import router as voice_router +from .routes_whatsapp import router as whatsapp_router @asynccontextmanager @@ -86,6 +87,7 @@ app.include_router(campaigns_router) app.include_router(numbers_router) app.include_router(integrations_router) app.include_router(voice_router) +app.include_router(whatsapp_router) app.include_router(admin_router) diff --git a/backend/app/models.py b/backend/app/models.py index 2d7d26b..e1bbf8f 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -491,3 +491,20 @@ class OtpMessageLog(Base): error_message: Mapped[str | None] = mapped_column(String(500), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) user: Mapped[UserAccount] = relationship(back_populates="otp_message_logs") + + +class WhatsAppWebhookEvent(Base): + """Inbound webhook payloads from Meta WhatsApp Business Platform.""" + + __tablename__ = "whatsapp_webhook_events" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + waba_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + phone_number_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + field: Mapped[str] = mapped_column(String(80), index=True) + event_type: Mapped[str] = mapped_column(String(80), index=True) + from_wa_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + message_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + status: Mapped[str | None] = mapped_column(String(32), nullable=True) + payload: Mapped[dict] = mapped_column(JSON) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) diff --git a/backend/app/routes_whatsapp.py b/backend/app/routes_whatsapp.py new file mode 100644 index 0000000..929c28c --- /dev/null +++ b/backend/app/routes_whatsapp.py @@ -0,0 +1,144 @@ +import json +import logging + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.orm import Session + +from .auth import current_user +from .config import settings +from .database import get_db +from .models import UserAccount, WhatsAppWebhookEvent +from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError +from .whatsapp_security import verify_meta_signature +from .whatsapp_webhook import persist_webhook_payload + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/whatsapp", tags=["whatsapp"]) + +RECOMMENDED_WEBHOOK_FIELDS = [ + "messages", + "message_template_status_update", + "message_template_quality_update", + "account_update", + "phone_number_name_update", + "phone_number_quality_update", + "business_capability_update", +] + + +def _serialize_event(event: WhatsAppWebhookEvent) -> dict: + return { + "id": str(event.id), + "waba_id": event.waba_id, + "phone_number_id": 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() + + if settings.whatsapp_app_secret: + if not verify_meta_signature(settings.whatsapp_app_secret, raw_body, x_hub_signature_256): + 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) + return {"status": "ok"} + + +@router.get("/setup") +def whatsapp_setup( + _: UserAccount = Depends(current_user), +) -> dict: + """Console setup info — paste these values into Meta Developer Console.""" + return { + "webhook_url": settings.whatsapp_webhook_url, + "verify_token_configured": bool(settings.whatsapp_verify_token), + "app_secret_configured": bool(settings.whatsapp_app_secret), + "access_token_configured": bool(settings.whatsapp_access_token), + "phone_number_id": settings.whatsapp_phone_number_id or None, + "business_account_id": settings.whatsapp_business_account_id or None, + "meta_app_id": settings.meta_app_id or None, + "api_version": settings.whatsapp_api_version, + "recommended_fields": RECOMMENDED_WEBHOOK_FIELDS, + "docs_url": "https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview/", + } + + +@router.get("/events") +def list_webhook_events( + limit: int = Query(default=25, ge=1, le=100), + db: Session = Depends(get_db), + _: UserAccount = Depends(current_user), +) -> list[dict]: + events = db.scalars( + select(WhatsAppWebhookEvent) + .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) + + +@router.post("/messages/send") +async def send_whatsapp_text( + payload: SendWhatsAppTextRequest, + _: UserAccount = Depends(current_user), +) -> dict: + """Send a test text message via Graph API (requires WHATSAPP_ACCESS_TOKEN).""" + client = WhatsAppGraphClient() + 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 diff --git a/backend/app/whatsapp_graph_client.py b/backend/app/whatsapp_graph_client.py new file mode 100644 index 0000000..a7e90e9 --- /dev/null +++ b/backend/app/whatsapp_graph_client.py @@ -0,0 +1,56 @@ +import httpx + +from .config import settings + + +class WhatsAppGraphError(Exception): + def __init__(self, detail: str, status_code: int = 502) -> None: + super().__init__(detail) + self.detail = detail + self.status_code = status_code + + +class WhatsAppGraphClient: + def __init__( + self, + *, + access_token: str | None = None, + phone_number_id: str | None = None, + api_version: str | None = None, + ) -> None: + self.access_token = access_token or settings.whatsapp_access_token + self.phone_number_id = phone_number_id or settings.whatsapp_phone_number_id + self.api_version = api_version or settings.whatsapp_api_version + + @property + def configured(self) -> bool: + return bool(self.access_token and self.phone_number_id) + + def _messages_url(self) -> str: + return ( + f"https://graph.facebook.com/{self.api_version}/" + f"{self.phone_number_id}/messages" + ) + + async def send_text(self, to: str, body: str) -> dict: + if not self.configured: + raise WhatsAppGraphError("WhatsApp Graph API is not configured", status_code=503) + + payload = { + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": to, + "type": "text", + "text": {"preview_url": False, "body": body}, + } + headers = {"Authorization": f"Bearer {self.access_token}"} + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(self._messages_url(), json=payload, headers=headers) + try: + data = response.json() + except ValueError: + data = {"raw": response.text} + if response.status_code >= 400: + detail = data.get("error", {}).get("message") if isinstance(data, dict) else response.text + raise WhatsAppGraphError(detail or "WhatsApp Graph API request failed", status_code=response.status_code) + return data diff --git a/backend/app/whatsapp_security.py b/backend/app/whatsapp_security.py new file mode 100644 index 0000000..52c0cae --- /dev/null +++ b/backend/app/whatsapp_security.py @@ -0,0 +1,13 @@ +import hashlib +import hmac + + +def verify_meta_signature(app_secret: str, raw_body: bytes, signature_header: str | None) -> bool: + """Validate X-Hub-Signature-256 from Meta webhook POSTs.""" + if not app_secret or not signature_header: + return False + if not signature_header.startswith("sha256="): + return False + expected = signature_header.removeprefix("sha256=") + computed = hmac.new(app_secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest() + return hmac.compare_digest(computed, expected) diff --git a/backend/app/whatsapp_webhook.py b/backend/app/whatsapp_webhook.py new file mode 100644 index 0000000..b463951 --- /dev/null +++ b/backend/app/whatsapp_webhook.py @@ -0,0 +1,97 @@ +import logging +from typing import Any + +from sqlalchemy.orm import Session + +from .models import WhatsAppWebhookEvent + +logger = logging.getLogger(__name__) + + +def _extract_events(payload: dict[str, Any]) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + if payload.get("object") != "whatsapp_business_account": + return events + + for entry in payload.get("entry", []): + waba_id = entry.get("id") + for change in entry.get("changes", []): + field = change.get("field", "unknown") + value = change.get("value", {}) + phone_number_id = (value.get("metadata") or {}).get("phone_number_id") + + if field == "messages": + for message in value.get("messages", []): + events.append( + { + "waba_id": waba_id, + "phone_number_id": phone_number_id, + "field": field, + "event_type": "message", + "from_wa_id": message.get("from"), + "message_id": message.get("id"), + "status": message.get("type"), + "payload": change, + } + ) + for status in value.get("statuses", []): + events.append( + { + "waba_id": waba_id, + "phone_number_id": phone_number_id, + "field": field, + "event_type": "status", + "from_wa_id": status.get("recipient_id"), + "message_id": status.get("id"), + "status": status.get("status"), + "payload": change, + } + ) + else: + events.append( + { + "waba_id": waba_id, + "phone_number_id": phone_number_id, + "field": field, + "event_type": field, + "from_wa_id": None, + "message_id": None, + "status": None, + "payload": change, + } + ) + return events + + +def persist_webhook_payload(db: Session, payload: dict[str, Any]) -> int: + events = _extract_events(payload) + if not events: + events = [ + { + "waba_id": None, + "phone_number_id": None, + "field": "unknown", + "event_type": "raw", + "from_wa_id": None, + "message_id": None, + "status": None, + "payload": payload, + } + ] + + for event in events: + db.add( + WhatsAppWebhookEvent( + waba_id=event.get("waba_id"), + phone_number_id=event.get("phone_number_id"), + field=event["field"], + event_type=event["event_type"], + from_wa_id=event.get("from_wa_id"), + message_id=event.get("message_id"), + status=event.get("status"), + payload=event["payload"], + ) + ) + db.commit() + logger.info("Stored %s WhatsApp webhook event(s)", len(events)) + return len(events) diff --git a/docker-compose.yml b/docker-compose.yml index cbf9a7e..afa7e42 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,6 +44,14 @@ services: BONVOICE_API_URL: ${BONVOICE_API_URL:-https://backend.pbx.bonvoice.com/autoDialManagement/autoCallBridging/} BONVOICE_MEDIA_ALLOWLIST: ${BONVOICE_MEDIA_ALLOWLIST:-103.131.25.5} VOICE_RELAY_PUBLIC_URL: ${VOICE_RELAY_PUBLIC_URL:-ws://localhost:8000/api/v1/voice} + PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL:-} + WHATSAPP_VERIFY_TOKEN: ${WHATSAPP_VERIFY_TOKEN:-} + WHATSAPP_APP_SECRET: ${WHATSAPP_APP_SECRET:-} + WHATSAPP_ACCESS_TOKEN: ${WHATSAPP_ACCESS_TOKEN:-} + WHATSAPP_PHONE_NUMBER_ID: ${WHATSAPP_PHONE_NUMBER_ID:-} + WHATSAPP_BUSINESS_ACCOUNT_ID: ${WHATSAPP_BUSINESS_ACCOUNT_ID:-} + META_APP_ID: ${META_APP_ID:-} + WHATSAPP_API_VERSION: ${WHATSAPP_API_VERSION:-v21.0} UPLOAD_DIR: uploads KYC_TEMPLATE_PATH: assets/kyc-template.docx volumes: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b2fb2bf..5b17f2c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ import { VoicePage } from './features/voice/VoicePage' 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 { UrmiOtpPage } from './features/agent/UrmiOtpPage' import { UrmiOtpDocsPage } from './features/agent/UrmiOtpDocsPage' import { KycPage } from './features/compliance/KycPage' @@ -53,6 +54,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 3cd3187..8319dcc 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -2,8 +2,9 @@ import { useState } from 'react' import { Activity, Bell, BookOpen, Bot, Boxes, ChevronDown, CircleDollarSign, Code2, CreditCard, Cpu, FileCheck2, Gauge, KeyRound, LayoutDashboard, - LogOut, Menu, MessageSquareText, Network, Phone, Search, Settings, ShieldCheck, + LogOut, Menu, MessageSquareText, Network, Phone, Search, Settings, ShieldCheck, SlidersHorizontal, TestTube2, Users, Webhook, X, Zap, + MessageCircle, } from 'lucide-react' import { NavLink, Outlet, useNavigate } from 'react-router-dom' import { useAuth } from '../features/auth/auth-context' @@ -28,6 +29,7 @@ const groups = [ ]}, { label: 'Communicate', links: [ { label: 'Messaging', path: '/console/messaging', icon: MessageSquareText }, + { label: 'WhatsApp', path: '/console/whatsapp', icon: MessageCircle }, { label: 'Campaigns', path: '/console/campaigns', icon: Zap }, { label: 'Templates', path: '/console/templates', icon: BookOpen }, ]}, diff --git a/frontend/src/features/whatsapp/WhatsAppSetupPage.tsx b/frontend/src/features/whatsapp/WhatsAppSetupPage.tsx new file mode 100644 index 0000000..ea28f1c --- /dev/null +++ b/frontend/src/features/whatsapp/WhatsAppSetupPage.tsx @@ -0,0 +1,139 @@ +import { Check, Copy, ExternalLink, MessageCircle } from 'lucide-react' +import { useEffect, useState } from 'react' +import { whatsappApi, type WhatsAppSetup, type WhatsAppWebhookEvent } from '../../lib/api' + +function ConfigBadge({ ok, label }: { ok: boolean; label: string }) { + return ( + + {label}: {ok ? 'Configured' : 'Missing'} + + ) +} + +export function WhatsAppSetupPage() { + const [setup, setSetup] = useState(null) + const [events, setEvents] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [copied, setCopied] = useState(null) + + useEffect(() => { + Promise.all([whatsappApi.setup(), whatsappApi.events()]) + .then(([setupPayload, eventPayload]) => { + setSetup(setupPayload) + setEvents(eventPayload) + }) + .catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load WhatsApp setup')) + .finally(() => setLoading(false)) + }, []) + + async function copyValue(key: string, value: string) { + await navigator.clipboard.writeText(value) + setCopied(key) + window.setTimeout(() => setCopied(null), 2000) + } + + if (loading) return

Loading WhatsApp setup…

+ + return ( +
+
+ Communicate +

WhatsApp

+

Configure Meta webhooks so Urmi can receive messages and delivery updates on behalf of your customers.

+
+ + {error &&

{error}

} + + {setup && ( + <> +
+
+
+

Meta webhook configuration

+

Paste these into App Dashboard → WhatsApp → Configuration → Configure Webhooks

+
+ + Meta docs + +
+ +
+
+ Callback URL + {setup.webhook_url} +
+ +
+ +
+
+ Verify token + {setup.verify_token_configured ? 'Set in server env (WHATSAPP_VERIFY_TOKEN)' : 'Not configured'} + Use the exact same string in Meta and in your .env file. +
+
+ +
+ + + + + +
+
+ +
+
+
+

Subscribe to webhook fields

+

In Meta, subscribe to these fields after verifying the callback URL.

+
+
+
    + {setup.recommended_fields.map((field) => ( +
  • {field}
  • + ))} +
+

+ Minimum for messaging: messages. For a tech provider / BSP, also enable template and account fields. +

+
+ +
+
+
+

Recent webhook events

+

Incoming payloads from Meta (after verification succeeds).

+
+
+ {events.length === 0 ? ( +

No webhook events yet. Click Verify and save in Meta to send a test.

+ ) : ( +
+
+ Field + Type + From + Status + Time +
+ {events.map((event) => ( +
+ {event.field} + {event.event_type} + {event.from_wa_id || '—'} + {event.status || '—'} + {new Date(event.created_at).toLocaleString()} +
+ ))} +
+ )} +
+ + )} +
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 793e9d7..ada4229 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -590,6 +590,23 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; } @media (max-width: 980px) { .analytics-toolbar-row, .analytics-grid, .analytics-summary-grid { grid-template-columns: 1fr; } } + +.whatsapp-setup-page { max-width: 900px; } +.whatsapp-panel { margin-bottom: 16px; } +.whatsapp-config-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding: 14px 0; border-bottom: 1px solid #edf0f4; } +.whatsapp-config-row span { display: block; color: #8a95a7; font-size: 10px; font-weight: 700; text-transform: uppercase; margin-bottom: 6px; } +.whatsapp-config-row code { display: block; color: #35435a; font-size: 12px; word-break: break-all; } +.whatsapp-config-row small { display: block; margin-top: 6px; color: #8a95a7; font-size: 11px; } +.whatsapp-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; } +.whatsapp-config-badge { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: 999px; font-size: 11px; font-weight: 600; } +.whatsapp-config-badge i { width: 6px; height: 6px; border-radius: 50%; } +.whatsapp-config-badge.ok { color: #0f7a49; background: #e8f8ef; } +.whatsapp-config-badge.ok i { background: #0f7a49; } +.whatsapp-config-badge.missing { color: #8a5b00; background: #fff7df; } +.whatsapp-config-badge.missing i { background: #d78219; } +.whatsapp-field-list { margin: 0; padding: 0; list-style: none; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 16px; } +.whatsapp-field-list li { display: flex; align-items: center; gap: 8px; color: #35435a; font-size: 12px; } +.whatsapp-field-list svg { color: #159768; } @media (max-width: 860px) { .logs-toolbar-row { grid-template-columns: 1fr; } .logs-table-head { display: none; } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a745fb3..069ff54 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -788,3 +788,38 @@ export const analyticsApi = { return jsonRequest(`/api/v1/analytics/usage?${query.toString()}`) }, } + +export type WhatsAppSetup = { + webhook_url: string + verify_token_configured: boolean + app_secret_configured: boolean + access_token_configured: boolean + phone_number_id: string | null + business_account_id: string | null + meta_app_id: string | null + api_version: string + recommended_fields: string[] + docs_url: string +} + +export type WhatsAppWebhookEvent = { + id: string + waba_id: string | null + phone_number_id: string | null + field: string + event_type: string + from_wa_id: string | null + message_id: string | null + status: string | null + created_at: string +} + +export const whatsappApi = { + setup: () => jsonRequest('/api/v1/whatsapp/setup'), + events: () => jsonRequest('/api/v1/whatsapp/events'), + sendText: (payload: { to: string; body: string }) => + jsonRequest>('/api/v1/whatsapp/messages/send', { + method: 'POST', + body: JSON.stringify(payload), + }), +}