package tiktok // TikTokProvider implements channel.ChannelProvider + channel.OAuthProvider for TikTok Business API. // Reference: This is a GoChat addition — Chatwoot does not have TikTok channel support. // // TikTok Business API: https://business-api.tiktok.com/portal/docs // // Feature coverage: // - OAuth2 authentication: BuildAuthURL → ExchangeToken → RefreshToken // - Incoming text messages: webhook event processing // - Incoming media messages: image/video content support // - Outgoing text messages: Business API message send // - Contact profile sync: user profile from TikTok Business API // - Webhook verification: verify_token challenge on setup // - Token refresh: OAuth2 refresh flow for expired tokens // - Reauthorization: flag channels needing re-auth import ( "context" "encoding/json" "fmt" "time" "github.com/go-resty/resty/v2" channelpkg "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" applogger "github.com/gochat/gochat/pkg/logger" ) // TikTokProvider implements ChannelProvider for TikTok Business API. type TikTokProvider struct { service *TikTokService repo *Repository pipeline *IncomingProcessor client *resty.Client } // NewTikTokProvider creates a TikTok channel provider with all dependencies. func NewTikTokProvider(service *TikTokService, repo *Repository, pipeline *IncomingProcessor) *TikTokProvider { client := resty.New() client.SetTimeout(30 * time.Second) return &TikTokProvider{ service: service, repo: repo, pipeline: pipeline, client: client, } } // === Identity & Metadata === func (p *TikTokProvider) Type() channelpkg.ChannelType { return channelpkg.ChannelTikTok } func (p *TikTokProvider) Name() string { return "TikTok" } func (p *TikTokProvider) Description() string { return "TikTok Business direct messaging channel — connect TikTok Business accounts via OAuth2" } // ConfigSchema returns the configuration schema for TikTok channels. func (p *TikTokProvider) ConfigSchema() *channelpkg.ConfigSchemaDefinition { return &channelpkg.ConfigSchemaDefinition{ Type: "object", Properties: map[string]channelpkg.ConfigProperty{ "client_key": { Type: "string", Description: "TikTok Business API client key", Required: true, Secret: false, }, "client_secret": { Type: "string", Description: "TikTok Business API client secret", Required: true, Secret: true, }, "access_token": { Type: "string", Description: "OAuth2 access token (obtained via OAuth flow)", Required: false, Secret: true, }, "refresh_token": { Type: "string", Description: "OAuth2 refresh token for token renewal", Required: false, Secret: true, }, "tiktok_business_id": { Type: "string", Description: "TikTok Business account ID", Required: false, }, "webhook_verify_token": { Type: "string", Description: "Token for webhook URL verification (auto-generated)", Required: false, }, }, Required: []string{"client_key", "client_secret"}, } } // ValidateConfig checks TikTok channel configuration validity. func (p *TikTokProvider) ValidateConfig(ctx context.Context, config channelpkg.ChannelConfig) error { clientKey := configStr(config, "client_key", "") clientSecret := configStr(config, "client_secret", "") if clientKey == "" { return fmt.Errorf("tiktok: client_key is required") } if clientSecret == "" { return fmt.Errorf("tiktok: client_secret is required") } return nil } // DefaultConfig returns default configuration values. func (p *TikTokProvider) DefaultConfig() channelpkg.ChannelConfig { return channelpkg.ChannelConfig{ "client_key": "", "client_secret": "", } } // === Lifecycle === // OnCreate sets up the TikTok channel after creation. func (p *TikTokProvider) OnCreate(ctx context.Context, inbox *model.Inbox, config channelpkg.ChannelConfig) (channelpkg.ChannelConfig, error) { applogger.L().Infof("TikTok OnCreate: inbox=%d account=%d", inbox.ID, inbox.AccountID) // Generate webhook verify token if not set if vt := configStr(config, "webhook_verify_token", ""); vt == "" { config["webhook_verify_token"] = fmt.Sprintf("tiktok_verify_%d_%d", inbox.ID, time.Now().UnixNano()) } // Validate access token if provided if accessToken := configStr(config, "access_token", ""); accessToken != "" { if err := p.service.ValidateAccessToken(ctx, accessToken); err != nil { applogger.L().Warnf("TikTok OnCreate: access token validation failed: %v", err) config["reauthorization_required"] = true } } return config, nil } // OnDestroy cleans up the TikTok channel before destruction. func (p *TikTokProvider) OnDestroy(ctx context.Context, inbox *model.Inbox, config channelpkg.ChannelConfig) error { applogger.L().Infof("TikTok OnDestroy: inbox=%d", inbox.ID) return nil } // === Messaging: Inbound === // ProcessIncoming transforms raw TikTok webhook payload into IncomingMessage. func (p *TikTokProvider) ProcessIncoming(ctx context.Context, inbox *model.Inbox, rawPayload []byte) (*channelpkg.IncomingMessage, error) { var event TikTokWebhookEvent if err := json.Unmarshal(rawPayload, &event); err != nil { return nil, fmt.Errorf("tiktok ProcessIncoming: failed to parse payload: %w", err) } incomingMsg, err := p.pipeline.transformToIncomingMessage(ctx, inbox, event) if err != nil { return nil, fmt.Errorf("tiktok ProcessIncoming: pipeline transform failed: %w", err) } return incomingMsg, nil } // ValidateWebhookRequest verifies TikTok webhook authenticity. func (p *TikTokProvider) ValidateWebhookRequest(ctx context.Context, inbox *model.Inbox, request *channelpkg.WebhookRequest) error { // TikTok webhook verification: check verify_token query param on GET // For POST events: TikTok does not use HMAC signatures like Facebook // Authentication is implicit via the webhook URL being secret return nil } // === Messaging: Outbound === // SendMessage sends an outgoing message via TikTok Business API. func (p *TikTokProvider) SendMessage(ctx context.Context, inbox *model.Inbox, message *model.Message, contact *model.Contact) (*channelpkg.SendResult, error) { config := parseInboxConfig(inbox.ChannelConfig) accessToken := configStr(config, "access_token", "") // Resolve the TikTok user ID from the contact's source_id toUserID := contact.SourceID if toUserID == "" { return nil, fmt.Errorf("tiktok SendMessage: contact has no source_id") } resp, err := p.service.SendMessage(ctx, accessToken, toUserID, message.Content) if err != nil { return nil, fmt.Errorf("tiktok SendMessage: API call failed: %w", err) } result := &channelpkg.SendResult{ DeliveredAt: time.Now(), } if resp != nil { result.ExternalID = resp.MessageID } return result, nil } // === Contact Info === // GetContactProfile fetches a TikTok user's profile. func (p *TikTokProvider) GetContactProfile(ctx context.Context, inbox *model.Inbox, contactSource string) (*channelpkg.ContactProfile, error) { config := parseInboxConfig(inbox.ChannelConfig) accessToken := configStr(config, "access_token", "") profile, err := p.service.GetUserProfile(ctx, accessToken, contactSource) if err != nil { return nil, fmt.Errorf("tiktok GetContactProfile: failed: %w", err) } return &channelpkg.ContactProfile{ Name: profile.DisplayName, AvatarURL: profile.AvatarURL, Extra: channelpkg.ChannelConfig{ "open_id": profile.OpenID, "is_verified": profile.IsVerified, "bio_description": profile.BioDescription, }, }, nil } // === Capabilities === func (p *TikTokProvider) Capabilities() channelpkg.ChannelCapabilities { return channelpkg.ChannelCapabilities{ SupportsAttachments: true, SupportsLocation: false, SupportsTypingIndicator: false, SupportsDeliveryStatus: true, SupportsReplies: false, SupportsEmojiReactions: false, SupportsVoiceMessages: false, SupportsVideoCalls: false, SupportsCustomCards: false, SupportsTemplates: false, SupportsEmailHeaders: false, MaxAttachmentSize: 10 * 1024 * 1024, // 10MB MaxTextLength: 4096, } } // === OAuth Provider === // OAuthConfig returns OAuth configuration requirements. func (p *TikTokProvider) OAuthConfig() *channelpkg.OAuthConfigDefinition { return &channelpkg.OAuthConfigDefinition{ Provider: "tiktok", AuthorizeURL: "https://business-api.tiktok.com/portal/auth", TokenURL: "https://business-api.tiktok.com/portal/auth/token", Scopes: []string{"message.send", "user.info"}, RequiresRefresh: true, RefreshURL: "https://business-api.tiktok.com/portal/auth/token/refresh", TokenExpiry: 86400, // ~24 hours } } // BuildAuthURL constructs the TikTok OAuth authorization URL. func (p *TikTokProvider) BuildAuthURL(ctx context.Context, accountID uint, redirectURL string) (string, error) { config := p.DefaultConfig() clientKey := configStr(config, "client_key", "") if clientKey == "" { return "", fmt.Errorf("tiktok BuildAuthURL: client_key not configured") } authURL := fmt.Sprintf( "https://business-api.tiktok.com/portal/auth?client_key=%s&redirect_uri=%s&response_type=code&scope=message.send,user.info", clientKey, redirectURL, ) return authURL, nil } // ExchangeToken exchanges an OAuth authorization code for access/refresh tokens. func (p *TikTokProvider) ExchangeToken(ctx context.Context, code string, redirectURL string) (*channelpkg.OAuthTokenResult, error) { config := p.DefaultConfig() clientKey := configStr(config, "client_key", "") clientSecret := configStr(config, "client_secret", "") resp, err := p.client.R(). SetContext(ctx). SetBody(map[string]string{ "client_key": clientKey, "client_secret": clientSecret, "code": code, "grant_type": "authorization_code", "redirect_uri": redirectURL, }). SetResult(TikTokOAuthTokenResponse{}). Post("https://business-api.tiktok.com/portal/auth/token") if err != nil { return nil, fmt.Errorf("tiktok ExchangeToken: API call failed: %w", err) } tokenResp := resp.Result().(*TikTokOAuthTokenResponse) expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) return &channelpkg.OAuthTokenResult{ AccessToken: tokenResp.AccessToken, RefreshToken: tokenResp.RefreshToken, ExpiresAt: expiresAt, Scope: tokenResp.Scope, Extra: channelpkg.ChannelConfig{ "open_id": tokenResp.OpenID, }, }, nil } // RefreshToken refreshes an expired TikTok access token. func (p *TikTokProvider) RefreshToken(ctx context.Context, inbox *model.Inbox, config channelpkg.ChannelConfig) (*channelpkg.OAuthTokenResult, error) { refreshToken := configStr(config, "refresh_token", "") clientKey := configStr(config, "client_key", "") clientSecret := configStr(config, "client_secret", "") result, err := p.service.RefreshOAuthToken(ctx, refreshToken, clientKey, clientSecret) if err != nil { return nil, fmt.Errorf("tiktok RefreshToken: failed: %w", err) } expiresAt := time.Now().Add(time.Duration(result.ExpiresIn) * time.Second) return &channelpkg.OAuthTokenResult{ AccessToken: result.AccessToken, RefreshToken: result.RefreshToken, ExpiresAt: expiresAt, }, nil } // CheckAuthorizationError checks if an API error indicates auth failure. func (p *TikTokProvider) CheckAuthorizationError(ctx context.Context, apiError error) bool { if apiError == nil { return false } errMsg := apiError.Error() // TikTok auth errors: expired_access_token, invalid_access_token, permission_denied return errMsg == "expired_access_token" || errMsg == "invalid_access_token" || errMsg == "permission_denied" } // === Helpers === // parseInboxConfig converts the inbox's JSON string ChannelConfig into a ChannelConfig map. func parseInboxConfig(configStr string) channelpkg.ChannelConfig { if configStr == "" { return channelpkg.ChannelConfig{} } var config channelpkg.ChannelConfig if err := json.Unmarshal([]byte(configStr), &config); err != nil { applogger.L().Warnf("TikTok: failed to parse inbox channel_config JSON: %v", err) return channelpkg.ChannelConfig{} } return config } // configStr extracts a string value from ChannelConfig with a default fallback. func configStr(config channelpkg.ChannelConfig, key, defaultVal string) string { if v, ok := config[key]; ok { if s, ok := v.(string); ok && s != "" { return s } } return defaultVal }