package twilio // WebhookHandler processes incoming Twilio SMS webhook HTTP requests. // Reference: gochat's Telegram WebhookHandler pattern // + Twilio SMS webhook documentation // // Twilio webhook flow: // 1. Twilio sends POST to the configured webhook URL // 2. POST body is form-encoded (application/x-www-form-urlencoded) — NOT JSON // 3. Handler parses form data into InboundSMS struct // 4. Handler routes to IncomingProcessor for transformation // // Key difference from other channels: // - Twilio uses form-encoded data (not JSON) // - Twilio expects a TwiML XML response for inbound messages // - Delivery status callbacks come on a separate URL endpoint // - No signature verification required for SMS (unlike LINE's HMAC) // - Optional signature validation available for security import ( "encoding/xml" "fmt" "net/http" channelpkg "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" applogger "github.com/gochat/gochat/pkg/logger" ) // WebhookHandler handles Twilio SMS webhook HTTP requests. type WebhookHandler struct { pipeline *IncomingProcessor service *TwilioService } // NewWebhookHandler creates a Twilio SMS webhook handler. func NewWebhookHandler(pipeline *IncomingProcessor, service *TwilioService) *WebhookHandler { return &WebhookHandler{ pipeline: pipeline, service: service, } } // HandleInboundSMS processes an incoming SMS webhook from Twilio. // Twilio sends form-encoded data with fields like From, To, Body, etc. func (h *WebhookHandler) HandleInboundSMS(w http.ResponseWriter, r *http.Request, inbox *model.Inbox) { incomingMsg, err := h.ProcessInboundSMS(r, inbox) if err != nil { applogger.L().Errorf("Twilio HandleInboundSMS: pipeline process failed: %v", err) writeTwiMLResponse(w, "") return } if incomingMsg != nil { applogger.L().Debugf("Twilio HandleInboundSMS: processed source_id=%s", incomingMsg.SourceID) } writeTwiMLResponse(w, "") } // ProcessInboundSMS parses an inbound Twilio webhook and returns the normalized incoming message. func (h *WebhookHandler) ProcessInboundSMS(r *http.Request, inbox *model.Inbox) (*channelpkg.IncomingMessage, error) { if err := r.ParseForm(); err != nil { return nil, fmt.Errorf("failed to parse form data: %w", err) } // Parse form data into InboundSMS struct sms := InboundSMS{ MessageSID: r.FormValue("MessageSid"), AccountSID: r.FormValue("AccountSid"), From: r.FormValue("From"), To: r.FormValue("To"), Body: r.FormValue("Body"), NumMedia: r.FormValue("NumMedia"), FromCity: r.FormValue("FromCity"), FromState: r.FormValue("FromState"), FromCountry: r.FormValue("FromCountry"), FromZip: r.FormValue("FromZip"), SmsStatus: r.FormValue("SmsStatus"), SmsSid: r.FormValue("SmsSid"), ApiVersion: r.FormValue("ApiVersion"), } // Parse media attachments (if MMS) numMedia := 0 if sms.NumMedia != "" { if n, err := fmt.Sscanf(sms.NumMedia, "%d", &numMedia); err != nil || n != 1 { numMedia = 0 } } for i := 0; i < numMedia; i++ { sms.MediaURL = append(sms.MediaURL, r.FormValue(fmt.Sprintf("MediaUrl%d", i))) sms.MediaContentType = append(sms.MediaContentType, r.FormValue(fmt.Sprintf("MediaContentType%d", i))) } // Process through pipeline ctx := r.Context() incomingMsg, err := h.pipeline.ProcessInboundSMS(ctx, inbox, sms) if err != nil { return nil, err } return incomingMsg, nil } // HandleDeliveryStatus processes a Twilio delivery status callback. func (h *WebhookHandler) HandleDeliveryStatus(w http.ResponseWriter, r *http.Request, inbox *model.Inbox) { if err := r.ParseForm(); err != nil { applogger.L().Errorf("Twilio HandleDeliveryStatus: failed to parse form: %v", err) http.Error(w, "Failed to parse form data", http.StatusBadRequest) return } status := DeliveryStatus{ MessageSID: r.FormValue("MessageSid"), MessageStatus: r.FormValue("MessageStatus"), ErrorCode: r.FormValue("ErrorCode"), AccountSID: r.FormValue("AccountSid"), From: r.FormValue("From"), To: r.FormValue("To"), } ctx := r.Context() incomingMsg, err := h.pipeline.ProcessDeliveryStatus(ctx, inbox, status) if err != nil { applogger.L().Errorf("Twilio HandleDeliveryStatus: pipeline process failed: %v", err) w.WriteHeader(http.StatusOK) return } if incomingMsg == nil { w.WriteHeader(http.StatusOK) return } applogger.L().Debugf("Twilio HandleDeliveryStatus: sid=%s status=%s", status.MessageSID, status.MessageStatus) w.WriteHeader(http.StatusOK) } // === TwiML Response === // TwiMLResponse represents a Twilio Markup Language response. type TwiMLResponse struct { XMLName xml.Name `xml:"Response"` Message []TwiMLMsg `xml:"Message,omitempty"` } // TwiMLMsg represents a TwiML element. type TwiMLMsg struct { Text string `xml:",chardata"` } // writeTwiMLResponse writes a TwiML XML response to the HTTP response writer. func writeTwiMLResponse(w http.ResponseWriter, messageText string) { w.Header().Set("Content-Type", "application/xml") resp := TwiMLResponse{} if messageText != "" { resp.Message = []TwiMLMsg{{Text: messageText}} } output, err := xml.Marshal(resp) if err != nil { w.WriteHeader(http.StatusOK) w.Write([]byte("")) return } w.WriteHeader(http.StatusOK) w.Write(output) }