Files
gochat/internal/channel/twilio/webhook_handler.go
T

171 lines
5.3 KiB
Go

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 <Message> 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("<Response></Response>"))
return
}
w.WriteHeader(http.StatusOK)
w.Write(output)
}