package webhook // LineWebhookHandler processes incoming LINE webhook HTTP requests via Gin. // Reference: Facebook webhook adapter pattern (facebook_webhook.go) // // URL pattern: /webhooks/line/:line_channel_id // Method: POST (LINE Messaging API sends events as JSON) import ( "encoding/json" "fmt" "io" "net/http" "github.com/gochat/gochat/internal/channel" linechannel "github.com/gochat/gochat/internal/channel/line" "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" ) // LineWebhookHandler processes LINE webhook requests via Gin. type LineWebhookHandler struct { lineWebhook *linechannel.WebhookHandler pipeline *linechannel.IncomingProcessor service *linechannel.LineService db *gorm.DB persister *IncomingPersister } // NewLineWebhookHandler creates a LINE webhook handler for Gin integration. func NewLineWebhookHandler(lineWebhook *linechannel.WebhookHandler, pipeline *linechannel.IncomingProcessor, service *linechannel.LineService, db *gorm.DB, dispatcher ...*channel.Dispatcher) *LineWebhookHandler { return &LineWebhookHandler{ lineWebhook: lineWebhook, pipeline: pipeline, service: service, db: db, persister: NewIncomingPersister(db, dispatcher...), } } func (h *LineWebhookHandler) WithWorkerPool(wp *worker.WorkerPool) *LineWebhookHandler { if h != nil && h.persister != nil { h.persister.SetWorkerPool(wp) } return h } // HandleLineWebhook processes an incoming LINE webhook Gin request. func (h *LineWebhookHandler) HandleLineWebhook(c *gin.Context) { lineChannelID := c.Param("line_channel_id") if lineChannelID == "" { lineChannelID = c.Param("channel_id") } if lineChannelID == "" { applogger.L().Warn("LINE webhook: missing line_channel_id in path") c.JSON(http.StatusOK, gin.H{"status": "ignored"}) return } // Lookup inbox from database inbox, err := h.lookupInboxByLineChannelID(lineChannelID) if err != nil { applogger.L().Warnf("LINE webhook: inbox lookup failed for channel_id %s: %v", lineChannelID, err) 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("LINE webhook: failed to read body for inbox %d: %v", inbox.ID, err) c.JSON(http.StatusOK, gin.H{"status": "ignored"}) return } defer c.Request.Body.Close() // Verify webhook signature config := h.parseChannelConfig(inbox) channelSecret := "" if v, ok := config["channel_secret"].(string); ok { channelSecret = v } signature := c.GetHeader("X-Line-Signature") if channelSecret != "" { if signature == "" || h.service == nil || !h.service.VerifySignature(channelSecret, string(body), signature) { applogger.L().Warnf("LINE webhook: invalid signature for inbox=%d", inbox.ID) c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid signature"}) return } } // Parse webhook event var webhookEvent linechannel.WebhookEvent if err := json.Unmarshal(body, &webhookEvent); err != nil { applogger.L().Errorf("LINE webhook: failed to parse JSON for inbox %d: %v", inbox.ID, err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}) return } // Process each event via the pipeline for _, event := range webhookEvent.Events { incomingMsg, err := h.pipeline.ProcessEvent(c.Request.Context(), inbox, event) if err != nil { applogger.L().Errorf("LINE webhook: process event failed for inbox %d: %v", inbox.ID, err) continue } if incomingMsg != nil { if _, persistErr := h.persister.PersistIncoming(c.Request.Context(), inbox, incomingMsg); persistErr != nil { applogger.L().Errorf("LINE webhook: persist event failed for inbox %d source_id=%s: %v", inbox.ID, incomingMsg.SourceID, persistErr) continue } applogger.L().Debugf("LINE webhook: persisted event type=%s sender=%s", event.Type, incomingMsg.SenderID) } } c.JSON(http.StatusOK, gin.H{"status": "processed"}) } // HandleLineVerification responds to LINE webhook URL verification. func (h *LineWebhookHandler) HandleLineVerification(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "verified"}) } // lookupInbox fetches an Inbox record from the database. func (h *LineWebhookHandler) 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 } // lookupInboxByLineChannelID fetches an Inbox through the LINE channel record. // Chatwoot exposes /webhooks/line/:line_channel_id and resolves the channel from that URL segment. func (h *LineWebhookHandler) lookupInboxByLineChannelID(lineChannelID string) (*model.Inbox, error) { if h.db == nil { return nil, fmt.Errorf("line webhook database is not configured") } var channel channelmodel.ChannelLINE if err := h.db.Where("channel_id = ?", lineChannelID).First(&channel).Error; err != nil { return nil, fmt.Errorf("line channel not found for channel_id=%s: %w", lineChannelID, err) } var inbox model.Inbox if err := h.db.Where("id = ? AND channel_type = ?", channel.InboxID, "line").First(&inbox).Error; err != nil { return nil, fmt.Errorf("line inbox not found for channel inbox_id=%d: %w", channel.InboxID, err) } return &inbox, nil } // parseChannelConfig parses the JSON-encoded ChannelConfig string into a map. func (h *LineWebhookHandler) 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("LINE: Failed to parse ChannelConfig for inbox %d: %v", inbox.ID, err) return map[string]interface{}{} } return config }