sendly 4.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 84b10285655be846bb857f5e11388a893d916838d480accfc46a646c16dd68a3
4
- data.tar.gz: cfe12c76eeea0a6db834c09d97c7546260c4a313f5f6d9c64a549d467884ae3b
3
+ metadata.gz: 6e04f0698b7520abcb9cd17816198b7e728ab2ce6d2ed1d564195887334dca82
4
+ data.tar.gz: 9f26320067a3fe83a7035364cdf6682e03f9b93bf26996faf728cbfb9899065c
5
5
  SHA512:
6
- metadata.gz: 5c528f37943ed1c30eb8b6929097cb167ba9be070e0a8b3ec6d69949e9710a38ccbf1bdb04ce57d92a569fabdb68e56df3e50104e3ffffb620d9c6962d3e4e73
7
- data.tar.gz: 73c7ba0af6ac53f75b33759b2566292f64ee5199c13f088da4ebb3b8bf68afea4b00fdddb76a0e7f3a7a1fa1547162ab94fa81a119d0b18004bce650ca6ed434
6
+ metadata.gz: 2b3ec03a21b0eae75affd32ee962e3e33a4481f1b5b23e656d5633ebeef18f0302168343943a5b74f45176609566229dcccead196d939f73e43a7c19aa676c77
7
+ data.tar.gz: f7d9d7f44fd1fe7aa99d53dcfbfdba8be8fb25e15d0ccdefda67488d7ea5552c04199f5a91287bab73b703192b1ae5860bb6c80fe23085bc2d4bb98d28f5ba2a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # sendly (Ruby)
2
2
 
3
+ ## 4.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **Voice configuration: `client.voice`.** Everything a phone call depends on is now configurable from code over the new `/api/v1/voice` routes. `client.voice.numbers` has `list`, `get(number)`, `update(number, voice_enabled:, voice_mode:, agent_id:)` and `register_emergency_address(number, street:, city:, state:, zip:, unit: nil, country: nil)`: switch voice on for a number, choose how it answers (`Sendly::VoiceNumber::VOICE_MODES`), and register the emergency address a US or Canadian number needs before it can place calls ($1.50 a month; registering again replaces the address without a second charge). `number` is the number's id or its E.164 phone number, and `agent_id: nil` clears the stored agent. `client.voice.agents` has `list`, `create(name:, ...)`, `get(id)`, `update(id, ...)` and `delete(id)` for the AI agents that answer and place calls (up to 20 per workspace, each holding its own scoped sending key); `tools:` takes `send_sms` and `transfer_to` in snake_case or camelCase. `client.voice.voices.list` lists the voices agents can speak with. Lists are Enumerable (`Sendly::VoiceNumberList`, `Sendly::VoiceAgentList`, `Sendly::VoiceList`); the models are `Sendly::VoiceNumber` (with `Sendly::VoiceNumberEmergencyAddress`, `Sendly::EmergencyAddress` and `Sendly::VoiceNumberRates`), `Sendly::VoiceAgent` (with `Sendly::VoiceAgentTools`), `Sendly::Voice` and `Sendly::DeletedVoiceAgent`. POSTs send the client's usual `Idempotency-Key`, and every write accepts `idempotency_key:`. Reads need the `calls:read` scope, writes `calls:write` and a live key; in a team workspace, number changes also need a role that can change settings and agent changes a role that can manage API keys. Deleting an agent that still answers a number raises `Sendly::APIError` (409 `agent_in_use`). `Sendly::Call::ERROR_CODES` gains `agent_in_use`, `agent_limit`, `invalid_voice_mode`, `invalid_address`, `e911_not_applicable`, `voice_attach_failed`, `carrier_refused`, `invalid_request` and `insufficient_permissions`.
8
+
9
+ ```ruby
10
+ agent = client.voice.agents.create(name: "Front desk", tools: { send_sms: true })
11
+ client.voice.numbers.update("+15555550188", voice_enabled: true, voice_mode: "agent", agent_id: agent.id)
12
+ ```
13
+
14
+ - **`Sendly::Error#response_body`** is the parsed JSON body of the API error response, for refusals that carry more than a message: a 409 `agent_in_use` lists the numbers the agent still answers under `"numbers"`, and a 422 `invalid_address` carries a corrected address (or `nil`) under `"suggested"`. It is `nil` for errors raised before a request is sent.
15
+ - **`Sendly::Client#delete` accepts `idempotency_key:`**, like `patch` and `put`.
16
+
17
+ ### Patch Changes
18
+
19
+ - **The `Sendly::CallRecording` docstring had the channels the wrong way round.** Agent-handled calls are recorded with the agent on the left channel and the other party on the right.
20
+
3
21
  ## 4.1.0
4
22
 
5
23
  ### Minor Changes
data/Gemfile.lock CHANGED
@@ -1,14 +1,14 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- sendly (4.1.0)
4
+ sendly (4.2.0)
5
5
  faraday (~> 2.0)
6
6
  faraday-retry (~> 2.0)
7
7
 
8
8
  GEM
9
9
  remote: https://rubygems.org/
10
10
  specs:
11
- addressable (2.8.8)
11
+ addressable (2.9.0)
12
12
  public_suffix (>= 2.0.2, < 8.0)
13
13
  ast (2.4.3)
14
14
  bigdecimal (3.3.1)
@@ -74,7 +74,7 @@ GEM
74
74
  unicode-emoji (~> 4.1)
75
75
  unicode-emoji (4.1.0)
76
76
  uri (1.1.1)
77
- webmock (3.26.1)
77
+ webmock (3.26.4)
78
78
  addressable (>= 2.8.0)
79
79
  crack (>= 0.3.2)
80
80
  hashdiff (>= 0.4.0, < 2.0.0)
data/README.md CHANGED
@@ -1175,10 +1175,12 @@ client.messages.send(
1175
1175
  ## Voice Calls
1176
1176
 
1177
1177
  Place phone calls that one of your AI agents handles, list and inspect
1178
- calls, end a call early, and download recordings. Agents are configured in
1179
- the dashboard under Calls, then Agents; the number you call from must have
1180
- voice switched on there (Calls, then Settings) and an emergency address
1181
- registered before it can place outbound calls. Each `Sendly::PhoneNumber`
1178
+ calls, end a call early, and download recordings. Agents are created with
1179
+ `client.voice.agents` or in the dashboard under Calls, then Agents; the
1180
+ number you call from must have voice switched on and an emergency address
1181
+ registered before it can place outbound calls (see
1182
+ [Configure voice](#configure-voice), or Calls, then Settings in the
1183
+ dashboard). Each `Sendly::PhoneNumber`
1182
1184
  from `client.numbers.list` carries `voice_enabled?` and `voice_mode`
1183
1185
  (`"none"`, `"ring_dashboard"` or `"agent"`) so you can pick a `from`:
1184
1186
 
@@ -1193,8 +1195,8 @@ are US and Canadian numbers. Reads need the `calls:read` scope; `create`
1193
1195
  and `hangup` need `calls:write` and a live key.
1194
1196
 
1195
1197
  > **Rolling out.** Voice is enabled workspace by workspace. Until it is on
1196
- > for yours, every call method raises `Sendly::NotFoundError`
1197
- > (`voice_not_enabled`).
1198
+ > for yours, every `client.calls` and `client.voice` method raises
1199
+ > `Sendly::NotFoundError` (`voice_not_enabled`).
1198
1200
 
1199
1201
  ```ruby
1200
1202
  # Place a call. Returns at once with the call ringing; the agent greets the
@@ -1228,7 +1230,8 @@ puts page.has_more?
1228
1230
  client.calls.hangup(call.id)
1229
1231
 
1230
1232
  # Recording. The URL is signed and valid for five minutes; it is nil until
1231
- # the recording is ready. Ogg/Opus, dual channel on agent calls.
1233
+ # the recording is ready. Ogg/Opus; agent calls are dual channel, with the
1234
+ # agent on the left channel and the other party on the right.
1232
1235
  rec = client.calls.recording(call.id)
1233
1236
  if rec.ready?
1234
1237
  File.binwrite("#{call.id}.ogg", Net::HTTP.get(URI(rec.url)))
@@ -1254,6 +1257,88 @@ The full list is `Sendly::Call::ERROR_CODES`; the hangup vocabulary is
1254
1257
  the same object in snake_case (`handled_by`, `hangup_class`, `billing`,
1255
1258
  `metadata`, ...); `Sendly::Call.new(event.raw_object)` reads it.
1256
1259
 
1260
+ ### Configure voice
1261
+
1262
+ Everything a call depends on is configurable from code with `client.voice`:
1263
+ switch voice on for a number and choose how it answers, register the
1264
+ number's emergency address, and create the AI agents that talk. Reads need
1265
+ the `calls:read` scope; writes need `calls:write` and a live key. In a team
1266
+ workspace, changing a number or its emergency address also needs a role that
1267
+ can change settings, and managing agents a role that can manage API keys
1268
+ (each agent holds its own scoped sending key).
1269
+
1270
+ ```ruby
1271
+ # Numbers. Pass the number's id or its E.164 phone number.
1272
+ client.voice.numbers.list.each do |n|
1273
+ puts "#{n.phone_number} #{n.voice_mode} #{n.emergency_address&.status || 'no emergency address'}"
1274
+ end
1275
+ number = client.voice.numbers.get("+15555550188")
1276
+ puts number.rate_per_minute.agent # credits a minute when an agent answers
1277
+
1278
+ # A US or Canadian number needs an emergency address before it can place
1279
+ # calls. The first registration adds $1.50 a month to the number;
1280
+ # registering again replaces the address without a second charge.
1281
+ number = client.voice.numbers.register_emergency_address(
1282
+ "+15555550188",
1283
+ street: "500 Example Ave",
1284
+ unit: "Suite 2",
1285
+ city: "Austin",
1286
+ state: "TX",
1287
+ zip: "78701" # country: defaults to "US"
1288
+ )
1289
+ puts number.emergency_address.status
1290
+
1291
+ # Voices and agents. An agent answers real callers on any number pointed at it.
1292
+ client.voice.voices.list.each { |v| puts "#{v.id}: #{v.label}" }
1293
+
1294
+ agent = client.voice.agents.create(
1295
+ name: "Front desk",
1296
+ voice: "ashley",
1297
+ greeting: "Thanks for calling Acme, how can I help?",
1298
+ instructions: "Answer questions about opening hours and take a message for anything else.",
1299
+ tools: { send_sms: true } # snake_case or camelCase keys
1300
+ )
1301
+ puts agent.can_send_sms? # true once it holds its scoped sending key
1302
+ client.voice.agents.update(agent.id, greeting: "Thanks for calling Acme. How can I help today?")
1303
+ client.voice.agents.list.each { |a| puts "#{a.name}: #{a.calls_handled} calls" }
1304
+
1305
+ # Switching voice on changes how real calls to the number are answered.
1306
+ client.voice.numbers.update("+15555550188", voice_enabled: true, voice_mode: "agent", agent_id: agent.id)
1307
+ client.voice.numbers.update("+15555550188", voice_mode: "ring_dashboard") # ring the team instead
1308
+ client.voice.numbers.update("+15555550188", voice_enabled: false) # switch voice off
1309
+
1310
+ # An agent that answers a number can't be deleted until the number is moved.
1311
+ begin
1312
+ client.voice.agents.delete(agent.id)
1313
+ rescue Sendly::APIError => e
1314
+ raise unless e.response_body&.dig("error") == "agent_in_use"
1315
+
1316
+ puts "Still answers #{e.response_body['numbers'].join(', ')}"
1317
+ end
1318
+ ```
1319
+
1320
+ A mode alone is enough: `voice_mode: "agent"` or `"ring_dashboard"` switches
1321
+ voice on, so it can fail the way switching on does, and `voice_mode: "none"`
1322
+ switches it off. `voice_enabled: false` wins over any mode, and
1323
+ `voice_enabled: true` with `"none"` answers in `"ring_dashboard"` mode.
1324
+
1325
+ Configuration errors map the same way: `Sendly::NotFoundError` for
1326
+ `number_not_found` and `agent_not_found`; `Sendly::ValidationError` for
1327
+ `invalid_request` (for example an emergency address field that is not a
1328
+ string), `invalid_voice_mode`, `agent_required`,
1329
+ `e911_not_applicable` and `invalid_address` (a 422 `invalid_address` means
1330
+ the address could not be validated, and `e.response_body["suggested"]` holds
1331
+ a corrected address when one was found); `Sendly::APIError` with
1332
+ `status_code` 409 for `agent_disabled`, `agent_limit` (20 agents per
1333
+ workspace) and `agent_in_use`; and `Sendly::ServerError` for 502
1334
+ `voice_attach_failed` and `carrier_refused` and 503 `voice_unavailable`,
1335
+ raised after the client has already retried the 5xx on its own. Not every
1336
+ `carrier_refused` is worth retrying: when the message says the number
1337
+ couldn't be found for emergency registration, retrying won't help, so
1338
+ contact support. When it says the address couldn't be registered or
1339
+ emergency calling couldn't be switched on, try again later. Every API error
1340
+ keeps the parsed body on `e.response_body`.
1341
+
1257
1342
  ## Error Handling
1258
1343
 
1259
1344
  ```ruby
@@ -58,6 +58,8 @@ module Sendly
58
58
  no_voice_number number_not_found destination_not_supported e911_required lines_busy
59
59
  daily_call_limit call_not_found live_key_required voice_internal_error
60
60
  insufficient_credits invalid_number rate_limit_exceeded forbidden
61
+ invalid_request insufficient_permissions agent_in_use agent_limit invalid_voice_mode
62
+ invalid_address e911_not_applicable voice_attach_failed carrier_refused
61
63
  ].freeze
62
64
 
63
65
  attr_reader :id, :object, :kind, :direction, :status, :handled_by, :agent_id,
@@ -182,8 +184,8 @@ module Sendly
182
184
  # call runs, "ready" once it can be fetched, or "failed". +url+ and
183
185
  # +expires_at+ are set only when {#ready?}: the URL is signed and valid for
184
186
  # five minutes. Recordings are Ogg/Opus (+content_type+ "audio/ogg");
185
- # agent-handled calls are recorded dual-channel, caller left and agent
186
- # right.
187
+ # agent-handled calls are recorded dual-channel, with the agent on the
188
+ # left channel and the other party on the right.
187
189
  class CallRecording
188
190
  STATUSES = %w[none recording ready failed].freeze
189
191
 
@@ -213,9 +215,10 @@ module Sendly
213
215
  # Calls resource: place phone calls handled by your AI agents, list and
214
216
  # inspect calls, end a call and fetch recordings.
215
217
  #
216
- # A call placed over the API is answered by one of the AI agents you
217
- # configure in the dashboard under Calls, then Agents; the +from+ number
218
- # must have voice switched on in the dashboard. Calls are charged per
218
+ # A call placed over the API is answered by one of your AI agents
219
+ # (create them with {VoiceAgentsResource#create} or in the dashboard
220
+ # under Calls, then Agents); the +from+ number must have voice switched
221
+ # on ({VoiceNumbersResource#update} or the dashboard). Calls are charged per
219
222
  # started minute from your credit balance: 2 credits a minute outbound,
220
223
  # plus 8 a minute while an agent is on the call. Destinations are US and
221
224
  # Canadian numbers. Reads need the +calls:read+ scope, writes
@@ -278,7 +281,8 @@ module Sendly
278
281
  # @raise [Sendly::InsufficientCreditsError] HTTP 402 when the balance
279
282
  # cannot cover one minute at the agent rate
280
283
  # @raise [Sendly::APIError] HTTP 428 +e911_required+ (register an
281
- # emergency address for the number first), 409 +agent_disabled+ /
284
+ # emergency address for the number first with
285
+ # {VoiceNumbersResource#register_emergency_address}), 409 +agent_disabled+ /
282
286
  # +no_voice_number+ / +lines_busy+, 403 +live_key_required+
283
287
  # @raise [Sendly::RateLimitError] HTTP 429 +daily_call_limit+ / +rate_limit_exceeded+
284
288
  # @raise [Sendly::ServerError] HTTP 503 +voice_unavailable+ /
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
@@ -255,10 +266,14 @@ module Sendly
255
266
 
256
267
  # Make a DELETE request
257
268
  #
269
+ # No Idempotency-Key is generated for a DELETE; pass +idempotency_key+
270
+ # to send one (1-255 printable ASCII characters).
271
+ #
258
272
  # @param path [String] API path
273
+ # @param idempotency_key [String, nil] Caller-supplied idempotency key (optional)
259
274
  # @return [Hash] Response body
260
- def delete(path)
261
- request(:delete, path)
275
+ def delete(path, idempotency_key: nil)
276
+ request(:delete, path, idempotency_key: idempotency_key)
262
277
  end
263
278
 
264
279
  # Make a GET request against the API origin, bypassing the +/api/v1+ base.
data/lib/sendly/errors.rb CHANGED
@@ -12,12 +12,30 @@ module Sendly
12
12
  # @return [Integer, nil] HTTP status code
13
13
  attr_reader :status_code
14
14
 
15
+ # @return [Hash, nil] The parsed JSON body of the API's error response,
16
+ # or +nil+ for errors raised before a request was sent. The API's own
17
+ # error code is under +"error"+, and some refusals carry more than a
18
+ # message: a 409 +agent_in_use+ lists the numbers the agent still
19
+ # answers under +"numbers"+, and a 422 +invalid_address+ carries a
20
+ # corrected address (or +nil+) under +"suggested"+.
21
+ attr_reader :response_body
22
+
15
23
  def initialize(message = nil, code: nil, details: nil, status_code: nil)
16
24
  @code = code
17
25
  @details = details
18
26
  @status_code = status_code
19
27
  super(message)
20
28
  end
29
+
30
+ # Attach the parsed body of the API response this error came from.
31
+ #
32
+ # @api private
33
+ # @param body [Hash, nil]
34
+ # @return [self]
35
+ def with_response_body(body)
36
+ @response_body = body
37
+ self
38
+ end
21
39
  end
22
40
 
23
41
  # Raised when the API key is invalid or missing
@@ -101,23 +119,24 @@ module Sendly
101
119
  code = body["code"]
102
120
  details = body["details"]
103
121
 
104
- case status
105
- when 400, 422
106
- ValidationError.new(message, details: details, field_errors: body["errors"])
107
- when 401
108
- AuthenticationError.new(message)
109
- when 402
110
- InsufficientCreditsError.new(message)
111
- when 404
112
- NotFoundError.new(message)
113
- when 429
114
- retry_after = body["retry_after"] || body["retryAfter"]
115
- RateLimitError.new(message, retry_after: retry_after)
116
- when 500..599
117
- ServerError.new(message, status_code: status)
118
- else
119
- APIError.new(message, status_code: status, code: code, details: details)
120
- end
122
+ error = case status
123
+ when 400, 422
124
+ ValidationError.new(message, details: details, field_errors: body["errors"])
125
+ when 401
126
+ AuthenticationError.new(message)
127
+ when 402
128
+ InsufficientCreditsError.new(message)
129
+ when 404
130
+ NotFoundError.new(message)
131
+ when 429
132
+ retry_after = body["retry_after"] || body["retryAfter"]
133
+ RateLimitError.new(message, retry_after: retry_after)
134
+ when 500..599
135
+ ServerError.new(message, status_code: status)
136
+ else
137
+ APIError.new(message, status_code: status, code: code, details: details)
138
+ end
139
+ error.with_response_body(body)
121
140
  end
122
141
  end
123
142
  end
@@ -55,7 +55,8 @@ module Sendly
55
55
  # ISO-8601 timestamp string, or nil when no release is scheduled.
56
56
  :scheduled_release_at,
57
57
  # true when the number can place and receive phone calls
58
- # (switched on in the dashboard under Calls, then Settings).
58
+ # (switched on with {VoiceNumbersResource#update} or in the
59
+ # dashboard under Calls, then Settings).
59
60
  :voice_enabled,
60
61
  # One of {VOICE_MODES}: "none" when voice is off, "ring_dashboard"
61
62
  # when inbound calls ring the team, "agent" when an AI agent answers.
@@ -222,10 +222,6 @@ module Sendly
222
222
  end
223
223
 
224
224
  # Copy an existing template into a new draft.
225
- #
226
- # @note Not available yet: the versioned API serves no clone route, so
227
- # this call fails with a 404. To copy a template today, read it with
228
- # {#get} and pass its {Template#text} to {#create}.
229
225
  def clone(id, name: nil)
230
226
  body = {}
231
227
  body[:name] = name if name
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sendly
4
- VERSION = "4.1.0"
4
+ VERSION = "4.2.0"
5
5
  end
@@ -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
data/lib/sendly.rb CHANGED
@@ -28,6 +28,7 @@ require_relative "sendly/links_resource"
28
28
  require_relative "sendly/whatsapp_resource"
29
29
  require_relative "sendly/rcs_resource"
30
30
  require_relative "sendly/calls_resource"
31
+ require_relative "sendly/voice_resource"
31
32
 
32
33
  # Sendly Ruby SDK
33
34
  #
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sendly
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.1.0
4
+ version: 4.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sendly
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-14 00:00:00.000000000 Z
11
+ date: 2026-09-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -146,6 +146,7 @@ files:
146
146
  - lib/sendly/types.rb
147
147
  - lib/sendly/verify.rb
148
148
  - lib/sendly/version.rb
149
+ - lib/sendly/voice_resource.rb
149
150
  - lib/sendly/webhooks.rb
150
151
  - lib/sendly/webhooks_resource.rb
151
152
  - lib/sendly/whatsapp_resource.rb