meta whatsapp chat
This commit is contained in:
parent
54f75387eb
commit
5ced0d0812
10 changed files with 1015 additions and 25 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue