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>

+120
+120
internal/sms/telnyx/client.go
··· 1 + package telnyx 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + "time" 11 + ) 12 + 13 + const ( 14 + TelnyxAPIBaseURL = "https://api.telnyx.com/v2" 15 + SendMessagePath = "/messages" 16 + ) 17 + 18 + // Client handles communication with Telnyx API 19 + type Client struct { 20 + apiKey string 21 + messagingProfileID string 22 + fromNumber string 23 + httpClient *http.Client 24 + } 25 + 26 + // NewClient creates a new Telnyx client 27 + func NewClient(apiKey, messagingProfileID, fromNumber string) *Client { 28 + return &Client{ 29 + apiKey: apiKey, 30 + messagingProfileID: messagingProfileID, 31 + fromNumber: fromNumber, 32 + httpClient: &http.Client{ 33 + Timeout: 10 * time.Second, 34 + }, 35 + } 36 + } 37 + 38 + // SendOTP sends an OTP code via SMS 39 + func (c *Client) SendOTP(ctx context.Context, phoneNumber, code string) error { 40 + message := fmt.Sprintf("Your Coves verification code is: %s\n\nThis code expires in 10 minutes.", code) 41 + 42 + req := &SendMessageRequest{ 43 + From: c.fromNumber, 44 + To: phoneNumber, 45 + Text: message, 46 + MessagingProfileID: c.messagingProfileID, 47 + } 48 + 49 + reqBody, err := json.Marshal(req) 50 + if err != nil { 51 + return fmt.Errorf("failed to marshal request: %w", err) 52 + } 53 + 54 + httpReq, err := http.NewRequestWithContext(ctx, "POST", TelnyxAPIBaseURL+SendMessagePath, bytes.NewReader(reqBody)) 55 + if err != nil { 56 + return fmt.Errorf("failed to create request: %w", err) 57 + } 58 + 59 + httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) 60 + httpReq.Header.Set("Content-Type", "application/json") 61 + 62 + resp, err := c.httpClient.Do(httpReq) 63 + if err != nil { 64 + return fmt.Errorf("failed to send request: %w", err) 65 + } 66 + defer resp.Body.Close() 67 + 68 + body, _ := io.ReadAll(resp.Body) 69 + 70 + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { 71 + var telnyxErr TelnyxErrorResponse 72 + if err := json.Unmarshal(body, &telnyxErr); err == nil && len(telnyxErr.Errors) > 0 { 73 + return fmt.Errorf("telnyx API error: %s (code: %s)", telnyxErr.Errors[0].Detail, telnyxErr.Errors[0].Code) 74 + } 75 + return fmt.Errorf("telnyx API error: status %d, body: %s", resp.StatusCode, string(body)) 76 + } 77 + 78 + var msgResp SendMessageResponse 79 + if err := json.Unmarshal(body, &msgResp); err != nil { 80 + return fmt.Errorf("failed to parse response: %w", err) 81 + } 82 + 83 + // Check if message was accepted 84 + if msgResp.Data.ID == "" { 85 + return fmt.Errorf("message not accepted by Telnyx") 86 + } 87 + 88 + return nil 89 + } 90 + 91 + // SendMessageRequest represents a Telnyx send message request 92 + type SendMessageRequest struct { 93 + From string `json:"from"` 94 + To string `json:"to"` 95 + Text string `json:"text"` 96 + MessagingProfileID string `json:"messaging_profile_id"` 97 + } 98 + 99 + // SendMessageResponse represents a Telnyx send message response 100 + type SendMessageResponse struct { 101 + Data MessageData `json:"data"` 102 + } 103 + 104 + // MessageData represents message data in Telnyx response 105 + type MessageData struct { 106 + ID string `json:"id"` 107 + Status string `json:"status"` 108 + } 109 + 110 + // TelnyxErrorResponse represents an error response from Telnyx 111 + type TelnyxErrorResponse struct { 112 + Errors []TelnyxError `json:"errors"` 113 + } 114 + 115 + // TelnyxError represents a single error from Telnyx 116 + type TelnyxError struct { 117 + Code string `json:"code"` 118 + Detail string `json:"detail"` 119 + Title string `json:"title"` 120 + }