diff --git a/internal/sms/telnyx/client.go b/internal/sms/telnyx/client.go new file mode 100644 --- /dev/null +++ b/internal/sms/telnyx/client.go @@ -0,0 +1,120 @@ +package telnyx + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const ( + TelnyxAPIBaseURL = "https://api.telnyx.com/v2" + SendMessagePath = "/messages" +) + +// Client handles communication with Telnyx API +type Client struct { + apiKey string + messagingProfileID string + fromNumber string + httpClient *http.Client +} + +// NewClient creates a new Telnyx client +func NewClient(apiKey, messagingProfileID, fromNumber string) *Client { + return &Client{ + apiKey: apiKey, + messagingProfileID: messagingProfileID, + fromNumber: fromNumber, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// SendOTP sends an OTP code via SMS +func (c *Client) SendOTP(ctx context.Context, phoneNumber, code string) error { + message := fmt.Sprintf("Your Coves verification code is: %s\n\nThis code expires in 10 minutes.", code) + + req := &SendMessageRequest{ + From: c.fromNumber, + To: phoneNumber, + Text: message, + MessagingProfileID: c.messagingProfileID, + } + + reqBody, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", TelnyxAPIBaseURL+SendMessagePath, bytes.NewReader(reqBody)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + var telnyxErr TelnyxErrorResponse + if err := json.Unmarshal(body, &telnyxErr); err == nil && len(telnyxErr.Errors) > 0 { + return fmt.Errorf("telnyx API error: %s (code: %s)", telnyxErr.Errors[0].Detail, telnyxErr.Errors[0].Code) + } + return fmt.Errorf("telnyx API error: status %d, body: %s", resp.StatusCode, string(body)) + } + + var msgResp SendMessageResponse + if err := json.Unmarshal(body, &msgResp); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + // Check if message was accepted + if msgResp.Data.ID == "" { + return fmt.Errorf("message not accepted by Telnyx") + } + + return nil +} + +// SendMessageRequest represents a Telnyx send message request +type SendMessageRequest struct { + From string `json:"from"` + To string `json:"to"` + Text string `json:"text"` + MessagingProfileID string `json:"messaging_profile_id"` +} + +// SendMessageResponse represents a Telnyx send message response +type SendMessageResponse struct { + Data MessageData `json:"data"` +} + +// MessageData represents message data in Telnyx response +type MessageData struct { + ID string `json:"id"` + Status string `json:"status"` +} + +// TelnyxErrorResponse represents an error response from Telnyx +type TelnyxErrorResponse struct { + Errors []TelnyxError `json:"errors"` +} + +// TelnyxError represents a single error from Telnyx +type TelnyxError struct { + Code string `json:"code"` + Detail string `json:"detail"` + Title string `json:"title"` +}