package v1 // LINEChannelHandler handles LINE Official Account channel-specific configuration CRUD. // Reference: This is a GoChat addition — Chatwoot does not have LINE channel support. // // LINE Messaging API: https://developers.line.biz/en/docs/messaging-api/ // // gochat maps LINE channel operations to: // - POST /api/v1/accounts/:id/channels/line_channel → create LINE inbox // - GET /api/v1/accounts/:id/channels/line_channel/:line_id → get LINE channel // - PATCH /api/v1/accounts/:id/channels/line_channel/:line_id → update LINE channel // - DELETE /api/v1/accounts/:id/channels/line_channel/:line_id → delete LINE channel // - GET /api/v1/accounts/:id/channels/line_channel → list LINE channels import ( "net/http" "github.com/gin-gonic/gin" pkgvalidator "github.com/gochat/gochat/pkg/validator" linechannel "github.com/gochat/gochat/internal/channel/line" channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" ) // LINEChannelHandler handles LINE Official Account channel management. // Follows FacebookChannelHandler pattern: uses top-level ChannelLINEService for CRUD, // internal LineProvider for webhook setup via OnCreate. type LINEChannelHandler struct { lineChannelSvc *service.ChannelLINEService lineProvider *linechannel.LineProvider inboxSvc *service.InboxService lineRepo *repository.ChannelLINERepo } // NewLINEChannelHandler creates a new LINE channel handler. func NewLINEChannelHandler( lineChannelSvc *service.ChannelLINEService, lineProvider *linechannel.LineProvider, inboxSvc *service.InboxService, lineRepo *repository.ChannelLINERepo, ) *LINEChannelHandler { return &LINEChannelHandler{ lineChannelSvc: lineChannelSvc, lineProvider: lineProvider, inboxSvc: inboxSvc, lineRepo: lineRepo, } } // === Create === // CreateLINEChannelRequest is the DTO for creating a LINE channel. type CreateLINEChannelRequest struct { ChannelID string `json:"channel_id" validate:"required"` Name string `json:"name" validate:"required"` ChannelAccessToken string `json:"channel_access_token" validate:"required"` ChannelSecret string `json:"channel_secret" validate:"required"` } // Create adds a new LINE channel and creates the associated inbox. // POST /api/v1/accounts/:id/channels/line_channel func (h *LINEChannelHandler) Create(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } var req CreateLINEChannelRequest if err := c.ShouldBindJSON(&req); err != nil { applogger.L().Errorf("Failed to bind LINE channel create request: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } if err := pkgvalidator.ValidateStruct(req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } if err := h.inboxSvc.EnsureCanCreateInbox(c.Request.Context(), accountID); err != nil { if renderInboxLimitExceeded(c, err) { return } c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create inbox"}) return } ch := &channelmodel.ChannelLINE{ AccountID: accountID, ChannelID: req.ChannelID, Name: req.Name, } if err := h.lineChannelSvc.Create(c.Request.Context(), ch); err != nil { applogger.L().Errorf("Failed to create LINE channel: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create LINE channel"}) return } // Create inbox for the LINE channel inboxReq := service.CreateInboxRequest{ Name: req.Name, ChannelType: "line", Enabled: true, Channel: lineCreateChannelConfig(req), } inbox, err := h.inboxSvc.Create(c.Request.Context(), accountID, inboxReq) if err != nil { applogger.L().Errorf("Failed to create inbox for LINE channel: %v", err) if delErr := h.lineChannelSvc.Delete(c.Request.Context(), ch.ID); delErr != nil { applogger.L().Warnf("Failed to rollback LINE channel after inbox creation failure: %v", delErr) } if renderInboxLimitExceeded(c, err) { return } c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create inbox"}) return } ch.InboxID = inbox.ID if err := h.lineChannelSvc.Update(c.Request.Context(), ch); err != nil { applogger.L().Errorf("Failed to update LINE channel with inbox_id: %v", err) } inbox, err = h.inboxSvc.BindChannel(c.Request.Context(), accountID, inbox.ID, ch.ID, inboxReq.Channel) if err != nil { applogger.L().Warnf("Failed to bind LINE inbox channel config: %v", err) } c.JSON(http.StatusOK, serializeInbox(inbox)) } // === Get === // Get retrieves a LINE channel by ID. // GET /api/v1/accounts/:id/channels/line_channel/:line_id func (h *LINEChannelHandler) Get(c *gin.Context) { lineID, err := parseUintParam(c, "line_id") if err != nil { applogger.L().Errorf("Invalid line_id: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid line_id"}) return } ch, err := h.lineChannelSvc.GetByID(c.Request.Context(), lineID) if err != nil { applogger.L().Errorf("Failed to get LINE channel: %v", err) c.JSON(http.StatusNotFound, gin.H{"error": "LINE channel not found"}) return } c.JSON(http.StatusOK, h.serializeInboxForLINEChannel(c, ch)) } // === Update === // UpdateLINEChannelRequest is the DTO for updating a LINE channel. type UpdateLINEChannelRequest struct { Name string `json:"name,omitempty"` ChannelAccessToken string `json:"channel_access_token,omitempty"` ChannelSecret string `json:"channel_secret,omitempty"` } // Update modifies an existing LINE channel. // PATCH /api/v1/accounts/:id/channels/line_channel/:line_id func (h *LINEChannelHandler) Update(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } lineID, err := parseUintParam(c, "line_id") if err != nil { applogger.L().Errorf("Invalid line_id: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid line_id"}) return } ch, err := h.lineChannelSvc.GetByID(c.Request.Context(), lineID) if err != nil { applogger.L().Errorf("Failed to get LINE channel: %v", err) c.JSON(http.StatusNotFound, gin.H{"error": "LINE channel not found"}) return } if ch.AccountID != accountID { c.JSON(http.StatusForbidden, gin.H{"error": "channel does not belong to this account"}) return } var req UpdateLINEChannelRequest if err := c.ShouldBindJSON(&req); err != nil { applogger.L().Errorf("Failed to bind LINE channel update request: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } if req.Name != "" { ch.Name = req.Name } if err := h.lineChannelSvc.Update(c.Request.Context(), ch); err != nil { applogger.L().Errorf("Failed to update LINE channel: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to update LINE channel"}) return } if req.Name != "" { if _, inboxErr := h.inboxSvc.Update(c.Request.Context(), accountID, ch.InboxID, service.UpdateInboxRequest{Name: req.Name}); inboxErr != nil { applogger.L().Warnf("Failed to update LINE inbox name: %v", inboxErr) } } inbox, bindErr := h.inboxSvc.BindChannel(c.Request.Context(), accountID, ch.InboxID, ch.ID, lineUpdateChannelConfig(req, ch)) if bindErr != nil { applogger.L().Warnf("Failed to update LINE inbox channel config: %v", bindErr) c.JSON(http.StatusOK, h.serializeInboxForLINEChannel(c, ch)) return } c.JSON(http.StatusOK, serializeInbox(inbox)) } // === Delete === // Delete removes a LINE channel and its associated inbox. // DELETE /api/v1/accounts/:id/channels/line_channel/:line_id func (h *LINEChannelHandler) Delete(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } lineID, err := parseUintParam(c, "line_id") if err != nil { applogger.L().Errorf("Invalid line_id: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid line_id"}) return } ch, err := h.lineChannelSvc.GetByID(c.Request.Context(), lineID) if err != nil { applogger.L().Errorf("Failed to get LINE channel: %v", err) c.JSON(http.StatusNotFound, gin.H{"error": "LINE channel not found"}) return } if ch.AccountID != accountID { c.JSON(http.StatusForbidden, gin.H{"error": "channel does not belong to this account"}) return } // Delete inbox first if ch.InboxID > 0 { if err := h.inboxSvc.Delete(c.Request.Context(), ch.InboxID); err != nil { applogger.L().Errorf("Failed to delete inbox for LINE channel: %v", err) } } if err := h.lineChannelSvc.Delete(c.Request.Context(), lineID); err != nil { applogger.L().Errorf("Failed to delete LINE channel: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to delete LINE channel"}) return } c.Status(http.StatusOK) } // === List === // List retrieves all LINE channels for an account. // GET /api/v1/accounts/:id/channels/line_channel func (h *LINEChannelHandler) List(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } channels, err := h.lineChannelSvc.List(c.Request.Context()) if err != nil { applogger.L().Errorf("Failed to list LINE channels: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list LINE channels"}) return } payload := make([]map[string]any, 0, len(channels)) for i := range channels { if channels[i].AccountID == accountID { payload = append(payload, h.serializeInboxForLINEChannel(c, &channels[i])) } } c.JSON(http.StatusOK, gin.H{"payload": payload}) } func (h *LINEChannelHandler) serializeInboxForLINEChannel(c *gin.Context, ch *channelmodel.ChannelLINE) map[string]any { if ch != nil && ch.InboxID > 0 { if inbox, err := h.inboxSvc.GetByAccountAndID(c.Request.Context(), ch.AccountID, ch.InboxID); err == nil { return serializeInbox(inbox) } } return gin.H{"id": ch.ID, "account_id": ch.AccountID, "inbox_id": ch.InboxID, "channel_id": ch.ChannelID, "name": ch.Name} } func lineChannelConfigFromModel(ch *channelmodel.ChannelLINE) map[string]any { return map[string]any{ "line_channel_id": ch.ChannelID, "channel_id": ch.ChannelID, } } func lineCreateChannelConfig(req CreateLINEChannelRequest) map[string]any { return map[string]any{ "line_channel_id": req.ChannelID, "line_channel_token": req.ChannelAccessToken, "line_channel_secret": req.ChannelSecret, "channel_id": req.ChannelID, "channel_access_token": req.ChannelAccessToken, "channel_secret": req.ChannelSecret, } } func lineUpdateChannelConfig(req UpdateLINEChannelRequest, ch *channelmodel.ChannelLINE) map[string]any { config := lineChannelConfigFromModel(ch) if req.ChannelAccessToken != "" { config["line_channel_token"] = req.ChannelAccessToken config["channel_access_token"] = req.ChannelAccessToken } if req.ChannelSecret != "" { config["line_channel_secret"] = req.ChannelSecret config["channel_secret"] = req.ChannelSecret } return config }