privacy policy and business integration
This commit is contained in:
parent
141ca9fda3
commit
cd3751219d
16 changed files with 1592 additions and 30 deletions
|
|
@ -64,4 +64,6 @@ WHATSAPP_PHONE_NUMBER_ID=
|
|||
WHATSAPP_BUSINESS_ACCOUNT_ID=
|
||||
# Meta App ID
|
||||
META_APP_ID=
|
||||
# Urmi business WhatsApp number (E.164 without +)
|
||||
WHATSAPP_BUSINESS_PHONE=919411257916
|
||||
WHATSAPP_API_VERSION=v21.0
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ class Settings(BaseSettings):
|
|||
whatsapp_phone_number_id: str = ""
|
||||
whatsapp_business_account_id: str = ""
|
||||
meta_app_id: str = ""
|
||||
# Public HTTPS base for webhook callback (no trailing slash), e.g. https://api.urmiai.com
|
||||
whatsapp_business_phone: str = "919411257916"
|
||||
# Public HTTPS base for webhook callback (no trailing slash), e.g. https://urmiai.com
|
||||
public_api_base_url: str = ""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Numeric, String, UniqueConstraint, func
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Numeric, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import JSON, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -508,3 +508,32 @@ class WhatsAppWebhookEvent(Base):
|
|||
status: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
payload: Mapped[dict] = mapped_column(JSON)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
|
||||
class WhatsAppMessage(Base):
|
||||
"""Inbound and outbound WhatsApp messages for Urmi's business number."""
|
||||
|
||||
__tablename__ = "whatsapp_messages"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
direction: Mapped[str] = mapped_column(String(16), index=True)
|
||||
wa_message_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
from_number: Mapped[str] = mapped_column(String(32), index=True)
|
||||
to_number: Mapped[str] = mapped_column(String(32), index=True)
|
||||
body: Mapped[str | None] = mapped_column(String(4096), nullable=True)
|
||||
message_type: Mapped[str] = mapped_column(String(24), default="text")
|
||||
status: Mapped[str] = mapped_column(String(24), default="received", index=True)
|
||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
|
||||
class PlatformSetting(Base):
|
||||
"""Runtime overrides for platform credentials (admin-editable)."""
|
||||
|
||||
__tablename__ = "platform_settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
value: Mapped[str] = mapped_column(Text, default="")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
|
|
|||
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 uuid import UUID
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
|
@ -22,9 +21,24 @@ from .models import (
|
|||
TemplateConfig,
|
||||
UrmiOtpProject,
|
||||
UserAccount,
|
||||
WhatsAppMessage,
|
||||
)
|
||||
from .phone_number_validators import normalize_phone_number, validate_per_min_price, validate_price
|
||||
from .routes_urmiotp import serialize_project
|
||||
from .whatsapp_config import (
|
||||
resolved_whatsapp_app_secret,
|
||||
resolved_whatsapp_business_account_id,
|
||||
resolved_whatsapp_phone_number_id,
|
||||
whatsapp_ready_to_send,
|
||||
)
|
||||
from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError
|
||||
from .whatsapp_messages import (
|
||||
extract_wa_message_id,
|
||||
normalize_wa_number,
|
||||
record_outbound_message,
|
||||
serialize_message,
|
||||
)
|
||||
from .platform_settings import set_setting
|
||||
|
||||
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
|
||||
|
||||
|
|
@ -516,3 +530,244 @@ def update_phone_number(
|
|||
db.commit()
|
||||
db.refresh(number)
|
||||
return serialize_admin_phone_number(number)
|
||||
|
||||
|
||||
class AdminWhatsAppSendRequest(BaseModel):
|
||||
to: str = Field(min_length=8, max_length=20)
|
||||
message_type: Literal[
|
||||
"text",
|
||||
"template",
|
||||
"image",
|
||||
"document",
|
||||
"audio",
|
||||
"video",
|
||||
"interactive_buttons",
|
||||
"interactive_list",
|
||||
"reaction",
|
||||
] = "text"
|
||||
text: str | None = Field(default=None, max_length=4096)
|
||||
reply_to: str | None = None
|
||||
template_name: str | None = None
|
||||
template_language: str = "en"
|
||||
template_components: list[dict] | None = None
|
||||
media_link: str | None = None
|
||||
media_caption: str | None = None
|
||||
media_filename: str | None = None
|
||||
interactive_body: str | None = None
|
||||
interactive_header: str | None = None
|
||||
interactive_footer: str | None = None
|
||||
interactive_buttons: list[dict[str, str]] | None = None
|
||||
interactive_list_button: str | None = None
|
||||
interactive_list_sections: list[dict] | None = None
|
||||
reaction_message_id: str | None = None
|
||||
reaction_emoji: str | None = None
|
||||
|
||||
|
||||
class AdminWhatsAppConfigUpdate(BaseModel):
|
||||
phone_number_id: str | None = None
|
||||
business_account_id: str | None = None
|
||||
app_secret: str | None = None
|
||||
|
||||
|
||||
def _whatsapp_client(db: Session) -> WhatsAppGraphClient:
|
||||
return WhatsAppGraphClient(
|
||||
phone_number_id=resolved_whatsapp_phone_number_id(db),
|
||||
business_account_id=resolved_whatsapp_business_account_id(db),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/whatsapp/config")
|
||||
def admin_whatsapp_config(db: Session = Depends(get_db), _: str = Depends(current_admin)) -> dict:
|
||||
return {
|
||||
"business_phone": settings.whatsapp_business_phone,
|
||||
"phone_number_id": resolved_whatsapp_phone_number_id(db) or None,
|
||||
"business_account_id": resolved_whatsapp_business_account_id(db) or None,
|
||||
"meta_app_id": settings.meta_app_id or None,
|
||||
"access_token_configured": bool(settings.whatsapp_access_token),
|
||||
"app_secret_configured": bool(resolved_whatsapp_app_secret(db)),
|
||||
"webhook_url": settings.whatsapp_webhook_url,
|
||||
"verify_token_configured": bool(settings.whatsapp_verify_token),
|
||||
"ready_to_send": whatsapp_ready_to_send(db),
|
||||
"supported_operations": [
|
||||
"text",
|
||||
"template",
|
||||
"image",
|
||||
"document",
|
||||
"audio",
|
||||
"video",
|
||||
"interactive_buttons",
|
||||
"interactive_list",
|
||||
"reaction",
|
||||
"mark_read",
|
||||
"media_download",
|
||||
"list_templates",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/whatsapp/config")
|
||||
def admin_update_whatsapp_config(
|
||||
payload: AdminWhatsAppConfigUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(current_admin),
|
||||
) -> dict:
|
||||
if payload.phone_number_id is not None:
|
||||
set_setting(db, "whatsapp_phone_number_id", payload.phone_number_id.strip())
|
||||
if payload.business_account_id is not None:
|
||||
set_setting(db, "whatsapp_business_account_id", payload.business_account_id.strip())
|
||||
if payload.app_secret is not None:
|
||||
set_setting(db, "whatsapp_app_secret", payload.app_secret.strip())
|
||||
return admin_whatsapp_config(db=db, _=_)
|
||||
|
||||
|
||||
@router.get("/whatsapp/messages")
|
||||
def admin_list_whatsapp_messages(
|
||||
limit: int = 50,
|
||||
peer: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(current_admin),
|
||||
) -> list[dict]:
|
||||
query = select(WhatsAppMessage).order_by(WhatsAppMessage.created_at.desc()).limit(min(limit, 500))
|
||||
if peer:
|
||||
try:
|
||||
normalized_peer = normalize_wa_number(peer)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
query = query.where(
|
||||
(WhatsAppMessage.from_number == normalized_peer) | (WhatsAppMessage.to_number == normalized_peer)
|
||||
)
|
||||
messages = db.scalars(query).all()
|
||||
return [serialize_message(message) for message in messages]
|
||||
|
||||
|
||||
@router.get("/whatsapp/templates")
|
||||
async def admin_list_whatsapp_templates(
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(current_admin),
|
||||
) -> dict:
|
||||
client = _whatsapp_client(db)
|
||||
try:
|
||||
return await client.list_templates()
|
||||
except WhatsAppGraphError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
|
||||
@router.get("/whatsapp/media/{media_id}")
|
||||
async def admin_download_whatsapp_media(
|
||||
media_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(current_admin),
|
||||
) -> Response:
|
||||
client = _whatsapp_client(db)
|
||||
try:
|
||||
content, mime_type = await client.download_media(media_id)
|
||||
except WhatsAppGraphError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
return Response(content=content, media_type=mime_type)
|
||||
|
||||
|
||||
@router.post("/whatsapp/messages/{wa_message_id}/read")
|
||||
async def admin_mark_whatsapp_read(
|
||||
wa_message_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(current_admin),
|
||||
) -> dict:
|
||||
client = _whatsapp_client(db)
|
||||
try:
|
||||
result = await client.mark_read(wa_message_id)
|
||||
except WhatsAppGraphError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/whatsapp/messages/send")
|
||||
async def admin_send_whatsapp_message(
|
||||
payload: AdminWhatsAppSendRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(current_admin),
|
||||
) -> dict:
|
||||
try:
|
||||
recipient = normalize_wa_number(payload.to)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
client = _whatsapp_client(db)
|
||||
message_type = payload.message_type
|
||||
try:
|
||||
if message_type == "text":
|
||||
if not payload.text:
|
||||
raise HTTPException(status_code=422, detail="text is required")
|
||||
result = await client.send_text(recipient, payload.text.strip(), reply_to=payload.reply_to)
|
||||
body = payload.text.strip()
|
||||
elif message_type == "template":
|
||||
if not payload.template_name:
|
||||
raise HTTPException(status_code=422, detail="template_name is required")
|
||||
result = await client.send_template(
|
||||
recipient,
|
||||
payload.template_name,
|
||||
language_code=payload.template_language,
|
||||
components=payload.template_components,
|
||||
)
|
||||
body = f"Template: {payload.template_name}"
|
||||
elif message_type in {"image", "document", "audio", "video"}:
|
||||
if not payload.media_link:
|
||||
raise HTTPException(status_code=422, detail="media_link is required")
|
||||
result = await client.send_media(
|
||||
recipient,
|
||||
message_type,
|
||||
link=payload.media_link,
|
||||
caption=payload.media_caption,
|
||||
filename=payload.media_filename,
|
||||
reply_to=payload.reply_to,
|
||||
)
|
||||
body = payload.media_caption or f"[{message_type}]"
|
||||
elif message_type == "interactive_buttons":
|
||||
if not payload.interactive_body or not payload.interactive_buttons:
|
||||
raise HTTPException(status_code=422, detail="interactive_body and interactive_buttons are required")
|
||||
result = await client.send_interactive_buttons(
|
||||
recipient,
|
||||
payload.interactive_body,
|
||||
payload.interactive_buttons,
|
||||
header=payload.interactive_header,
|
||||
footer=payload.interactive_footer,
|
||||
reply_to=payload.reply_to,
|
||||
)
|
||||
body = payload.interactive_body
|
||||
message_type = "interactive"
|
||||
elif message_type == "interactive_list":
|
||||
if not payload.interactive_body or not payload.interactive_list_button or not payload.interactive_list_sections:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="interactive_body, interactive_list_button, and interactive_list_sections are required",
|
||||
)
|
||||
result = await client.send_interactive_list(
|
||||
recipient,
|
||||
payload.interactive_body,
|
||||
payload.interactive_list_button,
|
||||
payload.interactive_list_sections,
|
||||
header=payload.interactive_header,
|
||||
footer=payload.interactive_footer,
|
||||
reply_to=payload.reply_to,
|
||||
)
|
||||
body = payload.interactive_body
|
||||
message_type = "interactive"
|
||||
elif message_type == "reaction":
|
||||
if not payload.reaction_message_id or not payload.reaction_emoji:
|
||||
raise HTTPException(status_code=422, detail="reaction_message_id and reaction_emoji are required")
|
||||
result = await client.send_reaction(recipient, payload.reaction_message_id, payload.reaction_emoji)
|
||||
body = payload.reaction_emoji
|
||||
else:
|
||||
raise HTTPException(status_code=422, detail="Unsupported message type")
|
||||
except WhatsAppGraphError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
message = record_outbound_message(
|
||||
db,
|
||||
to_number=recipient,
|
||||
body=body,
|
||||
message_type=message_type,
|
||||
wa_message_id=extract_wa_message_id(result),
|
||||
status="sent",
|
||||
payload=result if isinstance(result, dict) else None,
|
||||
)
|
||||
return serialize_message(message)
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@ from .config import settings
|
|||
from .database import get_db
|
||||
from .models import UserAccount, WhatsAppWebhookEvent
|
||||
from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError
|
||||
from .whatsapp_config import resolved_whatsapp_app_secret
|
||||
from .whatsapp_security import verify_meta_signature
|
||||
from .whatsapp_webhook import persist_webhook_payload
|
||||
from .whatsapp_messages import record_inbound_messages
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -73,9 +75,10 @@ async def receive_webhook(
|
|||
) -> dict[str, str]:
|
||||
"""Receive inbound WhatsApp Business Platform webhook payloads."""
|
||||
raw_body = await request.body()
|
||||
app_secret = resolved_whatsapp_app_secret(db)
|
||||
|
||||
if settings.whatsapp_app_secret:
|
||||
if not verify_meta_signature(settings.whatsapp_app_secret, raw_body, x_hub_signature_256):
|
||||
if app_secret:
|
||||
if not verify_meta_signature(app_secret, raw_body, x_hub_signature_256):
|
||||
logger.warning("WhatsApp webhook signature validation failed")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid signature")
|
||||
elif settings.environment == "production":
|
||||
|
|
@ -90,6 +93,7 @@ async def receive_webhook(
|
|||
payload = {}
|
||||
|
||||
persist_webhook_payload(db, payload)
|
||||
record_inbound_messages(db, payload)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
|
@ -100,6 +104,7 @@ def whatsapp_setup(
|
|||
"""Console setup info — paste these values into Meta Developer Console."""
|
||||
return {
|
||||
"webhook_url": settings.whatsapp_webhook_url,
|
||||
"verify_token": settings.whatsapp_verify_token or None,
|
||||
"verify_token_configured": bool(settings.whatsapp_verify_token),
|
||||
"app_secret_configured": bool(settings.whatsapp_app_secret),
|
||||
"access_token_configured": bool(settings.whatsapp_access_token),
|
||||
|
|
|
|||
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
|
||||
|
||||
from .config import settings
|
||||
|
|
@ -16,36 +18,34 @@ class WhatsAppGraphClient:
|
|||
*,
|
||||
access_token: str | None = None,
|
||||
phone_number_id: str | None = None,
|
||||
business_account_id: str | None = None,
|
||||
api_version: str | None = None,
|
||||
) -> None:
|
||||
self.access_token = access_token or settings.whatsapp_access_token
|
||||
self.phone_number_id = phone_number_id or settings.whatsapp_phone_number_id
|
||||
self.business_account_id = business_account_id or settings.whatsapp_business_account_id
|
||||
self.api_version = api_version or settings.whatsapp_api_version
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.access_token and self.phone_number_id)
|
||||
|
||||
def _graph_url(self, path: str) -> str:
|
||||
return f"https://graph.facebook.com/{self.api_version}/{path.lstrip('/')}"
|
||||
|
||||
def _messages_url(self) -> str:
|
||||
return (
|
||||
f"https://graph.facebook.com/{self.api_version}/"
|
||||
f"{self.phone_number_id}/messages"
|
||||
)
|
||||
return self._graph_url(f"{self.phone_number_id}/messages")
|
||||
|
||||
async def send_text(self, to: str, body: str) -> dict:
|
||||
if not self.configured:
|
||||
raise WhatsAppGraphError("WhatsApp Graph API is not configured", status_code=503)
|
||||
|
||||
payload = {
|
||||
"messaging_product": "whatsapp",
|
||||
"recipient_type": "individual",
|
||||
"to": to,
|
||||
"type": "text",
|
||||
"text": {"preview_url": False, "body": body},
|
||||
}
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
json_payload: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
headers = {"Authorization": f"Bearer {self.access_token}"}
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(self._messages_url(), json=payload, headers=headers)
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.request(method, url, json=json_payload, headers=headers)
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
|
|
@ -53,4 +53,173 @@ class WhatsAppGraphClient:
|
|||
if response.status_code >= 400:
|
||||
detail = data.get("error", {}).get("message") if isinstance(data, dict) else response.text
|
||||
raise WhatsAppGraphError(detail or "WhatsApp Graph API request failed", status_code=response.status_code)
|
||||
return data
|
||||
return data if isinstance(data, dict) else {"data": data}
|
||||
|
||||
async def _send_message(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if not self.configured:
|
||||
raise WhatsAppGraphError("WhatsApp Graph API is not configured", status_code=503)
|
||||
body = {"messaging_product": "whatsapp", **payload}
|
||||
return await self._request("POST", self._messages_url(), json_payload=body)
|
||||
|
||||
async def send_text(self, to: str, body: str, reply_to: str | None = None) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"recipient_type": "individual",
|
||||
"to": to,
|
||||
"type": "text",
|
||||
"text": {"preview_url": False, "body": body},
|
||||
}
|
||||
if reply_to:
|
||||
payload["context"] = {"message_id": reply_to}
|
||||
return await self._send_message(payload)
|
||||
|
||||
async def send_media(
|
||||
self,
|
||||
to: str,
|
||||
media_type: str,
|
||||
*,
|
||||
link: str | None = None,
|
||||
media_id: str | None = None,
|
||||
caption: str | None = None,
|
||||
filename: str | None = None,
|
||||
reply_to: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
media: dict[str, Any] = {}
|
||||
if media_id:
|
||||
media["id"] = media_id
|
||||
elif link:
|
||||
media["link"] = link
|
||||
else:
|
||||
raise WhatsAppGraphError("Media requires link or media_id", status_code=422)
|
||||
if caption and media_type in {"image", "document", "video"}:
|
||||
media["caption"] = caption
|
||||
if filename and media_type == "document":
|
||||
media["filename"] = filename
|
||||
payload: dict[str, Any] = {
|
||||
"recipient_type": "individual",
|
||||
"to": to,
|
||||
"type": media_type,
|
||||
media_type: media,
|
||||
}
|
||||
if reply_to:
|
||||
payload["context"] = {"message_id": reply_to}
|
||||
return await self._send_message(payload)
|
||||
|
||||
async def send_template(
|
||||
self,
|
||||
to: str,
|
||||
name: str,
|
||||
language_code: str = "en",
|
||||
components: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
template: dict[str, Any] = {"name": name, "language": {"code": language_code}}
|
||||
if components:
|
||||
template["components"] = components
|
||||
return await self._send_message(
|
||||
{
|
||||
"recipient_type": "individual",
|
||||
"to": to,
|
||||
"type": "template",
|
||||
"template": template,
|
||||
}
|
||||
)
|
||||
|
||||
async def send_interactive_buttons(
|
||||
self,
|
||||
to: str,
|
||||
body: str,
|
||||
buttons: list[dict[str, str]],
|
||||
*,
|
||||
header: str | None = None,
|
||||
footer: str | None = None,
|
||||
reply_to: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
interactive: dict[str, Any] = {
|
||||
"type": "button",
|
||||
"body": {"text": body},
|
||||
"action": {
|
||||
"buttons": [
|
||||
{"type": "reply", "reply": {"id": button["id"], "title": button["title"][:20]}}
|
||||
for button in buttons[:3]
|
||||
]
|
||||
},
|
||||
}
|
||||
if header:
|
||||
interactive["header"] = {"type": "text", "text": header[:60]}
|
||||
if footer:
|
||||
interactive["footer"] = {"text": footer[:60]}
|
||||
payload: dict[str, Any] = {
|
||||
"recipient_type": "individual",
|
||||
"to": to,
|
||||
"type": "interactive",
|
||||
"interactive": interactive,
|
||||
}
|
||||
if reply_to:
|
||||
payload["context"] = {"message_id": reply_to}
|
||||
return await self._send_message(payload)
|
||||
|
||||
async def send_interactive_list(
|
||||
self,
|
||||
to: str,
|
||||
body: str,
|
||||
button_text: str,
|
||||
sections: list[dict[str, Any]],
|
||||
*,
|
||||
header: str | None = None,
|
||||
footer: str | None = None,
|
||||
reply_to: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
interactive: dict[str, Any] = {
|
||||
"type": "list",
|
||||
"body": {"text": body},
|
||||
"action": {"button": button_text[:20], "sections": sections},
|
||||
}
|
||||
if header:
|
||||
interactive["header"] = {"type": "text", "text": header[:60]}
|
||||
if footer:
|
||||
interactive["footer"] = {"text": footer[:60]}
|
||||
payload: dict[str, Any] = {
|
||||
"recipient_type": "individual",
|
||||
"to": to,
|
||||
"type": "interactive",
|
||||
"interactive": interactive,
|
||||
}
|
||||
if reply_to:
|
||||
payload["context"] = {"message_id": reply_to}
|
||||
return await self._send_message(payload)
|
||||
|
||||
async def send_reaction(self, to: str, message_id: str, emoji: str) -> dict[str, Any]:
|
||||
return await self._send_message(
|
||||
{
|
||||
"recipient_type": "individual",
|
||||
"to": to,
|
||||
"type": "reaction",
|
||||
"reaction": {"message_id": message_id, "emoji": emoji},
|
||||
}
|
||||
)
|
||||
|
||||
async def mark_read(self, message_id: str) -> dict[str, Any]:
|
||||
return await self._send_message({"status": "read", "message_id": message_id})
|
||||
|
||||
async def list_templates(self, limit: int = 50) -> dict[str, Any]:
|
||||
if not self.access_token or not self.business_account_id:
|
||||
raise WhatsAppGraphError("WhatsApp business account ID is not configured", status_code=503)
|
||||
url = self._graph_url(f"{self.business_account_id}/message_templates?limit={limit}")
|
||||
return await self._request("GET", url)
|
||||
|
||||
async def get_media_metadata(self, media_id: str) -> dict[str, Any]:
|
||||
if not self.access_token:
|
||||
raise WhatsAppGraphError("WhatsApp access token is not configured", status_code=503)
|
||||
return await self._request("GET", self._graph_url(media_id))
|
||||
|
||||
async def download_media(self, media_id: str) -> tuple[bytes, str]:
|
||||
metadata = await self.get_media_metadata(media_id)
|
||||
download_url = metadata.get("url")
|
||||
mime_type = metadata.get("mime_type") or "application/octet-stream"
|
||||
if not download_url:
|
||||
raise WhatsAppGraphError("Media URL not available", status_code=404)
|
||||
headers = {"Authorization": f"Bearer {self.access_token}"}
|
||||
async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
|
||||
response = await client.get(download_url, headers=headers)
|
||||
if response.status_code >= 400:
|
||||
raise WhatsAppGraphError("Failed to download media", status_code=response.status_code)
|
||||
return response.content, mime_type
|
||||
|
|
|
|||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue