412 lines
14 KiB
Go
412 lines
14 KiB
Go
package v1
|
|
|
|
// TikTokChannelHandler handles TikTok Business channel-specific configuration CRUD.
|
|
// Reference: This is a GoChat addition — Chatwoot does not have TikTok channel support.
|
|
//
|
|
// TikTok Business API: https://business-api.tiktok.com/portal/docs
|
|
//
|
|
// gochat maps TikTok channel operations to:
|
|
// - POST /api/v1/accounts/:id/channels/tiktok_channel → create TikTok inbox
|
|
// - GET /api/v1/accounts/:id/channels/tiktok_channel/:tt_id → get TikTok channel
|
|
// - PATCH /api/v1/accounts/:id/channels/tiktok_channel/:tt_id → update TikTok channel
|
|
// - DELETE /api/v1/accounts/:id/channels/tiktok_channel/:tt_id → delete TikTok channel
|
|
// - GET /api/v1/accounts/:id/channels/tiktok_channel → list TikTok channels
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
tiktokchannel "github.com/gochat/gochat/internal/channel/tiktok"
|
|
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
"github.com/gochat/gochat/internal/service"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
"github.com/gochat/gochat/pkg/response"
|
|
)
|
|
|
|
// TikTokChannelHandler handles TikTok Business channel management.
|
|
// Follows FacebookChannelHandler pattern: uses top-level ChannelTikTokService for CRUD,
|
|
// internal TikTokProvider for webhook setup via OnCreate.
|
|
type TikTokChannelHandler struct {
|
|
ttChannelSvc *service.ChannelTikTokService
|
|
ttProvider *tiktokchannel.TikTokProvider
|
|
inboxSvc *service.InboxService
|
|
ttRepo *repository.ChannelTikTokRepo
|
|
}
|
|
|
|
// NewTikTokChannelHandler creates a new TikTok channel handler.
|
|
func NewTikTokChannelHandler(
|
|
ttChannelSvc *service.ChannelTikTokService,
|
|
ttProvider *tiktokchannel.TikTokProvider,
|
|
inboxSvc *service.InboxService,
|
|
ttRepo *repository.ChannelTikTokRepo,
|
|
) *TikTokChannelHandler {
|
|
return &TikTokChannelHandler{
|
|
ttChannelSvc: ttChannelSvc,
|
|
ttProvider: ttProvider,
|
|
inboxSvc: inboxSvc,
|
|
ttRepo: ttRepo,
|
|
}
|
|
}
|
|
|
|
// === TikTok Channel CRUD ===
|
|
|
|
// ChatwootAuthorization creates a TikTok OAuth authorization URL.
|
|
// POST /api/v1/accounts/:account_id/tiktok/authorization
|
|
func (h *TikTokChannelHandler) ChatwootAuthorization(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
|
|
var req TikTokAuthorizationRequest
|
|
_ = c.ShouldBindJSON(&req)
|
|
|
|
appID := req.AppID
|
|
if strings.TrimSpace(appID) == "" {
|
|
appID = envOrDefaultV1("TIKTOK_APP_ID", "")
|
|
}
|
|
appSecret := req.AppSecret
|
|
if strings.TrimSpace(appSecret) == "" {
|
|
appSecret = envOrDefaultV1("TIKTOK_APP_SECRET", "")
|
|
}
|
|
|
|
redirectURL, err := buildTikTokChatwootAuthorizationURL(accountID, authorizationReturnTo(c, req.ReturnTo), appID, appSecret)
|
|
if err != nil {
|
|
applogger.L().Errorf("Failed to build TikTok authorization URL: %v", err)
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"success": false, "error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "url": redirectURL})
|
|
}
|
|
|
|
// CreateTikTokChannelRequest is the DTO for creating a TikTok Business channel inbox.
|
|
type CreateTikTokChannelRequest struct {
|
|
Name string `json:"name" validate:"required,min=2"`
|
|
TikTokBusinessID string `json:"tiktok_business_id" validate:"required"`
|
|
AccessToken string `json:"access_token" validate:"required"`
|
|
InboxName string `json:"inbox_name,omitempty"`
|
|
EnableAutoAssignment bool `json:"enable_auto_assignment,omitempty"`
|
|
}
|
|
|
|
// CreateTikTokChannel creates a new TikTok Business channel and its associated inbox.
|
|
// POST /api/v1/accounts/:id/channels/tiktok_channel
|
|
func (h *TikTokChannelHandler) CreateTikTokChannel(c *gin.Context) {
|
|
accountIDStr := c.Param("id")
|
|
accountID, err := strconv.ParseUint(accountIDStr, 10, 64)
|
|
if err != nil {
|
|
applogger.L().Errorf("Invalid account_id: %v", err)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
|
|
return
|
|
}
|
|
|
|
var req CreateTikTokChannelRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
applogger.L().Errorf("Failed to bind create tiktok channel request: %v", err)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
if err := h.inboxSvc.EnsureCanCreateInbox(ctx, uint(accountID)); err != nil {
|
|
if renderInboxLimitExceeded(c, err) {
|
|
return
|
|
}
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create TikTok inbox", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
// 1. Create ChannelTikTok record
|
|
channelRecord := &channelmodel.ChannelTikTok{
|
|
AccountID: uint(accountID),
|
|
TikTokBusinessID: req.TikTokBusinessID,
|
|
AccessToken: req.AccessToken,
|
|
}
|
|
|
|
if err := h.ttChannelSvc.Create(ctx, channelRecord); err != nil {
|
|
applogger.L().Errorf("Failed to create TikTok channel: %v", err)
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create TikTok channel", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
// 2. Create Inbox record with channel_type=tiktok
|
|
inboxName := req.InboxName
|
|
if inboxName == "" {
|
|
inboxName = req.Name
|
|
}
|
|
|
|
createdInbox, err := h.inboxSvc.Create(ctx, uint(accountID), service.CreateInboxRequest{
|
|
Name: inboxName,
|
|
ChannelType: "tiktok",
|
|
Channel: map[string]any{
|
|
"tiktok_business_id": req.TikTokBusinessID,
|
|
"access_token": req.AccessToken,
|
|
},
|
|
EnableAutoAssignment: req.EnableAutoAssignment,
|
|
})
|
|
if err != nil {
|
|
applogger.L().Errorf("Failed to create TikTok inbox: %v", err)
|
|
if delErr := h.ttChannelSvc.Delete(ctx, channelRecord.ID); delErr != nil {
|
|
applogger.L().Warnf("Failed to rollback TikTok channel after inbox creation failure: %v", delErr)
|
|
}
|
|
if renderInboxLimitExceeded(c, err) {
|
|
return
|
|
}
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create TikTok inbox", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
// 3. Link ChannelTikTok.InboxID = createdInbox.ID
|
|
channelRecord.InboxID = createdInbox.ID
|
|
if err := h.ttChannelSvc.Update(ctx, channelRecord); err != nil {
|
|
applogger.L().Errorf("Failed to update TikTok channel inbox_id: %v", err)
|
|
// Non-critical: inbox was created, channel link can be repaired
|
|
}
|
|
|
|
// 4. Call ttProvider.OnCreate for webhook setup
|
|
// Note: OnCreate expects a ChannelConfig map; we build one from request data.
|
|
ttConfig := map[string]interface{}{
|
|
"tiktok_business_id": req.TikTokBusinessID,
|
|
"access_token": req.AccessToken,
|
|
}
|
|
if h.ttProvider == nil {
|
|
applogger.L().Warnf("TikTok OnCreate webhook setup skipped: provider is not configured")
|
|
} else if updatedConfig, onCreateErr := h.ttProvider.OnCreate(ctx, createdInbox, ttConfig); onCreateErr != nil {
|
|
applogger.L().Warnf("TikTok OnCreate webhook setup failed: %v", onCreateErr)
|
|
// Non-critical: channel + inbox created, webhook can be set up later
|
|
} else {
|
|
// Persist updated config (e.g. webhook_verify_token) if provider returned one
|
|
if verifyToken, ok := updatedConfig["webhook_verify_token"]; ok {
|
|
channelRecord.WebhookVerifyToken = verifyToken.(string)
|
|
if err := h.ttChannelSvc.Update(ctx, channelRecord); err != nil {
|
|
applogger.L().Warnf("Failed to persist TikTok webhook_verify_token: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"channel": gin.H{
|
|
"id": channelRecord.ID,
|
|
"account_id": channelRecord.AccountID,
|
|
"inbox_id": channelRecord.InboxID,
|
|
"tiktok_business_id": channelRecord.TikTokBusinessID,
|
|
},
|
|
"inbox": gin.H{
|
|
"id": createdInbox.ID,
|
|
"name": createdInbox.Name,
|
|
"channel_type": createdInbox.ChannelType,
|
|
"channel_id": createdInbox.ChannelID,
|
|
"enabled": createdInbox.Enabled,
|
|
"enable_auto_assignment": createdInbox.EnableAutoAssignment,
|
|
},
|
|
})
|
|
}
|
|
|
|
// GetTikTokChannel retrieves a TikTok channel by ID.
|
|
// GET /api/v1/accounts/:id/channels/tiktok_channel/:tt_id
|
|
func (h *TikTokChannelHandler) GetTikTokChannel(c *gin.Context) {
|
|
accountIDStr := c.Param("id")
|
|
accountID, err := strconv.ParseUint(accountIDStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
|
|
return
|
|
}
|
|
|
|
ttIDStr := c.Param("tt_id")
|
|
ttID, err := strconv.ParseUint(ttIDStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tt_id"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
ch, err := h.ttChannelSvc.GetByID(ctx, uint(ttID))
|
|
if err != nil {
|
|
applogger.L().Errorf("Failed to get TikTok channel: %v", err)
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "TikTok channel not found"})
|
|
return
|
|
}
|
|
|
|
// Verify account ownership
|
|
if ch.AccountID != uint(accountID) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "TikTok channel does not belong to this account"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"id": ch.ID,
|
|
"account_id": ch.AccountID,
|
|
"inbox_id": ch.InboxID,
|
|
"tiktok_business_id": ch.TikTokBusinessID,
|
|
"reauthorization_required": ch.ReauthorizationRequired,
|
|
})
|
|
}
|
|
|
|
// UpdateTikTokChannelRequest is the DTO for updating a TikTok channel.
|
|
type UpdateTikTokChannelRequest struct {
|
|
Name string `json:"name,omitempty"`
|
|
AccessToken string `json:"access_token,omitempty"`
|
|
InboxName string `json:"inbox_name,omitempty"`
|
|
EnableAutoAssignment *bool `json:"enable_auto_assignment,omitempty"`
|
|
}
|
|
|
|
// UpdateTikTokChannel updates a TikTok channel configuration.
|
|
// PATCH /api/v1/accounts/:id/channels/tiktok_channel/:tt_id
|
|
func (h *TikTokChannelHandler) UpdateTikTokChannel(c *gin.Context) {
|
|
accountIDStr := c.Param("id")
|
|
accountID, err := strconv.ParseUint(accountIDStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
|
|
return
|
|
}
|
|
|
|
ttIDStr := c.Param("tt_id")
|
|
ttID, err := strconv.ParseUint(ttIDStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tt_id"})
|
|
return
|
|
}
|
|
|
|
var req UpdateTikTokChannelRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
applogger.L().Errorf("Failed to bind update tiktok channel request: %v", err)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
ch, err := h.ttChannelSvc.GetByID(ctx, uint(ttID))
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "TikTok channel not found"})
|
|
return
|
|
}
|
|
|
|
// Verify account ownership
|
|
if ch.AccountID != uint(accountID) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "TikTok channel does not belong to this account"})
|
|
return
|
|
}
|
|
|
|
// Apply updates
|
|
if req.AccessToken != "" {
|
|
ch.AccessToken = req.AccessToken
|
|
}
|
|
|
|
if err := h.ttChannelSvc.Update(ctx, ch); err != nil {
|
|
applogger.L().Errorf("Failed to update TikTok channel: %v", err)
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to update TikTok channel"})
|
|
return
|
|
}
|
|
|
|
// Update inbox name if provided
|
|
if req.InboxName != "" && ch.InboxID > 0 {
|
|
updateReq := service.UpdateInboxRequest{
|
|
Name: req.InboxName,
|
|
}
|
|
if req.EnableAutoAssignment != nil {
|
|
updateReq.EnableAutoAssignment = req.EnableAutoAssignment
|
|
}
|
|
if _, inboxErr := h.inboxSvc.Update(ctx, uint(accountID), ch.InboxID, updateReq); inboxErr != nil {
|
|
applogger.L().Warnf("Failed to update TikTok inbox: %v", inboxErr)
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"id": ch.ID,
|
|
"account_id": ch.AccountID,
|
|
"inbox_id": ch.InboxID,
|
|
"tiktok_business_id": ch.TikTokBusinessID,
|
|
"message": "TikTok channel updated successfully",
|
|
})
|
|
}
|
|
|
|
// DeleteTikTokChannel removes a TikTok channel and its associated inbox.
|
|
// DELETE /api/v1/accounts/:id/channels/tiktok_channel/:tt_id
|
|
func (h *TikTokChannelHandler) DeleteTikTokChannel(c *gin.Context) {
|
|
accountIDStr := c.Param("id")
|
|
accountID, err := strconv.ParseUint(accountIDStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
|
|
return
|
|
}
|
|
|
|
ttIDStr := c.Param("tt_id")
|
|
ttID, err := strconv.ParseUint(ttIDStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tt_id"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
ch, err := h.ttChannelSvc.GetByID(ctx, uint(ttID))
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "TikTok channel not found"})
|
|
return
|
|
}
|
|
|
|
// Verify account ownership
|
|
if ch.AccountID != uint(accountID) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "TikTok channel does not belong to this account"})
|
|
return
|
|
}
|
|
|
|
// Delete the channel record
|
|
if err := h.ttChannelSvc.Delete(ctx, uint(ttID)); err != nil {
|
|
applogger.L().Errorf("Failed to delete TikTok channel: %v", err)
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to delete TikTok channel"})
|
|
return
|
|
}
|
|
|
|
// Delete the associated inbox (if exists)
|
|
if ch.InboxID > 0 {
|
|
if delErr := h.inboxSvc.DeleteByAccount(ctx, uint(accountID), ch.InboxID); delErr != nil {
|
|
applogger.L().Warnf("Failed to delete inbox for TikTok channel: %v", delErr)
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "TikTok channel deleted successfully"})
|
|
}
|
|
|
|
// ListTikTokChannels retrieves all TikTok channels for an account.
|
|
// GET /api/v1/accounts/:id/channels/tiktok_channel
|
|
func (h *TikTokChannelHandler) ListTikTokChannels(c *gin.Context) {
|
|
accountIDStr := c.Param("id")
|
|
accountID, err := strconv.ParseUint(accountIDStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
channels, err := h.ttChannelSvc.ListByAccount(ctx, uint(accountID))
|
|
if err != nil {
|
|
applogger.L().Errorf("Failed to list TikTok channels: %v", err)
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list TikTok channels"})
|
|
return
|
|
}
|
|
|
|
result := make([]gin.H, 0, len(channels))
|
|
for _, ch := range channels {
|
|
result = append(result, gin.H{
|
|
"id": ch.ID,
|
|
"account_id": ch.AccountID,
|
|
"inbox_id": ch.InboxID,
|
|
"tiktok_business_id": ch.TikTokBusinessID,
|
|
"reauthorization_required": ch.ReauthorizationRequired,
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"channels": result,
|
|
"count": len(result),
|
|
})
|
|
}
|