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

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 ""
}