package facebook // WebhookHandler parses and validates Facebook/Instagram webhook payloads. // Reference: Chatwoot's Facebook webhook handling: // - app/controllers/api/v1/accounts/channels/facebook_pages_controller.rb (CRUD + webhook) // - app/services/facebook/incoming_message_service.rb (message parsing) // - Facebook webhook verification: GET with hub.mode=subscribe, hub.verify_token, hub.challenge // - Facebook webhook events: POST with object=page, entry[].messaging[] // - Instagram webhook events: POST with object=instagram, entry[].messaging[] // // NOTE: HTTP routing is handled by the unified channel.WebhookHandler in webhook.go. // This file provides channel-specific payload parsing and event classification // that the FacebookProvider/InstagramProvider delegates to. import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "time" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" applogger "github.com/gochat/gochat/pkg/logger" ) // WebhookEventType classifies the type of webhook event received. type WebhookEventType string const ( // Facebook Messenger events EventFBMessage WebhookEventType = "fb_message" // text/attachment message EventFBMessageEcho WebhookEventType = "fb_message_echo" // echo of agent's outgoing message EventFBPostback WebhookEventType = "fb_postback" // postback button click EventFBReferral WebhookEventType = "fb_referral" // m.me referral EventFBDelivery WebhookEventType = "fb_delivery" // delivery receipt EventFBRead WebhookEventType = "fb_read" // read receipt EventFBOptin WebhookEventType = "fb_optin" // opt-in event EventFBPassThread WebhookEventType = "fb_pass_thread" // thread control passed EventFBTakeThread WebhookEventType = "fb_take_thread" // thread control taken EventFBRequestThread WebhookEventType = "fb_request_thread" // thread control requested // Instagram DM events EventIGMessage WebhookEventType = "ig_message" // Instagram DM text/attachment EventIGMessageEcho WebhookEventType = "ig_message_echo" // echo of agent's outgoing DM EventIGPostback WebhookEventType = "ig_postback" // Instagram postback EventIGDelivery WebhookEventType = "ig_delivery" // Instagram delivery receipt EventIGRead WebhookEventType = "ig_read" // Instagram read receipt // Instagram Comment events EventIGComment WebhookEventType = "ig_comment" // new comment on IG media post EventIGCommentReply WebhookEventType = "ig_comment_reply" // reply to an IG comment EventIGCommentDeleted WebhookEventType = "ig_comment_deleted" // IG comment deleted ) // ParsedWebhookEvent is the result of parsing a raw FB/IG webhook payload. type ParsedWebhookEvent struct { EventType WebhookEventType Object string // "page" (FB) or "instagram" (IG) PageID string // FB Page ID or IG Business Account ID SenderID string // PSID (FB) or IGSID (IG) RecipientID string // Page-scoped recipient ID Message *FBMessageData // message content (for message events) Postback *FBPostback // postback payload (for postback events) Referral *FBReferral // referral data (for referral events) Delivery *FBDelivery // delivery receipt data Read *FBRead // read receipt data Comment *IGCommentChangeValue // comment data (for IG comment events) Timestamp int64 // event timestamp in milliseconds RawPayload []byte // original raw JSON } // FBPostback represents a Facebook postback event payload. type FBPostback struct { Title string `json:"title"` Data string `json:"payload"` Referral *FBReferral `json:"referral,omitempty"` } // FBReferral represents a Facebook referral (m.me link) event payload. type FBReferral struct { Ref string `json:"ref"` Source string `json:"source"` Type string `json:"type"` AdID string `json:"ad_id,omitempty"` } // FBDelivery represents a Facebook message delivery receipt. type FBDelivery struct { Mids []string `json:"mids"` Watermark int64 `json:"watermark"` } // FBRead represents a Facebook message read receipt. type FBRead struct { Watermark int64 `json:"watermark"` } // WebhookParser handles parsing and classification of FB/IG webhook payloads. type WebhookParser struct{} // NewWebhookParser creates a new webhook parser. func NewWebhookParser() *WebhookParser { return &WebhookParser{} } // ParseWebhookPayload parses a raw FB/IG webhook JSON payload into structured events. // Returns one or more ParsedWebhookEvent for each messaging entry in the payload. // // Reference: Chatwoot IncomingMessageService.perform — iterates over entry[].messaging[] // and classifies each event type before processing. func (wp *WebhookParser) ParseWebhookPayload(rawPayload []byte) ([]*ParsedWebhookEvent, error) { var event FBWebhookEvent if err := json.Unmarshal(rawPayload, &event); err != nil { return nil, fmt.Errorf("failed to parse webhook payload: %w", err) } var results []*ParsedWebhookEvent object := event.Object // "page" (FB) or "instagram" (IG) for _, entry := range event.Entry { pageID := entry.ID entryTime := entry.Time // Process messaging events (FB Messenger + Instagram DMs share the same structure) for _, msgEvent := range entry.Messaging { parsed := wp.classifyMessagingEvent(object, pageID, msgEvent, entryTime, rawPayload) results = append(results, parsed) } // Process standalone changes (for future: feed stories, mentions, etc.) for _, change := range entry.Changes { parsed := wp.classifyChangeEvent(object, pageID, change, entryTime, rawPayload) if parsed != nil { results = append(results, parsed) } } } if len(results) == 0 { applogger.L().Debug("Webhook payload had no processable events", "object", object) } return results, nil } // classifyMessagingEvent determines the event type from a messaging entry. func (wp *WebhookParser) classifyMessagingEvent(object string, pageID string, msgEvent FBMessagingEvent, timestamp int64, rawPayload []byte) *ParsedWebhookEvent { senderID := msgEvent.Sender.ID recipientID := msgEvent.Recipient.ID eventTimestamp := msgEvent.Timestamp if eventTimestamp == 0 { eventTimestamp = timestamp } parsed := &ParsedWebhookEvent{ Object: object, PageID: pageID, SenderID: senderID, RecipientID: recipientID, Timestamp: eventTimestamp, RawPayload: rawPayload, } // Determine if this is from FB or IG isInstagram := object == "instagram" // Classify based on what fields are populated if msgEvent.Message != nil { parsed.Message = msgEvent.Message if msgEvent.Message.IsEcho { parsed.EventType = instagramOrFB(isInstagram, EventIGMessageEcho, EventFBMessageEcho) } else { parsed.EventType = instagramOrFB(isInstagram, EventIGMessage, EventFBMessage) } } else if msgEvent.Postback != nil { parsed.Postback = &FBPostback{ Title: msgEvent.Postback.Title, Data: msgEvent.Postback.Payload, } if msgEvent.Postback.Referral != nil { parsed.Postback.Referral = &FBReferral{ Ref: msgEvent.Postback.Referral.Ref, Source: msgEvent.Postback.Referral.Source, Type: msgEvent.Postback.Referral.Type, AdID: msgEvent.Postback.Referral.ADID, } } parsed.EventType = instagramOrFB(isInstagram, EventIGPostback, EventFBPostback) } else if msgEvent.Delivery != nil { parsed.Delivery = &FBDelivery{ Mids: msgEvent.Delivery.Mids, Watermark: msgEvent.Delivery.Watermark, } parsed.EventType = instagramOrFB(isInstagram, EventIGDelivery, EventFBDelivery) } else if msgEvent.Read != nil { parsed.Read = &FBRead{ Watermark: msgEvent.Read.Watermark, } parsed.EventType = instagramOrFB(isInstagram, EventIGRead, EventFBRead) } else if msgEvent.Optin != nil { parsed.EventType = EventFBOptin } else if msgEvent.PassThreadControl != nil { parsed.EventType = EventFBPassThread } else if msgEvent.TakeThreadControl != nil { parsed.EventType = EventFBTakeThread } return parsed } // classifyChangeEvent processes standalone change events (e.g. feed stories). // Not yet fully implemented — placeholder for future expansion. func (wp *WebhookParser) classifyChangeEvent(object string, pageID string, change FBChangeEvent, timestamp int64, rawPayload []byte) *ParsedWebhookEvent { isInstagram := object == "instagram" // Instagram comment webhook: object="instagram", field="comments" if isInstagram && change.Field == "comments" { commentData, err := parseIGCommentChangeValue(change.Value) if err != nil { applogger.L().Warn("Failed to parse Instagram comment change value", "error", err, "field", change.Field) return nil } eventType := EventIGComment if commentData.ParentID != "" { eventType = EventIGCommentReply } if commentData.IsDeleted { eventType = EventIGCommentDeleted } parsedTimestamp := timestamp if commentData.Timestamp != "" { // IG comment timestamps are ISO 8601 strings, convert to unix ms if ts, err := time.Parse(time.RFC3339, commentData.Timestamp); err == nil { parsedTimestamp = ts.UnixMilli() } } parsed := &ParsedWebhookEvent{ EventType: eventType, Object: object, PageID: pageID, SenderID: commentData.From.ID, Timestamp: parsedTimestamp, Comment: commentData, RawPayload: rawPayload, } applogger.L().Debug("Parsed Instagram comment event", "event_type", eventType, "comment_id", commentData.CommentID, "media_id", commentData.MediaID, "from_id", commentData.From.ID, "text_length", len(commentData.Text)) return parsed } applogger.L().Debug("Skipping unhandled change event", "object", object, "field", change.Field) return nil } // parseIGCommentChangeValue deserializes the map[string]interface{} value // from a FBChangeEvent with field="comments" into an IGCommentChangeValue struct. func parseIGCommentChangeValue(rawValue map[string]interface{}) (*IGCommentChangeValue, error) { // Re-marshal and unmarshal to get typed struct from raw map jsonBytes, err := json.Marshal(rawValue) if err != nil { return nil, fmt.Errorf("failed to re-marshal comment value: %w", err) } var commentData IGCommentChangeValue if err := json.Unmarshal(jsonBytes, &commentData); err != nil { return nil, fmt.Errorf("failed to unmarshal comment value: %w", err) } return &commentData, nil } // instagramOrFB returns the Instagram or Facebook event type based on the object type. func instagramOrFB(isInstagram bool, igType WebhookEventType, fbType WebhookEventType) WebhookEventType { if isInstagram { return igType } return fbType } // ValidateWebhookSignature validates the X-Hub-Signature-256 header against the raw payload. // Reference: Facebook requires SHA256 HMAC verification using the App Secret. // Chatwoot performs this in the webhook controller before processing. // // The signature header format is: sha256= func ValidateWebhookSignature(appSecret string, signatureHeader string, rawPayload []byte) bool { if signatureHeader == "" { applogger.L().Warn("Facebook webhook: missing X-Hub-Signature-256 header") return false } // Facebook sends signature as "sha256=" // Split on "=" to extract the hex digest portion eqIdx := -1 for i := 0; i < len(signatureHeader); i++ { if signatureHeader[i] == '=' { eqIdx = i break } } if eqIdx < 0 || signatureHeader[:eqIdx] != "sha256" { applogger.L().Warn("Facebook webhook: invalid signature format", "header", signatureHeader) return false } expectedSig := signatureHeader[eqIdx+1:] // Compute HMAC-SHA256 using app secret as key mac := hmac.New(sha256.New, []byte(appSecret)) mac.Write(rawPayload) actualSig := hex.EncodeToString(mac.Sum(nil)) // Timing-safe comparison — prevents timing attacks on signature verification if !hmac.Equal([]byte(expectedSig), []byte(actualSig)) { applogger.L().Warn("Facebook webhook: signature mismatch", "expected_length", len(expectedSig), "actual_length", len(actualSig)) return false } return true } // VerifyWebhookChallenge handles the FB webhook verification GET request. // Reference: Facebook requires responding with hub.challenge when hub.mode=subscribe // and hub.verify_token matches the configured token. // // This is called from the unified channel.WebhookHandler.HandleWebhookVerification. func VerifyWebhookChallenge(queryParams map[string]string, verifyToken string) (string, bool) { mode := queryParams["hub.mode"] token := queryParams["hub.verify_token"] challenge := queryParams["hub.challenge"] if mode != "subscribe" { applogger.L().Debug("Facebook webhook verification: hub.mode is not subscribe", "mode", mode) return "", false } if token != verifyToken { applogger.L().Warn("Facebook webhook verification: verify_token mismatch") return "", false } applogger.L().Info("Facebook webhook verification successful") return challenge, true } // IsEchoMessage checks if the event is an echo of an agent's outgoing message. // Echo messages should not create new conversations — they confirm delivery. // Reference: Chatwoot filters out is_echo messages in IncomingMessageService. func IsEchoMessage(event *ParsedWebhookEvent) bool { return event.EventType == EventFBMessageEcho || event.EventType == EventIGMessageEcho } // IsDeliveryOrReadReceipt checks if the event is a delivery or read receipt. // Receipts should update message status, not create new messages. // Reference: Chatwoot handles delivery/read receipts separately. func IsDeliveryOrReadReceipt(event *ParsedWebhookEvent) bool { return event.EventType == EventFBDelivery || event.EventType == EventFBRead || event.EventType == EventIGDelivery || event.EventType == EventIGRead } // ShouldCreateMessage determines if the event should result in a new message creation. // Echo messages and receipts are excluded from message creation. func ShouldCreateMessage(event *ParsedWebhookEvent) bool { return !IsEchoMessage(event) && !IsDeliveryOrReadReceipt(event) && !IsThreadControlEvent(event) } // IsThreadControlEvent checks if the event is a thread control handoff. func IsThreadControlEvent(event *ParsedWebhookEvent) bool { return event.EventType == EventFBPassThread || event.EventType == EventFBTakeThread || event.EventType == EventFBRequestThread } // ExtractIncomingMessageFromEvent converts a ParsedWebhookEvent into a channel.IncomingMessage. // This is the main entry point called by FacebookProvider.ProcessIncoming and InstagramProvider.ProcessIncoming. func ExtractIncomingMessageFromEvent(event *ParsedWebhookEvent, inbox *model.Inbox, channelType channel.ChannelType) (*channel.IncomingMessage, error) { if !ShouldCreateMessage(event) { // Delivery/read receipts and echo messages are handled separately return nil, fmt.Errorf("event type %s does not create incoming messages", event.EventType) } msg := &channel.IncomingMessage{ ChannelType: channelType, SourceID: extractSourceID(event), ConversationID: event.PageID, SenderID: event.SenderID, SenderType: channel.SenderContact, InboxID: inbox.ID, AccountID: inbox.AccountID, ReceivedAt: millisToTime(event.Timestamp), } // Fill content based on event type switch event.EventType { case EventFBMessage, EventIGMessage: if event.Message != nil { msg.Content = event.Message.Text msg.ContentType = determineContentType(event.Message) msg.Attachments = convertAttachments(event.Message.Attachments, channelType) if event.Message.ReplyTo != nil { msg.ReplyToID = event.Message.ReplyTo.Mid } } case EventFBPostback, EventIGPostback: if event.Postback != nil { msg.Content = event.Postback.Data msg.ContentType = channel.ContentText msg.Extra = map[string]interface{}{ "postback_title": event.Postback.Title, } if event.Postback.Referral != nil { msg.Extra["referral_ref"] = event.Postback.Referral.Ref msg.Extra["referral_source"] = event.Postback.Referral.Source } } } // Add sender/channel metadata msg.SenderExtra = map[string]interface{}{ "sender_id": event.SenderID, "recipient_id": event.RecipientID, } msg.ConversationExtra = map[string]interface{}{ "page_id": event.PageID, } return msg, nil } // extractSourceID extracts the external message ID from an event. func extractSourceID(event *ParsedWebhookEvent) string { if event.Message != nil && event.Message.Mid != "" { return event.Message.Mid } // For postbacks, generate a synthetic source ID return fmt.Sprintf("%s_%s_%d", event.PageID, event.SenderID, event.Timestamp) } // determineContentType determines the message content type from FB message data. func determineContentType(msgData *FBMessageData) channel.ContentType { if len(msgData.Attachments) > 0 { att := msgData.Attachments[0] switch att.Type { case "image": return channel.ContentImage case "video": return channel.ContentVideo case "audio": return channel.ContentAudio case "file": return channel.ContentFile case "location": return channel.ContentLocation case "fallback": return channel.ContentFile default: return channel.ContentFile } } if msgData.Text != "" { return channel.ContentText } return channel.ContentText } // convertAttachments converts FB attachments to gochat Attachment structs. func convertAttachments(fbAttachments []FBAttachment, channelType channel.ChannelType) []channel.Attachment { var attachments []channel.Attachment for _, att := range fbAttachments { attachment := channel.Attachment{ URL: att.Payload.URL, ContentType: mimeTypeFromFBType(att.Type), Extra: map[string]interface{}{ "fb_attachment_type": att.Type, "channel_type": string(channelType), }, } if att.Payload.Name != "" { attachment.Filename = att.Payload.Name } // Add location data if applicable if att.Type == "location" && att.Payload.Coordinates != nil { attachment.Extra["latitude"] = att.Payload.Coordinates.Lat attachment.Extra["longitude"] = att.Payload.Coordinates.Long } attachments = append(attachments, attachment) } return attachments } // millisToTime converts Unix milliseconds to time.Time. func millisToTime(millis int64) time.Time { if millis == 0 { return time.Now() } return time.UnixMilli(millis) } // IsIGCommentEvent checks if the parsed event is an Instagram comment event. func IsIGCommentEvent(event *ParsedWebhookEvent) bool { return event.EventType == EventIGComment || event.EventType == EventIGCommentReply || event.EventType == EventIGCommentDeleted }