From 621e75e41b6b65f18b958930b91ea7880e7369b2 Mon Sep 17 00:00:00 2001 From: dvirlabs Date: Wed, 13 May 2026 20:51:26 +0300 Subject: [PATCH] Try to fix --- backend/main.py | 169 +++++++++++++++++++++++++++++++++- backend/models.py | 39 ++++++++ backend/whatsapp.py | 34 +++++++ backend/whatsapp_templates.py | 10 +- 4 files changed, 247 insertions(+), 5 deletions(-) diff --git a/backend/main.py b/backend/main.py index 064aafc..a0c440c 100644 --- a/backend/main.py +++ b/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) \ No newline at end of file diff --git a/backend/models.py b/backend/models.py index 2845fb8..00466de 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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. diff --git a/backend/whatsapp.py b/backend/whatsapp.py index 38a8f99..6995600 100644 --- a/backend/whatsapp.py +++ b/backend/whatsapp.py @@ -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", diff --git a/backend/whatsapp_templates.py b/backend/whatsapp_templates.py index 5333560..ef7b646 100644 --- a/backend/whatsapp_templates.py +++ b/backend/whatsapp_templates.py @@ -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", }, }, }