package webhook // FacebookWebhookHandler processes incoming Facebook/Instagram webhook HTTP requests via Gin. // // Reference: Chatwoot routes Facebook webhooks at: // post '/webhooks/facebook/:page_id' => 'channels/facebook#process_message' // FB webhook verification (GET) at the same URL // // The handler: // 1. GET: Webhook verification — responds with hub.challenge when hub.mode=subscribe // and hub.verify_token matches the stored token (FB requirement for initial setup) // 2. POST: Incoming message/event processing — reads the JSON payload, validates // the X-Hub-Signature-256 header, parses events, and delegates to the // Facebook/Instagram provider pipeline // // Facebook webhook requirements: // - GET verification: must echo hub.challenge back when hub.verify_token matches // - POST events: must validate X-Hub-Signature-256 (SHA256 HMAC with app_secret) // - Must respond within 20 seconds // - Must return 200 OK even on processing errors (FB retries on non-200) // // Instagram DMs use the same webhook endpoint format (Meta Business Suite): // - object="instagram" for IG events vs object="page" for FB Messenger // - Same X-Hub-Signature-256 verification // - Same hub.mode/hub.verify_token/hub.challenge verification flow import ( "context" "encoding/json" "io" "net/http" "os" "strconv" "time" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/channel" fbchannel "github.com/gochat/gochat/internal/channel/facebook" "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/worker" applogger "github.com/gochat/gochat/pkg/logger" "gorm.io/gorm" ) // FacebookWebhookHandler processes Facebook/Instagram webhook requests via Gin. type FacebookWebhookHandler struct { fbProvider *fbchannel.FacebookProvider igProvider *fbchannel.InstagramProvider webhookParser *fbchannel.WebhookParser db *gorm.DB persister *IncomingPersister } // NewFacebookWebhookHandler creates a Facebook/Instagram webhook handler for Gin integration. func NewFacebookWebhookHandler( fbProvider *fbchannel.FacebookProvider, igProvider *fbchannel.InstagramProvider, db *gorm.DB, dispatcher ...*channel.Dispatcher, ) *FacebookWebhookHandler { return &FacebookWebhookHandler{ fbProvider: fbProvider, igProvider: igProvider, webhookParser: fbchannel.NewWebhookParser(), db: db, persister: NewIncomingPersister(db, dispatcher...), } } func (h *FacebookWebhookHandler) WithWorkerPool(wp *worker.WorkerPool) *FacebookWebhookHandler { if h != nil && h.persister != nil { h.persister.SetWorkerPool(wp) } return h } func (h *FacebookWebhookHandler) WithSearchIndexer(indexer IncomingSearchIndexer) *FacebookWebhookHandler { if h != nil && h.persister != nil { h.persister.SetSearchIndexer(indexer) } return h } // HandleFacebookVerification handles the GET webhook verification request from Facebook. // URL pattern: /webhooks/facebook/:inbox_id // Method: GET // Query params: hub.mode=subscribe, hub.verify_token=, hub.challenge= // // Reference: https://developers.facebook.com/docs/graph-api/webhooks/getting-started#verification-requests // Facebook sends a GET request with hub.mode=subscribe when verifying a new webhook subscription. // The server must respond with the hub.challenge value if hub.verify_token matches. func (h *FacebookWebhookHandler) HandleFacebookVerification(c *gin.Context) { pageID := c.Param("page_id") inboxID, _ := parseOptionalUintParam(c.Param("inbox_id")) // Extract verification parameters queryParams := map[string]string{ "hub.mode": c.Query("hub.mode"), "hub.verify_token": c.Query("hub.verify_token"), "hub.challenge": c.Query("hub.challenge"), } // Look up the inbox to find the verify token stored in ChannelConfig inbox, err := h.lookupFacebookInbox(pageID, inboxID) if err != nil { applogger.L().Warnf("Facebook webhook verification: inbox lookup failed for page_id=%s inbox_id=%d: %v", pageID, inboxID, err) c.JSON(http.StatusNotFound, gin.H{"error": "inbox not found"}) return } // Resolve the verify token from channel config verifyToken := h.resolveVerifyToken(inbox) if verifyToken == "" { applogger.L().Warnf("Facebook webhook verification: no verify_token for inbox %d", inbox.ID) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "verify token not configured"}) return } // Delegate to the channel-level verification logic challenge, ok := fbchannel.VerifyWebhookChallenge(queryParams, verifyToken) if !ok { applogger.L().Warnf("Facebook webhook verification: token mismatch for inbox %d", inbox.ID) c.JSON(http.StatusForbidden, gin.H{"error": "verification failed"}) return } applogger.L().Infof("Facebook webhook verified for inbox %d", inbox.ID) // Facebook expects the challenge value as the plain response body c.String(http.StatusOK, challenge) } // HandleFacebookWebhook processes an incoming Facebook/Instagram webhook POST request. // URL pattern: /webhooks/facebook/:inbox_id // Method: POST // Content-Type: application/json // Header: X-Hub-Signature-256 for signature verification // // Reference: Chatwoot processes Facebook webhook events via IncomingMessageService // FB sends webhook events as JSON POST to the configured callback URL // // Flow: // 1. Read raw body from request // 2. Validate X-Hub-Signature-256 HMAC signature // 3. Parse the payload to determine object type (page=FB, instagram=IG) // 4. Delegate to appropriate provider's ProcessIncoming method // 5. Return 200 OK immediately (Facebook retries on non-200) func (h *FacebookWebhookHandler) HandleFacebookWebhook(c *gin.Context) { pageID := c.Param("page_id") inboxID, _ := parseOptionalUintParam(c.Param("inbox_id")) // Read request body body, err := io.ReadAll(c.Request.Body) if err != nil { applogger.L().Errorf("Facebook webhook: failed to read body for page_id=%s inbox_id=%d: %v", pageID, inboxID, err) c.JSON(http.StatusOK, gin.H{"status": "ignored"}) return } defer c.Request.Body.Close() // Validate webhook signature (X-Hub-Signature-256) signatureHeader := c.GetHeader("X-Hub-Signature-256") inbox, err := h.lookupFacebookInbox(pageID, inboxID) if err != nil { applogger.L().Warnf("Facebook webhook: inbox lookup failed for page_id=%s inbox_id=%d: %v", pageID, inboxID, err) c.JSON(http.StatusOK, gin.H{"status": "ignored"}) return } // Resolve app_secret from channel config for signature validation appSecret := h.resolveAppSecret(inbox) if appSecret != "" && signatureHeader != "" { if !fbchannel.ValidateWebhookSignature(appSecret, signatureHeader, body) { applogger.L().Warnf("Facebook webhook: signature validation failed for inbox %d", inbox.ID) c.JSON(http.StatusForbidden, gin.H{"error": "invalid signature"}) return } } // Parse the payload to determine if it's Facebook or Instagram // object="page" → Facebook Messenger, object="instagram" → Instagram DMs parsedEvents, err := h.webhookParser.ParseWebhookPayload(body) if err != nil { applogger.L().Errorf("Facebook webhook: failed to parse payload for inbox %d: %v", inbox.ID, err) c.JSON(http.StatusOK, gin.H{"status": "ignored"}) return } applogger.L().Infof("Facebook webhook: received %d events for inbox %d", len(parsedEvents), inbox.ID) // Process each parsed event for _, event := range parsedEvents { // Skip echo messages (sent by the page/business itself) if fbchannel.IsEchoMessage(event) { mid := "" if event.Message != nil { mid = event.Message.Mid } applogger.L().Debug("Facebook webhook: skipping echo message", "mid", mid, "inbox_id", inbox.ID, ) continue } // Skip delivery/read receipts — no actionable message content if fbchannel.IsDeliveryOrReadReceipt(event) { applogger.L().Debug("Facebook webhook: skipping delivery/read receipt", "event_type", event.EventType, "inbox_id", inbox.ID, ) h.persistFacebookReceipt(c.Request.Context(), inbox, event) continue } // Skip thread control events (handled by TakeThreadControl in listener) if fbchannel.IsThreadControlEvent(event) { applogger.L().Debug("Facebook webhook: skipping thread control event", "event_type", event.EventType, "inbox_id", inbox.ID, ) continue } // Handle Instagram comment events separately from DM messages // Comment events use the Changes array (field="comments") and require // ProcessCommentIncoming to transform them into IncomingMessage objects. if fbchannel.IsIGCommentEvent(event) { applogger.L().Info("Facebook webhook: processing Instagram comment event", "event_type", event.EventType, "inbox_id", inbox.ID, ) commentMsg, err := h.igProvider.ProcessCommentIncoming(c.Request.Context(), inbox, event.Comment, event.EventType) if err != nil { applogger.L().Errorf("Facebook webhook: IG comment processing failed: %v", err) continue } if commentMsg != nil { if _, persistErr := h.persister.PersistIncoming(c.Request.Context(), inbox, commentMsg); persistErr != nil { applogger.L().Errorf("Facebook webhook: IG comment persist failed (source_id=%s): %v", commentMsg.SourceID, persistErr) continue } applogger.L().Infof("Facebook webhook: IG comment persisted (inbox_id=%d, source_id=%s, type=%s)", commentMsg.InboxID, commentMsg.SourceID, event.EventType) } continue } // Only process events that should create messages if !fbchannel.ShouldCreateMessage(event) { applogger.L().Debug("Facebook webhook: skipping non-message event", "event_type", event.EventType, "inbox_id", inbox.ID, ) continue } // Delegate to the appropriate provider based on object type var incomingMsg *channel.IncomingMessage switch event.Object { case "page": // Facebook Messenger incomingMsg, err = fbchannel.ExtractIncomingMessageFromEvent(event, inbox, channel.ChannelFacebook) if err != nil { applogger.L().Errorf("Facebook webhook: extract message failed (FB): %v", err) continue } case "instagram": // Instagram DMs incomingMsg, err = fbchannel.ExtractIncomingMessageFromEvent(event, inbox, channel.ChannelInstagram) if err != nil { applogger.L().Errorf("Facebook webhook: extract message failed (IG): %v", err) continue } default: applogger.L().Warnf("Facebook webhook: unknown object type %s for inbox %d", event.Object, inbox.ID) continue } if incomingMsg != nil { if _, persistErr := h.persister.PersistIncoming(c.Request.Context(), inbox, incomingMsg); persistErr != nil { applogger.L().Errorf("Facebook webhook: persist message failed (source_id=%s): %v", incomingMsg.SourceID, persistErr) continue } applogger.L().Infof("Facebook webhook: message persisted (inbox_id=%d, source_id=%s, type=%s)", incomingMsg.InboxID, incomingMsg.SourceID, event.EventType) } } // Always return 200 OK to Facebook — it retries if not 200 c.JSON(http.StatusOK, gin.H{"status": "ok"}) } // HandleInstagramVerification handles the Chatwoot-compatible no-param Instagram webhook verification route. func (h *FacebookWebhookHandler) HandleInstagramVerification(c *gin.Context) { token := c.Query("hub.verify_token") challenge := c.Query("hub.challenge") if h.validInstagramVerifyToken(token) { applogger.L().Info("Instagram webhook verified") c.String(http.StatusOK, challenge) return } c.JSON(http.StatusUnauthorized, gin.H{"error": "Error; wrong verify token"}) } // HandleInstagramWebhook processes the Chatwoot-compatible no-param Instagram event route. func (h *FacebookWebhookHandler) HandleInstagramWebhook(c *gin.Context) { body, err := io.ReadAll(c.Request.Body) if err != nil { applogger.L().Errorf("Instagram webhook: failed to read body: %v", err) c.JSON(http.StatusOK, gin.H{"status": "ignored"}) return } defer c.Request.Body.Close() parsedEvents, err := h.webhookParser.ParseWebhookPayload(body) if err != nil { applogger.L().Errorf("Instagram webhook: failed to parse payload: %v", err) c.JSON(http.StatusOK, gin.H{"status": "ignored"}) return } if len(parsedEvents) == 0 { c.JSON(http.StatusOK, gin.H{"status": "ok"}) return } if parsedEvents[0].Object != "instagram" { applogger.L().Warnf("Message is not received from the instagram webhook event: %s", parsedEvents[0].Object) c.Status(http.StatusUnprocessableEntity) return } if err := h.verifyInstagramSignature(c, body, parsedEvents); err != nil { applogger.L().Warnf("Instagram webhook: signature verification failed: %v", err) c.Status(http.StatusUnauthorized) return } for _, event := range parsedEvents { inbox, err := h.lookupInstagramInboxForEvent(event) if err != nil { applogger.L().Warnf("Instagram webhook: inbox lookup failed for page_id=%s sender=%s recipient=%s: %v", event.PageID, event.SenderID, event.RecipientID, err) continue } h.processInstagramEvent(c, inbox, event) } c.JSON(http.StatusOK, gin.H{"status": "ok"}) } // =========================== // Helper methods // =========================== // lookupInbox finds the Inbox by ID via GORM. func (h *FacebookWebhookHandler) lookupInbox(inboxID uint) (*model.Inbox, error) { var inbox model.Inbox if err := h.db.Where("id = ?", inboxID).First(&inbox).Error; err != nil { return nil, err } return &inbox, nil } func (h *FacebookWebhookHandler) lookupFacebookInbox(pageID string, inboxID uint) (*model.Inbox, error) { if inboxID != 0 { return h.lookupInbox(inboxID) } return h.lookupInboxByFacebookPageID(pageID) } func (h *FacebookWebhookHandler) lookupInboxByFacebookPageID(pageID string) (*model.Inbox, error) { var fb channelmodel.ChannelFacebook if err := h.db.Where("page_id = ?", pageID).First(&fb).Error; err != nil { return nil, err } return h.lookupInbox(fb.InboxID) } func (h *FacebookWebhookHandler) lookupInstagramInboxForEvent(event *fbchannel.ParsedWebhookEvent) (*model.Inbox, error) { instagramID := event.RecipientID if fbchannel.IsEchoMessage(event) && event.SenderID != "" { instagramID = event.SenderID } if instagramID == "" { instagramID = event.PageID } var ig channelmodel.ChannelInstagram if err := h.db.Where("instagram_account_id = ? OR instagram_business_account_id = ?", instagramID, instagramID).First(&ig).Error; err == nil { return h.lookupInbox(ig.InboxID) } var fb channelmodel.ChannelFacebook if err := h.db.Where("instagram_business_account_id = ?", instagramID).First(&fb).Error; err != nil { return nil, err } return h.lookupInbox(fb.InboxID) } func (h *FacebookWebhookHandler) validInstagramVerifyToken(token string) bool { if token == "" { return false } return token == os.Getenv("IG_VERIFY_TOKEN") || token == os.Getenv("INSTAGRAM_VERIFY_TOKEN") } func (h *FacebookWebhookHandler) verifyInstagramSignature(c *gin.Context, body []byte, events []*fbchannel.ParsedWebhookEvent) error { signatureHeader := c.GetHeader("X-Hub-Signature-256") if signatureHeader == "" { return strconv.ErrSyntax } for _, secret := range h.instagramAppSecrets(events) { if fbchannel.ValidateWebhookSignature(secret, signatureHeader, body) { return nil } } return strconv.ErrSyntax } func (h *FacebookWebhookHandler) instagramAppSecrets(events []*fbchannel.ParsedWebhookEvent) []string { seen := map[string]bool{} secrets := make([]string, 0, 4) add := func(secret string) { if secret != "" && !seen[secret] { seen[secret] = true secrets = append(secrets, secret) } } add(os.Getenv("INSTAGRAM_APP_SECRET")) add(os.Getenv("FB_APP_SECRET")) for _, event := range events { if inbox, err := h.lookupInstagramInboxForEvent(event); err == nil { add(h.resolveAppSecret(inbox)) } } return secrets } func (h *FacebookWebhookHandler) processInstagramEvent(c *gin.Context, inbox *model.Inbox, event *fbchannel.ParsedWebhookEvent) { if fbchannel.IsDeliveryOrReadReceipt(event) { h.persistFacebookReceipt(c.Request.Context(), inbox, event) return } if fbchannel.IsEchoMessage(event) || fbchannel.IsThreadControlEvent(event) { return } if fbchannel.IsIGCommentEvent(event) { if h.igProvider == nil { applogger.L().Warn("Instagram webhook: comment event ignored because Instagram provider is not configured") return } commentMsg, err := h.igProvider.ProcessCommentIncoming(c.Request.Context(), inbox, event.Comment, event.EventType) if err != nil { applogger.L().Errorf("Instagram webhook: comment processing failed: %v", err) } if commentMsg != nil { if _, persistErr := h.persister.PersistIncoming(c.Request.Context(), inbox, commentMsg); persistErr != nil { applogger.L().Errorf("Instagram webhook: comment persist failed (source_id=%s): %v", commentMsg.SourceID, persistErr) return } applogger.L().Infof("Instagram webhook: comment persisted (inbox_id=%d, source_id=%s, type=%s)", commentMsg.InboxID, commentMsg.SourceID, event.EventType) } return } if !fbchannel.ShouldCreateMessage(event) { return } incomingMsg, err := fbchannel.ExtractIncomingMessageFromEvent(event, inbox, channel.ChannelInstagram) if err != nil { applogger.L().Errorf("Instagram webhook: extract message failed: %v", err) return } if incomingMsg != nil { if _, persistErr := h.persister.PersistIncoming(c.Request.Context(), inbox, incomingMsg); persistErr != nil { applogger.L().Errorf("Instagram webhook: persist message failed (source_id=%s): %v", incomingMsg.SourceID, persistErr) return } applogger.L().Infof("Instagram webhook: message persisted (inbox_id=%d, source_id=%s, type=%s)", incomingMsg.InboxID, incomingMsg.SourceID, event.EventType) } } func (h *FacebookWebhookHandler) persistFacebookReceipt(ctx context.Context, inbox *model.Inbox, event *fbchannel.ParsedWebhookEvent) { if h.persister == nil { return } switch event.EventType { case fbchannel.EventFBDelivery, fbchannel.EventIGDelivery: if event.Delivery == nil { return } occurredAt := millisToTime(event.Delivery.Watermark) for _, mid := range event.Delivery.Mids { if err := h.persister.UpdateMessageStatus(ctx, inbox, mid, model.MessageStatusDelivered, occurredAt); err != nil { applogger.L().Errorf("Facebook webhook: delivery status persistence failed mid=%s inbox=%d: %v", mid, inbox.ID, err) } } case fbchannel.EventFBRead, fbchannel.EventIGRead: if event.Read == nil { return } if err := h.persister.UpdateContactConversationMessagesStatus(ctx, inbox, event.SenderID, model.MessageStatusRead, millisToTime(event.Read.Watermark)); err != nil { applogger.L().Errorf("Facebook webhook: read status persistence failed sender=%s inbox=%d: %v", event.SenderID, inbox.ID, err) } } } func millisToTime(value int64) *time.Time { if value <= 0 { return nil } t := time.UnixMilli(value) return &t } func parseOptionalUintParam(value string) (uint, error) { if value == "" { return 0, nil } parsed, err := strconv.ParseUint(value, 10, 32) return uint(parsed), err } // resolveVerifyToken extracts the webhook verify token from the inbox ChannelConfig JSON. func (h *FacebookWebhookHandler) resolveVerifyToken(inbox *model.Inbox) string { config := h.parseChannelConfig(inbox) if token, ok := config["webhook_verify_token"].(string); ok { return token } return "" } // resolveAppSecret extracts the Facebook app secret from the inbox ChannelConfig JSON. func (h *FacebookWebhookHandler) resolveAppSecret(inbox *model.Inbox) string { config := h.parseChannelConfig(inbox) if secret, ok := config["app_secret"].(string); ok { return secret } return "" } // parseChannelConfig parses the JSON-encoded ChannelConfig string into a map. func (h *FacebookWebhookHandler) parseChannelConfig(inbox *model.Inbox) map[string]interface{} { if inbox.ChannelConfig == "" { return map[string]interface{}{} } var config map[string]interface{} if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err != nil { applogger.L().Warnf("Failed to parse ChannelConfig for inbox %d: %v", inbox.ID, err) return map[string]interface{}{} } return config }