519 lines
18 KiB
Go
519 lines
18 KiB
Go
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
|
|
}
|