sendly 4.1.0 → 4.3.0

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.
@@ -8,7 +8,7 @@ client = Sendly::Client.new(ENV["SENDLY_API_KEY"] || "sk_test_v1_example")
8
8
  # List recent messages
9
9
  puts "=== Recent Messages ==="
10
10
  messages = client.messages.list(limit: 10)
11
- puts "Total: #{messages.total}"
11
+ puts "Total matching: #{messages.total}"
12
12
  puts "Has more: #{messages.has_more}"
13
13
  puts
14
14
 
data/examples/send_sms.rb CHANGED
@@ -9,7 +9,7 @@ client = Sendly::Client.new(ENV["SENDLY_API_KEY"] || "sk_test_v1_example")
9
9
  # Send an SMS
10
10
  begin
11
11
  message = client.messages.send(
12
- to: "+15551234567",
12
+ to: "+15125550123",
13
13
  text: "Hello from Sendly Ruby SDK!"
14
14
  )
15
15
 
@@ -25,7 +25,11 @@ rescue Sendly::InsufficientCreditsError => e
25
25
  rescue Sendly::ValidationError => e
26
26
  puts "Validation error: #{e.message}"
27
27
  rescue Sendly::RateLimitError => e
28
- puts "Rate limited. Retry after: #{e.retry_after} seconds"
28
+ if e.response_body&.dig("error") == "too_many_failed_key_attempts"
29
+ puts "Locked out after repeated wrong API keys. Fix the key, then wait #{e.retry_after} seconds"
30
+ else
31
+ puts "Rate limited. Retry after: #{e.retry_after} seconds"
32
+ end
29
33
  rescue Sendly::Error => e
30
34
  puts "Error: #{e.message}"
31
35
  end
@@ -69,17 +69,29 @@ module Sendly
69
69
 
70
70
  # Create a new API key
71
71
  #
72
+ # A live key needs a verified business and a credit balance; the API
73
+ # answers 403 +verification_required+ or 402 +credits_required+ otherwise.
74
+ #
72
75
  # @param name [String] Display name for the API key
76
+ # @param type [String] "test" (the default) or "live"
77
+ # @param scopes [Array<String>, nil] Scopes for the new key, e.g. ["sms:send"].
78
+ # Omit to give it every scope the calling key has; a key cannot grant a
79
+ # scope it does not have.
73
80
  # @param expires_at [String, nil] Optional expiration date (ISO 8601)
74
- # @return [Hash] Contains 'apiKey' (metadata) and 'key' (full secret - only shown once!)
81
+ # @return [Hash] +{ "id", "name", "key", "keyPrefix", "type", "createdAt",
82
+ # "expiresAt", "apiKey" }+, where "key" is the full secret (shown only
83
+ # once) and "apiKey" is the key's metadata
84
+ # @raise [ArgumentError] If +name+ is missing or +type+ is not "test" or "live"
75
85
  #
76
86
  # @example
77
- # result = client.account.create_api_key("Production")
87
+ # result = client.account.create_api_key("Production", type: "live", scopes: ["sms:send"])
78
88
  # puts "Save this key: #{result['key']}" # Only shown once!
79
- def create_api_key(name, expires_at: nil)
89
+ def create_api_key(name, type: "test", scopes: nil, expires_at: nil)
80
90
  raise ArgumentError, "API key name is required" if name.nil? || name.empty?
91
+ raise ArgumentError, "API key type must be \"test\" or \"live\"" unless %w[test live].include?(type.to_s)
81
92
 
82
- body = { name: name }
93
+ body = { name: name, type: type.to_s }
94
+ body[:scopes] = scopes unless scopes.nil?
83
95
  body[:expiresAt] = expires_at if expires_at
84
96
 
85
97
  @client.post("/account/keys", body)
@@ -363,7 +363,8 @@ module Sendly
363
363
 
364
364
  if file_bytes
365
365
  body_parts << "--#{boundary}\r\n"
366
- body_parts << "Content-Disposition: form-data; name=\"einDoc\"; filename=\"#{filename}\"\r\n"
366
+ safe_name = filename.to_s.gsub('"', "%22").gsub("\r", "%0D").gsub("\n", "%0A")
367
+ body_parts << "Content-Disposition: form-data; name=\"einDoc\"; filename=\"#{safe_name}\"\r\n"
367
368
  body_parts << "Content-Type: #{content_type}\r\n\r\n"
368
369
  body_parts << file_bytes
369
370
  body_parts << "\r\n"
@@ -385,7 +386,7 @@ module Sendly
385
386
  req["X-Organization-Id"] = @client.organization_id if @client.organization_id
386
387
  # Single-use auto key (this path has no retry loop).
387
388
  req["Idempotency-Key"] = @client.generate_idempotency_key
388
- req.body = body_parts.join
389
+ req.body = body_parts.map { |part| part.to_s.b }.join
389
390
 
390
391
  begin
391
392
  response = http.request(req)
@@ -23,7 +23,12 @@ module Sendly
23
23
  # A phone call placed or received by one of your workspace's numbers.
24
24
  #
25
25
  # +kind+ is "pstn" for a phone call and "internal" for a browser-to-browser
26
- # call between teammates. +status+ is "ringing" or "active" while the call
26
+ # call between teammates. +channel+ is where the call took place:
27
+ # "phone", "whatsapp" (a WhatsApp call, for example one placed from the
28
+ # dashboard) or "browser" (see {CHANNELS}); a value this SDK predates
29
+ # comes through unchanged, and it is nil when the API doesn't send one.
30
+ # An inbound WhatsApp call can read "phone" until the carrier labels it.
31
+ # +status+ is "ringing" or "active" while the call
27
32
  # is live and one of the terminal values ("completed", "no_answer", "busy",
28
33
  # "cancelled", "declined", "failed") once it has ended; "suspended" can
29
34
  # appear on an internal call whose media dropped and may recover.
@@ -40,6 +45,7 @@ module Sendly
40
45
  LIVE_STATUSES = %w[ringing active].freeze
41
46
  DIRECTIONS = %w[inbound outbound].freeze
42
47
  KINDS = %w[pstn internal].freeze
48
+ CHANNELS = %w[phone whatsapp browser].freeze
43
49
  HANDLED_BY = %w[agent dashboard].freeze
44
50
  BILLING_STATES = %w[metered settled unbilled].freeze
45
51
  RECORDING_STATUSES = %w[recording ready failed].freeze
@@ -58,12 +64,15 @@ module Sendly
58
64
  no_voice_number number_not_found destination_not_supported e911_required lines_busy
59
65
  daily_call_limit call_not_found live_key_required voice_internal_error
60
66
  insufficient_credits invalid_number rate_limit_exceeded forbidden
67
+ invalid_request insufficient_permissions agent_in_use agent_limit invalid_voice_mode
68
+ invalid_address e911_not_applicable voice_attach_failed carrier_refused
69
+ from_number_not_supported
61
70
  ].freeze
62
71
 
63
72
  attr_reader :id, :object, :kind, :direction, :status, :handled_by, :agent_id,
64
73
  :from, :to, :caller_name, :callee_name, :started_at, :answered_at,
65
74
  :ended_at, :duration_secs, :credits_charged, :billing, :hangup_class,
66
- :recording_status, :metadata, :transcript
75
+ :recording_status, :metadata, :transcript, :channel
67
76
 
68
77
  # @return [Hash] The raw parsed response
69
78
  attr_reader :raw
@@ -74,6 +83,7 @@ module Sendly
74
83
  @id = data["id"]
75
84
  @object = data["object"] || "call"
76
85
  @kind = data["kind"]
86
+ @channel = data["channel"]
77
87
  @direction = data["direction"]
78
88
  @status = data["status"]
79
89
  @handled_by = data["handledBy"] || data["handled_by"]
@@ -123,7 +133,7 @@ module Sendly
123
133
 
124
134
  def to_h
125
135
  {
126
- id: id, object: object, kind: kind, direction: direction, status: status,
136
+ id: id, object: object, kind: kind, channel: channel, direction: direction, status: status,
127
137
  handled_by: handled_by, agent_id: agent_id, from: from, to: to,
128
138
  caller_name: caller_name, callee_name: callee_name, started_at: started_at,
129
139
  answered_at: answered_at, ended_at: ended_at, duration_secs: duration_secs,
@@ -182,8 +192,8 @@ module Sendly
182
192
  # call runs, "ready" once it can be fetched, or "failed". +url+ and
183
193
  # +expires_at+ are set only when {#ready?}: the URL is signed and valid for
184
194
  # five minutes. Recordings are Ogg/Opus (+content_type+ "audio/ogg");
185
- # agent-handled calls are recorded dual-channel, caller left and agent
186
- # right.
195
+ # agent-handled calls are recorded dual-channel, with the agent on the
196
+ # left channel and the other party on the right.
187
197
  class CallRecording
188
198
  STATUSES = %w[none recording ready failed].freeze
189
199
 
@@ -213,9 +223,10 @@ module Sendly
213
223
  # Calls resource: place phone calls handled by your AI agents, list and
214
224
  # inspect calls, end a call and fetch recordings.
215
225
  #
216
- # A call placed over the API is answered by one of the AI agents you
217
- # configure in the dashboard under Calls, then Agents; the +from+ number
218
- # must have voice switched on in the dashboard. Calls are charged per
226
+ # A call placed over the API is answered by one of your AI agents
227
+ # (create them with {VoiceAgentsResource#create} or in the dashboard
228
+ # under Calls, then Agents); the +from+ number must have voice switched
229
+ # on ({VoiceNumbersResource#update} or the dashboard). Calls are charged per
219
230
  # started minute from your credit balance: 2 credits a minute outbound,
220
231
  # plus 8 a minute while an agent is on the call. Destinations are US and
221
232
  # Canadian numbers. Reads need the +calls:read+ scope, writes
@@ -228,9 +239,9 @@ module Sendly
228
239
  # +voice_not_enabled+, +outbound_calls_not_enabled+, +agent_not_found+,
229
240
  # +number_not_found+ and +call_not_found+; {Sendly::ValidationError} for
230
241
  # +invalid_number+, +destination_not_supported+, +agent_required+,
231
- # +invalid_metadata+ and +from_number_required+;
232
- # {Sendly::InsufficientCreditsError} for +insufficient_credits+;
233
- # {Sendly::RateLimitError} for +daily_call_limit+ and
242
+ # +invalid_metadata+, +from_number_required+ and
243
+ # +from_number_not_supported+; {Sendly::InsufficientCreditsError} for
244
+ # +insufficient_credits+; {Sendly::RateLimitError} for +daily_call_limit+ and
234
245
  # +rate_limit_exceeded+; {Sendly::APIError} with the HTTP status for
235
246
  # +e911_required+ (428), +agent_disabled+ / +no_voice_number+ /
236
247
  # +lines_busy+ (409) and +live_key_required+ / +forbidden+ (403); and
@@ -261,7 +272,9 @@ module Sendly
261
272
  # @param agent_id [String] The AI agent that talks on the call
262
273
  # @param from [String, nil] A voice-enabled number in your workspace.
263
274
  # Optional when the workspace has exactly one; required (the API
264
- # responds 400 +from_number_required+) when it has more.
275
+ # responds 400 +from_number_required+) when it has more. Calls can
276
+ # only be placed from US and Canadian numbers (400
277
+ # +from_number_not_supported+ otherwise).
265
278
  # @param context [String, nil] Up to 2000 characters appended to the
266
279
  # agent's instructions for this call only. Not echoed back.
267
280
  # @param metadata [Hash{String => String}, nil] Up to 20 string pairs
@@ -272,13 +285,15 @@ module Sendly
272
285
  # @return [Sendly::Call] The new call (+status+ "ringing", +handled_by+ "agent")
273
286
  # @raise [Sendly::ValidationError] If +to+ or +agent_id+ is missing, or
274
287
  # HTTP 400 (+invalid_number+, +destination_not_supported+,
275
- # +agent_required+, +invalid_metadata+, +from_number_required+)
288
+ # +agent_required+, +invalid_metadata+, +from_number_required+,
289
+ # +from_number_not_supported+)
276
290
  # @raise [Sendly::NotFoundError] HTTP 404 (+voice_not_enabled+,
277
291
  # +outbound_calls_not_enabled+, +agent_not_found+, +number_not_found+)
278
292
  # @raise [Sendly::InsufficientCreditsError] HTTP 402 when the balance
279
293
  # cannot cover one minute at the agent rate
280
294
  # @raise [Sendly::APIError] HTTP 428 +e911_required+ (register an
281
- # emergency address for the number first), 409 +agent_disabled+ /
295
+ # emergency address for the number first with
296
+ # {VoiceNumbersResource#register_emergency_address}), 409 +agent_disabled+ /
282
297
  # +no_voice_number+ / +lines_busy+, 403 +live_key_required+
283
298
  # @raise [Sendly::RateLimitError] HTTP 429 +daily_call_limit+ / +rate_limit_exceeded+
284
299
  # @raise [Sendly::ServerError] HTTP 503 +voice_unavailable+ /
@@ -1,22 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sendly
4
+ # A campaign. A sent campaign's status is +completed+. +recipient_count+
5
+ # is the number of recipients it was sent to, or for a scheduled campaign
6
+ # the number it will be sent to (0 for a draft). +started_at+ is when
7
+ # sending began. +template_id+ is always +nil+: the API does not return it.
4
8
  class Campaign
5
9
  attr_reader :id, :name, :text, :template_id, :contact_list_ids, :status,
6
10
  :recipient_count, :sent_count, :delivered_count, :failed_count,
7
11
  :estimated_credits, :credits_used, :scheduled_at, :timezone,
8
12
  :started_at, :completed_at, :created_at, :updated_at
9
13
 
10
- STATUSES = %w[draft scheduled sending sent paused cancelled failed].freeze
14
+ # Campaign statuses. The API returns +draft+, +scheduled+, +sending+,
15
+ # +completed+, +cancelled+ and +failed+; +sent+ and +paused+ are never
16
+ # returned.
17
+ STATUSES = %w[draft scheduled sending sent paused cancelled failed completed].freeze
11
18
 
12
19
  def initialize(data)
13
20
  @id = data["id"]
14
21
  @name = data["name"]
15
- @text = data["text"]
22
+ @text = data["text"] || data["messageText"]
16
23
  @template_id = data["template_id"] || data["templateId"]
17
- @contact_list_ids = data["contact_list_ids"] || data["contactListIds"] || []
24
+ @contact_list_ids = data["contact_list_ids"] || data["contactListIds"] || [data["targetListId"]].compact
18
25
  @status = data["status"]
19
- @recipient_count = data["recipient_count"] || data["recipientCount"] || 0
26
+ @recipient_count = data["recipient_count"] || data["recipientCount"] || data["totalRecipients"] || 0
20
27
  @sent_count = data["sent_count"] || data["sentCount"] || 0
21
28
  @delivered_count = data["delivered_count"] || data["deliveredCount"] || 0
22
29
  @failed_count = data["failed_count"] || data["failedCount"] || 0
@@ -24,7 +31,7 @@ module Sendly
24
31
  @credits_used = data["credits_used"] || data["creditsUsed"] || 0
25
32
  @scheduled_at = parse_time(data["scheduled_at"] || data["scheduledAt"])
26
33
  @timezone = data["timezone"]
27
- @started_at = parse_time(data["started_at"] || data["startedAt"])
34
+ @started_at = parse_time(data["started_at"] || data["startedAt"] || data["sentAt"])
28
35
  @completed_at = parse_time(data["completed_at"] || data["completedAt"])
29
36
  @created_at = parse_time(data["created_at"] || data["createdAt"])
30
37
  @updated_at = parse_time(data["updated_at"] || data["updatedAt"])
@@ -42,8 +49,14 @@ module Sendly
42
49
  status == "sending"
43
50
  end
44
51
 
52
+ # @return [Boolean] Whether the campaign has been sent (status +completed+)
45
53
  def sent?
46
- status == "sent"
54
+ %w[sent completed].include?(status)
55
+ end
56
+
57
+ # @return [Boolean] Whether the status is +completed+, which is what a sent campaign becomes
58
+ def completed?
59
+ status == "completed"
47
60
  end
48
61
 
49
62
  def cancelled?
@@ -73,6 +86,9 @@ module Sendly
73
86
  end
74
87
  end
75
88
 
89
+ # A campaign's recipient count and cost estimate. +id+ and
90
+ # +estimated_segments+ are not returned by the API (+nil+ and 0).
91
+ # +breakdown+ is per country: +{ "US" => { "count", "credits", "allowed" } }+.
76
92
  class CampaignPreview
77
93
  attr_reader :id, :recipient_count, :estimated_segments, :estimated_credits,
78
94
  :current_balance, :has_enough_credits, :breakdown,
@@ -85,7 +101,7 @@ module Sendly
85
101
  @estimated_credits = data["estimated_credits"] || data["estimatedCredits"] || 0
86
102
  @current_balance = data["current_balance"] || data["currentBalance"] || 0
87
103
  @has_enough_credits = data["has_enough_credits"] || data["hasEnoughCredits"] || false
88
- @breakdown = data["breakdown"]
104
+ @breakdown = data["breakdown"] || data["byCountry"]
89
105
  @blocked_count = data["blocked_count"] || data["blockedCount"]
90
106
  @sendable_count = data["sendable_count"] || data["sendableCount"]
91
107
  @warnings = data["warnings"]
@@ -97,6 +113,37 @@ module Sendly
97
113
  end
98
114
  end
99
115
 
116
+ # The result of {CampaignsResource#send_campaign}: the batch the campaign's
117
+ # messages went out in. +id+ is the campaign's ID, +status+ the batch's
118
+ # (+processing+, +completed+, +partial_failure+ or +failed+), and
119
+ # +recipient_count+ the messages in the batch. Name, text and dates are not
120
+ # part of the response; read the campaign with {CampaignsResource#get}.
121
+ class CampaignSendResult < Campaign
122
+ # @return [String] Batch the messages went out in; see {Sendly::Messages#get_batch}
123
+ attr_reader :batch_id
124
+
125
+ # @return [Integer] Credits returned for messages that failed
126
+ attr_reader :credits_refunded
127
+
128
+ # @return [Array<Hash>] Each message in the batch; empty while the batch is processing
129
+ attr_reader :messages
130
+
131
+ # @return [Hash] The raw parsed response
132
+ attr_reader :raw
133
+
134
+ def initialize(data, campaign_id = nil)
135
+ super(data)
136
+ @raw = data
137
+ @id = campaign_id || data["id"]
138
+ @batch_id = data["batchId"] || data["batch_id"]
139
+ @recipient_count = data["total"] || @recipient_count
140
+ @sent_count = data["sent"] || @sent_count
141
+ @failed_count = data["failed"] || @failed_count
142
+ @credits_refunded = data["creditsRefunded"] || data["credits_refunded"] || 0
143
+ @messages = data["messages"] || []
144
+ end
145
+ end
146
+
100
147
  class CampaignsResource
101
148
  def initialize(client)
102
149
  @client = client
@@ -155,9 +202,13 @@ module Sendly
155
202
  CampaignPreview.new(response)
156
203
  end
157
204
 
205
+ # Send a campaign now.
206
+ #
207
+ # @param id [String] Campaign ID
208
+ # @return [Sendly::CampaignSendResult] The batch the campaign went out in
158
209
  def send_campaign(id)
159
210
  response = @client.post("/campaigns/#{URI.encode_www_form_component(id)}/send")
160
- Campaign.new(response)
211
+ CampaignSendResult.new(response, id)
161
212
  end
162
213
 
163
214
  def schedule(id, scheduled_at:, timezone: nil)
data/lib/sendly/client.rb CHANGED
@@ -199,6 +199,17 @@ module Sendly
199
199
  @calls ||= CallsResource.new(self)
200
200
  end
201
201
 
202
+ # Access the Voice resource (numbers, AI agents and voices for phone calls)
203
+ #
204
+ # @return [Sendly::VoiceResource]
205
+ #
206
+ # @example
207
+ # agent = client.voice.agents.create(name: "Front desk")
208
+ # client.voice.numbers.update("+15555550188", voice_enabled: true, voice_mode: "agent", agent_id: agent.id)
209
+ def voice
210
+ @voice ||= VoiceResource.new(self)
211
+ end
212
+
202
213
  # Make a GET request
203
214
  #
204
215
  # @param path [String] API path
@@ -221,10 +232,11 @@ module Sendly
221
232
  # @param body [Hash] Request body
222
233
  # @param idempotency_key [String, nil] Caller-supplied idempotency key (optional)
223
234
  # @param auto_idempotency_key [Boolean] Auto-generate a key when none is supplied (default: true)
235
+ # @param retry_server_errors [Boolean] Retry a 5xx before raising it (default: true)
224
236
  # @return [Hash] Response body
225
- def post(path, body = {}, idempotency_key: nil, auto_idempotency_key: true)
237
+ def post(path, body = {}, idempotency_key: nil, auto_idempotency_key: true, retry_server_errors: true)
226
238
  request(:post, path, body: body, idempotency_key: idempotency_key,
227
- auto_idempotency_key: auto_idempotency_key)
239
+ auto_idempotency_key: auto_idempotency_key, retry_server_errors: retry_server_errors)
228
240
  end
229
241
 
230
242
  # Make a PATCH request
@@ -255,10 +267,14 @@ module Sendly
255
267
 
256
268
  # Make a DELETE request
257
269
  #
270
+ # No Idempotency-Key is generated for a DELETE; pass +idempotency_key+
271
+ # to send one (1-255 printable ASCII characters).
272
+ #
258
273
  # @param path [String] API path
274
+ # @param idempotency_key [String, nil] Caller-supplied idempotency key (optional)
259
275
  # @return [Hash] Response body
260
- def delete(path)
261
- request(:delete, path)
276
+ def delete(path, idempotency_key: nil)
277
+ request(:delete, path, idempotency_key: idempotency_key)
262
278
  end
263
279
 
264
280
  # Make a GET request against the API origin, bypassing the +/api/v1+ base.
@@ -297,8 +313,10 @@ module Sendly
297
313
  # @param content_type [String] MIME type of the file
298
314
  # @param filename [String] Name for the uploaded file
299
315
  # @param idempotency_key [String, nil] Caller-supplied idempotency key (optional)
316
+ # @param retry_server_errors [Boolean] Retry a 5xx before raising it (default: true)
300
317
  # @return [Hash] Response body
301
- def post_multipart(path, file, content_type: "image/jpeg", filename: "upload.jpg", idempotency_key: nil)
318
+ def post_multipart(path, file, content_type: "image/jpeg", filename: "upload.jpg", idempotency_key: nil,
319
+ retry_server_errors: true)
302
320
  uri = build_uri(path, {})
303
321
  http = build_http(uri)
304
322
 
@@ -308,10 +326,11 @@ module Sendly
308
326
  boundary = "SendlyRuby#{SecureRandom.hex(16)}"
309
327
 
310
328
  file_data = file.is_a?(String) ? File.binread(file) : file.read
329
+ safe_name = filename.to_s.gsub('"', "%22").gsub("\r", "%0D").gsub("\n", "%0A")
311
330
 
312
331
  body = []
313
332
  body << "--#{boundary}\r\n"
314
- body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\"\r\n"
333
+ body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{safe_name}\"\r\n"
315
334
  body << "Content-Type: #{content_type}\r\n\r\n"
316
335
  body << file_data
317
336
  body << "\r\n--#{boundary}--\r\n"
@@ -322,7 +341,7 @@ module Sendly
322
341
  req["User-Agent"] = "sendly-ruby/#{VERSION}"
323
342
  req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
324
343
  req["X-Organization-Id"] = @organization_id if @organization_id
325
- req.body = body.join
344
+ req.body = body.map { |part| part.to_s.b }.join
326
345
 
327
346
  attempt = 0
328
347
  begin
@@ -335,18 +354,14 @@ module Sendly
335
354
  raise NetworkError, "Connection failed: #{e.message}"
336
355
  rescue RateLimitError => e
337
356
  attempt += 1
338
- if attempt <= max_retries && e.retry_after
357
+ if attempt <= max_retries && e.retry_after && retryable_rate_limit?(e)
339
358
  sleep(e.retry_after)
340
359
  retry
341
360
  end
342
361
  raise
343
362
  rescue ServerError => e
344
363
  attempt += 1
345
- if attempt <= max_retries
346
- # A 5xx response may be cached under the key server-side, so an
347
- # auto-generated key is rotated to let the retry re-execute.
348
- # Caller-supplied keys are never rotated.
349
- key = generate_idempotency_key if explicit_key.nil?
364
+ if retry_server_errors && attempt <= max_retries
350
365
  sleep(2 ** attempt)
351
366
  retry
352
367
  end
@@ -363,6 +378,17 @@ module Sendly
363
378
 
364
379
  private
365
380
 
381
+ RETRYABLE_RATE_LIMIT_CODES = [
382
+ nil, "rate_limit_exceeded", "provision_rate_limit", "too_many_concurrent_verifications"
383
+ ].freeze
384
+ MAX_RETRY_WAIT_SECONDS = 60
385
+ private_constant :RETRYABLE_RATE_LIMIT_CODES, :MAX_RETRY_WAIT_SECONDS
386
+
387
+ def retryable_rate_limit?(error)
388
+ code = error.response_body.is_a?(Hash) ? error.response_body["error"] : nil
389
+ RETRYABLE_RATE_LIMIT_CODES.include?(code) && error.retry_after.to_f <= MAX_RETRY_WAIT_SECONDS
390
+ end
391
+
366
392
  def validate_api_key!
367
393
  raise AuthenticationError, "API key is required" if api_key.nil? || api_key.empty?
368
394
 
@@ -372,7 +398,7 @@ module Sendly
372
398
  end
373
399
 
374
400
  def request(method, path, params: {}, body: nil, unversioned: false, idempotency_key: nil,
375
- auto_idempotency_key: true)
401
+ auto_idempotency_key: true, retry_server_errors: true)
376
402
  uri = build_uri(path, params, unversioned: unversioned)
377
403
  http = build_http(uri)
378
404
  req = build_request(method, uri, body)
@@ -392,18 +418,14 @@ module Sendly
392
418
  raise NetworkError, "Connection failed: #{e.message}"
393
419
  rescue RateLimitError => e
394
420
  attempt += 1
395
- if attempt <= max_retries && e.retry_after
421
+ if attempt <= max_retries && e.retry_after && retryable_rate_limit?(e)
396
422
  sleep(e.retry_after)
397
423
  retry
398
424
  end
399
425
  raise
400
426
  rescue ServerError => e
401
427
  attempt += 1
402
- if attempt <= max_retries
403
- # A 5xx response may be cached under the key server-side, so an
404
- # auto-generated key is rotated to let the retry re-execute.
405
- # Caller-supplied keys are never rotated.
406
- key = generate_idempotency_key if key && explicit_key.nil?
428
+ if retry_server_errors && attempt <= max_retries
407
429
  sleep(2 ** attempt) # Exponential backoff
408
430
  retry
409
431
  end
@@ -429,6 +451,7 @@ module Sendly
429
451
  end
430
452
 
431
453
  def build_uri(path, params, unversioned: false)
454
+ validate_path_segments!(path)
432
455
  base = unversioned ? api_origin : base_url
433
456
  url = "#{base}#{path}"
434
457
  uri = URI.parse(url)
@@ -441,6 +464,16 @@ module Sendly
441
464
  uri
442
465
  end
443
466
 
467
+ DOT_SEGMENT = /\A(?:\.|%2e){1,2}\z/i
468
+ private_constant :DOT_SEGMENT
469
+
470
+ def validate_path_segments!(path)
471
+ segments = path.to_s.split("?", 2).first.to_s.split("/", -1).drop(1)
472
+ return unless segments.any? { |segment| segment.empty? || DOT_SEGMENT.match?(segment) }
473
+
474
+ raise ValidationError, "IDs in a request path cannot be empty, '.' or '..'"
475
+ end
476
+
444
477
  # Derive the bare API origin (scheme + host [+ non-default port]) from the
445
478
  # configured base URL, dropping its path. Origin-level endpoints such as
446
479
  # the URL shortener at +/api/links+ hang off this, not the +/api/v1+ base.
@@ -491,7 +524,8 @@ module Sendly
491
524
 
492
525
  return body if status >= 200 && status < 300
493
526
 
494
- raise ErrorFactory.from_response(status, body)
527
+ retry_after = status == 429 ? response["Retry-After"] : nil
528
+ raise ErrorFactory.from_response(status, body, retry_after_header: retry_after)
495
529
  end
496
530
 
497
531
  def parse_body(body)
@@ -30,11 +30,23 @@ module Sendly
30
30
  ConversationWithMessages.new(response)
31
31
  end
32
32
 
33
- def reply(id, text:, media_urls: nil, metadata: nil)
33
+ # Reply in a conversation. Send +text+, +media_urls+, or both.
34
+ #
35
+ # @param id [String] Conversation ID
36
+ # @param text [String, nil] Message text
37
+ # @param media_urls [Array<String>, nil] Media to attach (sent as MMS)
38
+ # @param metadata [Hash, nil] Custom metadata
39
+ # @return [Sendly::Message] The sent message
40
+ # @raise [Sendly::ValidationError] If +id+ is missing, or there is neither text nor media
41
+ def reply(id, text: nil, media_urls: nil, metadata: nil)
34
42
  raise ValidationError, "Conversation ID is required" if id.nil? || id.empty?
35
- raise ValidationError, "Message text is required" if text.nil? || text.empty?
36
43
 
37
- body = { text: text }
44
+ has_text = !(text.nil? || text.empty?)
45
+ has_media = media_urls.is_a?(Array) && !media_urls.empty?
46
+ raise ValidationError, "Provide 'text' or 'media_urls'" unless has_text || has_media
47
+
48
+ body = {}
49
+ body[:text] = text if has_text
38
50
  body[:mediaUrls] = media_urls if media_urls
39
51
  body[:metadata] = metadata if metadata
40
52
 
@@ -123,9 +135,9 @@ module Sendly
123
135
  page = list(limit: batch_size, offset: offset, status: status)
124
136
  page.each(&block)
125
137
 
126
- break unless page.has_more
138
+ break if !page.has_more || page.count.zero?
127
139
 
128
- offset += batch_size
140
+ offset += page.count
129
141
  end
130
142
  end
131
143
  end
@@ -24,7 +24,8 @@ module Sendly
24
24
  params[:offset] = offset if offset
25
25
 
26
26
  response = @client.get("/drafts", params.compact)
27
- DraftList.new(response)
27
+ page_size = limit.to_s.empty? ? 50 : [limit.to_s.to_i, 100].min
28
+ DraftList.new(response, page_size, offset.to_s.to_i)
28
29
  end
29
30
 
30
31
  def get(id)
@@ -102,13 +102,23 @@ module Sendly
102
102
  submit_verification(workspace_id, **partial_updates)
103
103
  end
104
104
 
105
- def inherit_verification(workspace_id, source_workspace_id:)
105
+ # Give a workspace the verification of another workspace you own.
106
+ #
107
+ # By default the workspace shares the source's verification and sending
108
+ # number. Pass +purchase_new_number: true+ to copy only the business
109
+ # details and buy the workspace its own toll-free number; the response
110
+ # then has +"newNumber" => true+.
111
+ #
112
+ # client.enterprise.workspaces.inherit_verification(workspace_id,
113
+ # source_workspace_id: source_id, purchase_new_number: true)
114
+ def inherit_verification(workspace_id, source_workspace_id:, purchase_new_number: nil)
106
115
  raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
107
116
  raise ArgumentError, "Source workspace ID is required" if source_workspace_id.nil? || source_workspace_id.empty?
108
117
 
109
- @client.post("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/verification/inherit", {
110
- source_workspace_id: source_workspace_id
111
- })
118
+ body = { sourceWorkspaceId: source_workspace_id }
119
+ body[:purchaseNewNumber] = purchase_new_number unless purchase_new_number.nil?
120
+
121
+ @client.post("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/verification/inherit", body)
112
122
  end
113
123
 
114
124
  def get_verification(workspace_id)
@@ -134,12 +144,13 @@ module Sendly
134
144
  @client.get("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/credits")
135
145
  end
136
146
 
137
- def create_key(workspace_id, name: nil, type: nil)
147
+ def create_key(workspace_id, name: nil, type: nil, scopes: nil)
138
148
  raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
149
+ raise ArgumentError, "Key name is required" if name.nil? || name.to_s.empty?
139
150
 
140
- body = {}
141
- body[:name] = name if name
151
+ body = { name: name }
142
152
  body[:type] = type if type
153
+ body[:scopes] = scopes unless scopes.nil?
143
154
 
144
155
  @client.post("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/keys", body)
145
156
  end
@@ -245,7 +256,7 @@ module Sendly
245
256
 
246
257
  def provision_bulk(workspaces)
247
258
  raise ArgumentError, "Workspaces array is required" if workspaces.nil? || !workspaces.is_a?(Array) || workspaces.empty?
248
- raise ArgumentError, "Maximum 50 workspaces per bulk provision" if workspaces.length > 50
259
+ raise ArgumentError, "Maximum 100 workspaces per bulk provision" if workspaces.length > 100
249
260
 
250
261
  @client.post("/enterprise/workspaces/provision/bulk", { workspaces: workspaces })
251
262
  end
@@ -470,7 +481,7 @@ module Sendly
470
481
  else "application/octet-stream"
471
482
  end
472
483
 
473
- filename = File.basename(file_path)
484
+ filename = File.basename(file_path).gsub('"', "%22").gsub("\r", "%0D").gsub("\n", "%0A")
474
485
 
475
486
  boundary = "SendlyRuby#{SecureRandom.hex(16)}"
476
487
  body_parts = []
@@ -507,7 +518,7 @@ module Sendly
507
518
  req["X-Organization-Id"] = @client.organization_id if @client.organization_id
508
519
  # Single-use auto key (this path has no retry loop).
509
520
  req["Idempotency-Key"] = @client.generate_idempotency_key
510
- req.body = body_parts.join
521
+ req.body = body_parts.map { |part| part.to_s.b }.join
511
522
 
512
523
  response = http.request(req)
513
524
  body = response.body.nil? || response.body.empty? ? {} : JSON.parse(response.body)