BUG-F (P1): Fake webhook lookupInbox used JSONB @> operator on a TEXT
column, causing all fake webhooks to return "ignored". Cast
channel_config::jsonb before the @> operator.
BUG-G (P3): Vue Router history.state warning on Activity page. Four
call sites replaced history.state with null/{}, destroying Vue Router's
internal navigation state. Now all replaceState calls preserve
window.history.state.
BUG-H (P3): Inbox list showed stale data because cache_keys endpoint
returned hardcoded "0000000000" for inbox/label/team, so the frontend
IndexedDB cache never invalidated. Cache keys are now derived from
actual DB state (row count + MAX(updated_at)), with defensive fallback
for missing tables.
BUG-I (P3): All worker goroutines shared the same Redis consumer name,
so XINFO CONSUMERS showed 1 consumer instead of N. Each goroutine now
generates a unique consumer ID (workerID-index).
202 lines
6.7 KiB
Go
202 lines
6.7 KiB
Go
package webhook
|
|
|
|
// FakeWebhookHandler processes incoming FakeMessagePlatform webhook HTTP
|
|
// requests via Gin.
|
|
//
|
|
// URL pattern: /webhooks/fake/:identifier
|
|
// - GET: webhook verification (echo challenge)
|
|
// - POST: incoming message/event processing
|
|
//
|
|
// Unlike Telegram/LINE which have a dedicated channel model table, the fake
|
|
// channel stores all config (identifier, webhook_url, token) directly in the
|
|
// Inbox.ChannelConfig JSON column. lookupInbox therefore queries by
|
|
// channel_type='fake' and filters the identifier in Go (plan §7.2 错误 2).
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/gochat/gochat/internal/channel"
|
|
channelprovider "github.com/gochat/gochat/internal/channel/provider"
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/worker"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
)
|
|
|
|
// FakeWebhookHandler processes FakeMessagePlatform webhook requests via Gin.
|
|
type FakeWebhookHandler struct {
|
|
provider *channelprovider.FakeProvider
|
|
db *gorm.DB
|
|
persister *IncomingPersister
|
|
}
|
|
|
|
// NewFakeWebhookHandler creates a Fake webhook handler for Gin integration.
|
|
func NewFakeWebhookHandler(
|
|
provider *channelprovider.FakeProvider,
|
|
db *gorm.DB,
|
|
dispatcher ...*channel.Dispatcher,
|
|
) *FakeWebhookHandler {
|
|
return &FakeWebhookHandler{
|
|
provider: provider,
|
|
db: db,
|
|
persister: NewIncomingPersister(db, dispatcher...),
|
|
}
|
|
}
|
|
|
|
func (h *FakeWebhookHandler) WithWorkerPool(wp *worker.WorkerPool) *FakeWebhookHandler {
|
|
if h != nil && h.persister != nil {
|
|
h.persister.SetWorkerPool(wp)
|
|
}
|
|
return h
|
|
}
|
|
|
|
func (h *FakeWebhookHandler) WithSearchIndexer(indexer IncomingSearchIndexer) *FakeWebhookHandler {
|
|
if h != nil && h.persister != nil {
|
|
h.persister.SetSearchIndexer(indexer)
|
|
}
|
|
return h
|
|
}
|
|
|
|
// HandleFakeWebhookVerification echoes a challenge token for webhook URL
|
|
// verification. FakeMessagePlatform sends GET /webhooks/fake/:identifier with
|
|
// a "hub.challenge" query param (mirroring the Facebook/WhatsApp pattern).
|
|
func (h *FakeWebhookHandler) HandleFakeWebhookVerification(c *gin.Context) {
|
|
challenge := c.Query("hub.challenge")
|
|
if challenge == "" {
|
|
challenge = c.Query("challenge")
|
|
}
|
|
if challenge == "" {
|
|
c.JSON(http.StatusOK, gin.H{"status": "verified"})
|
|
return
|
|
}
|
|
c.String(http.StatusOK, challenge)
|
|
}
|
|
|
|
// HandleFakeWebhook processes an incoming FakeMessagePlatform webhook POST.
|
|
func (h *FakeWebhookHandler) HandleFakeWebhook(c *gin.Context) {
|
|
identifier := c.Param("identifier")
|
|
if identifier == "" {
|
|
applogger.L().Warn("Fake webhook: missing identifier in path")
|
|
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
|
return
|
|
}
|
|
|
|
// Read request body
|
|
body, err := io.ReadAll(c.Request.Body)
|
|
if err != nil {
|
|
applogger.L().Errorf("Fake webhook: failed to read body: %v", err)
|
|
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
|
return
|
|
}
|
|
defer c.Request.Body.Close()
|
|
|
|
applogger.L().Infof("Fake webhook received for identifier=%s", identifier)
|
|
|
|
// Look up Inbox by channel_type='fake' and config identifier
|
|
inbox, err := h.lookupInbox(identifier)
|
|
if err != nil {
|
|
applogger.L().Errorf("Fake webhook: inbox lookup failed for identifier=%s: %v", identifier, err)
|
|
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
|
return
|
|
}
|
|
|
|
// Validate webhook request (X-Fake-Token header)
|
|
headers := make(map[string]string)
|
|
for k, v := range c.Request.Header {
|
|
if len(v) > 0 {
|
|
headers[k] = v[0]
|
|
}
|
|
}
|
|
wr := &channel.WebhookRequest{
|
|
ChannelType: channel.ChannelFake,
|
|
Identifier: identifier,
|
|
Headers: headers,
|
|
Body: body,
|
|
Method: c.Request.Method,
|
|
}
|
|
if err := h.provider.ValidateWebhookRequest(c.Request.Context(), inbox, wr); err != nil {
|
|
applogger.L().Warnf("Fake webhook: validation failed for inbox=%d: %v", inbox.ID, err)
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
|
return
|
|
}
|
|
|
|
// Process the incoming message via the provider
|
|
incomingMsg, err := h.provider.ProcessIncoming(c.Request.Context(), inbox, body)
|
|
if err != nil {
|
|
applogger.L().Errorf("Fake webhook: message processing failed for inbox=%d: %v", inbox.ID, err)
|
|
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
|
return
|
|
}
|
|
if incomingMsg == nil {
|
|
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
|
return
|
|
}
|
|
|
|
// Persist the incoming message
|
|
if _, persistErr := h.persister.PersistIncoming(c.Request.Context(), inbox, incomingMsg); persistErr != nil {
|
|
applogger.L().Errorf("Fake webhook: persist message failed for inbox=%d source_id=%s: %v",
|
|
inbox.ID, incomingMsg.SourceID, persistErr)
|
|
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
|
return
|
|
}
|
|
|
|
applogger.L().Infof("Fake webhook: message persisted (inbox_id=%d, source_id=%s, sender=%s)",
|
|
inbox.ID, incomingMsg.SourceID, incomingMsg.SenderID)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
}
|
|
|
|
// lookupInbox finds the Inbox for a fake channel by config identifier.
|
|
// On PostgreSQL, uses jsonb containment for a DB-side indexed lookup.
|
|
// On SQLite (test mode), falls back to loading all fake inboxes and
|
|
// filtering in Go — acceptable for low-volume test traffic.
|
|
func (h *FakeWebhookHandler) lookupInbox(identifier string) (*model.Inbox, error) {
|
|
if h.db == nil {
|
|
return nil, fmt.Errorf("fake webhook database is not configured")
|
|
}
|
|
|
|
// PostgreSQL: use jsonb @> for a server-side, indexable query.
|
|
if h.db.Dialector.Name() == "postgres" {
|
|
var inbox model.Inbox
|
|
// channel_config is TEXT, so cast to jsonb before using the @> operator.
|
|
// channel_config::jsonb @> '{"identifier":"<id>"}'
|
|
query := fmt.Sprintf(`channel_type = 'fake' AND channel_config::jsonb @> '{"identifier":"%s"}'`, identifier)
|
|
if err := h.db.Where(query).First(&inbox).Error; err != nil {
|
|
return nil, fmt.Errorf("fake inbox not found for identifier=%s: %w", identifier, err)
|
|
}
|
|
return &inbox, nil
|
|
}
|
|
|
|
// SQLite fallback: load all fake inboxes and filter in Go.
|
|
var inboxes []model.Inbox
|
|
if err := h.db.Where("channel_type = ?", "fake").Find(&inboxes).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to query fake inboxes: %w", err)
|
|
}
|
|
|
|
for i := range inboxes {
|
|
cfg := parseFakeInboxConfig(&inboxes[i])
|
|
if id, _ := cfg["identifier"].(string); id == identifier {
|
|
return &inboxes[i], nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("fake inbox not found for identifier=%s", identifier)
|
|
}
|
|
|
|
// parseFakeInboxConfig decodes the Inbox.ChannelConfig JSON into a map.
|
|
func parseFakeInboxConfig(inbox *model.Inbox) map[string]interface{} {
|
|
if inbox == nil || inbox.ChannelConfig == "" {
|
|
return map[string]interface{}{}
|
|
}
|
|
var cfg map[string]interface{}
|
|
if err := json.Unmarshal([]byte(inbox.ChannelConfig), &cfg); err != nil {
|
|
applogger.L().Warnf("Fake: failed to parse ChannelConfig for inbox %d: %v", inbox.ID, err)
|
|
return map[string]interface{}{}
|
|
}
|
|
return cfg
|
|
}
|