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.
@@ -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