package v1 // TwilioChannelHandler handles Twilio SMS channel-specific configuration CRUD. // Reference: Chatwoot app/controllers/api/v1/accounts/channels/twilio_controller.rb // // Twilio SMS API: https://www.twilio.com/docs/sms // // gochat maps Twilio SMS channel operations to: // - POST /api/v1/accounts/:id/channels/twilio_channel → create Twilio SMS inbox // - GET /api/v1/accounts/:id/channels/twilio_channel/:tw_id → get Twilio SMS channel // - PATCH /api/v1/accounts/:id/channels/twilio_channel/:tw_id → update Twilio SMS channel // - DELETE /api/v1/accounts/:id/channels/twilio_channel/:tw_id → delete Twilio SMS channel // - GET /api/v1/accounts/:id/channels/twilio_channel → list Twilio SMS channels import ( "encoding/json" "io" "net/http" "strings" "github.com/gin-gonic/gin" 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" ) // TwilioChannelHandler handles Twilio SMS channel management. // Follows LINEChannelHandler pattern: uses top-level ChannelTwilioSMSService for CRUD, // generic InboxService for inbox lifecycle. type TwilioChannelHandler struct { twChannelSvc *service.ChannelTwilioSMSService inboxSvc *service.InboxService twRepo *repository.ChannelTwilioSMSRepo } // NewTwilioChannelHandler creates a new Twilio SMS channel handler. func NewTwilioChannelHandler( twChannelSvc *service.ChannelTwilioSMSService, inboxSvc *service.InboxService, twRepo *repository.ChannelTwilioSMSRepo, ) *TwilioChannelHandler { return &TwilioChannelHandler{ twChannelSvc: twChannelSvc, inboxSvc: inboxSvc, twRepo: twRepo, } } // CreateTwilioSMSChannelRequest is the DTO for creating a Twilio SMS channel. type CreateTwilioSMSChannelRequest struct { AccountSID string `json:"account_sid" validate:"required"` APIKeySID string `json:"api_key_sid"` AuthToken string `json:"auth_token" validate:"required"` PhoneNumber string `json:"phone_number" validate:"required"` MessagingServiceSID string `json:"messaging_service_sid"` Medium string `json:"medium"` Name string `json:"name"` InboxName string `json:"inbox_name"` } // Create adds a new Twilio SMS channel and creates the associated inbox. // POST /api/v1/accounts/:id/channels/twilio_channel func (h *TwilioChannelHandler) Create(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } req, bindErr := bindTwilioCreateRequest(c) if bindErr != nil { applogger.L().Errorf("Failed to bind Twilio SMS channel create request: %v", bindErr) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } if req.AccountSID == "" || req.AuthToken == "" || (req.PhoneNumber == "" && req.MessagingServiceSID == "") { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } ctx := c.Request.Context() medium := firstNonEmptyString(req.Medium, "sms") phoneNumber := twilioPhoneNumberForMedium(req.PhoneNumber, medium) // Create the channel record first (without InboxID) ch := &channelmodel.ChannelTwilioSMS{ AccountSID: req.AccountSID, PhoneNumber: phoneNumber, MessagingServiceSID: req.MessagingServiceSID, AccountID: accountID, } if err := h.twChannelSvc.Create(ctx, ch); err != nil { applogger.L().Errorf("Failed to create Twilio SMS channel: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create Twilio SMS channel"}) return } // Create inbox for the Twilio SMS channel inboxName := req.InboxName if inboxName == "" { inboxName = req.Name } if inboxName == "" { inboxName = firstNonEmptyString(phoneNumber, req.MessagingServiceSID) } inboxReq := service.CreateInboxRequest{ Name: inboxName, ChannelType: "twilio_sms", Enabled: true, Channel: map[string]any{ "account_sid": req.AccountSID, "auth_token": req.AuthToken, "api_key_sid": req.APIKeySID, "phone_number": phoneNumber, "messaging_service_sid": req.MessagingServiceSID, "medium": medium, }, } inbox, err := h.inboxSvc.Create(ctx, accountID, inboxReq) if err != nil { applogger.L().Errorf("Failed to create inbox for Twilio SMS channel: %v", err) // Rollback channel creation if delErr := h.twChannelSvc.Delete(ctx, ch.ID); delErr != nil { applogger.L().Warnf("Failed to rollback Twilio SMS channel after inbox creation failure: %v", delErr) } c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create inbox"}) return } // Update channel with InboxID ch.InboxID = inbox.ID if err := h.twChannelSvc.Update(ctx, ch); err != nil { applogger.L().Errorf("Failed to update Twilio SMS channel with inbox_id: %v", err) } inbox, err = h.inboxSvc.BindChannel(ctx, accountID, inbox.ID, ch.ID, inboxReq.Channel) if err != nil { applogger.L().Warnf("Failed to bind Twilio inbox channel config: %v", err) } c.JSON(http.StatusOK, serializeInbox(inbox)) } func twilioPhoneNumberForMedium(phoneNumber, medium string) string { if medium == "whatsapp" && phoneNumber != "" && !strings.HasPrefix(phoneNumber, "whatsapp:") { return "whatsapp:" + phoneNumber } return phoneNumber } func firstNonEmptyString(values ...string) string { for _, value := range values { if value != "" { return value } } return "" } func bindTwilioCreateRequest(c *gin.Context) (CreateTwilioSMSChannelRequest, error) { body, err := io.ReadAll(c.Request.Body) if err != nil { return CreateTwilioSMSChannelRequest{}, err } var req CreateTwilioSMSChannelRequest if err := json.Unmarshal(body, &req); err != nil { return CreateTwilioSMSChannelRequest{}, err } if req.AccountSID != "" || req.AuthToken != "" { return req, nil } var wrapped struct { TwilioChannel CreateTwilioSMSChannelRequest `json:"twilio_channel"` } if err := json.Unmarshal(body, &wrapped); err != nil { return CreateTwilioSMSChannelRequest{}, err } return wrapped.TwilioChannel, nil } // Get retrieves a Twilio SMS channel by ID. // GET /api/v1/accounts/:id/channels/twilio_channel/:tw_id func (h *TwilioChannelHandler) Get(c *gin.Context) { twID, err := parseUintParam(c, "tw_id") if err != nil { applogger.L().Errorf("Invalid tw_id: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tw_id"}) return } ch, err := h.twChannelSvc.GetByID(c.Request.Context(), twID) if err != nil { applogger.L().Errorf("Failed to get Twilio SMS channel: %v", err) c.JSON(http.StatusNotFound, gin.H{"error": "Twilio SMS channel not found"}) return } c.JSON(http.StatusOK, h.serializeInboxForTwilioChannel(c, ch)) } // UpdateTwilioSMSChannelRequest is the DTO for updating a Twilio SMS channel. type UpdateTwilioSMSChannelRequest struct { PhoneNumber *string `json:"phone_number"` MessagingServiceSID *string `json:"messaging_service_sid"` InboxName *string `json:"inbox_name"` } // Update updates a Twilio SMS channel configuration. // PATCH /api/v1/accounts/:id/channels/twilio_channel/:tw_id func (h *TwilioChannelHandler) Update(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } twID, err := parseUintParam(c, "tw_id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tw_id"}) return } ctx := c.Request.Context() ch, err := h.twChannelSvc.GetByID(ctx, twID) if err != nil { applogger.L().Errorf("Failed to get Twilio SMS channel for update: %v", err) c.JSON(http.StatusNotFound, gin.H{"error": "Twilio SMS channel not found"}) return } // Verify account ownership if ch.AccountID != accountID { c.JSON(http.StatusForbidden, gin.H{"error": "Twilio SMS channel does not belong to this account"}) return } var req UpdateTwilioSMSChannelRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } if req.PhoneNumber != nil { ch.PhoneNumber = *req.PhoneNumber } if req.MessagingServiceSID != nil { ch.MessagingServiceSID = *req.MessagingServiceSID } if err := h.twChannelSvc.Update(ctx, ch); err != nil { applogger.L().Errorf("Failed to update Twilio SMS channel: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to update Twilio SMS channel"}) return } // Update inbox name if provided if req.InboxName != nil && ch.InboxID > 0 { updateReq := service.UpdateInboxRequest{ Name: *req.InboxName, } if _, inboxErr := h.inboxSvc.Update(ctx, accountID, ch.InboxID, updateReq); inboxErr != nil { applogger.L().Warnf("Failed to update Twilio SMS inbox name: %v", inboxErr) } } inbox, bindErr := h.inboxSvc.BindChannel(ctx, accountID, ch.InboxID, ch.ID, twilioChannelConfigFromModel(ch)) if bindErr != nil { applogger.L().Warnf("Failed to update Twilio inbox channel config: %v", bindErr) c.JSON(http.StatusOK, h.serializeInboxForTwilioChannel(c, ch)) return } c.JSON(http.StatusOK, serializeInbox(inbox)) } // Delete removes a Twilio SMS channel and its associated inbox. // DELETE /api/v1/accounts/:id/channels/twilio_channel/:tw_id func (h *TwilioChannelHandler) Delete(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } twID, err := parseUintParam(c, "tw_id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tw_id"}) return } ctx := c.Request.Context() ch, err := h.twChannelSvc.GetByID(ctx, twID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Twilio SMS channel not found"}) return } // Verify account ownership if ch.AccountID != accountID { c.JSON(http.StatusForbidden, gin.H{"error": "Twilio SMS channel does not belong to this account"}) return } // Delete the channel record if err := h.twChannelSvc.Delete(ctx, twID); err != nil { applogger.L().Errorf("Failed to delete Twilio SMS channel: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to delete Twilio SMS channel"}) return } // Delete the associated inbox if ch.InboxID > 0 { if delErr := h.inboxSvc.DeleteByAccount(ctx, accountID, ch.InboxID); delErr != nil { applogger.L().Warnf("Failed to delete inbox for Twilio SMS channel: %v", delErr) } } c.Status(http.StatusOK) } // List lists all Twilio SMS channels for an account. // GET /api/v1/accounts/:id/channels/twilio_channel func (h *TwilioChannelHandler) List(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } ctx := c.Request.Context() channels, err := h.twChannelSvc.ListByAccount(ctx, accountID) if err != nil { applogger.L().Errorf("Failed to list Twilio SMS channels: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list Twilio SMS channels"}) return } payload := make([]map[string]any, 0, len(channels)) for i := range channels { payload = append(payload, h.serializeInboxForTwilioChannel(c, &channels[i])) } c.JSON(http.StatusOK, gin.H{"payload": payload}) } func (h *TwilioChannelHandler) serializeInboxForTwilioChannel(c *gin.Context, ch *channelmodel.ChannelTwilioSMS) 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, "account_sid": ch.AccountSID, "phone_number": ch.PhoneNumber, "messaging_service_sid": ch.MessagingServiceSID} } func twilioChannelConfigFromModel(ch *channelmodel.ChannelTwilioSMS) map[string]any { medium := "sms" if strings.HasPrefix(ch.PhoneNumber, "whatsapp:") { medium = "whatsapp" } return map[string]any{ "account_sid": ch.AccountSID, "phone_number": ch.PhoneNumber, "messaging_service_sid": ch.MessagingServiceSID, "medium": medium, } }