sendly 3.37.0 → 3.38.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 +63 -1
- data/Gemfile.lock +2 -2
- data/README.md +180 -11
- data/lib/sendly/account_resource.rb +12 -8
- data/lib/sendly/business_upgrade_resource.rb +2 -0
- data/lib/sendly/client.rb +71 -4
- data/lib/sendly/enterprise.rb +2 -0
- data/lib/sendly/messages.rb +160 -13
- data/lib/sendly/rcs_resource.rb +222 -0
- data/lib/sendly/templates_resource.rb +169 -37
- data/lib/sendly/version.rb +1 -1
- data/lib/sendly/webhooks.rb +16 -0
- 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,655 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sendly
|
|
4
|
+
# A newly started WhatsApp signup. Hand +connect_url+ to a human — they
|
|
5
|
+
# open it in a browser and log in with Facebook to link their WhatsApp
|
|
6
|
+
# Business Account. Poll {WhatsAppSignupResource#get} with +id+ until the
|
|
7
|
+
# status is +"active"+.
|
|
8
|
+
class WhatsAppSignupSession
|
|
9
|
+
attr_reader :id, :connect_url, :status
|
|
10
|
+
|
|
11
|
+
STATUSES = %w[initiated registering active failed expired].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(data)
|
|
14
|
+
@id = data["id"]
|
|
15
|
+
@connect_url = data["connectUrl"] || data["connect_url"]
|
|
16
|
+
@status = data["status"]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def active?
|
|
20
|
+
status == "active"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def failed?
|
|
24
|
+
status == "failed"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_h
|
|
28
|
+
{ id: id, connect_url: connect_url, status: status }.compact
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The status of a WhatsApp signup. +business_account_id+ is nil until the
|
|
33
|
+
# human completes the connect step; +failure_reasons+ is set when the
|
|
34
|
+
# status is +"failed"+.
|
|
35
|
+
class WhatsAppSignup
|
|
36
|
+
attr_reader :id, :status, :phone_number, :business_account_id,
|
|
37
|
+
:failure_reasons, :updated_at
|
|
38
|
+
|
|
39
|
+
STATUSES = %w[initiated registering active failed expired].freeze
|
|
40
|
+
|
|
41
|
+
def initialize(data)
|
|
42
|
+
@id = data["id"]
|
|
43
|
+
@status = data["status"]
|
|
44
|
+
@phone_number = data["phoneNumber"] || data["phone_number"]
|
|
45
|
+
@business_account_id = data["businessAccountId"] || data["business_account_id"]
|
|
46
|
+
@failure_reasons = data["failureReasons"] || data["failure_reasons"]
|
|
47
|
+
@updated_at = data["updatedAt"] || data["updated_at"]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def active?
|
|
51
|
+
status == "active"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def failed?
|
|
55
|
+
status == "failed"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def expired?
|
|
59
|
+
status == "expired"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def to_h
|
|
63
|
+
{
|
|
64
|
+
id: id, status: status, phone_number: phone_number,
|
|
65
|
+
business_account_id: business_account_id,
|
|
66
|
+
failure_reasons: failure_reasons, updated_at: updated_at
|
|
67
|
+
}.compact
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A number connected (or connecting) to WhatsApp. +display_name+ is the
|
|
72
|
+
# name recipients see — chosen during the connect flow and reviewed by
|
|
73
|
+
# Meta; nil until set. +quality_rating+ is Meta's rating (e.g. "GREEN"),
|
|
74
|
+
# nil before the first rating.
|
|
75
|
+
class WhatsAppSender
|
|
76
|
+
attr_reader :phone_number, :display_name, :status, :quality_rating,
|
|
77
|
+
:created_at
|
|
78
|
+
|
|
79
|
+
STATUSES = %w[pending active suspended].freeze
|
|
80
|
+
|
|
81
|
+
def initialize(data)
|
|
82
|
+
@phone_number = data["phoneNumber"] || data["phone_number"]
|
|
83
|
+
@display_name = data["displayName"] || data["display_name"]
|
|
84
|
+
@status = data["status"]
|
|
85
|
+
@quality_rating = data["qualityRating"] || data["quality_rating"]
|
|
86
|
+
@created_at = data["createdAt"] || data["created_at"]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def pending?
|
|
90
|
+
status == "pending"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def active?
|
|
94
|
+
status == "active"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def suspended?
|
|
98
|
+
status == "suspended"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def to_h
|
|
102
|
+
{
|
|
103
|
+
phone_number: phone_number, display_name: display_name,
|
|
104
|
+
status: status, quality_rating: quality_rating, created_at: created_at
|
|
105
|
+
}.compact
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# A sender's WhatsApp business profile — what recipients see when they
|
|
110
|
+
# open the sender's details in WhatsApp. Unset fields are nil.
|
|
111
|
+
# +profile_photo_url+ is read-only here: the photo cannot be set through
|
|
112
|
+
# {WhatsAppSendersResource#update_profile}.
|
|
113
|
+
class WhatsAppSenderProfile
|
|
114
|
+
attr_reader :phone_number, :display_name, :profile_photo_url, :category,
|
|
115
|
+
:about, :description, :email, :website, :address
|
|
116
|
+
|
|
117
|
+
def initialize(data)
|
|
118
|
+
@phone_number = data["phoneNumber"] || data["phone_number"]
|
|
119
|
+
@display_name = data["displayName"] || data["display_name"]
|
|
120
|
+
@profile_photo_url = data["profilePhotoUrl"] || data["profile_photo_url"]
|
|
121
|
+
@category = data["category"]
|
|
122
|
+
@about = data["about"]
|
|
123
|
+
@description = data["description"]
|
|
124
|
+
@email = data["email"]
|
|
125
|
+
@website = data["website"]
|
|
126
|
+
@address = data["address"]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def to_h
|
|
130
|
+
{
|
|
131
|
+
phone_number: phone_number, display_name: display_name,
|
|
132
|
+
profile_photo_url: profile_photo_url, category: category,
|
|
133
|
+
about: about, description: description, email: email,
|
|
134
|
+
website: website, address: address
|
|
135
|
+
}.compact
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A WhatsApp message template. Meta reviews every template (usually
|
|
140
|
+
# 24-48h) and may reclassify its category — the category on the record
|
|
141
|
+
# is what drives per-message pricing. +rejection_reason+ is set when the
|
|
142
|
+
# status is +"REJECTED"+; +warnings+ carries non-blocking submission
|
|
143
|
+
# warnings on create responses.
|
|
144
|
+
class WhatsAppTemplate
|
|
145
|
+
attr_reader :id, :name, :language, :category, :status, :quality_rating,
|
|
146
|
+
:rejection_reason, :created_at, :updated_at, :warnings
|
|
147
|
+
|
|
148
|
+
STATUSES = %w[PENDING APPROVED REJECTED PAUSED DISABLED].freeze
|
|
149
|
+
CATEGORIES = %w[AUTHENTICATION UTILITY MARKETING].freeze
|
|
150
|
+
|
|
151
|
+
def initialize(data)
|
|
152
|
+
@id = data["id"]
|
|
153
|
+
@name = data["name"]
|
|
154
|
+
@language = data["language"]
|
|
155
|
+
@category = data["category"]
|
|
156
|
+
@status = data["status"]
|
|
157
|
+
@quality_rating = data["qualityRating"] || data["quality_rating"]
|
|
158
|
+
@rejection_reason = data["rejectionReason"] || data["rejection_reason"]
|
|
159
|
+
@created_at = data["createdAt"] || data["created_at"]
|
|
160
|
+
@updated_at = data["updatedAt"] || data["updated_at"]
|
|
161
|
+
@warnings = data["warnings"]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def pending?
|
|
165
|
+
status == "PENDING"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def approved?
|
|
169
|
+
status == "APPROVED"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def rejected?
|
|
173
|
+
status == "REJECTED"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def to_h
|
|
177
|
+
{
|
|
178
|
+
id: id, name: name, language: language, category: category,
|
|
179
|
+
status: status, quality_rating: quality_rating,
|
|
180
|
+
rejection_reason: rejection_reason, created_at: created_at,
|
|
181
|
+
updated_at: updated_at, warnings: warnings
|
|
182
|
+
}.compact
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Confirmation that a template was deleted.
|
|
187
|
+
class WhatsAppTemplateDeletion
|
|
188
|
+
attr_reader :id, :deleted
|
|
189
|
+
|
|
190
|
+
def initialize(data)
|
|
191
|
+
@id = data["id"]
|
|
192
|
+
@deleted = data["deleted"] || false
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def deleted?
|
|
196
|
+
deleted
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def to_h
|
|
200
|
+
{ id: id, deleted: deleted }.compact
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Whether a 24-hour customer-service window is open between one of your
|
|
205
|
+
# WhatsApp senders and a recipient. +expires_at+ is when the window
|
|
206
|
+
# closes (ISO 8601), nil when no window is open.
|
|
207
|
+
class WhatsAppWindow
|
|
208
|
+
attr_reader :open, :expires_at
|
|
209
|
+
|
|
210
|
+
def initialize(data)
|
|
211
|
+
@open = data["open"] || false
|
|
212
|
+
@expires_at = data["expiresAt"] || data["expires_at"]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def open?
|
|
216
|
+
open
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def to_h
|
|
220
|
+
{ open: open, expires_at: expires_at }.compact
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# WhatsApp-specific details on a sent message. +kind+ is what was sent
|
|
225
|
+
# ("text", "media", or "template"); +template+ carries the sent
|
|
226
|
+
# template's name, language, and billed category on template sends;
|
|
227
|
+
# +message_id+ is the WhatsApp message id, nil until the first delivery
|
|
228
|
+
# report lands.
|
|
229
|
+
class WhatsAppMessageDetails
|
|
230
|
+
attr_reader :kind, :template, :message_id
|
|
231
|
+
|
|
232
|
+
KINDS = %w[text media template].freeze
|
|
233
|
+
|
|
234
|
+
def initialize(data)
|
|
235
|
+
@kind = data["kind"]
|
|
236
|
+
if data["template"]
|
|
237
|
+
@template = {
|
|
238
|
+
name: data.dig("template", "name"),
|
|
239
|
+
language: data.dig("template", "language"),
|
|
240
|
+
category: data.dig("template", "category")
|
|
241
|
+
}
|
|
242
|
+
end
|
|
243
|
+
@message_id = data["messageId"] || data["message_id"]
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def to_h
|
|
247
|
+
{ kind: kind, template: template, message_id: message_id }.compact
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# A sent WhatsApp message. Unlike {Message}, +segments+ is always 1
|
|
252
|
+
# (WhatsApp has no segment concept), +text+ is nil for template and media
|
|
253
|
+
# sends, and +whatsapp+ carries the channel-specific details.
|
|
254
|
+
class WhatsAppMessage
|
|
255
|
+
attr_reader :id, :channel, :message_format, :to, :from, :text, :status,
|
|
256
|
+
:segments, :credits_used, :whatsapp, :created_at, :metadata
|
|
257
|
+
|
|
258
|
+
def initialize(data)
|
|
259
|
+
@id = data["id"]
|
|
260
|
+
@channel = data["channel"] || "whatsapp"
|
|
261
|
+
@message_format = data["message_format"] || data["messageFormat"] || "whatsapp"
|
|
262
|
+
@to = data["to"]
|
|
263
|
+
@from = data["from"]
|
|
264
|
+
@text = data["text"]
|
|
265
|
+
@status = data["status"]
|
|
266
|
+
@segments = data["segments"] || 1
|
|
267
|
+
@credits_used = data["creditsUsed"] || data["credits_used"] || 0
|
|
268
|
+
@whatsapp = data["whatsapp"] ? WhatsAppMessageDetails.new(data["whatsapp"]) : nil
|
|
269
|
+
@created_at = data["createdAt"] || data["created_at"]
|
|
270
|
+
@metadata = data["metadata"]
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def delivered?
|
|
274
|
+
status == "delivered"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def failed?
|
|
278
|
+
status == "failed"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def to_h
|
|
282
|
+
{
|
|
283
|
+
id: id, channel: channel, message_format: message_format,
|
|
284
|
+
to: to, from: from, text: text, status: status, segments: segments,
|
|
285
|
+
credits_used: credits_used, whatsapp: whatsapp&.to_h,
|
|
286
|
+
created_at: created_at, metadata: metadata
|
|
287
|
+
}.compact
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Connect numbers to WhatsApp. Starting a signup returns a +connect_url+
|
|
292
|
+
# a human must complete in a browser.
|
|
293
|
+
class WhatsAppSignupResource
|
|
294
|
+
def initialize(client)
|
|
295
|
+
@client = client
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Start connecting a number to WhatsApp.
|
|
299
|
+
#
|
|
300
|
+
# Charges a one-time $19 setup fee (no monthly fee) and returns a
|
|
301
|
+
# +connect_url+. Completing the connection requires a human: hand the
|
|
302
|
+
# URL to your user — they open it in a browser and log in with Facebook
|
|
303
|
+
# to link their WhatsApp Business Account. Then poll {#get} until the
|
|
304
|
+
# status is +"active"+.
|
|
305
|
+
#
|
|
306
|
+
# Calling again for a number with an in-flight signup returns the
|
|
307
|
+
# existing signup (same +connect_url+) without charging again. Requires
|
|
308
|
+
# a live API key.
|
|
309
|
+
#
|
|
310
|
+
# @param phone_number [String] The number to connect, in E.164 format.
|
|
311
|
+
# Must be an active number in your workspace.
|
|
312
|
+
# @return [WhatsAppSignupSession]
|
|
313
|
+
#
|
|
314
|
+
# @example
|
|
315
|
+
# signup = client.whatsapp.signup.create(phone_number: "+15559876543")
|
|
316
|
+
# puts "Open #{signup.connect_url} and log in with Facebook"
|
|
317
|
+
def create(phone_number:)
|
|
318
|
+
raise ValidationError, "phone_number is required" if phone_number.nil? || phone_number.to_s.empty?
|
|
319
|
+
|
|
320
|
+
response = @client.post("/whatsapp/signup", { phoneNumber: phone_number })
|
|
321
|
+
WhatsAppSignupSession.new(response)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Get the status of a WhatsApp signup.
|
|
325
|
+
#
|
|
326
|
+
# @param id [String] The signup's id
|
|
327
|
+
# @return [WhatsAppSignup]
|
|
328
|
+
# @raise [Sendly::NotFoundError] If no such signup exists in your workspace
|
|
329
|
+
#
|
|
330
|
+
# @example
|
|
331
|
+
# signup = client.whatsapp.signup.get("was_xxx")
|
|
332
|
+
# puts signup.failure_reasons if signup.failed?
|
|
333
|
+
def get(id)
|
|
334
|
+
raise ValidationError, "id is required" if id.nil? || id.to_s.empty?
|
|
335
|
+
|
|
336
|
+
encoded_id = URI.encode_www_form_component(id)
|
|
337
|
+
response = @client.get("/whatsapp/signup/#{encoded_id}")
|
|
338
|
+
WhatsAppSignup.new(response)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# List the numbers connected (or connecting) to WhatsApp, and manage
|
|
343
|
+
# their business profiles.
|
|
344
|
+
class WhatsAppSendersResource
|
|
345
|
+
def initialize(client)
|
|
346
|
+
@client = client
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# List your WhatsApp senders, newest first. An empty list means no
|
|
350
|
+
# number is connected yet — start one with
|
|
351
|
+
# {WhatsAppSignupResource#create}.
|
|
352
|
+
#
|
|
353
|
+
# @return [Hash] +{ senders: Array<WhatsAppSender> }+
|
|
354
|
+
#
|
|
355
|
+
# @example
|
|
356
|
+
# client.whatsapp.senders.list[:senders].each do |s|
|
|
357
|
+
# puts "#{s.phone_number} (#{s.display_name || 'no name yet'}) — #{s.status}"
|
|
358
|
+
# end
|
|
359
|
+
def list
|
|
360
|
+
response = @client.get("/whatsapp/senders")
|
|
361
|
+
senders = (response["senders"] || []).map { |s| WhatsAppSender.new(s) }
|
|
362
|
+
{ senders: senders }
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Get a sender's WhatsApp business profile.
|
|
366
|
+
#
|
|
367
|
+
# The business profile is what recipients see when they open the
|
|
368
|
+
# sender's details in WhatsApp: display name, photo, category, about
|
|
369
|
+
# line, description, and contact details. The sender must be +"active"+
|
|
370
|
+
# (connected to WhatsApp).
|
|
371
|
+
#
|
|
372
|
+
# @param phone_number [String] Your WhatsApp-connected sending number,
|
|
373
|
+
# in E.164 format
|
|
374
|
+
# @return [WhatsAppSenderProfile]
|
|
375
|
+
# @raise [Sendly::NotFoundError] If the number isn't connected to WhatsApp
|
|
376
|
+
#
|
|
377
|
+
# @example
|
|
378
|
+
# profile = client.whatsapp.senders.get_profile("+15559876543")
|
|
379
|
+
# puts profile.display_name
|
|
380
|
+
# puts profile.about
|
|
381
|
+
def get_profile(phone_number)
|
|
382
|
+
raise ValidationError, "phone_number is required" if phone_number.nil? || phone_number.to_s.empty?
|
|
383
|
+
|
|
384
|
+
encoded_number = URI.encode_www_form_component(phone_number)
|
|
385
|
+
response = @client.get("/whatsapp/senders/#{encoded_number}/profile")
|
|
386
|
+
WhatsAppSenderProfile.new(response)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Update a sender's WhatsApp business profile.
|
|
390
|
+
#
|
|
391
|
+
# Pass only the fields to change; omitted fields keep their current
|
|
392
|
+
# value. +about+ is limited to 139 characters and +description+ to 512.
|
|
393
|
+
# +display_name+ changes are reviewed by Meta before they take effect.
|
|
394
|
+
# The profile photo cannot be set through this method. Requires a live
|
|
395
|
+
# API key.
|
|
396
|
+
#
|
|
397
|
+
# @param phone_number [String] Your WhatsApp-connected sending number,
|
|
398
|
+
# in E.164 format
|
|
399
|
+
# @param display_name [String, nil] The business name recipients see
|
|
400
|
+
# @param about [String, nil] Short profile line (max 139 characters)
|
|
401
|
+
# @param description [String, nil] Longer business description (max 512 characters)
|
|
402
|
+
# @param category [String, nil] Business category shown on the profile
|
|
403
|
+
# @param email [String, nil] Contact email shown on the profile
|
|
404
|
+
# @param website [String, nil] Website shown on the profile
|
|
405
|
+
# @param address [String, nil] Business address shown on the profile
|
|
406
|
+
# @return [WhatsAppSenderProfile] The updated profile
|
|
407
|
+
# @raise [Sendly::ValidationError] If no field is given, or a field is too long
|
|
408
|
+
# @raise [Sendly::NotFoundError] If the number isn't connected to WhatsApp
|
|
409
|
+
#
|
|
410
|
+
# @example
|
|
411
|
+
# client.whatsapp.senders.update_profile("+15559876543",
|
|
412
|
+
# about: "Fresh roasted coffee, delivered.",
|
|
413
|
+
# website: "https://acme.example"
|
|
414
|
+
# )
|
|
415
|
+
def update_profile(phone_number, display_name: nil, about: nil, description: nil,
|
|
416
|
+
category: nil, email: nil, website: nil, address: nil)
|
|
417
|
+
raise ValidationError, "phone_number is required" if phone_number.nil? || phone_number.to_s.empty?
|
|
418
|
+
|
|
419
|
+
body = {}
|
|
420
|
+
body[:displayName] = display_name unless display_name.nil?
|
|
421
|
+
body[:about] = about unless about.nil?
|
|
422
|
+
body[:description] = description unless description.nil?
|
|
423
|
+
body[:category] = category unless category.nil?
|
|
424
|
+
body[:email] = email unless email.nil?
|
|
425
|
+
body[:website] = website unless website.nil?
|
|
426
|
+
body[:address] = address unless address.nil?
|
|
427
|
+
raise ValidationError, "Provide at least one profile field to update" if body.empty?
|
|
428
|
+
|
|
429
|
+
encoded_number = URI.encode_www_form_component(phone_number)
|
|
430
|
+
response = @client.patch("/whatsapp/senders/#{encoded_number}/profile", body)
|
|
431
|
+
WhatsAppSenderProfile.new(response)
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Manage Meta-reviewed message templates.
|
|
436
|
+
class WhatsAppTemplatesResource
|
|
437
|
+
def initialize(client)
|
|
438
|
+
@client = client
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# List your WhatsApp templates.
|
|
442
|
+
#
|
|
443
|
+
# @return [Hash] +{ templates: Array<WhatsAppTemplate> }+
|
|
444
|
+
#
|
|
445
|
+
# @example
|
|
446
|
+
# client.whatsapp.templates.list[:templates].each do |t|
|
|
447
|
+
# puts "#{t.name} (#{t.language}) — #{t.status}"
|
|
448
|
+
# end
|
|
449
|
+
def list
|
|
450
|
+
response = @client.get("/whatsapp/templates")
|
|
451
|
+
templates = (response["templates"] || []).map { |t| WhatsAppTemplate.new(t) }
|
|
452
|
+
{ templates: templates }
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Create a template and submit it to Meta for review.
|
|
456
|
+
#
|
|
457
|
+
# Review usually takes 24-48h; the template is usable once its status
|
|
458
|
+
# is +"APPROVED"+. Requires a live API key.
|
|
459
|
+
#
|
|
460
|
+
# @param sender [String] The WhatsApp-connected sending number this
|
|
461
|
+
# template belongs to, in E.164 format
|
|
462
|
+
# @param name [String] Template name: lowercase letters, digits, and
|
|
463
|
+
# underscores (e.g. "order_shipped")
|
|
464
|
+
# @param language [String] Template language code (e.g. "en_US")
|
|
465
|
+
# @param category [String] "AUTHENTICATION", "UTILITY", or "MARKETING" —
|
|
466
|
+
# drives Meta review rules and pricing
|
|
467
|
+
# @param body [String] Body text. Use {{1}}, {{2}}, ... for variables;
|
|
468
|
+
# every placeholder needs an example value in +examples+
|
|
469
|
+
# @param header [String, nil] Optional text header
|
|
470
|
+
# @param footer [String, nil] Optional footer line
|
|
471
|
+
# @param buttons [Array<Hash>, nil] Optional buttons, each with :type
|
|
472
|
+
# ("url", "quick_reply", or "otp"), :text, and for url buttons a :url
|
|
473
|
+
# (may contain a {{1}} placeholder) plus :example values for review
|
|
474
|
+
# @param examples [Hash, nil] Example values for body placeholders,
|
|
475
|
+
# keyed by placeholder number: { "1" => "Acme Inc", "2" => "#4821" }.
|
|
476
|
+
# Required when the body has variables.
|
|
477
|
+
# @return [WhatsAppTemplate] The created template (status "PENDING"),
|
|
478
|
+
# with any submission warnings
|
|
479
|
+
# @raise [Sendly::NotFoundError] If the sender isn't connected to WhatsApp
|
|
480
|
+
#
|
|
481
|
+
# @example
|
|
482
|
+
# template = client.whatsapp.templates.create(
|
|
483
|
+
# sender: "+15559876543",
|
|
484
|
+
# name: "order_shipped",
|
|
485
|
+
# language: "en_US",
|
|
486
|
+
# category: "UTILITY",
|
|
487
|
+
# body: "Hi {{1}}, your order {{2}} has shipped!",
|
|
488
|
+
# examples: { "1" => "Sam", "2" => "#4821" }
|
|
489
|
+
# )
|
|
490
|
+
# puts template.status # "PENDING"
|
|
491
|
+
def create(sender:, name:, language:, category:, body:,
|
|
492
|
+
header: nil, footer: nil, buttons: nil, examples: nil)
|
|
493
|
+
raise ValidationError, "sender is required" if sender.nil? || sender.to_s.empty?
|
|
494
|
+
raise ValidationError, "name is required" if name.nil? || name.to_s.empty?
|
|
495
|
+
raise ValidationError, "language is required" if language.nil? || language.to_s.empty?
|
|
496
|
+
raise ValidationError, "category is required" if category.nil? || category.to_s.empty?
|
|
497
|
+
raise ValidationError, "body is required" if body.nil? || body.to_s.empty?
|
|
498
|
+
|
|
499
|
+
payload = {
|
|
500
|
+
sender: sender,
|
|
501
|
+
name: name,
|
|
502
|
+
language: language,
|
|
503
|
+
category: category,
|
|
504
|
+
body: body
|
|
505
|
+
}
|
|
506
|
+
payload[:header] = header unless header.nil?
|
|
507
|
+
payload[:footer] = footer unless footer.nil?
|
|
508
|
+
payload[:buttons] = buttons if buttons
|
|
509
|
+
payload[:examples] = examples if examples
|
|
510
|
+
|
|
511
|
+
response = @client.post("/whatsapp/templates", payload)
|
|
512
|
+
WhatsAppTemplate.new(response)
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# Edit an APPROVED or REJECTED template and resubmit it for review.
|
|
516
|
+
#
|
|
517
|
+
# This is the recovery path for rejections: template names are locked
|
|
518
|
+
# for ~30 days after deletion, so editing a rejected template (rather
|
|
519
|
+
# than deleting and re-creating it) is the way to fix it. The updated
|
|
520
|
+
# template goes back to "PENDING" review. Requires a live API key.
|
|
521
|
+
#
|
|
522
|
+
# @param id [String] The template's id
|
|
523
|
+
# @param body [String, nil] Replacement body text
|
|
524
|
+
# @param header [String, nil] Replacement text header
|
|
525
|
+
# @param footer [String, nil] Replacement footer
|
|
526
|
+
# @param buttons [Array<Hash>, nil] Replacement buttons
|
|
527
|
+
# @param examples [Hash, nil] Replacement example values for body placeholders
|
|
528
|
+
# @return [WhatsAppTemplate] The updated template (status "PENDING")
|
|
529
|
+
# @raise [Sendly::NotFoundError] If no such template exists
|
|
530
|
+
#
|
|
531
|
+
# @example
|
|
532
|
+
# client.whatsapp.templates.update("wat_xxx",
|
|
533
|
+
# body: "Hi {{1}}, your order {{2}} is on its way!",
|
|
534
|
+
# examples: { "1" => "Sam", "2" => "#4821" }
|
|
535
|
+
# )
|
|
536
|
+
def update(id, body: nil, header: nil, footer: nil, buttons: nil, examples: nil)
|
|
537
|
+
raise ValidationError, "id is required" if id.nil? || id.to_s.empty?
|
|
538
|
+
|
|
539
|
+
payload = {}
|
|
540
|
+
payload[:body] = body unless body.nil?
|
|
541
|
+
payload[:header] = header unless header.nil?
|
|
542
|
+
payload[:footer] = footer unless footer.nil?
|
|
543
|
+
payload[:buttons] = buttons unless buttons.nil?
|
|
544
|
+
payload[:examples] = examples unless examples.nil?
|
|
545
|
+
|
|
546
|
+
encoded_id = URI.encode_www_form_component(id)
|
|
547
|
+
response = @client.patch("/whatsapp/templates/#{encoded_id}", payload)
|
|
548
|
+
WhatsAppTemplate.new(response)
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# Delete a template.
|
|
552
|
+
#
|
|
553
|
+
# Meta locks a deleted template's name for ~30 days — re-creating it
|
|
554
|
+
# fails with +template_name_locked+ until the lock lifts. To fix a
|
|
555
|
+
# rejected template, prefer {#update}. Requires a live API key.
|
|
556
|
+
#
|
|
557
|
+
# @param id [String] The template's id
|
|
558
|
+
# @return [WhatsAppTemplateDeletion]
|
|
559
|
+
# @raise [Sendly::NotFoundError] If no such template exists
|
|
560
|
+
#
|
|
561
|
+
# @example
|
|
562
|
+
# client.whatsapp.templates.delete("wat_xxx")
|
|
563
|
+
def delete(id)
|
|
564
|
+
raise ValidationError, "id is required" if id.nil? || id.to_s.empty?
|
|
565
|
+
|
|
566
|
+
encoded_id = URI.encode_www_form_component(id)
|
|
567
|
+
response = @client.delete("/whatsapp/templates/#{encoded_id}")
|
|
568
|
+
WhatsAppTemplateDeletion.new(response)
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# WhatsApp resource — connect senders, manage their business profiles and
|
|
573
|
+
# templates, and check 24-hour windows.
|
|
574
|
+
#
|
|
575
|
+
# WhatsApp is a first-class Sendly channel: connect a number you own,
|
|
576
|
+
# create Meta-reviewed message templates, and send via
|
|
577
|
+
# +client.messages.send(channel: "whatsapp", ...)+.
|
|
578
|
+
#
|
|
579
|
+
# Connecting a number is a one-time $19 setup (no monthly fee) and always
|
|
580
|
+
# ends with a human step: {WhatsAppSignupResource#create} returns a
|
|
581
|
+
# +connect_url+ that a person must open in a browser and log in with
|
|
582
|
+
# Facebook to link their WhatsApp Business Account.
|
|
583
|
+
#
|
|
584
|
+
# Two ways to reach a recipient:
|
|
585
|
+
#
|
|
586
|
+
# - Inside a 24-hour window (the recipient messaged you in the last 24h):
|
|
587
|
+
# free-form text and media are allowed. Check with {#window}.
|
|
588
|
+
# - Anytime: an approved template. Templates are reviewed by Meta
|
|
589
|
+
# (typically 24-48h) and categorized as authentication, utility, or
|
|
590
|
+
# marketing — pricing follows the category and destination country.
|
|
591
|
+
# Note: Meta has paused marketing template delivery to US (+1) numbers.
|
|
592
|
+
#
|
|
593
|
+
# @example Connect a number, create a template, then send
|
|
594
|
+
# # 1. Connect ($19 one-time; a human must open the connect URL)
|
|
595
|
+
# signup = client.whatsapp.signup.create(phone_number: "+15559876543")
|
|
596
|
+
# puts "Have your user open: #{signup.connect_url}"
|
|
597
|
+
#
|
|
598
|
+
# # 2. Poll until active
|
|
599
|
+
# status = client.whatsapp.signup.get(signup.id)
|
|
600
|
+
#
|
|
601
|
+
# # 3. Create a template (Meta reviews it, usually 24-48h)
|
|
602
|
+
# client.whatsapp.templates.create(
|
|
603
|
+
# sender: "+15559876543",
|
|
604
|
+
# name: "order_shipped",
|
|
605
|
+
# language: "en_US",
|
|
606
|
+
# category: "UTILITY",
|
|
607
|
+
# body: "Hi {{1}}, your order {{2}} has shipped!",
|
|
608
|
+
# examples: { "1" => "Sam", "2" => "#4821" }
|
|
609
|
+
# )
|
|
610
|
+
#
|
|
611
|
+
# # 4. Send — free-form inside an open 24h window, template anytime
|
|
612
|
+
# window = client.whatsapp.window(from: "+15559876543", to: "+15551234567")
|
|
613
|
+
class WhatsAppResource
|
|
614
|
+
# @return [WhatsAppSignupResource] Connect numbers to WhatsApp
|
|
615
|
+
attr_reader :signup
|
|
616
|
+
|
|
617
|
+
# @return [WhatsAppSendersResource] List connected numbers and manage
|
|
618
|
+
# their business profiles
|
|
619
|
+
attr_reader :senders
|
|
620
|
+
|
|
621
|
+
# @return [WhatsAppTemplatesResource] Manage Meta-reviewed templates
|
|
622
|
+
attr_reader :templates
|
|
623
|
+
|
|
624
|
+
def initialize(client)
|
|
625
|
+
@client = client
|
|
626
|
+
@signup = WhatsAppSignupResource.new(client)
|
|
627
|
+
@senders = WhatsAppSendersResource.new(client)
|
|
628
|
+
@templates = WhatsAppTemplatesResource.new(client)
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# Check whether a 24-hour customer-service window is open between one
|
|
632
|
+
# of your WhatsApp senders and a recipient.
|
|
633
|
+
#
|
|
634
|
+
# Free-form text and media only deliver while a window is open (it
|
|
635
|
+
# opens when the recipient messages you and lasts 24h from their last
|
|
636
|
+
# inbound message). Outside a window, send an approved template.
|
|
637
|
+
#
|
|
638
|
+
# @param from [String] Your WhatsApp-connected sending number, in E.164 format
|
|
639
|
+
# @param to [String] The recipient's number, in E.164 format
|
|
640
|
+
# @return [WhatsAppWindow]
|
|
641
|
+
#
|
|
642
|
+
# @example
|
|
643
|
+
# window = client.whatsapp.window(from: "+15559876543", to: "+15551234567")
|
|
644
|
+
# unless window.open?
|
|
645
|
+
# # Use a template send instead of free-form text
|
|
646
|
+
# end
|
|
647
|
+
def window(from:, to:)
|
|
648
|
+
raise ValidationError, "from is required" if from.nil? || from.to_s.empty?
|
|
649
|
+
raise ValidationError, "to is required" if to.nil? || to.to_s.empty?
|
|
650
|
+
|
|
651
|
+
response = @client.get("/whatsapp/window", { from: from, to: to })
|
|
652
|
+
WhatsAppWindow.new(response)
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
end
|
data/lib/sendly.rb
CHANGED
|
@@ -25,6 +25,8 @@ require_relative "sendly/business_upgrade_resource"
|
|
|
25
25
|
require_relative "sendly/numbers_resource"
|
|
26
26
|
require_relative "sendly/tendlc_resource"
|
|
27
27
|
require_relative "sendly/links_resource"
|
|
28
|
+
require_relative "sendly/whatsapp_resource"
|
|
29
|
+
require_relative "sendly/rcs_resource"
|
|
28
30
|
|
|
29
31
|
# Sendly Ruby SDK
|
|
30
32
|
#
|