sendly 4.0.0 → 4.2.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 +34 -0
- data/Gemfile.lock +3 -3
- data/README.md +168 -0
- data/lib/sendly/calls_resource.rb +413 -0
- data/lib/sendly/client.rb +24 -2
- data/lib/sendly/errors.rb +36 -17
- data/lib/sendly/numbers_resource.rb +23 -2
- data/lib/sendly/templates_resource.rb +0 -4
- data/lib/sendly/version.rb +1 -1
- data/lib/sendly/voice_resource.rb +721 -0
- data/lib/sendly/webhooks.rb +4 -0
- data/lib/sendly.rb +2 -0
- metadata +4 -2
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sendly
|
|
4
|
+
# A street address registered for emergency calls. +unit+ is +nil+ when
|
|
5
|
+
# there is none, +state+ is the two-letter state or province code and
|
|
6
|
+
# +country+ is "US" or "CA".
|
|
7
|
+
class EmergencyAddress
|
|
8
|
+
attr_reader :street, :unit, :city, :state, :zip, :country
|
|
9
|
+
|
|
10
|
+
def initialize(data)
|
|
11
|
+
data ||= {}
|
|
12
|
+
@street = data["street"]
|
|
13
|
+
@unit = data["unit"]
|
|
14
|
+
@city = data["city"]
|
|
15
|
+
@state = data["state"]
|
|
16
|
+
@zip = data["zip"]
|
|
17
|
+
@country = data["country"]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_h
|
|
21
|
+
{ street: street, unit: unit, city: city, state: state, zip: zip, country: country }.compact
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A number's emergency address registration. +status+ is "provisioning"
|
|
26
|
+
# while the registration is being switched on, "active" once it is in
|
|
27
|
+
# place, and otherwise the failure status as recorded. +address+ is +nil+
|
|
28
|
+
# when no address is on file.
|
|
29
|
+
class VoiceNumberEmergencyAddress
|
|
30
|
+
attr_reader :status, :address
|
|
31
|
+
|
|
32
|
+
def initialize(data)
|
|
33
|
+
data ||= {}
|
|
34
|
+
@status = data["status"]
|
|
35
|
+
@address = data["address"] ? EmergencyAddress.new(data["address"]) : nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def active?
|
|
39
|
+
status == "active"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_h
|
|
43
|
+
{ status: status, address: address&.to_h }.compact
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Credits charged per started minute on a number. +inbound+ is an inbound
|
|
48
|
+
# call the team answers in the dashboard, +outbound+ an outbound call (an
|
|
49
|
+
# agent on the call adds its own per-minute charge) and +agent+ an inbound
|
|
50
|
+
# call an AI agent answers, agent included.
|
|
51
|
+
class VoiceNumberRates
|
|
52
|
+
attr_reader :inbound, :outbound, :agent
|
|
53
|
+
|
|
54
|
+
def initialize(data)
|
|
55
|
+
data ||= {}
|
|
56
|
+
@inbound = data["inbound"]
|
|
57
|
+
@outbound = data["outbound"]
|
|
58
|
+
@agent = data["agent"]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def to_h
|
|
62
|
+
{ inbound: inbound, outbound: outbound, agent: agent }.compact
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A number in your workspace with its voice settings.
|
|
67
|
+
#
|
|
68
|
+
# +voice_mode+ is one of {VOICE_MODES} and reports how inbound calls are
|
|
69
|
+
# answered: "none" when calls are not answered (always so when voice is off),
|
|
70
|
+
# "ring_dashboard" when calls ring the team in the dashboard, and "agent"
|
|
71
|
+
# when an AI agent answers. +agent_id+ is the agent that answers in
|
|
72
|
+
# "agent" mode; in the other modes it is whichever agent was last stored,
|
|
73
|
+
# or +nil+. +emergency_address+ is +nil+ until one is registered.
|
|
74
|
+
# +phone_number_type+ (for example "local") and +country_code+ can be
|
|
75
|
+
# +nil+.
|
|
76
|
+
class VoiceNumber
|
|
77
|
+
VOICE_MODES = %w[none ring_dashboard agent].freeze
|
|
78
|
+
|
|
79
|
+
attr_reader :id, :object, :phone_number, :phone_number_type, :country_code,
|
|
80
|
+
:is_default, :voice_enabled, :voice_mode, :agent_id,
|
|
81
|
+
:emergency_address, :rate_per_minute
|
|
82
|
+
|
|
83
|
+
# @return [Hash] The raw parsed response
|
|
84
|
+
attr_reader :raw
|
|
85
|
+
|
|
86
|
+
def initialize(data)
|
|
87
|
+
data ||= {}
|
|
88
|
+
@raw = data
|
|
89
|
+
@id = data["id"]
|
|
90
|
+
@object = data["object"] || "voice_number"
|
|
91
|
+
@phone_number = data["phoneNumber"] || data["phone_number"]
|
|
92
|
+
@phone_number_type = data["phoneNumberType"] || data["phone_number_type"]
|
|
93
|
+
@country_code = data["countryCode"] || data["country_code"]
|
|
94
|
+
@is_default = data.key?("isDefault") ? data["isDefault"] : data["is_default"]
|
|
95
|
+
@voice_enabled = data.key?("voiceEnabled") ? data["voiceEnabled"] : data["voice_enabled"]
|
|
96
|
+
@voice_mode = data["voiceMode"] || data["voice_mode"]
|
|
97
|
+
@agent_id = data["agentId"] || data["agent_id"]
|
|
98
|
+
address = data["emergencyAddress"] || data["emergency_address"]
|
|
99
|
+
@emergency_address = address ? VoiceNumberEmergencyAddress.new(address) : nil
|
|
100
|
+
rates = data["ratePerMinute"] || data["rate_per_minute"]
|
|
101
|
+
@rate_per_minute = rates ? VoiceNumberRates.new(rates) : nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @return [Boolean] Whether this is the workspace's default sending number
|
|
105
|
+
def default?
|
|
106
|
+
is_default == true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @return [Boolean] Whether the number takes and places phone calls
|
|
110
|
+
def voice_enabled?
|
|
111
|
+
voice_enabled == true
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def to_h
|
|
115
|
+
{
|
|
116
|
+
id: id, object: object, phone_number: phone_number,
|
|
117
|
+
phone_number_type: phone_number_type, country_code: country_code,
|
|
118
|
+
is_default: is_default, voice_enabled: voice_enabled, voice_mode: voice_mode,
|
|
119
|
+
agent_id: agent_id, emergency_address: emergency_address&.to_h,
|
|
120
|
+
rate_per_minute: rate_per_minute&.to_h
|
|
121
|
+
}.compact
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# What an agent may do on a call.
|
|
126
|
+
#
|
|
127
|
+
# +send_sms+ is true when the agent may text the caller; it confirms the
|
|
128
|
+
# number back to them before sending. +transfer_to+ is an E.164 number
|
|
129
|
+
# for callers who need a person, or +nil+. Agents cannot transfer calls
|
|
130
|
+
# yet and never dial or read out this number: while it is set, a caller
|
|
131
|
+
# who asks for a person is told their message will be passed on, and the
|
|
132
|
+
# agent takes their name and number.
|
|
133
|
+
class VoiceAgentTools
|
|
134
|
+
attr_reader :send_sms, :transfer_to
|
|
135
|
+
|
|
136
|
+
def initialize(data)
|
|
137
|
+
data ||= {}
|
|
138
|
+
@send_sms = data.key?("sendSms") ? data["sendSms"] : data["send_sms"]
|
|
139
|
+
@transfer_to = data["transferTo"] || data["transfer_to"]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def send_sms?
|
|
143
|
+
send_sms == true
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def to_h
|
|
147
|
+
{ send_sms: send_sms, transfer_to: transfer_to }.compact
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# An AI agent that answers and places phone calls.
|
|
152
|
+
#
|
|
153
|
+
# +voice+ is a voice id from {VoiceVoicesResource#list} and +voice_label+
|
|
154
|
+
# its readable name. +greeting+ and +instructions+ are +""+ when unset.
|
|
155
|
+
# +can_send_sms+ is true when the agent holds its own scoped sending key,
|
|
156
|
+
# which +tools.send_sms+ needs to actually send. +calls_handled+ and
|
|
157
|
+
# +avg_duration_secs+ are the agent's call stats. Timestamps are ISO 8601
|
|
158
|
+
# strings.
|
|
159
|
+
class VoiceAgent
|
|
160
|
+
attr_reader :id, :object, :name, :enabled, :voice, :voice_label, :language,
|
|
161
|
+
:greeting, :instructions, :tools, :can_send_sms, :calls_handled,
|
|
162
|
+
:avg_duration_secs, :created_at, :updated_at
|
|
163
|
+
|
|
164
|
+
# @return [Hash] The raw parsed response
|
|
165
|
+
attr_reader :raw
|
|
166
|
+
|
|
167
|
+
def initialize(data)
|
|
168
|
+
data ||= {}
|
|
169
|
+
@raw = data
|
|
170
|
+
@id = data["id"]
|
|
171
|
+
@object = data["object"] || "voice_agent"
|
|
172
|
+
@name = data["name"]
|
|
173
|
+
@enabled = data["enabled"]
|
|
174
|
+
@voice = data["voice"]
|
|
175
|
+
@voice_label = data["voiceLabel"] || data["voice_label"]
|
|
176
|
+
@language = data["language"]
|
|
177
|
+
@greeting = data["greeting"]
|
|
178
|
+
@instructions = data["instructions"]
|
|
179
|
+
@tools = VoiceAgentTools.new(data["tools"])
|
|
180
|
+
@can_send_sms = data.key?("canSendSms") ? data["canSendSms"] : data["can_send_sms"]
|
|
181
|
+
@calls_handled = data["callsHandled"] || data["calls_handled"] || 0
|
|
182
|
+
@avg_duration_secs = data["avgDurationSecs"] || data["avg_duration_secs"] || 0
|
|
183
|
+
@created_at = data["createdAt"] || data["created_at"]
|
|
184
|
+
@updated_at = data["updatedAt"] || data["updated_at"]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# @return [Boolean] Whether the agent is switched on. A switched-off agent
|
|
188
|
+
# can't be pointed at a number or put on a call.
|
|
189
|
+
def enabled?
|
|
190
|
+
enabled == true
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# @return [Boolean] Whether the agent holds its scoped sending key
|
|
194
|
+
def can_send_sms?
|
|
195
|
+
can_send_sms == true
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def to_h
|
|
199
|
+
{
|
|
200
|
+
id: id, object: object, name: name, enabled: enabled, voice: voice,
|
|
201
|
+
voice_label: voice_label, language: language, greeting: greeting,
|
|
202
|
+
instructions: instructions, tools: tools.to_h, can_send_sms: can_send_sms,
|
|
203
|
+
calls_handled: calls_handled, avg_duration_secs: avg_duration_secs,
|
|
204
|
+
created_at: created_at, updated_at: updated_at
|
|
205
|
+
}.compact
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# A voice an agent can speak with. Pass +id+ as +voice:+ when creating or
|
|
210
|
+
# updating an agent; +language+ is the language it speaks, e.g. "en".
|
|
211
|
+
class Voice
|
|
212
|
+
attr_reader :id, :label, :language
|
|
213
|
+
|
|
214
|
+
def initialize(data)
|
|
215
|
+
data ||= {}
|
|
216
|
+
@id = data["id"]
|
|
217
|
+
@label = data["label"]
|
|
218
|
+
@language = data["language"]
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def to_h
|
|
222
|
+
{ id: id, label: label, language: language }.compact
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# The confirmation {VoiceAgentsResource#delete} returns.
|
|
227
|
+
class DeletedVoiceAgent
|
|
228
|
+
attr_reader :id, :object, :deleted
|
|
229
|
+
|
|
230
|
+
def initialize(data)
|
|
231
|
+
data ||= {}
|
|
232
|
+
@id = data["id"]
|
|
233
|
+
@object = data["object"] || "voice_agent"
|
|
234
|
+
@deleted = data["deleted"] == true
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def deleted?
|
|
238
|
+
deleted
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def to_h
|
|
242
|
+
{ id: id, object: object, deleted: deleted }
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# The +data+ array a voice list method returns, as an Enumerable of
|
|
247
|
+
# model objects.
|
|
248
|
+
class VoiceDataList
|
|
249
|
+
include Enumerable
|
|
250
|
+
|
|
251
|
+
attr_reader :data
|
|
252
|
+
|
|
253
|
+
def initialize(response, model)
|
|
254
|
+
@data = ((response || {})["data"] || []).map { |item| model.new(item) }
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def each(&block)
|
|
258
|
+
data.each(&block)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def size
|
|
262
|
+
data.length
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
alias length size
|
|
266
|
+
|
|
267
|
+
def empty?
|
|
268
|
+
data.empty?
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def last
|
|
272
|
+
data.last
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# The workspace's active numbers with their voice settings, as {VoiceNumber}s.
|
|
277
|
+
class VoiceNumberList < VoiceDataList
|
|
278
|
+
def initialize(response)
|
|
279
|
+
super(response, VoiceNumber)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# The workspace's AI agents, as {VoiceAgent}s.
|
|
284
|
+
class VoiceAgentList < VoiceDataList
|
|
285
|
+
def initialize(response)
|
|
286
|
+
super(response, VoiceAgent)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# The voices an agent can speak with, as {Voice}s.
|
|
291
|
+
class VoiceList < VoiceDataList
|
|
292
|
+
def initialize(response)
|
|
293
|
+
super(response, Voice)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Shapes voice input before it leaves the process.
|
|
298
|
+
#
|
|
299
|
+
# @api private
|
|
300
|
+
module VoiceInput
|
|
301
|
+
OMIT = Object.new.freeze
|
|
302
|
+
TOOL_KEYS = { "send_sms" => "sendSms", "transfer_to" => "transferTo" }.freeze
|
|
303
|
+
|
|
304
|
+
module_function
|
|
305
|
+
|
|
306
|
+
def require_text!(value, message)
|
|
307
|
+
raise ValidationError, message if value.nil? || value.to_s.strip.empty?
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def number_path(number)
|
|
311
|
+
require_text!(number, "number is required")
|
|
312
|
+
"/voice/numbers/#{URI.encode_www_form_component(number)}"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def agent_path(id)
|
|
316
|
+
require_text!(id, "Agent ID is required")
|
|
317
|
+
"/voice/agents/#{URI.encode_www_form_component(id)}"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def tools_body(tools)
|
|
321
|
+
return tools unless tools.is_a?(Hash)
|
|
322
|
+
|
|
323
|
+
tools.each_with_object({}) { |(key, value), out| out[TOOL_KEYS.fetch(key.to_s, key.to_s)] = value }
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def agent_body(name:, enabled:, voice:, language:, greeting:, instructions:, tools:)
|
|
327
|
+
body = {}
|
|
328
|
+
body[:name] = name unless name.nil?
|
|
329
|
+
body[:enabled] = enabled unless enabled.nil?
|
|
330
|
+
body[:voice] = voice unless voice.nil?
|
|
331
|
+
body[:language] = language unless language.nil?
|
|
332
|
+
body[:greeting] = greeting unless greeting.nil?
|
|
333
|
+
body[:instructions] = instructions unless instructions.nil?
|
|
334
|
+
body[:tools] = tools_body(tools) unless tools.nil?
|
|
335
|
+
body
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Voice settings for the numbers in your workspace: switch voice on for a
|
|
340
|
+
# number, choose how it answers, and register its emergency address.
|
|
341
|
+
# Reached as +client.voice.numbers+.
|
|
342
|
+
class VoiceNumbersResource
|
|
343
|
+
def initialize(client)
|
|
344
|
+
@client = client
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# List the workspace's active numbers with their voice settings, in the
|
|
348
|
+
# same order as the dashboard. Requires the +calls:read+ scope.
|
|
349
|
+
#
|
|
350
|
+
# @return [Sendly::VoiceNumberList]
|
|
351
|
+
#
|
|
352
|
+
# @example
|
|
353
|
+
# client.voice.numbers.list.each do |n|
|
|
354
|
+
# puts "#{n.phone_number} #{n.voice_mode} #{n.emergency_address&.status || 'no emergency address'}"
|
|
355
|
+
# end
|
|
356
|
+
def list
|
|
357
|
+
response = @client.get("/voice/numbers")
|
|
358
|
+
VoiceNumberList.new(response)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Fetch one number's voice settings. Requires the +calls:read+ scope.
|
|
362
|
+
#
|
|
363
|
+
# @param number [String] The number's id or its E.164 phone number
|
|
364
|
+
# @return [Sendly::VoiceNumber]
|
|
365
|
+
# @raise [Sendly::ValidationError] If +number+ is blank
|
|
366
|
+
# @raise [Sendly::NotFoundError] HTTP 404 +number_not_found+ when the
|
|
367
|
+
# number is not active in your workspace
|
|
368
|
+
#
|
|
369
|
+
# @example
|
|
370
|
+
# number = client.voice.numbers.get("+15555550188")
|
|
371
|
+
# puts number.voice_mode
|
|
372
|
+
# puts number.rate_per_minute.agent
|
|
373
|
+
def get(number)
|
|
374
|
+
response = @client.get(VoiceInput.number_path(number))
|
|
375
|
+
VoiceNumber.new(response)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Change how a number answers phone calls. Requires the +calls:write+
|
|
379
|
+
# scope and a live key; in a team workspace, also a role that can change
|
|
380
|
+
# settings.
|
|
381
|
+
#
|
|
382
|
+
# This changes what happens when real people call the number. Turning
|
|
383
|
+
# voice on connects the number for calls before the change is saved and
|
|
384
|
+
# answers in "ring_dashboard" mode unless +voice_mode+ is "agent".
|
|
385
|
+
# A mode alone is enough: +voice_mode: "ring_dashboard"+ or +"agent"+
|
|
386
|
+
# switches voice on, and +voice_mode: "none"+ switches it off.
|
|
387
|
+
# +voice_enabled: false+ wins over any mode, and +voice_enabled: true+
|
|
388
|
+
# with "none" answers in "ring_dashboard" mode.
|
|
389
|
+
#
|
|
390
|
+
# @param number [String] The number's id or its E.164 phone number
|
|
391
|
+
# @param voice_enabled [Boolean, nil] Switch voice on or off
|
|
392
|
+
# @param voice_mode [String, nil] One of {VoiceNumber::VOICE_MODES}
|
|
393
|
+
# @param agent_id [String, nil] The agent that answers in "agent" mode.
|
|
394
|
+
# Needed (here or already stored) for "agent" mode, and the agent must
|
|
395
|
+
# be switched on. Pass +nil+ to clear the stored agent; leave it out to
|
|
396
|
+
# keep it.
|
|
397
|
+
# @param idempotency_key [String, nil] Idempotency key for this operation
|
|
398
|
+
# @return [Sendly::VoiceNumber] The number after the change
|
|
399
|
+
# @raise [Sendly::ValidationError] If +number+ is blank, or HTTP 400
|
|
400
|
+
# +invalid_request+ (a wrongly typed field), +invalid_voice_mode+ or
|
|
401
|
+
# +agent_required+ ("agent" mode with no agent)
|
|
402
|
+
# @raise [Sendly::NotFoundError] HTTP 404 +number_not_found+ / +agent_not_found+
|
|
403
|
+
# @raise [Sendly::APIError] HTTP 409 +agent_disabled+ (the agent is
|
|
404
|
+
# switched off), 403 +forbidden+ / +live_key_required+
|
|
405
|
+
# @raise [Sendly::ServerError] HTTP 502 +voice_attach_failed+ (voice could
|
|
406
|
+
# not be switched on; try again) or 503 +voice_unavailable+, after the
|
|
407
|
+
# client's automatic retries. Either can follow a mode alone on a number
|
|
408
|
+
# whose voice is off.
|
|
409
|
+
#
|
|
410
|
+
# @example Have an agent answer
|
|
411
|
+
# client.voice.numbers.update(
|
|
412
|
+
# "+15555550188",
|
|
413
|
+
# voice_enabled: true,
|
|
414
|
+
# voice_mode: "agent",
|
|
415
|
+
# agent_id: "3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b"
|
|
416
|
+
# )
|
|
417
|
+
#
|
|
418
|
+
# @example Ring the team in the dashboard instead
|
|
419
|
+
# client.voice.numbers.update("+15555550188", voice_mode: "ring_dashboard")
|
|
420
|
+
def update(number, voice_enabled: nil, voice_mode: nil, agent_id: VoiceInput::OMIT, idempotency_key: nil)
|
|
421
|
+
path = VoiceInput.number_path(number)
|
|
422
|
+
body = {}
|
|
423
|
+
body[:voiceEnabled] = voice_enabled unless voice_enabled.nil?
|
|
424
|
+
body[:voiceMode] = voice_mode unless voice_mode.nil?
|
|
425
|
+
body[:agentId] = agent_id unless VoiceInput::OMIT.equal?(agent_id)
|
|
426
|
+
|
|
427
|
+
response = @client.patch(path, body, idempotency_key: idempotency_key)
|
|
428
|
+
VoiceNumber.new(response)
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Register the street address emergency services are sent to when
|
|
432
|
+
# someone calls them from this number. Requires the +calls:write+ scope
|
|
433
|
+
# and a live key; in a team workspace, also a role that can change
|
|
434
|
+
# settings.
|
|
435
|
+
#
|
|
436
|
+
# A US or Canadian number needs one before it can place calls. The first
|
|
437
|
+
# registration adds $1.50 a month to the number; registering again
|
|
438
|
+
# replaces the address without adding the charge a second time.
|
|
439
|
+
#
|
|
440
|
+
# @param number [String] The number's id or its E.164 phone number
|
|
441
|
+
# @param street [String] Street address
|
|
442
|
+
# @param city [String] City
|
|
443
|
+
# @param state [String] Two-letter state or province code, e.g. "TX"
|
|
444
|
+
# @param zip [String] Five-digit ZIP (or ZIP+4) in the US, a postal code
|
|
445
|
+
# like "A1A 1A1" in Canada
|
|
446
|
+
# @param unit [String, nil] Apartment, suite or floor
|
|
447
|
+
# @param country [String, nil] "US" or "CA" (the API defaults to "US")
|
|
448
|
+
# @param idempotency_key [String, nil] Idempotency key for this operation
|
|
449
|
+
# @return [Sendly::VoiceNumber] The number with its +emergency_address+
|
|
450
|
+
# @raise [Sendly::ValidationError] If +number+, +street+, +city+, +state+
|
|
451
|
+
# or +zip+ is blank; HTTP 400 +invalid_request+ (a field that is not a
|
|
452
|
+
# string, such as +zip: 78701+), +invalid_address+ (a malformed field)
|
|
453
|
+
# or +e911_not_applicable+ (a number outside the US and Canada); or HTTP
|
|
454
|
+
# 422 +invalid_address+ when the address could not be validated, with a
|
|
455
|
+
# corrected address (or +nil+) in +e.response_body["suggested"]+
|
|
456
|
+
# @raise [Sendly::NotFoundError] HTTP 404 +number_not_found+
|
|
457
|
+
# @raise [Sendly::ServerError] HTTP 502 +carrier_refused+ when the
|
|
458
|
+
# registration was refused, raised after the client has already retried
|
|
459
|
+
# the 5xx on its own. When the message says the number couldn't be
|
|
460
|
+
# found for emergency registration, retrying won't help: contact
|
|
461
|
+
# support. When it says the address couldn't be registered or emergency
|
|
462
|
+
# calling couldn't be switched on, try again later.
|
|
463
|
+
#
|
|
464
|
+
# @example
|
|
465
|
+
# number = client.voice.numbers.register_emergency_address(
|
|
466
|
+
# "+15555550188",
|
|
467
|
+
# street: "500 Example Ave",
|
|
468
|
+
# unit: "Suite 2",
|
|
469
|
+
# city: "Austin",
|
|
470
|
+
# state: "TX",
|
|
471
|
+
# zip: "78701"
|
|
472
|
+
# )
|
|
473
|
+
# puts number.emergency_address.status
|
|
474
|
+
def register_emergency_address(number, street:, city:, state:, zip:, unit: nil, country: nil,
|
|
475
|
+
idempotency_key: nil)
|
|
476
|
+
path = "#{VoiceInput.number_path(number)}/emergency-address"
|
|
477
|
+
VoiceInput.require_text!(street, "street is required")
|
|
478
|
+
VoiceInput.require_text!(city, "city is required")
|
|
479
|
+
VoiceInput.require_text!(state, "state is required")
|
|
480
|
+
VoiceInput.require_text!(zip, "zip is required")
|
|
481
|
+
|
|
482
|
+
body = { street: street }
|
|
483
|
+
body[:unit] = unit unless unit.nil?
|
|
484
|
+
body[:city] = city
|
|
485
|
+
body[:state] = state
|
|
486
|
+
body[:zip] = zip
|
|
487
|
+
body[:country] = country unless country.nil?
|
|
488
|
+
|
|
489
|
+
response = @client.post(path, body, idempotency_key: idempotency_key)
|
|
490
|
+
VoiceNumber.new(response)
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# The AI agents that answer and place your phone calls. Reached as
|
|
495
|
+
# +client.voice.agents+.
|
|
496
|
+
class VoiceAgentsResource
|
|
497
|
+
def initialize(client)
|
|
498
|
+
@client = client
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# List the workspace's AI agents with their call stats. Requires the
|
|
502
|
+
# +calls:read+ scope.
|
|
503
|
+
#
|
|
504
|
+
# @return [Sendly::VoiceAgentList]
|
|
505
|
+
#
|
|
506
|
+
# @example
|
|
507
|
+
# client.voice.agents.list.each do |agent|
|
|
508
|
+
# puts "#{agent.name} (#{agent.voice_label}) #{agent.calls_handled} calls"
|
|
509
|
+
# end
|
|
510
|
+
def list
|
|
511
|
+
response = @client.get("/voice/agents")
|
|
512
|
+
VoiceAgentList.new(response)
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# Create an AI agent. Requires the +calls:write+ scope and a live key; in
|
|
516
|
+
# a team workspace, also a role that can manage API keys.
|
|
517
|
+
#
|
|
518
|
+
# The agent answers real callers on any number pointed at it and talks on
|
|
519
|
+
# the calls you place with it. Each agent gets its own scoped sending key
|
|
520
|
+
# so it can text callers; {VoiceAgent#can_send_sms?} says whether it has
|
|
521
|
+
# one. A workspace can have up to 20 agents.
|
|
522
|
+
#
|
|
523
|
+
# @param name [String] 1-80 characters
|
|
524
|
+
# @param enabled [Boolean, nil] Whether the agent is switched on (the API
|
|
525
|
+
# defaults to +true+)
|
|
526
|
+
# @param voice [String, nil] A voice id from {VoiceVoicesResource#list};
|
|
527
|
+
# an unknown id falls back to the default voice
|
|
528
|
+
# @param language [String, nil] Language tag, up to 16 characters (the
|
|
529
|
+
# API defaults to "en-US")
|
|
530
|
+
# @param greeting [String, nil] What the agent says when it picks up, up
|
|
531
|
+
# to 500 characters
|
|
532
|
+
# @param instructions [String, nil] Business instructions the agent
|
|
533
|
+
# follows, up to 4000 characters
|
|
534
|
+
# @param tools [Hash, nil] +send_sms:+ and +transfer_to:+ (see
|
|
535
|
+
# {VoiceAgentTools}); camelCase keys are accepted too. +send_sms+
|
|
536
|
+
# defaults to +true+ and +transfer_to+ to +nil+.
|
|
537
|
+
# @param idempotency_key [String, nil] Idempotency key for this operation
|
|
538
|
+
# @return [Sendly::VoiceAgent] The new agent
|
|
539
|
+
# @raise [Sendly::ValidationError] If +name+ is blank, or HTTP 400
|
|
540
|
+
# +invalid_request+ naming the field the API rejected
|
|
541
|
+
# @raise [Sendly::APIError] HTTP 409 +agent_limit+ when the workspace
|
|
542
|
+
# already has 20 agents, 403 +forbidden+ / +live_key_required+
|
|
543
|
+
#
|
|
544
|
+
# @example
|
|
545
|
+
# agent = client.voice.agents.create(
|
|
546
|
+
# name: "Front desk",
|
|
547
|
+
# voice: "ashley",
|
|
548
|
+
# greeting: "Thanks for calling Acme, how can I help?",
|
|
549
|
+
# instructions: "Answer questions about opening hours and take a message for anything else.",
|
|
550
|
+
# tools: { send_sms: true }
|
|
551
|
+
# )
|
|
552
|
+
# puts agent.id
|
|
553
|
+
def create(name:, enabled: nil, voice: nil, language: nil, greeting: nil, instructions: nil,
|
|
554
|
+
tools: nil, idempotency_key: nil)
|
|
555
|
+
VoiceInput.require_text!(name, "name is required")
|
|
556
|
+
|
|
557
|
+
body = VoiceInput.agent_body(
|
|
558
|
+
name: name, enabled: enabled, voice: voice, language: language,
|
|
559
|
+
greeting: greeting, instructions: instructions, tools: tools
|
|
560
|
+
)
|
|
561
|
+
response = @client.post("/voice/agents", body, idempotency_key: idempotency_key)
|
|
562
|
+
VoiceAgent.new(response)
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# Fetch one agent. Requires the +calls:read+ scope.
|
|
566
|
+
#
|
|
567
|
+
# @param id [String] Agent identifier
|
|
568
|
+
# @return [Sendly::VoiceAgent]
|
|
569
|
+
# @raise [Sendly::ValidationError] If +id+ is blank
|
|
570
|
+
# @raise [Sendly::NotFoundError] HTTP 404 +agent_not_found+ when the
|
|
571
|
+
# agent is not in your workspace
|
|
572
|
+
#
|
|
573
|
+
# @example
|
|
574
|
+
# agent = client.voice.agents.get("3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b")
|
|
575
|
+
# puts agent.greeting
|
|
576
|
+
def get(id)
|
|
577
|
+
response = @client.get(VoiceInput.agent_path(id))
|
|
578
|
+
VoiceAgent.new(response)
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# Update an agent. Requires the +calls:write+ scope and a live key; in a
|
|
582
|
+
# team workspace, also a role that can manage API keys. Pass only what
|
|
583
|
+
# changes; +tools+ keys you leave out keep their current values.
|
|
584
|
+
#
|
|
585
|
+
# @param id [String] Agent identifier
|
|
586
|
+
# @param name [String, nil] 1-80 characters
|
|
587
|
+
# @param enabled [Boolean, nil] Switch the agent on or off
|
|
588
|
+
# @param voice [String, nil] A voice id from {VoiceVoicesResource#list};
|
|
589
|
+
# an unknown id falls back to the default voice
|
|
590
|
+
# @param language [String, nil] Language tag, up to 16 characters; +""+
|
|
591
|
+
# resets it to "en-US"
|
|
592
|
+
# @param greeting [String, nil] Up to 500 characters; +""+ clears it
|
|
593
|
+
# @param instructions [String, nil] Up to 4000 characters; +""+ clears it
|
|
594
|
+
# @param tools [Hash, nil] Tool settings to change (+send_sms:+,
|
|
595
|
+
# +transfer_to:+); +transfer_to: nil+ clears the number
|
|
596
|
+
# @param idempotency_key [String, nil] Idempotency key for this operation
|
|
597
|
+
# @return [Sendly::VoiceAgent] The agent after the change
|
|
598
|
+
# @raise [Sendly::ValidationError] If +id+ is blank, or HTTP 400
|
|
599
|
+
# +invalid_request+ naming the field the API rejected
|
|
600
|
+
# @raise [Sendly::NotFoundError] HTTP 404 +agent_not_found+
|
|
601
|
+
#
|
|
602
|
+
# @example
|
|
603
|
+
# client.voice.agents.update(
|
|
604
|
+
# "3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b",
|
|
605
|
+
# greeting: "Thanks for calling Acme. How can I help today?",
|
|
606
|
+
# tools: { send_sms: false }
|
|
607
|
+
# )
|
|
608
|
+
def update(id, name: nil, enabled: nil, voice: nil, language: nil, greeting: nil, instructions: nil,
|
|
609
|
+
tools: nil, idempotency_key: nil)
|
|
610
|
+
path = VoiceInput.agent_path(id)
|
|
611
|
+
body = VoiceInput.agent_body(
|
|
612
|
+
name: name, enabled: enabled, voice: voice, language: language,
|
|
613
|
+
greeting: greeting, instructions: instructions, tools: tools
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
response = @client.patch(path, body, idempotency_key: idempotency_key)
|
|
617
|
+
VoiceAgent.new(response)
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
# Delete an agent and revoke its sending key. Requires the +calls:write+
|
|
621
|
+
# scope and a live key; in a team workspace, also a role that can manage
|
|
622
|
+
# API keys.
|
|
623
|
+
#
|
|
624
|
+
# An agent that answers a number can't be deleted: the API responds 409
|
|
625
|
+
# +agent_in_use+ and +e.response_body["numbers"]+ lists those numbers.
|
|
626
|
+
# Point them at another agent or back to the team first with
|
|
627
|
+
# {VoiceNumbersResource#update}.
|
|
628
|
+
#
|
|
629
|
+
# @param id [String] Agent identifier
|
|
630
|
+
# @param idempotency_key [String, nil] Idempotency key for this operation
|
|
631
|
+
# @return [Sendly::DeletedVoiceAgent]
|
|
632
|
+
# @raise [Sendly::ValidationError] If +id+ is blank
|
|
633
|
+
# @raise [Sendly::NotFoundError] HTTP 404 +agent_not_found+
|
|
634
|
+
# @raise [Sendly::APIError] HTTP 409 +agent_in_use+
|
|
635
|
+
#
|
|
636
|
+
# @example
|
|
637
|
+
# begin
|
|
638
|
+
# client.voice.agents.delete(agent.id)
|
|
639
|
+
# rescue Sendly::APIError => e
|
|
640
|
+
# raise unless e.response_body&.dig("error") == "agent_in_use"
|
|
641
|
+
#
|
|
642
|
+
# e.response_body["numbers"].each do |number|
|
|
643
|
+
# client.voice.numbers.update(number, voice_mode: "ring_dashboard")
|
|
644
|
+
# end
|
|
645
|
+
# client.voice.agents.delete(agent.id)
|
|
646
|
+
# end
|
|
647
|
+
def delete(id, idempotency_key: nil)
|
|
648
|
+
response = @client.delete(VoiceInput.agent_path(id), idempotency_key: idempotency_key)
|
|
649
|
+
DeletedVoiceAgent.new(response)
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
# The voices an agent can speak with. Reached as +client.voice.voices+.
|
|
654
|
+
class VoiceVoicesResource
|
|
655
|
+
def initialize(client)
|
|
656
|
+
@client = client
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
# List the voices an agent can speak with. Requires the +calls:read+
|
|
660
|
+
# scope.
|
|
661
|
+
#
|
|
662
|
+
# @return [Sendly::VoiceList]
|
|
663
|
+
#
|
|
664
|
+
# @example
|
|
665
|
+
# client.voice.voices.list.each { |v| puts "#{v.id}: #{v.label}" }
|
|
666
|
+
def list
|
|
667
|
+
response = @client.get("/voice/voices")
|
|
668
|
+
VoiceList.new(response)
|
|
669
|
+
end
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
# Voice resource: configure everything a phone call depends on, from code.
|
|
673
|
+
# {#numbers} switches voice on for a number, chooses how it answers and
|
|
674
|
+
# registers its emergency address; {#agents} manages the AI agents that
|
|
675
|
+
# talk on calls; {#voices} lists the voices those agents can use.
|
|
676
|
+
#
|
|
677
|
+
# Reads need the +calls:read+ scope; writes need +calls:write+ and a live
|
|
678
|
+
# API key (+sk_live_v1_xxx+). In a team workspace, number and emergency
|
|
679
|
+
# address changes also need a role that can change settings, and agent
|
|
680
|
+
# changes a role that can manage API keys (each agent holds its own scoped
|
|
681
|
+
# sending key); otherwise the API responds 403 +forbidden+.
|
|
682
|
+
#
|
|
683
|
+
# Voice is enabled workspace by workspace. Until it is on for yours,
|
|
684
|
+
# every method here raises {Sendly::NotFoundError} (+voice_not_enabled+).
|
|
685
|
+
#
|
|
686
|
+
# Error codes map onto the usual classes: {Sendly::NotFoundError} for
|
|
687
|
+
# +voice_not_enabled+, +number_not_found+ and +agent_not_found+;
|
|
688
|
+
# {Sendly::ValidationError} for +invalid_request+, +invalid_voice_mode+,
|
|
689
|
+
# +agent_required+, +invalid_address+ (400 and 422) and
|
|
690
|
+
# +e911_not_applicable+; {Sendly::APIError} with the HTTP status for
|
|
691
|
+
# +agent_disabled+ / +agent_limit+ / +agent_in_use+ (409) and +forbidden+
|
|
692
|
+
# / +live_key_required+ / +insufficient_permissions+ (403); and
|
|
693
|
+
# {Sendly::ServerError} (not an +APIError+) for +voice_attach_failed+ /
|
|
694
|
+
# +carrier_refused+ (502), +voice_unavailable+ (503) and
|
|
695
|
+
# +voice_internal_error+ (500). Every API error keeps the parsed body on
|
|
696
|
+
# {Sendly::Error#response_body}. The full list is {Call::ERROR_CODES}.
|
|
697
|
+
#
|
|
698
|
+
# @example Have a new agent answer a number
|
|
699
|
+
# agent = client.voice.agents.create(name: "Front desk", greeting: "Thanks for calling Acme, how can I help?")
|
|
700
|
+
# client.voice.numbers.register_emergency_address(
|
|
701
|
+
# "+15555550188", street: "500 Example Ave", city: "Austin", state: "TX", zip: "78701"
|
|
702
|
+
# )
|
|
703
|
+
# client.voice.numbers.update("+15555550188", voice_enabled: true, voice_mode: "agent", agent_id: agent.id)
|
|
704
|
+
class VoiceResource
|
|
705
|
+
# @return [VoiceNumbersResource] Voice settings and emergency addresses for your numbers
|
|
706
|
+
attr_reader :numbers
|
|
707
|
+
|
|
708
|
+
# @return [VoiceAgentsResource] The AI agents that talk on calls
|
|
709
|
+
attr_reader :agents
|
|
710
|
+
|
|
711
|
+
# @return [VoiceVoicesResource] The voices agents can speak with
|
|
712
|
+
attr_reader :voices
|
|
713
|
+
|
|
714
|
+
def initialize(client)
|
|
715
|
+
@client = client
|
|
716
|
+
@numbers = VoiceNumbersResource.new(client)
|
|
717
|
+
@agents = VoiceAgentsResource.new(client)
|
|
718
|
+
@voices = VoiceVoicesResource.new(client)
|
|
719
|
+
end
|
|
720
|
+
end
|
|
721
|
+
end
|