189 lines
7.0 KiB
Go
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
|
|
}
|