feat(twilio): expose root callback

This commit is contained in:
2026-06-06 09:34:06 +08:00
parent dc8fc2ad41
commit 6b9049d284
8 changed files with 167 additions and 8 deletions
@@ -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)