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" "net/url" "strings" "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" "github.com/gochat/gochat/internal/worker" 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 } func (h *TwilioWebhookHandler) WithWorkerPool(wp *worker.WorkerPool) *TwilioWebhookHandler { if h != nil && h.persister != nil { h.persister.SetWorkerPool(wp) } return h } // 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("")) } // HandleTwilioCallback processes Chatwoot's root Twilio callback route. // Reference: Chatwoot Twilio::CallbackController#create enqueues the event and // always acknowledges with 204 No Content. func (h *TwilioWebhookHandler) HandleTwilioCallback(c *gin.Context) { if err := c.Request.ParseForm(); err != nil { applogger.L().Errorf("Twilio callback: parse form failed: %v", err) c.Status(http.StatusNoContent) return } if h.twilioWebhook == nil { applogger.L().Warn("Twilio callback: Twilio webhook pipeline is not configured") c.Status(http.StatusNoContent) return } inbox, err := h.lookupCallbackInbox(c.Request.Form) if err != nil { applogger.L().Warnf("Twilio callback: inbox lookup failed: %v", err) c.Status(http.StatusNoContent) return } incomingMsg, err := h.twilioWebhook.ProcessInboundSMS(c.Request, inbox) if err != nil { applogger.L().Errorf("Twilio callback: process inbound SMS failed for inbox %d: %v", inbox.ID, err) c.Status(http.StatusNoContent) return } if incomingMsg != nil { if _, persistErr := h.persister.PersistIncoming(c.Request.Context(), inbox, incomingMsg); persistErr != nil { applogger.L().Errorf("Twilio callback: persist inbound SMS failed for inbox %d source_id=%s: %v", inbox.ID, incomingMsg.SourceID, persistErr) } } c.Status(http.StatusNoContent) } // HandleTwilioDeliveryStatus processes a Twilio delivery status callback. func (h *TwilioWebhookHandler) HandleTwilioDeliveryStatus(c *gin.Context) { phoneNumber := c.Param("phone_number") if err := c.Request.ParseForm(); err != nil { applogger.L().Errorf("Twilio status webhook: parse form failed: %v", err) c.Status(http.StatusNoContent) return } var inbox *model.Inbox var err error if phoneNumber != "" { inbox, err = h.lookupInboxByPhoneNumber(phoneNumber) } else { inbox, err = h.lookupDeliveryStatusInbox(c.Request.Form) } if err != nil { applogger.L().Warnf("Twilio status webhook: inbox lookup failed: %v", err) c.Status(http.StatusNoContent) return } messageSID := c.Request.FormValue("MessageSid") messageStatus := c.Request.FormValue("MessageStatus") if mapped, ok := mapTwilioMessageStatus(messageStatus); ok { if err := h.persister.UpdateMessageStatusWithError(c.Request.Context(), inbox, messageSID, mapped, nil, twilioExternalError(c.Request.FormValue("ErrorCode"), c.Request.FormValue("ErrorMessage"), messageStatus)); 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.StatusNoContent) } func twilioExternalError(errorCode, errorMessage, status string) string { if errorCode == "" || (status != "failed" && status != "undelivered") { return "" } if errorMessage != "" { return fmt.Sprintf("%s - %s", errorCode, errorMessage) } return fmt.Sprintf("Twilio delivery failed with error code %s", errorCode) } func (h *TwilioWebhookHandler) lookupDeliveryStatusInbox(params url.Values) (*model.Inbox, error) { if h.db == nil { return nil, fmt.Errorf("twilio webhook database is not configured") } var twilioChannel channelmodel.ChannelTwilioSMS query := h.db if sid := params.Get("MessagingServiceSid"); sid != "" { query = query.Where(&channelmodel.ChannelTwilioSMS{MessagingServiceSID: sid}) } else if accountSID, from := params.Get("AccountSid"), params.Get("From"); accountSID != "" && from != "" { query = query.Where(&channelmodel.ChannelTwilioSMS{AccountSID: accountSID, PhoneNumber: from}) } else { return nil, fmt.Errorf("delivery status missing MessagingServiceSid or AccountSid/From") } if err := query.First(&twilioChannel).Error; err != nil { return nil, err } var inbox model.Inbox if err := h.db.Where("id = ? AND channel_type IN ?", twilioChannel.InboxID, []string{"twilio_sms", "sms"}).First(&inbox).Error; err != nil { return nil, err } return &inbox, nil } func (h *TwilioWebhookHandler) lookupCallbackInbox(params url.Values) (*model.Inbox, error) { if h.db == nil { return nil, fmt.Errorf("twilio webhook database is not configured") } var twilioChannel channelmodel.ChannelTwilioSMS query := h.db if phone := normalizeTwilioPhone(params.Get("To")); phone != "" { if accountSID := params.Get("AccountSid"); accountSID != "" { query = query.Where(&channelmodel.ChannelTwilioSMS{AccountSID: accountSID, PhoneNumber: phone}) } else { query = query.Where(&channelmodel.ChannelTwilioSMS{PhoneNumber: phone}) } } else if sid := params.Get("MessagingServiceSid"); sid != "" { query = query.Where(&channelmodel.ChannelTwilioSMS{MessagingServiceSID: sid}) } else { return nil, fmt.Errorf("twilio callback missing To or MessagingServiceSid") } if err := query.First(&twilioChannel).Error; err != nil { return nil, err } var inbox model.Inbox if err := h.db.Where("id = ? AND channel_type IN ?", twilioChannel.InboxID, []string{"twilio_sms", "sms"}).First(&inbox).Error; err != nil { return nil, err } return &inbox, nil } func normalizeTwilioPhone(phone string) string { phone = strings.TrimSpace(phone) if phone == "" { return "" } if strings.HasPrefix(phone, "+") { return phone } return "+" + phone } 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 }