298 lines
9.4 KiB
Go
298 lines
9.4 KiB
Go
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
|
|
} |