143 lines
4.9 KiB
Go
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
|
|
} |