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

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
}