package facebook // User mapping for Facebook/Instagram channels. // Reference: Chatwoot's contact resolution: // - app/services/facebook/incoming_message_service.rb (FB sender → Contact) // - app/services/contact_service.rb (create/find Contact by source_id) // - app/models/contact.rb (identifier = FB PSID or IGSID) // // Facebook uses Page-Scoped IDs (PSID) for Messenger users. // Instagram uses Instagram-Scoped IDs (IGSID) for DM users. // Both IDs are scoped to the specific Page/Business Account. // // Mapping flow: // 1. Webhook event arrives with sender PSID/IGSID // 2. ResolveContact() maps PSID/IGSID → existing Contact (via identifier lookup) // 3. If no Contact exists, create one using FB/IG user profile data // 4. Periodically update contact profile data (every 24h) import ( "context" "fmt" "time" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/search" applogger "github.com/gochat/gochat/pkg/logger" ) // UserMappingService handles the resolution of FB/IG user IDs to gochat Contacts. type UserMappingService struct { service *Service // Graph API service for profile fetches fbRepo *Repository // FB/IG channel config persistence contactRepo *repository.ContactRepo // Contact CRUD } // NewUserMappingService creates a new user mapping service. func NewUserMappingService(service *Service, fbRepo *Repository, contactRepo *repository.ContactRepo) *UserMappingService { return &UserMappingService{ service: service, fbRepo: fbRepo, contactRepo: contactRepo, } } // ResolveContact maps a FB/IG sender ID to an existing or new Contact. // This is the main entry point called during incoming message processing. func (ums *UserMappingService) ResolveContact( ctx context.Context, senderID string, inbox *model.Inbox, channelType channel.ChannelType, ) (*model.Contact, error) { // Step 1: Try to find existing contact by identifier (= source_id) // Use GORM directly since ContactRepo doesn't have FindByIdentifier yet existingContact, err := ums.findContactByIdentifier(ctx, senderID, inbox.AccountID) if err == nil && existingContact != nil { applogger.L().Debug("Resolved existing contact for FB/IG sender", "sender_id", senderID, "contact_id", existingContact.ID, "channel_type", channelType) // Update contact profile if stale (every 24 hours) if shouldUpdateProfile(existingContact) { if err := ums.updateContactProfile(ctx, existingContact, senderID, inbox, channelType); err != nil { applogger.L().Warn("Failed to update FB/IG contact profile", "contact_id", existingContact.ID, "error", err) } } return existingContact, nil } // Step 2: No existing contact — fetch user profile from FB/IG Graph API profile, err := ums.fetchUserProfile(ctx, senderID, inbox, channelType) if err != nil { applogger.L().Warn("Failed to fetch FB/IG user profile for new contact", "sender_id", senderID, "channel_type", channelType, "error", err) // Create a minimal contact even without profile data return ums.createMinimalContact(ctx, senderID, inbox, channelType) } // Step 3: Create new Contact with profile data return ums.createContactFromProfile(ctx, senderID, profile, inbox, channelType) } // findContactByIdentifier looks up a Contact by its identifier within an account. // This uses ContactRepo's underlying GORM connection for the query. func (ums *UserMappingService) findContactByIdentifier( ctx context.Context, identifier string, accountID uint, ) (*model.Contact, error) { // Delegate to ContactRepo — we use Search as a fallback since // ContactRepo doesn't have a FindByIdentifier method yet. contacts, _, err := ums.contactRepo.Search(ctx, accountID, identifier, int(0), int(10), "name ASC", search.SearchModeILike) if err != nil { return nil, err } // Search matches by name, email, phone, or identifier. // Filter to exact identifier match. for _, c := range contacts { if c.Identifier == identifier { return &c, nil } } return nil, fmt.Errorf("contact not found for identifier %s", identifier) } // fetchUserProfile retrieves the user's profile from the FB/IG Graph API. // Returns a ContactProfile populated from FBUserProfile or IGUserProfile. func (ums *UserMappingService) fetchUserProfile( ctx context.Context, senderID string, inbox *model.Inbox, channelType channel.ChannelType, ) (*channel.ContactProfile, error) { accessToken, err := ums.getAccessTokenForInbox(ctx, inbox, channelType) if err != nil { return nil, fmt.Errorf("failed to get access token: %w", err) } switch channelType { case channel.ChannelFacebook: fbProfile, err := ums.service.FetchFacebookUserProfile(ctx, senderID, accessToken) if err != nil { return nil, err } return &channel.ContactProfile{ Name: fbProfile.Name, AvatarURL: fbProfile.ProfilePic, Extra: channel.ChannelConfig{ "first_name": fbProfile.FirstName, "last_name": fbProfile.LastName, "locale": fbProfile.Locale, "timezone": fbProfile.Timezone, "gender": fbProfile.Gender, }, }, nil case channel.ChannelInstagram: igProfile, err := ums.service.FetchInstagramUserProfile(ctx, senderID, accessToken) if err != nil { return nil, err } return &channel.ContactProfile{ Name: igProfile.Name, AvatarURL: igProfile.ProfilePic, Extra: channel.ChannelConfig{ "username": igProfile.Username, "followers_count": igProfile.FollowersCount, }, }, nil default: return nil, fmt.Errorf("unsupported channel type for user mapping: %s", channelType) } } // getAccessTokenForInbox retrieves the access token from the channel configuration. func (ums *UserMappingService) getAccessTokenForInbox( ctx context.Context, inbox *model.Inbox, channelType channel.ChannelType, ) (string, error) { switch channelType { case channel.ChannelFacebook: chConfig, err := ums.fbRepo.GetFacebookByID(ctx, inbox.ChannelID) if err != nil { return "", fmt.Errorf("failed to find Facebook channel config: %w", err) } return chConfig.PageAccessToken, nil case channel.ChannelInstagram: chConfig, err := ums.fbRepo.GetInstagramByID(ctx, inbox.ChannelID) if err != nil { return "", fmt.Errorf("failed to find Instagram channel config: %w", err) } return chConfig.PageAccessToken, nil default: return "", fmt.Errorf("unsupported channel type: %s", channelType) } } // createContactFromProfile creates a new Contact using profile data from FB/IG. func (ums *UserMappingService) createContactFromProfile( ctx context.Context, senderID string, profile *channel.ContactProfile, inbox *model.Inbox, channelType channel.ChannelType, ) (*model.Contact, error) { contact := &model.Contact{ AccountID: inbox.AccountID, Name: profile.Name, AvatarURL: profile.AvatarURL, Identifier: senderID, // FB PSID or IGSID — used for lookup } if err := ums.contactRepo.Create(ctx, contact); err != nil { return nil, fmt.Errorf("failed to create contact for sender %s: %w", senderID, err) } applogger.L().Info("Created new contact for FB/IG sender", "sender_id", senderID, "contact_id", contact.ID, "channel_type", channelType, "name", profile.Name) return contact, nil } // createMinimalContact creates a Contact with minimal data when profile fetch fails. func (ums *UserMappingService) createMinimalContact( ctx context.Context, senderID string, inbox *model.Inbox, channelType channel.ChannelType, ) (*model.Contact, error) { displayName := "FB User" if channelType == channel.ChannelInstagram { displayName = "IG User" } displayName = fmt.Sprintf("%s %s", displayName, truncateID(senderID)) contact := &model.Contact{ AccountID: inbox.AccountID, Name: displayName, Identifier: senderID, } if err := ums.contactRepo.Create(ctx, contact); err != nil { return nil, fmt.Errorf("failed to create minimal contact for sender %s: %w", senderID, err) } applogger.L().Info("Created minimal contact for FB/IG sender (profile unavailable)", "sender_id", senderID, "contact_id", contact.ID, "channel_type", channelType) return contact, nil } // updateContactProfile updates an existing Contact's profile data from FB/IG. func (ums *UserMappingService) updateContactProfile( ctx context.Context, contact *model.Contact, senderID string, inbox *model.Inbox, channelType channel.ChannelType, ) error { profile, err := ums.fetchUserProfile(ctx, senderID, inbox, channelType) if err != nil { return fmt.Errorf("profile fetch failed: %w", err) } if profile.Name != "" && profile.Name != contact.Name { contact.Name = profile.Name } if profile.AvatarURL != "" && profile.AvatarURL != contact.AvatarURL { contact.AvatarURL = profile.AvatarURL } if err := ums.contactRepo.Update(ctx, contact); err != nil { return fmt.Errorf("contact update failed: %w", err) } applogger.L().Debug("Updated FB/IG contact profile", "contact_id", contact.ID, "sender_id", senderID) return nil } // shouldUpdateProfile checks if the contact's profile data should be refreshed. func shouldUpdateProfile(contact *model.Contact) bool { if contact.Name == "" { return true } if len(contact.Name) >= 7 { prefix := contact.Name[:7] if prefix == "FB User" || prefix == "IG User" { return true } } return time.Since(contact.UpdatedAt) > 24*time.Hour } // truncateID shortens a long FB/IG ID for display in placeholder names. func truncateID(id string) string { if len(id) > 8 { return id[len(id)-8:] } return id }