From cd3751219da84c82fd12321a88065bee052e2c39 Mon Sep 17 00:00:00 2001 From: debu-2 Date: Fri, 2 Oct 2026 14:09:03 +0530 Subject: [PATCH] privacy policy and business integration --- .env.example | 1 + backend/.env.example | 2 + backend/app/config.py | 3 +- backend/app/models.py | 31 +- backend/app/platform_settings.py | 17 + backend/app/routes_admin.py | 261 +++++++++- backend/app/routes_whatsapp.py | 9 +- backend/app/whatsapp_config.py | 20 + backend/app/whatsapp_graph_client.py | 205 +++++++- backend/app/whatsapp_messages.py | 180 +++++++ docker-compose.yml | 1 + frontend/src/features/admin/AdminPortal.tsx | 30 +- .../src/features/admin/AdminWhatsAppPage.tsx | 488 ++++++++++++++++++ .../features/whatsapp/WhatsAppSetupPage.tsx | 9 +- frontend/src/index.css | 280 ++++++++++ frontend/src/lib/api.ts | 85 +++ 16 files changed, 1592 insertions(+), 30 deletions(-) create mode 100644 backend/app/platform_settings.py create mode 100644 backend/app/whatsapp_config.py create mode 100644 backend/app/whatsapp_messages.py create mode 100644 frontend/src/features/admin/AdminWhatsAppPage.tsx diff --git a/.env.example b/.env.example index 0396249..260adbe 100644 --- a/.env.example +++ b/.env.example @@ -46,4 +46,5 @@ WHATSAPP_ACCESS_TOKEN= WHATSAPP_PHONE_NUMBER_ID= WHATSAPP_BUSINESS_ACCOUNT_ID= META_APP_ID= +WHATSAPP_BUSINESS_PHONE=919411257916 WHATSAPP_API_VERSION=v21.0 diff --git a/backend/.env.example b/backend/.env.example index 7ae7af9..4e3103b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -64,4 +64,6 @@ WHATSAPP_PHONE_NUMBER_ID= WHATSAPP_BUSINESS_ACCOUNT_ID= # Meta App ID META_APP_ID= +# Urmi business WhatsApp number (E.164 without +) +WHATSAPP_BUSINESS_PHONE=919411257916 WHATSAPP_API_VERSION=v21.0 diff --git a/backend/app/config.py b/backend/app/config.py index f72f65d..c09cafe 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -37,7 +37,8 @@ class Settings(BaseSettings): 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 + whatsapp_business_phone: str = "919411257916" + # Public HTTPS base for webhook callback (no trailing slash), e.g. https://urmiai.com public_api_base_url: str = "" model_config = SettingsConfigDict( diff --git a/backend/app/models.py b/backend/app/models.py index e1bbf8f..2b42181 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime -from sqlalchemy import Boolean, DateTime, ForeignKey, Numeric, String, UniqueConstraint, func +from sqlalchemy import Boolean, DateTime, ForeignKey, Numeric, String, Text, UniqueConstraint, func from sqlalchemy.dialects.postgresql import JSON, UUID from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -508,3 +508,32 @@ class WhatsAppWebhookEvent(Base): 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) + + +class WhatsAppMessage(Base): + """Inbound and outbound WhatsApp messages for Urmi's business number.""" + + __tablename__ = "whatsapp_messages" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + direction: Mapped[str] = mapped_column(String(16), index=True) + wa_message_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + from_number: Mapped[str] = mapped_column(String(32), index=True) + to_number: Mapped[str] = mapped_column(String(32), index=True) + body: Mapped[str | None] = mapped_column(String(4096), nullable=True) + message_type: Mapped[str] = mapped_column(String(24), default="text") + status: Mapped[str] = mapped_column(String(24), default="received", index=True) + payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) + + +class PlatformSetting(Base): + """Runtime overrides for platform credentials (admin-editable).""" + + __tablename__ = "platform_settings" + + key: Mapped[str] = mapped_column(String(64), primary_key=True) + value: Mapped[str] = mapped_column(Text, default="") + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) diff --git a/backend/app/platform_settings.py b/backend/app/platform_settings.py new file mode 100644 index 0000000..cb0a63f --- /dev/null +++ b/backend/app/platform_settings.py @@ -0,0 +1,17 @@ +from sqlalchemy.orm import Session + +from .models import PlatformSetting + + +def get_setting(db: Session, key: str, default: str = "") -> str: + row = db.get(PlatformSetting, key) + return row.value if row and row.value else default + + +def set_setting(db: Session, key: str, value: str) -> None: + row = db.get(PlatformSetting, key) + if row: + row.value = value + else: + db.add(PlatformSetting(key=key, value=value)) + db.commit() diff --git a/backend/app/routes_admin.py b/backend/app/routes_admin.py index 871cd8d..45d95ae 100644 --- a/backend/app/routes_admin.py +++ b/backend/app/routes_admin.py @@ -1,7 +1,6 @@ -from datetime import datetime, timezone -from uuid import UUID +from typing import Literal -from fastapi import APIRouter, Depends, HTTPException, Response, status +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.orm import Session, joinedload @@ -22,9 +21,24 @@ from .models import ( TemplateConfig, UrmiOtpProject, UserAccount, + WhatsAppMessage, ) from .phone_number_validators import normalize_phone_number, validate_per_min_price, validate_price from .routes_urmiotp import serialize_project +from .whatsapp_config import ( + resolved_whatsapp_app_secret, + resolved_whatsapp_business_account_id, + resolved_whatsapp_phone_number_id, + whatsapp_ready_to_send, +) +from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError +from .whatsapp_messages import ( + extract_wa_message_id, + normalize_wa_number, + record_outbound_message, + serialize_message, +) +from .platform_settings import set_setting router = APIRouter(prefix="/api/v1/admin", tags=["admin"]) @@ -516,3 +530,244 @@ def update_phone_number( db.commit() db.refresh(number) return serialize_admin_phone_number(number) + + +class AdminWhatsAppSendRequest(BaseModel): + to: str = Field(min_length=8, max_length=20) + message_type: Literal[ + "text", + "template", + "image", + "document", + "audio", + "video", + "interactive_buttons", + "interactive_list", + "reaction", + ] = "text" + text: str | None = Field(default=None, max_length=4096) + reply_to: str | None = None + template_name: str | None = None + template_language: str = "en" + template_components: list[dict] | None = None + media_link: str | None = None + media_caption: str | None = None + media_filename: str | None = None + interactive_body: str | None = None + interactive_header: str | None = None + interactive_footer: str | None = None + interactive_buttons: list[dict[str, str]] | None = None + interactive_list_button: str | None = None + interactive_list_sections: list[dict] | None = None + reaction_message_id: str | None = None + reaction_emoji: str | None = None + + +class AdminWhatsAppConfigUpdate(BaseModel): + phone_number_id: str | None = None + business_account_id: str | None = None + app_secret: str | None = None + + +def _whatsapp_client(db: Session) -> WhatsAppGraphClient: + return WhatsAppGraphClient( + phone_number_id=resolved_whatsapp_phone_number_id(db), + business_account_id=resolved_whatsapp_business_account_id(db), + ) + + +@router.get("/whatsapp/config") +def admin_whatsapp_config(db: Session = Depends(get_db), _: str = Depends(current_admin)) -> dict: + return { + "business_phone": settings.whatsapp_business_phone, + "phone_number_id": resolved_whatsapp_phone_number_id(db) or None, + "business_account_id": resolved_whatsapp_business_account_id(db) or None, + "meta_app_id": settings.meta_app_id or None, + "access_token_configured": bool(settings.whatsapp_access_token), + "app_secret_configured": bool(resolved_whatsapp_app_secret(db)), + "webhook_url": settings.whatsapp_webhook_url, + "verify_token_configured": bool(settings.whatsapp_verify_token), + "ready_to_send": whatsapp_ready_to_send(db), + "supported_operations": [ + "text", + "template", + "image", + "document", + "audio", + "video", + "interactive_buttons", + "interactive_list", + "reaction", + "mark_read", + "media_download", + "list_templates", + ], + } + + +@router.patch("/whatsapp/config") +def admin_update_whatsapp_config( + payload: AdminWhatsAppConfigUpdate, + db: Session = Depends(get_db), + _: str = Depends(current_admin), +) -> dict: + if payload.phone_number_id is not None: + set_setting(db, "whatsapp_phone_number_id", payload.phone_number_id.strip()) + if payload.business_account_id is not None: + set_setting(db, "whatsapp_business_account_id", payload.business_account_id.strip()) + if payload.app_secret is not None: + set_setting(db, "whatsapp_app_secret", payload.app_secret.strip()) + return admin_whatsapp_config(db=db, _=_) + + +@router.get("/whatsapp/messages") +def admin_list_whatsapp_messages( + limit: int = 50, + peer: str | None = None, + db: Session = Depends(get_db), + _: str = Depends(current_admin), +) -> list[dict]: + query = select(WhatsAppMessage).order_by(WhatsAppMessage.created_at.desc()).limit(min(limit, 500)) + if peer: + try: + normalized_peer = normalize_wa_number(peer) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + query = query.where( + (WhatsAppMessage.from_number == normalized_peer) | (WhatsAppMessage.to_number == normalized_peer) + ) + messages = db.scalars(query).all() + return [serialize_message(message) for message in messages] + + +@router.get("/whatsapp/templates") +async def admin_list_whatsapp_templates( + db: Session = Depends(get_db), + _: str = Depends(current_admin), +) -> dict: + client = _whatsapp_client(db) + try: + return await client.list_templates() + except WhatsAppGraphError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + + +@router.get("/whatsapp/media/{media_id}") +async def admin_download_whatsapp_media( + media_id: str, + db: Session = Depends(get_db), + _: str = Depends(current_admin), +) -> Response: + client = _whatsapp_client(db) + try: + content, mime_type = await client.download_media(media_id) + except WhatsAppGraphError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + return Response(content=content, media_type=mime_type) + + +@router.post("/whatsapp/messages/{wa_message_id}/read") +async def admin_mark_whatsapp_read( + wa_message_id: str, + db: Session = Depends(get_db), + _: str = Depends(current_admin), +) -> dict: + client = _whatsapp_client(db) + try: + result = await client.mark_read(wa_message_id) + except WhatsAppGraphError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + return result + + +@router.post("/whatsapp/messages/send") +async def admin_send_whatsapp_message( + payload: AdminWhatsAppSendRequest, + db: Session = Depends(get_db), + _: str = Depends(current_admin), +) -> dict: + try: + recipient = normalize_wa_number(payload.to) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + client = _whatsapp_client(db) + message_type = payload.message_type + try: + if message_type == "text": + if not payload.text: + raise HTTPException(status_code=422, detail="text is required") + result = await client.send_text(recipient, payload.text.strip(), reply_to=payload.reply_to) + body = payload.text.strip() + elif message_type == "template": + if not payload.template_name: + raise HTTPException(status_code=422, detail="template_name is required") + result = await client.send_template( + recipient, + payload.template_name, + language_code=payload.template_language, + components=payload.template_components, + ) + body = f"Template: {payload.template_name}" + elif message_type in {"image", "document", "audio", "video"}: + if not payload.media_link: + raise HTTPException(status_code=422, detail="media_link is required") + result = await client.send_media( + recipient, + message_type, + link=payload.media_link, + caption=payload.media_caption, + filename=payload.media_filename, + reply_to=payload.reply_to, + ) + body = payload.media_caption or f"[{message_type}]" + elif message_type == "interactive_buttons": + if not payload.interactive_body or not payload.interactive_buttons: + raise HTTPException(status_code=422, detail="interactive_body and interactive_buttons are required") + result = await client.send_interactive_buttons( + recipient, + payload.interactive_body, + payload.interactive_buttons, + header=payload.interactive_header, + footer=payload.interactive_footer, + reply_to=payload.reply_to, + ) + body = payload.interactive_body + message_type = "interactive" + elif message_type == "interactive_list": + if not payload.interactive_body or not payload.interactive_list_button or not payload.interactive_list_sections: + raise HTTPException( + status_code=422, + detail="interactive_body, interactive_list_button, and interactive_list_sections are required", + ) + result = await client.send_interactive_list( + recipient, + payload.interactive_body, + payload.interactive_list_button, + payload.interactive_list_sections, + header=payload.interactive_header, + footer=payload.interactive_footer, + reply_to=payload.reply_to, + ) + body = payload.interactive_body + message_type = "interactive" + elif message_type == "reaction": + if not payload.reaction_message_id or not payload.reaction_emoji: + raise HTTPException(status_code=422, detail="reaction_message_id and reaction_emoji are required") + result = await client.send_reaction(recipient, payload.reaction_message_id, payload.reaction_emoji) + body = payload.reaction_emoji + else: + raise HTTPException(status_code=422, detail="Unsupported message type") + except WhatsAppGraphError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + + message = record_outbound_message( + db, + to_number=recipient, + body=body, + message_type=message_type, + wa_message_id=extract_wa_message_id(result), + status="sent", + payload=result if isinstance(result, dict) else None, + ) + return serialize_message(message) diff --git a/backend/app/routes_whatsapp.py b/backend/app/routes_whatsapp.py index 929c28c..c9a843c 100644 --- a/backend/app/routes_whatsapp.py +++ b/backend/app/routes_whatsapp.py @@ -11,8 +11,10 @@ from .config import settings from .database import get_db from .models import UserAccount, WhatsAppWebhookEvent from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError +from .whatsapp_config import resolved_whatsapp_app_secret from .whatsapp_security import verify_meta_signature from .whatsapp_webhook import persist_webhook_payload +from .whatsapp_messages import record_inbound_messages logger = logging.getLogger(__name__) @@ -73,9 +75,10 @@ async def receive_webhook( ) -> dict[str, str]: """Receive inbound WhatsApp Business Platform webhook payloads.""" raw_body = await request.body() + app_secret = resolved_whatsapp_app_secret(db) - if settings.whatsapp_app_secret: - if not verify_meta_signature(settings.whatsapp_app_secret, raw_body, x_hub_signature_256): + if app_secret: + if not verify_meta_signature(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": @@ -90,6 +93,7 @@ async def receive_webhook( payload = {} persist_webhook_payload(db, payload) + record_inbound_messages(db, payload) return {"status": "ok"} @@ -100,6 +104,7 @@ def whatsapp_setup( """Console setup info — paste these values into Meta Developer Console.""" return { "webhook_url": settings.whatsapp_webhook_url, + "verify_token": settings.whatsapp_verify_token or None, "verify_token_configured": bool(settings.whatsapp_verify_token), "app_secret_configured": bool(settings.whatsapp_app_secret), "access_token_configured": bool(settings.whatsapp_access_token), diff --git a/backend/app/whatsapp_config.py b/backend/app/whatsapp_config.py new file mode 100644 index 0000000..cb22119 --- /dev/null +++ b/backend/app/whatsapp_config.py @@ -0,0 +1,20 @@ +from sqlalchemy.orm import Session + +from .config import settings +from .platform_settings import get_setting + + +def resolved_whatsapp_phone_number_id(db: Session) -> str: + return get_setting(db, "whatsapp_phone_number_id") or settings.whatsapp_phone_number_id + + +def resolved_whatsapp_business_account_id(db: Session) -> str: + return get_setting(db, "whatsapp_business_account_id") or settings.whatsapp_business_account_id + + +def resolved_whatsapp_app_secret(db: Session) -> str: + return get_setting(db, "whatsapp_app_secret") or settings.whatsapp_app_secret + + +def whatsapp_ready_to_send(db: Session) -> bool: + return bool(settings.whatsapp_access_token and resolved_whatsapp_phone_number_id(db)) diff --git a/backend/app/whatsapp_graph_client.py b/backend/app/whatsapp_graph_client.py index a7e90e9..8a2d330 100644 --- a/backend/app/whatsapp_graph_client.py +++ b/backend/app/whatsapp_graph_client.py @@ -1,3 +1,5 @@ +from typing import Any + import httpx from .config import settings @@ -16,36 +18,34 @@ class WhatsAppGraphClient: *, access_token: str | None = None, phone_number_id: str | None = None, + business_account_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.business_account_id = business_account_id or settings.whatsapp_business_account_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 _graph_url(self, path: str) -> str: + return f"https://graph.facebook.com/{self.api_version}/{path.lstrip('/')}" + def _messages_url(self) -> str: - return ( - f"https://graph.facebook.com/{self.api_version}/" - f"{self.phone_number_id}/messages" - ) + return self._graph_url(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}, - } + async def _request( + self, + method: str, + url: str, + *, + json_payload: dict[str, Any] | None = None, + ) -> dict[str, Any]: 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) + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.request(method, url, json=json_payload, headers=headers) try: data = response.json() except ValueError: @@ -53,4 +53,173 @@ class WhatsAppGraphClient: 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 + return data if isinstance(data, dict) else {"data": data} + + async def _send_message(self, payload: dict[str, Any]) -> dict[str, Any]: + if not self.configured: + raise WhatsAppGraphError("WhatsApp Graph API is not configured", status_code=503) + body = {"messaging_product": "whatsapp", **payload} + return await self._request("POST", self._messages_url(), json_payload=body) + + async def send_text(self, to: str, body: str, reply_to: str | None = None) -> dict[str, Any]: + payload: dict[str, Any] = { + "recipient_type": "individual", + "to": to, + "type": "text", + "text": {"preview_url": False, "body": body}, + } + if reply_to: + payload["context"] = {"message_id": reply_to} + return await self._send_message(payload) + + async def send_media( + self, + to: str, + media_type: str, + *, + link: str | None = None, + media_id: str | None = None, + caption: str | None = None, + filename: str | None = None, + reply_to: str | None = None, + ) -> dict[str, Any]: + media: dict[str, Any] = {} + if media_id: + media["id"] = media_id + elif link: + media["link"] = link + else: + raise WhatsAppGraphError("Media requires link or media_id", status_code=422) + if caption and media_type in {"image", "document", "video"}: + media["caption"] = caption + if filename and media_type == "document": + media["filename"] = filename + payload: dict[str, Any] = { + "recipient_type": "individual", + "to": to, + "type": media_type, + media_type: media, + } + if reply_to: + payload["context"] = {"message_id": reply_to} + return await self._send_message(payload) + + async def send_template( + self, + to: str, + name: str, + language_code: str = "en", + components: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + template: dict[str, Any] = {"name": name, "language": {"code": language_code}} + if components: + template["components"] = components + return await self._send_message( + { + "recipient_type": "individual", + "to": to, + "type": "template", + "template": template, + } + ) + + async def send_interactive_buttons( + self, + to: str, + body: str, + buttons: list[dict[str, str]], + *, + header: str | None = None, + footer: str | None = None, + reply_to: str | None = None, + ) -> dict[str, Any]: + interactive: dict[str, Any] = { + "type": "button", + "body": {"text": body}, + "action": { + "buttons": [ + {"type": "reply", "reply": {"id": button["id"], "title": button["title"][:20]}} + for button in buttons[:3] + ] + }, + } + if header: + interactive["header"] = {"type": "text", "text": header[:60]} + if footer: + interactive["footer"] = {"text": footer[:60]} + payload: dict[str, Any] = { + "recipient_type": "individual", + "to": to, + "type": "interactive", + "interactive": interactive, + } + if reply_to: + payload["context"] = {"message_id": reply_to} + return await self._send_message(payload) + + async def send_interactive_list( + self, + to: str, + body: str, + button_text: str, + sections: list[dict[str, Any]], + *, + header: str | None = None, + footer: str | None = None, + reply_to: str | None = None, + ) -> dict[str, Any]: + interactive: dict[str, Any] = { + "type": "list", + "body": {"text": body}, + "action": {"button": button_text[:20], "sections": sections}, + } + if header: + interactive["header"] = {"type": "text", "text": header[:60]} + if footer: + interactive["footer"] = {"text": footer[:60]} + payload: dict[str, Any] = { + "recipient_type": "individual", + "to": to, + "type": "interactive", + "interactive": interactive, + } + if reply_to: + payload["context"] = {"message_id": reply_to} + return await self._send_message(payload) + + async def send_reaction(self, to: str, message_id: str, emoji: str) -> dict[str, Any]: + return await self._send_message( + { + "recipient_type": "individual", + "to": to, + "type": "reaction", + "reaction": {"message_id": message_id, "emoji": emoji}, + } + ) + + async def mark_read(self, message_id: str) -> dict[str, Any]: + return await self._send_message({"status": "read", "message_id": message_id}) + + async def list_templates(self, limit: int = 50) -> dict[str, Any]: + if not self.access_token or not self.business_account_id: + raise WhatsAppGraphError("WhatsApp business account ID is not configured", status_code=503) + url = self._graph_url(f"{self.business_account_id}/message_templates?limit={limit}") + return await self._request("GET", url) + + async def get_media_metadata(self, media_id: str) -> dict[str, Any]: + if not self.access_token: + raise WhatsAppGraphError("WhatsApp access token is not configured", status_code=503) + return await self._request("GET", self._graph_url(media_id)) + + async def download_media(self, media_id: str) -> tuple[bytes, str]: + metadata = await self.get_media_metadata(media_id) + download_url = metadata.get("url") + mime_type = metadata.get("mime_type") or "application/octet-stream" + if not download_url: + raise WhatsAppGraphError("Media URL not available", status_code=404) + headers = {"Authorization": f"Bearer {self.access_token}"} + async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client: + response = await client.get(download_url, headers=headers) + if response.status_code >= 400: + raise WhatsAppGraphError("Failed to download media", status_code=response.status_code) + return response.content, mime_type diff --git a/backend/app/whatsapp_messages.py b/backend/app/whatsapp_messages.py new file mode 100644 index 0000000..9339656 --- /dev/null +++ b/backend/app/whatsapp_messages.py @@ -0,0 +1,180 @@ +import re +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from .config import settings +from .models import WhatsAppMessage + +PHONE_DIGITS_RE = re.compile(r"^\d{10,15}$") + + +def normalize_wa_number(value: str) -> str: + digits = re.sub(r"\D", "", value.strip()) + if len(digits) == 10: + digits = f"91{digits}" + if not PHONE_DIGITS_RE.match(digits): + raise ValueError("Invalid WhatsApp number") + return digits + + +def business_phone() -> str: + return normalize_wa_number(settings.whatsapp_business_phone) + + +def _media_from_payload(message_type: str, payload: dict[str, Any] | None) -> dict[str, Any] | None: + if not payload or message_type not in {"image", "audio", "video", "document", "sticker"}: + return None + media = payload.get(message_type) or {} + if not isinstance(media, dict): + return None + return { + "id": media.get("id"), + "mime_type": media.get("mime_type"), + "caption": media.get("caption"), + "filename": media.get("filename"), + } + + +def serialize_message(message: WhatsAppMessage) -> dict: + payload = message.payload if isinstance(message.payload, dict) else None + media = _media_from_payload(message.message_type, payload) + return { + "id": str(message.id), + "direction": message.direction, + "wa_message_id": message.wa_message_id, + "from": message.from_number, + "to": message.to_number, + "body": message.body, + "message_type": message.message_type, + "status": message.status, + "media": media, + "created_at": message.created_at, + } + + +def _extract_text_body(message: dict[str, Any]) -> str | None: + msg_type = message.get("type") + if msg_type == "text": + return (message.get("text") or {}).get("body") + if msg_type == "button": + return (message.get("button") or {}).get("text") + if msg_type == "interactive": + interactive = message.get("interactive") or {} + if interactive.get("type") == "button_reply": + return (interactive.get("button_reply") or {}).get("title") + if interactive.get("type") == "list_reply": + return (interactive.get("list_reply") or {}).get("title") + if msg_type in {"image", "video", "document"}: + return (message.get(msg_type) or {}).get("caption") + if msg_type == "reaction": + reaction = message.get("reaction") or {} + emoji = reaction.get("emoji") + return f"Reacted {emoji}" if emoji else "Reaction" + if msg_type == "location": + location = message.get("location") or {} + name = location.get("name") or location.get("address") + if name: + return f"Location: {name}" + lat = location.get("latitude") + lng = location.get("longitude") + if lat is not None and lng is not None: + return f"Location: {lat}, {lng}" + if msg_type == "contacts": + contacts = message.get("contacts") or [] + if contacts: + name = ((contacts[0].get("name") or {}).get("formatted_name")) or "Contact" + return f"Contact: {name}" + return None + + +def record_inbound_messages(db: Session, payload: dict[str, Any]) -> int: + if payload.get("object") != "whatsapp_business_account": + return 0 + + business = business_phone() + created = 0 + for entry in payload.get("entry", []): + for change in entry.get("changes", []): + if change.get("field") != "messages": + continue + value = change.get("value", {}) + for message in value.get("messages", []): + from_number = message.get("from") or "" + body = _extract_text_body(message) + db.add( + WhatsAppMessage( + direction="inbound", + wa_message_id=message.get("id"), + from_number=from_number, + to_number=business, + body=body, + message_type=message.get("type") or "text", + status="received", + payload=message, + ) + ) + created += 1 + for status in value.get("statuses", []): + wa_id = status.get("id") + if not wa_id: + continue + existing = db.scalar( + select(WhatsAppMessage).where(WhatsAppMessage.wa_message_id == wa_id) + ) + if existing: + existing.status = status.get("status") or existing.status + else: + db.add( + WhatsAppMessage( + direction="outbound", + wa_message_id=wa_id, + from_number=business, + to_number=status.get("recipient_id") or "", + body=None, + message_type="status", + status=status.get("status") or "unknown", + payload=status, + ) + ) + created += 1 + if created: + db.commit() + return created + + +def record_outbound_message( + db: Session, + *, + to_number: str, + body: str | None, + message_type: str = "text", + wa_message_id: str | None, + status: str = "sent", + payload: dict | None = None, +) -> WhatsAppMessage: + message = WhatsAppMessage( + direction="outbound", + wa_message_id=wa_message_id, + from_number=business_phone(), + to_number=to_number, + body=body, + message_type=message_type, + status=status, + payload=payload, + ) + db.add(message) + db.commit() + db.refresh(message) + return message + + +def extract_wa_message_id(result: dict[str, Any] | None) -> str | None: + if not isinstance(result, dict): + return None + messages = result.get("messages") + if isinstance(messages, list) and messages: + message_id = messages[0].get("id") + return message_id if isinstance(message_id, str) else None + return None diff --git a/docker-compose.yml b/docker-compose.yml index afa7e42..eea2891 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,6 +51,7 @@ services: WHATSAPP_PHONE_NUMBER_ID: ${WHATSAPP_PHONE_NUMBER_ID:-} WHATSAPP_BUSINESS_ACCOUNT_ID: ${WHATSAPP_BUSINESS_ACCOUNT_ID:-} META_APP_ID: ${META_APP_ID:-} + WHATSAPP_BUSINESS_PHONE: ${WHATSAPP_BUSINESS_PHONE:-919411257916} WHATSAPP_API_VERSION: ${WHATSAPP_API_VERSION:-v21.0} UPLOAD_DIR: uploads KYC_TEMPLATE_PATH: assets/kyc-template.docx diff --git a/frontend/src/features/admin/AdminPortal.tsx b/frontend/src/features/admin/AdminPortal.tsx index 3b9d807..b90fc16 100644 --- a/frontend/src/features/admin/AdminPortal.tsx +++ b/frontend/src/features/admin/AdminPortal.tsx @@ -10,6 +10,7 @@ import { type AdminTemplateConfig, type AdminUrmiOtpProject, } from '../../lib/api' +import { AdminWhatsAppPage } from './AdminWhatsAppPage' const KYC_DOC_LABELS: Record = { business_pan_card: 'Business PAN card', @@ -391,6 +392,7 @@ function AdminCompliance() { export function AdminApp() { const [authenticated, setAuthenticated] = useState(null) + const [section, setSection] = useState<'compliance' | 'whatsapp'>('whatsapp') useEffect(() => { adminApi.me().then(() => setAuthenticated(true)).catch(() => setAuthenticated(false)) @@ -406,11 +408,33 @@ export function AdminApp() { return (
-
UrmiAdmin · Global compliance
- {authenticated && } +
UrmiAdmin portal
+ {authenticated && ( +
+ + +
+ )}
- {authenticated ? : setAuthenticated(true)} />} + {authenticated + ? (section === 'whatsapp' ? : ) + : setAuthenticated(true)} />}
) diff --git a/frontend/src/features/admin/AdminWhatsAppPage.tsx b/frontend/src/features/admin/AdminWhatsAppPage.tsx new file mode 100644 index 0000000..be3a75f --- /dev/null +++ b/frontend/src/features/admin/AdminWhatsAppPage.tsx @@ -0,0 +1,488 @@ +import { MessageSquareText, RefreshCw, Settings2 } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import type { FormEvent } from 'react' +import { API_BASE_URL } from '../../lib/apiBase' +import { + adminApi, + type AdminWhatsAppConfig, + type AdminWhatsAppMessage, + type AdminWhatsAppSendPayload, + type AdminWhatsAppTemplate, +} from '../../lib/api' + +type SendMode = 'text' | 'template' | 'media' | 'buttons' | 'reaction' + +function formatWaNumber(value: string) { + const digits = value.replace(/\D/g, '') + if (digits.length === 12 && digits.startsWith('91')) { + const local = digits.slice(2) + return `+91 ${local.slice(0, 5)} ${local.slice(5)}` + } + if (digits.length === 10) return `+91 ${digits.slice(0, 5)} ${digits.slice(5)}` + return value +} + +function mediaUrl(mediaId: string) { + return `${API_BASE_URL}/api/v1/admin/whatsapp/media/${mediaId}` +} + +export function AdminWhatsAppPage() { + const [config, setConfig] = useState(null) + const [messages, setMessages] = useState([]) + const [templates, setTemplates] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedPeer, setSelectedPeer] = useState(null) + const [newPeer, setNewPeer] = useState('') + const [sendMode, setSendMode] = useState('text') + const [showSetup, setShowSetup] = useState(false) + const [error, setError] = useState('') + const [sending, setSending] = useState(false) + const [savingConfig, setSavingConfig] = useState(false) + + const [phoneNumberId, setPhoneNumberId] = useState('') + const [businessAccountId, setBusinessAccountId] = useState('') + const [appSecret, setAppSecret] = useState('') + + const [textBody, setTextBody] = useState('') + const [replyTo, setReplyTo] = useState('') + const [templateName, setTemplateName] = useState('') + const [templateLanguage, setTemplateLanguage] = useState('en') + const [mediaType, setMediaType] = useState<'image' | 'document' | 'audio' | 'video'>('image') + const [mediaLink, setMediaLink] = useState('') + const [mediaCaption, setMediaCaption] = useState('') + const [mediaFilename, setMediaFilename] = useState('') + const [buttonBody, setButtonBody] = useState('') + const [buttonOne, setButtonOne] = useState('') + const [buttonTwo, setButtonTwo] = useState('') + const [buttonThree, setButtonThree] = useState('') + const [reactionEmoji, setReactionEmoji] = useState('👍') + const [reactionTarget, setReactionTarget] = useState('') + + const chatEndRef = useRef(null) + + async function loadMessages(peer?: string | null) { + const items = await adminApi.listWhatsAppMessages(300, peer || undefined) + setMessages(items) + } + + async function loadAll() { + const [cfg, items, templateResponse] = await Promise.all([ + adminApi.whatsappConfig(), + adminApi.listWhatsAppMessages(300), + adminApi.listWhatsAppTemplates().catch(() => ({ data: [] as AdminWhatsAppTemplate[] })), + ]) + setConfig(cfg) + setMessages(items) + setTemplates(templateResponse.data ?? []) + setPhoneNumberId(cfg.phone_number_id ?? '') + setBusinessAccountId(cfg.business_account_id ?? '') + if (!cfg.ready_to_send) setShowSetup(true) + } + + useEffect(() => { + loadAll().finally(() => setLoading(false)) + const timer = window.setInterval(() => { + loadMessages(selectedPeer).catch(() => undefined) + }, 5000) + return () => window.clearInterval(timer) + }, [selectedPeer]) + + const threads = useMemo(() => { + const map = new Map() + for (const message of messages) { + const peer = message.direction === 'inbound' ? message.from : message.to + if (!peer || message.message_type === 'status') continue + const list = map.get(peer) ?? [] + list.push(message) + map.set(peer, list) + } + return [...map.entries()] + .map(([peer, thread]) => { + const sorted = [...thread].sort((a, b) => a.created_at.localeCompare(b.created_at)) + return { peer, thread: sorted, latest: sorted[sorted.length - 1] } + }) + .sort((a, b) => (b.latest?.created_at ?? '').localeCompare(a.latest?.created_at ?? '')) + }, [messages]) + + const activeThread = useMemo(() => { + if (!selectedPeer) return [] + const found = threads.find((item) => item.peer === selectedPeer) + return found?.thread ?? [] + }, [threads, selectedPeer]) + + useEffect(() => { + chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [activeThread.length, selectedPeer]) + + async function saveConfig(event: FormEvent) { + event.preventDefault() + setSavingConfig(true) + setError('') + try { + const updated = await adminApi.updateWhatsAppConfig({ + phone_number_id: phoneNumberId.trim(), + business_account_id: businessAccountId.trim(), + app_secret: appSecret.trim() || undefined, + }) + setConfig(updated) + setShowSetup(false) + const templateResponse = await adminApi.listWhatsAppTemplates().catch(() => ({ data: [] as AdminWhatsAppTemplate[] })) + setTemplates(templateResponse.data ?? []) + } catch (reason) { + setError(reason instanceof Error ? reason.message : 'Unable to save WhatsApp config') + } finally { + setSavingConfig(false) + } + } + + async function markRead(message: AdminWhatsAppMessage) { + if (!message.wa_message_id || message.direction !== 'inbound') return + try { + await adminApi.markWhatsAppRead(message.wa_message_id) + } catch { + // ignore read receipt failures + } + } + + async function sendMessage(event: FormEvent) { + event.preventDefault() + if (!selectedPeer && !newPeer.trim()) { + setError('Select a conversation or enter a recipient number') + return + } + setSending(true) + setError('') + const recipient = selectedPeer ?? newPeer.trim() + const payload: AdminWhatsAppSendPayload = { to: recipient, message_type: 'text' } + try { + if (sendMode === 'text') { + payload.message_type = 'text' + payload.text = textBody.trim() + payload.reply_to = replyTo.trim() || undefined + } else if (sendMode === 'template') { + payload.message_type = 'template' + payload.template_name = templateName + payload.template_language = templateLanguage + } else if (sendMode === 'media') { + payload.message_type = mediaType + payload.media_link = mediaLink.trim() + payload.media_caption = mediaCaption.trim() || undefined + payload.media_filename = mediaFilename.trim() || undefined + payload.reply_to = replyTo.trim() || undefined + } else if (sendMode === 'buttons') { + payload.message_type = 'interactive_buttons' + payload.interactive_body = buttonBody.trim() + payload.interactive_buttons = [ + { id: 'btn1', title: buttonOne.trim() }, + ...(buttonTwo.trim() ? [{ id: 'btn2', title: buttonTwo.trim() }] : []), + ...(buttonThree.trim() ? [{ id: 'btn3', title: buttonThree.trim() }] : []), + ] + payload.reply_to = replyTo.trim() || undefined + } else if (sendMode === 'reaction') { + payload.message_type = 'reaction' + payload.reaction_message_id = reactionTarget.trim() + payload.reaction_emoji = reactionEmoji.trim() + } + + const sent = await adminApi.sendWhatsAppMessage(payload) + if (!selectedPeer) { + const peer = sent.to + setSelectedPeer(peer) + setNewPeer('') + } + await loadMessages(selectedPeer ?? sent.to) + setTextBody('') + setMediaCaption('') + setButtonBody('') + setReplyTo('') + } catch (reason) { + setError(reason instanceof Error ? reason.message : 'Unable to send message') + } finally { + setSending(false) + } + } + + if (loading) return
Loading WhatsApp Business…
+ + const readyToSend = Boolean(config?.ready_to_send) + + return ( +
+
+
+ Urmi business +

WhatsApp Business

+

+ Send and receive on {config ? formatWaNumber(config.business_phone) : '+91 94112 57916'}. + Webhook: {config?.webhook_url ?? '—'} +

+
+
+ + {readyToSend ? 'Connected' : 'Setup required'} + + + +
+
+ + {showSetup && ( +
+
+
+

Meta connection

+

Paste IDs from Meta → WhatsApp → API Setup. Saved in admin without restarting the server.

+
+
+
+ + + +
+

Verify token: {config?.verify_token_configured ? 'Configured in server env' : 'Missing in env'}

+

Access token: {config?.access_token_configured ? 'Configured in server env' : 'Missing in env'}

+

Meta App ID: {config?.meta_app_id ?? '—'}

+
+ +
+
+ )} + + {!readyToSend && ( +

+ Add your Phone number ID in Meta setup above to enable sending. + Inbound messages still arrive once Meta webhook is subscribed at {config?.webhook_url}. +

+ )} + +
+ + +
+ {!selectedPeer ? ( +
+

Select a conversation to view messages and reply.

+
+ ) : ( + <> +
+ {formatWaNumber(selectedPeer)} + {activeThread.length} messages +
+
+ {activeThread.map((item) => ( +
+ {item.media?.id && ['image', 'sticker'].includes(item.message_type) && ( + {item.message_type} + )} + {item.media?.id && item.message_type === 'video' && ( +
+ ))} +
+
+ + )} +
+ +
+
+
+

Send

+

Supported: text, template, media, buttons, reaction, read receipts.

+
+
+
+ {(['text', 'template', 'media', 'buttons', 'reaction'] as SendMode[]).map((mode) => ( + + ))} +
+
+ {!selectedPeer && ( + + )} + + {sendMode === 'text' && ( + <> +