sendly 4.2.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
@@ -60,12 +66,13 @@ module Sendly
60
66
  insufficient_credits invalid_number rate_limit_exceeded forbidden
61
67
  invalid_request insufficient_permissions agent_in_use agent_limit invalid_voice_mode
62
68
  invalid_address e911_not_applicable voice_attach_failed carrier_refused
69
+ from_number_not_supported
63
70
  ].freeze
64
71
 
65
72
  attr_reader :id, :object, :kind, :direction, :status, :handled_by, :agent_id,
66
73
  :from, :to, :caller_name, :callee_name, :started_at, :answered_at,
67
74
  :ended_at, :duration_secs, :credits_charged, :billing, :hangup_class,
68
- :recording_status, :metadata, :transcript
75
+ :recording_status, :metadata, :transcript, :channel
69
76
 
70
77
  # @return [Hash] The raw parsed response
71
78
  attr_reader :raw
@@ -76,6 +83,7 @@ module Sendly
76
83
  @id = data["id"]
77
84
  @object = data["object"] || "call"
78
85
  @kind = data["kind"]
86
+ @channel = data["channel"]
79
87
  @direction = data["direction"]
80
88
  @status = data["status"]
81
89
  @handled_by = data["handledBy"] || data["handled_by"]
@@ -125,7 +133,7 @@ module Sendly
125
133
 
126
134
  def to_h
127
135
  {
128
- id: id, object: object, kind: kind, direction: direction, status: status,
136
+ id: id, object: object, kind: kind, channel: channel, direction: direction, status: status,
129
137
  handled_by: handled_by, agent_id: agent_id, from: from, to: to,
130
138
  caller_name: caller_name, callee_name: callee_name, started_at: started_at,
131
139
  answered_at: answered_at, ended_at: ended_at, duration_secs: duration_secs,
@@ -231,9 +239,9 @@ module Sendly
231
239
  # +voice_not_enabled+, +outbound_calls_not_enabled+, +agent_not_found+,
232
240
  # +number_not_found+ and +call_not_found+; {Sendly::ValidationError} for
233
241
  # +invalid_number+, +destination_not_supported+, +agent_required+,
234
- # +invalid_metadata+ and +from_number_required+;
235
- # {Sendly::InsufficientCreditsError} for +insufficient_credits+;
236
- # {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
237
245
  # +rate_limit_exceeded+; {Sendly::APIError} with the HTTP status for
238
246
  # +e911_required+ (428), +agent_disabled+ / +no_voice_number+ /
239
247
  # +lines_busy+ (409) and +live_key_required+ / +forbidden+ (403); and
@@ -264,7 +272,9 @@ module Sendly
264
272
  # @param agent_id [String] The AI agent that talks on the call
265
273
  # @param from [String, nil] A voice-enabled number in your workspace.
266
274
  # Optional when the workspace has exactly one; required (the API
267
- # 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).
268
278
  # @param context [String, nil] Up to 2000 characters appended to the
269
279
  # agent's instructions for this call only. Not echoed back.
270
280
  # @param metadata [Hash{String => String}, nil] Up to 20 string pairs
@@ -275,7 +285,8 @@ module Sendly
275
285
  # @return [Sendly::Call] The new call (+status+ "ringing", +handled_by+ "agent")
276
286
  # @raise [Sendly::ValidationError] If +to+ or +agent_id+ is missing, or
277
287
  # HTTP 400 (+invalid_number+, +destination_not_supported+,
278
- # +agent_required+, +invalid_metadata+, +from_number_required+)
288
+ # +agent_required+, +invalid_metadata+, +from_number_required+,
289
+ # +from_number_not_supported+)
279
290
  # @raise [Sendly::NotFoundError] HTTP 404 (+voice_not_enabled+,
280
291
  # +outbound_calls_not_enabled+, +agent_not_found+, +number_not_found+)
281
292
  # @raise [Sendly::InsufficientCreditsError] HTTP 402 when the balance
@@ -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
@@ -232,10 +232,11 @@ module Sendly
232
232
  # @param body [Hash] Request body
233
233
  # @param idempotency_key [String, nil] Caller-supplied idempotency key (optional)
234
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)
235
236
  # @return [Hash] Response body
236
- 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)
237
238
  request(:post, path, body: body, idempotency_key: idempotency_key,
238
- auto_idempotency_key: auto_idempotency_key)
239
+ auto_idempotency_key: auto_idempotency_key, retry_server_errors: retry_server_errors)
239
240
  end
240
241
 
241
242
  # Make a PATCH request
@@ -312,8 +313,10 @@ module Sendly
312
313
  # @param content_type [String] MIME type of the file
313
314
  # @param filename [String] Name for the uploaded file
314
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)
315
317
  # @return [Hash] Response body
316
- 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)
317
320
  uri = build_uri(path, {})
318
321
  http = build_http(uri)
319
322
 
@@ -323,10 +326,11 @@ module Sendly
323
326
  boundary = "SendlyRuby#{SecureRandom.hex(16)}"
324
327
 
325
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")
326
330
 
327
331
  body = []
328
332
  body << "--#{boundary}\r\n"
329
- body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\"\r\n"
333
+ body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{safe_name}\"\r\n"
330
334
  body << "Content-Type: #{content_type}\r\n\r\n"
331
335
  body << file_data
332
336
  body << "\r\n--#{boundary}--\r\n"
@@ -337,7 +341,7 @@ module Sendly
337
341
  req["User-Agent"] = "sendly-ruby/#{VERSION}"
338
342
  req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
339
343
  req["X-Organization-Id"] = @organization_id if @organization_id
340
- req.body = body.join
344
+ req.body = body.map { |part| part.to_s.b }.join
341
345
 
342
346
  attempt = 0
343
347
  begin
@@ -350,18 +354,14 @@ module Sendly
350
354
  raise NetworkError, "Connection failed: #{e.message}"
351
355
  rescue RateLimitError => e
352
356
  attempt += 1
353
- if attempt <= max_retries && e.retry_after
357
+ if attempt <= max_retries && e.retry_after && retryable_rate_limit?(e)
354
358
  sleep(e.retry_after)
355
359
  retry
356
360
  end
357
361
  raise
358
362
  rescue ServerError => e
359
363
  attempt += 1
360
- if attempt <= max_retries
361
- # A 5xx response may be cached under the key server-side, so an
362
- # auto-generated key is rotated to let the retry re-execute.
363
- # Caller-supplied keys are never rotated.
364
- key = generate_idempotency_key if explicit_key.nil?
364
+ if retry_server_errors && attempt <= max_retries
365
365
  sleep(2 ** attempt)
366
366
  retry
367
367
  end
@@ -378,6 +378,17 @@ module Sendly
378
378
 
379
379
  private
380
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
+
381
392
  def validate_api_key!
382
393
  raise AuthenticationError, "API key is required" if api_key.nil? || api_key.empty?
383
394
 
@@ -387,7 +398,7 @@ module Sendly
387
398
  end
388
399
 
389
400
  def request(method, path, params: {}, body: nil, unversioned: false, idempotency_key: nil,
390
- auto_idempotency_key: true)
401
+ auto_idempotency_key: true, retry_server_errors: true)
391
402
  uri = build_uri(path, params, unversioned: unversioned)
392
403
  http = build_http(uri)
393
404
  req = build_request(method, uri, body)
@@ -407,18 +418,14 @@ module Sendly
407
418
  raise NetworkError, "Connection failed: #{e.message}"
408
419
  rescue RateLimitError => e
409
420
  attempt += 1
410
- if attempt <= max_retries && e.retry_after
421
+ if attempt <= max_retries && e.retry_after && retryable_rate_limit?(e)
411
422
  sleep(e.retry_after)
412
423
  retry
413
424
  end
414
425
  raise
415
426
  rescue ServerError => e
416
427
  attempt += 1
417
- if attempt <= max_retries
418
- # A 5xx response may be cached under the key server-side, so an
419
- # auto-generated key is rotated to let the retry re-execute.
420
- # Caller-supplied keys are never rotated.
421
- key = generate_idempotency_key if key && explicit_key.nil?
428
+ if retry_server_errors && attempt <= max_retries
422
429
  sleep(2 ** attempt) # Exponential backoff
423
430
  retry
424
431
  end
@@ -444,6 +451,7 @@ module Sendly
444
451
  end
445
452
 
446
453
  def build_uri(path, params, unversioned: false)
454
+ validate_path_segments!(path)
447
455
  base = unversioned ? api_origin : base_url
448
456
  url = "#{base}#{path}"
449
457
  uri = URI.parse(url)
@@ -456,6 +464,16 @@ module Sendly
456
464
  uri
457
465
  end
458
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
+
459
477
  # Derive the bare API origin (scheme + host [+ non-default port]) from the
460
478
  # configured base URL, dropping its path. Origin-level endpoints such as
461
479
  # the URL shortener at +/api/links+ hang off this, not the +/api/v1+ base.
@@ -506,7 +524,8 @@ module Sendly
506
524
 
507
525
  return body if status >= 200 && status < 300
508
526
 
509
- 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)
510
529
  end
511
530
 
512
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)
data/lib/sendly/errors.rb CHANGED
@@ -114,7 +114,7 @@ module Sendly
114
114
 
115
115
  # Convert API response to appropriate error
116
116
  class ErrorFactory
117
- def self.from_response(status, body)
117
+ def self.from_response(status, body, retry_after_header: nil)
118
118
  message = body["message"] || body["error"] || "Unknown error"
119
119
  code = body["code"]
120
120
  details = body["details"]
@@ -129,7 +129,7 @@ module Sendly
129
129
  when 404
130
130
  NotFoundError.new(message)
131
131
  when 429
132
- retry_after = body["retry_after"] || body["retryAfter"]
132
+ retry_after = parse_retry_after(retry_after_header) || body["retry_after"] || body["retryAfter"]
133
133
  RateLimitError.new(message, retry_after: retry_after)
134
134
  when 500..599
135
135
  ServerError.new(message, status_code: status)
@@ -138,5 +138,14 @@ module Sendly
138
138
  end
139
139
  error.with_response_body(body)
140
140
  end
141
+
142
+ def self.parse_retry_after(value)
143
+ text = value.to_s.strip
144
+ return text.to_i if text.match?(/\A\d+\z/)
145
+ return text.to_f if text.match?(/\A\d+\.\d+\z/)
146
+
147
+ nil
148
+ end
149
+ private_class_method :parse_retry_after
141
150
  end
142
151
  end