package tiktok // TikTokService provides high-level operations for the TikTok channel. // Reference: TikTok Business API — https://business-api.tiktok.com/portal/docs // // This service coordinates: // - ChannelTikTok model CRUD (via Repository) // - TikTok Business API calls (OAuth, messaging, user profile) // - Incoming/outgoing message processing // // Design: Service pattern follows gochat's channel service architecture // (same as TelegramService, WhatsAppService). import ( "context" "fmt" "time" channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/go-resty/resty/v2" "gorm.io/gorm" applogger "github.com/gochat/gochat/pkg/logger" ) // TikTokService handles TikTok channel business logic. type TikTokService struct { client *resty.Client db *gorm.DB repo *Repository } // NewTikTokService creates a new TikTok service with GORM DB and repository. func NewTikTokService(repo *Repository) *TikTokService { client := resty.New() client.SetTimeout(30 * time.Second) client.SetRetryCount(3) client.SetRetryWaitTime(1 * time.Second) return &TikTokService{ client: client, db: repo.db, repo: repo, } } // === CRUD Operations === // CreateChannel creates a new TikTok channel. // Flow: validate access_token → create ChannelTikTok → setup webhook → create Inbox func (s *TikTokService) CreateChannel(ctx context.Context, accountID uint, accessToken, refreshToken, businessID string, expiresAt time.Time) (*channelmodel.ChannelTikTok, error) { // Validate access token by calling TikTok user info API if err := s.ValidateAccessToken(ctx, accessToken); err != nil { return nil, fmt.Errorf("invalid TikTok access token: %w", err) } channel := &channelmodel.ChannelTikTok{ AccountID: accountID, TikTokBusinessID: businessID, AccessToken: accessToken, RefreshToken: refreshToken, TokenExpiresAt: expiresAt, WebhookVerifyToken: fmt.Sprintf("tiktok_verify_%d_%d", accountID, time.Now().UnixNano()), } if err := s.repo.Create(ctx, channel); err != nil { return nil, fmt.Errorf("failed to create TikTok channel: %w", err) } applogger.L().Infof("TikTok channel created: id=%d, account=%d, business_id=%s", channel.ID, accountID, businessID) return channel, nil } // UpdateChannel updates an existing TikTok channel. func (s *TikTokService) UpdateChannel(ctx context.Context, channelID uint, updates map[string]interface{}) error { if err := s.repo.UpdateFields(ctx, channelID, updates); err != nil { return fmt.Errorf("failed to update TikTok channel %d: %w", channelID, err) } return nil } // DeleteChannel removes a TikTok channel. func (s *TikTokService) DeleteChannel(ctx context.Context, channelID uint) error { if err := s.repo.Delete(ctx, channelID); err != nil { return fmt.Errorf("failed to delete TikTok channel %d: %w", channelID, err) } return nil } // === API Operations === // SendMessage sends a message to a TikTok user via the Business API. // Reference: TikTok Business API POST /api/v2/message/send func (s *TikTokService) SendMessage(ctx context.Context, accessToken, toUserID, content string) (*TikTokSendMessageResponse, error) { resp, err := s.client.R(). SetContext(ctx). SetAuthToken(accessToken). SetBody(TikTokSendMessageRequest{ ToUserID: toUserID, ContentType: "text", Content: content, }). SetResult(TikTokSendMessageResponse{}). Post("https://business-api.tiktok.com/portal/api/v2/message/send") if err != nil { return nil, fmt.Errorf("TikTok send message API call failed: %w", err) } result := resp.Result().(*TikTokSendMessageResponse) if result.Code != 0 { return nil, fmt.Errorf("TikTok send message API error: code=%d, message=%s", result.Code, result.Message) } return result, nil } // GetUserProfile fetches a TikTok user's profile information. func (s *TikTokService) GetUserProfile(ctx context.Context, accessToken, userID string) (*TikTokUserProfile, error) { resp, err := s.client.R(). SetContext(ctx). SetAuthToken(accessToken). SetQueryParam("open_id", userID). SetResult(TikTokUserProfile{}). Get("https://business-api.tiktok.com/portal/api/v2/user/info") if err != nil { return nil, fmt.Errorf("TikTok get user profile API call failed: %w", err) } return resp.Result().(*TikTokUserProfile), nil } // ValidateAccessToken checks if an access token is valid by calling a lightweight API. func (s *TikTokService) ValidateAccessToken(ctx context.Context, accessToken string) error { resp, err := s.client.R(). SetContext(ctx). SetAuthToken(accessToken). Get("https://business-api.tiktok.com/portal/api/v2/business/info") if err != nil { return fmt.Errorf("TikTok token validation API call failed: %w", err) } if resp.StatusCode() >= 400 { return fmt.Errorf("TikTok token validation failed: HTTP %d", resp.StatusCode()) } return nil } // RefreshOAuthToken refreshes an expired TikTok access token. // Reference: TikTok Business API OAuth2 refresh token endpoint func (s *TikTokService) RefreshOAuthToken(ctx context.Context, refreshToken, clientKey, clientSecret string) (*TikTokOAuthTokenResponse, error) { resp, err := s.client.R(). SetContext(ctx). SetBody(map[string]string{ "client_key": clientKey, "client_secret": clientSecret, "grant_type": "refresh_token", "refresh_token": refreshToken, }). SetResult(TikTokOAuthTokenResponse{}). Post("https://business-api.tiktok.com/portal/auth/token/refresh") if err != nil { return nil, fmt.Errorf("TikTok OAuth token refresh failed: %w", err) } result := resp.Result().(*TikTokOAuthTokenResponse) if result.AccessToken == "" { return nil, fmt.Errorf("TikTok OAuth token refresh returned empty access_token") } return result, nil } // === Helper Methods === // GetChannelByID retrieves a TikTok channel by its database ID. func (s *TikTokService) GetChannelByID(ctx context.Context, channelID uint) (*channelmodel.ChannelTikTok, error) { return s.repo.GetByID(ctx, channelID) } // GetChannelByInboxID retrieves a TikTok channel by the associated inbox ID. func (s *TikTokService) GetChannelByInboxID(ctx context.Context, inboxID uint) (*channelmodel.ChannelTikTok, error) { return s.repo.GetByInboxID(ctx, inboxID) } // MarkReauthorizationRequired flags a channel as needing re-auth. func (s *TikTokService) MarkReauthorizationRequired(ctx context.Context, channelID uint) error { return s.UpdateChannel(ctx, channelID, map[string]interface{}{ "reauthorization_required": true, }) }