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 ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "strconv" "time" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" applogger "github.com/gochat/gochat/pkg/logger" ) // WebhookHandler processes WhatsApp webhook requests. type WebhookHandler struct { provider *WhatsAppProvider persister IncomingPersister } type IncomingPersister interface { PersistIncoming(ctx context.Context, inbox *model.Inbox, msg *channel.IncomingMessage) (interface{}, error) UpdateMessageStatus(ctx context.Context, inbox *model.Inbox, sourceID string, status model.MessageStatus, occurredAt *time.Time) error } // NewWebhookHandler creates a WhatsApp webhook handler. func NewWebhookHandler(provider *WhatsAppProvider) *WebhookHandler { return &WebhookHandler{ provider: provider, } } // SetIncomingPersister wires the durable message persistence boundary used after parsing. func (h *WebhookHandler) SetIncomingPersister(persister IncomingPersister) { h.persister = persister } // 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, resolveCloudAppSecret(waChannel)); 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 { messages, processErr := h.provider.ProcessIncomingMessages(c.Request.Context(), inbox, body) if processErr != nil { applogger.L().Error("WhatsApp webhook: message processing failed", "error", processErr) } else { for _, incomingMsg := range messages { if h.persister == nil || incomingMsg == nil { continue } if _, persistErr := h.persister.PersistIncoming(c.Request.Context(), inbox, incomingMsg); persistErr != nil { applogger.L().Error("WhatsApp webhook: message persistence failed", "source_id", incomingMsg.SourceID, "error", persistErr) } } } } if h.persister != nil { h.persistStatusUpdates(c.Request.Context(), inbox, event) } // Always return 200 OK — WhatsApp requires fast response c.JSON(http.StatusOK, gin.H{"status": "received"}) } func (h *WebhookHandler) persistStatusUpdates(ctx context.Context, inbox *model.Inbox, event *WAWebhookEvent) { for _, entry := range event.Entry { for _, change := range entry.Changes { for _, status := range change.Value.Statuses { mapped, ok := mapWhatsAppMessageStatus(status.Status) if !ok { continue } var occurredAt *time.Time if parsed := parseUnixTimestamp(status.Timestamp); parsed != nil { occurredAt = parsed } if err := h.persister.UpdateMessageStatus(ctx, inbox, status.ID, mapped, occurredAt); err != nil { applogger.L().Error("WhatsApp webhook: status persistence failed", "message_id", status.ID, "status", status.Status, "error", err) } } } } } func mapWhatsAppMessageStatus(status string) (model.MessageStatus, bool) { switch status { case "sent": return model.MessageStatusSent, true case "delivered": return model.MessageStatusDelivered, true case "read": return model.MessageStatusRead, true case "failed": return model.MessageStatusFailed, true default: return "", false } } func parseUnixTimestamp(value string) *time.Time { if value == "" { return nil } parsed, err := strconv.ParseInt(value, 10, 64) if err != nil || parsed <= 0 { return nil } t := time.Unix(parsed, 0) return &t } // 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). 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 == "" { return fmt.Errorf("WhatsApp app secret not configured") } 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") } var channel channelmodel.ChannelWhatsApp if err := h.provider.repository.db. Where("webhook_verify_token = ?", token). First(&channel).Error; err != nil { return nil, fmt.Errorf("no WhatsApp channel found with verify token: %w", err) } return &channel, nil } func resolveCloudAppSecret(channel *channelmodel.ChannelWhatsApp) string { if channel == nil { return "" } if channel.ProviderConfig != "" { var config map[string]interface{} if err := json.Unmarshal([]byte(channel.ProviderConfig), &config); err == nil { for _, key := range []string{"app_secret", "app_secret_key", "client_secret", "api_secret"} { if secret, ok := config[key].(string); ok && secret != "" { return secret } } } } for _, key := range []string{"WHATSAPP_APP_SECRET", "FB_APP_SECRET"} { if secret := os.Getenv(key); secret != "" { return secret } } return "" } // 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) }