privacy policy and business integration
This commit is contained in:
parent
141ca9fda3
commit
cd3751219d
16 changed files with 1592 additions and 30 deletions
|
|
@ -46,4 +46,5 @@ WHATSAPP_ACCESS_TOKEN=
|
||||||
WHATSAPP_PHONE_NUMBER_ID=
|
WHATSAPP_PHONE_NUMBER_ID=
|
||||||
WHATSAPP_BUSINESS_ACCOUNT_ID=
|
WHATSAPP_BUSINESS_ACCOUNT_ID=
|
||||||
META_APP_ID=
|
META_APP_ID=
|
||||||
|
WHATSAPP_BUSINESS_PHONE=919411257916
|
||||||
WHATSAPP_API_VERSION=v21.0
|
WHATSAPP_API_VERSION=v21.0
|
||||||
|
|
|
||||||
|
|
@ -64,4 +64,6 @@ WHATSAPP_PHONE_NUMBER_ID=
|
||||||
WHATSAPP_BUSINESS_ACCOUNT_ID=
|
WHATSAPP_BUSINESS_ACCOUNT_ID=
|
||||||
# Meta App ID
|
# Meta App ID
|
||||||
META_APP_ID=
|
META_APP_ID=
|
||||||
|
# Urmi business WhatsApp number (E.164 without +)
|
||||||
|
WHATSAPP_BUSINESS_PHONE=919411257916
|
||||||
WHATSAPP_API_VERSION=v21.0
|
WHATSAPP_API_VERSION=v21.0
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,8 @@ class Settings(BaseSettings):
|
||||||
whatsapp_phone_number_id: str = ""
|
whatsapp_phone_number_id: str = ""
|
||||||
whatsapp_business_account_id: str = ""
|
whatsapp_business_account_id: str = ""
|
||||||
meta_app_id: str = ""
|
meta_app_id: str = ""
|
||||||
# Public HTTPS base for webhook callback (no trailing slash), e.g. https://api.urmiai.com
|
whatsapp_business_phone: str = "919411257916"
|
||||||
|
# Public HTTPS base for webhook callback (no trailing slash), e.g. https://urmiai.com
|
||||||
public_api_base_url: str = ""
|
public_api_base_url: str = ""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Numeric, String, UniqueConstraint, func
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Numeric, String, Text, UniqueConstraint, func
|
||||||
from sqlalchemy.dialects.postgresql import JSON, UUID
|
from sqlalchemy.dialects.postgresql import JSON, UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
|
@ -508,3 +508,32 @@ class WhatsAppWebhookEvent(Base):
|
||||||
status: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
status: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
payload: Mapped[dict] = mapped_column(JSON)
|
payload: Mapped[dict] = mapped_column(JSON)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsAppMessage(Base):
|
||||||
|
"""Inbound and outbound WhatsApp messages for Urmi's business number."""
|
||||||
|
|
||||||
|
__tablename__ = "whatsapp_messages"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
direction: Mapped[str] = mapped_column(String(16), index=True)
|
||||||
|
wa_message_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||||
|
from_number: Mapped[str] = mapped_column(String(32), index=True)
|
||||||
|
to_number: Mapped[str] = mapped_column(String(32), index=True)
|
||||||
|
body: Mapped[str | None] = mapped_column(String(4096), nullable=True)
|
||||||
|
message_type: Mapped[str] = mapped_column(String(24), default="text")
|
||||||
|
status: Mapped[str] = mapped_column(String(24), default="received", index=True)
|
||||||
|
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformSetting(Base):
|
||||||
|
"""Runtime overrides for platform credentials (admin-editable)."""
|
||||||
|
|
||||||
|
__tablename__ = "platform_settings"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
value: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
|
||||||
17
backend/app/platform_settings.py
Normal file
17
backend/app/platform_settings.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .models import PlatformSetting
|
||||||
|
|
||||||
|
|
||||||
|
def get_setting(db: Session, key: str, default: str = "") -> str:
|
||||||
|
row = db.get(PlatformSetting, key)
|
||||||
|
return row.value if row and row.value else default
|
||||||
|
|
||||||
|
|
||||||
|
def set_setting(db: Session, key: str, value: str) -> None:
|
||||||
|
row = db.get(PlatformSetting, key)
|
||||||
|
if row:
|
||||||
|
row.value = value
|
||||||
|
else:
|
||||||
|
db.add(PlatformSetting(key=key, value=value))
|
||||||
|
db.commit()
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
from datetime import datetime, timezone
|
from typing import Literal
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
@ -22,9 +21,24 @@ from .models import (
|
||||||
TemplateConfig,
|
TemplateConfig,
|
||||||
UrmiOtpProject,
|
UrmiOtpProject,
|
||||||
UserAccount,
|
UserAccount,
|
||||||
|
WhatsAppMessage,
|
||||||
)
|
)
|
||||||
from .phone_number_validators import normalize_phone_number, validate_per_min_price, validate_price
|
from .phone_number_validators import normalize_phone_number, validate_per_min_price, validate_price
|
||||||
from .routes_urmiotp import serialize_project
|
from .routes_urmiotp import serialize_project
|
||||||
|
from .whatsapp_config import (
|
||||||
|
resolved_whatsapp_app_secret,
|
||||||
|
resolved_whatsapp_business_account_id,
|
||||||
|
resolved_whatsapp_phone_number_id,
|
||||||
|
whatsapp_ready_to_send,
|
||||||
|
)
|
||||||
|
from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError
|
||||||
|
from .whatsapp_messages import (
|
||||||
|
extract_wa_message_id,
|
||||||
|
normalize_wa_number,
|
||||||
|
record_outbound_message,
|
||||||
|
serialize_message,
|
||||||
|
)
|
||||||
|
from .platform_settings import set_setting
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
|
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
@ -516,3 +530,244 @@ def update_phone_number(
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(number)
|
db.refresh(number)
|
||||||
return serialize_admin_phone_number(number)
|
return serialize_admin_phone_number(number)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminWhatsAppSendRequest(BaseModel):
|
||||||
|
to: str = Field(min_length=8, max_length=20)
|
||||||
|
message_type: Literal[
|
||||||
|
"text",
|
||||||
|
"template",
|
||||||
|
"image",
|
||||||
|
"document",
|
||||||
|
"audio",
|
||||||
|
"video",
|
||||||
|
"interactive_buttons",
|
||||||
|
"interactive_list",
|
||||||
|
"reaction",
|
||||||
|
] = "text"
|
||||||
|
text: str | None = Field(default=None, max_length=4096)
|
||||||
|
reply_to: str | None = None
|
||||||
|
template_name: str | None = None
|
||||||
|
template_language: str = "en"
|
||||||
|
template_components: list[dict] | None = None
|
||||||
|
media_link: str | None = None
|
||||||
|
media_caption: str | None = None
|
||||||
|
media_filename: str | None = None
|
||||||
|
interactive_body: str | None = None
|
||||||
|
interactive_header: str | None = None
|
||||||
|
interactive_footer: str | None = None
|
||||||
|
interactive_buttons: list[dict[str, str]] | None = None
|
||||||
|
interactive_list_button: str | None = None
|
||||||
|
interactive_list_sections: list[dict] | None = None
|
||||||
|
reaction_message_id: str | None = None
|
||||||
|
reaction_emoji: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminWhatsAppConfigUpdate(BaseModel):
|
||||||
|
phone_number_id: str | None = None
|
||||||
|
business_account_id: str | None = None
|
||||||
|
app_secret: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _whatsapp_client(db: Session) -> WhatsAppGraphClient:
|
||||||
|
return WhatsAppGraphClient(
|
||||||
|
phone_number_id=resolved_whatsapp_phone_number_id(db),
|
||||||
|
business_account_id=resolved_whatsapp_business_account_id(db),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/whatsapp/config")
|
||||||
|
def admin_whatsapp_config(db: Session = Depends(get_db), _: str = Depends(current_admin)) -> dict:
|
||||||
|
return {
|
||||||
|
"business_phone": settings.whatsapp_business_phone,
|
||||||
|
"phone_number_id": resolved_whatsapp_phone_number_id(db) or None,
|
||||||
|
"business_account_id": resolved_whatsapp_business_account_id(db) or None,
|
||||||
|
"meta_app_id": settings.meta_app_id or None,
|
||||||
|
"access_token_configured": bool(settings.whatsapp_access_token),
|
||||||
|
"app_secret_configured": bool(resolved_whatsapp_app_secret(db)),
|
||||||
|
"webhook_url": settings.whatsapp_webhook_url,
|
||||||
|
"verify_token_configured": bool(settings.whatsapp_verify_token),
|
||||||
|
"ready_to_send": whatsapp_ready_to_send(db),
|
||||||
|
"supported_operations": [
|
||||||
|
"text",
|
||||||
|
"template",
|
||||||
|
"image",
|
||||||
|
"document",
|
||||||
|
"audio",
|
||||||
|
"video",
|
||||||
|
"interactive_buttons",
|
||||||
|
"interactive_list",
|
||||||
|
"reaction",
|
||||||
|
"mark_read",
|
||||||
|
"media_download",
|
||||||
|
"list_templates",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/whatsapp/config")
|
||||||
|
def admin_update_whatsapp_config(
|
||||||
|
payload: AdminWhatsAppConfigUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: str = Depends(current_admin),
|
||||||
|
) -> dict:
|
||||||
|
if payload.phone_number_id is not None:
|
||||||
|
set_setting(db, "whatsapp_phone_number_id", payload.phone_number_id.strip())
|
||||||
|
if payload.business_account_id is not None:
|
||||||
|
set_setting(db, "whatsapp_business_account_id", payload.business_account_id.strip())
|
||||||
|
if payload.app_secret is not None:
|
||||||
|
set_setting(db, "whatsapp_app_secret", payload.app_secret.strip())
|
||||||
|
return admin_whatsapp_config(db=db, _=_)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/whatsapp/messages")
|
||||||
|
def admin_list_whatsapp_messages(
|
||||||
|
limit: int = 50,
|
||||||
|
peer: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: str = Depends(current_admin),
|
||||||
|
) -> list[dict]:
|
||||||
|
query = select(WhatsAppMessage).order_by(WhatsAppMessage.created_at.desc()).limit(min(limit, 500))
|
||||||
|
if peer:
|
||||||
|
try:
|
||||||
|
normalized_peer = normalize_wa_number(peer)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||||
|
query = query.where(
|
||||||
|
(WhatsAppMessage.from_number == normalized_peer) | (WhatsAppMessage.to_number == normalized_peer)
|
||||||
|
)
|
||||||
|
messages = db.scalars(query).all()
|
||||||
|
return [serialize_message(message) for message in messages]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/whatsapp/templates")
|
||||||
|
async def admin_list_whatsapp_templates(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: str = Depends(current_admin),
|
||||||
|
) -> dict:
|
||||||
|
client = _whatsapp_client(db)
|
||||||
|
try:
|
||||||
|
return await client.list_templates()
|
||||||
|
except WhatsAppGraphError as exc:
|
||||||
|
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/whatsapp/media/{media_id}")
|
||||||
|
async def admin_download_whatsapp_media(
|
||||||
|
media_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: str = Depends(current_admin),
|
||||||
|
) -> Response:
|
||||||
|
client = _whatsapp_client(db)
|
||||||
|
try:
|
||||||
|
content, mime_type = await client.download_media(media_id)
|
||||||
|
except WhatsAppGraphError as exc:
|
||||||
|
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||||
|
return Response(content=content, media_type=mime_type)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/whatsapp/messages/{wa_message_id}/read")
|
||||||
|
async def admin_mark_whatsapp_read(
|
||||||
|
wa_message_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: str = Depends(current_admin),
|
||||||
|
) -> dict:
|
||||||
|
client = _whatsapp_client(db)
|
||||||
|
try:
|
||||||
|
result = await client.mark_read(wa_message_id)
|
||||||
|
except WhatsAppGraphError as exc:
|
||||||
|
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/whatsapp/messages/send")
|
||||||
|
async def admin_send_whatsapp_message(
|
||||||
|
payload: AdminWhatsAppSendRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: str = Depends(current_admin),
|
||||||
|
) -> dict:
|
||||||
|
try:
|
||||||
|
recipient = normalize_wa_number(payload.to)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
client = _whatsapp_client(db)
|
||||||
|
message_type = payload.message_type
|
||||||
|
try:
|
||||||
|
if message_type == "text":
|
||||||
|
if not payload.text:
|
||||||
|
raise HTTPException(status_code=422, detail="text is required")
|
||||||
|
result = await client.send_text(recipient, payload.text.strip(), reply_to=payload.reply_to)
|
||||||
|
body = payload.text.strip()
|
||||||
|
elif message_type == "template":
|
||||||
|
if not payload.template_name:
|
||||||
|
raise HTTPException(status_code=422, detail="template_name is required")
|
||||||
|
result = await client.send_template(
|
||||||
|
recipient,
|
||||||
|
payload.template_name,
|
||||||
|
language_code=payload.template_language,
|
||||||
|
components=payload.template_components,
|
||||||
|
)
|
||||||
|
body = f"Template: {payload.template_name}"
|
||||||
|
elif message_type in {"image", "document", "audio", "video"}:
|
||||||
|
if not payload.media_link:
|
||||||
|
raise HTTPException(status_code=422, detail="media_link is required")
|
||||||
|
result = await client.send_media(
|
||||||
|
recipient,
|
||||||
|
message_type,
|
||||||
|
link=payload.media_link,
|
||||||
|
caption=payload.media_caption,
|
||||||
|
filename=payload.media_filename,
|
||||||
|
reply_to=payload.reply_to,
|
||||||
|
)
|
||||||
|
body = payload.media_caption or f"[{message_type}]"
|
||||||
|
elif message_type == "interactive_buttons":
|
||||||
|
if not payload.interactive_body or not payload.interactive_buttons:
|
||||||
|
raise HTTPException(status_code=422, detail="interactive_body and interactive_buttons are required")
|
||||||
|
result = await client.send_interactive_buttons(
|
||||||
|
recipient,
|
||||||
|
payload.interactive_body,
|
||||||
|
payload.interactive_buttons,
|
||||||
|
header=payload.interactive_header,
|
||||||
|
footer=payload.interactive_footer,
|
||||||
|
reply_to=payload.reply_to,
|
||||||
|
)
|
||||||
|
body = payload.interactive_body
|
||||||
|
message_type = "interactive"
|
||||||
|
elif message_type == "interactive_list":
|
||||||
|
if not payload.interactive_body or not payload.interactive_list_button or not payload.interactive_list_sections:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="interactive_body, interactive_list_button, and interactive_list_sections are required",
|
||||||
|
)
|
||||||
|
result = await client.send_interactive_list(
|
||||||
|
recipient,
|
||||||
|
payload.interactive_body,
|
||||||
|
payload.interactive_list_button,
|
||||||
|
payload.interactive_list_sections,
|
||||||
|
header=payload.interactive_header,
|
||||||
|
footer=payload.interactive_footer,
|
||||||
|
reply_to=payload.reply_to,
|
||||||
|
)
|
||||||
|
body = payload.interactive_body
|
||||||
|
message_type = "interactive"
|
||||||
|
elif message_type == "reaction":
|
||||||
|
if not payload.reaction_message_id or not payload.reaction_emoji:
|
||||||
|
raise HTTPException(status_code=422, detail="reaction_message_id and reaction_emoji are required")
|
||||||
|
result = await client.send_reaction(recipient, payload.reaction_message_id, payload.reaction_emoji)
|
||||||
|
body = payload.reaction_emoji
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=422, detail="Unsupported message type")
|
||||||
|
except WhatsAppGraphError as exc:
|
||||||
|
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||||
|
|
||||||
|
message = record_outbound_message(
|
||||||
|
db,
|
||||||
|
to_number=recipient,
|
||||||
|
body=body,
|
||||||
|
message_type=message_type,
|
||||||
|
wa_message_id=extract_wa_message_id(result),
|
||||||
|
status="sent",
|
||||||
|
payload=result if isinstance(result, dict) else None,
|
||||||
|
)
|
||||||
|
return serialize_message(message)
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,10 @@ from .config import settings
|
||||||
from .database import get_db
|
from .database import get_db
|
||||||
from .models import UserAccount, WhatsAppWebhookEvent
|
from .models import UserAccount, WhatsAppWebhookEvent
|
||||||
from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError
|
from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError
|
||||||
|
from .whatsapp_config import resolved_whatsapp_app_secret
|
||||||
from .whatsapp_security import verify_meta_signature
|
from .whatsapp_security import verify_meta_signature
|
||||||
from .whatsapp_webhook import persist_webhook_payload
|
from .whatsapp_webhook import persist_webhook_payload
|
||||||
|
from .whatsapp_messages import record_inbound_messages
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -73,9 +75,10 @@ async def receive_webhook(
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Receive inbound WhatsApp Business Platform webhook payloads."""
|
"""Receive inbound WhatsApp Business Platform webhook payloads."""
|
||||||
raw_body = await request.body()
|
raw_body = await request.body()
|
||||||
|
app_secret = resolved_whatsapp_app_secret(db)
|
||||||
|
|
||||||
if settings.whatsapp_app_secret:
|
if app_secret:
|
||||||
if not verify_meta_signature(settings.whatsapp_app_secret, raw_body, x_hub_signature_256):
|
if not verify_meta_signature(app_secret, raw_body, x_hub_signature_256):
|
||||||
logger.warning("WhatsApp webhook signature validation failed")
|
logger.warning("WhatsApp webhook signature validation failed")
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid signature")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid signature")
|
||||||
elif settings.environment == "production":
|
elif settings.environment == "production":
|
||||||
|
|
@ -90,6 +93,7 @@ async def receive_webhook(
|
||||||
payload = {}
|
payload = {}
|
||||||
|
|
||||||
persist_webhook_payload(db, payload)
|
persist_webhook_payload(db, payload)
|
||||||
|
record_inbound_messages(db, payload)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -100,6 +104,7 @@ def whatsapp_setup(
|
||||||
"""Console setup info — paste these values into Meta Developer Console."""
|
"""Console setup info — paste these values into Meta Developer Console."""
|
||||||
return {
|
return {
|
||||||
"webhook_url": settings.whatsapp_webhook_url,
|
"webhook_url": settings.whatsapp_webhook_url,
|
||||||
|
"verify_token": settings.whatsapp_verify_token or None,
|
||||||
"verify_token_configured": bool(settings.whatsapp_verify_token),
|
"verify_token_configured": bool(settings.whatsapp_verify_token),
|
||||||
"app_secret_configured": bool(settings.whatsapp_app_secret),
|
"app_secret_configured": bool(settings.whatsapp_app_secret),
|
||||||
"access_token_configured": bool(settings.whatsapp_access_token),
|
"access_token_configured": bool(settings.whatsapp_access_token),
|
||||||
|
|
|
||||||
20
backend/app/whatsapp_config.py
Normal file
20
backend/app/whatsapp_config.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .platform_settings import get_setting
|
||||||
|
|
||||||
|
|
||||||
|
def resolved_whatsapp_phone_number_id(db: Session) -> str:
|
||||||
|
return get_setting(db, "whatsapp_phone_number_id") or settings.whatsapp_phone_number_id
|
||||||
|
|
||||||
|
|
||||||
|
def resolved_whatsapp_business_account_id(db: Session) -> str:
|
||||||
|
return get_setting(db, "whatsapp_business_account_id") or settings.whatsapp_business_account_id
|
||||||
|
|
||||||
|
|
||||||
|
def resolved_whatsapp_app_secret(db: Session) -> str:
|
||||||
|
return get_setting(db, "whatsapp_app_secret") or settings.whatsapp_app_secret
|
||||||
|
|
||||||
|
|
||||||
|
def whatsapp_ready_to_send(db: Session) -> bool:
|
||||||
|
return bool(settings.whatsapp_access_token and resolved_whatsapp_phone_number_id(db))
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
|
|
@ -16,36 +18,34 @@ class WhatsAppGraphClient:
|
||||||
*,
|
*,
|
||||||
access_token: str | None = None,
|
access_token: str | None = None,
|
||||||
phone_number_id: str | None = None,
|
phone_number_id: str | None = None,
|
||||||
|
business_account_id: str | None = None,
|
||||||
api_version: str | None = None,
|
api_version: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.access_token = access_token or settings.whatsapp_access_token
|
self.access_token = access_token or settings.whatsapp_access_token
|
||||||
self.phone_number_id = phone_number_id or settings.whatsapp_phone_number_id
|
self.phone_number_id = phone_number_id or settings.whatsapp_phone_number_id
|
||||||
|
self.business_account_id = business_account_id or settings.whatsapp_business_account_id
|
||||||
self.api_version = api_version or settings.whatsapp_api_version
|
self.api_version = api_version or settings.whatsapp_api_version
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
return bool(self.access_token and self.phone_number_id)
|
return bool(self.access_token and self.phone_number_id)
|
||||||
|
|
||||||
|
def _graph_url(self, path: str) -> str:
|
||||||
|
return f"https://graph.facebook.com/{self.api_version}/{path.lstrip('/')}"
|
||||||
|
|
||||||
def _messages_url(self) -> str:
|
def _messages_url(self) -> str:
|
||||||
return (
|
return self._graph_url(f"{self.phone_number_id}/messages")
|
||||||
f"https://graph.facebook.com/{self.api_version}/"
|
|
||||||
f"{self.phone_number_id}/messages"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def send_text(self, to: str, body: str) -> dict:
|
async def _request(
|
||||||
if not self.configured:
|
self,
|
||||||
raise WhatsAppGraphError("WhatsApp Graph API is not configured", status_code=503)
|
method: str,
|
||||||
|
url: str,
|
||||||
payload = {
|
*,
|
||||||
"messaging_product": "whatsapp",
|
json_payload: dict[str, Any] | None = None,
|
||||||
"recipient_type": "individual",
|
) -> dict[str, Any]:
|
||||||
"to": to,
|
|
||||||
"type": "text",
|
|
||||||
"text": {"preview_url": False, "body": body},
|
|
||||||
}
|
|
||||||
headers = {"Authorization": f"Bearer {self.access_token}"}
|
headers = {"Authorization": f"Bearer {self.access_token}"}
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
response = await client.post(self._messages_url(), json=payload, headers=headers)
|
response = await client.request(method, url, json=json_payload, headers=headers)
|
||||||
try:
|
try:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|
@ -53,4 +53,173 @@ class WhatsAppGraphClient:
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
detail = data.get("error", {}).get("message") if isinstance(data, dict) else response.text
|
detail = data.get("error", {}).get("message") if isinstance(data, dict) else response.text
|
||||||
raise WhatsAppGraphError(detail or "WhatsApp Graph API request failed", status_code=response.status_code)
|
raise WhatsAppGraphError(detail or "WhatsApp Graph API request failed", status_code=response.status_code)
|
||||||
return data
|
return data if isinstance(data, dict) else {"data": data}
|
||||||
|
|
||||||
|
async def _send_message(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if not self.configured:
|
||||||
|
raise WhatsAppGraphError("WhatsApp Graph API is not configured", status_code=503)
|
||||||
|
body = {"messaging_product": "whatsapp", **payload}
|
||||||
|
return await self._request("POST", self._messages_url(), json_payload=body)
|
||||||
|
|
||||||
|
async def send_text(self, to: str, body: str, reply_to: str | None = None) -> dict[str, Any]:
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"recipient_type": "individual",
|
||||||
|
"to": to,
|
||||||
|
"type": "text",
|
||||||
|
"text": {"preview_url": False, "body": body},
|
||||||
|
}
|
||||||
|
if reply_to:
|
||||||
|
payload["context"] = {"message_id": reply_to}
|
||||||
|
return await self._send_message(payload)
|
||||||
|
|
||||||
|
async def send_media(
|
||||||
|
self,
|
||||||
|
to: str,
|
||||||
|
media_type: str,
|
||||||
|
*,
|
||||||
|
link: str | None = None,
|
||||||
|
media_id: str | None = None,
|
||||||
|
caption: str | None = None,
|
||||||
|
filename: str | None = None,
|
||||||
|
reply_to: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
media: dict[str, Any] = {}
|
||||||
|
if media_id:
|
||||||
|
media["id"] = media_id
|
||||||
|
elif link:
|
||||||
|
media["link"] = link
|
||||||
|
else:
|
||||||
|
raise WhatsAppGraphError("Media requires link or media_id", status_code=422)
|
||||||
|
if caption and media_type in {"image", "document", "video"}:
|
||||||
|
media["caption"] = caption
|
||||||
|
if filename and media_type == "document":
|
||||||
|
media["filename"] = filename
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"recipient_type": "individual",
|
||||||
|
"to": to,
|
||||||
|
"type": media_type,
|
||||||
|
media_type: media,
|
||||||
|
}
|
||||||
|
if reply_to:
|
||||||
|
payload["context"] = {"message_id": reply_to}
|
||||||
|
return await self._send_message(payload)
|
||||||
|
|
||||||
|
async def send_template(
|
||||||
|
self,
|
||||||
|
to: str,
|
||||||
|
name: str,
|
||||||
|
language_code: str = "en",
|
||||||
|
components: list[dict[str, Any]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
template: dict[str, Any] = {"name": name, "language": {"code": language_code}}
|
||||||
|
if components:
|
||||||
|
template["components"] = components
|
||||||
|
return await self._send_message(
|
||||||
|
{
|
||||||
|
"recipient_type": "individual",
|
||||||
|
"to": to,
|
||||||
|
"type": "template",
|
||||||
|
"template": template,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def send_interactive_buttons(
|
||||||
|
self,
|
||||||
|
to: str,
|
||||||
|
body: str,
|
||||||
|
buttons: list[dict[str, str]],
|
||||||
|
*,
|
||||||
|
header: str | None = None,
|
||||||
|
footer: str | None = None,
|
||||||
|
reply_to: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
interactive: dict[str, Any] = {
|
||||||
|
"type": "button",
|
||||||
|
"body": {"text": body},
|
||||||
|
"action": {
|
||||||
|
"buttons": [
|
||||||
|
{"type": "reply", "reply": {"id": button["id"], "title": button["title"][:20]}}
|
||||||
|
for button in buttons[:3]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if header:
|
||||||
|
interactive["header"] = {"type": "text", "text": header[:60]}
|
||||||
|
if footer:
|
||||||
|
interactive["footer"] = {"text": footer[:60]}
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"recipient_type": "individual",
|
||||||
|
"to": to,
|
||||||
|
"type": "interactive",
|
||||||
|
"interactive": interactive,
|
||||||
|
}
|
||||||
|
if reply_to:
|
||||||
|
payload["context"] = {"message_id": reply_to}
|
||||||
|
return await self._send_message(payload)
|
||||||
|
|
||||||
|
async def send_interactive_list(
|
||||||
|
self,
|
||||||
|
to: str,
|
||||||
|
body: str,
|
||||||
|
button_text: str,
|
||||||
|
sections: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
header: str | None = None,
|
||||||
|
footer: str | None = None,
|
||||||
|
reply_to: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
interactive: dict[str, Any] = {
|
||||||
|
"type": "list",
|
||||||
|
"body": {"text": body},
|
||||||
|
"action": {"button": button_text[:20], "sections": sections},
|
||||||
|
}
|
||||||
|
if header:
|
||||||
|
interactive["header"] = {"type": "text", "text": header[:60]}
|
||||||
|
if footer:
|
||||||
|
interactive["footer"] = {"text": footer[:60]}
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"recipient_type": "individual",
|
||||||
|
"to": to,
|
||||||
|
"type": "interactive",
|
||||||
|
"interactive": interactive,
|
||||||
|
}
|
||||||
|
if reply_to:
|
||||||
|
payload["context"] = {"message_id": reply_to}
|
||||||
|
return await self._send_message(payload)
|
||||||
|
|
||||||
|
async def send_reaction(self, to: str, message_id: str, emoji: str) -> dict[str, Any]:
|
||||||
|
return await self._send_message(
|
||||||
|
{
|
||||||
|
"recipient_type": "individual",
|
||||||
|
"to": to,
|
||||||
|
"type": "reaction",
|
||||||
|
"reaction": {"message_id": message_id, "emoji": emoji},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mark_read(self, message_id: str) -> dict[str, Any]:
|
||||||
|
return await self._send_message({"status": "read", "message_id": message_id})
|
||||||
|
|
||||||
|
async def list_templates(self, limit: int = 50) -> dict[str, Any]:
|
||||||
|
if not self.access_token or not self.business_account_id:
|
||||||
|
raise WhatsAppGraphError("WhatsApp business account ID is not configured", status_code=503)
|
||||||
|
url = self._graph_url(f"{self.business_account_id}/message_templates?limit={limit}")
|
||||||
|
return await self._request("GET", url)
|
||||||
|
|
||||||
|
async def get_media_metadata(self, media_id: str) -> dict[str, Any]:
|
||||||
|
if not self.access_token:
|
||||||
|
raise WhatsAppGraphError("WhatsApp access token is not configured", status_code=503)
|
||||||
|
return await self._request("GET", self._graph_url(media_id))
|
||||||
|
|
||||||
|
async def download_media(self, media_id: str) -> tuple[bytes, str]:
|
||||||
|
metadata = await self.get_media_metadata(media_id)
|
||||||
|
download_url = metadata.get("url")
|
||||||
|
mime_type = metadata.get("mime_type") or "application/octet-stream"
|
||||||
|
if not download_url:
|
||||||
|
raise WhatsAppGraphError("Media URL not available", status_code=404)
|
||||||
|
headers = {"Authorization": f"Bearer {self.access_token}"}
|
||||||
|
async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
|
||||||
|
response = await client.get(download_url, headers=headers)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise WhatsAppGraphError("Failed to download media", status_code=response.status_code)
|
||||||
|
return response.content, mime_type
|
||||||
|
|
|
||||||
180
backend/app/whatsapp_messages.py
Normal file
180
backend/app/whatsapp_messages.py
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .models import WhatsAppMessage
|
||||||
|
|
||||||
|
PHONE_DIGITS_RE = re.compile(r"^\d{10,15}$")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_wa_number(value: str) -> str:
|
||||||
|
digits = re.sub(r"\D", "", value.strip())
|
||||||
|
if len(digits) == 10:
|
||||||
|
digits = f"91{digits}"
|
||||||
|
if not PHONE_DIGITS_RE.match(digits):
|
||||||
|
raise ValueError("Invalid WhatsApp number")
|
||||||
|
return digits
|
||||||
|
|
||||||
|
|
||||||
|
def business_phone() -> str:
|
||||||
|
return normalize_wa_number(settings.whatsapp_business_phone)
|
||||||
|
|
||||||
|
|
||||||
|
def _media_from_payload(message_type: str, payload: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
if not payload or message_type not in {"image", "audio", "video", "document", "sticker"}:
|
||||||
|
return None
|
||||||
|
media = payload.get(message_type) or {}
|
||||||
|
if not isinstance(media, dict):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": media.get("id"),
|
||||||
|
"mime_type": media.get("mime_type"),
|
||||||
|
"caption": media.get("caption"),
|
||||||
|
"filename": media.get("filename"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_message(message: WhatsAppMessage) -> dict:
|
||||||
|
payload = message.payload if isinstance(message.payload, dict) else None
|
||||||
|
media = _media_from_payload(message.message_type, payload)
|
||||||
|
return {
|
||||||
|
"id": str(message.id),
|
||||||
|
"direction": message.direction,
|
||||||
|
"wa_message_id": message.wa_message_id,
|
||||||
|
"from": message.from_number,
|
||||||
|
"to": message.to_number,
|
||||||
|
"body": message.body,
|
||||||
|
"message_type": message.message_type,
|
||||||
|
"status": message.status,
|
||||||
|
"media": media,
|
||||||
|
"created_at": message.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_text_body(message: dict[str, Any]) -> str | None:
|
||||||
|
msg_type = message.get("type")
|
||||||
|
if msg_type == "text":
|
||||||
|
return (message.get("text") or {}).get("body")
|
||||||
|
if msg_type == "button":
|
||||||
|
return (message.get("button") or {}).get("text")
|
||||||
|
if msg_type == "interactive":
|
||||||
|
interactive = message.get("interactive") or {}
|
||||||
|
if interactive.get("type") == "button_reply":
|
||||||
|
return (interactive.get("button_reply") or {}).get("title")
|
||||||
|
if interactive.get("type") == "list_reply":
|
||||||
|
return (interactive.get("list_reply") or {}).get("title")
|
||||||
|
if msg_type in {"image", "video", "document"}:
|
||||||
|
return (message.get(msg_type) or {}).get("caption")
|
||||||
|
if msg_type == "reaction":
|
||||||
|
reaction = message.get("reaction") or {}
|
||||||
|
emoji = reaction.get("emoji")
|
||||||
|
return f"Reacted {emoji}" if emoji else "Reaction"
|
||||||
|
if msg_type == "location":
|
||||||
|
location = message.get("location") or {}
|
||||||
|
name = location.get("name") or location.get("address")
|
||||||
|
if name:
|
||||||
|
return f"Location: {name}"
|
||||||
|
lat = location.get("latitude")
|
||||||
|
lng = location.get("longitude")
|
||||||
|
if lat is not None and lng is not None:
|
||||||
|
return f"Location: {lat}, {lng}"
|
||||||
|
if msg_type == "contacts":
|
||||||
|
contacts = message.get("contacts") or []
|
||||||
|
if contacts:
|
||||||
|
name = ((contacts[0].get("name") or {}).get("formatted_name")) or "Contact"
|
||||||
|
return f"Contact: {name}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def record_inbound_messages(db: Session, payload: dict[str, Any]) -> int:
|
||||||
|
if payload.get("object") != "whatsapp_business_account":
|
||||||
|
return 0
|
||||||
|
|
||||||
|
business = business_phone()
|
||||||
|
created = 0
|
||||||
|
for entry in payload.get("entry", []):
|
||||||
|
for change in entry.get("changes", []):
|
||||||
|
if change.get("field") != "messages":
|
||||||
|
continue
|
||||||
|
value = change.get("value", {})
|
||||||
|
for message in value.get("messages", []):
|
||||||
|
from_number = message.get("from") or ""
|
||||||
|
body = _extract_text_body(message)
|
||||||
|
db.add(
|
||||||
|
WhatsAppMessage(
|
||||||
|
direction="inbound",
|
||||||
|
wa_message_id=message.get("id"),
|
||||||
|
from_number=from_number,
|
||||||
|
to_number=business,
|
||||||
|
body=body,
|
||||||
|
message_type=message.get("type") or "text",
|
||||||
|
status="received",
|
||||||
|
payload=message,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
created += 1
|
||||||
|
for status in value.get("statuses", []):
|
||||||
|
wa_id = status.get("id")
|
||||||
|
if not wa_id:
|
||||||
|
continue
|
||||||
|
existing = db.scalar(
|
||||||
|
select(WhatsAppMessage).where(WhatsAppMessage.wa_message_id == wa_id)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
existing.status = status.get("status") or existing.status
|
||||||
|
else:
|
||||||
|
db.add(
|
||||||
|
WhatsAppMessage(
|
||||||
|
direction="outbound",
|
||||||
|
wa_message_id=wa_id,
|
||||||
|
from_number=business,
|
||||||
|
to_number=status.get("recipient_id") or "",
|
||||||
|
body=None,
|
||||||
|
message_type="status",
|
||||||
|
status=status.get("status") or "unknown",
|
||||||
|
payload=status,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
created += 1
|
||||||
|
if created:
|
||||||
|
db.commit()
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def record_outbound_message(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
to_number: str,
|
||||||
|
body: str | None,
|
||||||
|
message_type: str = "text",
|
||||||
|
wa_message_id: str | None,
|
||||||
|
status: str = "sent",
|
||||||
|
payload: dict | None = None,
|
||||||
|
) -> WhatsAppMessage:
|
||||||
|
message = WhatsAppMessage(
|
||||||
|
direction="outbound",
|
||||||
|
wa_message_id=wa_message_id,
|
||||||
|
from_number=business_phone(),
|
||||||
|
to_number=to_number,
|
||||||
|
body=body,
|
||||||
|
message_type=message_type,
|
||||||
|
status=status,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
db.add(message)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(message)
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def extract_wa_message_id(result: dict[str, Any] | None) -> str | None:
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
return None
|
||||||
|
messages = result.get("messages")
|
||||||
|
if isinstance(messages, list) and messages:
|
||||||
|
message_id = messages[0].get("id")
|
||||||
|
return message_id if isinstance(message_id, str) else None
|
||||||
|
return None
|
||||||
|
|
@ -51,6 +51,7 @@ services:
|
||||||
WHATSAPP_PHONE_NUMBER_ID: ${WHATSAPP_PHONE_NUMBER_ID:-}
|
WHATSAPP_PHONE_NUMBER_ID: ${WHATSAPP_PHONE_NUMBER_ID:-}
|
||||||
WHATSAPP_BUSINESS_ACCOUNT_ID: ${WHATSAPP_BUSINESS_ACCOUNT_ID:-}
|
WHATSAPP_BUSINESS_ACCOUNT_ID: ${WHATSAPP_BUSINESS_ACCOUNT_ID:-}
|
||||||
META_APP_ID: ${META_APP_ID:-}
|
META_APP_ID: ${META_APP_ID:-}
|
||||||
|
WHATSAPP_BUSINESS_PHONE: ${WHATSAPP_BUSINESS_PHONE:-919411257916}
|
||||||
WHATSAPP_API_VERSION: ${WHATSAPP_API_VERSION:-v21.0}
|
WHATSAPP_API_VERSION: ${WHATSAPP_API_VERSION:-v21.0}
|
||||||
UPLOAD_DIR: uploads
|
UPLOAD_DIR: uploads
|
||||||
KYC_TEMPLATE_PATH: assets/kyc-template.docx
|
KYC_TEMPLATE_PATH: assets/kyc-template.docx
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
type AdminTemplateConfig,
|
type AdminTemplateConfig,
|
||||||
type AdminUrmiOtpProject,
|
type AdminUrmiOtpProject,
|
||||||
} from '../../lib/api'
|
} from '../../lib/api'
|
||||||
|
import { AdminWhatsAppPage } from './AdminWhatsAppPage'
|
||||||
|
|
||||||
const KYC_DOC_LABELS: Record<string, string> = {
|
const KYC_DOC_LABELS: Record<string, string> = {
|
||||||
business_pan_card: 'Business PAN card',
|
business_pan_card: 'Business PAN card',
|
||||||
|
|
@ -391,6 +392,7 @@ function AdminCompliance() {
|
||||||
|
|
||||||
export function AdminApp() {
|
export function AdminApp() {
|
||||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null)
|
const [authenticated, setAuthenticated] = useState<boolean | null>(null)
|
||||||
|
const [section, setSection] = useState<'compliance' | 'whatsapp'>('whatsapp')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
adminApi.me().then(() => setAuthenticated(true)).catch(() => setAuthenticated(false))
|
adminApi.me().then(() => setAuthenticated(true)).catch(() => setAuthenticated(false))
|
||||||
|
|
@ -406,11 +408,33 @@ export function AdminApp() {
|
||||||
return (
|
return (
|
||||||
<div className="admin-shell">
|
<div className="admin-shell">
|
||||||
<header className="admin-topbar">
|
<header className="admin-topbar">
|
||||||
<div><img src="/urmi-logo.png" alt="Urmi" /><strong>Admin · Global compliance</strong></div>
|
<div><img src="/urmi-logo.png" alt="Urmi" /><strong>Admin portal</strong></div>
|
||||||
{authenticated && <button className="secondary-button" onClick={logout}>Log out</button>}
|
{authenticated && (
|
||||||
|
<div className="admin-topbar-actions">
|
||||||
|
<nav className="admin-main-nav">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={section === 'whatsapp' ? 'active' : ''}
|
||||||
|
onClick={() => setSection('whatsapp')}
|
||||||
|
>
|
||||||
|
WhatsApp Business
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={section === 'compliance' ? 'active' : ''}
|
||||||
|
onClick={() => setSection('compliance')}
|
||||||
|
>
|
||||||
|
Compliance
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
<button className="secondary-button" onClick={logout}>Log out</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
{authenticated ? <AdminCompliance /> : <AdminLogin onSuccess={() => setAuthenticated(true)} />}
|
{authenticated
|
||||||
|
? (section === 'whatsapp' ? <AdminWhatsAppPage /> : <AdminCompliance />)
|
||||||
|
: <AdminLogin onSuccess={() => setAuthenticated(true)} />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
488
frontend/src/features/admin/AdminWhatsAppPage.tsx
Normal file
488
frontend/src/features/admin/AdminWhatsAppPage.tsx
Normal file
|
|
@ -0,0 +1,488 @@
|
||||||
|
import { MessageSquareText, RefreshCw, Settings2 } from 'lucide-react'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import type { FormEvent } from 'react'
|
||||||
|
import { API_BASE_URL } from '../../lib/apiBase'
|
||||||
|
import {
|
||||||
|
adminApi,
|
||||||
|
type AdminWhatsAppConfig,
|
||||||
|
type AdminWhatsAppMessage,
|
||||||
|
type AdminWhatsAppSendPayload,
|
||||||
|
type AdminWhatsAppTemplate,
|
||||||
|
} from '../../lib/api'
|
||||||
|
|
||||||
|
type SendMode = 'text' | 'template' | 'media' | 'buttons' | 'reaction'
|
||||||
|
|
||||||
|
function formatWaNumber(value: string) {
|
||||||
|
const digits = value.replace(/\D/g, '')
|
||||||
|
if (digits.length === 12 && digits.startsWith('91')) {
|
||||||
|
const local = digits.slice(2)
|
||||||
|
return `+91 ${local.slice(0, 5)} ${local.slice(5)}`
|
||||||
|
}
|
||||||
|
if (digits.length === 10) return `+91 ${digits.slice(0, 5)} ${digits.slice(5)}`
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaUrl(mediaId: string) {
|
||||||
|
return `${API_BASE_URL}/api/v1/admin/whatsapp/media/${mediaId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminWhatsAppPage() {
|
||||||
|
const [config, setConfig] = useState<AdminWhatsAppConfig | null>(null)
|
||||||
|
const [messages, setMessages] = useState<AdminWhatsAppMessage[]>([])
|
||||||
|
const [templates, setTemplates] = useState<AdminWhatsAppTemplate[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [selectedPeer, setSelectedPeer] = useState<string | null>(null)
|
||||||
|
const [newPeer, setNewPeer] = useState('')
|
||||||
|
const [sendMode, setSendMode] = useState<SendMode>('text')
|
||||||
|
const [showSetup, setShowSetup] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
const [savingConfig, setSavingConfig] = useState(false)
|
||||||
|
|
||||||
|
const [phoneNumberId, setPhoneNumberId] = useState('')
|
||||||
|
const [businessAccountId, setBusinessAccountId] = useState('')
|
||||||
|
const [appSecret, setAppSecret] = useState('')
|
||||||
|
|
||||||
|
const [textBody, setTextBody] = useState('')
|
||||||
|
const [replyTo, setReplyTo] = useState('')
|
||||||
|
const [templateName, setTemplateName] = useState('')
|
||||||
|
const [templateLanguage, setTemplateLanguage] = useState('en')
|
||||||
|
const [mediaType, setMediaType] = useState<'image' | 'document' | 'audio' | 'video'>('image')
|
||||||
|
const [mediaLink, setMediaLink] = useState('')
|
||||||
|
const [mediaCaption, setMediaCaption] = useState('')
|
||||||
|
const [mediaFilename, setMediaFilename] = useState('')
|
||||||
|
const [buttonBody, setButtonBody] = useState('')
|
||||||
|
const [buttonOne, setButtonOne] = useState('')
|
||||||
|
const [buttonTwo, setButtonTwo] = useState('')
|
||||||
|
const [buttonThree, setButtonThree] = useState('')
|
||||||
|
const [reactionEmoji, setReactionEmoji] = useState('👍')
|
||||||
|
const [reactionTarget, setReactionTarget] = useState('')
|
||||||
|
|
||||||
|
const chatEndRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
async function loadMessages(peer?: string | null) {
|
||||||
|
const items = await adminApi.listWhatsAppMessages(300, peer || undefined)
|
||||||
|
setMessages(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
const [cfg, items, templateResponse] = await Promise.all([
|
||||||
|
adminApi.whatsappConfig(),
|
||||||
|
adminApi.listWhatsAppMessages(300),
|
||||||
|
adminApi.listWhatsAppTemplates().catch(() => ({ data: [] as AdminWhatsAppTemplate[] })),
|
||||||
|
])
|
||||||
|
setConfig(cfg)
|
||||||
|
setMessages(items)
|
||||||
|
setTemplates(templateResponse.data ?? [])
|
||||||
|
setPhoneNumberId(cfg.phone_number_id ?? '')
|
||||||
|
setBusinessAccountId(cfg.business_account_id ?? '')
|
||||||
|
if (!cfg.ready_to_send) setShowSetup(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAll().finally(() => setLoading(false))
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
loadMessages(selectedPeer).catch(() => undefined)
|
||||||
|
}, 5000)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [selectedPeer])
|
||||||
|
|
||||||
|
const threads = useMemo(() => {
|
||||||
|
const map = new Map<string, AdminWhatsAppMessage[]>()
|
||||||
|
for (const message of messages) {
|
||||||
|
const peer = message.direction === 'inbound' ? message.from : message.to
|
||||||
|
if (!peer || message.message_type === 'status') continue
|
||||||
|
const list = map.get(peer) ?? []
|
||||||
|
list.push(message)
|
||||||
|
map.set(peer, list)
|
||||||
|
}
|
||||||
|
return [...map.entries()]
|
||||||
|
.map(([peer, thread]) => {
|
||||||
|
const sorted = [...thread].sort((a, b) => a.created_at.localeCompare(b.created_at))
|
||||||
|
return { peer, thread: sorted, latest: sorted[sorted.length - 1] }
|
||||||
|
})
|
||||||
|
.sort((a, b) => (b.latest?.created_at ?? '').localeCompare(a.latest?.created_at ?? ''))
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
const activeThread = useMemo(() => {
|
||||||
|
if (!selectedPeer) return []
|
||||||
|
const found = threads.find((item) => item.peer === selectedPeer)
|
||||||
|
return found?.thread ?? []
|
||||||
|
}, [threads, selectedPeer])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [activeThread.length, selectedPeer])
|
||||||
|
|
||||||
|
async function saveConfig(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
setSavingConfig(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const updated = await adminApi.updateWhatsAppConfig({
|
||||||
|
phone_number_id: phoneNumberId.trim(),
|
||||||
|
business_account_id: businessAccountId.trim(),
|
||||||
|
app_secret: appSecret.trim() || undefined,
|
||||||
|
})
|
||||||
|
setConfig(updated)
|
||||||
|
setShowSetup(false)
|
||||||
|
const templateResponse = await adminApi.listWhatsAppTemplates().catch(() => ({ data: [] as AdminWhatsAppTemplate[] }))
|
||||||
|
setTemplates(templateResponse.data ?? [])
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : 'Unable to save WhatsApp config')
|
||||||
|
} finally {
|
||||||
|
setSavingConfig(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markRead(message: AdminWhatsAppMessage) {
|
||||||
|
if (!message.wa_message_id || message.direction !== 'inbound') return
|
||||||
|
try {
|
||||||
|
await adminApi.markWhatsAppRead(message.wa_message_id)
|
||||||
|
} catch {
|
||||||
|
// ignore read receipt failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!selectedPeer && !newPeer.trim()) {
|
||||||
|
setError('Select a conversation or enter a recipient number')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSending(true)
|
||||||
|
setError('')
|
||||||
|
const recipient = selectedPeer ?? newPeer.trim()
|
||||||
|
const payload: AdminWhatsAppSendPayload = { to: recipient, message_type: 'text' }
|
||||||
|
try {
|
||||||
|
if (sendMode === 'text') {
|
||||||
|
payload.message_type = 'text'
|
||||||
|
payload.text = textBody.trim()
|
||||||
|
payload.reply_to = replyTo.trim() || undefined
|
||||||
|
} else if (sendMode === 'template') {
|
||||||
|
payload.message_type = 'template'
|
||||||
|
payload.template_name = templateName
|
||||||
|
payload.template_language = templateLanguage
|
||||||
|
} else if (sendMode === 'media') {
|
||||||
|
payload.message_type = mediaType
|
||||||
|
payload.media_link = mediaLink.trim()
|
||||||
|
payload.media_caption = mediaCaption.trim() || undefined
|
||||||
|
payload.media_filename = mediaFilename.trim() || undefined
|
||||||
|
payload.reply_to = replyTo.trim() || undefined
|
||||||
|
} else if (sendMode === 'buttons') {
|
||||||
|
payload.message_type = 'interactive_buttons'
|
||||||
|
payload.interactive_body = buttonBody.trim()
|
||||||
|
payload.interactive_buttons = [
|
||||||
|
{ id: 'btn1', title: buttonOne.trim() },
|
||||||
|
...(buttonTwo.trim() ? [{ id: 'btn2', title: buttonTwo.trim() }] : []),
|
||||||
|
...(buttonThree.trim() ? [{ id: 'btn3', title: buttonThree.trim() }] : []),
|
||||||
|
]
|
||||||
|
payload.reply_to = replyTo.trim() || undefined
|
||||||
|
} else if (sendMode === 'reaction') {
|
||||||
|
payload.message_type = 'reaction'
|
||||||
|
payload.reaction_message_id = reactionTarget.trim()
|
||||||
|
payload.reaction_emoji = reactionEmoji.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const sent = await adminApi.sendWhatsAppMessage(payload)
|
||||||
|
if (!selectedPeer) {
|
||||||
|
const peer = sent.to
|
||||||
|
setSelectedPeer(peer)
|
||||||
|
setNewPeer('')
|
||||||
|
}
|
||||||
|
await loadMessages(selectedPeer ?? sent.to)
|
||||||
|
setTextBody('')
|
||||||
|
setMediaCaption('')
|
||||||
|
setButtonBody('')
|
||||||
|
setReplyTo('')
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : 'Unable to send message')
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="auth-loading">Loading WhatsApp Business…</div>
|
||||||
|
|
||||||
|
const readyToSend = Boolean(config?.ready_to_send)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-content admin-whatsapp-page">
|
||||||
|
<div className="account-heading admin-whatsapp-heading">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">Urmi business</span>
|
||||||
|
<h1><MessageSquareText size={24} /> WhatsApp Business</h1>
|
||||||
|
<p>
|
||||||
|
Send and receive on {config ? formatWaNumber(config.business_phone) : '+91 94112 57916'}.
|
||||||
|
Webhook: {config?.webhook_url ?? '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-whatsapp-heading-actions">
|
||||||
|
<span className={`doc-status ${readyToSend ? 'approved' : 'pending'}`}>
|
||||||
|
{readyToSend ? 'Connected' : 'Setup required'}
|
||||||
|
</span>
|
||||||
|
<button type="button" className="secondary-button" onClick={() => setShowSetup((value) => !value)}>
|
||||||
|
<Settings2 size={15} /> Meta setup
|
||||||
|
</button>
|
||||||
|
<button type="button" className="secondary-button" onClick={() => loadAll()}>
|
||||||
|
<RefreshCw size={15} /> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSetup && (
|
||||||
|
<section className="panel admin-whatsapp-setup">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<h2>Meta connection</h2>
|
||||||
|
<p>Paste IDs from Meta → WhatsApp → API Setup. Saved in admin without restarting the server.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form className="admin-whatsapp-setup-form" onSubmit={saveConfig}>
|
||||||
|
<label>
|
||||||
|
<span>Phone number ID</span>
|
||||||
|
<input value={phoneNumberId} onChange={(e) => setPhoneNumberId(e.target.value)} placeholder="106540352242922" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>WhatsApp Business Account ID</span>
|
||||||
|
<input value={businessAccountId} onChange={(e) => setBusinessAccountId(e.target.value)} placeholder="WABA ID" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>App secret (for webhook signature validation)</span>
|
||||||
|
<input value={appSecret} onChange={(e) => setAppSecret(e.target.value)} placeholder="From Meta App Dashboard → Basic" />
|
||||||
|
</label>
|
||||||
|
<div className="admin-whatsapp-setup-meta">
|
||||||
|
<p>Verify token: {config?.verify_token_configured ? 'Configured in server env' : 'Missing in env'}</p>
|
||||||
|
<p>Access token: {config?.access_token_configured ? 'Configured in server env' : 'Missing in env'}</p>
|
||||||
|
<p>Meta App ID: {config?.meta_app_id ?? '—'}</p>
|
||||||
|
</div>
|
||||||
|
<button className="primary-button" type="submit" disabled={savingConfig}>
|
||||||
|
{savingConfig ? 'Saving…' : 'Save Meta settings'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!readyToSend && (
|
||||||
|
<p className="admin-whatsapp-hint">
|
||||||
|
Add your <strong>Phone number ID</strong> in Meta setup above to enable sending.
|
||||||
|
Inbound messages still arrive once Meta webhook is subscribed at {config?.webhook_url}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="admin-whatsapp-messenger">
|
||||||
|
<aside className="panel admin-whatsapp-sidebar">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<h2>Conversations</h2>
|
||||||
|
<p>{threads.length} active threads</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{threads.length === 0 ? (
|
||||||
|
<p className="admin-whatsapp-empty">No messages yet. Ask someone to message +91 94112 57916 or scan your Meta QR.</p>
|
||||||
|
) : (
|
||||||
|
<div className="admin-whatsapp-thread-list compact">
|
||||||
|
{threads.map(({ peer, latest, thread }) => (
|
||||||
|
<button
|
||||||
|
key={peer}
|
||||||
|
type="button"
|
||||||
|
className={`admin-whatsapp-thread-item ${selectedPeer === peer ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedPeer(peer)
|
||||||
|
const lastInbound = [...thread].reverse().find((item) => item.direction === 'inbound')
|
||||||
|
if (lastInbound) markRead(lastInbound)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>{formatWaNumber(peer)}</strong>
|
||||||
|
<span>{latest.body || `[${latest.message_type}]`}</span>
|
||||||
|
<small>{new Date(latest.created_at).toLocaleString()}</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="panel admin-whatsapp-chat">
|
||||||
|
{!selectedPeer ? (
|
||||||
|
<div className="admin-whatsapp-chat-empty">
|
||||||
|
<p>Select a conversation to view messages and reply.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="admin-whatsapp-chat-head">
|
||||||
|
<strong>{formatWaNumber(selectedPeer)}</strong>
|
||||||
|
<small>{activeThread.length} messages</small>
|
||||||
|
</div>
|
||||||
|
<div className="admin-whatsapp-chat-body">
|
||||||
|
{activeThread.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`admin-whatsapp-bubble ${item.direction === 'inbound' ? 'inbound' : 'outbound'}`}
|
||||||
|
>
|
||||||
|
{item.media?.id && ['image', 'sticker'].includes(item.message_type) && (
|
||||||
|
<img src={mediaUrl(item.media.id)} alt={item.message_type} className="admin-whatsapp-media" />
|
||||||
|
)}
|
||||||
|
{item.media?.id && item.message_type === 'video' && (
|
||||||
|
<video src={mediaUrl(item.media.id)} controls className="admin-whatsapp-media" />
|
||||||
|
)}
|
||||||
|
{item.media?.id && item.message_type === 'audio' && (
|
||||||
|
<audio src={mediaUrl(item.media.id)} controls className="admin-whatsapp-media" />
|
||||||
|
)}
|
||||||
|
{item.media?.id && item.message_type === 'document' && (
|
||||||
|
<a href={mediaUrl(item.media.id)} target="_blank" rel="noreferrer">Download document</a>
|
||||||
|
)}
|
||||||
|
<span>{item.body || `[${item.message_type} · ${item.status}]`}</span>
|
||||||
|
<small>
|
||||||
|
{new Date(item.created_at).toLocaleTimeString()}
|
||||||
|
{item.status ? ` · ${item.status}` : ''}
|
||||||
|
{item.wa_message_id ? ` · ${item.wa_message_id.slice(0, 12)}…` : ''}
|
||||||
|
</small>
|
||||||
|
{item.direction === 'inbound' && item.wa_message_id && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="link-button"
|
||||||
|
onClick={() => {
|
||||||
|
setSendMode('reaction')
|
||||||
|
setReactionTarget(item.wa_message_id || '')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
React
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={chatEndRef} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel admin-whatsapp-compose-panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<h2>Send</h2>
|
||||||
|
<p>Supported: text, template, media, buttons, reaction, read receipts.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-whatsapp-mode-tabs">
|
||||||
|
{(['text', 'template', 'media', 'buttons', 'reaction'] as SendMode[]).map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
className={sendMode === mode ? 'active' : ''}
|
||||||
|
onClick={() => setSendMode(mode)}
|
||||||
|
>
|
||||||
|
{mode}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<form className="admin-whatsapp-form" onSubmit={sendMessage}>
|
||||||
|
{!selectedPeer && (
|
||||||
|
<label>
|
||||||
|
<span>New recipient</span>
|
||||||
|
<input
|
||||||
|
value={newPeer}
|
||||||
|
onChange={(e) => setNewPeer(e.target.value)}
|
||||||
|
placeholder="9411257916 or +91 94112 57916"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sendMode === 'text' && (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
<span>Message</span>
|
||||||
|
<textarea value={textBody} onChange={(e) => setTextBody(e.target.value)} rows={5} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Reply to message ID (optional)</span>
|
||||||
|
<input value={replyTo} onChange={(e) => setReplyTo(e.target.value)} placeholder="wamid…" />
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sendMode === 'template' && (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
<span>Approved template</span>
|
||||||
|
<select value={templateName} onChange={(e) => setTemplateName(e.target.value)} required>
|
||||||
|
<option value="">Select template</option>
|
||||||
|
{templates.map((template) => (
|
||||||
|
<option key={template.id ?? template.name} value={template.name}>
|
||||||
|
{template.name} ({template.language})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Language code</span>
|
||||||
|
<input value={templateLanguage} onChange={(e) => setTemplateLanguage(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<small>Templates work outside the 24-hour customer service window.</small>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sendMode === 'media' && (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
<span>Media type</span>
|
||||||
|
<select value={mediaType} onChange={(e) => setMediaType(e.target.value as typeof mediaType)}>
|
||||||
|
<option value="image">Image</option>
|
||||||
|
<option value="document">Document</option>
|
||||||
|
<option value="audio">Audio</option>
|
||||||
|
<option value="video">Video</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Public HTTPS URL</span>
|
||||||
|
<input value={mediaLink} onChange={(e) => setMediaLink(e.target.value)} placeholder="https://…" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Caption</span>
|
||||||
|
<input value={mediaCaption} onChange={(e) => setMediaCaption(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
{mediaType === 'document' && (
|
||||||
|
<label>
|
||||||
|
<span>Filename</span>
|
||||||
|
<input value={mediaFilename} onChange={(e) => setMediaFilename(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sendMode === 'buttons' && (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
<span>Body</span>
|
||||||
|
<textarea value={buttonBody} onChange={(e) => setButtonBody(e.target.value)} rows={4} required />
|
||||||
|
</label>
|
||||||
|
<label><span>Button 1</span><input value={buttonOne} onChange={(e) => setButtonOne(e.target.value)} required /></label>
|
||||||
|
<label><span>Button 2</span><input value={buttonTwo} onChange={(e) => setButtonTwo(e.target.value)} /></label>
|
||||||
|
<label><span>Button 3</span><input value={buttonThree} onChange={(e) => setButtonThree(e.target.value)} /></label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sendMode === 'reaction' && (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
<span>Message ID</span>
|
||||||
|
<input value={reactionTarget} onChange={(e) => setReactionTarget(e.target.value)} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Emoji</span>
|
||||||
|
<input value={reactionEmoji} onChange={(e) => setReactionEmoji(e.target.value)} required />
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="auth-error">{error}</p>}
|
||||||
|
<button className="primary-button" type="submit" disabled={sending || !readyToSend || (!selectedPeer && !newPeer.trim())}>
|
||||||
|
{sending ? 'Sending…' : 'Send'}
|
||||||
|
</button>
|
||||||
|
<small>Within 24h of customer message: free-form text/media/buttons. Outside window: use template.</small>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -71,9 +71,14 @@ export function WhatsAppSetupPage() {
|
||||||
<div className="whatsapp-config-row">
|
<div className="whatsapp-config-row">
|
||||||
<div>
|
<div>
|
||||||
<span>Verify token</span>
|
<span>Verify token</span>
|
||||||
<code>{setup.verify_token_configured ? 'Set in server env (WHATSAPP_VERIFY_TOKEN)' : 'Not configured'}</code>
|
<code>{setup.verify_token || 'Not configured'}</code>
|
||||||
<small>Use the exact same string in Meta and in your <code>.env</code> file.</small>
|
<small>Paste this exact value into Meta → Configure Webhooks → Verify token.</small>
|
||||||
</div>
|
</div>
|
||||||
|
{setup.verify_token && (
|
||||||
|
<button className="secondary-button" onClick={() => copyValue('token', setup.verify_token!)}>
|
||||||
|
<Copy size={14} /> {copied === 'token' ? 'Copied' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="whatsapp-badges">
|
<div className="whatsapp-badges">
|
||||||
|
|
|
||||||
|
|
@ -790,6 +790,22 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
|
||||||
.admin-topbar > div { display: flex; align-items: center; gap: 14px; }
|
.admin-topbar > div { display: flex; align-items: center; gap: 14px; }
|
||||||
.admin-topbar img { width: 92px; height: 42px; object-fit: contain; }
|
.admin-topbar img { width: 92px; height: 42px; object-fit: contain; }
|
||||||
.admin-topbar strong { font-size: 12px; color: #c7d6ea; }
|
.admin-topbar strong { font-size: 12px; color: #c7d6ea; }
|
||||||
|
.admin-topbar-actions { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.admin-main-nav { display: flex; gap: 8px; }
|
||||||
|
.admin-main-nav button {
|
||||||
|
border: 1px solid rgba(255,255,255,.18);
|
||||||
|
background: transparent;
|
||||||
|
color: #c7d6ea;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.admin-main-nav button.active {
|
||||||
|
background: #1475ff;
|
||||||
|
border-color: #1475ff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
.admin-content, .admin-login-page { padding: 28px; }
|
.admin-content, .admin-login-page { padding: 28px; }
|
||||||
.admin-login-page { min-height: calc(100vh - 72px); display: grid; place-items: center; }
|
.admin-login-page { min-height: calc(100vh - 72px); display: grid; place-items: center; }
|
||||||
.admin-login-card { width: min(420px, 100%); }
|
.admin-login-card { width: min(420px, 100%); }
|
||||||
|
|
@ -833,6 +849,270 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
|
||||||
.admin-otp-project a { color: #1475ff; word-break: break-all; }
|
.admin-otp-project a { color: #1475ff; word-break: break-all; }
|
||||||
.admin-otp-project p { margin: 0; line-height: 1.55; }
|
.admin-otp-project p { margin: 0; line-height: 1.55; }
|
||||||
.admin-otp-project .admin-actions { margin-top: 5px; }
|
.admin-otp-project .admin-actions { margin-top: 5px; }
|
||||||
|
.admin-whatsapp { display: grid; gap: 18px; }
|
||||||
|
.admin-whatsapp-config { margin-bottom: 0; }
|
||||||
|
.admin-whatsapp-hint {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff8e8;
|
||||||
|
color: #7a5b12;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-inbox, .admin-whatsapp-compose { min-height: 520px; }
|
||||||
|
.admin-whatsapp-empty {
|
||||||
|
margin: 0;
|
||||||
|
color: #8a95a7;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-thread-list { display: grid; gap: 14px; }
|
||||||
|
.admin-whatsapp-thread {
|
||||||
|
border: 1px solid #e6ebf2;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #fbfcfe;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-thread-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-thread-head strong { font-size: 12px; color: #1f2a3d; }
|
||||||
|
.admin-whatsapp-thread-head small, .admin-whatsapp-thread > small { color: #8a95a7; font-size: 10px; }
|
||||||
|
.admin-whatsapp-thread > p {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: #46546a;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-thread-messages {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-bubble {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: 88%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-bubble.inbound {
|
||||||
|
justify-self: start;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dfe5ed;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-bubble.outbound {
|
||||||
|
justify-self: end;
|
||||||
|
background: #e8f2ff;
|
||||||
|
border: 1px solid #c8ddff;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-bubble small { color: #8a95a7; font-size: 9px; }
|
||||||
|
.admin-whatsapp-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-form label > span {
|
||||||
|
color: #46546a;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-form input,
|
||||||
|
.admin-whatsapp-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #dfe5ed;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #1f2a3d;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-form textarea { resize: vertical; min-height: 120px; }
|
||||||
|
.admin-whatsapp-form small {
|
||||||
|
color: #8a95a7;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.admin-whatsapp-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
.admin-whatsapp-page { max-width: 1400px; }
|
||||||
|
.admin-whatsapp-heading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-heading-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-messenger {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px minmax(0, 1fr) 320px;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-sidebar,
|
||||||
|
.admin-whatsapp-chat,
|
||||||
|
.admin-whatsapp-compose-panel {
|
||||||
|
min-height: 640px;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-thread-list.compact {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 560px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-thread-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid #e6ebf2;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-thread-item.active {
|
||||||
|
border-color: #1475ff;
|
||||||
|
background: #f3f8ff;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-thread-item strong { font-size: 12px; color: #1f2a3d; }
|
||||||
|
.admin-whatsapp-thread-item span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #607089;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-thread-item small { font-size: 10px; color: #8a95a7; }
|
||||||
|
.admin-whatsapp-chat {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-chat-empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 520px;
|
||||||
|
color: #8a95a7;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-chat-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid #e6ebf2;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-chat-head strong { font-size: 13px; }
|
||||||
|
.admin-whatsapp-chat-head small { color: #8a95a7; font-size: 10px; }
|
||||||
|
.admin-whatsapp-chat-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
align-content: start;
|
||||||
|
max-height: 560px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-media {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-mode-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-mode-tabs button {
|
||||||
|
border: 1px solid #dfe5ed;
|
||||||
|
background: white;
|
||||||
|
color: #607089;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-mode-tabs button.active {
|
||||||
|
background: #1475ff;
|
||||||
|
border-color: #1475ff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-setup-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.admin-whatsapp-setup-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-setup-form label > span {
|
||||||
|
color: #46546a;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-setup-form input,
|
||||||
|
.admin-whatsapp-setup-form select,
|
||||||
|
.admin-whatsapp-form select {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #dfe5ed;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-setup-meta {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
color: #607089;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.admin-whatsapp-setup-form .primary-button {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
.link-button {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: #1475ff;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.admin-whatsapp-messenger { grid-template-columns: 1fr; }
|
||||||
|
.admin-whatsapp-sidebar,
|
||||||
|
.admin-whatsapp-chat,
|
||||||
|
.admin-whatsapp-compose-panel { min-height: auto; }
|
||||||
|
}
|
||||||
|
|
||||||
/* UrmiOTP */
|
/* UrmiOTP */
|
||||||
.urmiotp-page, .urmiotp-docs-page { max-width: 1080px; }
|
.urmiotp-page, .urmiotp-docs-page { max-width: 1080px; }
|
||||||
|
|
|
||||||
|
|
@ -648,6 +648,66 @@ export type AdminTemplateConfig = TemplateConfig & {
|
||||||
user: { id: string; name: string; email: string }
|
user: { id: string; name: string; email: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AdminWhatsAppConfig = {
|
||||||
|
business_phone: string
|
||||||
|
phone_number_id: string | null
|
||||||
|
business_account_id: string | null
|
||||||
|
meta_app_id: string | null
|
||||||
|
access_token_configured: boolean
|
||||||
|
app_secret_configured: boolean
|
||||||
|
webhook_url: string
|
||||||
|
verify_token_configured: boolean
|
||||||
|
ready_to_send: boolean
|
||||||
|
supported_operations: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminWhatsAppMessage = {
|
||||||
|
id: string
|
||||||
|
direction: 'inbound' | 'outbound'
|
||||||
|
wa_message_id: string | null
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
body: string | null
|
||||||
|
message_type: string
|
||||||
|
status: string
|
||||||
|
media?: {
|
||||||
|
id?: string
|
||||||
|
mime_type?: string
|
||||||
|
caption?: string
|
||||||
|
filename?: string
|
||||||
|
} | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminWhatsAppTemplate = {
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
language: string
|
||||||
|
status?: string
|
||||||
|
category?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminWhatsAppSendPayload = {
|
||||||
|
to: string
|
||||||
|
message_type: 'text' | 'template' | 'image' | 'document' | 'audio' | 'video' | 'interactive_buttons' | 'interactive_list' | 'reaction'
|
||||||
|
text?: string
|
||||||
|
reply_to?: string
|
||||||
|
template_name?: string
|
||||||
|
template_language?: string
|
||||||
|
template_components?: Record<string, unknown>[]
|
||||||
|
media_link?: string
|
||||||
|
media_caption?: string
|
||||||
|
media_filename?: string
|
||||||
|
interactive_body?: string
|
||||||
|
interactive_header?: string
|
||||||
|
interactive_footer?: string
|
||||||
|
interactive_buttons?: Array<{ id: string; title: string }>
|
||||||
|
interactive_list_button?: string
|
||||||
|
interactive_list_sections?: Record<string, unknown>[]
|
||||||
|
reaction_message_id?: string
|
||||||
|
reaction_emoji?: string
|
||||||
|
}
|
||||||
|
|
||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
me: () => jsonRequest<{ username: string }>('/api/v1/admin/me'),
|
me: () => jsonRequest<{ username: string }>('/api/v1/admin/me'),
|
||||||
login: (username: string, password: string) =>
|
login: (username: string, password: string) =>
|
||||||
|
|
@ -708,6 +768,30 @@ export const adminApi = {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
}),
|
}),
|
||||||
|
whatsappConfig: () => jsonRequest<AdminWhatsAppConfig>('/api/v1/admin/whatsapp/config'),
|
||||||
|
updateWhatsAppConfig: (payload: {
|
||||||
|
phone_number_id?: string
|
||||||
|
business_account_id?: string
|
||||||
|
app_secret?: string
|
||||||
|
}) => jsonRequest<AdminWhatsAppConfig>('/api/v1/admin/whatsapp/config', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
listWhatsAppMessages: (limit = 50, peer?: string) =>
|
||||||
|
jsonRequest<AdminWhatsAppMessage[]>(
|
||||||
|
`/api/v1/admin/whatsapp/messages?limit=${limit}${peer ? `&peer=${encodeURIComponent(peer)}` : ''}`,
|
||||||
|
),
|
||||||
|
listWhatsAppTemplates: () =>
|
||||||
|
jsonRequest<{ data?: AdminWhatsAppTemplate[] }>('/api/v1/admin/whatsapp/templates'),
|
||||||
|
markWhatsAppRead: (waMessageId: string) =>
|
||||||
|
jsonRequest<{ success?: boolean }>(`/api/v1/admin/whatsapp/messages/${encodeURIComponent(waMessageId)}/read`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
sendWhatsAppMessage: (payload: AdminWhatsAppSendPayload) =>
|
||||||
|
jsonRequest<AdminWhatsAppMessage>('/api/v1/admin/whatsapp/messages/send', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LogType = 'outbound' | 'inbound' | 'sms' | 'otp'
|
export type LogType = 'outbound' | 'inbound' | 'sms' | 'otp'
|
||||||
|
|
@ -791,6 +875,7 @@ export const analyticsApi = {
|
||||||
|
|
||||||
export type WhatsAppSetup = {
|
export type WhatsAppSetup = {
|
||||||
webhook_url: string
|
webhook_url: string
|
||||||
|
verify_token: string | null
|
||||||
verify_token_configured: boolean
|
verify_token_configured: boolean
|
||||||
app_secret_configured: boolean
|
app_secret_configured: boolean
|
||||||
access_token_configured: boolean
|
access_token_configured: boolean
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue