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" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "strconv" "strings" "time" "github.com/gochat/gochat/internal/channel" 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, dispatcher ...*channel.Dispatcher) *TikTokWebhookHandler { return &TikTokWebhookHandler{ tiktokWebhook: tiktokWebhook, pipeline: pipeline, db: db, persister: NewIncomingPersister(db, dispatcher...), } } // 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)) if err := verifyTikTokSignature(c.GetHeader("Tiktok-Signature"), body, time.Now()); err != nil { applogger.L().Warnf("TikTok webhook: signature verification failed: %v", err) c.JSON(http.StatusUnauthorized, gin.H{"error": "signature verification failed"}) return } 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 verifyTikTokSignature(signatureHeader string, body []byte, now time.Time) error { clientSecret := os.Getenv("TIKTOK_APP_SECRET") timestamp, signature := extractTikTokSignatureParts(signatureHeader) if clientSecret == "" || timestamp == 0 || signature == "" { return fmt.Errorf("missing tiktok signature credentials") } payload := fmt.Sprintf("%d.%s", timestamp, string(body)) mac := hmac.New(sha256.New, []byte(clientSecret)) mac.Write([]byte(payload)) expected := hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(expected), []byte(signature)) { return fmt.Errorf("invalid tiktok signature") } if now.Unix()-timestamp > 5 { return fmt.Errorf("stale tiktok signature") } return nil } func extractTikTokSignatureParts(signatureHeader string) (int64, string) { if signatureHeader == "" { return 0, "" } var timestamp int64 var signature string for _, part := range strings.Split(signatureHeader, ",") { keyValue := strings.SplitN(strings.TrimSpace(part), "=", 2) if len(keyValue) != 2 { continue } switch keyValue[0] { case "t": parsed, err := strconv.ParseInt(keyValue[1], 10, 64) if err == nil { timestamp = parsed } case "s": signature = keyValue[1] } } return timestamp, signature } 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 }