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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 40344d4fa41e8c72544b0b5327b7bc4d717285c38d244397bf0f93ad21ce5b1a
4
- data.tar.gz: d0bf2bd2c6256ccdbdea1f8c9319c5891b5a030b798a091e7afc59d33752b543
3
+ metadata.gz: 6e04f0698b7520abcb9cd17816198b7e728ab2ce6d2ed1d564195887334dca82
4
+ data.tar.gz: 9f26320067a3fe83a7035364cdf6682e03f9b93bf26996faf728cbfb9899065c
5
5
  SHA512:
6
- metadata.gz: 20aa959ad9bd7f630c1e26b0a511648aecbfb02d095a789d2d17500e2e035eb02b79fad1c9ae6dc9e0959c41dda68d73c97b1a3d1a795f2dedacd41f8ef03262
7
- data.tar.gz: 70f56c72f0b628b86e5ea964de3653b7e16d00fa9c4a5214ef14038dbf3da30feedf84b3b489bbfdfd316c42d70f4116ff6b914fe358d7204498d41601a8213a
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.0.0)
4
+ sendly (4.2.0)
5
5
  faraday (~> 2.0)
6
6
  faraday-retry (~> 2.0)
7
7
 
8
8
  GEM
9
9
  remote: https://rubygems.org/
10
10
  specs:
11
- addressable (2.8.8)
11
+ addressable (2.9.0)
12
12
  public_suffix (>= 2.0.2, < 8.0)
13
13
  ast (2.4.3)
14
14
  bigdecimal (3.3.1)
@@ -74,7 +74,7 @@ GEM
74
74
  unicode-emoji (~> 4.1)
75
75
  unicode-emoji (4.1.0)
76
76
  uri (1.1.1)
77
- webmock (3.26.1)
77
+ webmock (3.26.4)
78
78
  addressable (>= 2.8.0)
79
79
  crack (>= 0.3.2)
80
80
  hashdiff (>= 0.4.0, < 2.0.0)
data/README.md CHANGED
@@ -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