521 lines
18 KiB
Plaintext
521 lines
18 KiB
Plaintext
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=<hex_digest>
|
|
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=<hex_digest>"
|
|
// 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
|
|
}
|