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

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
}