Files
gochat/internal/channel/facebook/webhook_handler.go
T
2026-06-04 15:44:48 +08:00

520 lines
18 KiB
Go

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
}