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.
- checksums.yaml +7 -0
- data/.ruby-version +1 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +95 -0
- data/README.md +227 -0
- data/examples/list_messages.rb +33 -0
- data/examples/send_sms.rb +31 -0
- data/lib/sendly/client.rb +173 -0
- data/lib/sendly/errors.rb +121 -0
- data/lib/sendly/messages.rb +283 -0
- data/lib/sendly/types.rb +162 -0
- data/lib/sendly/version.rb +5 -0
- data/lib/sendly/webhooks.rb +159 -0
- data/lib/sendly.rb +60 -0
- metadata +159 -0
|
@@ -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
|
data/lib/sendly/types.rb
ADDED
|
@@ -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,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
|