285 lines
9.2 KiB
Go
285 lines
9.2 KiB
Go
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
|
|
} |