meta whatsapp chat

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

View file

@ -63,6 +63,17 @@ async def lifespan(_: FastAPI):
"ADD COLUMN IF NOT EXISTS monthly_outbound_price NUMERIC(10,2) NOT NULL DEFAULT 0"
)
)
conn.execute(
text(
"ALTER TABLE whatsapp_phone_assets "
"ADD COLUMN IF NOT EXISTS two_step_pin VARCHAR(6)"
)
)
conn.execute(text("ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS user_id UUID"))
conn.execute(text("ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS phone_number_id VARCHAR(64)"))
conn.execute(text("ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS contact_name VARCHAR(200)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_whatsapp_messages_user_id ON whatsapp_messages (user_id)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_whatsapp_messages_phone_number_id ON whatsapp_messages (phone_number_id)"))
yield

View file

@ -536,6 +536,9 @@ class WhatsAppMessage(Base):
message_type: Mapped[str] = mapped_column(String(24), default="text")
status: Mapped[str] = mapped_column(String(24), default="received", index=True)
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
user_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("user_accounts.id", ondelete="CASCADE"), nullable=True, index=True)
phone_number_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
contact_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
@ -605,6 +608,7 @@ class WhatsAppPhoneAsset(Base):
quality: Mapped[str | None] = mapped_column(String(24), nullable=True)
status: Mapped[str] = mapped_column(String(32), default="pending")
code_verification_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
two_step_pin: Mapped[str | None] = mapped_column(String(6), nullable=True)
account: Mapped[WhatsAppBusinessAccount] = relationship(back_populates="phones")

View file

@ -629,7 +629,12 @@ def admin_list_whatsapp_messages(
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> list[dict]:
query = select(WhatsAppMessage).order_by(WhatsAppMessage.created_at.desc()).limit(min(limit, 500))
query = (
select(WhatsAppMessage)
.where(WhatsAppMessage.user_id.is_(None))
.order_by(WhatsAppMessage.created_at.desc())
.limit(min(limit, 500))
)
if peer:
try:
normalized_peer = normalize_wa_number(peer)

View file

@ -20,8 +20,12 @@ from .whatsapp_onboarding import (
customer_token_for_phone,
developer_config,
disconnect_account,
download_account_media,
list_account_messages,
list_accounts,
mask_identifier,
register_phone_number,
send_account_message,
list_templates,
refresh_account,
signup_public_config,
@ -150,6 +154,26 @@ class SendWhatsAppTextRequest(BaseModel):
phone_number_id: str = Field(min_length=4, max_length=64)
class CustomerSendRequest(BaseModel):
to: str = Field(min_length=8, max_length=20)
message_type: str = "text"
text: str | None = Field(default=None, max_length=4096)
reply_to: str | None = None
template_name: str | None = None
template_language: str = "en"
media_link: str | None = None
media_caption: str | None = None
media_filename: str | None = None
interactive_body: str | None = None
interactive_buttons: list[dict[str, str]] | None = None
reaction_message_id: str | None = None
reaction_emoji: str | None = None
class RegisterPhoneRequest(BaseModel):
pin: str | None = Field(default=None, max_length=6)
class SignupCompleteRequest(BaseModel):
event: str = Field(min_length=1, max_length=80)
code: str | None = Field(default=None, max_length=2048)
@ -202,6 +226,48 @@ async def whatsapp_account_refresh(
return await refresh_account(db, user, account_id)
@router.get("/accounts/{account_id}/messages")
def whatsapp_account_messages(
account_id: str,
limit: int = Query(default=300, ge=1, le=500),
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> list[dict]:
return list_account_messages(db, user, account_id, limit)
@router.post("/accounts/{account_id}/messages/send")
async def whatsapp_account_send(
account_id: str,
payload: CustomerSendRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await send_account_message(db, user, account_id, payload.model_dump())
@router.get("/accounts/{account_id}/media/{media_id}")
async def whatsapp_account_media(
account_id: str,
media_id: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> Response:
content, mime_type = await download_account_media(db, user, account_id, media_id)
return Response(content=content, media_type=mime_type)
@router.post("/accounts/{account_id}/phones/{phone_id}/register")
async def whatsapp_register_phone(
account_id: str,
phone_id: str,
payload: RegisterPhoneRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await register_phone_number(db, user, account_id, phone_id, payload.pin)
@router.post("/accounts/{account_id}/disconnect")
async def whatsapp_account_disconnect(
account_id: str,

View file

@ -5,7 +5,8 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from .config import settings
from .models import WhatsAppMessage
from .models import WhatsAppMessage, WhatsAppPhoneAsset
from .whatsapp_config import resolved_whatsapp_phone_number_id
PHONE_DIGITS_RE = re.compile(r"^\d{10,15}$")
@ -50,6 +51,7 @@ def serialize_message(message: WhatsAppMessage) -> dict:
"message_type": message.message_type,
"status": message.status,
"media": media,
"contact_name": message.contact_name,
"created_at": message.created_at,
}
@ -93,13 +95,39 @@ def record_inbound_messages(db: Session, payload: dict[str, Any]) -> int:
if payload.get("object") != "whatsapp_business_account":
return 0
business = business_phone()
platform_number = business_phone()
platform_phone_id = resolved_whatsapp_phone_number_id(db)
created = 0
for entry in payload.get("entry", []):
for change in entry.get("changes", []):
if change.get("field") != "messages":
continue
value = change.get("value", {})
metadata = value.get("metadata") if isinstance(value.get("metadata"), dict) else {}
phone_number_id = str(metadata.get("phone_number_id") or "") or None
owner_id = None
line_number = platform_number
if phone_number_id and phone_number_id != platform_phone_id:
asset = db.scalar(
select(WhatsAppPhoneAsset).where(WhatsAppPhoneAsset.phone_number_id == phone_number_id)
)
if asset is None:
continue
owner_id = asset.user_id
raw_line = asset.display_phone or str(metadata.get("display_phone_number") or "")
try:
line_number = normalize_wa_number(raw_line)
except ValueError:
line_number = re.sub(r"\D", "", raw_line) or platform_number
names: dict[str, str] = {}
for contact in value.get("contacts") or []:
if not isinstance(contact, dict):
continue
wa_id = str(contact.get("wa_id") or "")
profile = contact.get("profile") if isinstance(contact.get("profile"), dict) else {}
name = profile.get("name")
if wa_id and isinstance(name, str) and name.strip():
names[wa_id] = name.strip()
for message in value.get("messages", []):
from_number = message.get("from") or ""
body = _extract_text_body(message)
@ -108,11 +136,14 @@ def record_inbound_messages(db: Session, payload: dict[str, Any]) -> int:
direction="inbound",
wa_message_id=message.get("id"),
from_number=from_number,
to_number=business,
to_number=line_number,
body=body,
message_type=message.get("type") or "text",
status="received",
payload=message,
user_id=owner_id,
phone_number_id=phone_number_id if owner_id else None,
contact_name=names.get(from_number),
)
)
created += 1
@ -130,12 +161,14 @@ def record_inbound_messages(db: Session, payload: dict[str, Any]) -> int:
WhatsAppMessage(
direction="outbound",
wa_message_id=wa_id,
from_number=business,
from_number=line_number,
to_number=status.get("recipient_id") or "",
body=None,
message_type="status",
status=status.get("status") or "unknown",
payload=status,
user_id=owner_id,
phone_number_id=phone_number_id if owner_id else None,
)
)
created += 1
@ -153,16 +186,21 @@ def record_outbound_message(
wa_message_id: str | None,
status: str = "sent",
payload: dict | None = None,
from_number: str | None = None,
user_id: Any = None,
phone_number_id: str | None = None,
) -> WhatsAppMessage:
message = WhatsAppMessage(
direction="outbound",
wa_message_id=wa_message_id,
from_number=business_phone(),
from_number=from_number or business_phone(),
to_number=to_number,
body=body,
message_type=message_type,
status=status,
payload=payload,
user_id=user_id,
phone_number_id=phone_number_id,
)
db.add(message)
db.commit()

View file

@ -8,6 +8,7 @@ addressed by another tenant.
from __future__ import annotations
import logging
import secrets
import uuid
from datetime import datetime, timezone
from typing import Any
@ -22,11 +23,14 @@ from .models import (
UserAccount,
WhatsAppBusinessAccount,
WhatsAppConnectionAudit,
WhatsAppMessage,
WhatsAppMetaConnection,
WhatsAppPhoneAsset,
WhatsAppWebhookEvent,
)
from .whatsapp_config import resolved_whatsapp_app_secret
from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError
from .whatsapp_messages import extract_wa_message_id, normalize_wa_number, record_outbound_message, serialize_message
logger = logging.getLogger(__name__)
@ -199,6 +203,7 @@ def _owned_account(db: Session, user: UserAccount, account_id: Any) -> WhatsAppB
def _serialize_phone(phone: WhatsAppPhoneAsset) -> dict[str, Any]:
verified = (phone.code_verification_status or "").upper()
return {
"id": str(phone.id),
"phone_number_id": phone.phone_number_id,
@ -207,6 +212,8 @@ def _serialize_phone(phone: WhatsAppPhoneAsset) -> dict[str, Any]:
"quality": phone.quality,
"status": phone.status,
"code_verification_status": phone.code_verification_status,
"needs_otp": phone.status != "connected" and verified in {"NOT_VERIFIED", "EXPIRED"},
"needs_registration": phone.status != "connected",
}
@ -344,6 +351,75 @@ def _apply_phones(
row.code_verification_status = item.get("code_verification_status")
def _pin_for_phone(phone: WhatsAppPhoneAsset, requested: str | None = None) -> str:
pin = (requested or phone.two_step_pin or "").strip()
if pin.isdigit() and len(pin) == 6:
phone.two_step_pin = pin
return pin
pin = "".join(secrets.choice("0123456789") for _ in range(6))
phone.two_step_pin = pin
return pin
async def _register_phone(token: str, phone: WhatsAppPhoneAsset, pin: str) -> str | None:
try:
await _graph(
"POST",
f"{phone.phone_number_id}/register",
token=token,
json_body={"messaging_product": "whatsapp", "pin": pin},
)
return None
except HTTPException as exc:
detail = str(exc.detail)
lowered = detail.lower()
if "already" in lowered and "register" in lowered:
return None
return detail[:500]
def _set_account_health(db: Session, user: UserAccount, account: WhatsAppBusinessAccount) -> None:
connected = any(phone.status == "connected" for phone in account.phones)
otp_pending = any(
phone.status != "connected"
and (phone.code_verification_status or "").upper() in {"NOT_VERIFIED", "EXPIRED"}
for phone in account.phones
)
if connected and account.management_status == "active":
account.status = "connected"
account.messaging_status = "active"
account.last_error = None
_audit(
db,
user,
"connected",
"WhatsApp Business connection verified",
connection_id=account.connection_id,
waba_id=account.waba_id,
)
return
account.status = "incomplete"
account.messaging_status = "unavailable"
if otp_pending:
account.last_error = (
"This number still needs the SMS or voice code from Meta. "
"It cannot stay logged into the WhatsApp or WhatsApp Business app while Cloud API uses it."
)
else:
account.last_error = account.last_error or (
"This number is attached to the WhatsApp Business Account, but Meta has not registered it for Cloud API yet. "
"Enter the 6-digit two-step PIN if this number already has one, then register it."
)
_audit(
db,
user,
"provision_incomplete",
account.last_error,
connection_id=account.connection_id,
waba_id=account.waba_id,
)
async def _provision_waba(
db: Session,
user: UserAccount,
@ -416,29 +492,31 @@ async def _provision_waba(
account.last_error = str(exc.detail)[:500]
_apply_phones(db, user, account, phones, session_phone_id)
for phone in account.phones:
verified = (phone.code_verification_status or "").upper()
if phone.status == "connected":
continue
if verified in {"NOT_VERIFIED", "EXPIRED"}:
continue
pin = _pin_for_phone(phone)
register_error = await _register_phone(token, phone, pin)
if register_error:
account.last_error = register_error
else:
phone.status = "connected"
try:
phones = await _load_phones(token, waba_id)
_apply_phones(db, user, account, phones, session_phone_id)
except HTTPException:
pass
subscribed = await _subscribe(token, waba_id)
template_count, templates_ok = await _template_count(token, waba_id)
account.template_count = template_count
account.templates_status = "active" if templates_ok else "unavailable"
account.webhooks_status = "active" if subscribed else "unavailable"
has_connected_phone = any(phone.status == "connected" for phone in account.phones)
if has_connected_phone and account.management_status == "active":
account.status = "connected"
account.messaging_status = "active"
_audit(db, user, "connected", "WhatsApp Business connection verified", connection_id=connection.id, waba_id=waba_id)
else:
account.status = "incomplete"
account.messaging_status = "unavailable"
account.last_error = account.last_error or "Phone number verification is not complete"
_audit(
db,
user,
"provision_incomplete",
account.last_error,
connection_id=connection.id,
waba_id=waba_id,
)
_set_account_health(db, user, account)
return account
@ -547,6 +625,49 @@ async def refresh_account(db: Session, user: UserAccount, account_id: Any) -> di
return serialize_account(account)
async def register_phone_number(
db: Session,
user: UserAccount,
account_id: Any,
phone_id: Any,
pin: str | None,
) -> dict[str, Any]:
account = _owned_account(db, user, account_id)
connection = account.connection
token = connection.access_token if connection else None
if connection is None or not token or connection.status == "disconnected":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reconnect this WhatsApp account before registering the number")
phone = next((item for item in account.phones if str(item.id) == str(phone_id)), None)
if phone is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Phone number not found on this account")
verified = (phone.code_verification_status or "").upper()
if verified in {"NOT_VERIFIED", "EXPIRED"}:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Complete the SMS or voice code for this number in Meta first. Then register it for Cloud API.",
)
submitted = (pin or "").strip()
if submitted and (not submitted.isdigit() or len(submitted) != 6):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Enter the 6-digit two-step PIN for this number")
register_pin = _pin_for_phone(phone, submitted or None)
register_error = await _register_phone(token, phone, register_pin)
if register_error:
account.last_error = register_error
_audit(db, user, "register_failed", register_error, connection_id=connection.id, waba_id=account.waba_id)
db.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=register_error)
try:
phones = await _load_phones(token, account.waba_id)
_apply_phones(db, user, account, phones, phone.phone_number_id)
except HTTPException:
phone.status = "connected"
_audit(db, user, "number_registered", "Registered this number for Cloud API", connection_id=connection.id, waba_id=account.waba_id)
_set_account_health(db, user, account)
db.commit()
db.refresh(account)
return serialize_account(account)
async def disconnect_account(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]:
account = _owned_account(db, user, account_id)
connection = account.connection
@ -692,3 +813,133 @@ def customer_token_for_phone(db: Session, user: UserAccount, phone_number_id: st
if not token:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reconnect this WhatsApp account before sending")
return token, phone.phone_number_id
def list_account_messages(db: Session, user: UserAccount, account_id: Any, limit: int = 300) -> list[dict[str, Any]]:
account = _owned_account(db, user, account_id)
phone_ids = [phone.phone_number_id for phone in account.phones]
if not phone_ids:
return []
rows = db.scalars(
select(WhatsAppMessage)
.where(WhatsAppMessage.user_id == user.id, WhatsAppMessage.phone_number_id.in_(phone_ids))
.order_by(WhatsAppMessage.created_at.desc())
.limit(min(limit, 500))
).all()
return [serialize_message(row) for row in rows]
def _messaging_phone(account: WhatsAppBusinessAccount) -> tuple[WhatsAppPhoneAsset, str]:
connection = account.connection
token = connection.access_token if connection else None
phone = next((item for item in account.phones if item.status == "connected"), None)
if phone is None or account.messaging_status != "active" or not token:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=account.last_error or "Messaging is unavailable until this number is connected on Cloud API",
)
return phone, token
async def send_account_message(db: Session, user: UserAccount, account_id: Any, payload: dict[str, Any]) -> dict[str, Any]:
account = _owned_account(db, user, account_id)
phone, token = _messaging_phone(account)
try:
recipient = normalize_wa_number(str(payload.get("to") or ""))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
client = WhatsAppGraphClient(access_token=token, phone_number_id=phone.phone_number_id, business_account_id=account.waba_id)
message_type = str(payload.get("message_type") or "text")
reply_to = payload.get("reply_to") or None
try:
if message_type == "text":
body = str(payload.get("text") or "").strip()
if not body:
raise HTTPException(status_code=422, detail="text is required")
result = await client.send_text(recipient, body, reply_to=reply_to)
elif message_type == "template":
name = str(payload.get("template_name") or "").strip()
if not name:
raise HTTPException(status_code=422, detail="template_name is required")
result = await client.send_template(recipient, name, language_code=str(payload.get("template_language") or "en"))
body = f"Template: {name}"
message_type = "template"
elif message_type in {"image", "document", "audio", "video"}:
link = str(payload.get("media_link") or "").strip()
if not link:
raise HTTPException(status_code=422, detail="media_link is required")
result = await client.send_media(
recipient,
message_type,
link=link,
caption=payload.get("media_caption"),
filename=payload.get("media_filename"),
reply_to=reply_to,
)
body = payload.get("media_caption") or f"[{message_type}]"
elif message_type == "interactive_buttons":
buttons = payload.get("interactive_buttons") or []
text = str(payload.get("interactive_body") or "").strip()
if not text or not buttons:
raise HTTPException(status_code=422, detail="interactive_body and interactive_buttons are required")
result = await client.send_interactive_buttons(recipient, text, buttons, reply_to=reply_to)
body = text
message_type = "interactive"
elif message_type == "reaction":
target = str(payload.get("reaction_message_id") or "")
emoji = str(payload.get("reaction_emoji") or "")
if not target or not emoji:
raise HTTPException(status_code=422, detail="reaction_message_id and reaction_emoji are required")
result = await client.send_reaction(recipient, target, emoji)
body = emoji
else:
raise HTTPException(status_code=422, detail="Unsupported message type")
except WhatsAppGraphError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
try:
from_number = normalize_wa_number(phone.display_phone or "")
except ValueError:
from_number = phone.display_phone or phone.phone_number_id
message = record_outbound_message(
db,
to_number=recipient,
body=body,
message_type=message_type,
wa_message_id=extract_wa_message_id(result),
status="sent",
payload=result if isinstance(result, dict) else None,
from_number=from_number,
user_id=user.id,
phone_number_id=phone.phone_number_id,
)
return serialize_message(message)
async def download_account_media(db: Session, user: UserAccount, account_id: Any, media_id: str) -> tuple[bytes, str]:
account = _owned_account(db, user, account_id)
token = account.connection.access_token if account.connection else None
if not token:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reconnect this WhatsApp account before opening media")
phone_ids = [phone.phone_number_id for phone in account.phones]
if not phone_ids:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Media not found")
rows = db.scalars(
select(WhatsAppMessage).where(
WhatsAppMessage.user_id == user.id,
WhatsAppMessage.phone_number_id.in_(phone_ids),
)
).all()
allowed = False
for row in rows:
payload = row.payload if isinstance(row.payload, dict) else {}
media = payload.get(row.message_type)
if isinstance(media, dict) and str(media.get("id") or "") == media_id:
allowed = True
break
if not allowed:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Media not found")
client = WhatsAppGraphClient(access_token=token, phone_number_id=phone_ids[0] if phone_ids else None)
try:
return await client.download_media(media_id)
except WhatsAppGraphError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc