privacy policy and business integration

This commit is contained in:
debu-2 2026-10-02 14:09:03 +05:30
parent 141ca9fda3
commit cd3751219d
16 changed files with 1592 additions and 30 deletions

View file

@ -46,4 +46,5 @@ 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_BUSINESS_PHONE=919411257916
WHATSAPP_API_VERSION=v21.0 WHATSAPP_API_VERSION=v21.0

View file

@ -64,4 +64,6 @@ WHATSAPP_PHONE_NUMBER_ID=
WHATSAPP_BUSINESS_ACCOUNT_ID= WHATSAPP_BUSINESS_ACCOUNT_ID=
# Meta App ID # Meta App ID
META_APP_ID= META_APP_ID=
# Urmi business WhatsApp number (E.164 without +)
WHATSAPP_BUSINESS_PHONE=919411257916
WHATSAPP_API_VERSION=v21.0 WHATSAPP_API_VERSION=v21.0

View file

@ -37,7 +37,8 @@ class Settings(BaseSettings):
whatsapp_phone_number_id: str = "" whatsapp_phone_number_id: str = ""
whatsapp_business_account_id: str = "" whatsapp_business_account_id: str = ""
meta_app_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 = "" public_api_base_url: str = ""
model_config = SettingsConfigDict( model_config = SettingsConfigDict(

View file

@ -1,7 +1,7 @@
import uuid import uuid
from datetime import datetime 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.dialects.postgresql import JSON, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship 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) status: Mapped[str | None] = mapped_column(String(32), nullable=True)
payload: Mapped[dict] = mapped_column(JSON) payload: Mapped[dict] = mapped_column(JSON)
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)
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()
)

View file

@ -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()

View file

@ -1,7 +1,6 @@
from datetime import datetime, timezone from typing import Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
@ -22,9 +21,24 @@ from .models import (
TemplateConfig, TemplateConfig,
UrmiOtpProject, UrmiOtpProject,
UserAccount, UserAccount,
WhatsAppMessage,
) )
from .phone_number_validators import normalize_phone_number, validate_per_min_price, validate_price from .phone_number_validators import normalize_phone_number, validate_per_min_price, validate_price
from .routes_urmiotp import serialize_project 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"]) router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
@ -516,3 +530,244 @@ def update_phone_number(
db.commit() db.commit()
db.refresh(number) db.refresh(number)
return serialize_admin_phone_number(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)

View file

@ -11,8 +11,10 @@ from .config import settings
from .database import get_db from .database import get_db
from .models import UserAccount, WhatsAppWebhookEvent from .models import UserAccount, WhatsAppWebhookEvent
from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError
from .whatsapp_config import resolved_whatsapp_app_secret
from .whatsapp_security import verify_meta_signature from .whatsapp_security import verify_meta_signature
from .whatsapp_webhook import persist_webhook_payload from .whatsapp_webhook import persist_webhook_payload
from .whatsapp_messages import record_inbound_messages
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -73,9 +75,10 @@ async def receive_webhook(
) -> dict[str, str]: ) -> dict[str, str]:
"""Receive inbound WhatsApp Business Platform webhook payloads.""" """Receive inbound WhatsApp Business Platform webhook payloads."""
raw_body = await request.body() raw_body = await request.body()
app_secret = resolved_whatsapp_app_secret(db)
if settings.whatsapp_app_secret: if app_secret:
if not verify_meta_signature(settings.whatsapp_app_secret, raw_body, x_hub_signature_256): if not verify_meta_signature(app_secret, raw_body, x_hub_signature_256):
logger.warning("WhatsApp webhook signature validation failed") logger.warning("WhatsApp webhook signature validation failed")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid signature") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid signature")
elif settings.environment == "production": elif settings.environment == "production":
@ -90,6 +93,7 @@ async def receive_webhook(
payload = {} payload = {}
persist_webhook_payload(db, payload) persist_webhook_payload(db, payload)
record_inbound_messages(db, payload)
return {"status": "ok"} return {"status": "ok"}
@ -100,6 +104,7 @@ def whatsapp_setup(
"""Console setup info — paste these values into Meta Developer Console.""" """Console setup info — paste these values into Meta Developer Console."""
return { return {
"webhook_url": settings.whatsapp_webhook_url, "webhook_url": settings.whatsapp_webhook_url,
"verify_token": settings.whatsapp_verify_token or None,
"verify_token_configured": bool(settings.whatsapp_verify_token), "verify_token_configured": bool(settings.whatsapp_verify_token),
"app_secret_configured": bool(settings.whatsapp_app_secret), "app_secret_configured": bool(settings.whatsapp_app_secret),
"access_token_configured": bool(settings.whatsapp_access_token), "access_token_configured": bool(settings.whatsapp_access_token),

View file

@ -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))

View file

@ -1,3 +1,5 @@
from typing import Any
import httpx import httpx
from .config import settings from .config import settings
@ -16,36 +18,34 @@ class WhatsAppGraphClient:
*, *,
access_token: str | None = None, access_token: str | None = None,
phone_number_id: str | None = None, phone_number_id: str | None = None,
business_account_id: str | None = None,
api_version: str | None = None, api_version: str | None = None,
) -> None: ) -> None:
self.access_token = access_token or settings.whatsapp_access_token self.access_token = access_token or settings.whatsapp_access_token
self.phone_number_id = phone_number_id or settings.whatsapp_phone_number_id 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 self.api_version = api_version or settings.whatsapp_api_version
@property @property
def configured(self) -> bool: def configured(self) -> bool:
return bool(self.access_token and self.phone_number_id) 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: def _messages_url(self) -> str:
return ( return self._graph_url(f"{self.phone_number_id}/messages")
f"https://graph.facebook.com/{self.api_version}/"
f"{self.phone_number_id}/messages"
)
async def send_text(self, to: str, body: str) -> dict: async def _request(
if not self.configured: self,
raise WhatsAppGraphError("WhatsApp Graph API is not configured", status_code=503) method: str,
url: str,
payload = { *,
"messaging_product": "whatsapp", json_payload: dict[str, Any] | None = None,
"recipient_type": "individual", ) -> dict[str, Any]:
"to": to,
"type": "text",
"text": {"preview_url": False, "body": body},
}
headers = {"Authorization": f"Bearer {self.access_token}"} headers = {"Authorization": f"Bearer {self.access_token}"}
async with httpx.AsyncClient(timeout=30.0) as client: async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(self._messages_url(), json=payload, headers=headers) response = await client.request(method, url, json=json_payload, headers=headers)
try: try:
data = response.json() data = response.json()
except ValueError: except ValueError:
@ -53,4 +53,173 @@ class WhatsAppGraphClient:
if response.status_code >= 400: if response.status_code >= 400:
detail = data.get("error", {}).get("message") if isinstance(data, dict) else response.text 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) 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

View file

@ -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

View file

@ -51,6 +51,7 @@ services:
WHATSAPP_PHONE_NUMBER_ID: ${WHATSAPP_PHONE_NUMBER_ID:-} WHATSAPP_PHONE_NUMBER_ID: ${WHATSAPP_PHONE_NUMBER_ID:-}
WHATSAPP_BUSINESS_ACCOUNT_ID: ${WHATSAPP_BUSINESS_ACCOUNT_ID:-} WHATSAPP_BUSINESS_ACCOUNT_ID: ${WHATSAPP_BUSINESS_ACCOUNT_ID:-}
META_APP_ID: ${META_APP_ID:-} META_APP_ID: ${META_APP_ID:-}
WHATSAPP_BUSINESS_PHONE: ${WHATSAPP_BUSINESS_PHONE:-919411257916}
WHATSAPP_API_VERSION: ${WHATSAPP_API_VERSION:-v21.0} 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

View file

@ -10,6 +10,7 @@ import {
type AdminTemplateConfig, type AdminTemplateConfig,
type AdminUrmiOtpProject, type AdminUrmiOtpProject,
} from '../../lib/api' } from '../../lib/api'
import { AdminWhatsAppPage } from './AdminWhatsAppPage'
const KYC_DOC_LABELS: Record<string, string> = { const KYC_DOC_LABELS: Record<string, string> = {
business_pan_card: 'Business PAN card', business_pan_card: 'Business PAN card',
@ -391,6 +392,7 @@ function AdminCompliance() {
export function AdminApp() { export function AdminApp() {
const [authenticated, setAuthenticated] = useState<boolean | null>(null) const [authenticated, setAuthenticated] = useState<boolean | null>(null)
const [section, setSection] = useState<'compliance' | 'whatsapp'>('whatsapp')
useEffect(() => { useEffect(() => {
adminApi.me().then(() => setAuthenticated(true)).catch(() => setAuthenticated(false)) adminApi.me().then(() => setAuthenticated(true)).catch(() => setAuthenticated(false))
@ -406,11 +408,33 @@ export function AdminApp() {
return ( return (
<div className="admin-shell"> <div className="admin-shell">
<header className="admin-topbar"> <header className="admin-topbar">
<div><img src="/urmi-logo.png" alt="Urmi" /><strong>Admin · Global compliance</strong></div> <div><img src="/urmi-logo.png" alt="Urmi" /><strong>Admin portal</strong></div>
{authenticated && <button className="secondary-button" onClick={logout}>Log out</button>} {authenticated && (
<div className="admin-topbar-actions">
<nav className="admin-main-nav">
<button
type="button"
className={section === 'whatsapp' ? 'active' : ''}
onClick={() => setSection('whatsapp')}
>
WhatsApp Business
</button>
<button
type="button"
className={section === 'compliance' ? 'active' : ''}
onClick={() => setSection('compliance')}
>
Compliance
</button>
</nav>
<button className="secondary-button" onClick={logout}>Log out</button>
</div>
)}
</header> </header>
<main> <main>
{authenticated ? <AdminCompliance /> : <AdminLogin onSuccess={() => setAuthenticated(true)} />} {authenticated
? (section === 'whatsapp' ? <AdminWhatsAppPage /> : <AdminCompliance />)
: <AdminLogin onSuccess={() => setAuthenticated(true)} />}
</main> </main>
</div> </div>
) )

View file

@ -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<AdminWhatsAppConfig | null>(null)
const [messages, setMessages] = useState<AdminWhatsAppMessage[]>([])
const [templates, setTemplates] = useState<AdminWhatsAppTemplate[]>([])
const [loading, setLoading] = useState(true)
const [selectedPeer, setSelectedPeer] = useState<string | null>(null)
const [newPeer, setNewPeer] = useState('')
const [sendMode, setSendMode] = useState<SendMode>('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<HTMLDivElement | null>(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<string, AdminWhatsAppMessage[]>()
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 <div className="auth-loading">Loading WhatsApp Business…</div>
const readyToSend = Boolean(config?.ready_to_send)
return (
<div className="admin-content admin-whatsapp-page">
<div className="account-heading admin-whatsapp-heading">
<div>
<span className="eyebrow">Urmi business</span>
<h1><MessageSquareText size={24} /> WhatsApp Business</h1>
<p>
Send and receive on {config ? formatWaNumber(config.business_phone) : '+91 94112 57916'}.
Webhook: {config?.webhook_url ?? '—'}
</p>
</div>
<div className="admin-whatsapp-heading-actions">
<span className={`doc-status ${readyToSend ? 'approved' : 'pending'}`}>
{readyToSend ? 'Connected' : 'Setup required'}
</span>
<button type="button" className="secondary-button" onClick={() => setShowSetup((value) => !value)}>
<Settings2 size={15} /> Meta setup
</button>
<button type="button" className="secondary-button" onClick={() => loadAll()}>
<RefreshCw size={15} /> Refresh
</button>
</div>
</div>
{showSetup && (
<section className="panel admin-whatsapp-setup">
<div className="panel-heading">
<div>
<h2>Meta connection</h2>
<p>Paste IDs from Meta → WhatsApp → API Setup. Saved in admin without restarting the server.</p>
</div>
</div>
<form className="admin-whatsapp-setup-form" onSubmit={saveConfig}>
<label>
<span>Phone number ID</span>
<input value={phoneNumberId} onChange={(e) => setPhoneNumberId(e.target.value)} placeholder="106540352242922" />
</label>
<label>
<span>WhatsApp Business Account ID</span>
<input value={businessAccountId} onChange={(e) => setBusinessAccountId(e.target.value)} placeholder="WABA ID" />
</label>
<label>
<span>App secret (for webhook signature validation)</span>
<input value={appSecret} onChange={(e) => setAppSecret(e.target.value)} placeholder="From Meta App Dashboard → Basic" />
</label>
<div className="admin-whatsapp-setup-meta">
<p>Verify token: {config?.verify_token_configured ? 'Configured in server env' : 'Missing in env'}</p>
<p>Access token: {config?.access_token_configured ? 'Configured in server env' : 'Missing in env'}</p>
<p>Meta App ID: {config?.meta_app_id ?? '—'}</p>
</div>
<button className="primary-button" type="submit" disabled={savingConfig}>
{savingConfig ? 'Saving…' : 'Save Meta settings'}
</button>
</form>
</section>
)}
{!readyToSend && (
<p className="admin-whatsapp-hint">
Add your <strong>Phone number ID</strong> in Meta setup above to enable sending.
Inbound messages still arrive once Meta webhook is subscribed at {config?.webhook_url}.
</p>
)}
<div className="admin-whatsapp-messenger">
<aside className="panel admin-whatsapp-sidebar">
<div className="panel-heading">
<div>
<h2>Conversations</h2>
<p>{threads.length} active threads</p>
</div>
</div>
{threads.length === 0 ? (
<p className="admin-whatsapp-empty">No messages yet. Ask someone to message +91 94112 57916 or scan your Meta QR.</p>
) : (
<div className="admin-whatsapp-thread-list compact">
{threads.map(({ peer, latest, thread }) => (
<button
key={peer}
type="button"
className={`admin-whatsapp-thread-item ${selectedPeer === peer ? 'active' : ''}`}
onClick={() => {
setSelectedPeer(peer)
const lastInbound = [...thread].reverse().find((item) => item.direction === 'inbound')
if (lastInbound) markRead(lastInbound)
}}
>
<strong>{formatWaNumber(peer)}</strong>
<span>{latest.body || `[${latest.message_type}]`}</span>
<small>{new Date(latest.created_at).toLocaleString()}</small>
</button>
))}
</div>
)}
</aside>
<section className="panel admin-whatsapp-chat">
{!selectedPeer ? (
<div className="admin-whatsapp-chat-empty">
<p>Select a conversation to view messages and reply.</p>
</div>
) : (
<>
<div className="admin-whatsapp-chat-head">
<strong>{formatWaNumber(selectedPeer)}</strong>
<small>{activeThread.length} messages</small>
</div>
<div className="admin-whatsapp-chat-body">
{activeThread.map((item) => (
<div
key={item.id}
className={`admin-whatsapp-bubble ${item.direction === 'inbound' ? 'inbound' : 'outbound'}`}
>
{item.media?.id && ['image', 'sticker'].includes(item.message_type) && (
<img src={mediaUrl(item.media.id)} alt={item.message_type} className="admin-whatsapp-media" />
)}
{item.media?.id && item.message_type === 'video' && (
<video src={mediaUrl(item.media.id)} controls className="admin-whatsapp-media" />
)}
{item.media?.id && item.message_type === 'audio' && (
<audio src={mediaUrl(item.media.id)} controls className="admin-whatsapp-media" />
)}
{item.media?.id && item.message_type === 'document' && (
<a href={mediaUrl(item.media.id)} target="_blank" rel="noreferrer">Download document</a>
)}
<span>{item.body || `[${item.message_type} · ${item.status}]`}</span>
<small>
{new Date(item.created_at).toLocaleTimeString()}
{item.status ? ` · ${item.status}` : ''}
{item.wa_message_id ? ` · ${item.wa_message_id.slice(0, 12)}…` : ''}
</small>
{item.direction === 'inbound' && item.wa_message_id && (
<button
type="button"
className="link-button"
onClick={() => {
setSendMode('reaction')
setReactionTarget(item.wa_message_id || '')
}}
>
React
</button>
)}
</div>
))}
<div ref={chatEndRef} />
</div>
</>
)}
</section>
<section className="panel admin-whatsapp-compose-panel">
<div className="panel-heading">
<div>
<h2>Send</h2>
<p>Supported: text, template, media, buttons, reaction, read receipts.</p>
</div>
</div>
<div className="admin-whatsapp-mode-tabs">
{(['text', 'template', 'media', 'buttons', 'reaction'] as SendMode[]).map((mode) => (
<button
key={mode}
type="button"
className={sendMode === mode ? 'active' : ''}
onClick={() => setSendMode(mode)}
>
{mode}
</button>
))}
</div>
<form className="admin-whatsapp-form" onSubmit={sendMessage}>
{!selectedPeer && (
<label>
<span>New recipient</span>
<input
value={newPeer}
onChange={(e) => setNewPeer(e.target.value)}
placeholder="9411257916 or +91 94112 57916"
/>
</label>
)}
{sendMode === 'text' && (
<>
<label>
<span>Message</span>
<textarea value={textBody} onChange={(e) => setTextBody(e.target.value)} rows={5} required />
</label>
<label>
<span>Reply to message ID (optional)</span>
<input value={replyTo} onChange={(e) => setReplyTo(e.target.value)} placeholder="wamid…" />
</label>
</>
)}
{sendMode === 'template' && (
<>
<label>
<span>Approved template</span>
<select value={templateName} onChange={(e) => setTemplateName(e.target.value)} required>
<option value="">Select template</option>
{templates.map((template) => (
<option key={template.id ?? template.name} value={template.name}>
{template.name} ({template.language})
</option>
))}
</select>
</label>
<label>
<span>Language code</span>
<input value={templateLanguage} onChange={(e) => setTemplateLanguage(e.target.value)} />
</label>
<small>Templates work outside the 24-hour customer service window.</small>
</>
)}
{sendMode === 'media' && (
<>
<label>
<span>Media type</span>
<select value={mediaType} onChange={(e) => setMediaType(e.target.value as typeof mediaType)}>
<option value="image">Image</option>
<option value="document">Document</option>
<option value="audio">Audio</option>
<option value="video">Video</option>
</select>
</label>
<label>
<span>Public HTTPS URL</span>
<input value={mediaLink} onChange={(e) => setMediaLink(e.target.value)} placeholder="https://…" required />
</label>
<label>
<span>Caption</span>
<input value={mediaCaption} onChange={(e) => setMediaCaption(e.target.value)} />
</label>
{mediaType === 'document' && (
<label>
<span>Filename</span>
<input value={mediaFilename} onChange={(e) => setMediaFilename(e.target.value)} />
</label>
)}
</>
)}
{sendMode === 'buttons' && (
<>
<label>
<span>Body</span>
<textarea value={buttonBody} onChange={(e) => setButtonBody(e.target.value)} rows={4} required />
</label>
<label><span>Button 1</span><input value={buttonOne} onChange={(e) => setButtonOne(e.target.value)} required /></label>
<label><span>Button 2</span><input value={buttonTwo} onChange={(e) => setButtonTwo(e.target.value)} /></label>
<label><span>Button 3</span><input value={buttonThree} onChange={(e) => setButtonThree(e.target.value)} /></label>
</>
)}
{sendMode === 'reaction' && (
<>
<label>
<span>Message ID</span>
<input value={reactionTarget} onChange={(e) => setReactionTarget(e.target.value)} required />
</label>
<label>
<span>Emoji</span>
<input value={reactionEmoji} onChange={(e) => setReactionEmoji(e.target.value)} required />
</label>
</>
)}
{error && <p className="auth-error">{error}</p>}
<button className="primary-button" type="submit" disabled={sending || !readyToSend || (!selectedPeer && !newPeer.trim())}>
{sending ? 'Sending…' : 'Send'}
</button>
<small>Within 24h of customer message: free-form text/media/buttons. Outside window: use template.</small>
</form>
</section>
</div>
</div>
)
}

View file

@ -71,9 +71,14 @@ export function WhatsAppSetupPage() {
<div className="whatsapp-config-row"> <div className="whatsapp-config-row">
<div> <div>
<span>Verify token</span> <span>Verify token</span>
<code>{setup.verify_token_configured ? 'Set in server env (WHATSAPP_VERIFY_TOKEN)' : 'Not configured'}</code> <code>{setup.verify_token || 'Not configured'}</code>
<small>Use the exact same string in Meta and in your <code>.env</code> file.</small> <small>Paste this exact value into Meta → Configure Webhooks → Verify token.</small>
</div> </div>
{setup.verify_token && (
<button className="secondary-button" onClick={() => copyValue('token', setup.verify_token!)}>
<Copy size={14} /> {copied === 'token' ? 'Copied' : 'Copy'}
</button>
)}
</div> </div>
<div className="whatsapp-badges"> <div className="whatsapp-badges">

View file

@ -790,6 +790,22 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.admin-topbar > div { display: flex; align-items: center; gap: 14px; } .admin-topbar > div { display: flex; align-items: center; gap: 14px; }
.admin-topbar img { width: 92px; height: 42px; object-fit: contain; } .admin-topbar img { width: 92px; height: 42px; object-fit: contain; }
.admin-topbar strong { font-size: 12px; color: #c7d6ea; } .admin-topbar strong { font-size: 12px; color: #c7d6ea; }
.admin-topbar-actions { display: flex; align-items: center; gap: 12px; }
.admin-main-nav { display: flex; gap: 8px; }
.admin-main-nav button {
border: 1px solid rgba(255,255,255,.18);
background: transparent;
color: #c7d6ea;
border-radius: 8px;
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
}
.admin-main-nav button.active {
background: #1475ff;
border-color: #1475ff;
color: white;
}
.admin-content, .admin-login-page { padding: 28px; } .admin-content, .admin-login-page { padding: 28px; }
.admin-login-page { min-height: calc(100vh - 72px); display: grid; place-items: center; } .admin-login-page { min-height: calc(100vh - 72px); display: grid; place-items: center; }
.admin-login-card { width: min(420px, 100%); } .admin-login-card { width: min(420px, 100%); }
@ -833,6 +849,270 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.admin-otp-project a { color: #1475ff; word-break: break-all; } .admin-otp-project a { color: #1475ff; word-break: break-all; }
.admin-otp-project p { margin: 0; line-height: 1.55; } .admin-otp-project p { margin: 0; line-height: 1.55; }
.admin-otp-project .admin-actions { margin-top: 5px; } .admin-otp-project .admin-actions { margin-top: 5px; }
.admin-whatsapp { display: grid; gap: 18px; }
.admin-whatsapp-config { margin-bottom: 0; }
.admin-whatsapp-hint {
margin: 0;
padding: 12px 14px;
border-radius: 8px;
background: #fff8e8;
color: #7a5b12;
font-size: 11px;
line-height: 1.5;
}
.admin-whatsapp-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
gap: 18px;
align-items: start;
}
.admin-whatsapp-inbox, .admin-whatsapp-compose { min-height: 520px; }
.admin-whatsapp-empty {
margin: 0;
color: #8a95a7;
font-size: 12px;
line-height: 1.55;
}
.admin-whatsapp-thread-list { display: grid; gap: 14px; }
.admin-whatsapp-thread {
border: 1px solid #e6ebf2;
border-radius: 10px;
padding: 12px 14px;
background: #fbfcfe;
}
.admin-whatsapp-thread-head {
display: flex;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.admin-whatsapp-thread-head strong { font-size: 12px; color: #1f2a3d; }
.admin-whatsapp-thread-head small, .admin-whatsapp-thread > small { color: #8a95a7; font-size: 10px; }
.admin-whatsapp-thread > p {
margin: 0 0 6px;
color: #46546a;
font-size: 11px;
line-height: 1.45;
}
.admin-whatsapp-thread-messages {
display: grid;
gap: 8px;
margin-top: 10px;
max-height: 220px;
overflow: auto;
}
.admin-whatsapp-bubble {
display: grid;
gap: 4px;
max-width: 88%;
padding: 8px 10px;
border-radius: 10px;
font-size: 11px;
line-height: 1.45;
}
.admin-whatsapp-bubble.inbound {
justify-self: start;
background: white;
border: 1px solid #dfe5ed;
}
.admin-whatsapp-bubble.outbound {
justify-self: end;
background: #e8f2ff;
border: 1px solid #c8ddff;
}
.admin-whatsapp-bubble small { color: #8a95a7; font-size: 9px; }
.admin-whatsapp-form {
display: grid;
gap: 12px;
}
.admin-whatsapp-form label {
display: grid;
gap: 6px;
}
.admin-whatsapp-form label > span {
color: #46546a;
font-size: 11px;
font-weight: 600;
}
.admin-whatsapp-form input,
.admin-whatsapp-form textarea {
width: 100%;
border: 1px solid #dfe5ed;
border-radius: 8px;
padding: 10px 12px;
font: inherit;
font-size: 12px;
color: #1f2a3d;
background: white;
}
.admin-whatsapp-form textarea { resize: vertical; min-height: 120px; }
.admin-whatsapp-form small {
color: #8a95a7;
font-size: 10px;
line-height: 1.45;
}
@media (max-width: 960px) {
.admin-whatsapp-grid { grid-template-columns: 1fr; }
}
.admin-whatsapp-page { max-width: 1400px; }
.admin-whatsapp-heading {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.admin-whatsapp-heading-actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.admin-whatsapp-messenger {
display: grid;
grid-template-columns: 260px minmax(0, 1fr) 320px;
gap: 16px;
align-items: start;
}
.admin-whatsapp-sidebar,
.admin-whatsapp-chat,
.admin-whatsapp-compose-panel {
min-height: 640px;
}
.admin-whatsapp-thread-list.compact {
display: grid;
gap: 8px;
max-height: 560px;
overflow: auto;
}
.admin-whatsapp-thread-item {
display: grid;
gap: 4px;
text-align: left;
border: 1px solid #e6ebf2;
border-radius: 10px;
padding: 10px 12px;
background: white;
cursor: pointer;
}
.admin-whatsapp-thread-item.active {
border-color: #1475ff;
background: #f3f8ff;
}
.admin-whatsapp-thread-item strong { font-size: 12px; color: #1f2a3d; }
.admin-whatsapp-thread-item span {
font-size: 11px;
color: #607089;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.admin-whatsapp-thread-item small { font-size: 10px; color: #8a95a7; }
.admin-whatsapp-chat {
display: grid;
grid-template-rows: auto 1fr;
}
.admin-whatsapp-chat-empty {
display: grid;
place-items: center;
min-height: 520px;
color: #8a95a7;
font-size: 12px;
}
.admin-whatsapp-chat-head {
display: flex;
justify-content: space-between;
gap: 10px;
padding-bottom: 12px;
border-bottom: 1px solid #e6ebf2;
margin-bottom: 12px;
}
.admin-whatsapp-chat-head strong { font-size: 13px; }
.admin-whatsapp-chat-head small { color: #8a95a7; font-size: 10px; }
.admin-whatsapp-chat-body {
display: grid;
gap: 10px;
align-content: start;
max-height: 560px;
overflow: auto;
padding-right: 4px;
}
.admin-whatsapp-media {
max-width: 100%;
border-radius: 8px;
margin-bottom: 6px;
}
.admin-whatsapp-mode-tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.admin-whatsapp-mode-tabs button {
border: 1px solid #dfe5ed;
background: white;
color: #607089;
border-radius: 8px;
padding: 7px 10px;
font-size: 10px;
font-weight: 600;
text-transform: capitalize;
}
.admin-whatsapp-mode-tabs button.active {
background: #1475ff;
border-color: #1475ff;
color: white;
}
.admin-whatsapp-setup-form {
display: grid;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-whatsapp-setup-form label {
display: grid;
gap: 6px;
}
.admin-whatsapp-setup-form label > span {
color: #46546a;
font-size: 11px;
font-weight: 600;
}
.admin-whatsapp-setup-form input,
.admin-whatsapp-setup-form select,
.admin-whatsapp-form select {
width: 100%;
border: 1px solid #dfe5ed;
border-radius: 8px;
padding: 10px 12px;
font: inherit;
font-size: 12px;
background: white;
}
.admin-whatsapp-setup-meta {
grid-column: 1 / -1;
display: grid;
gap: 4px;
color: #607089;
font-size: 11px;
}
.admin-whatsapp-setup-form .primary-button {
grid-column: 1 / -1;
justify-self: start;
}
.link-button {
border: none;
background: none;
color: #1475ff;
font-size: 10px;
padding: 0;
cursor: pointer;
justify-self: start;
}
@media (max-width: 1100px) {
.admin-whatsapp-messenger { grid-template-columns: 1fr; }
.admin-whatsapp-sidebar,
.admin-whatsapp-chat,
.admin-whatsapp-compose-panel { min-height: auto; }
}
/* UrmiOTP */ /* UrmiOTP */
.urmiotp-page, .urmiotp-docs-page { max-width: 1080px; } .urmiotp-page, .urmiotp-docs-page { max-width: 1080px; }

View file

@ -648,6 +648,66 @@ export type AdminTemplateConfig = TemplateConfig & {
user: { id: string; name: string; email: string } user: { id: string; name: string; email: string }
} }
export type AdminWhatsAppConfig = {
business_phone: string
phone_number_id: string | null
business_account_id: string | null
meta_app_id: string | null
access_token_configured: boolean
app_secret_configured: boolean
webhook_url: string
verify_token_configured: boolean
ready_to_send: boolean
supported_operations: string[]
}
export type AdminWhatsAppMessage = {
id: string
direction: 'inbound' | 'outbound'
wa_message_id: string | null
from: string
to: string
body: string | null
message_type: string
status: string
media?: {
id?: string
mime_type?: string
caption?: string
filename?: string
} | null
created_at: string
}
export type AdminWhatsAppTemplate = {
id?: string
name: string
language: string
status?: string
category?: string
}
export type AdminWhatsAppSendPayload = {
to: string
message_type: 'text' | 'template' | 'image' | 'document' | 'audio' | 'video' | 'interactive_buttons' | 'interactive_list' | 'reaction'
text?: string
reply_to?: string
template_name?: string
template_language?: string
template_components?: Record<string, unknown>[]
media_link?: string
media_caption?: string
media_filename?: string
interactive_body?: string
interactive_header?: string
interactive_footer?: string
interactive_buttons?: Array<{ id: string; title: string }>
interactive_list_button?: string
interactive_list_sections?: Record<string, unknown>[]
reaction_message_id?: string
reaction_emoji?: string
}
export const adminApi = { export const adminApi = {
me: () => jsonRequest<{ username: string }>('/api/v1/admin/me'), me: () => jsonRequest<{ username: string }>('/api/v1/admin/me'),
login: (username: string, password: string) => login: (username: string, password: string) =>
@ -708,6 +768,30 @@ export const adminApi = {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}), }),
whatsappConfig: () => jsonRequest<AdminWhatsAppConfig>('/api/v1/admin/whatsapp/config'),
updateWhatsAppConfig: (payload: {
phone_number_id?: string
business_account_id?: string
app_secret?: string
}) => jsonRequest<AdminWhatsAppConfig>('/api/v1/admin/whatsapp/config', {
method: 'PATCH',
body: JSON.stringify(payload),
}),
listWhatsAppMessages: (limit = 50, peer?: string) =>
jsonRequest<AdminWhatsAppMessage[]>(
`/api/v1/admin/whatsapp/messages?limit=${limit}${peer ? `&peer=${encodeURIComponent(peer)}` : ''}`,
),
listWhatsAppTemplates: () =>
jsonRequest<{ data?: AdminWhatsAppTemplate[] }>('/api/v1/admin/whatsapp/templates'),
markWhatsAppRead: (waMessageId: string) =>
jsonRequest<{ success?: boolean }>(`/api/v1/admin/whatsapp/messages/${encodeURIComponent(waMessageId)}/read`, {
method: 'POST',
}),
sendWhatsAppMessage: (payload: AdminWhatsAppSendPayload) =>
jsonRequest<AdminWhatsAppMessage>('/api/v1/admin/whatsapp/messages/send', {
method: 'POST',
body: JSON.stringify(payload),
}),
} }
export type LogType = 'outbound' | 'inbound' | 'sms' | 'otp' export type LogType = 'outbound' | 'inbound' | 'sms' | 'otp'
@ -791,6 +875,7 @@ export const analyticsApi = {
export type WhatsAppSetup = { export type WhatsAppSetup = {
webhook_url: string webhook_url: string
verify_token: string | null
verify_token_configured: boolean verify_token_configured: boolean
app_secret_configured: boolean app_secret_configured: boolean
access_token_configured: boolean access_token_configured: boolean