package facebook // Pipeline integration for Facebook/Instagram channel with gochat's message processing system. // Reference: Chatwoot's message pipeline architecture: // - Incoming: FB webhook → IncomingMessageService → Conversation + Message creation // - Outgoing: Message creation → SendOnFacebookService → FB Send API // - Instagram: same flow with IG-specific parsing // - Updates: message.updated events, delivery/read receipts // // gochat's pipeline architecture (from P5/P6): // - IncomingMessageProcessor: receives raw payloads → transforms to IncomingMessage → creates Conversation + Message // - OutgoingMessageProcessor: receives outgoing Message → sends via channel → returns SendResult // - Broker: routes messages between providers and pipelines // - Event system: message.created, message.updated, message.deleted events // // This file provides the bridge between the FB/IG providers and gochat's pipeline. import ( "context" "encoding/json" "fmt" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" applogger "github.com/gochat/gochat/pkg/logger" ) // === Incoming Pipeline Integration === // IncomingProcessor bridges Facebook/Instagram webhook events to gochat's IncomingMessageProcessor. // Reference: Chatwoot IncomingMessageService.perform — creates contact, conversation, message // // Flow: // 1. Receive FB/IG webhook event (via webhook handler) // 2. Parse into IncomingMessage (via FacebookProvider/InstagramProvider.ProcessIncoming) // 3. Create/update Contact (from FB user/IG profile) // 4. Create/update Conversation (from page_id / ig_account_id) // 5. Create Message (with content + attachments) // 6. Handle special cases (echo messages, delivery receipts, read receipts) type IncomingProcessor struct { fbProvider *FacebookProvider igProvider *InstagramProvider service *Service userMapping *UserMappingService // contactRepo ContactRepository (placeholder — injected in production) // conversationRepo ConversationRepository (placeholder — injected in production) // messageRepo MessageRepository (placeholder — injected in production) } // NewIncomingProcessor creates an incoming message processor for Facebook/Instagram. func NewIncomingProcessor(fbProvider *FacebookProvider, igProvider *InstagramProvider, service *Service, userMapping *UserMappingService) *IncomingProcessor { return &IncomingProcessor{ fbProvider: fbProvider, igProvider: igProvider, service: service, userMapping: userMapping, } } // ProcessFacebookEvent processes a Facebook Messenger webhook event through the incoming pipeline. // // Reference: Chatwoot IncomingMessageService steps: // 1. Find or create contact from FB sender PSID // 2. Find or create conversation (keyed on page_id + sender PSID) // 3. Create message with content/attachments // 4. Handle delivery/read receipts (not creating new messages) func (p *IncomingProcessor) ProcessFacebookEvent(ctx context.Context, inbox *model.Inbox, rawPayload []byte) error { // Step 1: Transform raw payload into IncomingMessage incomingMsg, err := p.fbProvider.ProcessIncoming(ctx, inbox, rawPayload) if err != nil { return fmt.Errorf("facebook incoming pipeline: transform failed: %w", err) } if incomingMsg == nil { return nil // no processable content (e.g., echo message, delivery receipt) } // Step 2: Resolve or create Contact from FB user PSID // Reference: Chatwoot: find_or_create_contact using source_id (FB PSID) contact, err := p.resolveOrCreateFacebookContact(ctx, inbox, incomingMsg) if err != nil { return fmt.Errorf("facebook incoming pipeline: contact resolution failed: %w", err) } // Step 3: Resolve or create Conversation // Reference: Chatwoot: find_or_create_conversation using inbox_id + contact_id conversation, err := p.resolveOrCreateConversation(ctx, inbox, contact, incomingMsg) if err != nil { return fmt.Errorf("facebook incoming pipeline: conversation resolution failed: %w", err) } // Step 4: Create or update Message if err := p.createOrUpdateMessage(ctx, conversation, contact, incomingMsg); err != nil { return fmt.Errorf("facebook incoming pipeline: message creation failed: %w", err) } applogger.L().Info("Facebook incoming message processed", "inbox_id", inbox.ID, "source_id", incomingMsg.SourceID, "contact_id", contact.ID, "conversation_id", conversation.ID, ) return nil } // ProcessInstagramEvent processes an Instagram DM webhook event through the incoming pipeline. func (p *IncomingProcessor) ProcessInstagramEvent(ctx context.Context, inbox *model.Inbox, rawPayload []byte) error { // Step 1: Transform raw payload into IncomingMessage incomingMsg, err := p.igProvider.ProcessIncoming(ctx, inbox, rawPayload) if err != nil { return fmt.Errorf("instagram incoming pipeline: transform failed: %w", err) } if incomingMsg == nil { return nil } // Step 2: Resolve or create Contact from IG user IGID contact, err := p.resolveOrCreateInstagramContact(ctx, inbox, incomingMsg) if err != nil { return fmt.Errorf("instagram incoming pipeline: contact resolution failed: %w", err) } // Step 3: Resolve or create Conversation conversation, err := p.resolveOrCreateConversation(ctx, inbox, contact, incomingMsg) if err != nil { return fmt.Errorf("instagram incoming pipeline: conversation resolution failed: %w", err) } // Step 4: Create or update Message if err := p.createOrUpdateMessage(ctx, conversation, contact, incomingMsg); err != nil { return fmt.Errorf("instagram incoming pipeline: message creation failed: %w", err) } applogger.L().Info("Instagram incoming message processed", "inbox_id", inbox.ID, "source_id", incomingMsg.SourceID, "contact_id", contact.ID, "conversation_id", conversation.ID, ) return nil } // ProcessInstagramCommentEvent processes an Instagram comment webhook event through the incoming pipeline. // Instagram comments on media posts arrive via the "changes" field (not "messaging") with field="comments". // Reference: https://developers.facebook.com/docs/instagram-api/guides/webhooks#comment-webhooks // // Flow: // 1. Parse the comment webhook event into ParsedWebhookEvent (via WebhookParser.classifyChangeEvent) // 2. Transform into IncomingMessage (via InstagramProvider.ProcessCommentIncoming) // 3. Resolve/create Contact from IG commenter profile // 4. Resolve/create Conversation (keyed on media_id for comments, not sender_id like DMs) // 5. Create Message with comment content func (p *IncomingProcessor) ProcessInstagramCommentEvent(ctx context.Context, inbox *model.Inbox, parsedEvent *ParsedWebhookEvent) error { if parsedEvent.Comment == nil { return fmt.Errorf("instagram comment event has no comment data") } commentData := parsedEvent.Comment // Step 1: Transform parsed comment event into IncomingMessage incomingMsg, err := p.igProvider.ProcessCommentIncoming(ctx, inbox, commentData, parsedEvent.EventType) if err != nil { return fmt.Errorf("instagram comment pipeline: transform failed: %w", err) } if incomingMsg == nil { return nil } // Step 2: Resolve or create Contact from IG commenter // Reference: Chatwoot creates a contact from the comment author's IG profile contact, err := p.resolveOrCreateInstagramContact(ctx, inbox, incomingMsg) if err != nil { return fmt.Errorf("instagram comment pipeline: contact resolution failed: %w", err) } // Step 3: Resolve or create Conversation // For comments, conversation is keyed on media_id + inbox_id rather than sender_id // Multiple commenters on the same media post share a conversation conversation, err := p.resolveOrCreateConversation(ctx, inbox, contact, incomingMsg) if err != nil { return fmt.Errorf("instagram comment pipeline: conversation resolution failed: %w", err) } // Step 4: Create Message if err := p.createOrUpdateMessage(ctx, conversation, contact, incomingMsg); err != nil { return fmt.Errorf("instagram comment pipeline: message creation failed: %w", err) } applogger.L().Info("Instagram comment event processed", "inbox_id", inbox.ID, "comment_id", commentData.CommentID, "media_id", commentData.MediaID, "event_type", parsedEvent.EventType, "contact_id", contact.ID, "conversation_id", conversation.ID, ) return nil } // === Outgoing Pipeline Integration === // OutgoingProcessor bridges outgoing gochat messages to Facebook/Instagram Send API. // Reference: Chatwoot SendOnFacebookService / SendOnInstagramService type OutgoingProcessor struct { fbProvider *FacebookProvider igProvider *InstagramProvider service *Service } // NewOutgoingProcessor creates an outgoing message processor for Facebook/Instagram. func NewOutgoingProcessor(fbProvider *FacebookProvider, igProvider *InstagramProvider, service *Service) *OutgoingProcessor { return &OutgoingProcessor{ fbProvider: fbProvider, igProvider: igProvider, service: service, } } // ProcessOutgoingMessage sends a gochat Message through the Facebook/Instagram channel. // Reference: Chatwoot SendOnFacebookService.perform // // Flow: // 1. Determine channel type (facebook vs instagram) from Inbox // 2. Format message for the target channel // 3. Call Send API via provider // 4. Handle delivery result (update Message external_id, delivered_at) func (p *OutgoingProcessor) ProcessOutgoingMessage(ctx context.Context, inbox *model.Inbox, message *model.Message, contact *model.Contact) (*channel.SendResult, error) { switch inbox.ChannelType { case "facebook": result, err := p.fbProvider.SendMessage(ctx, inbox, message, contact) if err != nil { // Check for authorization errors if p.fbProvider.CheckAuthorizationError(ctx, err) { p.fbProvider.OnReauthorization(ctx, inbox) } return nil, fmt.Errorf("facebook outgoing pipeline: send failed: %w", err) } applogger.L().Info("Facebook outgoing message sent", "inbox_id", inbox.ID, "message_id", message.ID, "external_id", result.ExternalID, ) return result, nil case "instagram": result, err := p.igProvider.SendMessage(ctx, inbox, message, contact) if err != nil { if p.igProvider.CheckAuthorizationError(ctx, err) { p.igProvider.OnReauthorization(ctx, inbox) } return nil, fmt.Errorf("instagram outgoing pipeline: send failed: %w", err) } applogger.L().Info("Instagram outgoing message sent", "inbox_id", inbox.ID, "message_id", message.ID, "external_id", result.ExternalID, ) return result, nil default: return nil, fmt.Errorf("unsupported channel type for outgoing: %s", inbox.ChannelType) } } // === Delivery/Read Receipt Pipeline === // ProcessDeliveryReceipt processes a Facebook message delivery receipt event. // Reference: Chatwoot marks Message as delivered when delivery webhook arrives func (p *IncomingProcessor) ProcessDeliveryReceipt(ctx context.Context, inbox *model.Inbox, event *FBMessagingEvent) error { if event.Delivery == nil { return nil } applogger.L().Debug("Facebook delivery receipt received", "inbox_id", inbox.ID, "mids", event.Delivery.Mids, "watermark", event.Delivery.Watermark, ) // In production: update Message status for each mid in event.Delivery.Mids // MessageStatus = "delivered" // DeliveredAt = event.Delivery.Watermark return nil } // ProcessReadReceipt processes a Facebook message read receipt event. func (p *IncomingProcessor) ProcessReadReceipt(ctx context.Context, inbox *model.Inbox, event *FBMessagingEvent) error { if event.Read == nil { return nil } applogger.L().Debug("Facebook read receipt received", "inbox_id", inbox.ID, "watermark", event.Read.Watermark, ) // In production: update Message status to "read" return nil } // === Internal Processing Helpers === // resolveOrCreateFacebookContact creates or resolves a Contact from a FB PSID. func (p *IncomingProcessor) resolveOrCreateFacebookContact(ctx context.Context, inbox *model.Inbox, msg *channel.IncomingMessage) (*model.Contact, error) { // Reference: Chatwoot Contacts::Inbox::FindBySourceId or create new contact // // 1. Look up existing contact by source_id (FB PSID) + inbox_id // 2. If not found, create new Contact with FB user profile info // 3. Fetch FB user profile via Graph API for name/avatar psID := msg.SenderID if psID == "" { return nil, fmt.Errorf("facebook incoming: missing sender PSID") } // Try to resolve via user mapping service contact, err := p.userMapping.ResolveContact(ctx, psID, inbox, channel.ChannelFacebook) if err == nil && contact != nil { // Update contact profile if needed return contact, nil } // Create new contact — fetch FB profile for name/avatar pageAccessToken := p.service.extractPageAccessTokenFromInbox(inbox) profile, profileErr := p.service.FetchFacebookUserProfile(ctx, psID, pageAccessToken) contact = &model.Contact{ AccountID: inbox.AccountID, Identifier: psID, } if profileErr == nil { contact.Name = profile.Name contact.AvatarURL = profile.ProfilePic } // In production: persist via contactRepo.Create(contact) applogger.L().Info("New Facebook contact created", "account_id", inbox.AccountID, "psid", psID, "name", contact.Name) return contact, nil } // resolveOrCreateInstagramContact creates or resolves a Contact from an IG IGID. func (p *IncomingProcessor) resolveOrCreateInstagramContact(ctx context.Context, inbox *model.Inbox, msg *channel.IncomingMessage) (*model.Contact, error) { igid := msg.SenderID if igid == "" { return nil, fmt.Errorf("instagram incoming: missing sender IGID") } contact, err := p.userMapping.ResolveContact(ctx, igid, inbox, channel.ChannelInstagram) if err == nil && contact != nil { return contact, nil } pageAccessToken := p.service.extractPageAccessTokenFromInbox(inbox) profile, profileErr := p.service.FetchInstagramUserProfile(ctx, igid, pageAccessToken) contact = &model.Contact{ AccountID: inbox.AccountID, Identifier: igid, } if profileErr == nil { contact.Name = profile.Username contact.AvatarURL = profile.ProfilePic } applogger.L().Info("New Instagram contact created", "account_id", inbox.AccountID, "igid", igid, "username", contact.Name) return contact, nil } // resolveOrCreateConversation creates or resolves a Conversation. func (p *IncomingProcessor) resolveOrCreateConversation(ctx context.Context, inbox *model.Inbox, contact *model.Contact, msg *channel.IncomingMessage) (*model.Conversation, error) { // Reference: Chatwoot Conversation.find_or_create_by inbox_id + contact_id // - For FB: conversation is per page + per sender PSID // - For IG: conversation is per IG account + per sender IGID conversation := &model.Conversation{ InboxID: inbox.ID, AccountID: inbox.AccountID, ContactID: contact.ID, ChannelType: string(msg.ChannelType), } // In production: persist or find via conversationRepo return conversation, nil } // createOrUpdateMessage creates a new Message or updates an existing one. func (p *IncomingProcessor) createOrUpdateMessage(ctx context.Context, conversation *model.Conversation, contact *model.Contact, msg *channel.IncomingMessage) error { _ = &model.Message{ ConversationID: conversation.ID, AccountID: conversation.AccountID, Content: msg.Content, ContentType: string(msg.ContentType), SourceID: msg.SourceID, InboxID: msg.InboxID, } // In production: persist via messageRepo.Create(message) // Handle attachments separately applogger.L().Info("Message created", "conversation_id", conversation.ID, "source_id", msg.SourceID, "content_type", msg.ContentType, ) return nil } // === Helper method on Service === // extractPageAccessTokenFromInbox is a helper to get page access token from inbox config. func (s *Service) extractPageAccessTokenFromInbox(inbox *model.Inbox) string { if inbox.ChannelConfig != "" { var config map[string]interface{} if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err == nil { if token, ok := config["page_access_token"].(string); ok { return token } } } return "" }