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 +4 -4
- data/CHANGELOG.md +18 -0
- data/Gemfile.lock +3 -3
- data/README.md +92 -7
- data/lib/sendly/calls_resource.rb +10 -6
- data/lib/sendly/client.rb +17 -2
- data/lib/sendly/errors.rb +36 -17
- data/lib/sendly/numbers_resource.rb +2 -1
- 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.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6e04f0698b7520abcb9cd17816198b7e728ab2ce6d2ed1d564195887334dca82
|
|
4
|
+
data.tar.gz: 9f26320067a3fe83a7035364cdf6682e03f9b93bf26996faf728cbfb9899065c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
1179
|
-
the dashboard under Calls, then Agents; the
|
|
1180
|
-
|
|
1181
|
-
registered before it can place outbound calls
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
217
|
-
#
|
|
218
|
-
#
|
|
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
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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
|
data/lib/sendly/version.rb
CHANGED
|
@@ -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
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.
|
|
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-
|
|
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
|