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

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
}