Files
gochat/internal/handler/webhook/tiktok_webhook.go
T

197 lines
6.6 KiB
Go

package webhook
// TikTok Gin webhook adapter — bridges HTTP requests from the Gin router
// to the TikTok channel package's WebhookHandler and IncomingProcessor.
// Reference: Facebook webhook adapter pattern (facebook_webhook.go)
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
tiktokchannel "github.com/gochat/gochat/internal/channel/tiktok"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
applogger "github.com/gochat/gochat/pkg/logger"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// TikTokWebhookHandler adapts TikTok webhook handling to Gin HTTP requests.
type TikTokWebhookHandler struct {
tiktokWebhook *tiktokchannel.WebhookHandler
pipeline *tiktokchannel.IncomingProcessor
db *gorm.DB
persister *IncomingPersister
}
// NewTikTokWebhookHandler creates a Gin-compatible TikTok webhook handler.
func NewTikTokWebhookHandler(tiktokWebhook *tiktokchannel.WebhookHandler, pipeline *tiktokchannel.IncomingProcessor, db *gorm.DB) *TikTokWebhookHandler {
return &TikTokWebhookHandler{
tiktokWebhook: tiktokWebhook,
pipeline: pipeline,
db: db,
persister: NewIncomingPersister(db),
}
}
// HandleTikTokWebhook processes incoming TikTok webhook HTTP requests.
func (h *TikTokWebhookHandler) HandleTikTokWebhook(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
applogger.L().Errorf("TikTok webhook: failed to read body: %v", err)
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
return
}
c.Request.Body.Close()
c.Request.Body = io.NopCloser(bytes.NewReader(body))
businessID := c.Param("business_id")
if businessID == "" {
businessID = extractTikTokBusinessID(body)
}
if businessID == "" {
applogger.L().Warn("TikTok webhook: missing business_id in path and payload")
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
return
}
// Lookup inbox from database
inbox, err := h.lookupInboxByBusinessID(businessID)
if err != nil {
applogger.L().Warnf("TikTok webhook: inbox lookup failed for business_id %s: %v", businessID, err)
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
return
}
// Parse the webhook event using the channel-level handler
event, err := h.tiktokWebhook.HandleWebhookRequest(c.Request)
if err != nil {
applogger.L().Errorf("TikTok webhook: parse request failed for inbox %d: %v", inbox.ID, err)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
// Process the event via the pipeline
incomingMsg, err := h.pipeline.ProcessUpdate(c.Request.Context(), inbox, *event)
if err != nil {
applogger.L().Errorf("TikTok webhook: process event failed for inbox %d: %v", inbox.ID, err)
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
return
}
if incomingMsg != nil {
if _, persistErr := h.persister.PersistIncoming(c.Request.Context(), inbox, incomingMsg); persistErr != nil {
applogger.L().Errorf("TikTok webhook: persist event failed for inbox %d source_id=%s: %v", inbox.ID, incomingMsg.SourceID, persistErr)
}
} else if event.Type == "message.read" {
if messageID := tiktokDataString(event.Data, "message_id"); messageID != "" {
if err := h.persister.UpdateMessageStatus(c.Request.Context(), inbox, messageID, model.MessageStatusRead, nil); err != nil {
applogger.L().Errorf("TikTok webhook: read status persistence failed for inbox %d source_id=%s: %v", inbox.ID, messageID, err)
}
}
}
c.JSON(http.StatusOK, gin.H{"status": "processed"})
}
// HandleTikTokVerification handles TikTok webhook verification (challenge-response).
func (h *TikTokWebhookHandler) HandleTikTokVerification(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to read body"})
return
}
defer c.Request.Body.Close()
// Parse verification challenge
var verifyReq map[string]interface{}
if err := json.Unmarshal(body, &verifyReq); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
return
}
challenge, _ := verifyReq["challenge"].(string)
applogger.L().Infof("TikTok webhook verification: business_id=%s challenge=%s", c.Param("business_id"), challenge)
c.JSON(http.StatusOK, gin.H{"challenge": challenge})
}
// lookupInbox fetches an Inbox record from the database.
func (h *TikTokWebhookHandler) lookupInbox(inboxID uint) (*model.Inbox, error) {
var inbox model.Inbox
if err := h.db.Where("id = ?", inboxID).First(&inbox).Error; err != nil {
return nil, err
}
return &inbox, nil
}
// lookupInboxByBusinessID fetches an Inbox through the TikTok channel record.
func (h *TikTokWebhookHandler) lookupInboxByBusinessID(businessID string) (*model.Inbox, error) {
if h.db == nil {
return nil, fmt.Errorf("tiktok webhook database is not configured")
}
var channel channelmodel.ChannelTikTok
if err := h.db.Where("tiktok_business_id = ?", businessID).First(&channel).Error; err != nil {
return nil, fmt.Errorf("tiktok channel not found for business_id=%s: %w", businessID, err)
}
var inbox model.Inbox
if err := h.db.Where("id = ? AND channel_type = ?", channel.InboxID, "tiktok").First(&inbox).Error; err != nil {
return nil, fmt.Errorf("tiktok inbox not found for channel inbox_id=%d: %w", channel.InboxID, err)
}
return &inbox, nil
}
func extractTikTokBusinessID(body []byte) string {
var payload struct {
BizID string `json:"biz_id"`
BusinessID string `json:"business_id"`
TikTokBusinessID string `json:"tiktok_business_id"`
Data struct {
BizID string `json:"biz_id"`
BusinessID string `json:"business_id"`
TikTokBusinessID string `json:"tiktok_business_id"`
} `json:"data"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return ""
}
for _, candidate := range []string{
payload.BizID,
payload.BusinessID,
payload.TikTokBusinessID,
payload.Data.BizID,
payload.Data.BusinessID,
payload.Data.TikTokBusinessID,
} {
if candidate != "" {
return candidate
}
}
return ""
}
func tiktokDataString(data map[string]interface{}, key string) string {
if value, ok := data[key].(string); ok {
return value
}
return ""
}
// parseChannelConfig parses the JSON-encoded ChannelConfig string into a map.
func (h *TikTokWebhookHandler) parseChannelConfig(inbox *model.Inbox) map[string]interface{} {
if inbox.ChannelConfig == "" {
return map[string]interface{}{}
}
var config map[string]interface{}
if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err != nil {
applogger.L().Warnf("TikTok: Failed to parse ChannelConfig for inbox %d: %v", inbox.ID, err)
return map[string]interface{}{}
}
return config
}