sendly 4.0.0 → 4.1.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 +16 -0
- data/Gemfile.lock +1 -1
- data/README.md +83 -0
- data/lib/sendly/calls_resource.rb +409 -0
- data/lib/sendly/client.rb +7 -0
- data/lib/sendly/numbers_resource.rb +22 -2
- data/lib/sendly/version.rb +1 -1
- data/lib/sendly/webhooks.rb +4 -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: 84b10285655be846bb857f5e11388a893d916838d480accfc46a646c16dd68a3
|
|
4
|
+
data.tar.gz: cfe12c76eeea0a6db834c09d97c7546260c4a313f5f6d9c64a549d467884ae3b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5c528f37943ed1c30eb8b6929097cb167ba9be070e0a8b3ec6d69949e9710a38ccbf1bdb04ce57d92a569fabdb68e56df3e50104e3ffffb620d9c6962d3e4e73
|
|
7
|
+
data.tar.gz: 73c7ba0af6ac53f75b33759b2566292f64ee5199c13f088da4ebb3b8bf68afea4b00fdddb76a0e7f3a7a1fa1547162ab94fa81a119d0b18004bce650ca6ed434
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# sendly (Ruby)
|
|
2
2
|
|
|
3
|
+
## 4.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- **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`.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
call = client.calls.create(
|
|
11
|
+
to: "+15555550123",
|
|
12
|
+
agent_id: "3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b",
|
|
13
|
+
context: "Confirm the 3pm appointment on Tuesday."
|
|
14
|
+
)
|
|
15
|
+
call = client.calls.get(call.id)
|
|
16
|
+
call.transcript&.each { |line| puts "#{line.speaker}: #{line.text}" }
|
|
17
|
+
```
|
|
18
|
+
|
|
3
19
|
## 4.0.0
|
|
4
20
|
|
|
5
21
|
**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
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,88 @@ 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 configured in
|
|
1179
|
+
the dashboard under Calls, then Agents; the number you call from must have
|
|
1180
|
+
voice switched on there (Calls, then Settings) and an emergency address
|
|
1181
|
+
registered before it can place outbound calls. Each `Sendly::PhoneNumber`
|
|
1182
|
+
from `client.numbers.list` carries `voice_enabled?` and `voice_mode`
|
|
1183
|
+
(`"none"`, `"ring_dashboard"` or `"agent"`) so you can pick a `from`:
|
|
1184
|
+
|
|
1185
|
+
```ruby
|
|
1186
|
+
from = client.numbers.list[:numbers].find(&:voice_enabled?)&.phone_number
|
|
1187
|
+
```
|
|
1188
|
+
|
|
1189
|
+
Calls are prepaid from your credit balance per started minute: 2 credits a
|
|
1190
|
+
minute outbound, plus 8 a minute while an AI agent is on the call (10 in
|
|
1191
|
+
total for an API-placed call). Unanswered calls cost nothing. Destinations
|
|
1192
|
+
are US and Canadian numbers. Reads need the `calls:read` scope; `create`
|
|
1193
|
+
and `hangup` need `calls:write` and a live key.
|
|
1194
|
+
|
|
1195
|
+
> **Rolling out.** Voice is enabled workspace by workspace. Until it is on
|
|
1196
|
+
> for yours, every call method raises `Sendly::NotFoundError`
|
|
1197
|
+
> (`voice_not_enabled`).
|
|
1198
|
+
|
|
1199
|
+
```ruby
|
|
1200
|
+
# Place a call. Returns at once with the call ringing; the agent greets the
|
|
1201
|
+
# callee when they answer and uses `context` for this call only.
|
|
1202
|
+
call = client.calls.create(
|
|
1203
|
+
to: "+15555550123",
|
|
1204
|
+
agent_id: "3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b",
|
|
1205
|
+
from: "+15555550188", # optional when you have one voice number
|
|
1206
|
+
context: "Confirm the 3pm appointment on Tuesday.",
|
|
1207
|
+
metadata: { "crmId" => "lead_8812" } # up to 20 string pairs, echoed everywhere
|
|
1208
|
+
)
|
|
1209
|
+
puts call.id
|
|
1210
|
+
puts call.status # "ringing"
|
|
1211
|
+
puts call.handled_by # "agent"
|
|
1212
|
+
|
|
1213
|
+
# Follow it. Agent-handled calls include a transcript once fetched by id.
|
|
1214
|
+
call = client.calls.get(call.id)
|
|
1215
|
+
puts call.status # "ringing" -> "active" -> "completed" (or no_answer, busy, ...)
|
|
1216
|
+
puts call.hangup_class # why it ended, e.g. "agent_agent_hangup"
|
|
1217
|
+
puts call.credits_charged
|
|
1218
|
+
call.transcript&.each { |line| puts "#{line.speaker}: #{line.text}" }
|
|
1219
|
+
|
|
1220
|
+
# List (newest first; limit 1-100, default 50)
|
|
1221
|
+
page = client.calls.list(status: "completed", direction: "outbound", agent_id: call.agent_id, limit: 20)
|
|
1222
|
+
page.each { |c| puts "#{c.to} #{c.duration_secs}s #{c.credits_charged} credits" }
|
|
1223
|
+
puts page.total
|
|
1224
|
+
puts page.has_more?
|
|
1225
|
+
|
|
1226
|
+
# End a call. Ringing -> "cancelled", active -> "completed"; a call that
|
|
1227
|
+
# has already ended comes back unchanged.
|
|
1228
|
+
client.calls.hangup(call.id)
|
|
1229
|
+
|
|
1230
|
+
# Recording. The URL is signed and valid for five minutes; it is nil until
|
|
1231
|
+
# the recording is ready. Ogg/Opus, dual channel on agent calls.
|
|
1232
|
+
rec = client.calls.recording(call.id)
|
|
1233
|
+
if rec.ready?
|
|
1234
|
+
File.binwrite("#{call.id}.ogg", Net::HTTP.get(URI(rec.url)))
|
|
1235
|
+
end
|
|
1236
|
+
```
|
|
1237
|
+
|
|
1238
|
+
Call errors map onto the usual classes: `Sendly::InsufficientCreditsError`
|
|
1239
|
+
when the balance cannot cover one minute at the agent rate;
|
|
1240
|
+
`Sendly::NotFoundError` for `voice_not_enabled`, `outbound_calls_not_enabled`,
|
|
1241
|
+
`agent_not_found`, `number_not_found` and `call_not_found`;
|
|
1242
|
+
`Sendly::ValidationError` for `invalid_number`, `destination_not_supported`,
|
|
1243
|
+
`agent_required`, `invalid_metadata` and `from_number_required`;
|
|
1244
|
+
`Sendly::RateLimitError` for `daily_call_limit`; `Sendly::APIError` with
|
|
1245
|
+
`status_code` 428 for `e911_required` (register an emergency address for the
|
|
1246
|
+
number), 409 for `agent_disabled`, `no_voice_number` and `lines_busy` (retry
|
|
1247
|
+
shortly), or 403 for `live_key_required` and a key missing the scope; and
|
|
1248
|
+
`Sendly::ServerError` (a sibling of `APIError`, not a subclass) for 503
|
|
1249
|
+
`voice_unavailable` / `agents_unavailable` and 500 `voice_internal_error`.
|
|
1250
|
+
The full list is `Sendly::Call::ERROR_CODES`; the hangup vocabulary is
|
|
1251
|
+
`Sendly::Call::HANGUP_CLASSES`.
|
|
1252
|
+
|
|
1253
|
+
`call.started`, `call.completed` and `call.recording.ready` webhooks carry
|
|
1254
|
+
the same object in snake_case (`handled_by`, `hangup_class`, `billing`,
|
|
1255
|
+
`metadata`, ...); `Sendly::Call.new(event.raw_object)` reads it.
|
|
1256
|
+
|
|
1174
1257
|
## Error Handling
|
|
1175
1258
|
|
|
1176
1259
|
```ruby
|
|
@@ -0,0 +1,409 @@
|
|
|
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
|
+
].freeze
|
|
62
|
+
|
|
63
|
+
attr_reader :id, :object, :kind, :direction, :status, :handled_by, :agent_id,
|
|
64
|
+
:from, :to, :caller_name, :callee_name, :started_at, :answered_at,
|
|
65
|
+
:ended_at, :duration_secs, :credits_charged, :billing, :hangup_class,
|
|
66
|
+
:recording_status, :metadata, :transcript
|
|
67
|
+
|
|
68
|
+
# @return [Hash] The raw parsed response
|
|
69
|
+
attr_reader :raw
|
|
70
|
+
|
|
71
|
+
def initialize(data)
|
|
72
|
+
data ||= {}
|
|
73
|
+
@raw = data
|
|
74
|
+
@id = data["id"]
|
|
75
|
+
@object = data["object"] || "call"
|
|
76
|
+
@kind = data["kind"]
|
|
77
|
+
@direction = data["direction"]
|
|
78
|
+
@status = data["status"]
|
|
79
|
+
@handled_by = data["handledBy"] || data["handled_by"]
|
|
80
|
+
@agent_id = data["agentId"] || data["agent_id"]
|
|
81
|
+
@from = data["from"]
|
|
82
|
+
@to = data["to"]
|
|
83
|
+
@caller_name = data["callerName"] || data["caller_name"]
|
|
84
|
+
@callee_name = data["calleeName"] || data["callee_name"]
|
|
85
|
+
@started_at = data["startedAt"] || data["started_at"]
|
|
86
|
+
@answered_at = data["answeredAt"] || data["answered_at"]
|
|
87
|
+
@ended_at = data["endedAt"] || data["ended_at"]
|
|
88
|
+
@duration_secs = data["durationSecs"] || data["duration_secs"] || 0
|
|
89
|
+
@credits_charged = data["creditsCharged"] || data["credits_charged"] || 0
|
|
90
|
+
@billing = data["billing"]
|
|
91
|
+
@hangup_class = data["hangupClass"] || data["hangup_class"]
|
|
92
|
+
@recording_status = data["recordingStatus"] || data["recording_status"]
|
|
93
|
+
@metadata = data["metadata"] || {}
|
|
94
|
+
lines = data["transcript"]
|
|
95
|
+
@transcript = lines.is_a?(Array) ? lines.map { |l| CallTranscriptLine.new(l) } : nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @return [Boolean] Whether the call is still ringing or in progress
|
|
99
|
+
def live?
|
|
100
|
+
LIVE_STATUSES.include?(status)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [Boolean] Whether the call has reached a terminal status
|
|
104
|
+
def ended?
|
|
105
|
+
!status.nil? && !live? && status != "suspended"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def answered?
|
|
109
|
+
!answered_at.nil?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def agent_handled?
|
|
113
|
+
handled_by == "agent"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def inbound?
|
|
117
|
+
direction == "inbound"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def outbound?
|
|
121
|
+
direction == "outbound"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def to_h
|
|
125
|
+
{
|
|
126
|
+
id: id, object: object, kind: kind, direction: direction, status: status,
|
|
127
|
+
handled_by: handled_by, agent_id: agent_id, from: from, to: to,
|
|
128
|
+
caller_name: caller_name, callee_name: callee_name, started_at: started_at,
|
|
129
|
+
answered_at: answered_at, ended_at: ended_at, duration_secs: duration_secs,
|
|
130
|
+
credits_charged: credits_charged, billing: billing, hangup_class: hangup_class,
|
|
131
|
+
recording_status: recording_status, metadata: metadata,
|
|
132
|
+
transcript: transcript&.map(&:to_h)
|
|
133
|
+
}.compact
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# A page of calls, newest first, with the pagination the API returned.
|
|
138
|
+
class CallList
|
|
139
|
+
include Enumerable
|
|
140
|
+
|
|
141
|
+
attr_reader :data, :total, :limit, :offset, :has_more
|
|
142
|
+
|
|
143
|
+
def initialize(response)
|
|
144
|
+
@data = (response["data"] || []).map { |c| Call.new(c) }
|
|
145
|
+
pagination = response["pagination"] || {}
|
|
146
|
+
@total = pagination["total"] || @data.length
|
|
147
|
+
@limit = pagination["limit"] || 50
|
|
148
|
+
@offset = pagination["offset"] || 0
|
|
149
|
+
@has_more = pagination["hasMore"] || pagination["has_more"] || false
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def has_more?
|
|
153
|
+
has_more == true
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def each(&block)
|
|
157
|
+
data.each(&block)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def count
|
|
161
|
+
data.length
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
alias size count
|
|
165
|
+
alias length count
|
|
166
|
+
|
|
167
|
+
def empty?
|
|
168
|
+
data.empty?
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def first
|
|
172
|
+
data.first
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def last
|
|
176
|
+
data.last
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# The recording of a call. +status+ is "none" when there is no recording
|
|
181
|
+
# (recording off, or the call was never answered), "recording" while the
|
|
182
|
+
# call runs, "ready" once it can be fetched, or "failed". +url+ and
|
|
183
|
+
# +expires_at+ are set only when {#ready?}: the URL is signed and valid for
|
|
184
|
+
# five minutes. Recordings are Ogg/Opus (+content_type+ "audio/ogg");
|
|
185
|
+
# agent-handled calls are recorded dual-channel, caller left and agent
|
|
186
|
+
# right.
|
|
187
|
+
class CallRecording
|
|
188
|
+
STATUSES = %w[none recording ready failed].freeze
|
|
189
|
+
|
|
190
|
+
attr_reader :call_id, :status, :url, :expires_at, :content_type
|
|
191
|
+
|
|
192
|
+
def initialize(data)
|
|
193
|
+
data ||= {}
|
|
194
|
+
@call_id = data["callId"] || data["call_id"]
|
|
195
|
+
@status = data["status"]
|
|
196
|
+
@url = data["url"]
|
|
197
|
+
@expires_at = data["expiresAt"] || data["expires_at"]
|
|
198
|
+
@content_type = data["contentType"] || data["content_type"]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def ready?
|
|
202
|
+
status == "ready"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def to_h
|
|
206
|
+
{
|
|
207
|
+
call_id: call_id, status: status, url: url, expires_at: expires_at,
|
|
208
|
+
content_type: content_type
|
|
209
|
+
}.compact
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Calls resource: place phone calls handled by your AI agents, list and
|
|
214
|
+
# inspect calls, end a call and fetch recordings.
|
|
215
|
+
#
|
|
216
|
+
# A call placed over the API is answered by one of the AI agents you
|
|
217
|
+
# configure in the dashboard under Calls, then Agents; the +from+ number
|
|
218
|
+
# must have voice switched on in the dashboard. Calls are charged per
|
|
219
|
+
# started minute from your credit balance: 2 credits a minute outbound,
|
|
220
|
+
# plus 8 a minute while an agent is on the call. Destinations are US and
|
|
221
|
+
# Canadian numbers. Reads need the +calls:read+ scope, writes
|
|
222
|
+
# +calls:write+ and a live API key (+sk_live_v1_xxx+).
|
|
223
|
+
#
|
|
224
|
+
# Voice is enabled workspace by workspace. Until it is on for yours,
|
|
225
|
+
# every method here raises {Sendly::NotFoundError} (+voice_not_enabled+).
|
|
226
|
+
#
|
|
227
|
+
# Error codes map onto the usual classes: {Sendly::NotFoundError} for
|
|
228
|
+
# +voice_not_enabled+, +outbound_calls_not_enabled+, +agent_not_found+,
|
|
229
|
+
# +number_not_found+ and +call_not_found+; {Sendly::ValidationError} for
|
|
230
|
+
# +invalid_number+, +destination_not_supported+, +agent_required+,
|
|
231
|
+
# +invalid_metadata+ and +from_number_required+;
|
|
232
|
+
# {Sendly::InsufficientCreditsError} for +insufficient_credits+;
|
|
233
|
+
# {Sendly::RateLimitError} for +daily_call_limit+ and
|
|
234
|
+
# +rate_limit_exceeded+; {Sendly::APIError} with the HTTP status for
|
|
235
|
+
# +e911_required+ (428), +agent_disabled+ / +no_voice_number+ /
|
|
236
|
+
# +lines_busy+ (409) and +live_key_required+ / +forbidden+ (403); and
|
|
237
|
+
# {Sendly::ServerError} (not an +APIError+) for +voice_unavailable+ /
|
|
238
|
+
# +agents_unavailable+ (503) and +voice_internal_error+ (500). The full
|
|
239
|
+
# list is {Call::ERROR_CODES}.
|
|
240
|
+
#
|
|
241
|
+
# @example Place a call and wait for it to end
|
|
242
|
+
# call = client.calls.create(
|
|
243
|
+
# to: "+15555550123",
|
|
244
|
+
# agent_id: "3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b",
|
|
245
|
+
# context: "Confirm the 3pm appointment on Tuesday."
|
|
246
|
+
# )
|
|
247
|
+
# call = client.calls.get(call.id) while call.live? && sleep(2)
|
|
248
|
+
# puts call.hangup_class
|
|
249
|
+
# call.transcript.each { |line| puts "#{line.speaker}: #{line.text}" }
|
|
250
|
+
class CallsResource
|
|
251
|
+
def initialize(client)
|
|
252
|
+
@client = client
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Place a phone call that one of your AI agents handles. Returns at once
|
|
256
|
+
# with the call +ringing+; poll {#get} or subscribe to the +call.started+
|
|
257
|
+
# and +call.completed+ webhooks to follow it. Requires the +calls:write+
|
|
258
|
+
# scope and a live key.
|
|
259
|
+
#
|
|
260
|
+
# @param to [String] The number to call, in E.164 format (US or Canada)
|
|
261
|
+
# @param agent_id [String] The AI agent that talks on the call
|
|
262
|
+
# @param from [String, nil] A voice-enabled number in your workspace.
|
|
263
|
+
# Optional when the workspace has exactly one; required (the API
|
|
264
|
+
# responds 400 +from_number_required+) when it has more.
|
|
265
|
+
# @param context [String, nil] Up to 2000 characters appended to the
|
|
266
|
+
# agent's instructions for this call only. Not echoed back.
|
|
267
|
+
# @param metadata [Hash{String => String}, nil] Up to 20 string pairs
|
|
268
|
+
# (keys 1-40 characters of +A-Z a-z 0-9 _ . : -+, values up to 500
|
|
269
|
+
# characters). Stored, echoed on every read and in every +call.*+
|
|
270
|
+
# webhook.
|
|
271
|
+
# @param idempotency_key [String, nil] Idempotency key for this operation
|
|
272
|
+
# @return [Sendly::Call] The new call (+status+ "ringing", +handled_by+ "agent")
|
|
273
|
+
# @raise [Sendly::ValidationError] If +to+ or +agent_id+ is missing, or
|
|
274
|
+
# HTTP 400 (+invalid_number+, +destination_not_supported+,
|
|
275
|
+
# +agent_required+, +invalid_metadata+, +from_number_required+)
|
|
276
|
+
# @raise [Sendly::NotFoundError] HTTP 404 (+voice_not_enabled+,
|
|
277
|
+
# +outbound_calls_not_enabled+, +agent_not_found+, +number_not_found+)
|
|
278
|
+
# @raise [Sendly::InsufficientCreditsError] HTTP 402 when the balance
|
|
279
|
+
# cannot cover one minute at the agent rate
|
|
280
|
+
# @raise [Sendly::APIError] HTTP 428 +e911_required+ (register an
|
|
281
|
+
# emergency address for the number first), 409 +agent_disabled+ /
|
|
282
|
+
# +no_voice_number+ / +lines_busy+, 403 +live_key_required+
|
|
283
|
+
# @raise [Sendly::RateLimitError] HTTP 429 +daily_call_limit+ / +rate_limit_exceeded+
|
|
284
|
+
# @raise [Sendly::ServerError] HTTP 503 +voice_unavailable+ /
|
|
285
|
+
# +agents_unavailable+ (calling is not switched on for this deployment),
|
|
286
|
+
# HTTP 500 +voice_internal_error+
|
|
287
|
+
#
|
|
288
|
+
# @example
|
|
289
|
+
# call = client.calls.create(
|
|
290
|
+
# to: "+15555550123",
|
|
291
|
+
# agent_id: "3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b",
|
|
292
|
+
# from: "+15555550188",
|
|
293
|
+
# metadata: { "crmId" => "lead_8812" }
|
|
294
|
+
# )
|
|
295
|
+
# puts call.id
|
|
296
|
+
def create(to:, agent_id:, from: nil, context: nil, metadata: nil, idempotency_key: nil)
|
|
297
|
+
raise ValidationError, "to is required" if to.nil? || to.to_s.empty?
|
|
298
|
+
raise ValidationError, "agent_id is required" if agent_id.nil? || agent_id.to_s.empty?
|
|
299
|
+
|
|
300
|
+
body = { to: to, agentId: agent_id }
|
|
301
|
+
body[:from] = from unless from.nil?
|
|
302
|
+
body[:context] = context unless context.nil?
|
|
303
|
+
body[:metadata] = metadata unless metadata.nil?
|
|
304
|
+
|
|
305
|
+
response = @client.post("/calls", body, idempotency_key: idempotency_key)
|
|
306
|
+
Call.new(response)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# List your workspace's calls, newest first. Live rows are reconciled
|
|
310
|
+
# before they are returned, so a ring past its deadline reads as
|
|
311
|
+
# +no_answer+. Requires the +calls:read+ scope.
|
|
312
|
+
#
|
|
313
|
+
# @param limit [Integer, nil] Calls per page (1-100, default 50)
|
|
314
|
+
# @param offset [Integer, nil] Calls to skip (default 0)
|
|
315
|
+
# @param status [String, nil] One of {Call::STATUSES}
|
|
316
|
+
# @param direction [String, nil] "inbound" or "outbound"
|
|
317
|
+
# @param kind [String, nil] "pstn" or "internal"
|
|
318
|
+
# @param agent_id [String, nil] Only calls handled by this agent
|
|
319
|
+
# @param to [String, nil] Exact E.164 match on the called number
|
|
320
|
+
# @param from [String, nil] Exact E.164 match on the calling number
|
|
321
|
+
# @return [Sendly::CallList] The page and its pagination
|
|
322
|
+
# @raise [Sendly::ValidationError] HTTP 400 +invalid_request+ for a value
|
|
323
|
+
# outside the vocabularies above
|
|
324
|
+
#
|
|
325
|
+
# @example
|
|
326
|
+
# page = client.calls.list(status: "completed", direction: "outbound", limit: 20)
|
|
327
|
+
# page.each { |c| puts "#{c.to} #{c.duration_secs}s #{c.credits_charged} credits" }
|
|
328
|
+
# puts page.has_more?
|
|
329
|
+
def list(limit: nil, offset: nil, status: nil, direction: nil, kind: nil,
|
|
330
|
+
agent_id: nil, to: nil, from: nil)
|
|
331
|
+
params = {}
|
|
332
|
+
params[:limit] = limit unless limit.nil?
|
|
333
|
+
params[:offset] = offset unless offset.nil?
|
|
334
|
+
params[:status] = status unless status.nil?
|
|
335
|
+
params[:direction] = direction unless direction.nil?
|
|
336
|
+
params[:kind] = kind unless kind.nil?
|
|
337
|
+
params[:agentId] = agent_id unless agent_id.nil?
|
|
338
|
+
params[:to] = to unless to.nil?
|
|
339
|
+
params[:from] = from unless from.nil?
|
|
340
|
+
|
|
341
|
+
response = @client.get("/calls", params)
|
|
342
|
+
CallList.new(response)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Fetch one call. Agent-handled calls include their +transcript+; a live
|
|
346
|
+
# call is reconciled first. Requires the +calls:read+ scope.
|
|
347
|
+
#
|
|
348
|
+
# @param id [String] Call identifier
|
|
349
|
+
# @return [Sendly::Call]
|
|
350
|
+
# @raise [Sendly::NotFoundError] HTTP 404 +call_not_found+ when the call
|
|
351
|
+
# is not in your workspace
|
|
352
|
+
#
|
|
353
|
+
# @example
|
|
354
|
+
# call = client.calls.get("6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f")
|
|
355
|
+
# call.transcript&.each { |line| puts "#{line.speaker}: #{line.text}" }
|
|
356
|
+
def get(id)
|
|
357
|
+
encoded_id = encode_id!(id)
|
|
358
|
+
response = @client.get("/calls/#{encoded_id}")
|
|
359
|
+
Call.new(response)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# End a call. A ringing call becomes +cancelled+ (+hangup_class+
|
|
363
|
+
# "caller_cancelled") and the callee stops ringing; an active call
|
|
364
|
+
# becomes +completed+ ("normal"). Hanging up a call that has already
|
|
365
|
+
# ended returns it unchanged. Requires the +calls:write+ scope and a
|
|
366
|
+
# live key.
|
|
367
|
+
#
|
|
368
|
+
# @param id [String] Call identifier
|
|
369
|
+
# @param idempotency_key [String, nil] Idempotency key for this operation
|
|
370
|
+
# @return [Sendly::Call] The call after the hangup
|
|
371
|
+
# @raise [Sendly::NotFoundError] HTTP 404 +call_not_found+
|
|
372
|
+
#
|
|
373
|
+
# @example
|
|
374
|
+
# call = client.calls.hangup(call.id)
|
|
375
|
+
# puts call.status # "cancelled" or "completed"
|
|
376
|
+
def hangup(id, idempotency_key: nil)
|
|
377
|
+
encoded_id = encode_id!(id)
|
|
378
|
+
response = @client.post("/calls/#{encoded_id}/hangup", {}, idempotency_key: idempotency_key)
|
|
379
|
+
Call.new(response)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Fetch the recording of a call. +url+ is a signed link valid for five
|
|
383
|
+
# minutes and is only set once the recording is +ready?+; fetch again
|
|
384
|
+
# for a fresh link. Requires the +calls:read+ scope.
|
|
385
|
+
#
|
|
386
|
+
# @param id [String] Call identifier
|
|
387
|
+
# @return [Sendly::CallRecording]
|
|
388
|
+
# @raise [Sendly::NotFoundError] HTTP 404 +call_not_found+
|
|
389
|
+
#
|
|
390
|
+
# @example
|
|
391
|
+
# rec = client.calls.recording(call.id)
|
|
392
|
+
# if rec.ready?
|
|
393
|
+
# File.binwrite("#{call.id}.ogg", Net::HTTP.get(URI(rec.url)))
|
|
394
|
+
# end
|
|
395
|
+
def recording(id)
|
|
396
|
+
encoded_id = encode_id!(id)
|
|
397
|
+
response = @client.get("/calls/#{encoded_id}/recording")
|
|
398
|
+
CallRecording.new(response)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
private
|
|
402
|
+
|
|
403
|
+
def encode_id!(id)
|
|
404
|
+
raise ValidationError, "Call ID is required" if id.nil? || id.to_s.empty?
|
|
405
|
+
|
|
406
|
+
URI.encode_www_form_component(id)
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
data/lib/sendly/client.rb
CHANGED
|
@@ -192,6 +192,13 @@ module Sendly
|
|
|
192
192
|
@rcs ||= RcsResource.new(self)
|
|
193
193
|
end
|
|
194
194
|
|
|
195
|
+
# Access the Calls resource (phone calls handled by your AI agents)
|
|
196
|
+
#
|
|
197
|
+
# @return [Sendly::CallsResource]
|
|
198
|
+
def calls
|
|
199
|
+
@calls ||= CallsResource.new(self)
|
|
200
|
+
end
|
|
201
|
+
|
|
195
202
|
# Make a GET request
|
|
196
203
|
#
|
|
197
204
|
# @param path [String] API path
|
|
@@ -53,9 +53,21 @@ module Sendly
|
|
|
53
53
|
# true when the number is scheduled for release at period end.
|
|
54
54
|
:pending_cancellation,
|
|
55
55
|
# ISO-8601 timestamp string, or nil when no release is scheduled.
|
|
56
|
-
:scheduled_release_at
|
|
56
|
+
:scheduled_release_at,
|
|
57
|
+
# true when the number can place and receive phone calls
|
|
58
|
+
# (switched on in the dashboard under Calls, then Settings).
|
|
59
|
+
:voice_enabled,
|
|
60
|
+
# One of {VOICE_MODES}: "none" when voice is off, "ring_dashboard"
|
|
61
|
+
# when inbound calls ring the team, "agent" when an AI agent answers.
|
|
62
|
+
:voice_mode
|
|
63
|
+
|
|
64
|
+
# @return [Hash] The raw parsed response
|
|
65
|
+
attr_reader :raw
|
|
66
|
+
|
|
67
|
+
VOICE_MODES = %w[none ring_dashboard agent].freeze
|
|
57
68
|
|
|
58
69
|
def initialize(data)
|
|
70
|
+
@raw = data
|
|
59
71
|
@id = data["id"]
|
|
60
72
|
@phone_number = data["phoneNumber"] || data["phone_number"]
|
|
61
73
|
@status = data["status"]
|
|
@@ -67,6 +79,13 @@ module Sendly
|
|
|
67
79
|
@requirements_submitted_at = data["requirementsSubmittedAt"] || data["requirements_submitted_at"]
|
|
68
80
|
@pending_cancellation = data.key?("pendingCancellation") ? data["pendingCancellation"] : data["pending_cancellation"]
|
|
69
81
|
@scheduled_release_at = data["scheduledReleaseAt"] || data["scheduled_release_at"]
|
|
82
|
+
@voice_enabled = data.key?("voiceEnabled") ? data["voiceEnabled"] : data["voice_enabled"]
|
|
83
|
+
@voice_mode = data["voiceMode"] || data["voice_mode"]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [Boolean] Whether the number can be used as the +from+ of a call
|
|
87
|
+
def voice_enabled?
|
|
88
|
+
voice_enabled == true
|
|
70
89
|
end
|
|
71
90
|
|
|
72
91
|
def to_h
|
|
@@ -76,7 +95,8 @@ module Sendly
|
|
|
76
95
|
monthly_cost_cents: monthly_cost_cents, is_default: is_default,
|
|
77
96
|
requirements_submitted_at: requirements_submitted_at,
|
|
78
97
|
pending_cancellation: pending_cancellation,
|
|
79
|
-
scheduled_release_at: scheduled_release_at
|
|
98
|
+
scheduled_release_at: scheduled_release_at,
|
|
99
|
+
voice_enabled: voice_enabled, voice_mode: voice_mode
|
|
80
100
|
}.compact
|
|
81
101
|
end
|
|
82
102
|
end
|
data/lib/sendly/version.rb
CHANGED
data/lib/sendly/webhooks.rb
CHANGED
|
@@ -99,6 +99,10 @@ module Sendly
|
|
|
99
99
|
EVENT_CALL_STARTED = "call.started"
|
|
100
100
|
EVENT_CALL_COMPLETED = "call.completed"
|
|
101
101
|
EVENT_CALL_RECORDING_READY = "call.recording.ready"
|
|
102
|
+
EVENT_SHORT_CODE_ACTION_REQUIRED = "short_code.action_required"
|
|
103
|
+
EVENT_SHORT_CODE_REJECTED = "short_code.rejected"
|
|
104
|
+
EVENT_SHORT_CODE_FILED = "short_code.filed"
|
|
105
|
+
EVENT_SHORT_CODE_LIVE = "short_code.live"
|
|
102
106
|
|
|
103
107
|
# Source of a list-health event. Frozen enum — new values will be
|
|
104
108
|
# added in minor SDK versions, never removed.
|
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.1.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-14 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|
|
@@ -126,6 +126,7 @@ files:
|
|
|
126
126
|
- lib/sendly.rb
|
|
127
127
|
- lib/sendly/account_resource.rb
|
|
128
128
|
- lib/sendly/business_upgrade_resource.rb
|
|
129
|
+
- lib/sendly/calls_resource.rb
|
|
129
130
|
- lib/sendly/campaigns_resource.rb
|
|
130
131
|
- lib/sendly/client.rb
|
|
131
132
|
- lib/sendly/contacts_resource.rb
|