meta whatsapp chat

This commit is contained in:
debu-2 2026-10-08 00:50:36 +05:30
parent 54f75387eb
commit 5ced0d0812
10 changed files with 1015 additions and 25 deletions

View file

@ -63,6 +63,17 @@ async def lifespan(_: FastAPI):
"ADD COLUMN IF NOT EXISTS monthly_outbound_price NUMERIC(10,2) NOT NULL DEFAULT 0" "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 yield

View file

@ -536,6 +536,9 @@ class WhatsAppMessage(Base):
message_type: Mapped[str] = mapped_column(String(24), default="text") message_type: Mapped[str] = mapped_column(String(24), default="text")
status: Mapped[str] = mapped_column(String(24), default="received", index=True) status: Mapped[str] = mapped_column(String(24), default="received", index=True)
payload: Mapped[dict | None] = mapped_column(JSON, nullable=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) 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) quality: Mapped[str | None] = mapped_column(String(24), nullable=True)
status: Mapped[str] = mapped_column(String(32), default="pending") status: Mapped[str] = mapped_column(String(32), default="pending")
code_verification_status: Mapped[str | None] = mapped_column(String(32), nullable=True) 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") account: Mapped[WhatsAppBusinessAccount] = relationship(back_populates="phones")

View file

@ -629,7 +629,12 @@ def admin_list_whatsapp_messages(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: str = Depends(current_admin), _: str = Depends(current_admin),
) -> list[dict]: ) -> 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: if peer:
try: try:
normalized_peer = normalize_wa_number(peer) normalized_peer = normalize_wa_number(peer)

View file

@ -20,8 +20,12 @@ from .whatsapp_onboarding import (
customer_token_for_phone, customer_token_for_phone,
developer_config, developer_config,
disconnect_account, disconnect_account,
download_account_media,
list_account_messages,
list_accounts, list_accounts,
mask_identifier, mask_identifier,
register_phone_number,
send_account_message,
list_templates, list_templates,
refresh_account, refresh_account,
signup_public_config, signup_public_config,
@ -150,6 +154,26 @@ class SendWhatsAppTextRequest(BaseModel):
phone_number_id: str = Field(min_length=4, max_length=64) 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): class SignupCompleteRequest(BaseModel):
event: str = Field(min_length=1, max_length=80) event: str = Field(min_length=1, max_length=80)
code: str | None = Field(default=None, max_length=2048) 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) 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") @router.post("/accounts/{account_id}/disconnect")
async def whatsapp_account_disconnect( async def whatsapp_account_disconnect(
account_id: str, account_id: str,

View file

@ -5,7 +5,8 @@ from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .config import settings 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}$") PHONE_DIGITS_RE = re.compile(r"^\d{10,15}$")
@ -50,6 +51,7 @@ def serialize_message(message: WhatsAppMessage) -> dict:
"message_type": message.message_type, "message_type": message.message_type,
"status": message.status, "status": message.status,
"media": media, "media": media,
"contact_name": message.contact_name,
"created_at": message.created_at, "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": if payload.get("object") != "whatsapp_business_account":
return 0 return 0
business = business_phone() platform_number = business_phone()
platform_phone_id = resolved_whatsapp_phone_number_id(db)
created = 0 created = 0
for entry in payload.get("entry", []): for entry in payload.get("entry", []):
for change in entry.get("changes", []): for change in entry.get("changes", []):
if change.get("field") != "messages": if change.get("field") != "messages":
continue continue
value = change.get("value", {}) 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", []): for message in value.get("messages", []):
from_number = message.get("from") or "" from_number = message.get("from") or ""
body = _extract_text_body(message) body = _extract_text_body(message)
@ -108,11 +136,14 @@ def record_inbound_messages(db: Session, payload: dict[str, Any]) -> int:
direction="inbound", direction="inbound",
wa_message_id=message.get("id"), wa_message_id=message.get("id"),
from_number=from_number, from_number=from_number,
to_number=business, to_number=line_number,
body=body, body=body,
message_type=message.get("type") or "text", message_type=message.get("type") or "text",
status="received", status="received",
payload=message, 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 created += 1
@ -130,12 +161,14 @@ def record_inbound_messages(db: Session, payload: dict[str, Any]) -> int:
WhatsAppMessage( WhatsAppMessage(
direction="outbound", direction="outbound",
wa_message_id=wa_id, wa_message_id=wa_id,
from_number=business, from_number=line_number,
to_number=status.get("recipient_id") or "", to_number=status.get("recipient_id") or "",
body=None, body=None,
message_type="status", message_type="status",
status=status.get("status") or "unknown", status=status.get("status") or "unknown",
payload=status, payload=status,
user_id=owner_id,
phone_number_id=phone_number_id if owner_id else None,
) )
) )
created += 1 created += 1
@ -153,16 +186,21 @@ def record_outbound_message(
wa_message_id: str | None, wa_message_id: str | None,
status: str = "sent", status: str = "sent",
payload: dict | None = None, payload: dict | None = None,
from_number: str | None = None,
user_id: Any = None,
phone_number_id: str | None = None,
) -> WhatsAppMessage: ) -> WhatsAppMessage:
message = WhatsAppMessage( message = WhatsAppMessage(
direction="outbound", direction="outbound",
wa_message_id=wa_message_id, wa_message_id=wa_message_id,
from_number=business_phone(), from_number=from_number or business_phone(),
to_number=to_number, to_number=to_number,
body=body, body=body,
message_type=message_type, message_type=message_type,
status=status, status=status,
payload=payload, payload=payload,
user_id=user_id,
phone_number_id=phone_number_id,
) )
db.add(message) db.add(message)
db.commit() db.commit()

View file

@ -8,6 +8,7 @@ addressed by another tenant.
from __future__ import annotations from __future__ import annotations
import logging import logging
import secrets
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
@ -22,11 +23,14 @@ from .models import (
UserAccount, UserAccount,
WhatsAppBusinessAccount, WhatsAppBusinessAccount,
WhatsAppConnectionAudit, WhatsAppConnectionAudit,
WhatsAppMessage,
WhatsAppMetaConnection, WhatsAppMetaConnection,
WhatsAppPhoneAsset, WhatsAppPhoneAsset,
WhatsAppWebhookEvent, WhatsAppWebhookEvent,
) )
from .whatsapp_config import resolved_whatsapp_app_secret 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__) 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]: def _serialize_phone(phone: WhatsAppPhoneAsset) -> dict[str, Any]:
verified = (phone.code_verification_status or "").upper()
return { return {
"id": str(phone.id), "id": str(phone.id),
"phone_number_id": phone.phone_number_id, "phone_number_id": phone.phone_number_id,
@ -207,6 +212,8 @@ def _serialize_phone(phone: WhatsAppPhoneAsset) -> dict[str, Any]:
"quality": phone.quality, "quality": phone.quality,
"status": phone.status, "status": phone.status,
"code_verification_status": phone.code_verification_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") 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( async def _provision_waba(
db: Session, db: Session,
user: UserAccount, user: UserAccount,
@ -416,29 +492,31 @@ async def _provision_waba(
account.last_error = str(exc.detail)[:500] account.last_error = str(exc.detail)[:500]
_apply_phones(db, user, account, phones, session_phone_id) _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) subscribed = await _subscribe(token, waba_id)
template_count, templates_ok = await _template_count(token, waba_id) template_count, templates_ok = await _template_count(token, waba_id)
account.template_count = template_count account.template_count = template_count
account.templates_status = "active" if templates_ok else "unavailable" account.templates_status = "active" if templates_ok else "unavailable"
account.webhooks_status = "active" if subscribed else "unavailable" account.webhooks_status = "active" if subscribed else "unavailable"
_set_account_health(db, user, account)
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,
)
return account return account
@ -547,6 +625,49 @@ async def refresh_account(db: Session, user: UserAccount, account_id: Any) -> di
return serialize_account(account) 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]: async def disconnect_account(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]:
account = _owned_account(db, user, account_id) account = _owned_account(db, user, account_id)
connection = account.connection connection = account.connection
@ -692,3 +813,133 @@ def customer_token_for_phone(db: Session, user: UserAccount, phone_number_id: st
if not token: if not token:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reconnect this WhatsApp account before sending") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reconnect this WhatsApp account before sending")
return token, phone.phone_number_id 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

View file

@ -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<CustomerWhatsAppMessage[]>([])
const [templates, setTemplates] = useState<WhatsAppTemplateRow[]>([])
const [loading, setLoading] = useState(true)
const [selectedPeer, setSelectedPeer] = useState<string | null>(null)
const [startingNew, setStartingNew] = useState(false)
const [newPeer, setNewPeer] = useState('')
const [sendMode, setSendMode] = useState<SendMode>('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<ListFilter>('all')
const [readPeers, setReadPeers] = useState<Set<string>>(new Set())
const [starred, setStarred] = useState<Set<string>>(new Set())
const [profileTab, setProfileTab] = useState<ProfileTab>('details')
const [moreOpen, setMoreOpen] = useState(false)
const [detailsId, setDetailsId] = useState<string | null>(null)
const [reactFor, setReactFor] = useState<string | null>(null)
const [emojiOpen, setEmojiOpen] = useState(false)
const chatEndRef = useRef<HTMLDivElement | null>(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<string, CustomerWhatsAppMessage[]>()
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 <p className="explorer-loading">Loading WhatsApp conversations…</p>
return (
<div className="wa-page wa-customer">
<div className="wa-page-title">
<div>
<button type="button" className="wa-biz-back" onClick={onBack}><ChevronLeft size={16} /> Accounts</button>
<h1><MessageCircle size={20} /> {account.business_name || account.name}</h1>
<p>Conversations on {formatWaNumber(line)}.</p>
</div>
<div className="wa-topbar-actions">
<span className={canSend ? 'wa-connected' : 'wa-connected pending'}><i />{canSend ? 'Messaging' : 'Messaging unavailable'}</span>
<button type="button" className="wa-ghost" onClick={() => void loadMessages()}><RefreshCw size={14} /> Refresh</button>
</div>
</div>
{!canSend && <p className="wa-biz-notice">{account.last_error || 'You can read messages already delivered here. Sending stays off until Meta connects this number.'}</p>}
<div className="wa-inbox">
<aside className="wa-list">
<div className="wa-list-head">
<h2>Conversations</h2>
<button type="button" className="wa-icon-btn" aria-label="New conversation" onClick={() => { setStartingNew(true); setSelectedPeer(null) }}>
<Plus size={14} />
</button>
</div>
<label className="wa-search">
<Search size={14} />
<input value={threadQuery} onChange={(event) => setThreadQuery(event.target.value)} placeholder="Search by name, phone or message…" />
</label>
<div className="wa-filters">
<button type="button" className={listFilter === 'all' ? 'active' : ''} onClick={() => setListFilter('all')}>All <em>{threads.length}</em></button>
<button type="button" className={listFilter === 'unread' ? 'active' : ''} onClick={() => setListFilter('unread')}>Unread <em>{unreadTotal}</em></button>
<button type="button" className={listFilter === 'important' ? 'active' : ''} onClick={() => setListFilter('important')}>Important <em>{importantTotal}</em></button>
</div>
<div className="wa-thread-list">
{visibleThreads.length === 0 && <p className="wa-empty">No conversations yet.</p>}
{visibleThreads.map((thread) => (
<button key={thread.peer} type="button" className={`wa-thread ${selectedPeer === thread.peer ? 'active' : ''}`} onClick={() => openThread(thread.peer)}>
<span className="wa-avatar wa-avatar-green">{thread.initials}</span>
<span className="wa-thread-copy">
<strong>{thread.title}</strong>
<span>{thread.latest.body || 'Attachment'}</span>
</span>
<span className="wa-thread-meta">
<small>{formatChatTime(thread.latest.created_at)}</small>
{thread.unread > 0 && <em>{thread.unread}</em>}
</span>
</button>
))}
</div>
</aside>
<section className="wa-chat">
<div className="wa-chat-head">
{active ? (
<>
<span className="wa-avatar wa-avatar-green">{active.initials}</span>
<div className="wa-chat-id">
<strong>{active.title}</strong>
<small>WhatsApp</small>
</div>
</>
) : (
<div className="wa-chat-id">
<strong>{startingNew ? 'New conversation' : 'Select a conversation'}</strong>
<small>{startingNew ? 'Enter a WhatsApp number' : formatWaNumber(line)}</small>
</div>
)}
<div className="wa-chat-tools">
<button
type="button"
className={`wa-tool ${active && starred.has(active.peer) ? 'on' : ''}`}
onClick={() => {
if (!active) return
setStarred((current) => {
const next = new Set(current)
if (next.has(active.peer)) next.delete(active.peer)
else next.add(active.peer)
return next
})
}}
>
<Star size={14} /> Star
</button>
<div className="wa-pop">
<button type="button" className="wa-tool" onClick={() => setMoreOpen((value) => !value)}><MoreHorizontal size={14} /> More</button>
{moreOpen && active && (
<div className="wa-menu">
<button type="button" onClick={() => { navigator.clipboard.writeText(active.phoneLabel); setMoreOpen(false) }}>Copy number</button>
</div>
)}
</div>
</div>
</div>
<div className="wa-canvas">
{!active && !startingNew && <p className="wa-empty">Messages for this WhatsApp number appear here.</p>}
{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 (
<div key={item.id}>
{showDay && <div className="wa-day">{formatDayLabel(item.created_at)}</div>}
<div className={`wa-bubble-row ${outbound ? 'out' : 'in'}`}>
<div className={`wa-bubble ${outbound ? 'out' : 'in'}`}>
{item.media?.id && ['image', 'sticker'].includes(item.message_type) && <img src={mediaUrl(item.media.id)} alt="" className="wa-media" />}
{item.media?.id && item.message_type === 'video' && <video src={mediaUrl(item.media.id)} controls className="wa-media" />}
{item.media?.id && item.message_type === 'audio' && <audio src={mediaUrl(item.media.id)} controls />}
{item.media?.id && item.message_type === 'document' && <a href={mediaUrl(item.media.id)} target="_blank" rel="noreferrer">Open document</a>}
{item.message_type === 'template' && <em className="wa-kind">Template</em>}
{item.body && <p>{item.body}</p>}
<footer>
<time>{formatClock(item.created_at)}</time>
{outbound && (item.status === 'delivered' || item.status === 'read' ? <CheckCheck size={13} className={item.status === 'read' ? 'read' : ''} /> : <Check size={13} />)}
</footer>
{detailsId === item.id && (
<div className="wa-inspector">
<strong>Message details</strong>
<span>Status · {item.status}</span>
<span>Type · {item.message_type.replace(/_/g, ' ')}</span>
</div>
)}
</div>
<div className="wa-hover">
{!outbound && <button type="button" onClick={() => { setReplyTo(item.wa_message_id || ''); setReplyPreview(item.body || 'Attachment'); setSendMode('text') }}>Reply</button>}
{!outbound && item.wa_message_id && <button type="button" onClick={() => setReactFor(reactFor === item.id ? null : item.id)}>React</button>}
<button type="button" onClick={() => setDetailsId(detailsId === item.id ? null : item.id)} aria-label="Message details"><MoreHorizontal size={14} /></button>
{reactFor === item.id && (
<span className="wa-reacts">
{REACTIONS.map((emoji) => <button key={emoji} type="button" onClick={() => void sendReaction(item, emoji)}>{emoji}</button>)}
</span>
)}
</div>
</div>
</div>
)
})}
<div ref={chatEndRef} />
</div>
<form className="wa-composer" onSubmit={(event) => void sendMessage(event)}>
<div className="wa-modes">
{(['text', 'template', 'media', 'buttons'] as const).map((id) => (
<button key={id} type="button" className={sendMode === id ? 'active' : ''} onClick={() => setSendMode(id)}>{id[0].toUpperCase() + id.slice(1)}</button>
))}
</div>
{replyPreview && (
<div className="wa-quote">
<div><strong>Replying</strong><p>{replyPreview}</p></div>
<button type="button" aria-label="Cancel reply" onClick={() => { setReplyPreview(''); setReplyTo('') }}><X size={14} /></button>
</div>
)}
{(startingNew || !active) && <input className="wa-new-peer" value={newPeer} onChange={(event) => setNewPeer(event.target.value)} placeholder="Recipient number, e.g. 919453226814" />}
{sendMode === 'template' && (
<div className="wa-extra">
<select value={templateName} onChange={(event) => setTemplateName(event.target.value)} required>
<option value="">Choose a template</option>
{templates.map((template) => <option key={`${template.name}-${template.language}`} value={template.name || ''}>{template.name} ({template.language})</option>)}
</select>
<input value={templateLanguage} onChange={(event) => setTemplateLanguage(event.target.value)} aria-label="Language code" />
</div>
)}
{sendMode === 'media' && (
<div className="wa-extra">
<select value={mediaType} onChange={(event) => setMediaType(event.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>
<input value={mediaLink} onChange={(event) => setMediaLink(event.target.value)} placeholder="Public file link" required />
</div>
)}
{sendMode === 'buttons' && (
<div className="wa-extra">
<input value={buttonOne} onChange={(event) => setButtonOne(event.target.value)} placeholder="Button 1" required />
<input value={buttonTwo} onChange={(event) => setButtonTwo(event.target.value)} placeholder="Button 2" />
<input value={buttonThree} onChange={(event) => setButtonThree(event.target.value)} placeholder="Button 3" />
</div>
)}
<div className="wa-compose-box">
<textarea
rows={2}
value={sendMode === 'media' ? mediaCaption : sendMode === 'buttons' ? buttonBody : textBody}
onChange={(event) => {
if (sendMode === 'media') setMediaCaption(event.target.value)
else if (sendMode === 'buttons') setButtonBody(event.target.value)
else setTextBody(event.target.value)
}}
placeholder={canSend ? 'Type a message…' : 'Sending is unavailable until this number is connected'}
required={sendMode !== 'template'}
disabled={!canSend}
/>
</div>
<div className="wa-compose-bar">
<div className="wa-compose-icons">
<button type="button" aria-label="Emoji" onClick={() => setEmojiOpen((value) => !value)}><Smile size={16} /></button>
<button type="button" aria-label="Attach file" onClick={() => { setSendMode('media'); setMediaType('document') }}><Paperclip size={16} /></button>
<button type="button" aria-label="Image" onClick={() => { setSendMode('media'); setMediaType('image') }}><ImageIcon size={16} /></button>
{emojiOpen && (
<span className="wa-reacts static">
{REACTIONS.map((emoji) => <button key={emoji} type="button" onClick={() => { setTextBody((value) => `${value}${emoji}`); setEmojiOpen(false); setSendMode('text') }}>{emoji}</button>)}
</span>
)}
</div>
{error && <p className="wa-error">{error}</p>}
<button className="wa-send" type="submit" disabled={sending || !canSend}><Send size={14} /> {sending ? 'Sending' : 'Send'}</button>
</div>
</form>
</section>
<aside className="wa-profile">
<div className="wa-profile-hero">
<span className="wa-avatar large wa-avatar-green">{active ? active.initials : <UserRound size={22} />}</span>
<div>
<strong className="wa-name">{active?.title || 'No contact'}</strong>
<small>{active ? 'WhatsApp' : 'Select a conversation'}</small>
</div>
</div>
<div className="wa-tabs">
{(['details', 'events'] as const).map((id) => (
<button key={id} type="button" className={profileTab === id ? 'active' : ''} onClick={() => setProfileTab(id)}>{id[0].toUpperCase() + id.slice(1)}</button>
))}
</div>
{profileTab === 'details' && active && (
<dl className="wa-fields">
<div><dt>Phone</dt><dd>{active.phoneLabel}</dd></div>
<div><dt>Status</dt><dd>{active.latest.status}</dd></div>
<div><dt>Messages</dt><dd>{active.messages.length}</dd></div>
<div><dt>First seen</dt><dd>{formatWhen(active.messages[0].created_at)}</dd></div>
<div><dt>Last message</dt><dd>{formatWhen(active.latest.created_at)}</dd></div>
<div><dt>Source</dt><dd>WhatsApp</dd></div>
</dl>
)}
{profileTab === 'details' && !active && <p className="wa-empty">Open a conversation to see the customer.</p>}
{profileTab === 'events' && (
<ul className="wa-events">
{activeThread.length === 0 && <li><strong>No messages yet</strong></li>}
{[...activeThread].reverse().map((item) => (
<li key={item.id}><strong>{item.direction === 'inbound' ? 'Customer message' : 'Reply sent'}</strong><span>{formatWhen(item.created_at)}</span></li>
))}
</ul>
)}
<div className="wa-quick">
<h3>Quick actions</h3>
<button type="button" onClick={() => setSendMode('template')}><MessageSquareText size={14} /> Send template</button>
<button type="button" onClick={() => setSendMode('media')}><ImageIcon size={14} /> Send media</button>
</div>
</aside>
</div>
</div>
)
}

View file

@ -1,4 +1,5 @@
import { Check, ChevronLeft, Copy, ExternalLink, Loader2, MessageCircle, MoreHorizontal, Plus, RefreshCw } from 'lucide-react' import { Check, ChevronLeft, Copy, ExternalLink, Loader2, MessageCircle, MoreHorizontal, Plus, RefreshCw } from 'lucide-react'
import { CustomerWhatsAppInbox } from './CustomerWhatsAppInbox'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { import {
whatsappApi, whatsappApi,
@ -10,7 +11,7 @@ import {
type WhatsAppTemplateRow, type WhatsAppTemplateRow,
} from '../../lib/api' } from '../../lib/api'
type Phase = 'loading' | 'landing' | 'list' | 'provisioning' | 'result' | 'detail' type Phase = 'loading' | 'landing' | 'list' | 'provisioning' | 'result' | 'detail' | 'inbox'
type DetailTab = 'overview' | 'numbers' | 'templates' | 'configuration' | 'logs' type DetailTab = 'overview' | 'numbers' | 'templates' | 'configuration' | 'logs'
type SignupSession = { type SignupSession = {
@ -137,6 +138,7 @@ export function WhatsAppSetupPage() {
const [developer, setDeveloper] = useState<WhatsAppDeveloperConfig | null>(null) const [developer, setDeveloper] = useState<WhatsAppDeveloperConfig | null>(null)
const [logs, setLogs] = useState<WhatsAppAccountLogs | null>(null) const [logs, setLogs] = useState<WhatsAppAccountLogs | null>(null)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [pins, setPins] = useState<Record<string, string>>({})
const sessionRef = useRef<SignupSession | null>(null) const sessionRef = useRef<SignupSession | null>(null)
const codeRef = useRef<string | null>(null) const codeRef = useRef<string | null>(null)
const finishingRef = useRef(false) const finishingRef = useRef(false)
@ -330,6 +332,20 @@ export function WhatsAppSetupPage() {
} }
} }
async function registerPhone(phoneId: string) {
if (!selected) return
setBusy(true)
setError('')
try {
replaceAccount(await whatsappApi.registerPhone(selected.id, phoneId, pins[phoneId]))
setNotice('Urmi asked Meta to register this number for Cloud API.')
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to register this number')
} finally {
setBusy(false)
}
}
async function confirmDisconnect() { async function confirmDisconnect() {
if (!disconnecting) return if (!disconnecting) return
setBusy(true) setBusy(true)
@ -355,6 +371,16 @@ export function WhatsAppSetupPage() {
const resultAccount = result?.accounts[0] const resultAccount = result?.accounts[0]
function openInbox(accountId: string) {
setSelectedId(accountId)
setMenuId(null)
setPhase('inbox')
}
if (phase === 'inbox' && selected) {
return <CustomerWhatsAppInbox account={selected} onBack={() => setPhase('list')} />
}
return ( return (
<div className="wa-biz"> <div className="wa-biz">
{phase === 'loading' && <p className="explorer-loading">Loading WhatsApp Business…</p>} {phase === 'loading' && <p className="explorer-loading">Loading WhatsApp Business…</p>}
@ -470,6 +496,11 @@ export function WhatsAppSetupPage() {
<StatusDot status={account.status} /> <StatusDot status={account.status} />
</div> </div>
<div className="wa-biz-menu"> <div className="wa-biz-menu">
{account.webhooks_status === 'active' && (
<button type="button" className="wa-chat-launch" onClick={() => openInbox(account.id)}>
<MessageCircle size={15} /> Chat
</button>
)}
<button className="secondary-button" onClick={() => { setSelectedId(account.id); setTab('overview'); setPhase('detail') }}>Open</button> <button className="secondary-button" onClick={() => { setSelectedId(account.id); setTab('overview'); setPhase('detail') }}>Open</button>
<button className="icon-button" aria-label="Account actions" onClick={() => setMenuId(menuId === account.id ? null : account.id)}> <button className="icon-button" aria-label="Account actions" onClick={() => setMenuId(menuId === account.id ? null : account.id)}>
<MoreHorizontal size={16} /> <MoreHorizontal size={16} />
@ -520,6 +551,11 @@ export function WhatsAppSetupPage() {
<StatusDot status={selected.status} /> <StatusDot status={selected.status} />
</div> </div>
<div className="wa-biz-actions"> <div className="wa-biz-actions">
{selected.webhooks_status === 'active' && (
<button type="button" className="wa-chat-launch" onClick={() => openInbox(selected.id)}>
<MessageCircle size={15} /> Chat
</button>
)}
<button className="secondary-button" onClick={() => void refreshSelected()} disabled={busy}> <button className="secondary-button" onClick={() => void refreshSelected()} disabled={busy}>
<RefreshCw size={14} /> Refresh <RefreshCw size={14} /> Refresh
</button> </button>
@ -565,6 +601,7 @@ export function WhatsAppSetupPage() {
{tab === 'numbers' && ( {tab === 'numbers' && (
<section className="wa-biz-card"> <section className="wa-biz-card">
<h2>Phone numbers</h2> <h2>Phone numbers</h2>
<p className="wa-biz-muted">Messaging stays unavailable until Meta reports this number as connected for Cloud API. Pending means the number is on the account but not registered yet. Quality stays unknown until then.</p>
{selected.phones.length === 0 && <p className="wa-biz-muted">Meta has not returned a phone number for this account yet.</p>} {selected.phones.length === 0 && <p className="wa-biz-muted">Meta has not returned a phone number for this account yet.</p>}
<ul className="wa-biz-numbers"> <ul className="wa-biz-numbers">
{selected.phones.map((phone) => ( {selected.phones.map((phone) => (
@ -572,9 +609,24 @@ export function WhatsAppSetupPage() {
<div> <div>
<strong>{phone.display_phone || 'Number pending'}</strong> <strong>{phone.display_phone || 'Number pending'}</strong>
<small>{phone.verified_name || 'Display name pending'}</small> <small>{phone.verified_name || 'Display name pending'}</small>
{phone.needs_otp && <small>Complete the SMS or voice code in Meta, then refresh. Log this number out of the WhatsApp app first.</small>}
</div> </div>
<StatusDot status={phone.status} /> <StatusDot status={phone.status} />
<span>Quality: {phone.quality || '—'}</span> <span>Quality: {phone.quality || '—'}</span>
{phone.needs_registration && !phone.needs_otp && (
<div className="wa-biz-register">
<input
inputMode="numeric"
maxLength={6}
placeholder="6-digit PIN"
value={pins[phone.id] || ''}
onChange={(event) => setPins((current) => ({ ...current, [phone.id]: event.target.value.replace(/\D/g, '').slice(0, 6) }))}
/>
<button className="primary-button" type="button" disabled={busy} onClick={() => void registerPhone(phone.id)}>
Register for Cloud API
</button>
</div>
)}
</li> </li>
))} ))}
</ul> </ul>

View file

@ -1770,6 +1770,13 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-compose-icons > button { display: grid; place-items: center; width: 30px; height: 30px; border: 0; border-radius: 8px; background: transparent; color: #607089; } .wa-compose-icons > button { display: grid; place-items: center; width: 30px; height: 30px; border: 0; border-radius: 8px; background: transparent; color: #607089; }
.wa-compose-icons > button:hover { background: #f4f7fb; color: #1475ff; } .wa-compose-icons > button:hover { background: #f4f7fb; color: #1475ff; }
.wa-send { display: inline-flex; align-items: center; gap: 6px; border: 0; border-radius: 10px; padding: 9px 14px; background: #1475ff; color: white; font-size: 13px; font-weight: 700; } .wa-send { display: inline-flex; align-items: center; gap: 6px; border: 0; border-radius: 10px; padding: 9px 14px; background: #1475ff; color: white; font-size: 13px; font-weight: 700; }
.wa-chat-launch { display: inline-flex; align-items: center; gap: 6px; border: 0; border-radius: 999px; padding: 8px 14px; background: #25D366; color: #fff; font-size: 13px; font-weight: 700; box-shadow: 0 6px 14px rgba(37, 211, 102, .28); }
.wa-chat-launch:hover { background: #1ebe5d; }
.wa-customer .wa-send { background: #25D366; }
.wa-customer .wa-send:disabled { background: #b7e7c7; }
.wa-customer .wa-bubble.out { background: #d9fdd3; }
.wa-customer .wa-filters button.active, .wa-customer .wa-modes button.active { color: #128C7E; }
.wa-avatar-green { background: #128C7E; color: white; }
.wa-send:disabled { opacity: .55; } .wa-send:disabled { opacity: .55; }
.wa-error { margin: 0; color: #b42318; font-size: 12px; } .wa-error { margin: 0; color: #b42318; font-size: 12px; }
.wa-profile { display: flex; flex-direction: column; border-left: 1px solid #eef2f6; background: #fff; overflow: auto; } .wa-profile { display: flex; flex-direction: column; border-left: 1px solid #eef2f6; background: #fff; overflow: auto; }
@ -1879,6 +1886,8 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-biz-capabilities li { justify-content: space-between; } .wa-biz-capabilities li { justify-content: space-between; }
.wa-biz-numbers { margin: 8px 0 0; padding: 0; list-style: none; display: grid; gap: 10px; } .wa-biz-numbers { margin: 8px 0 0; padding: 0; list-style: none; display: grid; gap: 10px; }
.wa-biz-numbers li { display: grid; grid-template-columns: 1fr auto auto; gap: 16px; align-items: center; padding: 12px 0; border-top: 1px solid #edf0f4; } .wa-biz-numbers li { display: grid; grid-template-columns: 1fr auto auto; gap: 16px; align-items: center; padding: 12px 0; border-top: 1px solid #edf0f4; }
.wa-biz-register { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.wa-biz-register input { width: 140px; padding: 10px 12px; border: 1px solid #dfe5ed; border-radius: 8px; font-size: 13px; letter-spacing: .2em; }
.wa-biz-table { margin-top: 12px; } .wa-biz-table { margin-top: 12px; }
.wa-biz-table-head, .wa-biz-table-row { display: grid; grid-template-columns: 1.4fr .8fr .7fr .8fr .6fr; gap: 8px; padding: 10px 0; } .wa-biz-table-head, .wa-biz-table-row { display: grid; grid-template-columns: 1.4fr .8fr .7fr .8fr .6fr; gap: 8px; padding: 10px 0; }
.wa-biz-table-head { color: #8a95a7; font-size: 11px; font-weight: 700; text-transform: uppercase; } .wa-biz-table-head { color: #8a95a7; font-size: 11px; font-weight: 700; text-transform: uppercase; }

View file

@ -1138,6 +1138,8 @@ export type WhatsAppPhoneAsset = {
quality: string | null quality: string | null
status: string status: string
code_verification_status: string | null code_verification_status: string | null
needs_otp?: boolean
needs_registration?: boolean
} }
export type WhatsAppAccount = { export type WhatsAppAccount = {
@ -1188,6 +1190,36 @@ export type WhatsAppAccountLogs = {
events: { id: string; field: string; event_type: string; from_wa_id: string | null; status: string | null; created_at: string }[] events: { id: string; field: string; event_type: string; from_wa_id: string | null; status: string | null; created_at: string }[]
} }
export type CustomerWhatsAppMessage = {
id: string
direction: 'inbound' | 'outbound'
wa_message_id: string | null
from: string
to: string
body: string | null
message_type: string
status: string
contact_name?: string | null
media?: { id?: string; mime_type?: string; caption?: string; filename?: string } | null
created_at: string
}
export type CustomerWhatsAppSend = {
to: string
message_type?: string
text?: string
reply_to?: string
template_name?: string
template_language?: string
media_link?: string
media_caption?: string
media_filename?: string
interactive_body?: string
interactive_buttons?: { id: string; title: string }[]
reaction_message_id?: string
reaction_emoji?: string
}
export type WhatsAppWebhookEvent = { export type WhatsAppWebhookEvent = {
id: string id: string
waba_id: string | null waba_id: string | null
@ -1222,6 +1254,18 @@ export const whatsappApi = {
jsonRequest<WhatsAppAccount>(`/api/v1/whatsapp/accounts/${accountId}/refresh`, { method: 'POST' }), jsonRequest<WhatsAppAccount>(`/api/v1/whatsapp/accounts/${accountId}/refresh`, { method: 'POST' }),
disconnectAccount: (accountId: string) => disconnectAccount: (accountId: string) =>
jsonRequest<WhatsAppAccount>(`/api/v1/whatsapp/accounts/${accountId}/disconnect`, { method: 'POST' }), jsonRequest<WhatsAppAccount>(`/api/v1/whatsapp/accounts/${accountId}/disconnect`, { method: 'POST' }),
registerPhone: (accountId: string, phoneId: string, pin?: string) =>
jsonRequest<WhatsAppAccount>(`/api/v1/whatsapp/accounts/${accountId}/phones/${phoneId}/register`, {
method: 'POST',
body: JSON.stringify({ pin: pin || null }),
}),
messages: (accountId: string) =>
jsonRequest<CustomerWhatsAppMessage[]>(`/api/v1/whatsapp/accounts/${accountId}/messages`),
sendAccountMessage: (accountId: string, payload: CustomerWhatsAppSend) =>
jsonRequest<CustomerWhatsAppMessage>(`/api/v1/whatsapp/accounts/${accountId}/messages/send`, {
method: 'POST',
body: JSON.stringify(payload),
}),
templates: (accountId: string) => templates: (accountId: string) =>
jsonRequest<{ available: boolean; templates: WhatsAppTemplateRow[] }>(`/api/v1/whatsapp/accounts/${accountId}/templates`), jsonRequest<{ available: boolean; templates: WhatsAppTemplateRow[] }>(`/api/v1/whatsapp/accounts/${accountId}/templates`),
logs: (accountId: string) => jsonRequest<WhatsAppAccountLogs>(`/api/v1/whatsapp/accounts/${accountId}/logs`), logs: (accountId: string) => jsonRequest<WhatsAppAccountLogs>(`/api/v1/whatsapp/accounts/${accountId}/logs`),