package twilio // TwilioProvider implements channel.ChannelProvider for Twilio SMS. // Reference: This is a GoChat addition — Chatwoot uses Twilio for SMS + WhatsApp // but our implementation separates them into distinct channels. // // Twilio SMS API: https://www.twilio.com/docs/sms // // Feature coverage: // - Account SID + Auth Token authentication (HTTP Basic Auth) // - Inbound SMS/MMS messages via form-encoded webhook // - Outbound SMS/MMS via Twilio REST API // - Delivery status callbacks (delivered, undelivered, failed) // - Contact resolution from E.164 phone numbers // - Phone number validation and normalization // - MMS media attachments // - Messaging Service SID for grouped phone numbers import ( "context" "encoding/json" "fmt" "net/url" "strings" "time" "github.com/go-resty/resty/v2" channelpkg "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" applogger "github.com/gochat/gochat/pkg/logger" ) // TwilioProvider implements ChannelProvider for Twilio SMS. type TwilioProvider struct { service *TwilioService repo *Repository pipeline *IncomingProcessor client *resty.Client } // NewTwilioProvider creates a Twilio SMS channel provider with all dependencies. func NewTwilioProvider(service *TwilioService, repo *Repository, pipeline *IncomingProcessor) *TwilioProvider { client := resty.New() client.SetTimeout(30 * time.Second) return &TwilioProvider{ service: service, repo: repo, pipeline: pipeline, client: client, } } // === Identity & Metadata === func (p *TwilioProvider) Type() channelpkg.ChannelType { return channelpkg.ChannelTwilioSMS } func (p *TwilioProvider) Name() string { return "Twilio SMS" } func (p *TwilioProvider) Description() string { return "Twilio SMS/MMS messaging channel — connect via Account SID, Auth Token, and phone number" } // ConfigSchema returns the configuration schema for Twilio SMS channels. func (p *TwilioProvider) ConfigSchema() *channelpkg.ConfigSchemaDefinition { return &channelpkg.ConfigSchemaDefinition{ Type: "object", Properties: map[string]channelpkg.ConfigProperty{ "account_sid": { Type: "string", Description: "Twilio Account SID", Required: true, }, "auth_token": { Type: "string", Description: "Twilio Auth Token", Required: true, Secret: true, }, "phone_number": { Type: "string", Description: "Twilio phone number (E.164 format, e.g. +123****7890)", Required: true, }, "messaging_service_sid": { Type: "string", Description: "Twilio Messaging Service SID (optional, for grouped numbers)", Required: false, }, "webhook_url": { Type: "string", Description: "Webhook URL for Twilio to send inbound messages", Required: false, Format: "url", }, }, Required: []string{"account_sid", "auth_token", "phone_number"}, } } // ValidateConfig checks Twilio SMS channel configuration validity. func (p *TwilioProvider) ValidateConfig(ctx context.Context, config channelpkg.ChannelConfig) error { accountSID := configStr(config, "account_sid", "") authToken := configStr(config, "auth_token", "") phoneNumber := configStr(config, "phone_number", "") if accountSID == "" { return fmt.Errorf("twilio: account_sid is required") } if authToken == "" { return fmt.Errorf("twilio: auth_token is required") } if phoneNumber == "" { return fmt.Errorf("twilio: phone_number is required") } // Verify account SID format (starts with "AC") if !strings.HasPrefix(accountSID, "AC") { return fmt.Errorf("twilio: account_sid must start with 'AC'") } return nil } // DefaultConfig returns default configuration values. func (p *TwilioProvider) DefaultConfig() channelpkg.ChannelConfig { return channelpkg.ChannelConfig{ "account_sid": "", "auth_token": "", "phone_number": "", "messaging_service_sid": "", } } // === Lifecycle === // OnCreate sets up the Twilio SMS channel after creation. func (p *TwilioProvider) OnCreate(ctx context.Context, inbox *model.Inbox, config channelpkg.ChannelConfig) (channelpkg.ChannelConfig, error) { applogger.L().Infof("Twilio OnCreate: inbox=%d account=%d", inbox.ID, inbox.AccountID) // Validate credentials by calling Twilio API accountSID := configStr(config, "account_sid", "") authToken := configStr(config, "auth_token", "") if accountSID != "" && authToken != "" { if err := p.service.ValidateCredentials(ctx, accountSID, authToken); err != nil { applogger.L().Warnf("Twilio OnCreate: credential validation failed: %v", err) config["reauthorization_required"] = true } } return config, nil } // OnDestroy cleans up the Twilio SMS channel before destruction. func (p *TwilioProvider) OnDestroy(ctx context.Context, inbox *model.Inbox, config channelpkg.ChannelConfig) error { applogger.L().Infof("Twilio OnDestroy: inbox=%d", inbox.ID) return nil } // === Messaging: Inbound === // ProcessIncoming transforms raw Twilio form-encoded webhook payload into IncomingMessage. func (p *TwilioProvider) ProcessIncoming(ctx context.Context, inbox *model.Inbox, rawPayload []byte) (*channelpkg.IncomingMessage, error) { // Twilio sends form-encoded data, not JSON values, err := url.ParseQuery(string(rawPayload)) if err != nil { return nil, fmt.Errorf("twilio ProcessIncoming: failed to parse form data: %w", err) } sms := InboundSMS{ MessageSID: values.Get("MessageSid"), AccountSID: values.Get("AccountSid"), From: values.Get("From"), To: values.Get("To"), Body: values.Get("Body"), NumMedia: values.Get("NumMedia"), FromCity: values.Get("FromCity"), FromState: values.Get("FromState"), FromCountry: values.Get("FromCountry"), FromZip: values.Get("FromZip"), SmsStatus: values.Get("SmsStatus"), } return p.pipeline.ProcessInboundSMS(ctx, inbox, sms) } // ValidateWebhookRequest verifies Twilio webhook authenticity. func (p *TwilioProvider) ValidateWebhookRequest(ctx context.Context, inbox *model.Inbox, request *channelpkg.WebhookRequest) error { // Twilio webhook signature validation is optional but recommended. // TODO: Implement full Twilio signature validation return nil } // === Messaging: Outbound === // SendMessage sends an outgoing SMS/MMS message via Twilio REST API. func (p *TwilioProvider) SendMessage(ctx context.Context, inbox *model.Inbox, message *model.Message, contact *model.Contact) (*channelpkg.SendResult, error) { config := parseInboxConfig(inbox.ChannelConfig) accountSID := configStr(config, "account_sid", "") authToken := configStr(config, "auth_token", "") phoneNumber := configStr(config, "phone_number", "") messagingServiceSID := configStr(config, "messaging_service_sid", "") toPhone := contact.SourceID if toPhone == "" { return nil, fmt.Errorf("twilio SendMessage: contact has no source_id") } // Use messaging service SID or phone number as sender from := phoneNumber if messagingServiceSID != "" { from = messagingServiceSID } // Normalize recipient phone number toPhone = p.service.ParsePhoneNumber(toPhone) result, err := p.service.SendMessage(ctx, accountSID, authToken, from, toPhone, message.Content, nil) if err != nil { return nil, fmt.Errorf("twilio SendMessage: API call failed: %w", err) } sendResult := &channelpkg.SendResult{ DeliveredAt: time.Now(), } if result != nil { sendResult.ExternalID = result.SID } return sendResult, nil } // === Contact Info === // GetContactProfile resolves a phone number contact profile. func (p *TwilioProvider) GetContactProfile(ctx context.Context, inbox *model.Inbox, contactSource string) (*channelpkg.ContactProfile, error) { normalizedPhone := p.service.ParsePhoneNumber(contactSource) return &channelpkg.ContactProfile{ Name: normalizedPhone, AvatarURL: "", Extra: channelpkg.ChannelConfig{ "phone_number": normalizedPhone, }, }, nil } // === Capabilities === func (p *TwilioProvider) Capabilities() channelpkg.ChannelCapabilities { return channelpkg.ChannelCapabilities{ SupportsAttachments: true, SupportsLocation: false, SupportsTypingIndicator: false, SupportsDeliveryStatus: true, SupportsReplies: false, SupportsEmojiReactions: false, SupportsVoiceMessages: false, SupportsVideoCalls: false, SupportsCustomCards: false, SupportsTemplates: false, SupportsEmailHeaders: false, MaxAttachmentSize: 5 * 1024 * 1024, MaxTextLength: 160, } } // === Helpers === // parseInboxConfig converts the inbox's JSON string ChannelConfig into a ChannelConfig map. func parseInboxConfig(configJSON string) channelpkg.ChannelConfig { if configJSON == "" { return channelpkg.ChannelConfig{} } var config channelpkg.ChannelConfig if err := json.Unmarshal([]byte(configJSON), &config); err != nil { applogger.L().Warnf("Twilio: failed to parse inbox channel_config JSON: %v", err) return channelpkg.ChannelConfig{} } return config } // configStr extracts a string value from ChannelConfig with a default fallback. func configStr(config channelpkg.ChannelConfig, key, defaultVal string) string { if v, ok := config[key]; ok { if s, ok := v.(string); ok && s != "" { return s } } return defaultVal }