sendly 3.40.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 +138 -1
- data/Gemfile.lock +3 -3
- data/README.md +153 -0
- data/lib/sendly/account_resource.rb +4 -4
- data/lib/sendly/calls_resource.rb +409 -0
- data/lib/sendly/campaigns_resource.rb +8 -8
- data/lib/sendly/client.rb +7 -0
- data/lib/sendly/contacts_resource.rb +9 -9
- data/lib/sendly/enterprise.rb +26 -26
- data/lib/sendly/numbers_resource.rb +22 -2
- data/lib/sendly/templates_resource.rb +6 -6
- data/lib/sendly/verify.rb +3 -3
- data/lib/sendly/version.rb +1 -1
- data/lib/sendly/webhooks.rb +4 -0
- data/lib/sendly/webhooks_resource.rb +10 -10
- data/lib/sendly.rb +1 -0
- metadata +3 -2
|
@@ -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
|
|
@@ -131,7 +131,7 @@ module Sendly
|
|
|
131
131
|
end
|
|
132
132
|
|
|
133
133
|
def get(id)
|
|
134
|
-
response = @client.get("/campaigns/#{id}")
|
|
134
|
+
response = @client.get("/campaigns/#{URI.encode_www_form_component(id)}")
|
|
135
135
|
Campaign.new(response)
|
|
136
136
|
end
|
|
137
137
|
|
|
@@ -142,21 +142,21 @@ module Sendly
|
|
|
142
142
|
body[:templateId] = template_id unless template_id.nil?
|
|
143
143
|
body[:contactListIds] = contact_list_ids if contact_list_ids
|
|
144
144
|
|
|
145
|
-
response = @client.patch("/campaigns/#{id}", body)
|
|
145
|
+
response = @client.patch("/campaigns/#{URI.encode_www_form_component(id)}", body)
|
|
146
146
|
Campaign.new(response)
|
|
147
147
|
end
|
|
148
148
|
|
|
149
149
|
def delete(id)
|
|
150
|
-
@client.delete("/campaigns/#{id}")
|
|
150
|
+
@client.delete("/campaigns/#{URI.encode_www_form_component(id)}")
|
|
151
151
|
end
|
|
152
152
|
|
|
153
153
|
def preview(id)
|
|
154
|
-
response = @client.get("/campaigns/#{id}/preview")
|
|
154
|
+
response = @client.get("/campaigns/#{URI.encode_www_form_component(id)}/preview")
|
|
155
155
|
CampaignPreview.new(response)
|
|
156
156
|
end
|
|
157
157
|
|
|
158
158
|
def send_campaign(id)
|
|
159
|
-
response = @client.post("/campaigns/#{id}/send")
|
|
159
|
+
response = @client.post("/campaigns/#{URI.encode_www_form_component(id)}/send")
|
|
160
160
|
Campaign.new(response)
|
|
161
161
|
end
|
|
162
162
|
|
|
@@ -164,17 +164,17 @@ module Sendly
|
|
|
164
164
|
body = { scheduledAt: scheduled_at }
|
|
165
165
|
body[:timezone] = timezone if timezone
|
|
166
166
|
|
|
167
|
-
response = @client.post("/campaigns/#{id}/schedule", body)
|
|
167
|
+
response = @client.post("/campaigns/#{URI.encode_www_form_component(id)}/schedule", body)
|
|
168
168
|
Campaign.new(response)
|
|
169
169
|
end
|
|
170
170
|
|
|
171
171
|
def cancel(id)
|
|
172
|
-
response = @client.post("/campaigns/#{id}/cancel")
|
|
172
|
+
response = @client.post("/campaigns/#{URI.encode_www_form_component(id)}/cancel")
|
|
173
173
|
Campaign.new(response)
|
|
174
174
|
end
|
|
175
175
|
|
|
176
176
|
def clone(id)
|
|
177
|
-
response = @client.post("/campaigns/#{id}/clone")
|
|
177
|
+
response = @client.post("/campaigns/#{URI.encode_www_form_component(id)}/clone")
|
|
178
178
|
Campaign.new(response)
|
|
179
179
|
end
|
|
180
180
|
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
|
|
@@ -114,7 +114,7 @@ module Sendly
|
|
|
114
114
|
params[:limit] = limit if limit
|
|
115
115
|
params[:offset] = offset if offset
|
|
116
116
|
|
|
117
|
-
response = @client.get("/contact-lists/#{id}", params)
|
|
117
|
+
response = @client.get("/contact-lists/#{URI.encode_www_form_component(id)}", params)
|
|
118
118
|
ContactList.new(response)
|
|
119
119
|
end
|
|
120
120
|
|
|
@@ -131,21 +131,21 @@ module Sendly
|
|
|
131
131
|
body[:name] = name if name
|
|
132
132
|
body[:description] = description unless description.nil?
|
|
133
133
|
|
|
134
|
-
response = @client.patch("/contact-lists/#{id}", body)
|
|
134
|
+
response = @client.patch("/contact-lists/#{URI.encode_www_form_component(id)}", body)
|
|
135
135
|
ContactList.new(response)
|
|
136
136
|
end
|
|
137
137
|
|
|
138
138
|
def delete(id)
|
|
139
|
-
@client.delete("/contact-lists/#{id}")
|
|
139
|
+
@client.delete("/contact-lists/#{URI.encode_www_form_component(id)}")
|
|
140
140
|
end
|
|
141
141
|
|
|
142
142
|
def add_contacts(list_id, contact_ids)
|
|
143
|
-
response = @client.post("/contact-lists/#{list_id}/contacts", { contact_ids: contact_ids })
|
|
143
|
+
response = @client.post("/contact-lists/#{URI.encode_www_form_component(list_id)}/contacts", { contact_ids: contact_ids })
|
|
144
144
|
{ added_count: response["added_count"] || response["addedCount"] }
|
|
145
145
|
end
|
|
146
146
|
|
|
147
147
|
def remove_contact(list_id, contact_id)
|
|
148
|
-
@client.delete("/contact-lists/#{list_id}/contacts/#{contact_id}")
|
|
148
|
+
@client.delete("/contact-lists/#{URI.encode_www_form_component(list_id)}/contacts/#{URI.encode_www_form_component(contact_id)}")
|
|
149
149
|
end
|
|
150
150
|
end
|
|
151
151
|
|
|
@@ -175,7 +175,7 @@ module Sendly
|
|
|
175
175
|
end
|
|
176
176
|
|
|
177
177
|
def get(id)
|
|
178
|
-
response = @client.get("/contacts/#{id}")
|
|
178
|
+
response = @client.get("/contacts/#{URI.encode_www_form_component(id)}")
|
|
179
179
|
Contact.new(response)
|
|
180
180
|
end
|
|
181
181
|
|
|
@@ -195,12 +195,12 @@ module Sendly
|
|
|
195
195
|
body[:email] = email unless email.nil?
|
|
196
196
|
body[:metadata] = metadata unless metadata.nil?
|
|
197
197
|
|
|
198
|
-
response = @client.patch("/contacts/#{id}", body)
|
|
198
|
+
response = @client.patch("/contacts/#{URI.encode_www_form_component(id)}", body)
|
|
199
199
|
Contact.new(response)
|
|
200
200
|
end
|
|
201
201
|
|
|
202
202
|
def delete(id)
|
|
203
|
-
@client.delete("/contacts/#{id}")
|
|
203
|
+
@client.delete("/contacts/#{URI.encode_www_form_component(id)}")
|
|
204
204
|
end
|
|
205
205
|
|
|
206
206
|
# Clear the invalid flag on a contact so future campaigns include it again.
|
|
@@ -208,7 +208,7 @@ module Sendly
|
|
|
208
208
|
# error (landline, invalid number) or when a carrier lookup reports they
|
|
209
209
|
# can't receive SMS.
|
|
210
210
|
def mark_valid(id)
|
|
211
|
-
response = @client.post("/contacts/#{id}/mark-valid", {})
|
|
211
|
+
response = @client.post("/contacts/#{URI.encode_www_form_component(id)}/mark-valid", {})
|
|
212
212
|
Contact.new(response)
|
|
213
213
|
end
|
|
214
214
|
|