Files
gochat/internal/channel/webhook.go
T
2026-06-04 15:44:48 +08:00

207 lines
7.7 KiB
Go

package channel
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
applogger "github.com/gochat/gochat/pkg/logger"
)
// WebhookHandler provides unified webhook callback entry point for all channels.
// Reference: P2D §6 — all external channel callbacks route through /webhooks/{channel_type}/{identifier}
// Corresponds to Chatwoot's per-channel WebhooksController (Telegram, Facebook, WhatsApp etc.)
//
// Updated to use MessageBroker + Dispatcher instead of calling ChannelProvider directly.
// The flow is now: webhook → WebhookHandler → MessageBroker.HandleIncoming →
// IncomingMessageProcessor pipeline → Dispatcher.Dispatch (events to listeners)
type WebhookHandler struct {
inboxRepo InboxRepository
broker Broker
dispatcher *Dispatcher
}
// InboxRepository abstracts inbox lookup for webhook processing.
type InboxRepository interface {
FindByChannelTypeAndIdentifier(channelType string, identifier string) (*model.Inbox, error)
FindByID(id uint) (*model.Inbox, error)
}
// NewWebhookHandler creates a new webhook handler with the given dependencies.
// broker routes the message through the appropriate ChannelProvider's pipeline.
// dispatcher broadcasts events (message.created, conversation.created, etc.) to registered listeners.
func NewWebhookHandler(inboxRepo InboxRepository, broker Broker, dispatcher *Dispatcher) *WebhookHandler {
return &WebhookHandler{
inboxRepo: inboxRepo,
broker: broker,
dispatcher: dispatcher,
}
}
// RegisterRoutes registers webhook routes on the given router group.
// Pattern: /webhooks/{channel_type}/{identifier}
// Example: /webhooks/telegram/123456:ABC-DEF (bot_token as identifier)
// Example: /webhooks/facebook/123456789 (page_id as identifier)
// The router group may have middleware applied (e.g. WebhookAuth for token-based verification).
func (h *WebhookHandler) RegisterRoutes(g *gin.RouterGroup) {
g.POST("/:channel_type/:identifier", h.HandleWebhook)
g.GET("/:channel_type/:identifier", h.HandleWebhookVerification) // for FB/WhatsApp verification
}
// HandleWebhook is the unified POST webhook callback handler.
// Flow:
// 1. Parse channel_type + identifier from URL params
// 2. Look up the Inbox via InboxRepository
// 3. Extract raw request body + headers + query params
// 4. Find the ChannelProvider via registry and validate webhook authenticity
// 5. Parse the raw body into an IncomingMessage via the provider
// 6. Route through MessageBroker.HandleIncoming (pipeline: validate → resolve → persist)
// 7. Dispatch events via Dispatcher (webhook, notification, status listeners)
func (h *WebhookHandler) HandleWebhook(c *gin.Context) {
channelType := ChannelType(c.Param("channel_type"))
identifier := c.Param("identifier")
// Check if channel provider is registered
provider, err := Get(channelType)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported channel type"})
return
}
// Find inbox by channel type and identifier
inbox, err := h.inboxRepo.FindByChannelTypeAndIdentifier(string(channelType), identifier)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "inbox not found"})
return
}
// Build WebhookRequest for provider validation
body, _ := c.GetRawData()
headers := make(map[string]string)
for k, v := range c.Request.Header {
if len(v) > 0 {
headers[k] = v[0]
}
}
queryParams := make(map[string]string)
for k, v := range c.Request.URL.Query() {
if len(v) > 0 {
queryParams[k] = v[0]
}
}
webhookReq := &WebhookRequest{
ChannelType: channelType,
Identifier: identifier,
Headers: headers,
Body: body,
QueryParams: queryParams,
Method: c.Request.Method,
}
// Validate webhook request authenticity (signature verification)
if err := provider.ValidateWebhookRequest(c.Request.Context(), inbox, webhookReq); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "webhook validation failed"})
return
}
// Parse the raw webhook body into an IncomingMessage via the provider
incomingMsg, err := provider.ProcessIncoming(c.Request.Context(), inbox, body)
if err != nil {
applogger.L().Errorf("provider.ProcessIncoming failed: channel=%s inbox=%d err=%v", channelType, inbox.ID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse incoming message"})
return
}
// Acknowledge to external channel immediately (before pipeline processing)
// This prevents webhook timeouts for channels that require fast response (e.g. Telegram)
c.JSON(http.StatusOK, gin.H{
"status": "received",
"channel_type": channelType,
"inbox_id": inbox.ID,
"account_id": inbox.AccountID,
})
// Route through MessageBroker → IncomingMessageProcessor pipeline
// Pipeline stages: ValidateConfig → ResolveContact → ResolveConversation → PersistMessage → PublishEvent
if err := h.broker.HandleIncoming(c.Request.Context(), inbox, incomingMsg); err != nil {
applogger.L().Errorf("broker.HandleIncoming failed: channel=%s inbox=%d err=%v", channelType, inbox.ID, err)
// Already responded 200 to the channel — log the error for investigation
return
}
// Dispatch events to all registered listeners
// Listeners: WebhookListener (forward to external integrations),
// NotificationListener (push to agents), ChannelStatusListener (track channel health)
event := &ChannelEvent{
Type: EventMessageCreated,
Channel: channelType,
InboxID: inbox.ID,
AccountID: inbox.AccountID,
Timestamp: incomingMsg.ReceivedAt.Unix(),
Data: map[string]interface{}{
"inbox": inbox,
"incoming_msg": incomingMsg,
"channel_type": string(channelType),
"source_id": incomingMsg.SourceID,
},
}
if err := h.dispatcher.Dispatch(c.Request.Context(), event); err != nil {
applogger.L().Errorf("dispatcher.Dispatch failed: event=%s inbox=%d err=%v", event.Type, inbox.ID, err)
}
}
// HandleWebhookVerification handles GET requests for webhook verification.
// Used by Facebook/WhatsApp webhook verification (challenge-response).
// This method does NOT use the broker/dispatcher — verification is a direct provider interaction.
func (h *WebhookHandler) HandleWebhookVerification(c *gin.Context) {
channelType := ChannelType(c.Param("channel_type"))
identifier := c.Param("identifier")
provider, err := Get(channelType)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported channel type"})
return
}
// Some channels (Facebook, WhatsApp) require GET verification
// The provider handles this via ValidateWebhookRequest for GET method
inbox, err := h.inboxRepo.FindByChannelTypeAndIdentifier(string(channelType), identifier)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "inbox not found"})
return
}
headers := make(map[string]string)
for k, v := range c.Request.Header {
if len(v) > 0 {
headers[k] = v[0]
}
}
queryParams := make(map[string]string)
for k, v := range c.Request.URL.Query() {
if len(v) > 0 {
queryParams[k] = v[0]
}
}
webhookReq := &WebhookRequest{
ChannelType: channelType,
Identifier: identifier,
Headers: headers,
Body: nil, // GET requests have no body
QueryParams: queryParams,
Method: c.Request.Method,
}
// Provider handles verification challenge (e.g. Facebook hub.mode=subscribe, hub.challenge)
if err := provider.ValidateWebhookRequest(c.Request.Context(), inbox, webhookReq); err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "verification failed"})
return
}
// If ValidateWebhookRequest succeeded, the provider may have set a challenge response
// in the webhookReq. Return 200 OK to confirm verification.
c.JSON(http.StatusOK, gin.H{"status": "verified", "channel_type": channelType})
}