381 lines
12 KiB
Go
381 lines
12 KiB
Go
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()
|
|
if err := h.inboxSvc.EnsureCanCreateInbox(ctx, accountID); err != nil {
|
|
if renderInboxLimitExceeded(c, err) {
|
|
return
|
|
}
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create inbox"})
|
|
return
|
|
}
|
|
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)
|
|
}
|
|
if renderInboxLimitExceeded(c, err) {
|
|
return
|
|
}
|
|
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,
|
|
}
|
|
}
|