package facebook // FacebookService provides high-level operations for Facebook Messenger and Instagram DM channels. // Reference: Chatwoot app/services/facebook_service.rb // // This service coordinates between: // - ChannelFacebook / ChannelInstagram model (CRUD) // - FacebookProvider / InstagramProvider (Graph API calls) // - Repository (GORM persistence) // - IncomingProcessor / OutgoingProcessor (pipeline integration) // // Design: Service pattern follows Chatwoot's service layer where each channel // has a service module that wraps channel-specific business logic. // Facebook and Instagram share this service since Instagram DMs use the FB Graph API. import ( "context" "fmt" "os" "time" "github.com/go-resty/resty/v2" channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/model" applogger "github.com/gochat/gochat/pkg/logger" ) // Service handles Facebook/Instagram channel business logic. type Service struct { client *resty.Client repo *Repository graphAPIBase string appID string appSecret string } // NewService creates a new Facebook/Instagram service. func NewService(repo *Repository) *Service { client := resty.New() client.SetTimeout(30 * time.Second) client.SetRetryCount(3) client.SetRetryWaitTime(1 * time.Second) client.SetRetryMaxWaitTime(5 * time.Second) graphAPIBase := getEnvOrDefault("FB_GRAPH_API_BASE", "https://graph.facebook.com/v18.0") appID := getEnvOrDefault("FB_APP_ID", "") appSecret := getEnvOrDefault("FB_APP_SECRET", "") return &Service{ client: client, repo: repo, graphAPIBase: graphAPIBase, appID: appID, appSecret: appSecret, } } // === Facebook Channel CRUD === // CreateFacebookChannel creates a new Facebook Messenger channel. // Reference: Chatwoot FacebookPagesController#create // Flow: validate page_access_token → create ChannelFacebook → setup webhook → create Inbox func (s *Service) CreateFacebookChannel(ctx context.Context, accountID uint, pageID string, pageAccessToken string, webhookVerifyToken string) (*channelmodel.ChannelFacebook, error) { // Step 1: Validate page access token via Graph API (Chatwoot: ensure_valid_page_token) pageInfo, err := s.validatePageAccessToken(ctx, pageID, pageAccessToken) if err != nil { return nil, fmt.Errorf("facebook page access token validation failed: %w", err) } ch := &channelmodel.ChannelFacebook{ AccountID: accountID, PageID: pageID, PageAccessToken: pageAccessToken, PageName: pageInfo.Name, AppID: s.appID, WebhookVerifyToken: webhookVerifyToken, } // Check if the page has a linked Instagram business account if pageInfo.InstagramBusinessAccount != nil && pageInfo.InstagramBusinessAccount.ID != "" { ch.InstagramBusinessAccountID = pageInfo.InstagramBusinessAccount.ID } // Step 2: Persist channel (via repository) if err := s.repo.CreateFacebook(ctx, ch); err != nil { return nil, fmt.Errorf("failed to create ChannelFacebook: %w", err) } // Step 3: Setup webhook subscription fbProvider := NewFacebookProvider() if err := fbProvider.setupWebhookSubscription(ctx, s.appID, pageID, pageAccessToken, webhookVerifyToken); err != nil { applogger.L().Warn("Facebook webhook subscription setup failed (can retry later)", "error", err, "page_id", pageID) } applogger.L().Info("Facebook channel created", "account_id", accountID, "page_id", pageID, "page_name", pageInfo.Name) return ch, nil } // UpdateFacebookChannel updates an existing Facebook channel. // Reference: Chatwoot FacebookPagesController#update func (s *Service) UpdateFacebookChannel(ctx context.Context, channelID uint, params map[string]interface{}) (*channelmodel.ChannelFacebook, error) { ch, err := s.repo.GetFacebookByID(ctx, channelID) if err != nil { return nil, fmt.Errorf("failed to fetch ChannelFacebook: %w", err) } if token, ok := params["page_access_token"].(string); ok && token != "" { // Re-validate new token _, validateErr := s.validatePageAccessToken(ctx, ch.PageID, token) if validateErr != nil { return nil, fmt.Errorf("new page_access_token validation failed: %w", validateErr) } ch.PageAccessToken = token } if name, ok := params["page_name"].(string); ok { ch.PageName = name } if err := s.repo.UpdateFacebook(ctx, ch); err != nil { return nil, fmt.Errorf("failed to update ChannelFacebook: %w", err) } applogger.L().Info("Facebook channel updated", "id", channelID) return ch, nil } // DeleteFacebookChannel removes a Facebook channel and its webhook subscription. // Reference: Chatwoot FacebookPagesController#destroy + after_destroy :delete_facebook_page func (s *Service) DeleteFacebookChannel(ctx context.Context, channelID uint) error { ch, err := s.repo.GetFacebookByID(ctx, channelID) if err != nil { return fmt.Errorf("failed to fetch ChannelFacebook for deletion: %w", err) } // Unsubscribe webhook fbProvider := NewFacebookProvider() fbProvider.unsubscribeWebhook(ctx, s.appID, ch.PageID, ch.PageAccessToken) if err := s.repo.DeleteFacebook(ctx, channelID); err != nil { return fmt.Errorf("failed to delete ChannelFacebook: %w", err) } applogger.L().Info("Facebook channel deleted", "id", channelID) return nil } // === Instagram Channel CRUD === // CreateInstagramChannel creates a new Instagram DM channel. // Reference: Chatwoot InstagramController#create // Flow: validate ig account → create ChannelInstagram → setup webhook (via FB Page) → create Inbox func (s *Service) CreateInstagramChannel(ctx context.Context, accountID uint, igAccountID string, pageAccessToken string, connectedFBPageID string, webhookVerifyToken string) (*channelmodel.ChannelInstagram, error) { // Step 1: Validate Instagram account via Graph API igProfile, err := s.validateInstagramAccount(ctx, igAccountID, pageAccessToken) if err != nil { return nil, fmt.Errorf("instagram account validation failed: %w", err) } // Step 2: Resolve Instagram Business Account ID from FB Page igBusinessAccountID := igAccountID pageInfo, pageErr := s.fetchFBPageInfo(ctx, connectedFBPageID, pageAccessToken) if pageErr == nil && pageInfo.InstagramBusinessAccount != nil { igBusinessAccountID = pageInfo.InstagramBusinessAccount.ID } ch := &channelmodel.ChannelInstagram{ AccountID: accountID, InstagramAccountID: igAccountID, InstagramBusinessAccountID: igBusinessAccountID, PageAccessToken: pageAccessToken, ConnectedFBPageID: connectedFBPageID, InstagramAccountName: igProfile.Username, } // Step 3: Persist channel if err := s.repo.CreateInstagram(ctx, ch); err != nil { return nil, fmt.Errorf("failed to create ChannelInstagram: %w", err) } // Step 4: Setup webhook subscription via FB Page fbProvider := NewFacebookProvider() if err := fbProvider.setupWebhookSubscription(ctx, s.appID, connectedFBPageID, pageAccessToken, webhookVerifyToken); err != nil { applogger.L().Warn("Instagram webhook subscription setup failed (can retry later)", "error", err, "ig_account_id", igAccountID) } applogger.L().Info("Instagram channel created", "account_id", accountID, "ig_account_id", igAccountID, "ig_username", igProfile.Username) return ch, nil } // UpdateInstagramChannel updates an existing Instagram channel. func (s *Service) UpdateInstagramChannel(ctx context.Context, channelID uint, params map[string]interface{}) (*channelmodel.ChannelInstagram, error) { ch, err := s.repo.GetInstagramByID(ctx, channelID) if err != nil { return nil, fmt.Errorf("failed to fetch ChannelInstagram: %w", err) } if token, ok := params["page_access_token"].(string); ok && token != "" { _, validateErr := s.validateInstagramAccount(ctx, ch.InstagramAccountID, token) if validateErr != nil { return nil, fmt.Errorf("new page_access_token validation failed: %w", validateErr) } ch.PageAccessToken = token } if name, ok := params["instagram_account_name"].(string); ok { ch.InstagramAccountName = name } if err := s.repo.UpdateInstagram(ctx, ch); err != nil { return nil, fmt.Errorf("failed to update ChannelInstagram: %w", err) } applogger.L().Info("Instagram channel updated", "id", channelID) return ch, nil } // DeleteInstagramChannel removes an Instagram channel. func (s *Service) DeleteInstagramChannel(ctx context.Context, channelID uint) error { ch, err := s.repo.GetInstagramByID(ctx, channelID) if err != nil { return fmt.Errorf("failed to fetch ChannelInstagram for deletion: %w", err) } // Unsubscribe webhook via FB Page fbProvider := NewFacebookProvider() fbProvider.unsubscribeWebhook(ctx, s.appID, ch.ConnectedFBPageID, ch.PageAccessToken) if err := s.repo.DeleteInstagram(ctx, channelID); err != nil { return fmt.Errorf("failed to delete ChannelInstagram: %w", err) } applogger.L().Info("Instagram channel deleted", "id", channelID) return nil } // === Token Management === // RefreshPageAccessToken refreshes a long-lived Facebook Page access token. // Reference: Chatwoot RefreshOauthTokenService for Facebook // Facebook long-lived tokens expire after ~60 days; refresh before expiry. func (s *Service) RefreshPageAccessToken(ctx context.Context, channelID uint, channelType string) error { switch channelType { case "facebook": ch, err := s.repo.GetFacebookByID(ctx, channelID) if err != nil { return fmt.Errorf("failed to fetch ChannelFacebook for token refresh: %w", err) } newToken, refreshErr := s.exchangeLongLivedToken(ctx, ch.PageAccessToken) if refreshErr != nil { // Mark as requiring reauthorization ch.ReauthorizationRequired = true s.repo.UpdateFacebook(ctx, ch) return fmt.Errorf("token refresh failed, marked for reauthorization: %w", refreshErr) } ch.PageAccessToken = newToken.AccessToken ch.ReauthorizationRequired = false return s.repo.UpdateFacebook(ctx, ch) case "instagram": ch, err := s.repo.GetInstagramByID(ctx, channelID) if err != nil { return fmt.Errorf("failed to fetch ChannelInstagram for token refresh: %w", err) } newToken, refreshErr := s.exchangeLongLivedToken(ctx, ch.PageAccessToken) if refreshErr != nil { ch.ReauthorizationRequired = true s.repo.UpdateInstagram(ctx, ch) return fmt.Errorf("token refresh failed, marked for reauthorization: %w", refreshErr) } ch.PageAccessToken = newToken.AccessToken ch.ReauthorizationRequired = false return s.repo.UpdateInstagram(ctx, ch) default: return fmt.Errorf("unsupported channel type for token refresh: %s", channelType) } } // === Graph API Helpers === // validatePageAccessToken validates a Facebook Page access token via the Graph API. func (s *Service) validatePageAccessToken(ctx context.Context, pageID string, pageAccessToken string) (*FBPageInfo, error) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() resp, err := s.client.R(). SetContext(ctx). SetQueryParams(map[string]string{ "fields": "id,name,access_token,instagram_business_account{id}", "access_token": pageAccessToken, }). SetResult(&FBPageInfo{}). Get(fmt.Sprintf("%s/%s", s.graphAPIBase, pageID)) if err != nil { return nil, fmt.Errorf("page validation API call failed: %w", err) } if resp.StatusCode() != 200 { return nil, fmt.Errorf("page not found or token invalid (HTTP %d)", resp.StatusCode()) } return resp.Result().(*FBPageInfo), nil } // validateInstagramAccount validates an Instagram Business account via Graph API. func (s *Service) validateInstagramAccount(ctx context.Context, igAccountID string, pageAccessToken string) (*IGUserProfile, error) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() resp, err := s.client.R(). SetContext(ctx). SetQueryParams(map[string]string{ "fields": "id,username,name,profile_picture_url,biography", "access_token": pageAccessToken, }). SetResult(&IGUserProfile{}). Get(fmt.Sprintf("%s/%s", s.graphAPIBase, igAccountID)) if err != nil { return nil, fmt.Errorf("instagram account validation API call failed: %w", err) } if resp.StatusCode() != 200 { return nil, fmt.Errorf("instagram account not found (HTTP %d)", resp.StatusCode()) } return resp.Result().(*IGUserProfile), nil } // fetchFBPageInfo fetches Facebook Page info including linked Instagram account. func (s *Service) fetchFBPageInfo(ctx context.Context, pageID string, pageAccessToken string) (*FBPageInfo, error) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() resp, err := s.client.R(). SetContext(ctx). SetQueryParams(map[string]string{ "fields": "id,name,instagram_business_account{id,username}", "access_token": pageAccessToken, }). SetResult(&FBPageInfo{}). Get(fmt.Sprintf("%s/%s", s.graphAPIBase, pageID)) if err != nil { return nil, fmt.Errorf("page info fetch failed: %w", err) } if resp.StatusCode() != 200 { return nil, fmt.Errorf("page info fetch returned HTTP %d", resp.StatusCode()) } return resp.Result().(*FBPageInfo), nil } // exchangeLongLivedToken exchanges a short-lived or expiring long-lived token for a fresh long-lived token. // Reference: https://developers.facebook.com/docs/facebook-login/guides/access-tokens/get-long-lived func (s *Service) exchangeLongLivedToken(ctx context.Context, currentToken string) (*FBLongLivedTokenResponse, error) { resp, err := s.client.R(). SetContext(ctx). SetQueryParams(map[string]string{ "grant_type": "fb_exchange_token", "client_id": s.appID, "client_secret": s.appSecret, "fb_exchange_token": currentToken, }). SetResult(&FBLongLivedTokenResponse{}). Get(fmt.Sprintf("%s/oauth/access_token", s.graphAPIBase)) if err != nil { return nil, fmt.Errorf("token exchange API call failed: %w", err) } if resp.StatusCode() != 200 { return nil, fmt.Errorf("token exchange returned HTTP %d", resp.StatusCode()) } return resp.Result().(*FBLongLivedTokenResponse), nil } // === Contact Resolution === // FetchFacebookUserProfile fetches a Facebook user profile via the Graph API. // Used for contact creation/update when processing incoming messages. func (s *Service) FetchFacebookUserProfile(ctx context.Context, psID string, pageAccessToken string) (*FBUserProfile, error) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() resp, err := s.client.R(). SetContext(ctx). SetQueryParams(map[string]string{ "fields": "id,name,first_name,last_name,profile_pic,locale,timezone,gender", "access_token": pageAccessToken, }). SetResult(&FBUserProfile{}). Get(fmt.Sprintf("%s/%s", s.graphAPIBase, psID)) if err != nil { return nil, fmt.Errorf("facebook user profile fetch failed: %w", err) } if resp.StatusCode() != 200 { return nil, fmt.Errorf("facebook user profile fetch returned HTTP %d", resp.StatusCode()) } return resp.Result().(*FBUserProfile), nil } // FetchInstagramUserProfile fetches an Instagram user profile via the Graph API. func (s *Service) FetchInstagramUserProfile(ctx context.Context, igid string, pageAccessToken string) (*IGUserProfile, error) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() resp, err := s.client.R(). SetContext(ctx). SetQueryParams(map[string]string{ "fields": "id,username,name,profile_picture_url,biography,followers_count", "access_token": pageAccessToken, }). SetResult(&IGUserProfile{}). Get(fmt.Sprintf("%s/%s", s.graphAPIBase, igid)) if err != nil { return nil, fmt.Errorf("instagram user profile fetch failed: %w", err) } if resp.StatusCode() != 200 { return nil, fmt.Errorf("instagram user profile fetch returned HTTP %d", resp.StatusCode()) } return resp.Result().(*IGUserProfile), nil } // === Webhook Verification === // VerifyWebhookToken checks if the provided verify token matches the stored one. func (s *Service) VerifyWebhookToken(ctx context.Context, pageID string, providedToken string) (bool, error) { // Lookup channel by page_id to get stored verify token ch, err := s.repo.GetFacebookByPageID(ctx, pageID) if err != nil { return false, fmt.Errorf("failed to find ChannelFacebook by page_id: %w", err) } return ch.WebhookVerifyToken == providedToken, nil } // === Reauthorization Status === // MarkReauthorizationRequired flags a channel as needing token reauthorization. // Reference: Chatwoot prompt_reauthorization! — sets reauthorization_required=true func (s *Service) MarkReauthorizationRequired(ctx context.Context, channelID uint, channelType string, reason string) error { switch channelType { case "facebook": ch, err := s.repo.GetFacebookByID(ctx, channelID) if err != nil { return err } ch.ReauthorizationRequired = true return s.repo.UpdateFacebook(ctx, ch) case "instagram": ch, err := s.repo.GetInstagramByID(ctx, channelID) if err != nil { return err } ch.ReauthorizationRequired = true return s.repo.UpdateInstagram(ctx, ch) default: return fmt.Errorf("unsupported channel type: %s", channelType) } } // === Helper Functions === // getEnvOrDefault returns the value of an environment variable or a default value. func getEnvOrDefault(key string, defaultValue string) string { if value := os.Getenv(key); value != "" { return value } return defaultValue } // === Inbox Resolution === // GetInboxForFacebookPage resolves an Inbox from a Facebook Page ID. func (s *Service) GetInboxForFacebookPage(ctx context.Context, pageID string) (*model.Inbox, error) { ch, err := s.repo.GetFacebookByPageID(ctx, pageID) if err != nil { return nil, fmt.Errorf("no ChannelFacebook found for page_id=%s: %w", pageID, err) } // In production: query Inbox by channel_id + channel_type // Placeholder: return minimal Inbox with channel reference inbox := &model.Inbox{ AccountID: ch.AccountID, } return inbox, nil } // GetInboxForInstagramAccount resolves an Inbox from an Instagram Account ID. func (s *Service) GetInboxForInstagramAccount(ctx context.Context, igAccountID string) (*model.Inbox, error) { ch, err := s.repo.GetInstagramByAccountID(ctx, igAccountID) if err != nil { return nil, fmt.Errorf("no ChannelInstagram found for ig_account_id=%s: %w", igAccountID, err) } inbox := &model.Inbox{ AccountID: ch.AccountID, } return inbox, nil }