171 lines
5.3 KiB
Go
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)
|
|
}
|