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

160 lines
4.8 KiB
Go

package twilio
// TwilioService provides high-level operations for the Twilio SMS channel.
// Reference: Twilio SMS API — https://www.twilio.com/docs/sms
//
// This service coordinates:
// - Sending SMS/MMS messages via Twilio REST API
// - Fetching phone number information
// - Validating account credentials
// - Processing delivery status callbacks
// - Managing channel configuration
//
// Design: Follows the same service pattern as LineService and WhatsAppService.
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/go-resty/resty/v2"
applogger "github.com/gochat/gochat/pkg/logger"
)
// TwilioService provides Twilio SMS API operations.
type TwilioService struct {
repo *Repository
client *resty.Client
}
// NewTwilioService creates a Twilio SMS service instance.
func NewTwilioService(repo *Repository) *TwilioService {
client := resty.New()
client.SetTimeout(30 * time.Second)
return &TwilioService{
repo: repo,
client: client,
}
}
// === Message Sending ===
// SendMessage sends an SMS or MMS message via Twilio REST API.
// Reference: https://www.twilio.com/docs/sms/api/message-resource
func (s *TwilioService) SendMessage(ctx context.Context, accountSID, authToken, from, to, body string, mediaURLs []string) (*OutboundSMSResponse, error) {
apiURL := fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", accountSID)
formData := map[string]string{
"From": from,
"To": to,
"Body": body,
}
// Add media URLs for MMS
for i, mediaURL := range mediaURLs {
formData[fmt.Sprintf("MediaUrl%d", i)] = mediaURL
}
var result OutboundSMSResponse
resp, err := s.client.R().
SetContext(ctx).
SetBasicAuth(accountSID, authToken).
SetFormData(formData).
SetResult(&result).
Post(apiURL)
if err != nil {
return nil, fmt.Errorf("twilio SendMessage: API call failed: %w", err)
}
if resp.StatusCode() != 201 {
// Parse Twilio error response
var errResp map[string]interface{}
if json.Unmarshal(resp.Body(), &errResp) == nil {
msg, _ := errResp["message"].(string)
code, _ := errResp["code"].(float64)
return nil, fmt.Errorf("twilio SendMessage: API error %d: %s", int(code), msg)
}
return nil, fmt.Errorf("twilio SendMessage: API returned status %d", resp.StatusCode())
}
applogger.L().Debugf("Twilio SendMessage: SID=%s to=%s", result.SID, to)
return &result, nil
}
// === Phone Number Info ===
// GetPhoneNumberInfo fetches details about a Twilio phone number.
func (s *TwilioService) GetPhoneNumberInfo(ctx context.Context, accountSID, authToken, phoneSID string) (*PhoneNumberInfo, error) {
apiURL := fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/IncomingPhoneNumbers/%s.json", accountSID, phoneSID)
var result PhoneNumberInfo
resp, err := s.client.R().
SetContext(ctx).
SetBasicAuth(accountSID, authToken).
SetResult(&result).
Get(apiURL)
if err != nil {
return nil, fmt.Errorf("twilio GetPhoneNumberInfo: API call failed: %w", err)
}
if resp.StatusCode() != 200 {
return nil, fmt.Errorf("twilio GetPhoneNumberInfo: API returned status %d", resp.StatusCode())
}
return &result, nil
}
// === Credential Validation ===
// ValidateCredentials verifies Twilio account SID and auth token.
func (s *TwilioService) ValidateCredentials(ctx context.Context, accountSID, authToken string) error {
apiURL := fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s.json", accountSID)
resp, err := s.client.R().
SetContext(ctx).
SetBasicAuth(accountSID, authToken).
Get(apiURL)
if err != nil {
return fmt.Errorf("twilio ValidateCredentials: API call failed: %w", err)
}
if resp.StatusCode() != 200 {
return fmt.Errorf("twilio ValidateCredentials: invalid credentials (status %d)", resp.StatusCode())
}
return nil
}
// === Configuration ===
// UpdateChannel updates Twilio SMS channel configuration fields.
func (s *TwilioService) UpdateChannel(ctx context.Context, channelID uint, updates map[string]interface{}) error {
return s.repo.UpdateFields(ctx, channelID, updates)
}
// MarkReauthorizationRequired flags a channel needing credential reconfiguration.
func (s *TwilioService) MarkReauthorizationRequired(ctx context.Context, channelID uint) error {
return s.UpdateChannel(ctx, channelID, map[string]interface{}{
"reauthorization_required": true,
})
}
// ParsePhoneNumber normalizes a phone number to E.164 format.
func (s *TwilioService) ParsePhoneNumber(phone string) string {
// Strip all non-digit characters
phone = strings.ReplaceAll(phone, "-", "")
phone = strings.ReplaceAll(phone, " ", "")
phone = strings.ReplaceAll(phone, "+", "")
phone = strings.ReplaceAll(phone, ".", "")
phone = strings.ReplaceAll(phone, "(", "")
phone = strings.ReplaceAll(phone, ")", "")
// Add + prefix for E.164
if !strings.HasPrefix(phone, "+") {
phone = "+" + phone
}
return phone
}