whatsapp integration
This commit is contained in:
parent
d077292bbc
commit
141ca9fda3
15 changed files with 578 additions and 1 deletions
10
.env.example
10
.env.example
|
|
@ -37,3 +37,13 @@ BONVOICE_API_URL=https://backend.pbx.bonvoice.com/autoDialManagement/autoCallBri
|
||||||
BONVOICE_MEDIA_ALLOWLIST=103.131.25.5
|
BONVOICE_MEDIA_ALLOWLIST=103.131.25.5
|
||||||
# Public WSS base; Bonvoice media endpoint = {VOICE_RELAY_PUBLIC_URL}/ws/media
|
# Public WSS base; Bonvoice media endpoint = {VOICE_RELAY_PUBLIC_URL}/ws/media
|
||||||
VOICE_RELAY_PUBLIC_URL=ws://localhost:8000/api/v1/voice
|
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
|
||||||
|
|
|
||||||
|
|
@ -48,3 +48,20 @@ BONVOICE_MEDIA_ALLOWLIST=103.131.25.5
|
||||||
VOICE_RELAY_PUBLIC_URL=ws://localhost:8000/api/v1/voice
|
VOICE_RELAY_PUBLIC_URL=ws://localhost:8000/api/v1/voice
|
||||||
# Production: VOICE_RELAY_PUBLIC_URL=wss://api.urmiai.com/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
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,15 @@ class Settings(BaseSettings):
|
||||||
bonvoice_api_url: str = "https://backend.pbx.bonvoice.com/autoDialManagement/autoCallBridging/"
|
bonvoice_api_url: str = "https://backend.pbx.bonvoice.com/autoDialManagement/autoCallBridging/"
|
||||||
bonvoice_media_allowlist: str = "103.131.25.5"
|
bonvoice_media_allowlist: str = "103.131.25.5"
|
||||||
voice_session_ttl_seconds: int = 600
|
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(
|
model_config = SettingsConfigDict(
|
||||||
env_file=(".env", "../.env"),
|
env_file=(".env", "../.env"),
|
||||||
|
|
@ -46,4 +55,13 @@ class Settings(BaseSettings):
|
||||||
return [self.frontend_origin]
|
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()
|
settings = Settings()
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ from .routes_integrations import router as integrations_router
|
||||||
from .routes_numbers import router as numbers_router
|
from .routes_numbers import router as numbers_router
|
||||||
from .routes_urmiotp import router as urmiotp_router
|
from .routes_urmiotp import router as urmiotp_router
|
||||||
from .routes_voice import router as voice_router
|
from .routes_voice import router as voice_router
|
||||||
|
from .routes_whatsapp import router as whatsapp_router
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|
@ -86,6 +87,7 @@ app.include_router(campaigns_router)
|
||||||
app.include_router(numbers_router)
|
app.include_router(numbers_router)
|
||||||
app.include_router(integrations_router)
|
app.include_router(integrations_router)
|
||||||
app.include_router(voice_router)
|
app.include_router(voice_router)
|
||||||
|
app.include_router(whatsapp_router)
|
||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -491,3 +491,20 @@ class OtpMessageLog(Base):
|
||||||
error_message: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
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)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
user: Mapped[UserAccount] = relationship(back_populates="otp_message_logs")
|
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)
|
||||||
|
|
|
||||||
144
backend/app/routes_whatsapp.py
Normal file
144
backend/app/routes_whatsapp.py
Normal file
|
|
@ -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
|
||||||
56
backend/app/whatsapp_graph_client.py
Normal file
56
backend/app/whatsapp_graph_client.py
Normal file
|
|
@ -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
|
||||||
13
backend/app/whatsapp_security.py
Normal file
13
backend/app/whatsapp_security.py
Normal file
|
|
@ -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)
|
||||||
97
backend/app/whatsapp_webhook.py
Normal file
97
backend/app/whatsapp_webhook.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -44,6 +44,14 @@ services:
|
||||||
BONVOICE_API_URL: ${BONVOICE_API_URL:-https://backend.pbx.bonvoice.com/autoDialManagement/autoCallBridging/}
|
BONVOICE_API_URL: ${BONVOICE_API_URL:-https://backend.pbx.bonvoice.com/autoDialManagement/autoCallBridging/}
|
||||||
BONVOICE_MEDIA_ALLOWLIST: ${BONVOICE_MEDIA_ALLOWLIST:-103.131.25.5}
|
BONVOICE_MEDIA_ALLOWLIST: ${BONVOICE_MEDIA_ALLOWLIST:-103.131.25.5}
|
||||||
VOICE_RELAY_PUBLIC_URL: ${VOICE_RELAY_PUBLIC_URL:-ws://localhost:8000/api/v1/voice}
|
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
|
UPLOAD_DIR: uploads
|
||||||
KYC_TEMPLATE_PATH: assets/kyc-template.docx
|
KYC_TEMPLATE_PATH: assets/kyc-template.docx
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { VoicePage } from './features/voice/VoicePage'
|
||||||
import { TestingCenterPage } from './features/testing/TestingCenterPage'
|
import { TestingCenterPage } from './features/testing/TestingCenterPage'
|
||||||
import { LogsPage } from './features/logs/LogsPage'
|
import { LogsPage } from './features/logs/LogsPage'
|
||||||
import { AnalyticsPage } from './features/analytics/AnalyticsPage'
|
import { AnalyticsPage } from './features/analytics/AnalyticsPage'
|
||||||
|
import { WhatsAppSetupPage } from './features/whatsapp/WhatsAppSetupPage'
|
||||||
import { UrmiOtpPage } from './features/agent/UrmiOtpPage'
|
import { UrmiOtpPage } from './features/agent/UrmiOtpPage'
|
||||||
import { UrmiOtpDocsPage } from './features/agent/UrmiOtpDocsPage'
|
import { UrmiOtpDocsPage } from './features/agent/UrmiOtpDocsPage'
|
||||||
import { KycPage } from './features/compliance/KycPage'
|
import { KycPage } from './features/compliance/KycPage'
|
||||||
|
|
@ -53,6 +54,7 @@ export default function App() {
|
||||||
<Route path="message-settings" element={<MessageSettings />} />
|
<Route path="message-settings" element={<MessageSettings />} />
|
||||||
<Route path="templates" element={<TemplatesPage />} />
|
<Route path="templates" element={<TemplatesPage />} />
|
||||||
<Route path="messaging" element={<ComposeMessage />} />
|
<Route path="messaging" element={<ComposeMessage />} />
|
||||||
|
<Route path="whatsapp" element={<WhatsAppSetupPage />} />
|
||||||
<Route path="campaigns" element={<CampaignsPage />} />
|
<Route path="campaigns" element={<CampaignsPage />} />
|
||||||
<Route path="campaigns/:campaignId" element={<CampaignDetailPage />} />
|
<Route path="campaigns/:campaignId" element={<CampaignDetailPage />} />
|
||||||
<Route path="api-keys" element={<ApiKeysPage />} />
|
<Route path="api-keys" element={<ApiKeysPage />} />
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@ import { useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Activity, Bell, BookOpen, Bot, Boxes, ChevronDown, CircleDollarSign,
|
Activity, Bell, BookOpen, Bot, Boxes, ChevronDown, CircleDollarSign,
|
||||||
Code2, CreditCard, Cpu, FileCheck2, Gauge, KeyRound, LayoutDashboard,
|
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,
|
SlidersHorizontal, TestTube2, Users, Webhook, X, Zap,
|
||||||
|
MessageCircle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../features/auth/auth-context'
|
import { useAuth } from '../features/auth/auth-context'
|
||||||
|
|
@ -28,6 +29,7 @@ const groups = [
|
||||||
]},
|
]},
|
||||||
{ label: 'Communicate', links: [
|
{ label: 'Communicate', links: [
|
||||||
{ label: 'Messaging', path: '/console/messaging', icon: MessageSquareText },
|
{ label: 'Messaging', path: '/console/messaging', icon: MessageSquareText },
|
||||||
|
{ label: 'WhatsApp', path: '/console/whatsapp', icon: MessageCircle },
|
||||||
{ label: 'Campaigns', path: '/console/campaigns', icon: Zap },
|
{ label: 'Campaigns', path: '/console/campaigns', icon: Zap },
|
||||||
{ label: 'Templates', path: '/console/templates', icon: BookOpen },
|
{ label: 'Templates', path: '/console/templates', icon: BookOpen },
|
||||||
]},
|
]},
|
||||||
|
|
|
||||||
139
frontend/src/features/whatsapp/WhatsAppSetupPage.tsx
Normal file
139
frontend/src/features/whatsapp/WhatsAppSetupPage.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<span className={`whatsapp-config-badge ${ok ? 'ok' : 'missing'}`}>
|
||||||
|
<i /> {label}: {ok ? 'Configured' : 'Missing'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WhatsAppSetupPage() {
|
||||||
|
const [setup, setSetup] = useState<WhatsAppSetup | null>(null)
|
||||||
|
const [events, setEvents] = useState<WhatsAppWebhookEvent[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [copied, setCopied] = useState<string | null>(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 <p className="explorer-loading">Loading WhatsApp setup…</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="whatsapp-setup-page">
|
||||||
|
<div className="account-heading">
|
||||||
|
<span className="eyebrow">Communicate</span>
|
||||||
|
<h1>WhatsApp</h1>
|
||||||
|
<p>Configure Meta webhooks so Urmi can receive messages and delivery updates on behalf of your customers.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="auth-error">{error}</p>}
|
||||||
|
|
||||||
|
{setup && (
|
||||||
|
<>
|
||||||
|
<section className="panel whatsapp-panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<h2><MessageCircle size={18} /> Meta webhook configuration</h2>
|
||||||
|
<p>Paste these into <strong>App Dashboard → WhatsApp → Configuration → Configure Webhooks</strong></p>
|
||||||
|
</div>
|
||||||
|
<a className="ghost-button" href={setup.docs_url} target="_blank" rel="noreferrer">
|
||||||
|
Meta docs <ExternalLink size={14} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="whatsapp-config-row">
|
||||||
|
<div>
|
||||||
|
<span>Callback URL</span>
|
||||||
|
<code>{setup.webhook_url}</code>
|
||||||
|
</div>
|
||||||
|
<button className="secondary-button" onClick={() => copyValue('url', setup.webhook_url)}>
|
||||||
|
<Copy size={14} /> {copied === 'url' ? 'Copied' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="whatsapp-config-row">
|
||||||
|
<div>
|
||||||
|
<span>Verify token</span>
|
||||||
|
<code>{setup.verify_token_configured ? 'Set in server env (WHATSAPP_VERIFY_TOKEN)' : 'Not configured'}</code>
|
||||||
|
<small>Use the exact same string in Meta and in your <code>.env</code> file.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="whatsapp-badges">
|
||||||
|
<ConfigBadge ok={setup.verify_token_configured} label="Verify token" />
|
||||||
|
<ConfigBadge ok={setup.app_secret_configured} label="App secret" />
|
||||||
|
<ConfigBadge ok={setup.access_token_configured} label="Access token" />
|
||||||
|
<ConfigBadge ok={Boolean(setup.phone_number_id)} label="Phone number ID" />
|
||||||
|
<ConfigBadge ok={Boolean(setup.business_account_id)} label="WABA ID" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel whatsapp-panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<h2>Subscribe to webhook fields</h2>
|
||||||
|
<p>In Meta, subscribe to these fields after verifying the callback URL.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="whatsapp-field-list">
|
||||||
|
{setup.recommended_fields.map((field) => (
|
||||||
|
<li key={field}><Check size={14} /> <code>{field}</code></li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="relay-note">
|
||||||
|
Minimum for messaging: <code>messages</code>. For a tech provider / BSP, also enable template and account fields.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel whatsapp-panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<h2>Recent webhook events</h2>
|
||||||
|
<p>Incoming payloads from Meta (after verification succeeds).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="analytics-empty">No webhook events yet. Click <strong>Verify and save</strong> in Meta to send a test.</p>
|
||||||
|
) : (
|
||||||
|
<div className="logs-table">
|
||||||
|
<div className="logs-table-head logs-cols-5">
|
||||||
|
<span>Field</span>
|
||||||
|
<span>Type</span>
|
||||||
|
<span>From</span>
|
||||||
|
<span>Status</span>
|
||||||
|
<span>Time</span>
|
||||||
|
</div>
|
||||||
|
{events.map((event) => (
|
||||||
|
<div className="logs-table-row logs-cols-5" key={event.id}>
|
||||||
|
<span>{event.field}</span>
|
||||||
|
<span>{event.event_type}</span>
|
||||||
|
<span>{event.from_wa_id || '—'}</span>
|
||||||
|
<span>{event.status || '—'}</span>
|
||||||
|
<span>{new Date(event.created_at).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -590,6 +590,23 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.analytics-toolbar-row, .analytics-grid, .analytics-summary-grid { grid-template-columns: 1fr; }
|
.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) {
|
@media (max-width: 860px) {
|
||||||
.logs-toolbar-row { grid-template-columns: 1fr; }
|
.logs-toolbar-row { grid-template-columns: 1fr; }
|
||||||
.logs-table-head { display: none; }
|
.logs-table-head { display: none; }
|
||||||
|
|
|
||||||
|
|
@ -788,3 +788,38 @@ export const analyticsApi = {
|
||||||
return jsonRequest<UsageAnalytics>(`/api/v1/analytics/usage?${query.toString()}`)
|
return jsonRequest<UsageAnalytics>(`/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<WhatsAppSetup>('/api/v1/whatsapp/setup'),
|
||||||
|
events: () => jsonRequest<WhatsAppWebhookEvent[]>('/api/v1/whatsapp/events'),
|
||||||
|
sendText: (payload: { to: string; body: string }) =>
|
||||||
|
jsonRequest<Record<string, unknown>>('/api/v1/whatsapp/messages/send', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue