Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
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"
|
||||
"github.com/gochat/gochat/internal/worker"
|
||||
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...),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *TikTokWebhookHandler) WithWorkerPool(wp *worker.WorkerPool) *TikTokWebhookHandler {
|
||||
if h != nil && h.persister != nil {
|
||||
h.persister.SetWorkerPool(wp)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *TikTokWebhookHandler) WithSearchIndexer(indexer IncomingSearchIndexer) *TikTokWebhookHandler {
|
||||
if h != nil && h.persister != nil {
|
||||
h.persister.SetSearchIndexer(indexer)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user