427 lines
16 KiB
Go
427 lines
16 KiB
Go
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 ""
|
|
}
|