Files
gochat/internal/channel/twilio/pipeline.go
T
2026-06-04 15:44:48 +08:00

143 lines
4.9 KiB
Go

package twilio
// Pipeline integration for Twilio SMS/WhatsApp channel with gochat's message processing system.
// Reference: gochat's Telegram pipeline architecture — same pattern adapted for Twilio.
//
// Flow:
// WebhookHandler.ProcessInboundSMS → IncomingProcessor.ProcessInboundSMS
// → channelpkg.IncomingMessage → core message processing
//
// Twilio SMS specifics:
// - Sender identification by phone number (E.164 format)
// - Support for plain SMS text and MMS media attachments
// - Delivery status tracking via callback events
// - WhatsApp via Twilio uses same pipeline with different ChannelType
import (
"context"
"fmt"
channelpkg "github.com/gochat/gochat/internal/channel"
"github.com/gochat/gochat/internal/model"
applogger "github.com/gochat/gochat/pkg/logger"
)
// IncomingProcessor transforms raw Twilio webhook data into gochat's unified IncomingMessage.
type IncomingProcessor struct {
service *TwilioService
}
// NewIncomingProcessor creates a Twilio incoming message processor.
func NewIncomingProcessor(service *TwilioService) *IncomingProcessor {
return &IncomingProcessor{service: service}
}
// ProcessInboundSMS transforms an inbound SMS/MMS message into a gochat IncomingMessage.
func (p *IncomingProcessor) ProcessInboundSMS(ctx context.Context, inbox *model.Inbox, sms InboundSMS) (*channelpkg.IncomingMessage, error) {
// Use the sender's phone number as the contact source identifier
fromPhone := sms.From
if fromPhone == "" {
return nil, fmt.Errorf("twilio ProcessInboundSMS: no From number")
}
// Normalize phone number to E.164
normalizedFrom := p.service.ParsePhoneNumber(fromPhone)
incomingMsg := &channelpkg.IncomingMessage{
ChannelType: channelpkg.ChannelTwilioSMS,
SourceID: sms.MessageSID, // Twilio MessageSID as source ID
SenderID: normalizedFrom,
SenderName: normalizedFrom, // Phone number as display name (may be enriched later)
SenderType: channelpkg.SenderContact,
InboxID: inbox.ID,
AccountID: inbox.AccountID,
Extra: make(channelpkg.ChannelConfig),
}
// Store Twilio message metadata
incomingMsg.Extra["message_sid"] = sms.MessageSID
incomingMsg.Extra["account_sid"] = sms.AccountSID
incomingMsg.Extra["from_city"] = sms.FromCity
incomingMsg.Extra["from_state"] = sms.FromState
incomingMsg.Extra["from_country"] = sms.FromCountry
incomingMsg.Extra["from_zip"] = sms.FromZip
// Handle media attachments (MMS)
numMedia := 0
if sms.NumMedia != "" {
if n, err := fmt.Sscanf(sms.NumMedia, "%d", &numMedia); err != nil || n != 1 {
numMedia = 0
}
}
if numMedia > 0 && len(sms.MediaURL) > 0 {
// MMS message with media attachments
incomingMsg.ContentType = channelpkg.ContentImage
incomingMsg.Content = sms.MediaURL[0] // Primary media URL
incomingMsg.Extra["num_media"] = numMedia
incomingMsg.Extra["media_urls"] = sms.MediaURL
incomingMsg.Extra["media_content_types"] = sms.MediaContentType
// Build attachment list
for i, url := range sms.MediaURL {
ct := "image"
if i < len(sms.MediaContentType) {
ct = sms.MediaContentType[i]
}
incomingMsg.Attachments = append(incomingMsg.Attachments, channelpkg.Attachment{
URL: url,
ContentType: ct,
})
}
// If there's text alongside media, add it to extra
if sms.Body != "" {
incomingMsg.Extra["body"] = sms.Body
}
} else {
// Pure SMS text message
incomingMsg.ContentType = channelpkg.ContentText
incomingMsg.Content = sms.Body
}
applogger.L().Debugf("Twilio ProcessInboundSMS: from=%s body_len=%d media=%d",
normalizedFrom, len(sms.Body), numMedia)
return incomingMsg, nil
}
// ProcessDeliveryStatus transforms a Twilio delivery status callback into a channel event.
func (p *IncomingProcessor) ProcessDeliveryStatus(ctx context.Context, inbox *model.Inbox, status DeliveryStatus) (*channelpkg.IncomingMessage, error) {
toPhone := p.service.ParsePhoneNumber(status.To)
incomingMsg := &channelpkg.IncomingMessage{
ChannelType: channelpkg.ChannelTwilioSMS,
SourceID: status.MessageSID,
SenderID: toPhone,
SenderType: channelpkg.SenderSystem, // delivery status is system-generated
InboxID: inbox.ID,
AccountID: inbox.AccountID,
ContentType: channelpkg.ContentText,
Extra: make(channelpkg.ChannelConfig),
}
// Map Twilio delivery status to gochat content
switch status.MessageStatus {
case "delivered":
incomingMsg.Content = fmt.Sprintf("Message %s delivered", status.MessageSID)
case "undelivered", "failed":
incomingMsg.Content = fmt.Sprintf("Message %s failed: status=%s error=%s",
status.MessageSID, status.MessageStatus, status.ErrorCode)
default:
applogger.L().Debugf("Twilio ProcessDeliveryStatus: unhandled status %s", status.MessageStatus)
return nil, nil
}
incomingMsg.Extra["message_sid"] = status.MessageSID
incomingMsg.Extra["message_status"] = status.MessageStatus
if status.ErrorCode != "" {
incomingMsg.Extra["error_code"] = status.ErrorCode
}
return incomingMsg, nil
}