privacy policy and business integration

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

View file

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

View file

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

View file

@ -0,0 +1,17 @@
from sqlalchemy.orm import Session
from .models import PlatformSetting
def get_setting(db: Session, key: str, default: str = "") -> str:
row = db.get(PlatformSetting, key)
return row.value if row and row.value else default
def set_setting(db: Session, key: str, value: str) -> None:
row = db.get(PlatformSetting, key)
if row:
row.value = value
else:
db.add(PlatformSetting(key=key, value=value))
db.commit()

View file

@ -1,7 +1,6 @@
from datetime import datetime, timezone
from 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)

View file

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

View file

@ -0,0 +1,20 @@
from sqlalchemy.orm import Session
from .config import settings
from .platform_settings import get_setting
def resolved_whatsapp_phone_number_id(db: Session) -> str:
return get_setting(db, "whatsapp_phone_number_id") or settings.whatsapp_phone_number_id
def resolved_whatsapp_business_account_id(db: Session) -> str:
return get_setting(db, "whatsapp_business_account_id") or settings.whatsapp_business_account_id
def resolved_whatsapp_app_secret(db: Session) -> str:
return get_setting(db, "whatsapp_app_secret") or settings.whatsapp_app_secret
def whatsapp_ready_to_send(db: Session) -> bool:
return bool(settings.whatsapp_access_token and resolved_whatsapp_phone_number_id(db))

View file

@ -1,3 +1,5 @@
from typing import Any
import httpx
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

View file

@ -0,0 +1,180 @@
import re
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from .config import settings
from .models import WhatsAppMessage
PHONE_DIGITS_RE = re.compile(r"^\d{10,15}$")
def normalize_wa_number(value: str) -> str:
digits = re.sub(r"\D", "", value.strip())
if len(digits) == 10:
digits = f"91{digits}"
if not PHONE_DIGITS_RE.match(digits):
raise ValueError("Invalid WhatsApp number")
return digits
def business_phone() -> str:
return normalize_wa_number(settings.whatsapp_business_phone)
def _media_from_payload(message_type: str, payload: dict[str, Any] | None) -> dict[str, Any] | None:
if not payload or message_type not in {"image", "audio", "video", "document", "sticker"}:
return None
media = payload.get(message_type) or {}
if not isinstance(media, dict):
return None
return {
"id": media.get("id"),
"mime_type": media.get("mime_type"),
"caption": media.get("caption"),
"filename": media.get("filename"),
}
def serialize_message(message: WhatsAppMessage) -> dict:
payload = message.payload if isinstance(message.payload, dict) else None
media = _media_from_payload(message.message_type, payload)
return {
"id": str(message.id),
"direction": message.direction,
"wa_message_id": message.wa_message_id,
"from": message.from_number,
"to": message.to_number,
"body": message.body,
"message_type": message.message_type,
"status": message.status,
"media": media,
"created_at": message.created_at,
}
def _extract_text_body(message: dict[str, Any]) -> str | None:
msg_type = message.get("type")
if msg_type == "text":
return (message.get("text") or {}).get("body")
if msg_type == "button":
return (message.get("button") or {}).get("text")
if msg_type == "interactive":
interactive = message.get("interactive") or {}
if interactive.get("type") == "button_reply":
return (interactive.get("button_reply") or {}).get("title")
if interactive.get("type") == "list_reply":
return (interactive.get("list_reply") or {}).get("title")
if msg_type in {"image", "video", "document"}:
return (message.get(msg_type) or {}).get("caption")
if msg_type == "reaction":
reaction = message.get("reaction") or {}
emoji = reaction.get("emoji")
return f"Reacted {emoji}" if emoji else "Reaction"
if msg_type == "location":
location = message.get("location") or {}
name = location.get("name") or location.get("address")
if name:
return f"Location: {name}"
lat = location.get("latitude")
lng = location.get("longitude")
if lat is not None and lng is not None:
return f"Location: {lat}, {lng}"
if msg_type == "contacts":
contacts = message.get("contacts") or []
if contacts:
name = ((contacts[0].get("name") or {}).get("formatted_name")) or "Contact"
return f"Contact: {name}"
return None
def record_inbound_messages(db: Session, payload: dict[str, Any]) -> int:
if payload.get("object") != "whatsapp_business_account":
return 0
business = business_phone()
created = 0
for entry in payload.get("entry", []):
for change in entry.get("changes", []):
if change.get("field") != "messages":
continue
value = change.get("value", {})
for message in value.get("messages", []):
from_number = message.get("from") or ""
body = _extract_text_body(message)
db.add(
WhatsAppMessage(
direction="inbound",
wa_message_id=message.get("id"),
from_number=from_number,
to_number=business,
body=body,
message_type=message.get("type") or "text",
status="received",
payload=message,
)
)
created += 1
for status in value.get("statuses", []):
wa_id = status.get("id")
if not wa_id:
continue
existing = db.scalar(
select(WhatsAppMessage).where(WhatsAppMessage.wa_message_id == wa_id)
)
if existing:
existing.status = status.get("status") or existing.status
else:
db.add(
WhatsAppMessage(
direction="outbound",
wa_message_id=wa_id,
from_number=business,
to_number=status.get("recipient_id") or "",
body=None,
message_type="status",
status=status.get("status") or "unknown",
payload=status,
)
)
created += 1
if created:
db.commit()
return created
def record_outbound_message(
db: Session,
*,
to_number: str,
body: str | None,
message_type: str = "text",
wa_message_id: str | None,
status: str = "sent",
payload: dict | None = None,
) -> WhatsAppMessage:
message = WhatsAppMessage(
direction="outbound",
wa_message_id=wa_message_id,
from_number=business_phone(),
to_number=to_number,
body=body,
message_type=message_type,
status=status,
payload=payload,
)
db.add(message)
db.commit()
db.refresh(message)
return message
def extract_wa_message_id(result: dict[str, Any] | None) -> str | None:
if not isinstance(result, dict):
return None
messages = result.get("messages")
if isinstance(messages, list) and messages:
message_id = messages[0].get("id")
return message_id if isinstance(message_id, str) else None
return None