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,176 @@
|
||||
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
|
||||
}
|
||||
|
||||
func (h *LineWebhookHandler) WithSearchIndexer(indexer IncomingSearchIndexer) *LineWebhookHandler {
|
||||
if h != nil && h.persister != nil {
|
||||
h.persister.SetSearchIndexer(indexer)
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user