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 import FastAPI, Depends, HTTPException, Query, Request, UploadFile, File
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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 fastapi.staticfiles import StaticFiles
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
@ -1032,6 +1032,8 @@ async def send_wedding_invitation_bulk(
|
|||||||
template_key=request_body.template_key or "wedding_invitation",
|
template_key=request_body.template_key or "wedding_invitation",
|
||||||
to_phone=to_phone,
|
to_phone=to_phone,
|
||||||
params=params,
|
params=params,
|
||||||
|
event_id=str(event_id),
|
||||||
|
guest_id=str(guest.id),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Commit any pending DB changes (e.g. RSVP token) on successful send
|
# 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(
|
result = await service.send_by_template_key(
|
||||||
template_key=template_key,
|
template_key=template_key,
|
||||||
to_phone=phone,
|
to_phone=phone,
|
||||||
params=params
|
params=params,
|
||||||
|
event_id=params.get("event_id"),
|
||||||
|
guest_id=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"[TEST] Message sent successfully: {result}")
|
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__":
|
if __name__ == "__main__":
|
||||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||||
@ -140,6 +140,45 @@ class RsvpToken(Base):
|
|||||||
|
|
||||||
# ── WhatsApp Custom Templates ──────────────────────────────────────────────────
|
# ── 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):
|
class WhatsAppTemplate(Base):
|
||||||
"""
|
"""
|
||||||
Stores custom WhatsApp message templates created by users.
|
Stores custom WhatsApp message templates created by users.
|
||||||
|
|||||||
@ -438,6 +438,8 @@ class WhatsAppService:
|
|||||||
template_key: str,
|
template_key: str,
|
||||||
to_phone: str,
|
to_phone: str,
|
||||||
params: dict,
|
params: dict,
|
||||||
|
event_id: Optional[str] = None,
|
||||||
|
guest_id: Optional[str] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Send a WhatsApp template message using the template registry.
|
Send a WhatsApp template message using the template registry.
|
||||||
@ -461,9 +463,14 @@ class WhatsAppService:
|
|||||||
language_code = tpl.get("language_code", "he")
|
language_code = tpl.get("language_code", "he")
|
||||||
header_type = tpl.get("header_type", "TEXT")
|
header_type = tpl.get("header_type", "TEXT")
|
||||||
header_handle = tpl.get("header_handle", "")
|
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_type = tpl.get("button_type", "")
|
||||||
button_url = tpl.get("button_url", "")
|
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)
|
header_values, body_values = build_params_list(self.db, template_key, params)
|
||||||
|
|
||||||
to_e164 = self.normalize_phone_to_e164(to_phone)
|
to_e164 = self.normalize_phone_to_e164(to_phone)
|
||||||
@ -622,6 +629,33 @@ class WhatsAppService:
|
|||||||
f"[WhatsApp] Message sent successfully! ID: {message_id}\n"
|
f"[WhatsApp] Message sent successfully! ID: {message_id}\n"
|
||||||
f"Template: {meta_name}, To: {to_e164}, Status: {response.status_code}"
|
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 {
|
return {
|
||||||
"message_id": message_id,
|
"message_id": message_id,
|
||||||
"status": "sent",
|
"status": "sent",
|
||||||
|
|||||||
@ -242,15 +242,18 @@ TEMPLATES: Dict[str, Dict[str, Any]] = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
# ── hina_invitation ────────────────────────────────────────────────────────
|
# ── 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)
|
# Body {{1}} = contact_name (guest's name in greeting)
|
||||||
# Button {{1}} = event_id (dynamic URL parameter)
|
# Button {{1}} = event_id (dynamic URL parameter)
|
||||||
"hina_invitation": {
|
"hina_invitation": {
|
||||||
"meta_name": "hina_invitation",
|
"meta_name": "hina_invitation",
|
||||||
"language_code": "he",
|
"language_code": "he",
|
||||||
"friendly_name": "הזמנה לחינה",
|
"friendly_name": "הזמנה לחינה",
|
||||||
"description": "הזמנה לאירוע חינה עם קישור דינמי",
|
"description": "הזמנה לאירוע חינה עם תמונה וקישור דינמי",
|
||||||
"header_params": [],
|
"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"],
|
"body_params": ["contact_name"],
|
||||||
"button_type": "URL",
|
"button_type": "URL",
|
||||||
"button_text": "הצבע על הזמנה",
|
"button_text": "הצבע על הזמנה",
|
||||||
@ -259,6 +262,7 @@ TEMPLATES: Dict[str, Dict[str, Any]] = {
|
|||||||
"fallbacks": {
|
"fallbacks": {
|
||||||
"contact_name": "חבר",
|
"contact_name": "חבר",
|
||||||
"event_id": "event-id",
|
"event_id": "event-id",
|
||||||
|
"invitation_image_url": "https://api-invy.dvirlabs.com/uploads/default.jpg",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user