package webhook // TwilioWebhookHandler processes incoming Twilio SMS webhook HTTP requests via Gin. // Reference: Facebook webhook adapter pattern (facebook_webhook.go) // // URL patterns: // /webhooks/sms/:phone_number — Chatwoot-compatible inbound SMS/MMS // /webhooks/twilio/sms/:phone_number — legacy inbound SMS/MMS // /webhooks/twilio/status/:phone_number — delivery status callbacks // // Method: POST (Twilio sends form-encoded data, not JSON) import ( "fmt" "net/http" "github.com/gochat/gochat/internal/channel" twiliochannel "github.com/gochat/gochat/internal/channel/twilio" "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // TwilioWebhookHandler processes Twilio SMS webhook requests via Gin. type TwilioWebhookHandler struct { twilioWebhook *twiliochannel.WebhookHandler db *gorm.DB persister *IncomingPersister } // NewTwilioWebhookHandler creates a Twilio SMS webhook handler for Gin integration. func NewTwilioWebhookHandler(twilioWebhook *twiliochannel.WebhookHandler, db *gorm.DB, dispatcher ...*channel.Dispatcher) *TwilioWebhookHandler { return &TwilioWebhookHandler{ twilioWebhook: twilioWebhook, db: db, persister: NewIncomingPersister(db, dispatcher...), } } // HandleTwilioInboundSMS processes an incoming Twilio SMS webhook Gin request. func (h *TwilioWebhookHandler) HandleTwilioInboundSMS(c *gin.Context) { phoneNumber := c.Param("phone_number") if phoneNumber == "" { applogger.L().Warn("Twilio webhook: missing phone_number in path") c.Data(http.StatusOK, "application/xml", []byte("")) return } // Lookup inbox from database inbox, err := h.lookupInboxByPhoneNumber(phoneNumber) if err != nil { applogger.L().Warnf("Twilio webhook: inbox lookup failed for phone_number %s: %v", phoneNumber, err) c.Data(http.StatusOK, "application/xml", []byte("")) return } incomingMsg, err := h.twilioWebhook.ProcessInboundSMS(c.Request, inbox) if err != nil { applogger.L().Errorf("Twilio webhook: process inbound SMS failed for inbox %d: %v", inbox.ID, err) c.Data(http.StatusOK, "application/xml", []byte("")) return } if incomingMsg != nil { if _, persistErr := h.persister.PersistIncoming(c.Request.Context(), inbox, incomingMsg); persistErr != nil { applogger.L().Errorf("Twilio webhook: persist inbound SMS failed for inbox %d source_id=%s: %v", inbox.ID, incomingMsg.SourceID, persistErr) } } c.Data(http.StatusOK, "application/xml", []byte("")) } // HandleTwilioDeliveryStatus processes a Twilio delivery status callback. func (h *TwilioWebhookHandler) HandleTwilioDeliveryStatus(c *gin.Context) { phoneNumber := c.Param("phone_number") if phoneNumber == "" { applogger.L().Warn("Twilio status webhook: missing phone_number in path") c.Status(http.StatusOK) return } // Lookup inbox from database inbox, err := h.lookupInboxByPhoneNumber(phoneNumber) if err != nil { applogger.L().Warnf("Twilio status webhook: inbox lookup failed for phone_number %s: %v", phoneNumber, err) c.Status(http.StatusOK) return } if err := c.Request.ParseForm(); err != nil { applogger.L().Errorf("Twilio status webhook: parse form failed for inbox %d: %v", inbox.ID, err) c.Status(http.StatusOK) return } messageSID := c.Request.FormValue("MessageSid") messageStatus := c.Request.FormValue("MessageStatus") if mapped, ok := mapTwilioMessageStatus(messageStatus); ok { if err := h.persister.UpdateMessageStatus(c.Request.Context(), inbox, messageSID, mapped, nil); err != nil { applogger.L().Errorf("Twilio status webhook: status persistence failed for inbox %d sid=%s status=%s: %v", inbox.ID, messageSID, messageStatus, err) } } c.Status(http.StatusOK) } func mapTwilioMessageStatus(status string) (model.MessageStatus, bool) { switch status { case "sent", "queued", "accepted", "sending": return model.MessageStatusSent, true case "delivered": return model.MessageStatusDelivered, true case "read": return model.MessageStatusRead, true case "undelivered", "failed": return model.MessageStatusFailed, true default: return "", false } } // lookupInbox fetches an Inbox record from the database. func (h *TwilioWebhookHandler) 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 } // lookupInboxByPhoneNumber fetches an Inbox through the Twilio SMS channel record. // Chatwoot exposes /webhooks/sms/:phone_number and routes by the phone number. func (h *TwilioWebhookHandler) lookupInboxByPhoneNumber(phoneNumber string) (*model.Inbox, error) { if h.db == nil { return nil, fmt.Errorf("twilio webhook database is not configured") } var channel channelmodel.ChannelTwilioSMS if err := h.db.Where("phone_number = ?", phoneNumber).First(&channel).Error; err != nil { return nil, fmt.Errorf("twilio sms channel not found for phone_number=%s: %w", phoneNumber, err) } var inbox model.Inbox if err := h.db.Where("id = ? AND channel_type IN ?", channel.InboxID, []string{"twilio_sms", "sms"}).First(&inbox).Error; err != nil { return nil, fmt.Errorf("twilio inbox not found for channel inbox_id=%d: %w", channel.InboxID, err) } return &inbox, nil }