sendly 4.0.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/Gemfile.lock +3 -3
- data/README.md +168 -0
- data/lib/sendly/calls_resource.rb +413 -0
- data/lib/sendly/client.rb +24 -2
- data/lib/sendly/errors.rb +36 -17
- data/lib/sendly/numbers_resource.rb +23 -2
- data/lib/sendly/templates_resource.rb +0 -4
- data/lib/sendly/version.rb +1 -1
- data/lib/sendly/voice_resource.rb +721 -0
- data/lib/sendly/webhooks.rb +4 -0
- data/lib/sendly.rb +2 -0
- metadata +4 -2
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,39 @@
|
|
|
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
|
+
|
|
21
|
+
## 4.1.0
|
|
22
|
+
|
|
23
|
+
### Minor Changes
|
|
24
|
+
|
|
25
|
+
- **Voice calls: `client.calls`.** Place phone calls handled by your AI agents, list and inspect them, end one early and fetch recordings, over the new `/api/v1/calls` routes. `create(to:, agent_id:, from: nil, context: nil, metadata: nil)` returns a `Sendly::Call` that is `ringing`; `list` takes `limit:`, `offset:`, `status:`, `direction:`, `kind:`, `agent_id:`, `to:` and `from:` and returns an Enumerable `Sendly::CallList` with `total`, `limit`, `offset` and `has_more?`; `get(id)` adds the `transcript` (an array of `Sendly::CallTranscriptLine`) on agent-handled calls and leaves it `nil` otherwise; `hangup(id)` cancels a ringing call or completes an active one and returns an already-ended call unchanged; `recording(id)` returns a `Sendly::CallRecording` whose signed `url` is set only while `ready?` and expires after five minutes. `create` and `hangup` send the client's usual `Idempotency-Key` and accept `idempotency_key:`. `Sendly::Call` also reads the snake_case object carried by the `call.started`, `call.completed` and `call.recording.ready` webhooks, including the new `billing` and `metadata` keys. Vocabularies are published as `Sendly::Call::STATUSES`, `::HANGUP_CLASSES` and `::ERROR_CODES`. `Sendly::PhoneNumber` gains `voice_enabled` (with `voice_enabled?`), `voice_mode` (`Sendly::PhoneNumber::VOICE_MODES`) and `raw`, so `client.numbers.list` can pick the `from` number for a call. Reads need the `calls:read` scope; writes need `calls:write` and a live key. Voice is enabled workspace by workspace: until it is on for yours the routes answer 404 `voice_not_enabled`, which raises `Sendly::NotFoundError`.
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
call = client.calls.create(
|
|
29
|
+
to: "+15555550123",
|
|
30
|
+
agent_id: "3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b",
|
|
31
|
+
context: "Confirm the 3pm appointment on Tuesday."
|
|
32
|
+
)
|
|
33
|
+
call = client.calls.get(call.id)
|
|
34
|
+
call.transcript&.each { |line| puts "#{line.speaker}: #{line.text}" }
|
|
35
|
+
```
|
|
36
|
+
|
|
3
37
|
## 4.0.0
|
|
4
38
|
|
|
5
39
|
**Upgrading from 3.40.0:** that release already contained the breaking changes below, published by mistake as a minor version. 4.0.0 carries them under the correct major. Relative to 3.40.0, the only new changes are under **Security**.
|
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
|
@@ -802,6 +802,7 @@ end
|
|
|
802
802
|
# Get one number you own (includes is_default, which the list omits)
|
|
803
803
|
number = client.numbers.get('num_abc123')
|
|
804
804
|
puts "#{number.phone_number} — default sender: #{number.is_default}"
|
|
805
|
+
puts "voice: #{number.voice_enabled?} (#{number.voice_mode})"
|
|
805
806
|
|
|
806
807
|
# Update a number — make it the default sender (must be active),
|
|
807
808
|
# and/or cancel a scheduled release ("keep this number")
|
|
@@ -1171,6 +1172,173 @@ client.messages.send(
|
|
|
1171
1172
|
)
|
|
1172
1173
|
```
|
|
1173
1174
|
|
|
1175
|
+
## Voice Calls
|
|
1176
|
+
|
|
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 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`
|
|
1184
|
+
from `client.numbers.list` carries `voice_enabled?` and `voice_mode`
|
|
1185
|
+
(`"none"`, `"ring_dashboard"` or `"agent"`) so you can pick a `from`:
|
|
1186
|
+
|
|
1187
|
+
```ruby
|
|
1188
|
+
from = client.numbers.list[:numbers].find(&:voice_enabled?)&.phone_number
|
|
1189
|
+
```
|
|
1190
|
+
|
|
1191
|
+
Calls are prepaid from your credit balance per started minute: 2 credits a
|
|
1192
|
+
minute outbound, plus 8 a minute while an AI agent is on the call (10 in
|
|
1193
|
+
total for an API-placed call). Unanswered calls cost nothing. Destinations
|
|
1194
|
+
are US and Canadian numbers. Reads need the `calls:read` scope; `create`
|
|
1195
|
+
and `hangup` need `calls:write` and a live key.
|
|
1196
|
+
|
|
1197
|
+
> **Rolling out.** Voice is enabled workspace by workspace. Until it is on
|
|
1198
|
+
> for yours, every `client.calls` and `client.voice` method raises
|
|
1199
|
+
> `Sendly::NotFoundError` (`voice_not_enabled`).
|
|
1200
|
+
|
|
1201
|
+
```ruby
|
|
1202
|
+
# Place a call. Returns at once with the call ringing; the agent greets the
|
|
1203
|
+
# callee when they answer and uses `context` for this call only.
|
|
1204
|
+
call = client.calls.create(
|
|
1205
|
+
to: "+15555550123",
|
|
1206
|
+
agent_id: "3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b",
|
|
1207
|
+
from: "+15555550188", # optional when you have one voice number
|
|
1208
|
+
context: "Confirm the 3pm appointment on Tuesday.",
|
|
1209
|
+
metadata: { "crmId" => "lead_8812" } # up to 20 string pairs, echoed everywhere
|
|
1210
|
+
)
|
|
1211
|
+
puts call.id
|
|
1212
|
+
puts call.status # "ringing"
|
|
1213
|
+
puts call.handled_by # "agent"
|
|
1214
|
+
|
|
1215
|
+
# Follow it. Agent-handled calls include a transcript once fetched by id.
|
|
1216
|
+
call = client.calls.get(call.id)
|
|
1217
|
+
puts call.status # "ringing" -> "active" -> "completed" (or no_answer, busy, ...)
|
|
1218
|
+
puts call.hangup_class # why it ended, e.g. "agent_agent_hangup"
|
|
1219
|
+
puts call.credits_charged
|
|
1220
|
+
call.transcript&.each { |line| puts "#{line.speaker}: #{line.text}" }
|
|
1221
|
+
|
|
1222
|
+
# List (newest first; limit 1-100, default 50)
|
|
1223
|
+
page = client.calls.list(status: "completed", direction: "outbound", agent_id: call.agent_id, limit: 20)
|
|
1224
|
+
page.each { |c| puts "#{c.to} #{c.duration_secs}s #{c.credits_charged} credits" }
|
|
1225
|
+
puts page.total
|
|
1226
|
+
puts page.has_more?
|
|
1227
|
+
|
|
1228
|
+
# End a call. Ringing -> "cancelled", active -> "completed"; a call that
|
|
1229
|
+
# has already ended comes back unchanged.
|
|
1230
|
+
client.calls.hangup(call.id)
|
|
1231
|
+
|
|
1232
|
+
# Recording. The URL is signed and valid for five minutes; it is nil until
|
|
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.
|
|
1235
|
+
rec = client.calls.recording(call.id)
|
|
1236
|
+
if rec.ready?
|
|
1237
|
+
File.binwrite("#{call.id}.ogg", Net::HTTP.get(URI(rec.url)))
|
|
1238
|
+
end
|
|
1239
|
+
```
|
|
1240
|
+
|
|
1241
|
+
Call errors map onto the usual classes: `Sendly::InsufficientCreditsError`
|
|
1242
|
+
when the balance cannot cover one minute at the agent rate;
|
|
1243
|
+
`Sendly::NotFoundError` for `voice_not_enabled`, `outbound_calls_not_enabled`,
|
|
1244
|
+
`agent_not_found`, `number_not_found` and `call_not_found`;
|
|
1245
|
+
`Sendly::ValidationError` for `invalid_number`, `destination_not_supported`,
|
|
1246
|
+
`agent_required`, `invalid_metadata` and `from_number_required`;
|
|
1247
|
+
`Sendly::RateLimitError` for `daily_call_limit`; `Sendly::APIError` with
|
|
1248
|
+
`status_code` 428 for `e911_required` (register an emergency address for the
|
|
1249
|
+
number), 409 for `agent_disabled`, `no_voice_number` and `lines_busy` (retry
|
|
1250
|
+
shortly), or 403 for `live_key_required` and a key missing the scope; and
|
|
1251
|
+
`Sendly::ServerError` (a sibling of `APIError`, not a subclass) for 503
|
|
1252
|
+
`voice_unavailable` / `agents_unavailable` and 500 `voice_internal_error`.
|
|
1253
|
+
The full list is `Sendly::Call::ERROR_CODES`; the hangup vocabulary is
|
|
1254
|
+
`Sendly::Call::HANGUP_CLASSES`.
|
|
1255
|
+
|
|
1256
|
+
`call.started`, `call.completed` and `call.recording.ready` webhooks carry
|
|
1257
|
+
the same object in snake_case (`handled_by`, `hangup_class`, `billing`,
|
|
1258
|
+
`metadata`, ...); `Sendly::Call.new(event.raw_object)` reads it.
|
|
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
|
+
|
|
1174
1342
|
## Error Handling
|
|
1175
1343
|
|
|
1176
1344
|
```ruby
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sendly
|
|
4
|
+
# One line of what was said on an agent-handled call. +speaker+ is
|
|
5
|
+
# "caller" or "agent"; +at_ms+ is the offset from the start of the call.
|
|
6
|
+
class CallTranscriptLine
|
|
7
|
+
SPEAKERS = %w[caller agent].freeze
|
|
8
|
+
|
|
9
|
+
attr_reader :speaker, :text, :at_ms
|
|
10
|
+
|
|
11
|
+
def initialize(data)
|
|
12
|
+
data ||= {}
|
|
13
|
+
@speaker = data["speaker"]
|
|
14
|
+
@text = data["text"]
|
|
15
|
+
@at_ms = data["atMs"] || data["at_ms"]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_h
|
|
19
|
+
{ speaker: speaker, text: text, at_ms: at_ms }.compact
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A phone call placed or received by one of your workspace's numbers.
|
|
24
|
+
#
|
|
25
|
+
# +kind+ is "pstn" for a phone call and "internal" for a browser-to-browser
|
|
26
|
+
# call between teammates. +status+ is "ringing" or "active" while the call
|
|
27
|
+
# is live and one of the terminal values ("completed", "no_answer", "busy",
|
|
28
|
+
# "cancelled", "declined", "failed") once it has ended; "suspended" can
|
|
29
|
+
# appear on an internal call whose media dropped and may recover.
|
|
30
|
+
# +handled_by+ says whether an AI agent ("agent") or the team in the
|
|
31
|
+
# dashboard ("dashboard") took the call. +billing+ is "metered" while a
|
|
32
|
+
# phone call is charged per started minute, "settled" once it has ended,
|
|
33
|
+
# and "unbilled" for calls that are never charged. +hangup_class+ says why
|
|
34
|
+
# the call ended (see {HANGUP_CLASSES}); anything unrecognised arrives as
|
|
35
|
+
# "ended". +metadata+ is the string map attached on create (+{}+ when
|
|
36
|
+
# none). +transcript+ is only present on {CallsResource#get} for
|
|
37
|
+
# agent-handled calls and is +nil+ otherwise.
|
|
38
|
+
class Call
|
|
39
|
+
STATUSES = %w[ringing active completed no_answer busy cancelled declined failed suspended].freeze
|
|
40
|
+
LIVE_STATUSES = %w[ringing active].freeze
|
|
41
|
+
DIRECTIONS = %w[inbound outbound].freeze
|
|
42
|
+
KINDS = %w[pstn internal].freeze
|
|
43
|
+
HANDLED_BY = %w[agent dashboard].freeze
|
|
44
|
+
BILLING_STATES = %w[metered settled unbilled].freeze
|
|
45
|
+
RECORDING_STATUSES = %w[recording ready failed].freeze
|
|
46
|
+
HANGUP_CLASSES = %w[
|
|
47
|
+
normal caller_hung_up callee_hung_up caller_left peer_left agent_ended
|
|
48
|
+
agent_agent_hangup agent_caller_left
|
|
49
|
+
ring_timeout callee_declined callee_busy caller_cancelled room_closed_unanswered
|
|
50
|
+
agent_left_unanswered agent_caller_never_joined invalid_number destination_rejected
|
|
51
|
+
max_duration credits_exhausted media_aborted peer_connection_lost room_closed agent_left
|
|
52
|
+
setup_failed agent_dispatch_failed agent_api_unreachable agent_already_ended
|
|
53
|
+
ended
|
|
54
|
+
].freeze
|
|
55
|
+
ERROR_CODES = %w[
|
|
56
|
+
voice_unavailable agents_unavailable voice_not_enabled outbound_calls_not_enabled
|
|
57
|
+
agent_required agent_not_found agent_disabled invalid_metadata from_number_required
|
|
58
|
+
no_voice_number number_not_found destination_not_supported e911_required lines_busy
|
|
59
|
+
daily_call_limit call_not_found live_key_required voice_internal_error
|
|
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
|
|
63
|
+
].freeze
|
|
64
|
+
|
|
65
|
+
attr_reader :id, :object, :kind, :direction, :status, :handled_by, :agent_id,
|
|
66
|
+
:from, :to, :caller_name, :callee_name, :started_at, :answered_at,
|
|
67
|
+
:ended_at, :duration_secs, :credits_charged, :billing, :hangup_class,
|
|
68
|
+
:recording_status, :metadata, :transcript
|
|
69
|
+
|
|
70
|
+
# @return [Hash] The raw parsed response
|
|
71
|
+
attr_reader :raw
|
|
72
|
+
|
|
73
|
+
def initialize(data)
|
|
74
|
+
data ||= {}
|
|
75
|
+
@raw = data
|
|
76
|
+
@id = data["id"]
|
|
77
|
+
@object = data["object"] || "call"
|
|
78
|
+
@kind = data["kind"]
|
|
79
|
+
@direction = data["direction"]
|
|
80
|
+
@status = data["status"]
|
|
81
|
+
@handled_by = data["handledBy"] || data["handled_by"]
|
|
82
|
+
@agent_id = data["agentId"] || data["agent_id"]
|
|
83
|
+
@from = data["from"]
|
|
84
|
+
@to = data["to"]
|
|
85
|
+
@caller_name = data["callerName"] || data["caller_name"]
|
|
86
|
+
@callee_name = data["calleeName"] || data["callee_name"]
|
|
87
|
+
@started_at = data["startedAt"] || data["started_at"]
|
|
88
|
+
@answered_at = data["answeredAt"] || data["answered_at"]
|
|
89
|
+
@ended_at = data["endedAt"] || data["ended_at"]
|
|
90
|
+
@duration_secs = data["durationSecs"] || data["duration_secs"] || 0
|
|
91
|
+
@credits_charged = data["creditsCharged"] || data["credits_charged"] || 0
|
|
92
|
+
@billing = data["billing"]
|
|
93
|
+
@hangup_class = data["hangupClass"] || data["hangup_class"]
|
|
94
|
+
@recording_status = data["recordingStatus"] || data["recording_status"]
|
|
95
|
+
@metadata = data["metadata"] || {}
|
|
96
|
+
lines = data["transcript"]
|
|
97
|
+
@transcript = lines.is_a?(Array) ? lines.map { |l| CallTranscriptLine.new(l) } : nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [Boolean] Whether the call is still ringing or in progress
|
|
101
|
+
def live?
|
|
102
|
+
LIVE_STATUSES.include?(status)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @return [Boolean] Whether the call has reached a terminal status
|
|
106
|
+
def ended?
|
|
107
|
+
!status.nil? && !live? && status != "suspended"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def answered?
|
|
111
|
+
!answered_at.nil?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def agent_handled?
|
|
115
|
+
handled_by == "agent"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def inbound?
|
|
119
|
+
direction == "inbound"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def outbound?
|
|
123
|
+
direction == "outbound"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def to_h
|
|
127
|
+
{
|
|
128
|
+
id: id, object: object, kind: kind, direction: direction, status: status,
|
|
129
|
+
handled_by: handled_by, agent_id: agent_id, from: from, to: to,
|
|
130
|
+
caller_name: caller_name, callee_name: callee_name, started_at: started_at,
|
|
131
|
+
answered_at: answered_at, ended_at: ended_at, duration_secs: duration_secs,
|
|
132
|
+
credits_charged: credits_charged, billing: billing, hangup_class: hangup_class,
|
|
133
|
+
recording_status: recording_status, metadata: metadata,
|
|
134
|
+
transcript: transcript&.map(&:to_h)
|
|
135
|
+
}.compact
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A page of calls, newest first, with the pagination the API returned.
|
|
140
|
+
class CallList
|
|
141
|
+
include Enumerable
|
|
142
|
+
|
|
143
|
+
attr_reader :data, :total, :limit, :offset, :has_more
|
|
144
|
+
|
|
145
|
+
def initialize(response)
|
|
146
|
+
@data = (response["data"] || []).map { |c| Call.new(c) }
|
|
147
|
+
pagination = response["pagination"] || {}
|
|
148
|
+
@total = pagination["total"] || @data.length
|
|
149
|
+
@limit = pagination["limit"] || 50
|
|
150
|
+
@offset = pagination["offset"] || 0
|
|
151
|
+
@has_more = pagination["hasMore"] || pagination["has_more"] || false
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def has_more?
|
|
155
|
+
has_more == true
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def each(&block)
|
|
159
|
+
data.each(&block)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def count
|
|
163
|
+
data.length
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
alias size count
|
|
167
|
+
alias length count
|
|
168
|
+
|
|
169
|
+
def empty?
|
|
170
|
+
data.empty?
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def first
|
|
174
|
+
data.first
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def last
|
|
178
|
+
data.last
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# The recording of a call. +status+ is "none" when there is no recording
|
|
183
|
+
# (recording off, or the call was never answered), "recording" while the
|
|
184
|
+
# call runs, "ready" once it can be fetched, or "failed". +url+ and
|
|
185
|
+
# +expires_at+ are set only when {#ready?}: the URL is signed and valid for
|
|
186
|
+
# five minutes. Recordings are Ogg/Opus (+content_type+ "audio/ogg");
|
|
187
|
+
# agent-handled calls are recorded dual-channel, with the agent on the
|
|
188
|
+
# left channel and the other party on the right.
|
|
189
|
+
class CallRecording
|
|
190
|
+
STATUSES = %w[none recording ready failed].freeze
|
|
191
|
+
|
|
192
|
+
attr_reader :call_id, :status, :url, :expires_at, :content_type
|
|
193
|
+
|
|
194
|
+
def initialize(data)
|
|
195
|
+
data ||= {}
|
|
196
|
+
@call_id = data["callId"] || data["call_id"]
|
|
197
|
+
@status = data["status"]
|
|
198
|
+
@url = data["url"]
|
|
199
|
+
@expires_at = data["expiresAt"] || data["expires_at"]
|
|
200
|
+
@content_type = data["contentType"] || data["content_type"]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def ready?
|
|
204
|
+
status == "ready"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def to_h
|
|
208
|
+
{
|
|
209
|
+
call_id: call_id, status: status, url: url, expires_at: expires_at,
|
|
210
|
+
content_type: content_type
|
|
211
|
+
}.compact
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Calls resource: place phone calls handled by your AI agents, list and
|
|
216
|
+
# inspect calls, end a call and fetch recordings.
|
|
217
|
+
#
|
|
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
|
|
222
|
+
# started minute from your credit balance: 2 credits a minute outbound,
|
|
223
|
+
# plus 8 a minute while an agent is on the call. Destinations are US and
|
|
224
|
+
# Canadian numbers. Reads need the +calls:read+ scope, writes
|
|
225
|
+
# +calls:write+ and a live API key (+sk_live_v1_xxx+).
|
|
226
|
+
#
|
|
227
|
+
# Voice is enabled workspace by workspace. Until it is on for yours,
|
|
228
|
+
# every method here raises {Sendly::NotFoundError} (+voice_not_enabled+).
|
|
229
|
+
#
|
|
230
|
+
# Error codes map onto the usual classes: {Sendly::NotFoundError} for
|
|
231
|
+
# +voice_not_enabled+, +outbound_calls_not_enabled+, +agent_not_found+,
|
|
232
|
+
# +number_not_found+ and +call_not_found+; {Sendly::ValidationError} for
|
|
233
|
+
# +invalid_number+, +destination_not_supported+, +agent_required+,
|
|
234
|
+
# +invalid_metadata+ and +from_number_required+;
|
|
235
|
+
# {Sendly::InsufficientCreditsError} for +insufficient_credits+;
|
|
236
|
+
# {Sendly::RateLimitError} for +daily_call_limit+ and
|
|
237
|
+
# +rate_limit_exceeded+; {Sendly::APIError} with the HTTP status for
|
|
238
|
+
# +e911_required+ (428), +agent_disabled+ / +no_voice_number+ /
|
|
239
|
+
# +lines_busy+ (409) and +live_key_required+ / +forbidden+ (403); and
|
|
240
|
+
# {Sendly::ServerError} (not an +APIError+) for +voice_unavailable+ /
|
|
241
|
+
# +agents_unavailable+ (503) and +voice_internal_error+ (500). The full
|
|
242
|
+
# list is {Call::ERROR_CODES}.
|
|
243
|
+
#
|
|
244
|
+
# @example Place a call and wait for it to end
|
|
245
|
+
# call = client.calls.create(
|
|
246
|
+
# to: "+15555550123",
|
|
247
|
+
# agent_id: "3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b",
|
|
248
|
+
# context: "Confirm the 3pm appointment on Tuesday."
|
|
249
|
+
# )
|
|
250
|
+
# call = client.calls.get(call.id) while call.live? && sleep(2)
|
|
251
|
+
# puts call.hangup_class
|
|
252
|
+
# call.transcript.each { |line| puts "#{line.speaker}: #{line.text}" }
|
|
253
|
+
class CallsResource
|
|
254
|
+
def initialize(client)
|
|
255
|
+
@client = client
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Place a phone call that one of your AI agents handles. Returns at once
|
|
259
|
+
# with the call +ringing+; poll {#get} or subscribe to the +call.started+
|
|
260
|
+
# and +call.completed+ webhooks to follow it. Requires the +calls:write+
|
|
261
|
+
# scope and a live key.
|
|
262
|
+
#
|
|
263
|
+
# @param to [String] The number to call, in E.164 format (US or Canada)
|
|
264
|
+
# @param agent_id [String] The AI agent that talks on the call
|
|
265
|
+
# @param from [String, nil] A voice-enabled number in your workspace.
|
|
266
|
+
# Optional when the workspace has exactly one; required (the API
|
|
267
|
+
# responds 400 +from_number_required+) when it has more.
|
|
268
|
+
# @param context [String, nil] Up to 2000 characters appended to the
|
|
269
|
+
# agent's instructions for this call only. Not echoed back.
|
|
270
|
+
# @param metadata [Hash{String => String}, nil] Up to 20 string pairs
|
|
271
|
+
# (keys 1-40 characters of +A-Z a-z 0-9 _ . : -+, values up to 500
|
|
272
|
+
# characters). Stored, echoed on every read and in every +call.*+
|
|
273
|
+
# webhook.
|
|
274
|
+
# @param idempotency_key [String, nil] Idempotency key for this operation
|
|
275
|
+
# @return [Sendly::Call] The new call (+status+ "ringing", +handled_by+ "agent")
|
|
276
|
+
# @raise [Sendly::ValidationError] If +to+ or +agent_id+ is missing, or
|
|
277
|
+
# HTTP 400 (+invalid_number+, +destination_not_supported+,
|
|
278
|
+
# +agent_required+, +invalid_metadata+, +from_number_required+)
|
|
279
|
+
# @raise [Sendly::NotFoundError] HTTP 404 (+voice_not_enabled+,
|
|
280
|
+
# +outbound_calls_not_enabled+, +agent_not_found+, +number_not_found+)
|
|
281
|
+
# @raise [Sendly::InsufficientCreditsError] HTTP 402 when the balance
|
|
282
|
+
# cannot cover one minute at the agent rate
|
|
283
|
+
# @raise [Sendly::APIError] HTTP 428 +e911_required+ (register an
|
|
284
|
+
# emergency address for the number first with
|
|
285
|
+
# {VoiceNumbersResource#register_emergency_address}), 409 +agent_disabled+ /
|
|
286
|
+
# +no_voice_number+ / +lines_busy+, 403 +live_key_required+
|
|
287
|
+
# @raise [Sendly::RateLimitError] HTTP 429 +daily_call_limit+ / +rate_limit_exceeded+
|
|
288
|
+
# @raise [Sendly::ServerError] HTTP 503 +voice_unavailable+ /
|
|
289
|
+
# +agents_unavailable+ (calling is not switched on for this deployment),
|
|
290
|
+
# HTTP 500 +voice_internal_error+
|
|
291
|
+
#
|
|
292
|
+
# @example
|
|
293
|
+
# call = client.calls.create(
|
|
294
|
+
# to: "+15555550123",
|
|
295
|
+
# agent_id: "3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b",
|
|
296
|
+
# from: "+15555550188",
|
|
297
|
+
# metadata: { "crmId" => "lead_8812" }
|
|
298
|
+
# )
|
|
299
|
+
# puts call.id
|
|
300
|
+
def create(to:, agent_id:, from: nil, context: nil, metadata: nil, idempotency_key: nil)
|
|
301
|
+
raise ValidationError, "to is required" if to.nil? || to.to_s.empty?
|
|
302
|
+
raise ValidationError, "agent_id is required" if agent_id.nil? || agent_id.to_s.empty?
|
|
303
|
+
|
|
304
|
+
body = { to: to, agentId: agent_id }
|
|
305
|
+
body[:from] = from unless from.nil?
|
|
306
|
+
body[:context] = context unless context.nil?
|
|
307
|
+
body[:metadata] = metadata unless metadata.nil?
|
|
308
|
+
|
|
309
|
+
response = @client.post("/calls", body, idempotency_key: idempotency_key)
|
|
310
|
+
Call.new(response)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# List your workspace's calls, newest first. Live rows are reconciled
|
|
314
|
+
# before they are returned, so a ring past its deadline reads as
|
|
315
|
+
# +no_answer+. Requires the +calls:read+ scope.
|
|
316
|
+
#
|
|
317
|
+
# @param limit [Integer, nil] Calls per page (1-100, default 50)
|
|
318
|
+
# @param offset [Integer, nil] Calls to skip (default 0)
|
|
319
|
+
# @param status [String, nil] One of {Call::STATUSES}
|
|
320
|
+
# @param direction [String, nil] "inbound" or "outbound"
|
|
321
|
+
# @param kind [String, nil] "pstn" or "internal"
|
|
322
|
+
# @param agent_id [String, nil] Only calls handled by this agent
|
|
323
|
+
# @param to [String, nil] Exact E.164 match on the called number
|
|
324
|
+
# @param from [String, nil] Exact E.164 match on the calling number
|
|
325
|
+
# @return [Sendly::CallList] The page and its pagination
|
|
326
|
+
# @raise [Sendly::ValidationError] HTTP 400 +invalid_request+ for a value
|
|
327
|
+
# outside the vocabularies above
|
|
328
|
+
#
|
|
329
|
+
# @example
|
|
330
|
+
# page = client.calls.list(status: "completed", direction: "outbound", limit: 20)
|
|
331
|
+
# page.each { |c| puts "#{c.to} #{c.duration_secs}s #{c.credits_charged} credits" }
|
|
332
|
+
# puts page.has_more?
|
|
333
|
+
def list(limit: nil, offset: nil, status: nil, direction: nil, kind: nil,
|
|
334
|
+
agent_id: nil, to: nil, from: nil)
|
|
335
|
+
params = {}
|
|
336
|
+
params[:limit] = limit unless limit.nil?
|
|
337
|
+
params[:offset] = offset unless offset.nil?
|
|
338
|
+
params[:status] = status unless status.nil?
|
|
339
|
+
params[:direction] = direction unless direction.nil?
|
|
340
|
+
params[:kind] = kind unless kind.nil?
|
|
341
|
+
params[:agentId] = agent_id unless agent_id.nil?
|
|
342
|
+
params[:to] = to unless to.nil?
|
|
343
|
+
params[:from] = from unless from.nil?
|
|
344
|
+
|
|
345
|
+
response = @client.get("/calls", params)
|
|
346
|
+
CallList.new(response)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Fetch one call. Agent-handled calls include their +transcript+; a live
|
|
350
|
+
# call is reconciled first. Requires the +calls:read+ scope.
|
|
351
|
+
#
|
|
352
|
+
# @param id [String] Call identifier
|
|
353
|
+
# @return [Sendly::Call]
|
|
354
|
+
# @raise [Sendly::NotFoundError] HTTP 404 +call_not_found+ when the call
|
|
355
|
+
# is not in your workspace
|
|
356
|
+
#
|
|
357
|
+
# @example
|
|
358
|
+
# call = client.calls.get("6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f")
|
|
359
|
+
# call.transcript&.each { |line| puts "#{line.speaker}: #{line.text}" }
|
|
360
|
+
def get(id)
|
|
361
|
+
encoded_id = encode_id!(id)
|
|
362
|
+
response = @client.get("/calls/#{encoded_id}")
|
|
363
|
+
Call.new(response)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# End a call. A ringing call becomes +cancelled+ (+hangup_class+
|
|
367
|
+
# "caller_cancelled") and the callee stops ringing; an active call
|
|
368
|
+
# becomes +completed+ ("normal"). Hanging up a call that has already
|
|
369
|
+
# ended returns it unchanged. Requires the +calls:write+ scope and a
|
|
370
|
+
# live key.
|
|
371
|
+
#
|
|
372
|
+
# @param id [String] Call identifier
|
|
373
|
+
# @param idempotency_key [String, nil] Idempotency key for this operation
|
|
374
|
+
# @return [Sendly::Call] The call after the hangup
|
|
375
|
+
# @raise [Sendly::NotFoundError] HTTP 404 +call_not_found+
|
|
376
|
+
#
|
|
377
|
+
# @example
|
|
378
|
+
# call = client.calls.hangup(call.id)
|
|
379
|
+
# puts call.status # "cancelled" or "completed"
|
|
380
|
+
def hangup(id, idempotency_key: nil)
|
|
381
|
+
encoded_id = encode_id!(id)
|
|
382
|
+
response = @client.post("/calls/#{encoded_id}/hangup", {}, idempotency_key: idempotency_key)
|
|
383
|
+
Call.new(response)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Fetch the recording of a call. +url+ is a signed link valid for five
|
|
387
|
+
# minutes and is only set once the recording is +ready?+; fetch again
|
|
388
|
+
# for a fresh link. Requires the +calls:read+ scope.
|
|
389
|
+
#
|
|
390
|
+
# @param id [String] Call identifier
|
|
391
|
+
# @return [Sendly::CallRecording]
|
|
392
|
+
# @raise [Sendly::NotFoundError] HTTP 404 +call_not_found+
|
|
393
|
+
#
|
|
394
|
+
# @example
|
|
395
|
+
# rec = client.calls.recording(call.id)
|
|
396
|
+
# if rec.ready?
|
|
397
|
+
# File.binwrite("#{call.id}.ogg", Net::HTTP.get(URI(rec.url)))
|
|
398
|
+
# end
|
|
399
|
+
def recording(id)
|
|
400
|
+
encoded_id = encode_id!(id)
|
|
401
|
+
response = @client.get("/calls/#{encoded_id}/recording")
|
|
402
|
+
CallRecording.new(response)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
private
|
|
406
|
+
|
|
407
|
+
def encode_id!(id)
|
|
408
|
+
raise ValidationError, "Call ID is required" if id.nil? || id.to_s.empty?
|
|
409
|
+
|
|
410
|
+
URI.encode_www_form_component(id)
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
end
|