feat(twilio): expose root callback
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gochat/gochat/internal/channel"
|
||||
twiliochannel "github.com/gochat/gochat/internal/channel/twilio"
|
||||
@@ -80,6 +81,43 @@ func (h *TwilioWebhookHandler) HandleTwilioInboundSMS(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "application/xml", []byte("<Response></Response>"))
|
||||
}
|
||||
|
||||
// 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")
|
||||
@@ -144,6 +182,44 @@ func (h *TwilioWebhookHandler) lookupDeliveryStatusInbox(params url.Values) (*mo
|
||||
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":
|
||||
|
||||
@@ -805,6 +805,73 @@ func TestTwilioWebhookPersistsIncomingMessage(t *testing.T) {
|
||||
assertPersistedMessage(t, db, inbox.ID, "SMIN1", "hello sms")
|
||||
}
|
||||
|
||||
func TestTwilioCallbackExactRoutePersistsIncomingMessage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := newWebhookLookupTestDB(t)
|
||||
inbox := seedWebhookInbox(t, db, "twilio_sms")
|
||||
channelRecord := channelmodel.ChannelTwilioSMS{AccountID: 1, InboxID: inbox.ID, AccountSID: "AC123", PhoneNumber: "+15551234567", MessagingServiceSID: "MG123"}
|
||||
if err := db.Create(&channelRecord).Error; err != nil {
|
||||
t.Fatalf("create twilio channel: %v", err)
|
||||
}
|
||||
|
||||
twilioRepo := twiliochannel.NewRepository(db)
|
||||
twilioService := twiliochannel.NewTwilioService(twilioRepo)
|
||||
twilioPipeline := twiliochannel.NewIncomingProcessor(twilioService)
|
||||
twilioWebhook := twiliochannel.NewWebhookHandler(twilioPipeline, twilioService)
|
||||
h := NewTwilioWebhookHandler(twilioWebhook, db)
|
||||
r := gin.New()
|
||||
r.POST("/twilio/callback", h.HandleTwilioCallback)
|
||||
form := url.Values{}
|
||||
form.Set("MessageSid", "SMROOT1")
|
||||
form.Set("AccountSid", "AC123")
|
||||
form.Set("From", "+15550002222")
|
||||
form.Set("To", "15551234567")
|
||||
form.Set("Body", "hello root callback")
|
||||
req := httptest.NewRequest(http.MethodPost, "/twilio/callback", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
assertPersistedMessage(t, db, inbox.ID, "SMROOT1", "hello root callback")
|
||||
}
|
||||
|
||||
func TestTwilioCallbackExactRouteFallsBackToMessagingServiceSid(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := newWebhookLookupTestDB(t)
|
||||
inbox := seedWebhookInbox(t, db, "twilio_sms")
|
||||
channelRecord := channelmodel.ChannelTwilioSMS{AccountID: 1, InboxID: inbox.ID, AccountSID: "AC123", PhoneNumber: "+15551234567", MessagingServiceSID: "MG123"}
|
||||
if err := db.Create(&channelRecord).Error; err != nil {
|
||||
t.Fatalf("create twilio channel: %v", err)
|
||||
}
|
||||
|
||||
twilioRepo := twiliochannel.NewRepository(db)
|
||||
twilioService := twiliochannel.NewTwilioService(twilioRepo)
|
||||
twilioPipeline := twiliochannel.NewIncomingProcessor(twilioService)
|
||||
twilioWebhook := twiliochannel.NewWebhookHandler(twilioPipeline, twilioService)
|
||||
h := NewTwilioWebhookHandler(twilioWebhook, db)
|
||||
r := gin.New()
|
||||
r.POST("/twilio/callback", h.HandleTwilioCallback)
|
||||
form := url.Values{}
|
||||
form.Set("MessageSid", "SMROOTMG1")
|
||||
form.Set("MessagingServiceSid", "MG123")
|
||||
form.Set("From", "+15550002222")
|
||||
form.Set("Body", "hello service callback")
|
||||
req := httptest.NewRequest(http.MethodPost, "/twilio/callback", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
assertPersistedMessage(t, db, inbox.ID, "SMROOTMG1", "hello service callback")
|
||||
}
|
||||
|
||||
func TestTwilioInboundSMSQueuesIncomingMessageWithWorker(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := newWebhookLookupTestDB(t)
|
||||
|
||||
Reference in New Issue
Block a user