ruby-whatsapp 0.4.0 → 0.6.1
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 +152 -0
- data/README.md +322 -617
- data/docs/README.md +76 -0
- data/docs/business_phone_number/README.md +243 -0
- data/docs/business_phone_number/account.md +170 -0
- data/docs/business_phone_number/contact_book.md +90 -0
- data/docs/business_phone_number/profile.md +220 -0
- data/docs/business_phone_number/username.md +157 -0
- data/docs/configuration.md +177 -0
- data/docs/errors.md +219 -0
- data/docs/media/README.md +161 -0
- data/docs/message_templates/README.md +206 -0
- data/docs/message_templates/authentication.md +107 -0
- data/docs/message_templates/carousel.md +103 -0
- data/docs/message_templates/components.md +215 -0
- data/docs/message_templates/library.md +98 -0
- data/docs/message_templates/limited_time_offer.md +94 -0
- data/docs/message_templates/responses.md +173 -0
- data/docs/message_templates/standard.md +138 -0
- data/docs/messages/README.md +212 -0
- data/docs/messages/address.md +86 -0
- data/docs/messages/audio.md +43 -0
- data/docs/messages/contacts.md +129 -0
- data/docs/messages/document.md +49 -0
- data/docs/messages/image.md +59 -0
- data/docs/messages/interactive.md +342 -0
- data/docs/messages/location.md +51 -0
- data/docs/messages/location_request.md +59 -0
- data/docs/messages/mark_message_as_read.md +84 -0
- data/docs/messages/reaction.md +59 -0
- data/docs/messages/sticker.md +40 -0
- data/docs/messages/template.md +193 -0
- data/docs/messages/text.md +55 -0
- data/docs/messages/video.md +41 -0
- data/docs/schedule/README.md +183 -0
- data/docs/subscribed_app/README.md +139 -0
- data/docs/webhooks/README.md +217 -0
- data/docs/webhooks/account_alerts.md +44 -0
- data/docs/webhooks/account_review_update.md +29 -0
- data/docs/webhooks/account_update.md +54 -0
- data/docs/webhooks/automatic_events.md +47 -0
- data/docs/webhooks/business_capability_update.md +40 -0
- data/docs/webhooks/business_username_updates.md +55 -0
- data/docs/webhooks/history.md +107 -0
- data/docs/webhooks/message_template_components_update.md +46 -0
- data/docs/webhooks/message_template_quality_update.md +49 -0
- data/docs/webhooks/message_template_status_update.md +55 -0
- data/docs/webhooks/messages.md +520 -0
- data/docs/webhooks/partner_solutions.md +35 -0
- data/docs/webhooks/payment_configuration_update.md +40 -0
- data/docs/webhooks/phone_number_name_update.md +44 -0
- data/docs/webhooks/phone_number_quality_update.md +40 -0
- data/docs/webhooks/security.md +43 -0
- data/docs/webhooks/smb_app_state_sync.md +91 -0
- data/docs/webhooks/smb_message_echoes.md +91 -0
- data/docs/webhooks/template_category_update.md +52 -0
- data/docs/webhooks/user_id_update.md +84 -0
- data/docs/webhooks/user_preferences.md +87 -0
- data/lib/ruby/whatsapp/business_phone_number/account/details.rb +149 -0
- data/lib/ruby/whatsapp/business_phone_number/account/get.rb +53 -0
- data/lib/ruby/whatsapp/business_phone_number/account/transport.rb +30 -0
- data/lib/ruby/whatsapp/business_phone_number/account/update.rb +76 -0
- data/lib/ruby/whatsapp/business_phone_number/account.rb +23 -0
- data/lib/ruby/whatsapp/business_phone_number/contact_book/delete.rb +87 -0
- data/lib/ruby/whatsapp/business_phone_number/contact_book/deletion.rb +50 -0
- data/lib/ruby/whatsapp/business_phone_number/contact_book.rb +25 -0
- data/lib/ruby/whatsapp/business_phone_number/profile/details.rb +105 -0
- data/lib/ruby/whatsapp/business_phone_number/profile/get.rb +56 -0
- data/lib/ruby/whatsapp/business_phone_number/profile/update.rb +148 -0
- data/lib/ruby/whatsapp/business_phone_number/profile/verticals.rb +54 -0
- data/lib/ruby/whatsapp/business_phone_number/profile.rb +22 -0
- data/lib/ruby/whatsapp/business_phone_number/response.rb +3 -3
- data/lib/ruby/whatsapp/business_phone_number/transport.rb +12 -12
- data/lib/ruby/whatsapp/business_phone_number/username/claim.rb +180 -0
- data/lib/ruby/whatsapp/business_phone_number/username/delete.rb +36 -0
- data/lib/ruby/whatsapp/business_phone_number/username/details.rb +66 -0
- data/lib/ruby/whatsapp/business_phone_number/username/get.rb +33 -0
- data/lib/ruby/whatsapp/business_phone_number/username/suggest.rb +37 -0
- data/lib/ruby/whatsapp/business_phone_number/username/suggestions.rb +50 -0
- data/lib/ruby/whatsapp/business_phone_number/username.rb +27 -0
- data/lib/ruby/whatsapp/business_phone_number.rb +20 -4
- data/lib/ruby/whatsapp/message_templates/button/request_contact_info.rb +66 -0
- data/lib/ruby/whatsapp/message_templates/button.rb +5 -2
- data/lib/ruby/whatsapp/message_templates/component_set.rb +28 -0
- data/lib/ruby/whatsapp/message_templates/response/paging.rb +2 -1
- data/lib/ruby/whatsapp/message_templates.rb +2 -6
- data/lib/ruby/whatsapp/messages/base.rb +47 -5
- data/lib/ruby/whatsapp/messages/interactive/request_contact_info.rb +59 -0
- data/lib/ruby/whatsapp/messages/interactive.rb +22 -4
- data/lib/ruby/whatsapp/messages/response/contacts.rb +22 -5
- data/lib/ruby/whatsapp/path_building.rb +33 -0
- data/lib/ruby/whatsapp/schedule/create.rb +101 -0
- data/lib/ruby/whatsapp/schedule/list.rb +50 -0
- data/lib/ruby/whatsapp/schedule/response/collection.rb +61 -0
- data/lib/ruby/whatsapp/schedule/response/created.rb +30 -0
- data/lib/ruby/whatsapp/schedule/response/node.rb +85 -0
- data/lib/ruby/whatsapp/schedule/response.rb +18 -0
- data/lib/ruby/whatsapp/schedule/statuses.rb +19 -0
- data/lib/ruby/whatsapp/schedule/transport.rb +24 -0
- data/lib/ruby/whatsapp/schedule.rb +15 -0
- data/lib/ruby/whatsapp/subscribed_app/transport.rb +6 -5
- data/lib/ruby/whatsapp/version.rb +1 -1
- data/lib/ruby/whatsapp/webhook/business_username_updates.rb +51 -0
- data/lib/ruby/whatsapp/webhook/change.rb +3 -1
- data/lib/ruby/whatsapp/webhook/contact.rb +26 -3
- data/lib/ruby/whatsapp/webhook/history/chunk.rb +50 -0
- data/lib/ruby/whatsapp/webhook/history/thread.rb +64 -0
- data/lib/ruby/whatsapp/webhook/history.rb +73 -34
- data/lib/ruby/whatsapp/webhook/message/base.rb +27 -2
- data/lib/ruby/whatsapp/webhook/message/contacts/contact.rb +15 -2
- data/lib/ruby/whatsapp/webhook/message/context.rb +23 -2
- data/lib/ruby/whatsapp/webhook/message/edit.rb +50 -0
- data/lib/ruby/whatsapp/webhook/message/revoke.rb +36 -0
- data/lib/ruby/whatsapp/webhook/message/system.rb +19 -2
- data/lib/ruby/whatsapp/webhook/message/unknown.rb +2 -2
- data/lib/ruby/whatsapp/webhook/message.rb +3 -0
- data/lib/ruby/whatsapp/webhook/messages.rb +4 -3
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync/contact.rb +76 -0
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync/state_sync.rb +18 -4
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync.rb +33 -9
- data/lib/ruby/whatsapp/webhook/smb_message_echoes/echo.rb +60 -0
- data/lib/ruby/whatsapp/webhook/smb_message_echoes.rb +26 -11
- data/lib/ruby/whatsapp/webhook/status.rb +23 -2
- data/lib/ruby/whatsapp/webhook/user_id_update/entry.rb +56 -0
- data/lib/ruby/whatsapp/webhook/user_id_update/id_change.rb +35 -0
- data/lib/ruby/whatsapp/webhook/user_id_update.rb +61 -0
- data/lib/ruby/whatsapp/webhook/user_preferences/entry.rb +76 -0
- data/lib/ruby/whatsapp/webhook/user_preferences.rb +44 -23
- metadata +100 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
module BusinessPhoneNumber
|
|
5
|
+
module Username
|
|
6
|
+
# The usernames Meta has reserved for your business portfolio, returned by {Suggest}.
|
|
7
|
+
#
|
|
8
|
+
# Owns the doubly-wrapped `{"data": [{"username_suggestions": [...]}]}` envelope so
|
|
9
|
+
# callers never reach through it — the same flatten-the-meaningless-wrapper habit as
|
|
10
|
+
# {Profile::Details}, {SubscribedApp::Response::App}, and
|
|
11
|
+
# {MessageTemplates::Response::Paging}. Unlike {Profile::Details}, which reads only
|
|
12
|
+
# `data[0]` because a phone number has exactly one profile, this flattens across
|
|
13
|
+
# *every* entry: Meta's example shows one, but nothing documents that as a limit, and
|
|
14
|
+
# `flat_map` is identical for one entry while degrading correctly for more.
|
|
15
|
+
class Suggestions
|
|
16
|
+
include Enumerable
|
|
17
|
+
|
|
18
|
+
# @!attribute [rw] suggestions
|
|
19
|
+
# @return [Array<String>] Reserved usernames, which have a higher chance of
|
|
20
|
+
# approval than one you invent. Empty when Meta has reserved none.
|
|
21
|
+
attr_accessor :suggestions
|
|
22
|
+
|
|
23
|
+
def initialize(suggestions: [])
|
|
24
|
+
@suggestions = suggestions
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @yieldparam suggestion [String]
|
|
28
|
+
# @return [Enumerator, self]
|
|
29
|
+
def each(&)
|
|
30
|
+
return suggestions.each unless block_given?
|
|
31
|
+
|
|
32
|
+
suggestions.each(&)
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
# @param response [Hash, nil] The parsed response body.
|
|
38
|
+
# @return [Suggestions]
|
|
39
|
+
def deserialize(response)
|
|
40
|
+
response ||= {}
|
|
41
|
+
|
|
42
|
+
new(
|
|
43
|
+
suggestions: Array(response["data"]).flat_map { |entry| Array((entry || {})["username_suggestions"]) }
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
module BusinessPhoneNumber
|
|
5
|
+
# Reads, claims, and deletes the **business username** on a phone number — the
|
|
6
|
+
# searchable `@name` a WhatsApp user can find you by, as opposed to the display name,
|
|
7
|
+
# which they cannot search for.
|
|
8
|
+
#
|
|
9
|
+
# Like {Profile}, and unlike {Account}, this is not an exception to the module's
|
|
10
|
+
# `phone_id` rule: Meta exposes both edges on the phone number itself
|
|
11
|
+
# (`GET`/`POST`/`DELETE /{phone_id}/username` and
|
|
12
|
+
# `GET /{phone_id}/username_suggestions`), so all four actions reuse
|
|
13
|
+
# {BusinessPhoneNumber::Transport} unchanged. It is the first sub-namespace here to
|
|
14
|
+
# address two edges, which is exactly why `edge_path` takes the edge as an argument.
|
|
15
|
+
#
|
|
16
|
+
# A username is 1:1 with a phone number across all of WhatsApp — no two numbers,
|
|
17
|
+
# consumer or business, can hold the same one. Adopting one does **not** hide your
|
|
18
|
+
# phone number in the app; it is a discovery feature, not a privacy one, which is the
|
|
19
|
+
# opposite of what a *user* username does. The asynchronous counterpart is the
|
|
20
|
+
# `business_username_updates` webhook (see `docs/webhooks/business_username_updates.md`),
|
|
21
|
+
# which is also the only place a `"deleted"` status ever appears.
|
|
22
|
+
# See `docs/business_phone_number/username.md`.
|
|
23
|
+
# Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
|
|
24
|
+
module Username
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -8,13 +8,29 @@ module Whatsapp
|
|
|
8
8
|
# the first place. It is a different concern from {Whatsapp::SubscribedApp}, which
|
|
9
9
|
# turns webhook delivery on and off for a whole WhatsApp Business Account: this
|
|
10
10
|
# module is per phone number and is a prerequisite for messaging itself, not just
|
|
11
|
-
# for notifications.
|
|
12
|
-
# {Whatsapp::
|
|
11
|
+
# for notifications. The onboarding actions address `phone_id`, like
|
|
12
|
+
# {Whatsapp::Media} and {Whatsapp::Messages}.
|
|
13
13
|
#
|
|
14
14
|
# The full flow, in order: {RequestCode} -> {VerifyCode} -> {Register}, with
|
|
15
15
|
# {Deregister} as the reverse switch.
|
|
16
|
-
#
|
|
17
|
-
#
|
|
16
|
+
#
|
|
17
|
+
# {Profile} reads and updates the public card a WhatsApp user sees before they reply.
|
|
18
|
+
# It addresses `phone_id` like the onboarding actions above, so it shares their transport.
|
|
19
|
+
#
|
|
20
|
+
# {Username} reads, claims, and deletes the searchable `@name` a WhatsApp user can find
|
|
21
|
+
# the number by — the business half of Meta's usernames rollout, whose user half is what
|
|
22
|
+
# makes {Whatsapp::Messages::Base#recipient} necessary. Two `phone_id` edges, one
|
|
23
|
+
# transport, still no exception.
|
|
24
|
+
#
|
|
25
|
+
# {ContactBook} deletes a stored user contact — the store Meta uses to keep putting a
|
|
26
|
+
# user's phone number in your webhooks after they adopt a username. Also a `phone_id`
|
|
27
|
+
# edge, though what it affects is scoped to the whole business portfolio.
|
|
28
|
+
#
|
|
29
|
+
# {Account} is the one exception to the `phone_id` rule: it reads and updates the
|
|
30
|
+
# WhatsApp Business Account a number belongs to, so it addresses `waba_id` and
|
|
31
|
+
# carries its own transport.
|
|
32
|
+
# See `lib/ruby/whatsapp/business_phone_number/CLAUDE.md`,
|
|
33
|
+
# `docs/business_phone_number/README.md`, and `docs/business_phone_number/profile.md`.
|
|
18
34
|
# Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/registration
|
|
19
35
|
module BusinessPhoneNumber
|
|
20
36
|
class Error < Whatsapp::Error; end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
class Button
|
|
6
|
+
# Asks the recipient to share their phone number, which they hand over with one tap.
|
|
7
|
+
#
|
|
8
|
+
# The counterpart to Meta's usernames rollout: once a user adopts a username their
|
|
9
|
+
# phone number may stop reaching you, leaving only their
|
|
10
|
+
# [BSUID](https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids).
|
|
11
|
+
# This is how you ask for the number back — and it is the only way to reach such a
|
|
12
|
+
# user with a one-tap, zero-tap, or copy-code authentication template, since those
|
|
13
|
+
# require a phone number.
|
|
14
|
+
#
|
|
15
|
+
# A tap arrives as an inbound `contacts` message whose
|
|
16
|
+
# {Whatsapp::Webhook::Message::Contacts::Contact#origin} is `"contact_request"`.
|
|
17
|
+
#
|
|
18
|
+
# Allowed on `UTILITY` and `MARKETING` templates only — a cross-component rule, so it
|
|
19
|
+
# lives in {ComponentSet} rather than here.
|
|
20
|
+
# Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
|
|
21
|
+
class RequestContactInfo < Base
|
|
22
|
+
module Defaults
|
|
23
|
+
TYPE = "REQUEST_CONTACT_INFO"
|
|
24
|
+
# The only label Meta accepts, if you send one at all. What the recipient sees is
|
|
25
|
+
# rendered in their own app language, so the button cannot be customized.
|
|
26
|
+
TEXT = "Share Contact Info"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @!attribute [rw] text
|
|
30
|
+
# @return [String, nil] Optional, and only ever {Defaults::TEXT}.
|
|
31
|
+
attr_accessor :text
|
|
32
|
+
|
|
33
|
+
validate :validate_text
|
|
34
|
+
|
|
35
|
+
# @param text [String, nil] Omit it — the label is not customizable and Meta
|
|
36
|
+
# localizes what the recipient sees. Accepted only as the exact literal
|
|
37
|
+
# {Defaults::TEXT}, for callers mirroring Meta's own example payload; it is
|
|
38
|
+
# forwarded rather than dropped, since Meta documents the field as accepted.
|
|
39
|
+
# @raise [ActiveModel::ValidationError] if `text` is anything else.
|
|
40
|
+
def initialize(text: nil)
|
|
41
|
+
@text = text
|
|
42
|
+
|
|
43
|
+
validate!
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Hash] The serialized button — type alone, unless a label was given.
|
|
47
|
+
def serialize
|
|
48
|
+
{ type: Defaults::TYPE, text: }.compact
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Hand-rolled rather than an `inclusion:` validator, because the default message
|
|
54
|
+
# ("is not included in the list") would not tell anyone *why* one value is allowed.
|
|
55
|
+
# @return [void]
|
|
56
|
+
def validate_text
|
|
57
|
+
return if text.nil? || text == Defaults::TEXT
|
|
58
|
+
|
|
59
|
+
errors.add(:text,
|
|
60
|
+
"must be exactly #{Defaults::TEXT.inspect} if given; " \
|
|
61
|
+
"Meta localizes the label the recipient sees, so it cannot be customized")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -11,8 +11,10 @@ module Whatsapp
|
|
|
11
11
|
#
|
|
12
12
|
# Only the button types with a published schema are registered. Meta's Graph API
|
|
13
13
|
# enum also lists FLOW, MPM, CATALOG, VOICE_CALL, VIDEO_CALL, POSTBACK,
|
|
14
|
-
# BOOKING_STATUS
|
|
15
|
-
#
|
|
14
|
+
# BOOKING_STATUS and PAYMENT_REQUEST, but publishes no field reference for them, so
|
|
15
|
+
# they are deliberately absent rather than guessed. REQUEST_CONTACT_INFO used to be on
|
|
16
|
+
# that list; the business-scoped user ID documentation publishes its schema, so it is
|
|
17
|
+
# registered.
|
|
16
18
|
# Source: https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/components/
|
|
17
19
|
class Button
|
|
18
20
|
TYPES = {
|
|
@@ -21,6 +23,7 @@ module Whatsapp
|
|
|
21
23
|
phone_number: PhoneNumber,
|
|
22
24
|
copy_code: CopyCode,
|
|
23
25
|
otp: Otp,
|
|
26
|
+
request_contact_info: RequestContactInfo,
|
|
24
27
|
}.freeze
|
|
25
28
|
|
|
26
29
|
class << self
|
|
@@ -50,6 +50,12 @@ module Whatsapp
|
|
|
50
50
|
# Categories that may use a location header.
|
|
51
51
|
LOCATION_HEADER_CATEGORIES = [Categories::UTILITY, Categories::MARKETING].freeze
|
|
52
52
|
|
|
53
|
+
# Categories that may carry a request-contact-info button. Deliberately a separate
|
|
54
|
+
# constant from LOCATION_HEADER_CATEGORIES despite holding the same two values
|
|
55
|
+
# today: they are unrelated Meta rules that merely coincide, and merging them would
|
|
56
|
+
# couple two things that can move independently.
|
|
57
|
+
REQUEST_CONTACT_INFO_CATEGORIES = [Categories::UTILITY, Categories::MARKETING].freeze
|
|
58
|
+
|
|
53
59
|
# @!attribute [rw] components
|
|
54
60
|
# @return [Array<Component::Base>]
|
|
55
61
|
attr_accessor :components
|
|
@@ -67,6 +73,7 @@ module Whatsapp
|
|
|
67
73
|
validate :validate_singletons
|
|
68
74
|
validate :validate_category_restrictions
|
|
69
75
|
validate :validate_location_header
|
|
76
|
+
validate :validate_request_contact_info_category
|
|
70
77
|
validate :validate_limited_time_offer
|
|
71
78
|
|
|
72
79
|
# @param components [Array<Hash, Component::Base>] The template's components.
|
|
@@ -151,6 +158,27 @@ module Whatsapp
|
|
|
151
158
|
"a LOCATION header is only supported on #{LOCATION_HEADER_CATEGORIES.join('/')} templates, not #{category}")
|
|
152
159
|
end
|
|
153
160
|
|
|
161
|
+
# Unlike CATEGORY_RESTRICTED_TYPES, which keys on *component* types, this rule is
|
|
162
|
+
# carried by a button nested inside a BUTTONS component — so it drills in the way
|
|
163
|
+
# #validate_location_header drills into a header, using the `api_types` that
|
|
164
|
+
# {Component::Buttons} already exposes.
|
|
165
|
+
#
|
|
166
|
+
# Only the top-level BUTTONS component is scanned, not carousel-card buttons: a
|
|
167
|
+
# CAROUSEL already forces MARKETING via CATEGORY_RESTRICTED_TYPES, which is an
|
|
168
|
+
# allowed category here, so a card-level scan could never fire.
|
|
169
|
+
# @return [void]
|
|
170
|
+
def validate_request_contact_info_category
|
|
171
|
+
return if category.nil?
|
|
172
|
+
|
|
173
|
+
buttons = find(Component::Buttons::Defaults::TYPE)
|
|
174
|
+
return unless buttons&.api_types&.include?(Button::RequestContactInfo::Defaults::TYPE)
|
|
175
|
+
return if REQUEST_CONTACT_INFO_CATEGORIES.include?(category)
|
|
176
|
+
|
|
177
|
+
errors.add(:components,
|
|
178
|
+
"a #{Button::RequestContactInfo::Defaults::TYPE} button is only supported on " \
|
|
179
|
+
"#{REQUEST_CONTACT_INFO_CATEGORIES.join('/')} templates, not #{category}")
|
|
180
|
+
end
|
|
181
|
+
|
|
154
182
|
# A limited-time offer reshapes the rest of the template: no footer, a shorter
|
|
155
183
|
# body, a media-only header, and a copy-code button that must come first.
|
|
156
184
|
# @return [void]
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module Whatsapp
|
|
4
4
|
class MessageTemplates
|
|
5
5
|
module Response
|
|
6
|
-
# Graph API cursor pagination for a template list.
|
|
6
|
+
# Graph API cursor pagination for a template list. Nothing in it is
|
|
7
|
+
# template-specific, so {Whatsapp::Schedule::Response::Collection} reuses it.
|
|
7
8
|
#
|
|
8
9
|
# Flattens the nested `paging.cursors` object, since the intermediate level
|
|
9
10
|
# carries no information of its own.
|
|
@@ -25,6 +25,7 @@ module Whatsapp
|
|
|
25
25
|
# templates.find(template_id: created.id).status # => "PENDING"
|
|
26
26
|
# Source: https://developers.facebook.com/docs/graph-api/reference/whats-app-business-account/message_templates/
|
|
27
27
|
class MessageTemplates
|
|
28
|
+
include PathBuilding
|
|
28
29
|
include ResponseHandling
|
|
29
30
|
|
|
30
31
|
class TemplateError < Whatsapp::Error; end
|
|
@@ -204,12 +205,7 @@ module Whatsapp
|
|
|
204
205
|
# @return [String] The versioned path for a WABA-scoped edge.
|
|
205
206
|
# @raise [TemplateError] if no WABA ID is configured.
|
|
206
207
|
def edge_path(edge)
|
|
207
|
-
|
|
208
|
-
raise TemplateError,
|
|
209
|
-
"waba_id is required for template management; set it via Whatsapp.configure or Client.new(waba_id:)"
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
client.path_for(client.waba_id, edge)
|
|
208
|
+
scoped_path(client, :waba_id, edge, error_class: TemplateError, purpose: "template management")
|
|
213
209
|
end
|
|
214
210
|
|
|
215
211
|
# @return [Hash] Only the editable fields that were supplied.
|
|
@@ -10,16 +10,41 @@ module Whatsapp
|
|
|
10
10
|
RECIPIENT_TYPE = "individual"
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
+
# The two ways to address a message. Drives both the "at least one" guard and its
|
|
14
|
+
# message, so the rule and its wording cannot drift apart.
|
|
15
|
+
RECIPIENT_ATTRIBUTES = %i[to recipient].freeze
|
|
16
|
+
|
|
13
17
|
# @!attribute [rw] to
|
|
14
|
-
# @return [String]
|
|
18
|
+
# @return [String, nil] The recipient's phone number. Optional since Meta added
|
|
19
|
+
# {#recipient}, but still the identifier that wins if both are given.
|
|
15
20
|
attr_accessor :to
|
|
16
21
|
|
|
17
|
-
|
|
22
|
+
# @!attribute [rw] recipient
|
|
23
|
+
# @return [String, nil] The recipient's business-scoped user ID (BSUID), e.g.
|
|
24
|
+
# `"US.13491208655302741918"`, or a parent BSUID, e.g.
|
|
25
|
+
# `"US.ENT.11815799212886844830"` — a country code, a period, and up to 128
|
|
26
|
+
# alphanumeric characters, with `ENT.` inserted for a parent BSUID. Pass the
|
|
27
|
+
# whole value; omitting or altering any part of it fails the request.
|
|
28
|
+
#
|
|
29
|
+
# Deliberately not format-checked here. Meta describes the shape but publishes
|
|
30
|
+
# no client-side rule for it, and the canonical source of a BSUID is this gem's
|
|
31
|
+
# own webhook side ({Whatsapp::Webhook::Contact#user_id} and friends), which
|
|
32
|
+
# keeps it as a raw string — rejecting on the way out what was accepted on the
|
|
33
|
+
# way in would be the worst possible asymmetry.
|
|
34
|
+
attr_accessor :recipient
|
|
35
|
+
|
|
36
|
+
validate :validate_recipient_present
|
|
37
|
+
validates :to, presence: true, allow_nil: true
|
|
38
|
+
validates :recipient, presence: true, allow_nil: true
|
|
18
39
|
|
|
19
|
-
# @param to [String] The recipient's phone number.
|
|
40
|
+
# @param to [String, nil] The recipient's phone number.
|
|
41
|
+
# @param recipient [String, nil] The recipient's BSUID or parent BSUID. Supply
|
|
42
|
+
# either or both of `to`/`recipient`; Meta gives `to` precedence when both are
|
|
43
|
+
# present.
|
|
20
44
|
# @raise [ActiveModel::ValidationError] if validation fails.
|
|
21
|
-
def initialize(to:)
|
|
45
|
+
def initialize(to: nil, recipient: nil)
|
|
22
46
|
@to = to
|
|
47
|
+
@recipient = recipient
|
|
23
48
|
end
|
|
24
49
|
|
|
25
50
|
def serialize
|
|
@@ -36,11 +61,28 @@ module Whatsapp
|
|
|
36
61
|
{
|
|
37
62
|
messaging_product: Messaging::PRODUCT,
|
|
38
63
|
recipient_type: Messaging::RECIPIENT_TYPE,
|
|
39
|
-
|
|
64
|
+
**recipient_fields,
|
|
40
65
|
type:,
|
|
41
66
|
**body,
|
|
42
67
|
}
|
|
43
68
|
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Only the recipient pair is compacted, not the whole envelope: an absent
|
|
73
|
+
# identifier must be left out rather than sent as null, but compacting the outer
|
|
74
|
+
# hash would also swallow a `nil` a subclass passes in `body`.
|
|
75
|
+
# @return [Hash] Whichever of `to`/`recipient` was given.
|
|
76
|
+
def recipient_fields
|
|
77
|
+
{ to:, recipient: }.compact
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [void]
|
|
81
|
+
def validate_recipient_present
|
|
82
|
+
return if RECIPIENT_ATTRIBUTES.any? { |attribute| !public_send(attribute).nil? }
|
|
83
|
+
|
|
84
|
+
errors.add(:base, "at least one of #{RECIPIENT_ATTRIBUTES.join(' or ')} must be provided")
|
|
85
|
+
end
|
|
44
86
|
end
|
|
45
87
|
end
|
|
46
88
|
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class Messages
|
|
5
|
+
class Interactive
|
|
6
|
+
# Asks the recipient to share their phone number, which they hand over with one tap.
|
|
7
|
+
#
|
|
8
|
+
# The interactive-message counterpart to
|
|
9
|
+
# {Whatsapp::MessageTemplates::Button::RequestContactInfo}: use this inside the
|
|
10
|
+
# 24-hour service window, and the template button to open a conversation. Either way
|
|
11
|
+
# this is how you get a phone number back from someone who has adopted a username and
|
|
12
|
+
# reaches you only as a
|
|
13
|
+
# [BSUID](https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids).
|
|
14
|
+
#
|
|
15
|
+
# The only action kind with nothing for the caller to supply — the whole payload is
|
|
16
|
+
# `{ name: "request_contact_info" }` — which is why {Interactive} marks it
|
|
17
|
+
# parameterless and stops requiring an `action:`. Compare
|
|
18
|
+
# {Whatsapp::Messages::LocationRequest}, whose action is the same shape.
|
|
19
|
+
# Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
|
|
20
|
+
class RequestContactInfo < Base
|
|
21
|
+
module Defaults
|
|
22
|
+
ACTION_NAME = "request_contact_info"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @!attribute [rw] name
|
|
26
|
+
# @return [String] Always {Defaults::ACTION_NAME}.
|
|
27
|
+
attr_accessor :name
|
|
28
|
+
|
|
29
|
+
validates :name, inclusion: { in: [Defaults::ACTION_NAME] }
|
|
30
|
+
|
|
31
|
+
# @param name [String] The action name. Defaulted, since Meta accepts exactly one
|
|
32
|
+
# value; the keyword exists so a caller spelling out
|
|
33
|
+
# `action: { name: "request_contact_info" }` — mirroring Meta's example payload —
|
|
34
|
+
# still works.
|
|
35
|
+
# @raise [ActiveModel::ValidationError] if `name` is anything else.
|
|
36
|
+
def initialize(name: Defaults::ACTION_NAME)
|
|
37
|
+
@name = name
|
|
38
|
+
|
|
39
|
+
validate!
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# Takes a keyword splat rather than named parameters, because {Interactive} calls
|
|
44
|
+
# it as `serialize(**action)` with an empty hash for this kind.
|
|
45
|
+
# @return [Hash] Serialized representation of the request.
|
|
46
|
+
def serialize(**)
|
|
47
|
+
new(**).serialize
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Hash] Serialized representation of the request — no `parameters` key,
|
|
52
|
+
# unlike every other action kind, because there is nothing to parameterize.
|
|
53
|
+
def serialize
|
|
54
|
+
{ name: }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -19,6 +19,10 @@ module Whatsapp
|
|
|
19
19
|
url_button: { klass: UrlButton, api_type: "cta_url" },
|
|
20
20
|
media_carousel: { klass: MediaCarousel, api_type: "carousel" },
|
|
21
21
|
product_carousel: { klass: ProductCarousel, api_type: "product_list" },
|
|
22
|
+
# The one kind whose registry key and wire value coincide — the registry exists
|
|
23
|
+
# because they usually differ, so this is not a copy-paste slip. `parameterless`
|
|
24
|
+
# marks the one kind with nothing for a caller to supply; see #validate_action.
|
|
25
|
+
request_contact_info: { klass: RequestContactInfo, api_type: "request_contact_info", parameterless: true },
|
|
22
26
|
}.freeze
|
|
23
27
|
|
|
24
28
|
# @!attribute [rw] type
|
|
@@ -43,16 +47,17 @@ module Whatsapp
|
|
|
43
47
|
|
|
44
48
|
validates :type, presence: true, inclusion: { in: ACTION_TYPES.keys }
|
|
45
49
|
validates :body, presence: true
|
|
46
|
-
|
|
50
|
+
validate :validate_action
|
|
47
51
|
|
|
48
52
|
# @param type [Symbol, String] The action kind (see {ACTION_TYPES}).
|
|
49
53
|
# @param body [String] The body text of the interactive message.
|
|
50
|
-
# @param action [Hash] The action content, forwarded to the action class.
|
|
54
|
+
# @param action [Hash] The action content, forwarded to the action class. Defaults to
|
|
55
|
+
# empty, which only the parameterless `:request_contact_info` kind accepts.
|
|
51
56
|
# @param header [Hash, nil] The header content (optional).
|
|
52
57
|
# @param footer [Hash, nil] The footer content (optional).
|
|
53
|
-
# @param kwargs [Hash] Additional keyword arguments (
|
|
58
|
+
# @param kwargs [Hash] Additional keyword arguments passed to {Base} (:to / :recipient).
|
|
54
59
|
# @raise [ActiveModel::ValidationError] if validation fails.
|
|
55
|
-
def initialize(type:, body:, action
|
|
60
|
+
def initialize(type:, body:, action: {}, header: nil, footer: nil, **)
|
|
56
61
|
super(**)
|
|
57
62
|
|
|
58
63
|
@type = type.to_sym
|
|
@@ -87,6 +92,19 @@ module Whatsapp
|
|
|
87
92
|
ACTION_TYPES.fetch(type)
|
|
88
93
|
end
|
|
89
94
|
|
|
95
|
+
# An empty `action` is blank, so a plain presence validator would reject the one kind
|
|
96
|
+
# that legitimately has nothing to supply. Reads the registry with `[]` rather than
|
|
97
|
+
# the `fetch`-backed #action_config, so an unknown `type` falls through to the
|
|
98
|
+
# inclusion validator above instead of raising KeyError from inside a validator.
|
|
99
|
+
# @return [void]
|
|
100
|
+
def validate_action
|
|
101
|
+
config = ACTION_TYPES[type]
|
|
102
|
+
return if config.nil? || config[:parameterless]
|
|
103
|
+
return if action.present?
|
|
104
|
+
|
|
105
|
+
errors.add(:action, "can't be blank")
|
|
106
|
+
end
|
|
107
|
+
|
|
90
108
|
# @return [Hash, nil] The serialized header information.
|
|
91
109
|
def serialized_header
|
|
92
110
|
return unless header
|
|
@@ -5,18 +5,34 @@ module Whatsapp
|
|
|
5
5
|
class Response
|
|
6
6
|
class Contacts
|
|
7
7
|
# @!attribute [rw] input
|
|
8
|
-
# @return [String]
|
|
8
|
+
# @return [String] Whatever you addressed the message to: the phone number, the
|
|
9
|
+
# BSUID or parent BSUID, or a group ID.
|
|
9
10
|
attr_accessor :input
|
|
10
11
|
|
|
11
12
|
# @!attribute [rw] wa_id
|
|
12
|
-
# @return [String]
|
|
13
|
+
# @return [String, nil] The recipient's WhatsApp ID. Present only if the message
|
|
14
|
+
# was sent to their phone number; omitted otherwise.
|
|
13
15
|
attr_accessor :wa_id
|
|
14
16
|
|
|
17
|
+
# @!attribute [rw] user_id
|
|
18
|
+
# @return [String, nil] The recipient's business-scoped user ID (BSUID), or parent
|
|
19
|
+
# BSUID — Meta returns either in this one field, unlike the webhook classes,
|
|
20
|
+
# which separate `user_id` from `parent_user_id`.
|
|
21
|
+
#
|
|
22
|
+
# Omitted whenever the message went to a phone number, **including when both
|
|
23
|
+
# `to` and `recipient` were supplied**, since Meta gives `to` precedence and
|
|
24
|
+
# answers as though `recipient` had not been sent. A `nil` here therefore does
|
|
25
|
+
# not mean the recipient has no BSUID.
|
|
26
|
+
attr_accessor :user_id
|
|
27
|
+
|
|
15
28
|
# @param input [String] The input value of the contact.
|
|
16
|
-
# @param wa_id [String] The WhatsApp ID of the contact
|
|
17
|
-
|
|
29
|
+
# @param wa_id [String, nil] The WhatsApp ID of the contact, if it was addressed by
|
|
30
|
+
# phone number.
|
|
31
|
+
# @param user_id [String, nil] The contact's BSUID, if it was addressed by one.
|
|
32
|
+
def initialize(input:, wa_id: nil, user_id: nil)
|
|
18
33
|
@input = input
|
|
19
34
|
@wa_id = wa_id
|
|
35
|
+
@user_id = user_id
|
|
20
36
|
end
|
|
21
37
|
|
|
22
38
|
class << self
|
|
@@ -26,7 +42,8 @@ module Whatsapp
|
|
|
26
42
|
def deserialize(data)
|
|
27
43
|
new(
|
|
28
44
|
input: data["input"],
|
|
29
|
-
wa_id: data["wa_id"]
|
|
45
|
+
wa_id: data["wa_id"],
|
|
46
|
+
user_id: data["user_id"]
|
|
30
47
|
)
|
|
31
48
|
end
|
|
32
49
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
# The ID guard and path build shared by every WABA- or phone-number-scoped feature.
|
|
5
|
+
#
|
|
6
|
+
# Each feature keeps its own transport with a named method — {Whatsapp::SubscribedApp::Transport}'s
|
|
7
|
+
# `edge_path`, {Whatsapp::BusinessPhoneNumber::Account::Transport}'s `node_path` — since those
|
|
8
|
+
# names are what keep an edge and a node from being confused at a call site. This holds only
|
|
9
|
+
# the part that was identical across all of them: refusing to address an unconfigured ID, and
|
|
10
|
+
# phrasing that refusal the same way every time.
|
|
11
|
+
module PathBuilding
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# @param client [Whatsapp::Client]
|
|
15
|
+
# @param id_name [Symbol] The client attribute to address (`:waba_id` or `:phone_id`).
|
|
16
|
+
# @param segments [Array<String>] Edge segments after the ID; pass none to address the node itself.
|
|
17
|
+
# @param error_class [Class] The feature's own error class, never {Whatsapp::RequestError}.
|
|
18
|
+
# @param purpose [String] What the caller was reaching for, e.g. `"subscribed apps"`.
|
|
19
|
+
# @return [String] The versioned path.
|
|
20
|
+
# @raise [Whatsapp::Error] the given error_class, if the ID is not configured.
|
|
21
|
+
def scoped_path(client, id_name, *segments, error_class:, purpose:)
|
|
22
|
+
id = client.public_send(id_name)
|
|
23
|
+
|
|
24
|
+
if id.nil? || id.to_s.empty?
|
|
25
|
+
raise error_class,
|
|
26
|
+
"#{id_name} is required for #{purpose}; " \
|
|
27
|
+
"set it via Whatsapp.configure or Client.new(#{id_name}:)"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
client.path_for(id, *segments)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
module Schedule
|
|
5
|
+
# Schedules an approved template to be sent to an audience at a set time.
|
|
6
|
+
#
|
|
7
|
+
# A validated instance rather than a bare class method (unlike {List}, which has
|
|
8
|
+
# nothing to check): Meta documents all six body fields as required, so a missing
|
|
9
|
+
# one fails here rather than costing a request. Nothing beyond that is checked —
|
|
10
|
+
# Meta publishes no length limits and no rule that `delivery_time` must be in the
|
|
11
|
+
# future.
|
|
12
|
+
# Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/schedules-api
|
|
13
|
+
class Create
|
|
14
|
+
include ActiveModel::Validations
|
|
15
|
+
extend ResponseHandling
|
|
16
|
+
extend Transport
|
|
17
|
+
|
|
18
|
+
# @!attribute [rw] hsm_id
|
|
19
|
+
# @return [String] The ID of the approved template to send.
|
|
20
|
+
attr_accessor :hsm_id
|
|
21
|
+
|
|
22
|
+
# @!attribute [rw] audience_id
|
|
23
|
+
# @return [String] The ID of the audience to send it to.
|
|
24
|
+
attr_accessor :audience_id
|
|
25
|
+
|
|
26
|
+
# @!attribute [rw] waba_cs_id
|
|
27
|
+
# @return [String] What Meta documents only as "WABA customer status ID (phone
|
|
28
|
+
# number)"; whether that means a phone number ID or the number itself is
|
|
29
|
+
# unverified, so it is passed through as given.
|
|
30
|
+
attr_accessor :waba_cs_id
|
|
31
|
+
|
|
32
|
+
# @!attribute [rw] name
|
|
33
|
+
# @return [String] The schedule's name.
|
|
34
|
+
attr_accessor :name
|
|
35
|
+
|
|
36
|
+
# @!attribute [rw] description
|
|
37
|
+
# @return [String] The schedule's description.
|
|
38
|
+
attr_accessor :description
|
|
39
|
+
|
|
40
|
+
# @!attribute [rw] delivery_time
|
|
41
|
+
# @return [Integer] When to send, as a Unix timestamp.
|
|
42
|
+
attr_accessor :delivery_time
|
|
43
|
+
|
|
44
|
+
validates :hsm_id, :audience_id, :waba_cs_id, :name, :description, presence: true
|
|
45
|
+
validates :delivery_time, numericality: { only_integer: true }
|
|
46
|
+
|
|
47
|
+
# @param hsm_id [String] See {#hsm_id}.
|
|
48
|
+
# @param audience_id [String] See {#audience_id}.
|
|
49
|
+
# @param waba_cs_id [String] See {#waba_cs_id}.
|
|
50
|
+
# @param name [String] See {#name}.
|
|
51
|
+
# @param description [String] See {#description}.
|
|
52
|
+
# @param delivery_time [Integer] A Unix timestamp; pass `time.to_i` for a `Time`.
|
|
53
|
+
# @raise [ActiveModel::ValidationError] if a field is blank, or `delivery_time`
|
|
54
|
+
# is not an integer.
|
|
55
|
+
def initialize(hsm_id:, audience_id:, waba_cs_id:, name:, description:, delivery_time:)
|
|
56
|
+
@hsm_id = hsm_id
|
|
57
|
+
@audience_id = audience_id
|
|
58
|
+
@waba_cs_id = waba_cs_id
|
|
59
|
+
@name = name
|
|
60
|
+
@description = description
|
|
61
|
+
@delivery_time = delivery_time
|
|
62
|
+
|
|
63
|
+
validate!
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Hash] The schedule payload. Every field is documented required, so
|
|
67
|
+
# none is ever compacted away.
|
|
68
|
+
def serialize
|
|
69
|
+
{ hsm_id:, audience_id:, waba_cs_id:, name:, description:, delivery_time: }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class << self
|
|
73
|
+
# @example Send a template to an audience in one hour
|
|
74
|
+
# Create.call(
|
|
75
|
+
# hsm_id: "1234567890", audience_id: "2345678901", waba_cs_id: "3456789012",
|
|
76
|
+
# name: "Black Friday", description: "Black Friday promo to VIP customers",
|
|
77
|
+
# delivery_time: Time.now.to_i + 3600
|
|
78
|
+
# ).id
|
|
79
|
+
# @param hsm_id [String] See {#initialize}.
|
|
80
|
+
# @param audience_id [String] See {#initialize}.
|
|
81
|
+
# @param waba_cs_id [String] See {#initialize}.
|
|
82
|
+
# @param name [String] See {#initialize}.
|
|
83
|
+
# @param description [String] See {#initialize}.
|
|
84
|
+
# @param delivery_time [Integer] See {#initialize}.
|
|
85
|
+
# @param client [Whatsapp::Client] The WhatsApp client instance.
|
|
86
|
+
# @return [Response::Created]
|
|
87
|
+
# @raise [ActiveModel::ValidationError] if a field is blank, or `delivery_time`
|
|
88
|
+
# is not an integer.
|
|
89
|
+
# @raise [Error] if no WABA ID is configured, or the request fails.
|
|
90
|
+
def call(hsm_id:, audience_id:, waba_cs_id:, name:, description:, delivery_time:, client: Client.new)
|
|
91
|
+
body = new(hsm_id:, audience_id:, waba_cs_id:, name:, description:, delivery_time:).serialize
|
|
92
|
+
response = client.connection.post(edge_path(client), json: body)
|
|
93
|
+
|
|
94
|
+
Response::Created.deserialize(
|
|
95
|
+
parse_json(handle_response!(response, error_class: Error, action: "create schedule"))
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|