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

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
}