sendly 3.37.1 → 3.39.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 +85 -1
- data/Gemfile.lock +2 -2
- data/README.md +305 -11
- data/lib/sendly/account_resource.rb +12 -8
- data/lib/sendly/business_upgrade_resource.rb +2 -0
- data/lib/sendly/client.rb +83 -8
- data/lib/sendly/enterprise.rb +2 -0
- data/lib/sendly/errors.rb +4 -2
- data/lib/sendly/messages.rb +160 -13
- data/lib/sendly/rcs_resource.rb +1155 -0
- data/lib/sendly/templates_resource.rb +169 -37
- data/lib/sendly/version.rb +1 -1
- data/lib/sendly/whatsapp_resource.rb +655 -0
- data/lib/sendly.rb +2 -0
- data/sendly.gemspec +51 -0
- metadata +5 -2
|
@@ -0,0 +1,1155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sendly
|
|
4
|
+
# An RCS agent registered for your workspace — the branded identity your
|
|
5
|
+
# RCS messages come from. +status+ moves +"draft"+ → +"submitted"+ →
|
|
6
|
+
# +"testing"+ (can send, but only to invited test devices) → +"approved"+
|
|
7
|
+
# (can send to everyone); +"suspended"+ agents cannot send. +sendable+ is
|
|
8
|
+
# true when the agent is fully provisioned and its status allows sending.
|
|
9
|
+
# +stage+ is the registration stage, one of
|
|
10
|
+
# {RcsRegistration::CUSTOMER_STAGES}.
|
|
11
|
+
class RcsAgent
|
|
12
|
+
attr_reader :id, :name, :status, :use_case, :sendable, :stage, :created_at
|
|
13
|
+
|
|
14
|
+
STATUSES = %w[draft submitted testing approved suspended].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(data)
|
|
17
|
+
@id = data["id"]
|
|
18
|
+
@name = data["name"]
|
|
19
|
+
@status = data["status"]
|
|
20
|
+
@use_case = data["useCase"] || data["use_case"]
|
|
21
|
+
@sendable = data["sendable"] || false
|
|
22
|
+
@stage = data["stage"]
|
|
23
|
+
@created_at = data["createdAt"] || data["created_at"]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def sendable?
|
|
27
|
+
sendable
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def approved?
|
|
31
|
+
status == "approved"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_h
|
|
35
|
+
{
|
|
36
|
+
id: id, name: name, status: status, use_case: use_case,
|
|
37
|
+
sendable: sendable, stage: stage, created_at: created_at
|
|
38
|
+
}.compact
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Whether a recipient's device and network can receive RCS. When
|
|
43
|
+
# +capable+ is false, a text send to this recipient falls back to plain
|
|
44
|
+
# SMS (unless the fallback is disabled) and a card send fails. +features+
|
|
45
|
+
# lists the feature tags the device reports.
|
|
46
|
+
class RcsCapability
|
|
47
|
+
attr_reader :to, :agent_id, :capable, :features
|
|
48
|
+
|
|
49
|
+
def initialize(data)
|
|
50
|
+
@to = data["to"]
|
|
51
|
+
@agent_id = data["agentId"] || data["agent_id"]
|
|
52
|
+
@capable = data["capable"] || false
|
|
53
|
+
@features = data["features"] || []
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def capable?
|
|
57
|
+
capable
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def to_h
|
|
61
|
+
{ to: to, agent_id: agent_id, capable: capable, features: features }.compact
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# RCS-specific details on a sent message. On a native RCS delivery +kind+
|
|
66
|
+
# is what was sent ("text" or "card") and +agent_name+ is the brand name
|
|
67
|
+
# recipients see. On an SMS fallback +requested_channel+ is "rcs" and
|
|
68
|
+
# +suggestions_dropped+ is true when suggested replies/actions were
|
|
69
|
+
# dropped (they have no SMS form).
|
|
70
|
+
class RcsMessageDetails
|
|
71
|
+
attr_reader :kind, :agent_id, :agent_name, :requested_channel,
|
|
72
|
+
:suggestions_dropped
|
|
73
|
+
|
|
74
|
+
KINDS = %w[text card].freeze
|
|
75
|
+
|
|
76
|
+
def initialize(data)
|
|
77
|
+
@kind = data["kind"]
|
|
78
|
+
@agent_id = data["agentId"] || data["agent_id"]
|
|
79
|
+
@agent_name = data["agentName"] || data["agent_name"]
|
|
80
|
+
@requested_channel = data["requestedChannel"] || data["requested_channel"]
|
|
81
|
+
@suggestions_dropped = data["suggestionsDropped"] || data["suggestions_dropped"] || false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def to_h
|
|
85
|
+
{
|
|
86
|
+
kind: kind, agent_id: agent_id, agent_name: agent_name,
|
|
87
|
+
requested_channel: requested_channel,
|
|
88
|
+
suggestions_dropped: suggestions_dropped
|
|
89
|
+
}.compact
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A message sent through the RCS channel. +channel+ is "rcs" when it was
|
|
94
|
+
# delivered as RCS, or "sms" when the recipient couldn't receive RCS and
|
|
95
|
+
# the message fell back to plain SMS — check with {#fell_back?}. On a
|
|
96
|
+
# fallback +fell_back_to+ is "sms" and +segments+/+credits_used+ are
|
|
97
|
+
# billed as SMS; +rcs+ carries the channel-specific details either way.
|
|
98
|
+
class RcsMessage
|
|
99
|
+
attr_reader :id, :channel, :fell_back_to, :message_format, :to, :from,
|
|
100
|
+
:text, :status, :segments, :credits_used, :rcs, :created_at,
|
|
101
|
+
:metadata
|
|
102
|
+
|
|
103
|
+
def initialize(data)
|
|
104
|
+
@id = data["id"]
|
|
105
|
+
@channel = data["channel"] || "rcs"
|
|
106
|
+
@fell_back_to = data["fellBackTo"] || data["fell_back_to"]
|
|
107
|
+
@message_format = data["message_format"] || data["messageFormat"] || @channel
|
|
108
|
+
@to = data["to"]
|
|
109
|
+
@from = data["from"]
|
|
110
|
+
@text = data["text"]
|
|
111
|
+
@status = data["status"]
|
|
112
|
+
@segments = data["segments"] || 1
|
|
113
|
+
@credits_used = data["creditsUsed"] || data["credits_used"] || 0
|
|
114
|
+
@rcs = data["rcs"] ? RcsMessageDetails.new(data["rcs"]) : nil
|
|
115
|
+
@created_at = data["createdAt"] || data["created_at"]
|
|
116
|
+
@metadata = data["metadata"]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def fell_back?
|
|
120
|
+
fell_back_to == "sms"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def delivered?
|
|
124
|
+
status == "delivered"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def failed?
|
|
128
|
+
status == "failed"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def to_h
|
|
132
|
+
{
|
|
133
|
+
id: id, channel: channel, fell_back_to: fell_back_to,
|
|
134
|
+
message_format: message_format, to: to, from: from, text: text,
|
|
135
|
+
status: status, segments: segments, credits_used: credits_used,
|
|
136
|
+
rcs: rcs&.to_h, created_at: created_at, metadata: metadata
|
|
137
|
+
}.compact
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Shapes RCS registration input before it leaves the process. Nested
|
|
142
|
+
# hashes (address, contact, basics, campaign, testing, devices) accept
|
|
143
|
+
# either snake_case or camelCase keys; keys are camelised and values are
|
|
144
|
+
# passed through untouched.
|
|
145
|
+
#
|
|
146
|
+
# @api private
|
|
147
|
+
module RcsRegistrationInput
|
|
148
|
+
OMIT = Object.new.freeze
|
|
149
|
+
|
|
150
|
+
module_function
|
|
151
|
+
|
|
152
|
+
def omitted?(value)
|
|
153
|
+
OMIT.equal?(value)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def camelize(value)
|
|
157
|
+
case value
|
|
158
|
+
when Hash
|
|
159
|
+
value.each_with_object({}) { |(k, v), out| out[camel_key(k)] = camelize(v) }
|
|
160
|
+
when Array
|
|
161
|
+
value.map { |v| camelize(v) }
|
|
162
|
+
else
|
|
163
|
+
value
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def underscore(value)
|
|
168
|
+
case value
|
|
169
|
+
when Hash
|
|
170
|
+
value.each_with_object({}) { |(k, v), out| out[snake_key(k)] = underscore(v) }
|
|
171
|
+
when Array
|
|
172
|
+
value.map { |v| underscore(v) }
|
|
173
|
+
else
|
|
174
|
+
value
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def camel_key(key)
|
|
179
|
+
key.to_s.gsub(/_([a-z0-9])/) { Regexp.last_match(1).upcase }.to_sym
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def snake_key(key)
|
|
183
|
+
key.to_s.gsub(/([A-Z])/) { "_#{Regexp.last_match(1).downcase}" }.to_sym
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def encode_id!(id, label)
|
|
187
|
+
raise ValidationError, "#{label} is required" if id.nil? || id.to_s.empty?
|
|
188
|
+
|
|
189
|
+
URI.encode_www_form_component(id)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# A postal address on an RCS brand. +country_code+ is always "US": RCS
|
|
194
|
+
# registration is open to US businesses for now.
|
|
195
|
+
class RcsAddress
|
|
196
|
+
attr_reader :line1, :line2, :city, :state, :postal_code, :country_code
|
|
197
|
+
|
|
198
|
+
def initialize(data)
|
|
199
|
+
data ||= {}
|
|
200
|
+
@line1 = data["line1"]
|
|
201
|
+
@line2 = data["line2"]
|
|
202
|
+
@city = data["city"]
|
|
203
|
+
@state = data["state"]
|
|
204
|
+
@postal_code = data["postalCode"] || data["postal_code"]
|
|
205
|
+
@country_code = data["countryCode"] || data["country_code"]
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def to_h
|
|
209
|
+
{
|
|
210
|
+
line1: line1, line2: line2, city: city, state: state,
|
|
211
|
+
postal_code: postal_code, country_code: country_code
|
|
212
|
+
}.compact
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# The business contact on an RCS brand.
|
|
217
|
+
class RcsContact
|
|
218
|
+
attr_reader :first_name, :last_name, :title, :email, :phone_number
|
|
219
|
+
|
|
220
|
+
def initialize(data)
|
|
221
|
+
data ||= {}
|
|
222
|
+
@first_name = data["firstName"] || data["first_name"]
|
|
223
|
+
@last_name = data["lastName"] || data["last_name"]
|
|
224
|
+
@title = data["title"]
|
|
225
|
+
@email = data["email"]
|
|
226
|
+
@phone_number = data["phoneNumber"] || data["phone_number"]
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def to_h
|
|
230
|
+
{
|
|
231
|
+
first_name: first_name, last_name: last_name, title: title,
|
|
232
|
+
email: email, phone_number: phone_number
|
|
233
|
+
}.compact
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# The business behind an RCS agent. +review_status+ starts +"draft"+,
|
|
238
|
+
# becomes +"awaiting_review"+ once an agent under it is submitted, and
|
|
239
|
+
# moves to +"approved_for_carrier"+ (Sendly approved it and sent it on to
|
|
240
|
+
# the carrier network), +"changes_requested"+ (see +review_note+) or
|
|
241
|
+
# +"rejected"+ (see +rejection_reason+). +verified_at+ is set once the
|
|
242
|
+
# carrier network has verified the business. +customer_stage+ is the
|
|
243
|
+
# same lifecycle summarised as one of {RcsRegistration::CUSTOMER_STAGES}.
|
|
244
|
+
class RcsBrand
|
|
245
|
+
LEGAL_ENTITY_TYPES = %w[LIMITED_LIABILITY_COMPANY SOLE_PROPRIETORSHIP PARTNERSHIP
|
|
246
|
+
CORPORATION S_CORPORATION].freeze
|
|
247
|
+
ORGANIZATION_TYPES = %w[PRIVATE_PROFIT PUBLIC_PROFIT NON_PROFIT GOVERNMENT UNKNOWN].freeze
|
|
248
|
+
|
|
249
|
+
attr_reader :id, :review_status, :customer_stage, :display_name,
|
|
250
|
+
:legal_name, :legal_entity_type, :organization_type,
|
|
251
|
+
:stock_symbol, :website_url, :ein, :address, :contact,
|
|
252
|
+
:review_note, :rejection_reason, :submitted_for_review_at,
|
|
253
|
+
:sent_to_carrier_at, :verified_at, :created_at, :updated_at
|
|
254
|
+
|
|
255
|
+
def initialize(data)
|
|
256
|
+
@id = data["id"]
|
|
257
|
+
@review_status = data["reviewStatus"] || data["review_status"]
|
|
258
|
+
@customer_stage = data["customerStage"] || data["customer_stage"]
|
|
259
|
+
@display_name = data["displayName"] || data["display_name"]
|
|
260
|
+
@legal_name = data["legalName"] || data["legal_name"]
|
|
261
|
+
@legal_entity_type = data["legalEntityType"] || data["legal_entity_type"]
|
|
262
|
+
@organization_type = data["organizationType"] || data["organization_type"]
|
|
263
|
+
@stock_symbol = data["stockSymbol"] || data["stock_symbol"]
|
|
264
|
+
@website_url = data["websiteUrl"] || data["website_url"]
|
|
265
|
+
@ein = data["ein"]
|
|
266
|
+
@address = data["address"] ? RcsAddress.new(data["address"]) : nil
|
|
267
|
+
@contact = data["contact"] ? RcsContact.new(data["contact"]) : nil
|
|
268
|
+
@review_note = data["reviewNote"] || data["review_note"]
|
|
269
|
+
@rejection_reason = data["rejectionReason"] || data["rejection_reason"]
|
|
270
|
+
@submitted_for_review_at = data["submittedForReviewAt"] || data["submitted_for_review_at"]
|
|
271
|
+
@sent_to_carrier_at = data["sentToCarrierAt"] || data["sent_to_carrier_at"]
|
|
272
|
+
@verified_at = data["verifiedAt"] || data["verified_at"]
|
|
273
|
+
@created_at = data["createdAt"] || data["created_at"]
|
|
274
|
+
@updated_at = data["updatedAt"] || data["updated_at"]
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def draft?
|
|
278
|
+
review_status == "draft"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def awaiting_review?
|
|
282
|
+
review_status == "awaiting_review"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def changes_requested?
|
|
286
|
+
review_status == "changes_requested"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def rejected?
|
|
290
|
+
review_status == "rejected"
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def verified?
|
|
294
|
+
!verified_at.nil?
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# True while the brand can still be edited (draft or changes requested).
|
|
298
|
+
def editable?
|
|
299
|
+
draft? || changes_requested?
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def to_h
|
|
303
|
+
{
|
|
304
|
+
id: id, review_status: review_status, customer_stage: customer_stage,
|
|
305
|
+
display_name: display_name, legal_name: legal_name,
|
|
306
|
+
legal_entity_type: legal_entity_type, organization_type: organization_type,
|
|
307
|
+
stock_symbol: stock_symbol, website_url: website_url, ein: ein,
|
|
308
|
+
address: address&.to_h, contact: contact&.to_h,
|
|
309
|
+
review_note: review_note, rejection_reason: rejection_reason,
|
|
310
|
+
submitted_for_review_at: submitted_for_review_at,
|
|
311
|
+
sent_to_carrier_at: sent_to_carrier_at, verified_at: verified_at,
|
|
312
|
+
created_at: created_at, updated_at: updated_at
|
|
313
|
+
}.compact
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# A phone that can receive an agent's messages while it is in testing.
|
|
318
|
+
# +invite_status+ is nil until the carrier network has been asked to
|
|
319
|
+
# invite the device.
|
|
320
|
+
class RcsTestDevice
|
|
321
|
+
attr_reader :id, :phone_number, :label, :invite_status, :created_at
|
|
322
|
+
|
|
323
|
+
def initialize(data)
|
|
324
|
+
@id = data["id"]
|
|
325
|
+
@phone_number = data["phoneNumber"] || data["phone_number"]
|
|
326
|
+
@label = data["label"]
|
|
327
|
+
@invite_status = data["inviteStatus"] || data["invite_status"]
|
|
328
|
+
@created_at = data["createdAt"] || data["created_at"]
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def invited?
|
|
332
|
+
!invite_status.nil?
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def to_h
|
|
336
|
+
{
|
|
337
|
+
id: id, phone_number: phone_number, label: label,
|
|
338
|
+
invite_status: invite_status, created_at: created_at
|
|
339
|
+
}.compact
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# What recipients see of an agent: name, use case, description, logo and
|
|
344
|
+
# hero images, colour, policy links and contact entries. +phone_number+,
|
|
345
|
+
# +website+ and +email+ are hashes as stored (+"number"+ / +"url"+ /
|
|
346
|
+
# +"address"+ plus +"label"+). +logo_url+ and +hero_url+ must be public
|
|
347
|
+
# https URLs; assets cannot be uploaded over the API.
|
|
348
|
+
class RcsAgentBasics
|
|
349
|
+
attr_reader :display_name, :use_case, :hosting_region, :description,
|
|
350
|
+
:logo_url, :hero_url, :brand_color, :privacy_policy_url,
|
|
351
|
+
:terms_and_conditions_url, :phone_number, :website, :email
|
|
352
|
+
|
|
353
|
+
def initialize(data)
|
|
354
|
+
data ||= {}
|
|
355
|
+
@display_name = data["displayName"] || data["display_name"]
|
|
356
|
+
@use_case = data["useCase"] || data["use_case"]
|
|
357
|
+
@hosting_region = data["hostingRegion"] || data["hosting_region"]
|
|
358
|
+
@description = data["description"]
|
|
359
|
+
@logo_url = data["logoUrl"] || data["logo_url"]
|
|
360
|
+
@hero_url = data["heroUrl"] || data["hero_url"]
|
|
361
|
+
@brand_color = data["brandColor"] || data["brand_color"]
|
|
362
|
+
@privacy_policy_url = data["privacyPolicyUrl"] || data["privacy_policy_url"]
|
|
363
|
+
@terms_and_conditions_url = data["termsAndConditionsUrl"] || data["terms_and_conditions_url"]
|
|
364
|
+
@phone_number = data["phoneNumber"] || data["phone_number"]
|
|
365
|
+
@website = data["website"]
|
|
366
|
+
@email = data["email"]
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def to_h
|
|
370
|
+
{
|
|
371
|
+
display_name: display_name, use_case: use_case,
|
|
372
|
+
hosting_region: hosting_region, description: description,
|
|
373
|
+
logo_url: logo_url, hero_url: hero_url, brand_color: brand_color,
|
|
374
|
+
privacy_policy_url: privacy_policy_url,
|
|
375
|
+
terms_and_conditions_url: terms_and_conditions_url,
|
|
376
|
+
phone_number: phone_number, website: website, email: email
|
|
377
|
+
}.compact
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# One kind of conversation the agent will have with recipients.
|
|
382
|
+
class RcsCampaignInteraction
|
|
383
|
+
INTERACTION_TYPES = %w[TRANSACTIONAL_UPDATES CUSTOMER_SUPPORT LOYALTY_OR_REWARD
|
|
384
|
+
MARKETING_OR_PROMOTIONAL ACCOUNT_ALERTS TWO_WAY_CONVERSATION
|
|
385
|
+
OTHER].freeze
|
|
386
|
+
|
|
387
|
+
attr_reader :interaction_type, :description
|
|
388
|
+
|
|
389
|
+
def initialize(data)
|
|
390
|
+
data ||= {}
|
|
391
|
+
@interaction_type = data["interactionType"] || data["interaction_type"]
|
|
392
|
+
@description = data["description"]
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def to_h
|
|
396
|
+
{ interaction_type: interaction_type, description: description }.compact
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# One way recipients opt in to the agent's messages.
|
|
401
|
+
class RcsOptInMethod
|
|
402
|
+
METHOD_TYPES = %w[SMS WEBSITE MOBILE_APP QR_CODE SALE_POINT OTHER].freeze
|
|
403
|
+
|
|
404
|
+
attr_reader :method_type, :description
|
|
405
|
+
|
|
406
|
+
def initialize(data)
|
|
407
|
+
data ||= {}
|
|
408
|
+
@method_type = data["methodType"] || data["method_type"]
|
|
409
|
+
@description = data["description"]
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def to_h
|
|
413
|
+
{ method_type: method_type, description: description }.compact
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# How recipients consent to, get help with, and stop the agent's
|
|
418
|
+
# messages. +call_to_action_media_url+ must be a public https URL.
|
|
419
|
+
class RcsConsentSettings
|
|
420
|
+
attr_reader :opt_in_methods, :call_to_action, :call_to_action_url,
|
|
421
|
+
:call_to_action_media_url, :double_opt_in,
|
|
422
|
+
:double_opt_in_message, :opt_in_message, :help_response,
|
|
423
|
+
:opt_out_response
|
|
424
|
+
|
|
425
|
+
def initialize(data)
|
|
426
|
+
data ||= {}
|
|
427
|
+
methods = data["optInMethods"] || data["opt_in_methods"]
|
|
428
|
+
@opt_in_methods = methods ? methods.map { |m| RcsOptInMethod.new(m) } : nil
|
|
429
|
+
@call_to_action = data["callToAction"] || data["call_to_action"]
|
|
430
|
+
@call_to_action_url = data["callToActionUrl"] || data["call_to_action_url"]
|
|
431
|
+
@call_to_action_media_url = data["callToActionMediaUrl"] || data["call_to_action_media_url"]
|
|
432
|
+
@double_opt_in = data.key?("doubleOptIn") ? data["doubleOptIn"] : data["double_opt_in"]
|
|
433
|
+
@double_opt_in_message = data["doubleOptInMessage"] || data["double_opt_in_message"]
|
|
434
|
+
@opt_in_message = data["optInMessage"] || data["opt_in_message"]
|
|
435
|
+
@help_response = data["helpResponse"] || data["help_response"]
|
|
436
|
+
@opt_out_response = data["optOutResponse"] || data["opt_out_response"]
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def to_h
|
|
440
|
+
{
|
|
441
|
+
opt_in_methods: opt_in_methods&.map(&:to_h), call_to_action: call_to_action,
|
|
442
|
+
call_to_action_url: call_to_action_url,
|
|
443
|
+
call_to_action_media_url: call_to_action_media_url,
|
|
444
|
+
double_opt_in: double_opt_in, double_opt_in_message: double_opt_in_message,
|
|
445
|
+
opt_in_message: opt_in_message, help_response: help_response,
|
|
446
|
+
opt_out_response: opt_out_response
|
|
447
|
+
}.compact
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# The agent's campaign: what the business does, what the agent sends,
|
|
452
|
+
# example messages and consent settings. Reviewed before launch.
|
|
453
|
+
class RcsAgentCampaign
|
|
454
|
+
attr_reader :company_overview, :agent_overview, :additional_information,
|
|
455
|
+
:interactions, :message_examples, :consent_settings
|
|
456
|
+
|
|
457
|
+
def initialize(data)
|
|
458
|
+
data ||= {}
|
|
459
|
+
@company_overview = data["companyOverview"] || data["company_overview"]
|
|
460
|
+
@agent_overview = data["agentOverview"] || data["agent_overview"]
|
|
461
|
+
@additional_information = data["additionalInformation"] || data["additional_information"]
|
|
462
|
+
interactions = data["interactions"]
|
|
463
|
+
@interactions = interactions ? interactions.map { |i| RcsCampaignInteraction.new(i) } : nil
|
|
464
|
+
@message_examples = data["messageExamples"] || data["message_examples"]
|
|
465
|
+
consent = data["consentSettings"] || data["consent_settings"]
|
|
466
|
+
@consent_settings = consent ? RcsConsentSettings.new(consent) : nil
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def to_h
|
|
470
|
+
{
|
|
471
|
+
company_overview: company_overview, agent_overview: agent_overview,
|
|
472
|
+
additional_information: additional_information,
|
|
473
|
+
interactions: interactions&.map(&:to_h),
|
|
474
|
+
message_examples: message_examples,
|
|
475
|
+
consent_settings: consent_settings&.to_h
|
|
476
|
+
}.compact
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# How the agent was tested on an invited device before launch.
|
|
481
|
+
class RcsAgentTesting
|
|
482
|
+
attr_reader :test_url, :message_id, :additional_information
|
|
483
|
+
|
|
484
|
+
def initialize(data)
|
|
485
|
+
data ||= {}
|
|
486
|
+
@test_url = data["testUrl"] || data["test_url"]
|
|
487
|
+
@message_id = data["messageId"] || data["message_id"]
|
|
488
|
+
@additional_information = data["additionalInformation"] || data["additional_information"]
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def to_h
|
|
492
|
+
{
|
|
493
|
+
test_url: test_url, message_id: message_id,
|
|
494
|
+
additional_information: additional_information
|
|
495
|
+
}.compact
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# The full registration record of an RCS agent: its basics, campaign,
|
|
500
|
+
# testing details, test devices and review state. +status+ is the send
|
|
501
|
+
# status also shown by {RcsAgentsResource#list} ("draft", "submitted",
|
|
502
|
+
# "testing", "approved", "suspended"); +review_status+ tracks the
|
|
503
|
+
# registration itself, one of {RcsRegistration::REVIEW_STATUSES}, and
|
|
504
|
+
# +customer_stage+ summarises both with the brand as one of
|
|
505
|
+
# {RcsRegistration::CUSTOMER_STAGES}. +review_note+ carries Sendly's
|
|
506
|
+
# note when changes are requested and +rejection_reason+ the carrier
|
|
507
|
+
# network's reason when it rejects the agent.
|
|
508
|
+
class RcsAgentRegistration
|
|
509
|
+
USE_CASES = %w[MULTI_USE PROMOTIONAL TRANSACTIONAL OTP].freeze
|
|
510
|
+
|
|
511
|
+
attr_reader :id, :brand_id, :status, :review_status, :customer_stage,
|
|
512
|
+
:display_name, :use_case, :hosting_region, :basics,
|
|
513
|
+
:campaign, :testing, :review_note, :rejection_reason,
|
|
514
|
+
:test_devices, :submitted_for_review_at,
|
|
515
|
+
:basics_submitted_at, :launch_submitted_at, :live_at,
|
|
516
|
+
:created_at, :updated_at
|
|
517
|
+
|
|
518
|
+
def initialize(data)
|
|
519
|
+
@id = data["id"]
|
|
520
|
+
@brand_id = data["brandId"] || data["brand_id"]
|
|
521
|
+
@status = data["status"]
|
|
522
|
+
@review_status = data["reviewStatus"] || data["review_status"]
|
|
523
|
+
@customer_stage = data["customerStage"] || data["customer_stage"]
|
|
524
|
+
@display_name = data["displayName"] || data["display_name"]
|
|
525
|
+
@use_case = data["useCase"] || data["use_case"]
|
|
526
|
+
@hosting_region = data["hostingRegion"] || data["hosting_region"]
|
|
527
|
+
@basics = data["basics"] ? RcsAgentBasics.new(data["basics"]) : nil
|
|
528
|
+
@campaign = data["campaign"] ? RcsAgentCampaign.new(data["campaign"]) : nil
|
|
529
|
+
@testing = data["testing"] ? RcsAgentTesting.new(data["testing"]) : nil
|
|
530
|
+
@review_note = data["reviewNote"] || data["review_note"]
|
|
531
|
+
@rejection_reason = data["rejectionReason"] || data["rejection_reason"]
|
|
532
|
+
devices = data["testDevices"] || data["test_devices"] || []
|
|
533
|
+
@test_devices = devices.map { |d| RcsTestDevice.new(d) }
|
|
534
|
+
@submitted_for_review_at = data["submittedForReviewAt"] || data["submitted_for_review_at"]
|
|
535
|
+
@basics_submitted_at = data["basicsSubmittedAt"] || data["basics_submitted_at"]
|
|
536
|
+
@launch_submitted_at = data["launchSubmittedAt"] || data["launch_submitted_at"]
|
|
537
|
+
@live_at = data["liveAt"] || data["live_at"]
|
|
538
|
+
@created_at = data["createdAt"] || data["created_at"]
|
|
539
|
+
@updated_at = data["updatedAt"] || data["updated_at"]
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def draft?
|
|
543
|
+
review_status == "draft"
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def awaiting_review?
|
|
547
|
+
review_status == "awaiting_review"
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def changes_requested?
|
|
551
|
+
review_status == "changes_requested"
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def approved_for_carrier?
|
|
555
|
+
review_status == "approved_for_carrier"
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def launch_requested?
|
|
559
|
+
review_status == "launch_requested"
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def launch_rejected?
|
|
563
|
+
review_status == "launch_rejected"
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def rejected?
|
|
567
|
+
review_status == "rejected"
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# True while the agent reaches invited test devices only.
|
|
571
|
+
def testing?
|
|
572
|
+
status == "testing"
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def live?
|
|
576
|
+
customer_stage == "live"
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# True while the agent's basics can still be edited and it can be
|
|
580
|
+
# submitted for review.
|
|
581
|
+
def editable?
|
|
582
|
+
draft? || changes_requested?
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def to_h
|
|
586
|
+
{
|
|
587
|
+
id: id, brand_id: brand_id, status: status,
|
|
588
|
+
review_status: review_status, customer_stage: customer_stage,
|
|
589
|
+
display_name: display_name, use_case: use_case,
|
|
590
|
+
hosting_region: hosting_region, basics: basics&.to_h,
|
|
591
|
+
campaign: campaign&.to_h, testing: testing&.to_h,
|
|
592
|
+
review_note: review_note, rejection_reason: rejection_reason,
|
|
593
|
+
test_devices: test_devices.map(&:to_h),
|
|
594
|
+
submitted_for_review_at: submitted_for_review_at,
|
|
595
|
+
basics_submitted_at: basics_submitted_at,
|
|
596
|
+
launch_submitted_at: launch_submitted_at, live_at: live_at,
|
|
597
|
+
created_at: created_at, updated_at: updated_at
|
|
598
|
+
}.compact
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
# The workspace's RCS registration at a glance: the newest agent, its
|
|
603
|
+
# brand and test devices, and the +stage+ they are at together. +brand+
|
|
604
|
+
# and +agent+ are nil until something has been drafted, and +stage+ is
|
|
605
|
+
# then "draft". +us_eligible+ is false when something on file names a
|
|
606
|
+
# non-US country.
|
|
607
|
+
class RcsRegistration
|
|
608
|
+
CUSTOMER_STAGES = %w[draft in_review changes_requested rejected brand_verification
|
|
609
|
+
agent_review testing launch_review launching launch_rejected
|
|
610
|
+
live suspended failed].freeze
|
|
611
|
+
REVIEW_STATUSES = %w[draft awaiting_review changes_requested approved_for_carrier
|
|
612
|
+
rejected launch_requested launch_submitted launch_rejected
|
|
613
|
+
failed].freeze
|
|
614
|
+
ERROR_CODES = %w[rcs_not_enabled rcs_not_found rcs_field_locked rcs_us_only
|
|
615
|
+
rcs_invalid_content rcs_brand_not_verified rcs_launch_not_ready
|
|
616
|
+
rcs_internal_error].freeze
|
|
617
|
+
|
|
618
|
+
attr_reader :brand, :agent, :devices, :stage, :us_eligible
|
|
619
|
+
|
|
620
|
+
def initialize(data)
|
|
621
|
+
@brand = data["brand"] ? RcsBrand.new(data["brand"]) : nil
|
|
622
|
+
@agent = data["agent"] ? RcsAgentRegistration.new(data["agent"]) : nil
|
|
623
|
+
@devices = (data["devices"] || []).map { |d| RcsTestDevice.new(d) }
|
|
624
|
+
@stage = data["stage"]
|
|
625
|
+
@us_eligible = data.key?("usEligible") ? data["usEligible"] : data["us_eligible"]
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def us_eligible?
|
|
629
|
+
us_eligible == true
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def live?
|
|
633
|
+
stage == "live"
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
def to_h
|
|
637
|
+
{
|
|
638
|
+
brand: brand&.to_h, agent: agent&.to_h,
|
|
639
|
+
devices: devices.map(&:to_h), stage: stage, us_eligible: us_eligible
|
|
640
|
+
}.compact
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
# Business details Sendly already holds for the workspace, ready to seed
|
|
645
|
+
# an RCS brand. +brand+ is a snake_case hash of only the fields on file
|
|
646
|
+
# (+legal_name+, +display_name+, +ein+, +organization_type+,
|
|
647
|
+
# +website_url+, +address+, +contact+), so it can be passed straight to
|
|
648
|
+
# {RcsBrandsResource#create} with +**dossier.brand+. +source+ says where
|
|
649
|
+
# the details came from: "tendlc" (the workspace's newest 10DLC brand),
|
|
650
|
+
# "verification" (its active toll-free verification) or "none".
|
|
651
|
+
class RcsDossier
|
|
652
|
+
SOURCES = %w[tendlc verification none].freeze
|
|
653
|
+
|
|
654
|
+
attr_reader :brand, :us_eligible, :source
|
|
655
|
+
|
|
656
|
+
def initialize(data)
|
|
657
|
+
@brand = RcsRegistrationInput.underscore(data["brand"] || {})
|
|
658
|
+
@us_eligible = data.key?("usEligible") ? data["usEligible"] : data["us_eligible"]
|
|
659
|
+
@source = data["source"]
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
def us_eligible?
|
|
663
|
+
us_eligible == true
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
def prefilled?
|
|
667
|
+
!brand.empty?
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
def to_h
|
|
671
|
+
{ brand: brand, us_eligible: us_eligible, source: source }.compact
|
|
672
|
+
end
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
# Read the workspace's RCS registration as a whole.
|
|
676
|
+
class RcsRegistrationResource
|
|
677
|
+
def initialize(client)
|
|
678
|
+
@client = client
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# Fetch the registration: the newest agent, its brand and test devices,
|
|
682
|
+
# and the stage they are at. Requires the +rcs:read+ scope.
|
|
683
|
+
#
|
|
684
|
+
# @return [RcsRegistration]
|
|
685
|
+
#
|
|
686
|
+
# @example
|
|
687
|
+
# reg = client.rcs.registration.get
|
|
688
|
+
# puts reg.stage # "draft" until something is submitted
|
|
689
|
+
# puts reg.agent&.review_status # e.g. "awaiting_review"
|
|
690
|
+
def get
|
|
691
|
+
response = @client.get("/rcs/registration")
|
|
692
|
+
RcsRegistration.new(response)
|
|
693
|
+
end
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
# Business details already on file, to seed an RCS brand from.
|
|
697
|
+
class RcsDossierResource
|
|
698
|
+
def initialize(client)
|
|
699
|
+
@client = client
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
# Fetch the details Sendly already holds for this workspace (from its
|
|
703
|
+
# 10DLC brand or toll-free verification), compacted to the fields that
|
|
704
|
+
# are set. Requires the +rcs:read+ scope.
|
|
705
|
+
#
|
|
706
|
+
# @return [RcsDossier]
|
|
707
|
+
#
|
|
708
|
+
# @example Seed a brand from the dossier
|
|
709
|
+
# dossier = client.rcs.dossier.get
|
|
710
|
+
# brand = client.rcs.brands.create(**dossier.brand) if dossier.prefilled?
|
|
711
|
+
def get
|
|
712
|
+
response = @client.get("/rcs/dossier")
|
|
713
|
+
RcsDossier.new(response)
|
|
714
|
+
end
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
# Draft and edit the business behind your RCS agents.
|
|
718
|
+
class RcsBrandsResource
|
|
719
|
+
def initialize(client)
|
|
720
|
+
@client = client
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
# Draft a brand. Every field is optional here; required fields are
|
|
724
|
+
# checked when an agent under the brand is submitted for review. The
|
|
725
|
+
# address must be in the US. Requires the +rcs:write+ scope.
|
|
726
|
+
#
|
|
727
|
+
# Nested hashes accept snake_case or camelCase keys.
|
|
728
|
+
#
|
|
729
|
+
# @param display_name [String, nil] The brand name recipients see
|
|
730
|
+
# @param legal_name [String, nil] Registered legal name
|
|
731
|
+
# @param legal_entity_type [String, nil] One of {RcsBrand::LEGAL_ENTITY_TYPES}
|
|
732
|
+
# @param organization_type [String, nil] One of {RcsBrand::ORGANIZATION_TYPES}
|
|
733
|
+
# @param website_url [String, nil] Public https website
|
|
734
|
+
# @param ein [String, nil] Employer identification number ("12-3456789" or "123456789")
|
|
735
|
+
# @param stock_symbol [String, nil] "EXCHANGE:TICKER" for public companies
|
|
736
|
+
# @param address [Hash, nil] +{ line1:, line2:, city:, state:, postal_code:, country_code: "US" }+
|
|
737
|
+
# @param contact [Hash, nil] +{ first_name:, last_name:, title:, email:, phone_number: }+ (E.164 phone)
|
|
738
|
+
# @param idempotency_key [String, nil] Idempotency key for this operation
|
|
739
|
+
# @return [RcsBrand]
|
|
740
|
+
# @raise [Sendly::ValidationError] HTTP 422 +rcs_us_only+ when the address is outside the US
|
|
741
|
+
#
|
|
742
|
+
# @example
|
|
743
|
+
# brand = client.rcs.brands.create(
|
|
744
|
+
# display_name: "Acme Coffee",
|
|
745
|
+
# legal_name: "Acme Holdings LLC",
|
|
746
|
+
# legal_entity_type: "LIMITED_LIABILITY_COMPANY",
|
|
747
|
+
# organization_type: "PRIVATE_PROFIT",
|
|
748
|
+
# website_url: "https://acme.example",
|
|
749
|
+
# ein: "12-3456789",
|
|
750
|
+
# address: { line1: "1 Main St", city: "Austin", state: "TX",
|
|
751
|
+
# postal_code: "78701", country_code: "US" },
|
|
752
|
+
# contact: { first_name: "Sam", last_name: "Lee", email: "sam@acme.example",
|
|
753
|
+
# phone_number: "+15551234567" }
|
|
754
|
+
# )
|
|
755
|
+
def create(display_name: nil, legal_name: nil, legal_entity_type: nil,
|
|
756
|
+
organization_type: nil, website_url: nil, ein: nil,
|
|
757
|
+
stock_symbol: nil, address: nil, contact: nil,
|
|
758
|
+
idempotency_key: nil)
|
|
759
|
+
body = {}
|
|
760
|
+
body[:displayName] = display_name unless display_name.nil?
|
|
761
|
+
body[:legalName] = legal_name unless legal_name.nil?
|
|
762
|
+
body[:legalEntityType] = legal_entity_type unless legal_entity_type.nil?
|
|
763
|
+
body[:organizationType] = organization_type unless organization_type.nil?
|
|
764
|
+
body[:websiteUrl] = website_url unless website_url.nil?
|
|
765
|
+
body[:ein] = ein unless ein.nil?
|
|
766
|
+
body[:stockSymbol] = stock_symbol unless stock_symbol.nil?
|
|
767
|
+
body[:address] = RcsRegistrationInput.camelize(address) unless address.nil?
|
|
768
|
+
body[:contact] = RcsRegistrationInput.camelize(contact) unless contact.nil?
|
|
769
|
+
|
|
770
|
+
response = @client.post("/rcs/brands", body, idempotency_key: idempotency_key)
|
|
771
|
+
RcsBrand.new(response["brand"] || {})
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
# Edit a brand that is still editable (draft or changes requested).
|
|
775
|
+
# Only the fields you pass are changed; pass +nil+ to clear a nullable
|
|
776
|
+
# field. +address+ and +contact+ may be partial hashes. Requires the
|
|
777
|
+
# +rcs:write+ scope.
|
|
778
|
+
#
|
|
779
|
+
# @param id [String] Brand identifier
|
|
780
|
+
# @param display_name [String, nil]
|
|
781
|
+
# @param legal_name [String, nil]
|
|
782
|
+
# @param legal_entity_type [String, nil] One of {RcsBrand::LEGAL_ENTITY_TYPES}
|
|
783
|
+
# @param organization_type [String, nil] One of {RcsBrand::ORGANIZATION_TYPES}
|
|
784
|
+
# @param website_url [String, nil]
|
|
785
|
+
# @param ein [String, nil]
|
|
786
|
+
# @param stock_symbol [String, nil]
|
|
787
|
+
# @param address [Hash, nil] Partial address; +country_code+, when present, must be "US"
|
|
788
|
+
# @param contact [Hash, nil] Partial contact
|
|
789
|
+
# @param idempotency_key [String, nil] Idempotency key for this operation
|
|
790
|
+
# @return [RcsBrand] The updated brand
|
|
791
|
+
# @raise [Sendly::ValidationError] If no field is given, or HTTP 422
|
|
792
|
+
# (+rcs_us_only+, +rcs_invalid_content+ with +field_errors+)
|
|
793
|
+
# @raise [Sendly::NotFoundError] HTTP 404 +rcs_not_found+ when the brand isn't in this workspace
|
|
794
|
+
# @raise [Sendly::APIError] HTTP 409 +rcs_field_locked+ while the brand is under review
|
|
795
|
+
#
|
|
796
|
+
# @example
|
|
797
|
+
# client.rcs.brands.update(brand.id, website_url: "https://acme.example",
|
|
798
|
+
# address: { line2: "Suite 4" })
|
|
799
|
+
def update(id, display_name: RcsRegistrationInput::OMIT,
|
|
800
|
+
legal_name: RcsRegistrationInput::OMIT,
|
|
801
|
+
legal_entity_type: RcsRegistrationInput::OMIT,
|
|
802
|
+
organization_type: RcsRegistrationInput::OMIT,
|
|
803
|
+
website_url: RcsRegistrationInput::OMIT,
|
|
804
|
+
ein: RcsRegistrationInput::OMIT,
|
|
805
|
+
stock_symbol: RcsRegistrationInput::OMIT,
|
|
806
|
+
address: RcsRegistrationInput::OMIT,
|
|
807
|
+
contact: RcsRegistrationInput::OMIT,
|
|
808
|
+
idempotency_key: nil)
|
|
809
|
+
encoded_id = RcsRegistrationInput.encode_id!(id, "Brand ID")
|
|
810
|
+
|
|
811
|
+
body = {}
|
|
812
|
+
body[:displayName] = display_name unless RcsRegistrationInput.omitted?(display_name)
|
|
813
|
+
body[:legalName] = legal_name unless RcsRegistrationInput.omitted?(legal_name)
|
|
814
|
+
body[:legalEntityType] = legal_entity_type unless RcsRegistrationInput.omitted?(legal_entity_type)
|
|
815
|
+
body[:organizationType] = organization_type unless RcsRegistrationInput.omitted?(organization_type)
|
|
816
|
+
body[:websiteUrl] = website_url unless RcsRegistrationInput.omitted?(website_url)
|
|
817
|
+
body[:ein] = ein unless RcsRegistrationInput.omitted?(ein)
|
|
818
|
+
body[:stockSymbol] = stock_symbol unless RcsRegistrationInput.omitted?(stock_symbol)
|
|
819
|
+
body[:address] = RcsRegistrationInput.camelize(address) unless RcsRegistrationInput.omitted?(address)
|
|
820
|
+
body[:contact] = RcsRegistrationInput.camelize(contact) unless RcsRegistrationInput.omitted?(contact)
|
|
821
|
+
raise ValidationError, "Provide at least one brand field to update" if body.empty?
|
|
822
|
+
|
|
823
|
+
response = @client.patch("/rcs/brands/#{encoded_id}", body, idempotency_key: idempotency_key)
|
|
824
|
+
RcsBrand.new(response["brand"] || {})
|
|
825
|
+
end
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
# Draft, edit, test, submit and launch the RCS agents registered for
|
|
829
|
+
# your workspace, and list them.
|
|
830
|
+
class RcsAgentsResource
|
|
831
|
+
def initialize(client)
|
|
832
|
+
@client = client
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
# List your RCS agents, newest first. An empty list means no agent has
|
|
836
|
+
# been drafted yet: register one with {#create} or from the dashboard.
|
|
837
|
+
# +stage+ on each agent is its registration stage.
|
|
838
|
+
#
|
|
839
|
+
# @return [Hash] +{ agents: Array<RcsAgent> }+
|
|
840
|
+
#
|
|
841
|
+
# @example
|
|
842
|
+
# client.rcs.agents.list[:agents].each do |a|
|
|
843
|
+
# puts "#{a.name} — #{a.status}#{a.sendable? ? ' (sendable)' : ''}"
|
|
844
|
+
# end
|
|
845
|
+
def list
|
|
846
|
+
response = @client.get("/rcs/agents")
|
|
847
|
+
agents = (response["agents"] || []).map { |a| RcsAgent.new(a) }
|
|
848
|
+
{ agents: agents }
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
# Draft an agent under a brand. Everything but +brand_id+ is optional
|
|
852
|
+
# here; required fields are checked when you {#submit}. Nested hashes
|
|
853
|
+
# accept snake_case or camelCase keys. Logo, hero and call-to-action
|
|
854
|
+
# media must already be public https URLs: assets cannot be uploaded
|
|
855
|
+
# over the API, only from the dashboard. Requires the +rcs:write+ scope.
|
|
856
|
+
#
|
|
857
|
+
# @param brand_id [String] The brand this agent belongs to
|
|
858
|
+
# @param display_name [String, nil] The agent name recipients see
|
|
859
|
+
# @param use_case [String, nil] One of {RcsAgentRegistration::USE_CASES}
|
|
860
|
+
# @param basics [Hash, nil] +{ description:, logo_url:, hero_url:, brand_color:,
|
|
861
|
+
# privacy_policy_url:, terms_and_conditions_url:, phone_number: { number:, label: },
|
|
862
|
+
# website: { url:, label: }, email: { address:, label: } }+
|
|
863
|
+
# @param campaign [Hash, nil] +{ company_overview:, agent_overview:, additional_information:,
|
|
864
|
+
# interactions: [{ interaction_type:, description: }], message_examples: [...],
|
|
865
|
+
# consent_settings: { opt_in_methods: [{ method_type:, description: }], call_to_action:,
|
|
866
|
+
# call_to_action_url:, call_to_action_media_url:, double_opt_in:, double_opt_in_message:,
|
|
867
|
+
# opt_in_message:, help_response:, opt_out_response: } }+
|
|
868
|
+
# @param testing [Hash, nil] +{ test_url:, message_id:, additional_information: }+
|
|
869
|
+
# @param idempotency_key [String, nil] Idempotency key for this operation
|
|
870
|
+
# @return [RcsAgentRegistration]
|
|
871
|
+
# @raise [Sendly::NotFoundError] HTTP 404 +rcs_not_found+ when the brand isn't in this workspace
|
|
872
|
+
# @raise [Sendly::ValidationError] HTTP 422 +rcs_invalid_content+ (see +field_errors+)
|
|
873
|
+
#
|
|
874
|
+
# @example
|
|
875
|
+
# agent = client.rcs.agents.create(
|
|
876
|
+
# brand_id: brand.id,
|
|
877
|
+
# display_name: "Acme Coffee",
|
|
878
|
+
# use_case: "MULTI_USE",
|
|
879
|
+
# basics: {
|
|
880
|
+
# description: "Order updates and offers from Acme Coffee",
|
|
881
|
+
# logo_url: "https://acme.example/rcs/logo.png",
|
|
882
|
+
# hero_url: "https://acme.example/rcs/hero.png",
|
|
883
|
+
# brand_color: "#5B3A29",
|
|
884
|
+
# privacy_policy_url: "https://acme.example/privacy",
|
|
885
|
+
# terms_and_conditions_url: "https://acme.example/terms",
|
|
886
|
+
# phone_number: { number: "+15551234567", label: "Support" }
|
|
887
|
+
# }
|
|
888
|
+
# )
|
|
889
|
+
def create(brand_id:, display_name: nil, use_case: nil, basics: nil,
|
|
890
|
+
campaign: nil, testing: nil, idempotency_key: nil)
|
|
891
|
+
raise ValidationError, "brand_id is required" if brand_id.nil? || brand_id.to_s.empty?
|
|
892
|
+
|
|
893
|
+
body = { brandId: brand_id }
|
|
894
|
+
body[:displayName] = display_name unless display_name.nil?
|
|
895
|
+
body[:useCase] = use_case unless use_case.nil?
|
|
896
|
+
body[:basics] = RcsRegistrationInput.camelize(basics) unless basics.nil?
|
|
897
|
+
body[:campaign] = RcsRegistrationInput.camelize(campaign) unless campaign.nil?
|
|
898
|
+
body[:testing] = RcsRegistrationInput.camelize(testing) unless testing.nil?
|
|
899
|
+
|
|
900
|
+
response = @client.post("/rcs/agents", body, idempotency_key: idempotency_key)
|
|
901
|
+
RcsAgentRegistration.new(response["agent"] || {})
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
# Fetch one agent's full registration record, including its test
|
|
905
|
+
# devices and review state. Requires the +rcs:read+ scope.
|
|
906
|
+
#
|
|
907
|
+
# @param id [String] Agent identifier
|
|
908
|
+
# @return [RcsAgentRegistration]
|
|
909
|
+
# @raise [Sendly::NotFoundError] HTTP 404 +rcs_not_found+ when the agent isn't in this workspace
|
|
910
|
+
#
|
|
911
|
+
# @example
|
|
912
|
+
# agent = client.rcs.agents.get("rca_abc123")
|
|
913
|
+
# puts agent.customer_stage
|
|
914
|
+
# puts agent.review_note if agent.changes_requested?
|
|
915
|
+
def get(id)
|
|
916
|
+
encoded_id = RcsRegistrationInput.encode_id!(id, "Agent ID")
|
|
917
|
+
response = @client.get("/rcs/agents/#{encoded_id}")
|
|
918
|
+
RcsAgentRegistration.new(response["agent"] || {})
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
# Edit an agent that is still editable. Only the groups you pass are
|
|
922
|
+
# changed: +display_name+, +use_case+ and +basics+ are merged into the
|
|
923
|
+
# basics, +campaign+ and +testing+ are merged section-wise, and
|
|
924
|
+
# +campaign: nil+ or +testing: nil+ clears that section. The same
|
|
925
|
+
# public-https rule applies to logo, hero and call-to-action media.
|
|
926
|
+
# Requires the +rcs:write+ scope.
|
|
927
|
+
#
|
|
928
|
+
# @param id [String] Agent identifier
|
|
929
|
+
# @param display_name [String, nil]
|
|
930
|
+
# @param use_case [String, nil] One of {RcsAgentRegistration::USE_CASES}
|
|
931
|
+
# @param basics [Hash, nil] Partial basics (see {#create})
|
|
932
|
+
# @param campaign [Hash, nil] Partial campaign, or +nil+ to clear it
|
|
933
|
+
# @param testing [Hash, nil] Partial testing details, or +nil+ to clear them
|
|
934
|
+
# @param idempotency_key [String, nil] Idempotency key for this operation
|
|
935
|
+
# @return [RcsAgentRegistration] The updated agent
|
|
936
|
+
# @raise [Sendly::ValidationError] If no field is given, or HTTP 422
|
|
937
|
+
# +rcs_invalid_content+ (see +field_errors+)
|
|
938
|
+
# @raise [Sendly::NotFoundError] HTTP 404 +rcs_not_found+
|
|
939
|
+
# @raise [Sendly::APIError] HTTP 409 +rcs_field_locked+ while the agent is under review
|
|
940
|
+
#
|
|
941
|
+
# @example
|
|
942
|
+
# client.rcs.agents.update(agent.id,
|
|
943
|
+
# campaign: {
|
|
944
|
+
# agent_overview: "Order updates, delivery alerts and support replies",
|
|
945
|
+
# interactions: [{ interaction_type: "TRANSACTIONAL_UPDATES",
|
|
946
|
+
# description: "Order and delivery status" }],
|
|
947
|
+
# message_examples: ["Your order #4821 has shipped!",
|
|
948
|
+
# "Your barista is ready for pickup",
|
|
949
|
+
# "Reply HELP for support or STOP to opt out"]
|
|
950
|
+
# }
|
|
951
|
+
# )
|
|
952
|
+
def update(id, display_name: RcsRegistrationInput::OMIT,
|
|
953
|
+
use_case: RcsRegistrationInput::OMIT,
|
|
954
|
+
basics: RcsRegistrationInput::OMIT,
|
|
955
|
+
campaign: RcsRegistrationInput::OMIT,
|
|
956
|
+
testing: RcsRegistrationInput::OMIT,
|
|
957
|
+
idempotency_key: nil)
|
|
958
|
+
encoded_id = RcsRegistrationInput.encode_id!(id, "Agent ID")
|
|
959
|
+
|
|
960
|
+
body = {}
|
|
961
|
+
body[:displayName] = display_name unless RcsRegistrationInput.omitted?(display_name)
|
|
962
|
+
body[:useCase] = use_case unless RcsRegistrationInput.omitted?(use_case)
|
|
963
|
+
body[:basics] = RcsRegistrationInput.camelize(basics) unless RcsRegistrationInput.omitted?(basics)
|
|
964
|
+
body[:campaign] = RcsRegistrationInput.camelize(campaign) unless RcsRegistrationInput.omitted?(campaign)
|
|
965
|
+
body[:testing] = RcsRegistrationInput.camelize(testing) unless RcsRegistrationInput.omitted?(testing)
|
|
966
|
+
raise ValidationError, "Provide at least one agent field to update" if body.empty?
|
|
967
|
+
|
|
968
|
+
response = @client.patch("/rcs/agents/#{encoded_id}", body, idempotency_key: idempotency_key)
|
|
969
|
+
RcsAgentRegistration.new(response["agent"] || {})
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
# Replace the agent's test devices: the phones that can receive its
|
|
973
|
+
# messages while it is in testing. The list is authoritative, so
|
|
974
|
+
# numbers left out are removed and new ones are invited. Up to 20
|
|
975
|
+
# devices. Requires the +rcs:write+ scope.
|
|
976
|
+
#
|
|
977
|
+
# @param id [String] Agent identifier
|
|
978
|
+
# @param devices [Array<String, Hash>] E.164 numbers, or
|
|
979
|
+
# +{ phone_number:, label: }+ hashes
|
|
980
|
+
# @param idempotency_key [String, nil] Idempotency key for this operation
|
|
981
|
+
# @return [Hash] +{ devices: Array<RcsTestDevice> }+ — the full list after the change
|
|
982
|
+
# @raise [Sendly::ValidationError] If +devices+ isn't an array, or HTTP 422
|
|
983
|
+
# +rcs_invalid_content+ (bad number, more than 20 devices)
|
|
984
|
+
# @raise [Sendly::APIError] HTTP 409 +rcs_field_locked+ while the agent is under review
|
|
985
|
+
#
|
|
986
|
+
# @example
|
|
987
|
+
# client.rcs.agents.set_test_devices(agent.id, devices: [
|
|
988
|
+
# { phone_number: "+15551234567", label: "Sam's Pixel" },
|
|
989
|
+
# "+15557654321"
|
|
990
|
+
# ])
|
|
991
|
+
def set_test_devices(id, devices:, idempotency_key: nil)
|
|
992
|
+
encoded_id = RcsRegistrationInput.encode_id!(id, "Agent ID")
|
|
993
|
+
raise ValidationError, "devices must be an array" unless devices.is_a?(Array)
|
|
994
|
+
|
|
995
|
+
body = {
|
|
996
|
+
devices: devices.map do |device|
|
|
997
|
+
device.is_a?(Hash) ? RcsRegistrationInput.camelize(device) : { phoneNumber: device }
|
|
998
|
+
end
|
|
999
|
+
}
|
|
1000
|
+
response = @client.put("/rcs/agents/#{encoded_id}/test-devices", body,
|
|
1001
|
+
idempotency_key: idempotency_key)
|
|
1002
|
+
list = (response["devices"] || []).map { |d| RcsTestDevice.new(d) }
|
|
1003
|
+
{ devices: list }
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
# Submit the agent and its brand for Sendly's review. Once approved
|
|
1007
|
+
# they go on to the carrier network for brand verification and agent
|
|
1008
|
+
# review; watch +customer_stage+ with {#get}. Requires the +rcs:write+
|
|
1009
|
+
# scope.
|
|
1010
|
+
#
|
|
1011
|
+
# Pass your own +idempotency_key+ if you may retry: a replay returns
|
|
1012
|
+
# the original response without submitting again.
|
|
1013
|
+
#
|
|
1014
|
+
# @param id [String] Agent identifier
|
|
1015
|
+
# @param idempotency_key [String, nil] Idempotency key for this operation
|
|
1016
|
+
# @return [RcsAgentRegistration] The agent, now +awaiting_review?+
|
|
1017
|
+
# @raise [Sendly::ValidationError] HTTP 422 +rcs_invalid_content+ when the
|
|
1018
|
+
# brand or agent basics are incomplete (+field_errors+ lists
|
|
1019
|
+
# +brand.<field>+ / +agent.<field>+ paths)
|
|
1020
|
+
# @raise [Sendly::APIError] HTTP 409 +rcs_field_locked+ when already
|
|
1021
|
+
# submitted, or +rcs_brand_not_verified+ when the brand failed verification
|
|
1022
|
+
#
|
|
1023
|
+
# @example
|
|
1024
|
+
# agent = client.rcs.agents.submit(agent.id, idempotency_key: "rcs-submit-#{agent.id}")
|
|
1025
|
+
# puts agent.review_status # "awaiting_review"
|
|
1026
|
+
def submit(id, idempotency_key: nil)
|
|
1027
|
+
encoded_id = RcsRegistrationInput.encode_id!(id, "Agent ID")
|
|
1028
|
+
response = @client.post("/rcs/agents/#{encoded_id}/submit", {}, idempotency_key: idempotency_key)
|
|
1029
|
+
RcsAgentRegistration.new(response["agent"] || {})
|
|
1030
|
+
end
|
|
1031
|
+
|
|
1032
|
+
# Ask to launch an agent that has been tested on an invited device.
|
|
1033
|
+
# Sendly reviews the campaign and testing details, then sends the
|
|
1034
|
+
# launch on to the carrier network. Requires the +rcs:write+ scope.
|
|
1035
|
+
#
|
|
1036
|
+
# @param id [String] Agent identifier
|
|
1037
|
+
# @param test_url [String, nil] Where the test conversation can be seen; stored
|
|
1038
|
+
# into the agent's testing details first
|
|
1039
|
+
# @param testing_additional_information [String, nil] Anything else the
|
|
1040
|
+
# reviewer should know about the test
|
|
1041
|
+
# @param idempotency_key [String, nil] Idempotency key for this operation
|
|
1042
|
+
# @return [RcsAgentRegistration] The agent, now +launch_requested?+
|
|
1043
|
+
# @raise [Sendly::ValidationError] HTTP 422 +rcs_invalid_content+ when the
|
|
1044
|
+
# campaign or testing details are incomplete (+field_errors+ lists
|
|
1045
|
+
# +campaign.<field>+ / +testing.<field>+ paths)
|
|
1046
|
+
# @raise [Sendly::APIError] HTTP 409 +rcs_launch_not_ready+ until the agent
|
|
1047
|
+
# is in testing, or +rcs_field_locked+ while a request is pending
|
|
1048
|
+
#
|
|
1049
|
+
# @example
|
|
1050
|
+
# client.rcs.agents.request_launch(agent.id,
|
|
1051
|
+
# test_url: "https://acme.example/rcs-test",
|
|
1052
|
+
# testing_additional_information: "Tested on two invited devices"
|
|
1053
|
+
# )
|
|
1054
|
+
def request_launch(id, test_url: nil, testing_additional_information: nil,
|
|
1055
|
+
idempotency_key: nil)
|
|
1056
|
+
encoded_id = RcsRegistrationInput.encode_id!(id, "Agent ID")
|
|
1057
|
+
|
|
1058
|
+
body = {}
|
|
1059
|
+
body[:testUrl] = test_url unless test_url.nil?
|
|
1060
|
+
body[:testingAdditionalInformation] = testing_additional_information unless testing_additional_information.nil?
|
|
1061
|
+
|
|
1062
|
+
response = @client.post("/rcs/agents/#{encoded_id}/request-launch", body,
|
|
1063
|
+
idempotency_key: idempotency_key)
|
|
1064
|
+
RcsAgentRegistration.new(response["agent"] || {})
|
|
1065
|
+
end
|
|
1066
|
+
end
|
|
1067
|
+
|
|
1068
|
+
# RCS resource — register your RCS agent, discover your agents and
|
|
1069
|
+
# pre-flight recipient capability.
|
|
1070
|
+
#
|
|
1071
|
+
# RCS is a first-class Sendly channel: send branded rich messages — text
|
|
1072
|
+
# with suggested replies and actions, or rich cards — via
|
|
1073
|
+
# +client.messages.send(channel: "rcs", ...)+.
|
|
1074
|
+
#
|
|
1075
|
+
# Registration is self-serve, from the dashboard or this API: draft the
|
|
1076
|
+
# brand ({RcsBrandsResource#create}) and the agent
|
|
1077
|
+
# ({RcsAgentsResource#create}), submit them for Sendly's review
|
|
1078
|
+
# ({RcsAgentsResource#submit}), and once approved they go on to the
|
|
1079
|
+
# carrier network for brand verification and agent review. The agent
|
|
1080
|
+
# then reaches invited test devices only
|
|
1081
|
+
# ({RcsAgentsResource#set_test_devices}); when you have tested it,
|
|
1082
|
+
# {RcsAgentsResource#request_launch} asks Sendly to review the campaign
|
|
1083
|
+
# and send the launch on to the carrier network. {RcsRegistrationResource#get}
|
|
1084
|
+
# shows where the registration is, and {RcsDossierResource#get} seeds
|
|
1085
|
+
# the brand from details Sendly already holds. Logo, hero and
|
|
1086
|
+
# call-to-action media must be public https URLs; assets cannot be
|
|
1087
|
+
# uploaded over the API, only from the dashboard. Registration reads
|
|
1088
|
+
# need the +rcs:read+ scope and writes +rcs:write+.
|
|
1089
|
+
#
|
|
1090
|
+
# Delivery is per-recipient: not every device or network supports RCS.
|
|
1091
|
+
# Text messages fall back to plain SMS automatically (billed as SMS)
|
|
1092
|
+
# unless you pass +fallback_to_sms: false+; rich cards have no SMS form
|
|
1093
|
+
# and only deliver to RCS-capable recipients. Use {#capability} to check
|
|
1094
|
+
# a recipient before sending.
|
|
1095
|
+
#
|
|
1096
|
+
# RCS sends and capability checks require a live API key
|
|
1097
|
+
# (+sk_live_v1_xxx+).
|
|
1098
|
+
#
|
|
1099
|
+
# @example Check capability, then send
|
|
1100
|
+
# cap = client.rcs.capability(to: "+15551234567")
|
|
1101
|
+
# if cap.capable?
|
|
1102
|
+
# client.messages.send(
|
|
1103
|
+
# channel: "rcs",
|
|
1104
|
+
# to: "+15551234567",
|
|
1105
|
+
# text: "Your order has shipped!"
|
|
1106
|
+
# )
|
|
1107
|
+
# end
|
|
1108
|
+
class RcsResource
|
|
1109
|
+
# @return [RcsAgentsResource] Register and list agents
|
|
1110
|
+
attr_reader :agents
|
|
1111
|
+
|
|
1112
|
+
# @return [RcsRegistrationResource] The registration at a glance
|
|
1113
|
+
attr_reader :registration
|
|
1114
|
+
|
|
1115
|
+
# @return [RcsDossierResource] Business details on file, to seed a brand
|
|
1116
|
+
attr_reader :dossier
|
|
1117
|
+
|
|
1118
|
+
# @return [RcsBrandsResource] Draft and edit brands
|
|
1119
|
+
attr_reader :brands
|
|
1120
|
+
|
|
1121
|
+
def initialize(client)
|
|
1122
|
+
@client = client
|
|
1123
|
+
@agents = RcsAgentsResource.new(client)
|
|
1124
|
+
@registration = RcsRegistrationResource.new(client)
|
|
1125
|
+
@dossier = RcsDossierResource.new(client)
|
|
1126
|
+
@brands = RcsBrandsResource.new(client)
|
|
1127
|
+
end
|
|
1128
|
+
|
|
1129
|
+
# Check whether a recipient can receive RCS.
|
|
1130
|
+
#
|
|
1131
|
+
# Probes the recipient's device and network. When the recipient is not
|
|
1132
|
+
# capable, a text send falls back to plain SMS (unless the fallback is
|
|
1133
|
+
# disabled) and a card send fails with
|
|
1134
|
+
# +rcs_not_supported_for_recipient+.
|
|
1135
|
+
#
|
|
1136
|
+
# @param to [String] The recipient's number, in E.164 format
|
|
1137
|
+
# @param agent_id [String, nil] The agent to probe with. Optional when
|
|
1138
|
+
# your workspace has exactly one sendable agent; required (the API
|
|
1139
|
+
# responds 400 +rcs_agent_ambiguous+) when it has more.
|
|
1140
|
+
# @return [RcsCapability]
|
|
1141
|
+
#
|
|
1142
|
+
# @example
|
|
1143
|
+
# cap = client.rcs.capability(to: "+15551234567")
|
|
1144
|
+
# puts cap.capable?
|
|
1145
|
+
# puts cap.features.inspect
|
|
1146
|
+
def capability(to:, agent_id: nil)
|
|
1147
|
+
raise ValidationError, "to is required" if to.nil? || to.to_s.empty?
|
|
1148
|
+
|
|
1149
|
+
params = { to: to }
|
|
1150
|
+
params[:agentId] = agent_id if agent_id
|
|
1151
|
+
response = @client.get("/rcs/capability", params)
|
|
1152
|
+
RcsCapability.new(response)
|
|
1153
|
+
end
|
|
1154
|
+
end
|
|
1155
|
+
end
|