366 lines
12 KiB
Go
366 lines
12 KiB
Go
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
|
|
} |