sendly 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendly
4
+ # Messages resource for sending and managing SMS
5
+ class Messages
6
+ # @return [Sendly::Client] The API client
7
+ attr_reader :client
8
+
9
+ def initialize(client)
10
+ @client = client
11
+ end
12
+
13
+ # Send an SMS message
14
+ #
15
+ # @param to [String] Recipient phone number in E.164 format
16
+ # @param text [String] Message content (max 1600 characters)
17
+ # @return [Sendly::Message] The sent message
18
+ #
19
+ # @raise [Sendly::ValidationError] If parameters are invalid
20
+ # @raise [Sendly::InsufficientCreditsError] If account has no credits
21
+ # @raise [Sendly::RateLimitError] If rate limit is exceeded
22
+ #
23
+ # @example
24
+ # message = client.messages.send(
25
+ # to: "+15551234567",
26
+ # text: "Hello from Sendly!"
27
+ # )
28
+ # puts message.id
29
+ # puts message.status
30
+ def send(to:, text:)
31
+ validate_phone!(to)
32
+ validate_text!(text)
33
+
34
+ response = client.post("/messages", { to: to, text: text })
35
+ # API returns message directly at top level
36
+ Message.new(response)
37
+ end
38
+
39
+ # List messages
40
+ #
41
+ # @param limit [Integer] Maximum messages to return (default: 20, max: 100)
42
+ # @param offset [Integer] Number of messages to skip
43
+ # @param status [String] Filter by status
44
+ # @param to [String] Filter by recipient
45
+ # @return [Sendly::MessageList] Paginated list of messages
46
+ #
47
+ # @example
48
+ # messages = client.messages.list(limit: 50)
49
+ # messages.each { |m| puts m.to }
50
+ #
51
+ # @example With filters
52
+ # messages = client.messages.list(
53
+ # status: "delivered",
54
+ # to: "+15551234567"
55
+ # )
56
+ def list(limit: 20, offset: 0, status: nil, to: nil)
57
+ params = {
58
+ limit: [limit, 100].min,
59
+ offset: offset
60
+ }
61
+ params[:status] = status if status
62
+ params[:to] = to if to
63
+
64
+ response = client.get("/messages", params.compact)
65
+ MessageList.new(response)
66
+ end
67
+
68
+ # Get a message by ID
69
+ #
70
+ # @param id [String] Message ID
71
+ # @return [Sendly::Message] The message
72
+ #
73
+ # @raise [Sendly::NotFoundError] If message is not found
74
+ #
75
+ # @example
76
+ # message = client.messages.get("msg_abc123")
77
+ # puts message.status
78
+ def get(id)
79
+ raise ValidationError, "Message ID is required" if id.nil? || id.empty?
80
+
81
+ # URL encode the ID to prevent path injection
82
+ encoded_id = URI.encode_www_form_component(id)
83
+ response = client.get("/messages/#{encoded_id}")
84
+ # API returns message directly at top level
85
+ Message.new(response)
86
+ end
87
+
88
+ # Iterate over all messages with automatic pagination
89
+ #
90
+ # @param status [String] Filter by status
91
+ # @param to [String] Filter by recipient
92
+ # @param batch_size [Integer] Number of messages per request
93
+ # @yield [Message] Each message
94
+ # @return [Enumerator] If no block given
95
+ #
96
+ # @example
97
+ # client.messages.each do |message|
98
+ # puts "#{message.id}: #{message.to}"
99
+ # end
100
+ def each(status: nil, to: nil, batch_size: 100, &block)
101
+ return enum_for(:each, status: status, to: to, batch_size: batch_size) unless block_given?
102
+
103
+ offset = 0
104
+ loop do
105
+ page = list(limit: batch_size, offset: offset, status: status, to: to)
106
+ page.each(&block)
107
+
108
+ break unless page.has_more
109
+
110
+ offset += batch_size
111
+ end
112
+ end
113
+
114
+ # Schedule an SMS message for future delivery
115
+ #
116
+ # @param to [String] Recipient phone number in E.164 format
117
+ # @param text [String] Message content (max 1600 characters)
118
+ # @param scheduled_at [String] ISO 8601 datetime for when to send
119
+ # @param from [String] Sender ID or phone number (optional)
120
+ # @return [Hash] The scheduled message
121
+ #
122
+ # @raise [Sendly::ValidationError] If parameters are invalid
123
+ #
124
+ # @example
125
+ # scheduled = client.messages.schedule(
126
+ # to: "+15551234567",
127
+ # text: "Reminder: Your appointment is tomorrow!",
128
+ # scheduled_at: "2025-01-20T10:00:00Z"
129
+ # )
130
+ # puts scheduled["id"]
131
+ def schedule(to:, text:, scheduled_at:, from: nil)
132
+ validate_phone!(to)
133
+ validate_text!(text)
134
+ raise ValidationError, "scheduled_at is required" if scheduled_at.nil? || scheduled_at.empty?
135
+
136
+ body = { to: to, text: text, scheduledAt: scheduled_at }
137
+ body[:from] = from if from
138
+
139
+ client.post("/messages/schedule", body)
140
+ end
141
+
142
+ # List scheduled messages
143
+ #
144
+ # @param limit [Integer] Maximum messages to return (default: 20, max: 100)
145
+ # @param offset [Integer] Number of messages to skip
146
+ # @param status [String] Filter by status (scheduled, sent, cancelled, failed)
147
+ # @return [Hash] Paginated list of scheduled messages
148
+ #
149
+ # @example
150
+ # scheduled = client.messages.list_scheduled(limit: 50)
151
+ # scheduled["data"].each { |m| puts m["scheduledAt"] }
152
+ def list_scheduled(limit: 20, offset: 0, status: nil)
153
+ params = {
154
+ limit: [limit, 100].min,
155
+ offset: offset
156
+ }
157
+ params[:status] = status if status
158
+
159
+ client.get("/messages/scheduled", params.compact)
160
+ end
161
+
162
+ # Get a scheduled message by ID
163
+ #
164
+ # @param id [String] Scheduled message ID
165
+ # @return [Hash] The scheduled message
166
+ #
167
+ # @raise [Sendly::NotFoundError] If scheduled message is not found
168
+ #
169
+ # @example
170
+ # scheduled = client.messages.get_scheduled("sched_abc123")
171
+ # puts scheduled["status"]
172
+ def get_scheduled(id)
173
+ raise ValidationError, "Scheduled message ID is required" if id.nil? || id.empty?
174
+
175
+ encoded_id = URI.encode_www_form_component(id)
176
+ client.get("/messages/scheduled/#{encoded_id}")
177
+ end
178
+
179
+ # Cancel a scheduled message
180
+ #
181
+ # @param id [String] Scheduled message ID
182
+ # @return [Hash] The cancelled message with refund details
183
+ #
184
+ # @raise [Sendly::NotFoundError] If scheduled message is not found
185
+ # @raise [Sendly::ValidationError] If message cannot be cancelled
186
+ #
187
+ # @example
188
+ # result = client.messages.cancel_scheduled("sched_abc123")
189
+ # puts "Refunded #{result['creditsRefunded']} credits"
190
+ def cancel_scheduled(id)
191
+ raise ValidationError, "Scheduled message ID is required" if id.nil? || id.empty?
192
+
193
+ encoded_id = URI.encode_www_form_component(id)
194
+ client.delete("/messages/scheduled/#{encoded_id}")
195
+ end
196
+
197
+ # Send multiple SMS messages in a batch
198
+ #
199
+ # @param messages [Array<Hash>] Array of messages with :to and :text keys
200
+ # @param from [String] Sender ID or phone number (optional, applies to all)
201
+ # @return [Hash] Batch response with batch_id and status
202
+ #
203
+ # @raise [Sendly::ValidationError] If parameters are invalid
204
+ # @raise [Sendly::InsufficientCreditsError] If account has insufficient credits
205
+ #
206
+ # @example
207
+ # result = client.messages.send_batch(
208
+ # messages: [
209
+ # { to: "+15551234567", text: "Hello Alice!" },
210
+ # { to: "+15559876543", text: "Hello Bob!" }
211
+ # ]
212
+ # )
213
+ # puts "Batch #{result['batchId']}: #{result['queued']} queued"
214
+ def send_batch(messages:, from: nil)
215
+ raise ValidationError, "Messages array is required" if messages.nil? || messages.empty?
216
+
217
+ messages.each_with_index do |msg, i|
218
+ raise ValidationError, "Message at index #{i} missing 'to'" unless msg[:to] || msg["to"]
219
+ raise ValidationError, "Message at index #{i} missing 'text'" unless msg[:text] || msg["text"]
220
+
221
+ to = msg[:to] || msg["to"]
222
+ text = msg[:text] || msg["text"]
223
+ validate_phone!(to)
224
+ validate_text!(text)
225
+ end
226
+
227
+ body = { messages: messages }
228
+ body[:from] = from if from
229
+
230
+ client.post("/messages/batch", body)
231
+ end
232
+
233
+ # Get batch status by ID
234
+ #
235
+ # @param batch_id [String] Batch ID
236
+ # @return [Hash] Batch status and details
237
+ #
238
+ # @raise [Sendly::NotFoundError] If batch is not found
239
+ #
240
+ # @example
241
+ # batch = client.messages.get_batch("batch_abc123")
242
+ # puts "#{batch['sent']}/#{batch['total']} sent"
243
+ def get_batch(batch_id)
244
+ raise ValidationError, "Batch ID is required" if batch_id.nil? || batch_id.empty?
245
+
246
+ encoded_id = URI.encode_www_form_component(batch_id)
247
+ client.get("/messages/batch/#{encoded_id}")
248
+ end
249
+
250
+ # List batches
251
+ #
252
+ # @param limit [Integer] Maximum batches to return (default: 20, max: 100)
253
+ # @param offset [Integer] Number of batches to skip
254
+ # @param status [String] Filter by status (processing, completed, failed)
255
+ # @return [Hash] Paginated list of batches
256
+ #
257
+ # @example
258
+ # batches = client.messages.list_batches(limit: 10)
259
+ # batches["data"].each { |b| puts "#{b['batchId']}: #{b['status']}" }
260
+ def list_batches(limit: 20, offset: 0, status: nil)
261
+ params = {
262
+ limit: [limit, 100].min,
263
+ offset: offset
264
+ }
265
+ params[:status] = status if status
266
+
267
+ client.get("/messages/batches", params.compact)
268
+ end
269
+
270
+ private
271
+
272
+ def validate_phone!(phone)
273
+ return if phone.is_a?(String) && phone.match?(/^\+[1-9]\d{1,14}$/)
274
+
275
+ raise ValidationError, "Invalid phone number format. Use E.164 format (e.g., +15551234567)"
276
+ end
277
+
278
+ def validate_text!(text)
279
+ raise ValidationError, "Message text is required" if text.nil? || text.empty?
280
+ raise ValidationError, "Message text exceeds maximum length (1600 characters)" if text.length > 1600
281
+ end
282
+ end
283
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendly
4
+ # Represents an SMS message
5
+ class Message
6
+ # @return [String] Unique message identifier
7
+ attr_reader :id
8
+
9
+ # @return [String] Recipient phone number
10
+ attr_reader :to
11
+
12
+ # @return [String, nil] Sender ID or phone number
13
+ attr_reader :from
14
+
15
+ # @return [String] Message content
16
+ attr_reader :text
17
+
18
+ # @return [String] Delivery status
19
+ attr_reader :status
20
+
21
+ # @return [String, nil] Error message if failed
22
+ attr_reader :error
23
+
24
+ # @return [Integer] Number of SMS segments
25
+ attr_reader :segments
26
+
27
+ # @return [Integer] Credits used
28
+ attr_reader :credits_used
29
+
30
+ # @return [Boolean] Whether sent in sandbox mode
31
+ attr_reader :is_sandbox
32
+
33
+ # @return [Time, nil] Creation timestamp
34
+ attr_reader :created_at
35
+
36
+ # @return [Time, nil] Delivery timestamp
37
+ attr_reader :delivered_at
38
+
39
+ # Message status constants
40
+ STATUSES = %w[queued sending sent delivered failed].freeze
41
+
42
+ def initialize(data)
43
+ @id = data["id"]
44
+ @to = data["to"]
45
+ @from = data["from"]
46
+ @text = data["text"]
47
+ @status = data["status"]
48
+ @error = data["error"]
49
+ @segments = data["segments"] || 1
50
+ @credits_used = data["creditsUsed"] || 0
51
+ @is_sandbox = data["isSandbox"] || false
52
+ @created_at = parse_time(data["createdAt"])
53
+ @delivered_at = parse_time(data["deliveredAt"])
54
+ end
55
+
56
+ # Check if message was delivered
57
+ # @return [Boolean]
58
+ def delivered?
59
+ status == "delivered"
60
+ end
61
+
62
+ # Check if message failed
63
+ # @return [Boolean]
64
+ def failed?
65
+ status == "failed"
66
+ end
67
+
68
+ # Check if message is pending
69
+ # @return [Boolean]
70
+ def pending?
71
+ %w[queued sending sent].include?(status)
72
+ end
73
+
74
+ # Convert to hash
75
+ # @return [Hash]
76
+ def to_h
77
+ {
78
+ id: id,
79
+ to: to,
80
+ from: from,
81
+ text: text,
82
+ status: status,
83
+ error: error,
84
+ segments: segments,
85
+ credits_used: credits_used,
86
+ is_sandbox: is_sandbox,
87
+ created_at: created_at&.iso8601,
88
+ delivered_at: delivered_at&.iso8601
89
+ }.compact
90
+ end
91
+
92
+ private
93
+
94
+ def parse_time(value)
95
+ return nil if value.nil?
96
+
97
+ Time.parse(value)
98
+ rescue ArgumentError
99
+ nil
100
+ end
101
+ end
102
+
103
+ # Represents a paginated list of messages
104
+ class MessageList
105
+ include Enumerable
106
+
107
+ # @return [Array<Message>] Messages in this page
108
+ attr_reader :data
109
+
110
+ # @return [Integer] Total number of messages
111
+ attr_reader :total
112
+
113
+ # @return [Integer] Current limit
114
+ attr_reader :limit
115
+
116
+ # @return [Integer] Current offset
117
+ attr_reader :offset
118
+
119
+ # @return [Boolean] Whether there are more pages
120
+ attr_reader :has_more
121
+
122
+ def initialize(response)
123
+ @data = (response["data"] || []).map { |m| Message.new(m) }
124
+ @total = response["count"] || @data.length
125
+ @limit = response["limit"] || 20
126
+ @offset = response["offset"] || 0
127
+ @has_more = (@offset + @data.length) < @total
128
+ end
129
+
130
+ # Iterate over messages
131
+ def each(&block)
132
+ data.each(&block)
133
+ end
134
+
135
+ # Get message count
136
+ # @return [Integer]
137
+ def count
138
+ data.length
139
+ end
140
+
141
+ alias size count
142
+ alias length count
143
+
144
+ # Check if empty
145
+ # @return [Boolean]
146
+ def empty?
147
+ data.empty?
148
+ end
149
+
150
+ # Get first message
151
+ # @return [Message, nil]
152
+ def first
153
+ data.first
154
+ end
155
+
156
+ # Get last message
157
+ # @return [Message, nil]
158
+ def last
159
+ data.last
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendly
4
+ VERSION = "1.0.5"
5
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'json'
5
+
6
+ module Sendly
7
+ # Webhook utilities for verifying and parsing Sendly webhook events.
8
+ #
9
+ # @example In a Rails controller
10
+ # class WebhooksController < ApplicationController
11
+ # skip_before_action :verify_authenticity_token
12
+ #
13
+ # def handle
14
+ # signature = request.headers['X-Sendly-Signature']
15
+ # payload = request.raw_post
16
+ #
17
+ # begin
18
+ # event = Sendly::Webhooks.parse_event(payload, signature, ENV['WEBHOOK_SECRET'])
19
+ #
20
+ # case event.type
21
+ # when 'message.delivered'
22
+ # puts "Message delivered: #{event.data.message_id}"
23
+ # when 'message.failed'
24
+ # puts "Message failed: #{event.data.error}"
25
+ # end
26
+ #
27
+ # head :ok
28
+ # rescue Sendly::WebhookSignatureError
29
+ # head :unauthorized
30
+ # end
31
+ # end
32
+ # end
33
+ module Webhooks
34
+ class << self
35
+ # Verify webhook signature from Sendly.
36
+ #
37
+ # @param payload [String] Raw request body as string
38
+ # @param signature [String] X-Sendly-Signature header value
39
+ # @param secret [String] Your webhook secret from dashboard
40
+ # @return [Boolean] True if signature is valid, false otherwise
41
+ def verify_signature(payload, signature, secret)
42
+ return false if payload.nil? || payload.empty?
43
+ return false if signature.nil? || signature.empty?
44
+ return false if secret.nil? || secret.empty?
45
+
46
+ expected = 'sha256=' + OpenSSL::HMAC.hexdigest('SHA256', secret, payload)
47
+
48
+ # Timing-safe comparison
49
+ secure_compare(expected, signature)
50
+ end
51
+
52
+ # Parse and validate a webhook event.
53
+ #
54
+ # @param payload [String] Raw request body as string
55
+ # @param signature [String] X-Sendly-Signature header value
56
+ # @param secret [String] Your webhook secret from dashboard
57
+ # @return [WebhookEvent] Parsed and validated event
58
+ # @raise [WebhookSignatureError] If signature is invalid or payload is malformed
59
+ def parse_event(payload, signature, secret)
60
+ raise WebhookSignatureError, 'Invalid webhook signature' unless verify_signature(payload, signature, secret)
61
+
62
+ data = JSON.parse(payload, symbolize_names: true)
63
+
64
+ unless data[:id] && data[:type] && data[:data] && data[:created_at]
65
+ raise WebhookSignatureError, 'Invalid event structure'
66
+ end
67
+
68
+ WebhookEvent.new(data)
69
+ rescue JSON::ParserError => e
70
+ raise WebhookSignatureError, "Failed to parse webhook payload: #{e.message}"
71
+ end
72
+
73
+ # Generate a webhook signature for testing purposes.
74
+ #
75
+ # @param payload [String] The payload to sign
76
+ # @param secret [String] The secret to use for signing
77
+ # @return [String] The signature in the format "sha256=..."
78
+ def generate_signature(payload, secret)
79
+ 'sha256=' + OpenSSL::HMAC.hexdigest('SHA256', secret, payload)
80
+ end
81
+
82
+ private
83
+
84
+ # Timing-safe string comparison
85
+ def secure_compare(a, b)
86
+ return false unless a.bytesize == b.bytesize
87
+
88
+ l = a.unpack('C*')
89
+ res = 0
90
+ b.each_byte { |byte| res |= byte ^ l.shift }
91
+ res.zero?
92
+ end
93
+ end
94
+ end
95
+
96
+ # Webhook signature verification error
97
+ class WebhookSignatureError < Error
98
+ def initialize(message = 'Invalid webhook signature')
99
+ super(message, code: 'WEBHOOK_SIGNATURE_ERROR')
100
+ end
101
+ end
102
+
103
+ # Webhook event from Sendly
104
+ class WebhookEvent
105
+ attr_reader :id, :type, :data, :created_at, :api_version
106
+
107
+ def initialize(data)
108
+ @id = data[:id]
109
+ @type = data[:type]
110
+ @data = WebhookMessageData.new(data[:data])
111
+ @created_at = data[:created_at]
112
+ @api_version = data[:api_version] || '2024-01-01'
113
+ end
114
+
115
+ def to_h
116
+ {
117
+ id: @id,
118
+ type: @type,
119
+ data: @data.to_h,
120
+ created_at: @created_at,
121
+ api_version: @api_version
122
+ }
123
+ end
124
+ end
125
+
126
+ # Webhook message data
127
+ class WebhookMessageData
128
+ attr_reader :message_id, :status, :to, :from, :error, :error_code,
129
+ :delivered_at, :failed_at, :segments, :credits_used
130
+
131
+ def initialize(data)
132
+ @message_id = data[:message_id]
133
+ @status = data[:status]
134
+ @to = data[:to]
135
+ @from = data[:from] || ''
136
+ @error = data[:error]
137
+ @error_code = data[:error_code]
138
+ @delivered_at = data[:delivered_at]
139
+ @failed_at = data[:failed_at]
140
+ @segments = data[:segments] || 1
141
+ @credits_used = data[:credits_used] || 0
142
+ end
143
+
144
+ def to_h
145
+ {
146
+ message_id: @message_id,
147
+ status: @status,
148
+ to: @to,
149
+ from: @from,
150
+ error: @error,
151
+ error_code: @error_code,
152
+ delivered_at: @delivered_at,
153
+ failed_at: @failed_at,
154
+ segments: @segments,
155
+ credits_used: @credits_used
156
+ }.compact
157
+ end
158
+ end
159
+ end
data/lib/sendly.rb ADDED
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ require_relative "sendly/version"
7
+ require_relative "sendly/errors"
8
+ require_relative "sendly/types"
9
+ require_relative "sendly/client"
10
+ require_relative "sendly/messages"
11
+ require_relative "sendly/webhooks"
12
+
13
+ # Sendly Ruby SDK
14
+ #
15
+ # Official Ruby client for the Sendly SMS API.
16
+ #
17
+ # @example Basic usage
18
+ # client = Sendly::Client.new("sk_live_v1_xxx")
19
+ # message = client.messages.send(to: "+15551234567", text: "Hello!")
20
+ #
21
+ module Sendly
22
+ class << self
23
+ # @return [String, nil] Default API key
24
+ attr_accessor :api_key
25
+
26
+ # @return [String] Default base URL
27
+ attr_accessor :base_url
28
+
29
+ # Configure the SDK with default options
30
+ #
31
+ # @yield [self] Yields self for configuration
32
+ # @return [void]
33
+ #
34
+ # @example
35
+ # Sendly.configure do |config|
36
+ # config.api_key = "sk_live_v1_xxx"
37
+ # end
38
+ def configure
39
+ yield self
40
+ end
41
+
42
+ # Create a client with the default API key
43
+ #
44
+ # @return [Sendly::Client]
45
+ def client
46
+ @client ||= Client.new(api_key: api_key)
47
+ end
48
+
49
+ # Send a message using the default client
50
+ #
51
+ # @param to [String] Recipient phone number
52
+ # @param text [String] Message content
53
+ # @return [Sendly::Message]
54
+ def send_message(to:, text:)
55
+ client.messages.send(to: to, text: text)
56
+ end
57
+ end
58
+
59
+ self.base_url = "https://sendly.live/api/v1"
60
+ end