This commit is contained in:
parent
92b35d297d
commit
621e75e41b
169
backend/main.py
169
backend/main.py
@ -1,6 +1,6 @@
|
||||
from fastapi import FastAPI, Depends, HTTPException, Query, Request, UploadFile, File
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse, PlainTextResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
@ -1032,6 +1032,8 @@ async def send_wedding_invitation_bulk(
|
||||
template_key=request_body.template_key or "wedding_invitation",
|
||||
to_phone=to_phone,
|
||||
params=params,
|
||||
event_id=str(event_id),
|
||||
guest_id=str(guest.id),
|
||||
)
|
||||
|
||||
# Commit any pending DB changes (e.g. RSVP token) on successful send
|
||||
@ -2015,7 +2017,9 @@ async def test_whatsapp_send(
|
||||
result = await service.send_by_template_key(
|
||||
template_key=template_key,
|
||||
to_phone=phone,
|
||||
params=params
|
||||
params=params,
|
||||
event_id=params.get("event_id"),
|
||||
guest_id=None,
|
||||
)
|
||||
|
||||
logger.info(f"[TEST] Message sent successfully: {result}")
|
||||
@ -2098,5 +2102,166 @@ async def fix_templates(db: Session = Depends(get_db)):
|
||||
)
|
||||
|
||||
|
||||
# ============================================
|
||||
# WhatsApp Webhook Endpoints
|
||||
# ============================================
|
||||
|
||||
@app.get("/whatsapp/webhook")
|
||||
async def whatsapp_webhook_verify(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Webhook verification endpoint for Meta WhatsApp Cloud API.
|
||||
Meta sends a GET request with hub.mode, hub.verify_token, and hub.challenge.
|
||||
We must respond with the challenge value to verify the webhook.
|
||||
|
||||
Example GET request from Meta:
|
||||
/whatsapp/webhook?hub.mode=subscribe&hub.challenge=1234567890&hub.verify_token=your_token
|
||||
"""
|
||||
query_params = request.query_params
|
||||
mode = query_params.get("hub.mode")
|
||||
token = query_params.get("hub.verify_token")
|
||||
challenge = query_params.get("hub.challenge")
|
||||
|
||||
verify_token = os.getenv("WHATSAPP_VERIFY_TOKEN", "")
|
||||
|
||||
logger.info(
|
||||
f"[WhatsApp Webhook] Verification request received: "
|
||||
f"mode={mode}, token_match={token == verify_token}, challenge={challenge}"
|
||||
)
|
||||
|
||||
if mode == "subscribe" and token == verify_token:
|
||||
logger.info("[WhatsApp Webhook] ✓ Verification successful")
|
||||
return PlainTextResponse(content=challenge)
|
||||
else:
|
||||
logger.warning(
|
||||
f"[WhatsApp Webhook] ✗ Verification failed: "
|
||||
f"mode={mode}, token_match={token == verify_token}"
|
||||
)
|
||||
raise HTTPException(status_code=403, detail="Verification failed")
|
||||
|
||||
|
||||
@app.post("/whatsapp/webhook")
|
||||
async def whatsapp_webhook(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Webhook endpoint for Meta WhatsApp Cloud API status updates.
|
||||
Receives POST callbacks for message status: sent, delivered, read, failed.
|
||||
|
||||
Payload structure from Meta:
|
||||
{
|
||||
"object": "whatsapp_business_account",
|
||||
"entry": [{
|
||||
"id": "PHONE_NUMBER_ID",
|
||||
"changes": [{
|
||||
"value": {
|
||||
"messaging_product": "whatsapp",
|
||||
"metadata": { "display_phone_number": "...", "phone_number_id": "..." },
|
||||
"statuses": [{
|
||||
"id": "wamid.xxx", // WhatsApp message ID
|
||||
"status": "delivered", // sent, delivered, read, failed
|
||||
"timestamp": "1234567890",
|
||||
"recipient_id": "972501234567",
|
||||
"errors": [{ "code": 131047, "title": "...", "message": "..." }] // if failed
|
||||
}]
|
||||
},
|
||||
"field": "messages"
|
||||
}]
|
||||
}]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
|
||||
# Log full webhook payload for debugging
|
||||
import json
|
||||
logger.info(
|
||||
f"[WhatsApp Webhook] Received callback:\n"
|
||||
f"{json.dumps(body, indent=2, ensure_ascii=False)}"
|
||||
)
|
||||
|
||||
# Extract status updates from webhook payload
|
||||
entries = body.get("entry", [])
|
||||
for entry in entries:
|
||||
changes = entry.get("changes", [])
|
||||
for change in changes:
|
||||
value = change.get("value", {})
|
||||
|
||||
# Process message statuses
|
||||
statuses = value.get("statuses", [])
|
||||
for status_update in statuses:
|
||||
wamid = status_update.get("id")
|
||||
status = status_update.get("status")
|
||||
timestamp = status_update.get("timestamp")
|
||||
recipient_id = status_update.get("recipient_id")
|
||||
errors = status_update.get("errors", [])
|
||||
|
||||
if not wamid:
|
||||
logger.warning("[WhatsApp Webhook] Status update missing wamid")
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f"[WhatsApp Webhook] Status update: "
|
||||
f"wamid={wamid}, status={status}, recipient={recipient_id}"
|
||||
)
|
||||
|
||||
# Find message in database
|
||||
msg = db.query(models.WhatsAppMessage).filter(
|
||||
models.WhatsAppMessage.wamid == wamid
|
||||
).first()
|
||||
|
||||
if not msg:
|
||||
# Message not found - create a new record
|
||||
logger.warning(
|
||||
f"[WhatsApp Webhook] Message {wamid} not found in database, creating new record"
|
||||
)
|
||||
msg = models.WhatsAppMessage(
|
||||
wamid=wamid,
|
||||
to_phone=recipient_id or "",
|
||||
status=status,
|
||||
)
|
||||
db.add(msg)
|
||||
else:
|
||||
# Update existing message status
|
||||
msg.status = status
|
||||
|
||||
# Update timestamps based on status
|
||||
from datetime import datetime
|
||||
if status == "delivered" and not msg.delivered_at:
|
||||
msg.delivered_at = datetime.utcnow()
|
||||
elif status == "read" and not msg.read_at:
|
||||
msg.read_at = datetime.utcnow()
|
||||
elif status == "failed" and not msg.failed_at:
|
||||
msg.failed_at = datetime.utcnow()
|
||||
|
||||
# Store error details
|
||||
if errors:
|
||||
error = errors[0]
|
||||
msg.error_code = str(error.get("code", ""))
|
||||
msg.error_title = error.get("title", "")
|
||||
msg.error_message = error.get("message", "")
|
||||
|
||||
logger.error(
|
||||
f"[WhatsApp Webhook] Message {wamid} FAILED: "
|
||||
f"code={msg.error_code}, title={msg.error_title}, "
|
||||
f"message={msg.error_message}"
|
||||
)
|
||||
|
||||
db.commit()
|
||||
logger.info(f"[WhatsApp Webhook] ✓ Updated message {wamid} to status: {status}")
|
||||
|
||||
# Meta expects 200 OK response
|
||||
return {"status": "ok"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[WhatsApp Webhook] Error processing webhook: {str(e)}", exc_info=True)
|
||||
db.rollback()
|
||||
# Still return 200 to prevent Meta from retrying
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
@ -140,6 +140,45 @@ class RsvpToken(Base):
|
||||
|
||||
# ── WhatsApp Custom Templates ──────────────────────────────────────────────────
|
||||
|
||||
class WhatsAppMessage(Base):
|
||||
"""
|
||||
Tracks WhatsApp messages sent through Meta Cloud API.
|
||||
Stores wamid and delivery status from webhooks.
|
||||
"""
|
||||
__tablename__ = "whatsapp_messages"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
wamid = Column(String, unique=True, nullable=False, index=True) # WhatsApp message ID from Meta
|
||||
|
||||
# Message context
|
||||
event_id = Column(UUID(as_uuid=True), ForeignKey("events.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
guest_id = Column(UUID(as_uuid=True), ForeignKey("guests_v2.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
to_phone = Column(String, nullable=False) # E.164 format
|
||||
|
||||
# Template info
|
||||
template_key = Column(String, nullable=True)
|
||||
template_name = Column(String, nullable=True)
|
||||
|
||||
# Status tracking (sent → delivered → read, or sent → failed)
|
||||
status = Column(String, default="sent", nullable=False) # sent, delivered, read, failed
|
||||
|
||||
# Error details (if failed)
|
||||
error_code = Column(String, nullable=True)
|
||||
error_title = Column(String, nullable=True)
|
||||
error_message = Column(Text, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
sent_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
delivered_at = Column(DateTime(timezone=True), nullable=True)
|
||||
read_at = Column(DateTime(timezone=True), nullable=True)
|
||||
failed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
event = relationship("Event")
|
||||
guest = relationship("Guest")
|
||||
|
||||
|
||||
class WhatsAppTemplate(Base):
|
||||
"""
|
||||
Stores custom WhatsApp message templates created by users.
|
||||
|
||||
@ -438,6 +438,8 @@ class WhatsAppService:
|
||||
template_key: str,
|
||||
to_phone: str,
|
||||
params: dict,
|
||||
event_id: Optional[str] = None,
|
||||
guest_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Send a WhatsApp template message using the template registry.
|
||||
@ -461,8 +463,13 @@ class WhatsAppService:
|
||||
language_code = tpl.get("language_code", "he")
|
||||
header_type = tpl.get("header_type", "TEXT")
|
||||
header_handle = tpl.get("header_handle", "")
|
||||
header_handle_key = tpl.get("header_handle_key", "") # For dynamic image/video/doc URLs
|
||||
button_type = tpl.get("button_type", "")
|
||||
button_url = tpl.get("button_url", "")
|
||||
|
||||
# If header_handle_key is specified, get the dynamic URL from params
|
||||
if header_handle_key and not header_handle:
|
||||
header_handle = str(params.get(header_handle_key, "")).strip()
|
||||
|
||||
header_values, body_values = build_params_list(self.db, template_key, params)
|
||||
|
||||
@ -622,6 +629,33 @@ class WhatsAppService:
|
||||
f"[WhatsApp] Message sent successfully! ID: {message_id}\n"
|
||||
f"Template: {meta_name}, To: {to_e164}, Status: {response.status_code}"
|
||||
)
|
||||
|
||||
# Save message to database for status tracking
|
||||
if self.db:
|
||||
try:
|
||||
from models import WhatsAppMessage
|
||||
from uuid import UUID
|
||||
|
||||
msg = WhatsAppMessage(
|
||||
wamid=message_id,
|
||||
event_id=UUID(event_id) if event_id else None,
|
||||
guest_id=UUID(guest_id) if guest_id else None,
|
||||
to_phone=to_e164,
|
||||
template_key=template_key,
|
||||
template_name=meta_name,
|
||||
status="sent",
|
||||
)
|
||||
self.db.add(msg)
|
||||
self.db.commit()
|
||||
logger.info(f"[WhatsApp] ✓ Saved message {message_id} to database")
|
||||
except Exception as db_error:
|
||||
logger.warning(
|
||||
f"[WhatsApp] Failed to save message to database: {db_error}"
|
||||
)
|
||||
# Don't fail the whole send operation if DB save fails
|
||||
if self.db:
|
||||
self.db.rollback()
|
||||
|
||||
return {
|
||||
"message_id": message_id,
|
||||
"status": "sent",
|
||||
|
||||
@ -242,15 +242,18 @@ TEMPLATES: Dict[str, Dict[str, Any]] = {
|
||||
},
|
||||
|
||||
# ── hina_invitation ────────────────────────────────────────────────────────
|
||||
# Special event template with both body and button parameters
|
||||
# Special event template with dynamic IMAGE header, body params, and button
|
||||
# Header: IMAGE (dynamic - sent from event.invitation_image_url)
|
||||
# Body {{1}} = contact_name (guest's name in greeting)
|
||||
# Button {{1}} = event_id (dynamic URL parameter)
|
||||
"hina_invitation": {
|
||||
"meta_name": "hina_invitation",
|
||||
"language_code": "he",
|
||||
"friendly_name": "הזמנה לחינה",
|
||||
"description": "הזמנה לאירוע חינה עם קישור דינמי",
|
||||
"header_params": [],
|
||||
"description": "הזמנה לאירוע חינה עם תמונה וקישור דינמי",
|
||||
"header_type": "IMAGE",
|
||||
"header_params": [], # IMAGE headers use header_handle, not header_params
|
||||
"header_handle_key": "invitation_image_url", # Dynamic - from params dict
|
||||
"body_params": ["contact_name"],
|
||||
"button_type": "URL",
|
||||
"button_text": "הצבע על הזמנה",
|
||||
@ -259,6 +262,7 @@ TEMPLATES: Dict[str, Dict[str, Any]] = {
|
||||
"fallbacks": {
|
||||
"contact_name": "חבר",
|
||||
"event_id": "event-id",
|
||||
"invitation_image_url": "https://api-invy.dvirlabs.com/uploads/default.jpg",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user