A community based topic aggregation platform built on atproto

feat: Add Telnyx SMS integration

Telnyx selected for SMS delivery:
- 50% cheaper than Twilio ($0.004/SMS vs $0.0079)
- Owned infrastructure (better reliability)
- International number support
- Free expert support

Client handles OTP delivery with proper error handling.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+120
internal
sms
telnyx
+120
internal/sms/telnyx/client.go
···
+
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"`
+
}