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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +98 -0
- data/Gemfile.lock +3 -3
- data/README.md +829 -150
- 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 +29 -14
- data/lib/sendly/campaigns_resource.rb +59 -8
- data/lib/sendly/client.rb +55 -21
- 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 +46 -18
- data/lib/sendly/messages.rb +31 -11
- data/lib/sendly/numbers_resource.rb +2 -1
- data/lib/sendly/templates_resource.rb +0 -4
- data/lib/sendly/types.rb +165 -26
- data/lib/sendly/version.rb +1 -1
- data/lib/sendly/voice_resource.rb +721 -0
- data/lib/sendly/webhooks_resource.rb +30 -7
- data/lib/sendly/whatsapp_resource.rb +595 -44
- data/lib/sendly.rb +1 -0
- metadata +3 -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
|
|
@@ -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,
|
|
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
|
|
217
|
-
#
|
|
218
|
-
#
|
|
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
|
|
232
|
-
# {Sendly::InsufficientCreditsError} for
|
|
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
|
|
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
|
-
|
|
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
|
@@ -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=\"#{
|
|
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
|
-
|
|
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
|
-
|
|
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)
|