package whatsapp // WebhookHandler processes incoming WhatsApp webhook HTTP requests. // Reference: Chatwoot's webhook handling for WhatsApp: // - app/controllers/api/v1/accounts/channels/whatsapp_channels_controller.rb (CRUD + webhook) // - app/services/whatsapp/incoming_message_service.rb (message parsing) // - WhatsApp Cloud API webhook verification: GET with hub.mode=subscribe, hub.verify_token, hub.challenge // - WhatsApp Cloud API webhook events: POST with object=whatsapp_business_account, entry[].changes[] // // The handler: // 1. GET verification: Meta Cloud API sends hub.mode=subscribe, hub.verify_token=, // hub.challenge= — respond with hub.challenge if verify_token matches // 2. POST processing: Receives JSON payload with WAWebhookEvent structure, // delegates to WhatsAppProvider.ProcessIncoming for pipeline processing // 3. Return 200 OK immediately (WhatsApp expects fast response) import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "github.com/gin-gonic/gin" channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/model" applogger "github.com/gochat/gochat/pkg/logger" ) // WebhookHandler processes WhatsApp webhook requests. type WebhookHandler struct { provider *WhatsAppProvider } // NewWebhookHandler creates a WhatsApp webhook handler. func NewWebhookHandler(provider *WhatsAppProvider) *WebhookHandler { return &WebhookHandler{ provider: provider, } } // HandleVerification handles GET requests for WhatsApp webhook verification. // Meta Cloud API sends: hub.mode=subscribe, hub.verify_token=, hub.challenge= // We respond with hub.challenge if verify_token matches the channel's WebhookVerifyToken. // // Reference: https://developers.facebook.com/docs/whatsapp/cloud-api/get-started#verify-webhook func (h *WebhookHandler) HandleVerification(c *gin.Context) { mode := c.Query("hub.mode") token := c.Query("hub.verify_token") challenge := c.Query("hub.challenge") if mode != "subscribe" { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid hub.mode"}) return } // Look up the WhatsApp channel by verify token waChannel, err := h.lookupByVerifyToken(token) if err != nil { applogger.L().Warn("WhatsApp webhook verification: token lookup failed", "token", token, "error", err, ) c.JSON(http.StatusForbidden, gin.H{"error": "Invalid verify token"}) return } applogger.L().Info("WhatsApp webhook verification successful", "phone_number", waChannel.PhoneNumber, ) // Echo back the challenge string c.String(http.StatusOK, challenge) } // HandleWebhookEvent handles POST requests for WhatsApp webhook events. // Receives JSON payload with WAWebhookEvent structure. // Responds 200 OK immediately and processes via provider pipeline. // // Reference: https://developers.facebook.com/docs/whatsapp/cloud-api/webhooks func (h *WebhookHandler) HandleWebhookEvent(c *gin.Context) { body, err := io.ReadAll(c.Request.Body) if err != nil { applogger.L().Error("WhatsApp webhook: failed to read request body", "error", err) c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"}) return } // Parse the webhook event to extract phone_number_id for inbox lookup event := &WAWebhookEvent{} if err := json.Unmarshal(body, event); err != nil { applogger.L().Error("WhatsApp webhook: failed to parse payload", "error", err) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON payload"}) return } // Extract phone_number_id from the webhook metadata to identify the inbox phoneNumberID := extractPhoneNumberID(event) if phoneNumberID == "" { applogger.L().Warn("WhatsApp webhook: no phone_number_id in payload") c.JSON(http.StatusOK, gin.H{"status": "received"}) return } // Look up the WhatsApp channel by phone_number_id, then find its inbox inbox, err := h.resolveInbox(phoneNumberID) if err != nil { applogger.L().Warn("WhatsApp webhook: inbox resolution failed", "phone_number_id", phoneNumberID, "error", err, ) c.JSON(http.StatusOK, gin.H{"status": "received"}) return } // Get WhatsApp channel config for provider-specific verification waChannel, _ := h.getChannelConfig(inbox) if waChannel != nil && waChannel.Provider == "whatsapp_cloud" { if err := h.verifyCloudSignature(c, body, waChannel.AccessToken); err != nil { applogger.L().Warn("WhatsApp webhook: signature verification failed", "error", err) c.JSON(http.StatusUnauthorized, gin.H{"error": "Signature verification failed"}) return } } // Delegate to provider's ProcessIncoming for full pipeline processing if h.provider != nil { _, processErr := h.provider.ProcessIncoming(c.Request.Context(), inbox, body) if processErr != nil { applogger.L().Error("WhatsApp webhook: message processing failed", "error", processErr) } } // Always return 200 OK — WhatsApp requires fast response c.JSON(http.StatusOK, gin.H{"status": "received"}) } // HandleWebhookVerification is an alias for HandleVerification for routing convenience. func (h *WebhookHandler) HandleWebhookVerification(c *gin.Context) { h.HandleVerification(c) } // HandleWebhook is an alias for HandleWebhookEvent for routing convenience. func (h *WebhookHandler) HandleWebhook(c *gin.Context) { h.HandleWebhookEvent(c) } // === Internal Helpers === // extractPhoneNumberID extracts the phone_number_id from a webhook event. func extractPhoneNumberID(event *WAWebhookEvent) string { for _, entry := range event.Entry { for _, change := range entry.Changes { if change.Value.Metadata.PhoneNumberID != "" { return change.Value.Metadata.PhoneNumberID } } } return "" } // verifyCloudSignature verifies the HMAC-SHA256 signature for Cloud API webhooks. // Meta sends X-Hub-Signature-256 header with signature = hmac(appSecret, body). // NOTE: The app secret should come from environment config, not the channel model. // For now, we use the access token as a placeholder (signature verification will be // properly implemented when AppSecret is added to the model or env config). func (h *WebhookHandler) verifyCloudSignature(c *gin.Context, body []byte, appSecret string) error { signature := c.GetHeader("X-Hub-Signature-256") if signature == "" { return fmt.Errorf("missing X-Hub-Signature-256 header") } if appSecret == "" { applogger.L().Warn("WhatsApp app secret not configured, skipping webhook signature verification") return nil } mac := hmac.New(sha256.New, []byte(appSecret)) mac.Write(body) expectedSig := "sha256=" + hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(signature), []byte(expectedSig)) { return fmt.Errorf("webhook signature verification failed") } return nil } // lookupByVerifyToken finds the WhatsApp channel config by webhook verify token. func (h *WebhookHandler) lookupByVerifyToken(token string) (*channelmodel.ChannelWhatsApp, error) { if h.provider == nil || h.provider.repository == nil { return nil, fmt.Errorf("provider or repository not configured") } // Iterate WhatsApp channels to find one matching the verify token // TODO: Add a dedicated GetByWebhookVerifyToken query for efficiency channels, err := h.provider.repository.FindByAccountID(nil, 0) if err != nil { return nil, fmt.Errorf("channel lookup failed: %w", err) } for i := range channels { if channels[i].WebhookVerifyToken == token { return &channels[i], nil } } return nil, fmt.Errorf("no WhatsApp channel found with verify token: %s", token) } // resolveInbox finds the inbox for a given phone_number_id. // Steps: GetByPhoneNumberID → find ChannelWhatsApp → use InboxID to find Inbox. func (h *WebhookHandler) resolveInbox(phoneNumberID string) (*model.Inbox, error) { if h.provider == nil || h.provider.repository == nil { return nil, fmt.Errorf("provider or repository not configured") } // Step 1: Find the WhatsApp channel by phone_number_id waChannel, err := h.provider.repository.GetByPhoneNumberID(nil, phoneNumberID) if err != nil { return nil, fmt.Errorf("WhatsApp channel lookup by phone_number_id failed: %w", err) } // Step 2: Find the inbox using InboxRepository inboxRepo := &InboxRepository{db: h.provider.repository.db} return inboxRepo.FindByID(nil, waChannel.InboxID) } // getChannelConfig retrieves the ChannelWhatsApp configuration for the given inbox. func (h *WebhookHandler) getChannelConfig(inbox *model.Inbox) (*channelmodel.ChannelWhatsApp, error) { if h.provider == nil || h.provider.repository == nil { return nil, fmt.Errorf("provider or repository not configured") } return h.provider.repository.GetByInboxID(nil, inbox.ID) }