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 ( "encoding/json" "io" "net/http" "strconv" "github.com/gin-gonic/gin" fbchannel "github.com/gochat/gochat/internal/channel/facebook" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" 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 } // NewFacebookWebhookHandler creates a Facebook/Instagram webhook handler for Gin integration. func NewFacebookWebhookHandler( fbProvider *fbchannel.FacebookProvider, igProvider *fbchannel.InstagramProvider, db *gorm.DB, ) *FacebookWebhookHandler { return &FacebookWebhookHandler{ fbProvider: fbProvider, igProvider: igProvider, webhookParser: fbchannel.NewWebhookParser(), db: db, } } // 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) { inboxIDStr := c.Param("inbox_id") inboxID, err := strconv.ParseUint(inboxIDStr, 10, 32) if err != nil { applogger.L().Warnf("Facebook webhook verification: invalid inbox_id %s", inboxIDStr) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox_id"}) return } // 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.lookupInbox(uint(inboxID)) if err != nil { applogger.L().Warnf("Facebook webhook verification: inbox lookup failed for id %d: %v", 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", inboxID) c.JSON(http.StatusInternalServerError, 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", inboxID) c.JSON(http.StatusForbidden, gin.H{"error": "verification failed"}) return } applogger.L().Infof("Facebook webhook verified for inbox %d", inboxID) // 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) { inboxIDStr := c.Param("inbox_id") inboxID, err := strconv.ParseUint(inboxIDStr, 10, 32) if err != nil { applogger.L().Warnf("Facebook webhook: invalid inbox_id %s", inboxIDStr) c.JSON(http.StatusOK, gin.H{"status": "ignored"}) return } // Read request body body, err := io.ReadAll(c.Request.Body) if err != nil { applogger.L().Errorf("Facebook webhook: failed to read body for inbox %d: %v", 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.lookupInbox(uint(inboxID)) if err != nil { applogger.L().Warnf("Facebook webhook: inbox lookup failed for id %d: %v", 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", inboxID) 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", inboxID, err) c.JSON(http.StatusOK, gin.H{"status": "ignored"}) return } applogger.L().Infof("Facebook webhook: received %d events for inbox %d", len(parsedEvents), inboxID) // 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", inboxID, ) 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", inboxID, ) 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", inboxID, ) 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", inboxID, ) 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 { applogger.L().Infof("Facebook webhook: IG comment processed (inbox_id=%d, source_id=%s, type=%s)", commentMsg.InboxID, commentMsg.SourceID, event.EventType) // TODO: Push to message broker/dispatcher for persistence + notification } 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", inboxID, ) 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, inboxID) continue } if incomingMsg != nil { applogger.L().Infof("Facebook webhook: message extracted (inbox_id=%d, source_id=%s, type=%s)", incomingMsg.InboxID, incomingMsg.SourceID, event.EventType) // TODO: Push to message broker/dispatcher for persistence + notification // Reference: Chatwoot pushes to IncomingMessageService → Conversation + Message creation } } // Always return 200 OK to Facebook — it retries if not 200 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 } // 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 }