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":""}' 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 }