Try to fix
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
dvirlabs 2026-05-13 20:51:26 +03:00
parent 92b35d297d
commit 621e75e41b
4 changed files with 247 additions and 5 deletions

View File

@ -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)

View File

@ -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.

View File

@ -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,8 +463,13 @@ 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)
@ -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",

View File

@ -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",
}, },
}, },
} }