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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -0
- data/Gemfile.lock +1 -1
- data/README.md +742 -148
- data/examples/list_messages.rb +1 -1
- data/examples/send_sms.rb +6 -2
- data/lib/sendly/account_resource.rb +16 -4
- data/lib/sendly/business_upgrade_resource.rb +3 -2
- data/lib/sendly/calls_resource.rb +19 -8
- data/lib/sendly/campaigns_resource.rb +59 -8
- data/lib/sendly/client.rb +38 -19
- data/lib/sendly/conversations_resource.rb +17 -5
- data/lib/sendly/drafts_resource.rb +2 -1
- data/lib/sendly/enterprise.rb +21 -10
- data/lib/sendly/errors.rb +11 -2
- data/lib/sendly/messages.rb +31 -11
- data/lib/sendly/types.rb +165 -26
- data/lib/sendly/version.rb +1 -1
- data/lib/sendly/webhooks_resource.rb +30 -7
- data/lib/sendly/whatsapp_resource.rb +595 -44
- metadata +2 -2
data/examples/list_messages.rb
CHANGED
|
@@ -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: "+
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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. +
|
|
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
|
|
235
|
-
# {Sendly::InsufficientCreditsError} for
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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=\"#{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
138
|
+
break if !page.has_more || page.count.zero?
|
|
127
139
|
|
|
128
|
-
offset +=
|
|
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
|
-
|
|
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)
|
data/lib/sendly/enterprise.rb
CHANGED
|
@@ -102,13 +102,23 @@ module Sendly
|
|
|
102
102
|
submit_verification(workspace_id, **partial_updates)
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
|
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
|