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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 40344d4fa41e8c72544b0b5327b7bc4d717285c38d244397bf0f93ad21ce5b1a
4
- data.tar.gz: d0bf2bd2c6256ccdbdea1f8c9319c5891b5a030b798a091e7afc59d33752b543
3
+ metadata.gz: 84b10285655be846bb857f5e11388a893d916838d480accfc46a646c16dd68a3
4
+ data.tar.gz: cfe12c76eeea0a6db834c09d97c7546260c4a313f5f6d9c64a549d467884ae3b
5
5
  SHA512:
6
- metadata.gz: 20aa959ad9bd7f630c1e26b0a511648aecbfb02d095a789d2d17500e2e035eb02b79fad1c9ae6dc9e0959c41dda68d73c97b1a3d1a795f2dedacd41f8ef03262
7
- data.tar.gz: 70f56c72f0b628b86e5ea964de3653b7e16d00fa9c4a5214ef14038dbf3da30feedf84b3b489bbfdfd316c42d70f4116ff6b914fe358d7204498d41601a8213a
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- sendly (4.0.0)
4
+ sendly (4.1.0)
5
5
  faraday (~> 2.0)
6
6
  faraday-retry (~> 2.0)
7
7
 
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sendly
4
- VERSION = "4.0.0"
4
+ VERSION = "4.1.0"
5
5
  end
@@ -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
@@ -27,6 +27,7 @@ require_relative "sendly/tendlc_resource"
27
27
  require_relative "sendly/links_resource"
28
28
  require_relative "sendly/whatsapp_resource"
29
29
  require_relative "sendly/rcs_resource"
30
+ require_relative "sendly/calls_resource"
30
31
 
31
32
  # Sendly Ruby SDK
32
33
  #
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.0.0
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 00:00:00.000000000 Z
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