Files
gochat/internal/handler/webhook/twilio_webhook.go
T

189 lines
7.0 KiB
Go

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"
"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("<Response></Response>"))
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("<Response></Response>"))
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("<Response></Response>"))
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("<Response></Response>"))
}
// 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 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
}