From 5ced0d08129b238a50460707c7e2121d3ad769ed Mon Sep 17 00:00:00 2001 From: debu-2 Date: Thu, 8 Oct 2026 00:50:36 +0530 Subject: [PATCH] meta whatsapp chat --- backend/app/main.py | 11 + backend/app/models.py | 4 + backend/app/routes_admin.py | 7 +- backend/app/routes_whatsapp.py | 66 +++ backend/app/whatsapp_messages.py | 48 +- backend/app/whatsapp_onboarding.py | 287 +++++++++- .../whatsapp/CustomerWhatsAppInbox.tsx | 510 ++++++++++++++++++ .../features/whatsapp/WhatsAppSetupPage.tsx | 54 +- frontend/src/index.css | 9 + frontend/src/lib/api.ts | 44 ++ 10 files changed, 1015 insertions(+), 25 deletions(-) create mode 100644 frontend/src/features/whatsapp/CustomerWhatsAppInbox.tsx diff --git a/backend/app/main.py b/backend/app/main.py index 5fca9f0..d1aed4c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -63,6 +63,17 @@ async def lifespan(_: FastAPI): "ADD COLUMN IF NOT EXISTS monthly_outbound_price NUMERIC(10,2) NOT NULL DEFAULT 0" ) ) + conn.execute( + text( + "ALTER TABLE whatsapp_phone_assets " + "ADD COLUMN IF NOT EXISTS two_step_pin VARCHAR(6)" + ) + ) + conn.execute(text("ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS user_id UUID")) + conn.execute(text("ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS phone_number_id VARCHAR(64)")) + conn.execute(text("ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS contact_name VARCHAR(200)")) + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_whatsapp_messages_user_id ON whatsapp_messages (user_id)")) + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_whatsapp_messages_phone_number_id ON whatsapp_messages (phone_number_id)")) yield diff --git a/backend/app/models.py b/backend/app/models.py index ca92811..ca7e4bd 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -536,6 +536,9 @@ class WhatsAppMessage(Base): 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) + user_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("user_accounts.id", ondelete="CASCADE"), nullable=True, index=True) + phone_number_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + contact_name: Mapped[str | None] = mapped_column(String(200), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) @@ -605,6 +608,7 @@ class WhatsAppPhoneAsset(Base): quality: Mapped[str | None] = mapped_column(String(24), nullable=True) status: Mapped[str] = mapped_column(String(32), default="pending") code_verification_status: Mapped[str | None] = mapped_column(String(32), nullable=True) + two_step_pin: Mapped[str | None] = mapped_column(String(6), nullable=True) account: Mapped[WhatsAppBusinessAccount] = relationship(back_populates="phones") diff --git a/backend/app/routes_admin.py b/backend/app/routes_admin.py index 7e0e32e..7ed7882 100644 --- a/backend/app/routes_admin.py +++ b/backend/app/routes_admin.py @@ -629,7 +629,12 @@ def admin_list_whatsapp_messages( db: Session = Depends(get_db), _: str = Depends(current_admin), ) -> list[dict]: - query = select(WhatsAppMessage).order_by(WhatsAppMessage.created_at.desc()).limit(min(limit, 500)) + query = ( + select(WhatsAppMessage) + .where(WhatsAppMessage.user_id.is_(None)) + .order_by(WhatsAppMessage.created_at.desc()) + .limit(min(limit, 500)) + ) if peer: try: normalized_peer = normalize_wa_number(peer) diff --git a/backend/app/routes_whatsapp.py b/backend/app/routes_whatsapp.py index 5e1b7f3..f6ccb58 100644 --- a/backend/app/routes_whatsapp.py +++ b/backend/app/routes_whatsapp.py @@ -20,8 +20,12 @@ from .whatsapp_onboarding import ( customer_token_for_phone, developer_config, disconnect_account, + download_account_media, + list_account_messages, list_accounts, mask_identifier, + register_phone_number, + send_account_message, list_templates, refresh_account, signup_public_config, @@ -150,6 +154,26 @@ class SendWhatsAppTextRequest(BaseModel): phone_number_id: str = Field(min_length=4, max_length=64) +class CustomerSendRequest(BaseModel): + to: str = Field(min_length=8, max_length=20) + message_type: str = "text" + text: str | None = Field(default=None, max_length=4096) + reply_to: str | None = None + template_name: str | None = None + template_language: str = "en" + media_link: str | None = None + media_caption: str | None = None + media_filename: str | None = None + interactive_body: str | None = None + interactive_buttons: list[dict[str, str]] | None = None + reaction_message_id: str | None = None + reaction_emoji: str | None = None + + +class RegisterPhoneRequest(BaseModel): + pin: str | None = Field(default=None, max_length=6) + + class SignupCompleteRequest(BaseModel): event: str = Field(min_length=1, max_length=80) code: str | None = Field(default=None, max_length=2048) @@ -202,6 +226,48 @@ async def whatsapp_account_refresh( return await refresh_account(db, user, account_id) +@router.get("/accounts/{account_id}/messages") +def whatsapp_account_messages( + account_id: str, + limit: int = Query(default=300, ge=1, le=500), + db: Session = Depends(get_db), + user: UserAccount = Depends(current_user), +) -> list[dict]: + return list_account_messages(db, user, account_id, limit) + + +@router.post("/accounts/{account_id}/messages/send") +async def whatsapp_account_send( + account_id: str, + payload: CustomerSendRequest, + db: Session = Depends(get_db), + user: UserAccount = Depends(current_user), +) -> dict: + return await send_account_message(db, user, account_id, payload.model_dump()) + + +@router.get("/accounts/{account_id}/media/{media_id}") +async def whatsapp_account_media( + account_id: str, + media_id: str, + db: Session = Depends(get_db), + user: UserAccount = Depends(current_user), +) -> Response: + content, mime_type = await download_account_media(db, user, account_id, media_id) + return Response(content=content, media_type=mime_type) + + +@router.post("/accounts/{account_id}/phones/{phone_id}/register") +async def whatsapp_register_phone( + account_id: str, + phone_id: str, + payload: RegisterPhoneRequest, + db: Session = Depends(get_db), + user: UserAccount = Depends(current_user), +) -> dict: + return await register_phone_number(db, user, account_id, phone_id, payload.pin) + + @router.post("/accounts/{account_id}/disconnect") async def whatsapp_account_disconnect( account_id: str, diff --git a/backend/app/whatsapp_messages.py b/backend/app/whatsapp_messages.py index 9339656..3e64e45 100644 --- a/backend/app/whatsapp_messages.py +++ b/backend/app/whatsapp_messages.py @@ -5,7 +5,8 @@ from sqlalchemy import select from sqlalchemy.orm import Session from .config import settings -from .models import WhatsAppMessage +from .models import WhatsAppMessage, WhatsAppPhoneAsset +from .whatsapp_config import resolved_whatsapp_phone_number_id PHONE_DIGITS_RE = re.compile(r"^\d{10,15}$") @@ -50,6 +51,7 @@ def serialize_message(message: WhatsAppMessage) -> dict: "message_type": message.message_type, "status": message.status, "media": media, + "contact_name": message.contact_name, "created_at": message.created_at, } @@ -93,13 +95,39 @@ def record_inbound_messages(db: Session, payload: dict[str, Any]) -> int: if payload.get("object") != "whatsapp_business_account": return 0 - business = business_phone() + platform_number = business_phone() + platform_phone_id = resolved_whatsapp_phone_number_id(db) created = 0 for entry in payload.get("entry", []): for change in entry.get("changes", []): if change.get("field") != "messages": continue value = change.get("value", {}) + metadata = value.get("metadata") if isinstance(value.get("metadata"), dict) else {} + phone_number_id = str(metadata.get("phone_number_id") or "") or None + owner_id = None + line_number = platform_number + if phone_number_id and phone_number_id != platform_phone_id: + asset = db.scalar( + select(WhatsAppPhoneAsset).where(WhatsAppPhoneAsset.phone_number_id == phone_number_id) + ) + if asset is None: + continue + owner_id = asset.user_id + raw_line = asset.display_phone or str(metadata.get("display_phone_number") or "") + try: + line_number = normalize_wa_number(raw_line) + except ValueError: + line_number = re.sub(r"\D", "", raw_line) or platform_number + names: dict[str, str] = {} + for contact in value.get("contacts") or []: + if not isinstance(contact, dict): + continue + wa_id = str(contact.get("wa_id") or "") + profile = contact.get("profile") if isinstance(contact.get("profile"), dict) else {} + name = profile.get("name") + if wa_id and isinstance(name, str) and name.strip(): + names[wa_id] = name.strip() for message in value.get("messages", []): from_number = message.get("from") or "" body = _extract_text_body(message) @@ -108,11 +136,14 @@ def record_inbound_messages(db: Session, payload: dict[str, Any]) -> int: direction="inbound", wa_message_id=message.get("id"), from_number=from_number, - to_number=business, + to_number=line_number, body=body, message_type=message.get("type") or "text", status="received", payload=message, + user_id=owner_id, + phone_number_id=phone_number_id if owner_id else None, + contact_name=names.get(from_number), ) ) created += 1 @@ -130,12 +161,14 @@ def record_inbound_messages(db: Session, payload: dict[str, Any]) -> int: WhatsAppMessage( direction="outbound", wa_message_id=wa_id, - from_number=business, + from_number=line_number, to_number=status.get("recipient_id") or "", body=None, message_type="status", status=status.get("status") or "unknown", payload=status, + user_id=owner_id, + phone_number_id=phone_number_id if owner_id else None, ) ) created += 1 @@ -153,16 +186,21 @@ def record_outbound_message( wa_message_id: str | None, status: str = "sent", payload: dict | None = None, + from_number: str | None = None, + user_id: Any = None, + phone_number_id: str | None = None, ) -> WhatsAppMessage: message = WhatsAppMessage( direction="outbound", wa_message_id=wa_message_id, - from_number=business_phone(), + from_number=from_number or business_phone(), to_number=to_number, body=body, message_type=message_type, status=status, payload=payload, + user_id=user_id, + phone_number_id=phone_number_id, ) db.add(message) db.commit() diff --git a/backend/app/whatsapp_onboarding.py b/backend/app/whatsapp_onboarding.py index f1ddc54..5ab8a2e 100644 --- a/backend/app/whatsapp_onboarding.py +++ b/backend/app/whatsapp_onboarding.py @@ -8,6 +8,7 @@ addressed by another tenant. from __future__ import annotations import logging +import secrets import uuid from datetime import datetime, timezone from typing import Any @@ -22,11 +23,14 @@ from .models import ( UserAccount, WhatsAppBusinessAccount, WhatsAppConnectionAudit, + WhatsAppMessage, WhatsAppMetaConnection, WhatsAppPhoneAsset, WhatsAppWebhookEvent, ) from .whatsapp_config import resolved_whatsapp_app_secret +from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError +from .whatsapp_messages import extract_wa_message_id, normalize_wa_number, record_outbound_message, serialize_message logger = logging.getLogger(__name__) @@ -199,6 +203,7 @@ def _owned_account(db: Session, user: UserAccount, account_id: Any) -> WhatsAppB def _serialize_phone(phone: WhatsAppPhoneAsset) -> dict[str, Any]: + verified = (phone.code_verification_status or "").upper() return { "id": str(phone.id), "phone_number_id": phone.phone_number_id, @@ -207,6 +212,8 @@ def _serialize_phone(phone: WhatsAppPhoneAsset) -> dict[str, Any]: "quality": phone.quality, "status": phone.status, "code_verification_status": phone.code_verification_status, + "needs_otp": phone.status != "connected" and verified in {"NOT_VERIFIED", "EXPIRED"}, + "needs_registration": phone.status != "connected", } @@ -344,6 +351,75 @@ def _apply_phones( row.code_verification_status = item.get("code_verification_status") +def _pin_for_phone(phone: WhatsAppPhoneAsset, requested: str | None = None) -> str: + pin = (requested or phone.two_step_pin or "").strip() + if pin.isdigit() and len(pin) == 6: + phone.two_step_pin = pin + return pin + pin = "".join(secrets.choice("0123456789") for _ in range(6)) + phone.two_step_pin = pin + return pin + + +async def _register_phone(token: str, phone: WhatsAppPhoneAsset, pin: str) -> str | None: + try: + await _graph( + "POST", + f"{phone.phone_number_id}/register", + token=token, + json_body={"messaging_product": "whatsapp", "pin": pin}, + ) + return None + except HTTPException as exc: + detail = str(exc.detail) + lowered = detail.lower() + if "already" in lowered and "register" in lowered: + return None + return detail[:500] + + +def _set_account_health(db: Session, user: UserAccount, account: WhatsAppBusinessAccount) -> None: + connected = any(phone.status == "connected" for phone in account.phones) + otp_pending = any( + phone.status != "connected" + and (phone.code_verification_status or "").upper() in {"NOT_VERIFIED", "EXPIRED"} + for phone in account.phones + ) + if connected and account.management_status == "active": + account.status = "connected" + account.messaging_status = "active" + account.last_error = None + _audit( + db, + user, + "connected", + "WhatsApp Business connection verified", + connection_id=account.connection_id, + waba_id=account.waba_id, + ) + return + account.status = "incomplete" + account.messaging_status = "unavailable" + if otp_pending: + account.last_error = ( + "This number still needs the SMS or voice code from Meta. " + "It cannot stay logged into the WhatsApp or WhatsApp Business app while Cloud API uses it." + ) + else: + account.last_error = account.last_error or ( + "This number is attached to the WhatsApp Business Account, but Meta has not registered it for Cloud API yet. " + "Enter the 6-digit two-step PIN if this number already has one, then register it." + ) + _audit( + db, + user, + "provision_incomplete", + account.last_error, + connection_id=account.connection_id, + waba_id=account.waba_id, + ) + + async def _provision_waba( db: Session, user: UserAccount, @@ -416,29 +492,31 @@ async def _provision_waba( account.last_error = str(exc.detail)[:500] _apply_phones(db, user, account, phones, session_phone_id) + for phone in account.phones: + verified = (phone.code_verification_status or "").upper() + if phone.status == "connected": + continue + if verified in {"NOT_VERIFIED", "EXPIRED"}: + continue + pin = _pin_for_phone(phone) + register_error = await _register_phone(token, phone, pin) + if register_error: + account.last_error = register_error + else: + phone.status = "connected" + + try: + phones = await _load_phones(token, waba_id) + _apply_phones(db, user, account, phones, session_phone_id) + except HTTPException: + pass + subscribed = await _subscribe(token, waba_id) template_count, templates_ok = await _template_count(token, waba_id) account.template_count = template_count account.templates_status = "active" if templates_ok else "unavailable" account.webhooks_status = "active" if subscribed else "unavailable" - - has_connected_phone = any(phone.status == "connected" for phone in account.phones) - if has_connected_phone and account.management_status == "active": - account.status = "connected" - account.messaging_status = "active" - _audit(db, user, "connected", "WhatsApp Business connection verified", connection_id=connection.id, waba_id=waba_id) - else: - account.status = "incomplete" - account.messaging_status = "unavailable" - account.last_error = account.last_error or "Phone number verification is not complete" - _audit( - db, - user, - "provision_incomplete", - account.last_error, - connection_id=connection.id, - waba_id=waba_id, - ) + _set_account_health(db, user, account) return account @@ -547,6 +625,49 @@ async def refresh_account(db: Session, user: UserAccount, account_id: Any) -> di return serialize_account(account) +async def register_phone_number( + db: Session, + user: UserAccount, + account_id: Any, + phone_id: Any, + pin: str | None, +) -> dict[str, Any]: + account = _owned_account(db, user, account_id) + connection = account.connection + token = connection.access_token if connection else None + if connection is None or not token or connection.status == "disconnected": + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reconnect this WhatsApp account before registering the number") + phone = next((item for item in account.phones if str(item.id) == str(phone_id)), None) + if phone is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Phone number not found on this account") + verified = (phone.code_verification_status or "").upper() + if verified in {"NOT_VERIFIED", "EXPIRED"}: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Complete the SMS or voice code for this number in Meta first. Then register it for Cloud API.", + ) + submitted = (pin or "").strip() + if submitted and (not submitted.isdigit() or len(submitted) != 6): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Enter the 6-digit two-step PIN for this number") + register_pin = _pin_for_phone(phone, submitted or None) + register_error = await _register_phone(token, phone, register_pin) + if register_error: + account.last_error = register_error + _audit(db, user, "register_failed", register_error, connection_id=connection.id, waba_id=account.waba_id) + db.commit() + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=register_error) + try: + phones = await _load_phones(token, account.waba_id) + _apply_phones(db, user, account, phones, phone.phone_number_id) + except HTTPException: + phone.status = "connected" + _audit(db, user, "number_registered", "Registered this number for Cloud API", connection_id=connection.id, waba_id=account.waba_id) + _set_account_health(db, user, account) + db.commit() + db.refresh(account) + return serialize_account(account) + + async def disconnect_account(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]: account = _owned_account(db, user, account_id) connection = account.connection @@ -692,3 +813,133 @@ def customer_token_for_phone(db: Session, user: UserAccount, phone_number_id: st if not token: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reconnect this WhatsApp account before sending") return token, phone.phone_number_id + + +def list_account_messages(db: Session, user: UserAccount, account_id: Any, limit: int = 300) -> list[dict[str, Any]]: + account = _owned_account(db, user, account_id) + phone_ids = [phone.phone_number_id for phone in account.phones] + if not phone_ids: + return [] + rows = db.scalars( + select(WhatsAppMessage) + .where(WhatsAppMessage.user_id == user.id, WhatsAppMessage.phone_number_id.in_(phone_ids)) + .order_by(WhatsAppMessage.created_at.desc()) + .limit(min(limit, 500)) + ).all() + return [serialize_message(row) for row in rows] + + +def _messaging_phone(account: WhatsAppBusinessAccount) -> tuple[WhatsAppPhoneAsset, str]: + connection = account.connection + token = connection.access_token if connection else None + phone = next((item for item in account.phones if item.status == "connected"), None) + if phone is None or account.messaging_status != "active" or not token: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=account.last_error or "Messaging is unavailable until this number is connected on Cloud API", + ) + return phone, token + + +async def send_account_message(db: Session, user: UserAccount, account_id: Any, payload: dict[str, Any]) -> dict[str, Any]: + account = _owned_account(db, user, account_id) + phone, token = _messaging_phone(account) + try: + recipient = normalize_wa_number(str(payload.get("to") or "")) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + client = WhatsAppGraphClient(access_token=token, phone_number_id=phone.phone_number_id, business_account_id=account.waba_id) + message_type = str(payload.get("message_type") or "text") + reply_to = payload.get("reply_to") or None + try: + if message_type == "text": + body = str(payload.get("text") or "").strip() + if not body: + raise HTTPException(status_code=422, detail="text is required") + result = await client.send_text(recipient, body, reply_to=reply_to) + elif message_type == "template": + name = str(payload.get("template_name") or "").strip() + if not name: + raise HTTPException(status_code=422, detail="template_name is required") + result = await client.send_template(recipient, name, language_code=str(payload.get("template_language") or "en")) + body = f"Template: {name}" + message_type = "template" + elif message_type in {"image", "document", "audio", "video"}: + link = str(payload.get("media_link") or "").strip() + if not link: + raise HTTPException(status_code=422, detail="media_link is required") + result = await client.send_media( + recipient, + message_type, + link=link, + caption=payload.get("media_caption"), + filename=payload.get("media_filename"), + reply_to=reply_to, + ) + body = payload.get("media_caption") or f"[{message_type}]" + elif message_type == "interactive_buttons": + buttons = payload.get("interactive_buttons") or [] + text = str(payload.get("interactive_body") or "").strip() + if not text or not buttons: + raise HTTPException(status_code=422, detail="interactive_body and interactive_buttons are required") + result = await client.send_interactive_buttons(recipient, text, buttons, reply_to=reply_to) + body = text + message_type = "interactive" + elif message_type == "reaction": + target = str(payload.get("reaction_message_id") or "") + emoji = str(payload.get("reaction_emoji") or "") + if not target or not emoji: + raise HTTPException(status_code=422, detail="reaction_message_id and reaction_emoji are required") + result = await client.send_reaction(recipient, target, emoji) + body = 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 + try: + from_number = normalize_wa_number(phone.display_phone or "") + except ValueError: + from_number = phone.display_phone or phone.phone_number_id + 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, + from_number=from_number, + user_id=user.id, + phone_number_id=phone.phone_number_id, + ) + return serialize_message(message) + + +async def download_account_media(db: Session, user: UserAccount, account_id: Any, media_id: str) -> tuple[bytes, str]: + account = _owned_account(db, user, account_id) + token = account.connection.access_token if account.connection else None + if not token: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reconnect this WhatsApp account before opening media") + phone_ids = [phone.phone_number_id for phone in account.phones] + if not phone_ids: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Media not found") + rows = db.scalars( + select(WhatsAppMessage).where( + WhatsAppMessage.user_id == user.id, + WhatsAppMessage.phone_number_id.in_(phone_ids), + ) + ).all() + allowed = False + for row in rows: + payload = row.payload if isinstance(row.payload, dict) else {} + media = payload.get(row.message_type) + if isinstance(media, dict) and str(media.get("id") or "") == media_id: + allowed = True + break + if not allowed: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Media not found") + client = WhatsAppGraphClient(access_token=token, phone_number_id=phone_ids[0] if phone_ids else None) + try: + return await client.download_media(media_id) + except WhatsAppGraphError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc diff --git a/frontend/src/features/whatsapp/CustomerWhatsAppInbox.tsx b/frontend/src/features/whatsapp/CustomerWhatsAppInbox.tsx new file mode 100644 index 0000000..d81bf61 --- /dev/null +++ b/frontend/src/features/whatsapp/CustomerWhatsAppInbox.tsx @@ -0,0 +1,510 @@ +import { + Check, + CheckCheck, + ChevronLeft, + Image as ImageIcon, + MessageCircle, + MessageSquareText, + MoreHorizontal, + Paperclip, + Plus, + RefreshCw, + Search, + Send, + Smile, + Star, + UserRound, + X, +} from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import type { FormEvent } from 'react' +import { API_BASE_URL } from '../../lib/apiBase' +import { + whatsappApi, + type CustomerWhatsAppMessage, + type CustomerWhatsAppSend, + type WhatsAppAccount, + type WhatsAppTemplateRow, +} from '../../lib/api' + +type SendMode = 'text' | 'template' | 'media' | 'buttons' +type ListFilter = 'all' | 'unread' | 'important' +type ProfileTab = 'details' | 'events' + +const REACTIONS = ['šŸ‘', 'ā¤ļø', 'šŸ˜‚', '😮', '😢', 'šŸ™'] + +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.startsWith('+') ? value : `+${digits || value}` +} + +function formatClock(value: string) { + return new Date(value).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) +} + +function formatChatTime(value: string) { + const date = new Date(value) + const now = new Date() + if (date.toDateString() === now.toDateString()) return formatClock(value) + const yesterday = new Date(now) + yesterday.setDate(now.getDate() - 1) + if (date.toDateString() === yesterday.toDateString()) return 'Yesterday' + return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) +} + +function formatDayLabel(value: string) { + const date = new Date(value) + const now = new Date() + if (date.toDateString() === now.toDateString()) return 'Today' + const yesterday = new Date(now) + yesterday.setDate(now.getDate() - 1) + if (date.toDateString() === yesterday.toDateString()) return 'Yesterday' + return date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }) +} + +function formatWhen(value: string) { + return new Date(value).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) +} + +function initials(name: string) { + const parts = name.split(/\s+/).filter(Boolean) + if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase() + return name.replace(/\D/g, '').slice(-2) || 'WA' +} + +export function CustomerWhatsAppInbox({ account, onBack }: { account: WhatsAppAccount; onBack: () => void }) { + const [messages, setMessages] = useState([]) + const [templates, setTemplates] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedPeer, setSelectedPeer] = useState(null) + const [startingNew, setStartingNew] = useState(false) + const [newPeer, setNewPeer] = useState('') + const [sendMode, setSendMode] = useState('text') + const [error, setError] = useState('') + const [sending, setSending] = useState(false) + const [textBody, setTextBody] = useState('') + const [replyTo, setReplyTo] = useState('') + const [replyPreview, setReplyPreview] = 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 [buttonBody, setButtonBody] = useState('') + const [buttonOne, setButtonOne] = useState('') + const [buttonTwo, setButtonTwo] = useState('') + const [buttonThree, setButtonThree] = useState('') + const [threadQuery, setThreadQuery] = useState('') + const [listFilter, setListFilter] = useState('all') + const [readPeers, setReadPeers] = useState>(new Set()) + const [starred, setStarred] = useState>(new Set()) + const [profileTab, setProfileTab] = useState('details') + const [moreOpen, setMoreOpen] = useState(false) + const [detailsId, setDetailsId] = useState(null) + const [reactFor, setReactFor] = useState(null) + const [emojiOpen, setEmojiOpen] = useState(false) + const chatEndRef = useRef(null) + const line = account.primary_phone?.display_phone || account.name + const canSend = account.messaging_status === 'active' && account.usable + + async function loadMessages() { + setMessages(await whatsappApi.messages(account.id)) + } + + useEffect(() => { + Promise.all([ + whatsappApi.messages(account.id), + whatsappApi.templates(account.id).catch(() => ({ available: false, templates: [] as WhatsAppTemplateRow[] })), + ]) + .then(([items, templatePayload]) => { + setMessages(items) + setTemplates(templatePayload.templates.filter((item) => item.status === 'APPROVED' || !item.status)) + }) + .catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load conversations')) + .finally(() => setLoading(false)) + const timer = window.setInterval(() => { + loadMessages().catch(() => undefined) + }, 8000) + return () => window.clearInterval(timer) + }, [account.id]) + + const threads = useMemo(() => { + const grouped = new Map() + for (const message of messages) { + if (message.message_type === 'status') continue + const peer = message.direction === 'inbound' ? message.from : message.to + if (!peer) continue + const list = grouped.get(peer) ?? [] + list.push(message) + grouped.set(peer, list) + } + return [...grouped.entries()] + .map(([peer, thread]) => { + const sorted = [...thread].sort((a, b) => a.created_at.localeCompare(b.created_at)) + const latest = sorted[sorted.length - 1] + const name = sorted.find((item) => item.contact_name)?.contact_name + const unread = readPeers.has(peer) ? 0 : sorted.filter((item) => item.direction === 'inbound' && item.status !== 'read').length + return { + peer, + title: name || formatWaNumber(peer), + phoneLabel: formatWaNumber(peer), + initials: initials(name || peer), + messages: sorted, + latest, + unread, + important: starred.has(peer), + } + }) + .sort((a, b) => b.latest.created_at.localeCompare(a.latest.created_at)) + }, [messages, readPeers, starred]) + + const visibleThreads = threads.filter((thread) => { + const query = threadQuery.trim().toLowerCase() + if (query && !`${thread.title} ${thread.phoneLabel} ${thread.latest.body || ''}`.toLowerCase().includes(query)) return false + if (listFilter === 'unread') return thread.unread > 0 + if (listFilter === 'important') return thread.important + return true + }) + const active = threads.find((thread) => thread.peer === selectedPeer) || null + const activeThread = active?.messages ?? [] + const unreadTotal = threads.reduce((sum, thread) => sum + thread.unread, 0) + const importantTotal = threads.filter((thread) => thread.important).length + + useEffect(() => { + chatEndRef.current?.scrollIntoView({ block: 'end' }) + }, [activeThread.length, selectedPeer]) + + function openThread(peer: string) { + setSelectedPeer(peer) + setStartingNew(false) + setReadPeers((current) => new Set(current).add(peer)) + setMoreOpen(false) + } + + async function sendReaction(message: CustomerWhatsAppMessage, emoji: string) { + if (!message.wa_message_id) return + setSending(true) + setError('') + try { + await whatsappApi.sendAccountMessage(account.id, { + to: message.from, + message_type: 'reaction', + reaction_message_id: message.wa_message_id, + reaction_emoji: emoji, + }) + setReactFor(null) + await loadMessages() + } catch (reason) { + setError(reason instanceof Error ? reason.message : 'Unable to send reaction') + } finally { + setSending(false) + } + } + + async function sendMessage(event: FormEvent) { + event.preventDefault() + const peer = active?.peer || newPeer + if (!peer) { + setError('Enter a WhatsApp number') + return + } + setSending(true) + setError('') + const payload: CustomerWhatsAppSend = { to: peer, message_type: sendMode === 'buttons' ? 'interactive_buttons' : sendMode } + if (replyTo) payload.reply_to = replyTo + if (sendMode === 'text') payload.text = textBody.trim() + if (sendMode === 'template') { + payload.template_name = templateName + payload.template_language = templateLanguage + } + if (sendMode === 'media') { + payload.message_type = mediaType + payload.media_link = mediaLink.trim() + payload.media_caption = mediaCaption.trim() + } + if (sendMode === 'buttons') { + payload.interactive_body = buttonBody.trim() + payload.interactive_buttons = [buttonOne, buttonTwo, buttonThree] + .map((title) => title.trim()) + .filter(Boolean) + .map((title, index) => ({ id: `btn_${index + 1}`, title })) + } + try { + const sent = await whatsappApi.sendAccountMessage(account.id, payload) + setSelectedPeer(sent.to) + setStartingNew(false) + setNewPeer('') + setTextBody('') + setButtonBody('') + setMediaCaption('') + setReplyTo('') + setReplyPreview('') + setSendMode('text') + await loadMessages() + } catch (reason) { + setError(reason instanceof Error ? reason.message : 'Unable to send message') + } finally { + setSending(false) + } + } + + function mediaUrl(mediaId: string) { + return `${API_BASE_URL}/api/v1/whatsapp/accounts/${account.id}/media/${mediaId}` + } + + if (loading) return

Loading WhatsApp conversations…

+ + return ( +
+
+
+ +

{account.business_name || account.name}

+

Conversations on {formatWaNumber(line)}.

+
+
+ {canSend ? 'Messaging' : 'Messaging unavailable'} + +
+
+ {!canSend &&

{account.last_error || 'You can read messages already delivered here. Sending stays off until Meta connects this number.'}

} +
+ + +
+
+ {active ? ( + <> + {active.initials} +
+ {active.title} + WhatsApp +
+ + ) : ( +
+ {startingNew ? 'New conversation' : 'Select a conversation'} + {startingNew ? 'Enter a WhatsApp number' : formatWaNumber(line)} +
+ )} +
+ +
+ + {moreOpen && active && ( +
+ +
+ )} +
+
+
+
+ {!active && !startingNew &&

Messages for this WhatsApp number appear here.

} + {activeThread.map((item, index) => { + const previous = activeThread[index - 1] + const showDay = !previous || new Date(previous.created_at).toDateString() !== new Date(item.created_at).toDateString() + const outbound = item.direction === 'outbound' + return ( +
+ {showDay &&
{formatDayLabel(item.created_at)}
} +
+
+ {item.media?.id && ['image', 'sticker'].includes(item.message_type) && } + {item.media?.id && item.message_type === 'video' &&
+
+ {!outbound && } + {!outbound && item.wa_message_id && } + + {reactFor === item.id && ( + + {REACTIONS.map((emoji) => )} + + )} +
+
+
+ ) + })} +
+
+
void sendMessage(event)}> +
+ {(['text', 'template', 'media', 'buttons'] as const).map((id) => ( + + ))} +
+ {replyPreview && ( +
+
Replying

{replyPreview}

+ +
+ )} + {(startingNew || !active) && setNewPeer(event.target.value)} placeholder="Recipient number, e.g. 919453226814" />} + {sendMode === 'template' && ( +
+ + setTemplateLanguage(event.target.value)} aria-label="Language code" /> +
+ )} + {sendMode === 'media' && ( +
+ + setMediaLink(event.target.value)} placeholder="Public file link" required /> +
+ )} + {sendMode === 'buttons' && ( +
+ setButtonOne(event.target.value)} placeholder="Button 1" required /> + setButtonTwo(event.target.value)} placeholder="Button 2" /> + setButtonThree(event.target.value)} placeholder="Button 3" /> +
+ )} +
+