ruby-whatsapp 0.3.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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +54 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +15 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +643 -0
- data/Rakefile +12 -0
- data/lib/ruby/whatsapp/client.rb +82 -0
- data/lib/ruby/whatsapp/configuration.rb +79 -0
- data/lib/ruby/whatsapp/instrumentation.rb +34 -0
- data/lib/ruby/whatsapp/media.rb +134 -0
- data/lib/ruby/whatsapp/message_templates/button/base.rb +26 -0
- data/lib/ruby/whatsapp/message_templates/button/copy_code.rb +59 -0
- data/lib/ruby/whatsapp/message_templates/button/otp/supported_app.rb +44 -0
- data/lib/ruby/whatsapp/message_templates/button/otp.rb +138 -0
- data/lib/ruby/whatsapp/message_templates/button/phone_number.rb +45 -0
- data/lib/ruby/whatsapp/message_templates/button/quick_reply.rb +38 -0
- data/lib/ruby/whatsapp/message_templates/button/url.rb +110 -0
- data/lib/ruby/whatsapp/message_templates/button.rb +56 -0
- data/lib/ruby/whatsapp/message_templates/categories.rb +47 -0
- data/lib/ruby/whatsapp/message_templates/component/base.rb +112 -0
- data/lib/ruby/whatsapp/message_templates/component/body.rb +139 -0
- data/lib/ruby/whatsapp/message_templates/component/buttons.rb +95 -0
- data/lib/ruby/whatsapp/message_templates/component/carousel/card.rb +100 -0
- data/lib/ruby/whatsapp/message_templates/component/carousel.rb +71 -0
- data/lib/ruby/whatsapp/message_templates/component/footer.rb +99 -0
- data/lib/ruby/whatsapp/message_templates/component/header.rb +188 -0
- data/lib/ruby/whatsapp/message_templates/component/limited_time_offer.rb +57 -0
- data/lib/ruby/whatsapp/message_templates/component.rb +62 -0
- data/lib/ruby/whatsapp/message_templates/component_set.rb +221 -0
- data/lib/ruby/whatsapp/message_templates/example.rb +148 -0
- data/lib/ruby/whatsapp/message_templates/library_template/body_inputs.rb +81 -0
- data/lib/ruby/whatsapp/message_templates/library_template/button_inputs.rb +146 -0
- data/lib/ruby/whatsapp/message_templates/library_template.rb +114 -0
- data/lib/ruby/whatsapp/message_templates/parameter_formats.rb +49 -0
- data/lib/ruby/whatsapp/message_templates/placeholders.rb +86 -0
- data/lib/ruby/whatsapp/message_templates/response/collection.rb +74 -0
- data/lib/ruby/whatsapp/message_templates/response/created.rb +65 -0
- data/lib/ruby/whatsapp/message_templates/response/node.rb +152 -0
- data/lib/ruby/whatsapp/message_templates/response/paging.rb +39 -0
- data/lib/ruby/whatsapp/message_templates/response/quality_score.rb +49 -0
- data/lib/ruby/whatsapp/message_templates/response/summary.rb +68 -0
- data/lib/ruby/whatsapp/message_templates/response.rb +22 -0
- data/lib/ruby/whatsapp/message_templates/statuses.rb +66 -0
- data/lib/ruby/whatsapp/message_templates/template.rb +180 -0
- data/lib/ruby/whatsapp/message_templates/value_object.rb +62 -0
- data/lib/ruby/whatsapp/message_templates.rb +283 -0
- data/lib/ruby/whatsapp/messages/address.rb +103 -0
- data/lib/ruby/whatsapp/messages/audio.rb +64 -0
- data/lib/ruby/whatsapp/messages/base.rb +46 -0
- data/lib/ruby/whatsapp/messages/contacts/address.rb +84 -0
- data/lib/ruby/whatsapp/messages/contacts/contact.rb +78 -0
- data/lib/ruby/whatsapp/messages/contacts/email.rb +49 -0
- data/lib/ruby/whatsapp/messages/contacts/name.rb +68 -0
- data/lib/ruby/whatsapp/messages/contacts/org.rb +41 -0
- data/lib/ruby/whatsapp/messages/contacts/phone.rb +59 -0
- data/lib/ruby/whatsapp/messages/contacts/url.rb +51 -0
- data/lib/ruby/whatsapp/messages/contacts.rb +37 -0
- data/lib/ruby/whatsapp/messages/document.rb +79 -0
- data/lib/ruby/whatsapp/messages/image.rb +72 -0
- data/lib/ruby/whatsapp/messages/interactive/base.rb +15 -0
- data/lib/ruby/whatsapp/messages/interactive/body.rb +39 -0
- data/lib/ruby/whatsapp/messages/interactive/footer.rb +40 -0
- data/lib/ruby/whatsapp/messages/interactive/header.rb +102 -0
- data/lib/ruby/whatsapp/messages/interactive/list_buttons/section/row.rb +66 -0
- data/lib/ruby/whatsapp/messages/interactive/list_buttons/section.rb +64 -0
- data/lib/ruby/whatsapp/messages/interactive/list_buttons.rb +63 -0
- data/lib/ruby/whatsapp/messages/interactive/media_carousel/card/button/quick_reply.rb +50 -0
- data/lib/ruby/whatsapp/messages/interactive/media_carousel/card/button.rb +47 -0
- data/lib/ruby/whatsapp/messages/interactive/media_carousel/card.rb +78 -0
- data/lib/ruby/whatsapp/messages/interactive/media_carousel.rb +50 -0
- data/lib/ruby/whatsapp/messages/interactive/product_carousel/card.rb +55 -0
- data/lib/ruby/whatsapp/messages/interactive/product_carousel.rb +50 -0
- data/lib/ruby/whatsapp/messages/interactive/reply_buttons/button.rb +59 -0
- data/lib/ruby/whatsapp/messages/interactive/reply_buttons.rb +47 -0
- data/lib/ruby/whatsapp/messages/interactive/url_button.rb +68 -0
- data/lib/ruby/whatsapp/messages/interactive.rb +112 -0
- data/lib/ruby/whatsapp/messages/location.rb +67 -0
- data/lib/ruby/whatsapp/messages/location_request.rb +41 -0
- data/lib/ruby/whatsapp/messages/mark_message_as_read.rb +42 -0
- data/lib/ruby/whatsapp/messages/reaction.rb +61 -0
- data/lib/ruby/whatsapp/messages/response/contacts.rb +36 -0
- data/lib/ruby/whatsapp/messages/response/messages.rb +27 -0
- data/lib/ruby/whatsapp/messages/response.rb +49 -0
- data/lib/ruby/whatsapp/messages/sticker.rb +62 -0
- data/lib/ruby/whatsapp/messages/template/component.rb +67 -0
- data/lib/ruby/whatsapp/messages/template/language.rb +55 -0
- data/lib/ruby/whatsapp/messages/template/parameter.rb +113 -0
- data/lib/ruby/whatsapp/messages/template.rb +62 -0
- data/lib/ruby/whatsapp/messages/text.rb +43 -0
- data/lib/ruby/whatsapp/messages/video.rb +68 -0
- data/lib/ruby/whatsapp/messages.rb +116 -0
- data/lib/ruby/whatsapp/railtie.rb +14 -0
- data/lib/ruby/whatsapp/request_error.rb +5 -0
- data/lib/ruby/whatsapp/response_handling.rb +44 -0
- data/lib/ruby/whatsapp/utils/language_codes.rb +96 -0
- data/lib/ruby/whatsapp/version.rb +5 -0
- data/lib/ruby/whatsapp/webhook/account_alerts.rb +58 -0
- data/lib/ruby/whatsapp/webhook/account_review_update.rb +27 -0
- data/lib/ruby/whatsapp/webhook/account_update/ban_info.rb +33 -0
- data/lib/ruby/whatsapp/webhook/account_update.rb +46 -0
- data/lib/ruby/whatsapp/webhook/automatic_events.rb +43 -0
- data/lib/ruby/whatsapp/webhook/business_capability_update.rb +36 -0
- data/lib/ruby/whatsapp/webhook/change.rb +61 -0
- data/lib/ruby/whatsapp/webhook/contact.rb +36 -0
- data/lib/ruby/whatsapp/webhook/entry.rb +35 -0
- data/lib/ruby/whatsapp/webhook/error.rb +47 -0
- data/lib/ruby/whatsapp/webhook/history.rb +54 -0
- data/lib/ruby/whatsapp/webhook/installer.rb +61 -0
- data/lib/ruby/whatsapp/webhook/message/audio.rb +28 -0
- data/lib/ruby/whatsapp/webhook/message/base.rb +61 -0
- data/lib/ruby/whatsapp/webhook/message/button.rb +33 -0
- data/lib/ruby/whatsapp/webhook/message/contacts/address.rb +68 -0
- data/lib/ruby/whatsapp/webhook/message/contacts/contact.rb +68 -0
- data/lib/ruby/whatsapp/webhook/message/contacts/email.rb +35 -0
- data/lib/ruby/whatsapp/webhook/message/contacts/name.rb +62 -0
- data/lib/ruby/whatsapp/webhook/message/contacts/org.rb +40 -0
- data/lib/ruby/whatsapp/webhook/message/contacts/phone.rb +40 -0
- data/lib/ruby/whatsapp/webhook/message/contacts/url.rb +35 -0
- data/lib/ruby/whatsapp/webhook/message/contacts.rb +31 -0
- data/lib/ruby/whatsapp/webhook/message/context.rb +49 -0
- data/lib/ruby/whatsapp/webhook/message/document.rb +32 -0
- data/lib/ruby/whatsapp/webhook/message/image.rb +18 -0
- data/lib/ruby/whatsapp/webhook/message/interactive.rb +53 -0
- data/lib/ruby/whatsapp/webhook/message/location.rb +49 -0
- data/lib/ruby/whatsapp/webhook/message/media.rb +54 -0
- data/lib/ruby/whatsapp/webhook/message/order/product_item.rb +50 -0
- data/lib/ruby/whatsapp/webhook/message/order.rb +43 -0
- data/lib/ruby/whatsapp/webhook/message/reaction.rb +37 -0
- data/lib/ruby/whatsapp/webhook/message/referral.rb +80 -0
- data/lib/ruby/whatsapp/webhook/message/sticker.rb +32 -0
- data/lib/ruby/whatsapp/webhook/message/system.rb +49 -0
- data/lib/ruby/whatsapp/webhook/message/text.rb +28 -0
- data/lib/ruby/whatsapp/webhook/message/unknown.rb +39 -0
- data/lib/ruby/whatsapp/webhook/message/video.rb +18 -0
- data/lib/ruby/whatsapp/webhook/message.rb +39 -0
- data/lib/ruby/whatsapp/webhook/message_template_components_update.rb +53 -0
- data/lib/ruby/whatsapp/webhook/message_template_quality_update.rb +59 -0
- data/lib/ruby/whatsapp/webhook/message_template_status_update.rb +54 -0
- data/lib/ruby/whatsapp/webhook/messages.rb +55 -0
- data/lib/ruby/whatsapp/webhook/metadata.rb +35 -0
- data/lib/ruby/whatsapp/webhook/notification.rb +36 -0
- data/lib/ruby/whatsapp/webhook/partner_solutions.rb +36 -0
- data/lib/ruby/whatsapp/webhook/payment_configuration_update.rb +51 -0
- data/lib/ruby/whatsapp/webhook/phone_number_name_update.rb +51 -0
- data/lib/ruby/whatsapp/webhook/phone_number_quality_update.rb +45 -0
- data/lib/ruby/whatsapp/webhook/security.rb +41 -0
- data/lib/ruby/whatsapp/webhook/signature.rb +28 -0
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync/state_sync.rb +38 -0
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync.rb +33 -0
- data/lib/ruby/whatsapp/webhook/smb_message_echoes.rb +48 -0
- data/lib/ruby/whatsapp/webhook/status/conversation.rb +42 -0
- data/lib/ruby/whatsapp/webhook/status/pricing.rb +38 -0
- data/lib/ruby/whatsapp/webhook/status.rb +65 -0
- data/lib/ruby/whatsapp/webhook/template_category_update.rb +64 -0
- data/lib/ruby/whatsapp/webhook/templates/webhooks_controller.rb.tt +31 -0
- data/lib/ruby/whatsapp/webhook/unknown_field.rb +37 -0
- data/lib/ruby/whatsapp/webhook/user_preferences.rb +47 -0
- data/lib/ruby/whatsapp/webhook/verification.rb +27 -0
- data/lib/ruby/whatsapp/webhook.rb +8 -0
- data/lib/ruby/whatsapp.rb +49 -0
- data/lib/tasks/whatsapp.rake +10 -0
- data/sig/ruby/whatsapp.rbs +6 -0
- metadata +267 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
class Button
|
|
6
|
+
# Opens a URL in the device's default browser.
|
|
7
|
+
#
|
|
8
|
+
# May carry a single variable, and only appended at the very end of the URL —
|
|
9
|
+
# Meta substitutes the send-side value there. Note the `example` here is a flat
|
|
10
|
+
# array *on the button*, unlike the header/body `example` object built by
|
|
11
|
+
# {Example}; that inconsistency is Meta's.
|
|
12
|
+
# Source: https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/components/
|
|
13
|
+
class Url < Base
|
|
14
|
+
module Defaults
|
|
15
|
+
TYPE = "URL"
|
|
16
|
+
MAX_URL_LENGTH = 2000
|
|
17
|
+
MAX_VARIABLES = 1
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Matches a placeholder occupying the very end of the URL.
|
|
21
|
+
TRAILING_VARIABLE = /\{\{\s*\w+\s*\}\}\z/
|
|
22
|
+
|
|
23
|
+
# @!attribute [rw] text
|
|
24
|
+
# @return [String]
|
|
25
|
+
attr_accessor :text
|
|
26
|
+
|
|
27
|
+
# @!attribute [rw] url
|
|
28
|
+
# @return [String]
|
|
29
|
+
attr_accessor :url
|
|
30
|
+
|
|
31
|
+
# @!attribute [rw] example
|
|
32
|
+
# @return [Array<String>, nil]
|
|
33
|
+
attr_accessor :example
|
|
34
|
+
|
|
35
|
+
validates :text, presence: true, length: { maximum: MAX_TEXT_LENGTH }
|
|
36
|
+
validates :url, presence: true, length: { maximum: Defaults::MAX_URL_LENGTH }
|
|
37
|
+
validate :validate_variable
|
|
38
|
+
validate :validate_example_present
|
|
39
|
+
|
|
40
|
+
# @param text [String] The button label (max 25 characters).
|
|
41
|
+
# @param url [String] The URL (max 2000 characters), optionally ending in a
|
|
42
|
+
# single `{{1}}` or `{{name}}` placeholder.
|
|
43
|
+
# @param example [String, Array<String>, nil] A sample value for the
|
|
44
|
+
# placeholder. Required when the URL has one.
|
|
45
|
+
# @raise [ActiveModel::ValidationError] if validation fails.
|
|
46
|
+
def initialize(text:, url:, example: nil)
|
|
47
|
+
@text = text
|
|
48
|
+
@url = url
|
|
49
|
+
@example = normalize_example(example)
|
|
50
|
+
|
|
51
|
+
validate!
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Hash] The serialized button.
|
|
55
|
+
def serialize
|
|
56
|
+
{
|
|
57
|
+
type: Defaults::TYPE,
|
|
58
|
+
text:,
|
|
59
|
+
url:,
|
|
60
|
+
example:,
|
|
61
|
+
}.compact
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# @return [Array<String>, nil]
|
|
67
|
+
def normalize_example(value)
|
|
68
|
+
return if blank_value?(value)
|
|
69
|
+
|
|
70
|
+
value.is_a?(Array) ? value : [value]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Boolean] Whether the URL declares a placeholder.
|
|
74
|
+
def variable?
|
|
75
|
+
Placeholders.occurrences(url).positive?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A URL may carry at most one variable, and it must be the trailing segment.
|
|
79
|
+
#
|
|
80
|
+
# Counted by occurrence, not by unique name: `.../{{1}}/details/{{1}}` ends in a
|
|
81
|
+
# placeholder and names only one parameter, but the leading one still sits
|
|
82
|
+
# mid-URL, which is exactly what Meta forbids.
|
|
83
|
+
# @return [void]
|
|
84
|
+
def validate_variable
|
|
85
|
+
return if blank_value?(url)
|
|
86
|
+
|
|
87
|
+
count = Placeholders.occurrences(url)
|
|
88
|
+
return if count.zero?
|
|
89
|
+
|
|
90
|
+
if count > Defaults::MAX_VARIABLES
|
|
91
|
+
errors.add(:url, "supports at most one variable, found #{count}")
|
|
92
|
+
return
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
return if url.match?(TRAILING_VARIABLE)
|
|
96
|
+
|
|
97
|
+
errors.add(:url, "variable must be at the end of the URL")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [void]
|
|
101
|
+
def validate_example_present
|
|
102
|
+
return if blank_value?(url) || !variable?
|
|
103
|
+
return unless blank_value?(example)
|
|
104
|
+
|
|
105
|
+
errors.add(:example, "can't be blank when the URL contains a variable")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
# Resolves a button kind to its implementing class.
|
|
6
|
+
#
|
|
7
|
+
# Resolution goes through the frozen {TYPES} whitelist — never `const_get` on
|
|
8
|
+
# caller input — so an unknown or hostile `type` can only raise, never resolve an
|
|
9
|
+
# arbitrary constant. Mirrors {Whatsapp::Messages::KINDS} and
|
|
10
|
+
# {Whatsapp::Messages::Interactive::ACTION_TYPES}.
|
|
11
|
+
#
|
|
12
|
+
# Only the button types with a published schema are registered. Meta's Graph API
|
|
13
|
+
# enum also lists FLOW, MPM, CATALOG, VOICE_CALL, VIDEO_CALL, POSTBACK,
|
|
14
|
+
# BOOKING_STATUS, PAYMENT_REQUEST and REQUEST_CONTACT_INFO, but publishes no
|
|
15
|
+
# field reference for them, so they are deliberately absent rather than guessed.
|
|
16
|
+
# Source: https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/components/
|
|
17
|
+
class Button
|
|
18
|
+
TYPES = {
|
|
19
|
+
quick_reply: QuickReply,
|
|
20
|
+
url: Url,
|
|
21
|
+
phone_number: PhoneNumber,
|
|
22
|
+
copy_code: CopyCode,
|
|
23
|
+
otp: Otp,
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# @param type [Symbol, String] The button kind, in either casing
|
|
28
|
+
# (`:quick_reply`, `"quick_reply"`, or Meta's `"QUICK_REPLY"`).
|
|
29
|
+
# @param attrs [Hash] Forwarded to the resolved button class.
|
|
30
|
+
# @return [Button::Base]
|
|
31
|
+
# @raise [TemplateError] if the type is unknown.
|
|
32
|
+
# @raise [ActiveModel::ValidationError] if the button is invalid.
|
|
33
|
+
def build(type:, **attrs)
|
|
34
|
+
klass = TYPES.fetch(normalize(type)) do
|
|
35
|
+
raise TemplateError,
|
|
36
|
+
"Unknown button type: #{type.inspect}. Known types: #{TYPES.keys.join(', ')}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
klass.new(**attrs)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Builds and serializes in one step.
|
|
43
|
+
# @return [Hash]
|
|
44
|
+
def serialize(type:, **attrs)
|
|
45
|
+
build(type:, **attrs).serialize
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param type [Symbol, String]
|
|
49
|
+
# @return [Symbol] The registry key for the given type.
|
|
50
|
+
def normalize(type)
|
|
51
|
+
type.to_s.downcase.to_sym
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
# The three categories every template must declare.
|
|
6
|
+
#
|
|
7
|
+
# The choice is not cosmetic: it drives pricing, it decides which components are
|
|
8
|
+
# legal, and Meta validates it against the template's actual content — a mismatch
|
|
9
|
+
# comes back as REJECTED with the reason INCORRECT_CATEGORY.
|
|
10
|
+
#
|
|
11
|
+
# Shared by {Template}, {ComponentSet} and {LibraryTemplate}, hence its own file
|
|
12
|
+
# rather than living on any one of them.
|
|
13
|
+
# Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/template-categorization
|
|
14
|
+
module Categories
|
|
15
|
+
# Identity verification with one-time passcodes. No URLs, media, or emojis, and
|
|
16
|
+
# parameters are capped at 15 characters.
|
|
17
|
+
AUTHENTICATION = "AUTHENTICATION"
|
|
18
|
+
# Promotions, awareness, retargeting. Anything ambiguous lands here.
|
|
19
|
+
MARKETING = "MARKETING"
|
|
20
|
+
# Transactional and non-promotional: order, account, or transaction updates, or
|
|
21
|
+
# genuinely essential notices.
|
|
22
|
+
UTILITY = "UTILITY"
|
|
23
|
+
|
|
24
|
+
ALL = [AUTHENTICATION, MARKETING, UTILITY].freeze
|
|
25
|
+
|
|
26
|
+
# Normalizes caller input to a canonical uppercase category.
|
|
27
|
+
#
|
|
28
|
+
# Meta's own docs are inconsistent about enum casing, so callers may reasonably
|
|
29
|
+
# pass `:marketing`, `"marketing"`, or `"MARKETING"`. Unrecognized values are
|
|
30
|
+
# returned untouched so validations can report them.
|
|
31
|
+
# @param value [String, Symbol, nil]
|
|
32
|
+
# @return [String, nil]
|
|
33
|
+
def self.normalize(value)
|
|
34
|
+
return if value.nil?
|
|
35
|
+
|
|
36
|
+
candidate = value.to_s.upcase
|
|
37
|
+
ALL.include?(candidate) ? candidate : value.to_s
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param value [String, Symbol, nil]
|
|
41
|
+
# @return [Boolean]
|
|
42
|
+
def self.valid?(value)
|
|
43
|
+
ALL.include?(normalize(value))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
class Component
|
|
6
|
+
# Shared behaviour for every component a template can declare.
|
|
7
|
+
#
|
|
8
|
+
# Holds the template's `parameter_format`, which every component accepts even
|
|
9
|
+
# though only {Header} and {Body} use it. Uniformity is deliberate: {Component
|
|
10
|
+
# .build} can then thread the format down without knowing which components care,
|
|
11
|
+
# and carousel cards can pass it on to their own nested components.
|
|
12
|
+
#
|
|
13
|
+
# Each subclass owns its wire type as `Defaults::TYPE`, so the {Component::TYPES}
|
|
14
|
+
# registry maps kind to class only and there is one source of truth.
|
|
15
|
+
class Base
|
|
16
|
+
include ValueObject
|
|
17
|
+
|
|
18
|
+
# @!attribute [rw] parameter_format
|
|
19
|
+
# @return [String] see {ParameterFormats}.
|
|
20
|
+
attr_accessor :parameter_format
|
|
21
|
+
|
|
22
|
+
# @!attribute [r] example_error
|
|
23
|
+
# Set when the caller's example values could not be interpreted. See
|
|
24
|
+
# {#build_example_payload}.
|
|
25
|
+
# @return [String, nil]
|
|
26
|
+
attr_reader :example_error
|
|
27
|
+
|
|
28
|
+
validate :validate_example_shape
|
|
29
|
+
|
|
30
|
+
# @param parameter_format [String, Symbol] The owning template's placeholder
|
|
31
|
+
# style. Defaults to Meta's own default.
|
|
32
|
+
def initialize(parameter_format: ParameterFormats::POSITIONAL)
|
|
33
|
+
@parameter_format = ParameterFormats.normalize(parameter_format)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The uppercase value Meta expects in the component's `type` field.
|
|
37
|
+
# @return [String]
|
|
38
|
+
def api_type
|
|
39
|
+
self.class::Defaults::TYPE
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
protected
|
|
43
|
+
|
|
44
|
+
# Builds an example payload, deferring any shape mismatch to validation.
|
|
45
|
+
#
|
|
46
|
+
# {Example.serialize} raises ArgumentError when the values contradict the
|
|
47
|
+
# parameter format (a Hash of names under POSITIONAL, say). Letting that escape
|
|
48
|
+
# `initialize` would bypass the validation layer every other failure goes
|
|
49
|
+
# through, leaving callers to handle two error types for one kind of mistake.
|
|
50
|
+
# It is captured here and re-reported by {#validate_example_shape}.
|
|
51
|
+
# @param role [Symbol] `:header` or `:body`.
|
|
52
|
+
# @param values [Array, Hash, String, nil]
|
|
53
|
+
# @return [Hash, nil]
|
|
54
|
+
def build_example_payload(role:, values:)
|
|
55
|
+
Example.serialize(role:, parameter_format:, values:)
|
|
56
|
+
rescue ArgumentError => e
|
|
57
|
+
@example_error = e.message
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Validates that text placeholders are well-formed and consistent with the
|
|
62
|
+
# template's parameter format.
|
|
63
|
+
#
|
|
64
|
+
# Shared by {Header} and {Body} — the two components that accept placeholders —
|
|
65
|
+
# because the three failure modes (mixed styles, a positional run that does not
|
|
66
|
+
# start at 1, and a style that contradicts the template) are identical for both
|
|
67
|
+
# and are among the most common causes of a rejected template.
|
|
68
|
+
# @param text [String, nil]
|
|
69
|
+
# @return [void]
|
|
70
|
+
def validate_placeholder_style(text)
|
|
71
|
+
return if blank_value?(text)
|
|
72
|
+
|
|
73
|
+
style = Placeholders.style(text)
|
|
74
|
+
return if style.nil?
|
|
75
|
+
|
|
76
|
+
return errors.add(:text, "cannot mix positional and named placeholders") if style == :mixed
|
|
77
|
+
return validate_named_style if style == :named
|
|
78
|
+
|
|
79
|
+
validate_positional_style(text)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# @return [void]
|
|
85
|
+
def validate_example_shape
|
|
86
|
+
errors.add(:example, example_error) unless example_error.nil?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [void]
|
|
90
|
+
def validate_named_style
|
|
91
|
+
return if ParameterFormats.named?(parameter_format)
|
|
92
|
+
|
|
93
|
+
errors.add(:text,
|
|
94
|
+
"uses named placeholders but the template parameter_format is " \
|
|
95
|
+
"#{ParameterFormats::POSITIONAL}")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @return [void]
|
|
99
|
+
def validate_positional_style(text)
|
|
100
|
+
if ParameterFormats.named?(parameter_format)
|
|
101
|
+
return errors.add(:text,
|
|
102
|
+
"uses positional placeholders but the template parameter_format is #{ParameterFormats::NAMED}")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
return if Placeholders.sequential?(text)
|
|
106
|
+
|
|
107
|
+
errors.add(:text, "positional placeholders must start at {{1}} and increment without gaps")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
class Component
|
|
6
|
+
# The template's message text. The only component Meta requires.
|
|
7
|
+
#
|
|
8
|
+
# Two mutually exclusive shapes, hence the XOR validation:
|
|
9
|
+
#
|
|
10
|
+
# standard — `text` (1024 chars) with any number of placeholders, plus a
|
|
11
|
+
# matching `example`
|
|
12
|
+
# authentication — no text at all. Meta supplies the fixed, localised string
|
|
13
|
+
# "<VERIFICATION_CODE> is your verification code." and
|
|
14
|
+
# `add_security_recommendation` only decides whether the
|
|
15
|
+
# "do not share this code" line is appended.
|
|
16
|
+
#
|
|
17
|
+
# The tighter 600-character limit that applies inside a limited-time-offer
|
|
18
|
+
# template is enforced by {Template}, which is the only object that can see
|
|
19
|
+
# whether such a component is present.
|
|
20
|
+
# Source: https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/components/
|
|
21
|
+
class Body < Base
|
|
22
|
+
module Defaults
|
|
23
|
+
TYPE = "BODY"
|
|
24
|
+
MAX_TEXT_LENGTH = 1024
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @!attribute [rw] text
|
|
28
|
+
# @return [String, nil]
|
|
29
|
+
attr_accessor :text
|
|
30
|
+
|
|
31
|
+
# @!attribute [rw] example
|
|
32
|
+
# @return [Hash, nil] The serialized example payload.
|
|
33
|
+
attr_accessor :example
|
|
34
|
+
|
|
35
|
+
# @!attribute [rw] add_security_recommendation
|
|
36
|
+
# @return [Boolean, nil] Authentication templates only.
|
|
37
|
+
attr_accessor :add_security_recommendation
|
|
38
|
+
|
|
39
|
+
# @!attribute [rw] example_values
|
|
40
|
+
# The raw example input, kept so the placeholder/example counts can be
|
|
41
|
+
# compared without re-parsing the serialized payload.
|
|
42
|
+
# @return [Array, Hash, String, nil]
|
|
43
|
+
attr_accessor :example_values
|
|
44
|
+
|
|
45
|
+
validate :validate_shape
|
|
46
|
+
validate :validate_text
|
|
47
|
+
validate :validate_example
|
|
48
|
+
|
|
49
|
+
# @param text [String, nil] The body text (max 1024 characters).
|
|
50
|
+
# @param example [Array, Hash, String, nil] Sample values for the placeholders,
|
|
51
|
+
# or a pre-built example payload.
|
|
52
|
+
# @param add_security_recommendation [Boolean, nil] Authentication templates
|
|
53
|
+
# only; mutually exclusive with `text`.
|
|
54
|
+
# @param kwargs [Hash] Forwarded to {Base} (`parameter_format`).
|
|
55
|
+
# @raise [ActiveModel::ValidationError] if validation fails.
|
|
56
|
+
def initialize(text: nil, example: nil, add_security_recommendation: nil, **)
|
|
57
|
+
super(**)
|
|
58
|
+
|
|
59
|
+
@text = text
|
|
60
|
+
@add_security_recommendation = add_security_recommendation
|
|
61
|
+
@example_values = example
|
|
62
|
+
@example = build_example_payload(role: :body, values: example)
|
|
63
|
+
|
|
64
|
+
validate!
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Boolean] Whether this is the flag-based authentication shape.
|
|
68
|
+
def authentication?
|
|
69
|
+
!add_security_recommendation.nil?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Hash] The serialized component.
|
|
73
|
+
def serialize
|
|
74
|
+
return { type: Defaults::TYPE, add_security_recommendation: } if authentication?
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
type: Defaults::TYPE,
|
|
78
|
+
text:,
|
|
79
|
+
example:,
|
|
80
|
+
}.compact
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# @return [void]
|
|
86
|
+
def validate_shape
|
|
87
|
+
return unless authentication? && !blank_value?(text)
|
|
88
|
+
|
|
89
|
+
errors.add(:text, "cannot be combined with add_security_recommendation")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [void]
|
|
93
|
+
def validate_text
|
|
94
|
+
return if authentication?
|
|
95
|
+
return errors.add(:text, "can't be blank") if blank_value?(text)
|
|
96
|
+
|
|
97
|
+
if text.length > Defaults::MAX_TEXT_LENGTH
|
|
98
|
+
errors.add(:text, "is too long (maximum is #{Defaults::MAX_TEXT_LENGTH} characters)")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
validate_placeholder_style(text)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The number of examples must match the number of placeholders exactly — a
|
|
105
|
+
# mismatch is one of the most common causes of a rejected template.
|
|
106
|
+
# @return [void]
|
|
107
|
+
def validate_example
|
|
108
|
+
# A shape mismatch is already reported by validate_example_shape, and
|
|
109
|
+
# Example.count would raise on the same input.
|
|
110
|
+
return if authentication? || blank_value?(text) || !example_error.nil?
|
|
111
|
+
|
|
112
|
+
expected = Placeholders.count(text)
|
|
113
|
+
return if expected.zero? && blank_value?(example)
|
|
114
|
+
|
|
115
|
+
if blank_value?(example)
|
|
116
|
+
return errors.add(:example,
|
|
117
|
+
"can't be blank when the body text contains placeholders")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
actual = Example.count(role: :body, parameter_format:, values: example_values)
|
|
121
|
+
return if actual == expected
|
|
122
|
+
|
|
123
|
+
errors.add(:example, count_mismatch_message(expected, actual))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @return [String]
|
|
127
|
+
def count_mismatch_message(expected, actual)
|
|
128
|
+
"does not match the body text: #{expected} #{pluralize(expected, 'placeholder')} " \
|
|
129
|
+
"but #{actual} #{pluralize(actual, 'example')}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @return [String]
|
|
133
|
+
def pluralize(count, word)
|
|
134
|
+
count == 1 ? word : "#{word}s"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
class Component
|
|
6
|
+
# The container for a template's tappable buttons. At most one per template,
|
|
7
|
+
# holding up to 10 buttons.
|
|
8
|
+
#
|
|
9
|
+
# Note the asymmetry with the send side: here every button lives inside this one
|
|
10
|
+
# component, whereas sending a template emits one `button` component per button,
|
|
11
|
+
# addressed by a zero-based `index` that follows the order declared here. That is
|
|
12
|
+
# why {#serialize} preserves the caller's order exactly.
|
|
13
|
+
# Source: https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/components/
|
|
14
|
+
class Buttons < Base
|
|
15
|
+
module Defaults
|
|
16
|
+
TYPE = "BUTTONS"
|
|
17
|
+
MAX_BUTTONS = 10
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Documented per-type caps. Only the limits Meta actually publishes are
|
|
21
|
+
# enforced; OTP is deliberately uncapped here (the overall MAX_BUTTONS still
|
|
22
|
+
# applies) because no per-type limit is stated for it.
|
|
23
|
+
MAX_PER_TYPE = {
|
|
24
|
+
Button::QuickReply::Defaults::TYPE => 10,
|
|
25
|
+
Button::Url::Defaults::TYPE => 2,
|
|
26
|
+
Button::PhoneNumber::Defaults::TYPE => 1,
|
|
27
|
+
Button::CopyCode::Defaults::TYPE => 1,
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# @!attribute [rw] buttons
|
|
31
|
+
# @return [Array<Button::Base>]
|
|
32
|
+
attr_accessor :buttons
|
|
33
|
+
|
|
34
|
+
validates :buttons, presence: true, length: { maximum: Defaults::MAX_BUTTONS }
|
|
35
|
+
validate :validate_per_type_caps
|
|
36
|
+
validate :validate_quick_reply_grouping
|
|
37
|
+
|
|
38
|
+
# @param buttons [Array<Hash, Button::Base>] The buttons, in display order.
|
|
39
|
+
# @param kwargs [Hash] Forwarded to {Base} (`parameter_format`).
|
|
40
|
+
# @raise [ActiveModel::ValidationError] if validation fails.
|
|
41
|
+
def initialize(buttons: [], **)
|
|
42
|
+
super(**)
|
|
43
|
+
|
|
44
|
+
@buttons = build_buttons(buttons)
|
|
45
|
+
|
|
46
|
+
validate!
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The wire types in declared order. Exposed so {Carousel} can compare card
|
|
50
|
+
# structures and {Template} can inspect buttons across components.
|
|
51
|
+
# @return [Array<String>]
|
|
52
|
+
def api_types
|
|
53
|
+
buttons.map(&:api_type)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Hash] The serialized component.
|
|
57
|
+
def serialize
|
|
58
|
+
{ type: Defaults::TYPE, buttons: buttons.map(&:serialize) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# @return [Array<Button::Base>]
|
|
64
|
+
# @raise [ActiveModel::ValidationError] if a button is invalid.
|
|
65
|
+
# @raise [TemplateError] if a button type is unknown.
|
|
66
|
+
def build_buttons(list)
|
|
67
|
+
Array(list).map { |button| button.is_a?(Button::Base) ? button : Button.build(**button) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [void]
|
|
71
|
+
def validate_per_type_caps
|
|
72
|
+
api_types.tally.each do |type, count|
|
|
73
|
+
maximum = MAX_PER_TYPE[type]
|
|
74
|
+
next if maximum.nil? || count <= maximum
|
|
75
|
+
|
|
76
|
+
errors.add(:buttons, "allow at most #{maximum} #{type} #{maximum == 1 ? 'button' : 'buttons'}")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Meta rejects a payload whose quick-reply buttons are interrupted by another
|
|
81
|
+
# type: `[QR, URL, QR]` is invalid while `[QR, QR, URL]` and `[URL, QR, QR]`
|
|
82
|
+
# are both fine. The check is therefore "do their indexes form one unbroken
|
|
83
|
+
# run", not "are they first or last".
|
|
84
|
+
# @return [void]
|
|
85
|
+
def validate_quick_reply_grouping
|
|
86
|
+
indexes = api_types.each_index.select { |i| api_types[i] == Button::QuickReply::Defaults::TYPE }
|
|
87
|
+
return if indexes.size <= 1
|
|
88
|
+
return if indexes.last - indexes.first == indexes.size - 1
|
|
89
|
+
|
|
90
|
+
errors.add(:buttons, "quick reply buttons must be grouped together, without other types between them")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
class Component
|
|
6
|
+
class Carousel
|
|
7
|
+
# One card in a carousel: a required media header, an optional body, and up to
|
|
8
|
+
# two buttons.
|
|
9
|
+
#
|
|
10
|
+
# Composes {Component::Header} and {Component::Buttons} rather than restating
|
|
11
|
+
# their rules — the only extra constraint is that a card's header must be image
|
|
12
|
+
# or video, since a carousel card cannot be text or a location.
|
|
13
|
+
#
|
|
14
|
+
# Note there is no `card_index` here. Cards are positional at creation time;
|
|
15
|
+
# the zero-based `card_index` only appears when sending a message.
|
|
16
|
+
# Source: https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/carousel-templates
|
|
17
|
+
class Card
|
|
18
|
+
include ValueObject
|
|
19
|
+
|
|
20
|
+
module Defaults
|
|
21
|
+
MAX_BUTTONS = 2
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A carousel card's header is restricted to these formats.
|
|
25
|
+
ALLOWED_HEADER_FORMATS = [Header::Formats::IMAGE, Header::Formats::VIDEO].freeze
|
|
26
|
+
|
|
27
|
+
# @!attribute [rw] header
|
|
28
|
+
# @return [Component::Header]
|
|
29
|
+
attr_accessor :header
|
|
30
|
+
|
|
31
|
+
# @!attribute [rw] body
|
|
32
|
+
# @return [Component::Body, nil]
|
|
33
|
+
attr_accessor :body
|
|
34
|
+
|
|
35
|
+
# @!attribute [rw] buttons
|
|
36
|
+
# @return [Component::Buttons, nil]
|
|
37
|
+
attr_accessor :buttons
|
|
38
|
+
|
|
39
|
+
validates :header, presence: true
|
|
40
|
+
validate :validate_header_format
|
|
41
|
+
validate :validate_button_count
|
|
42
|
+
|
|
43
|
+
# @param header [Hash, Component::Header] The card's media header.
|
|
44
|
+
# @param body [Hash, Component::Body, nil] The card's body.
|
|
45
|
+
# @param buttons [Array<Hash, Button::Base>, nil] Up to 2 buttons.
|
|
46
|
+
# @param parameter_format [String, Symbol] The owning template's placeholder
|
|
47
|
+
# style, threaded into the card's own components.
|
|
48
|
+
# @raise [ActiveModel::ValidationError] if validation fails.
|
|
49
|
+
def initialize(header:, body: nil, buttons: nil, parameter_format: ParameterFormats::POSITIONAL)
|
|
50
|
+
@parameter_format = ParameterFormats.normalize(parameter_format)
|
|
51
|
+
@header = build(Header, header)
|
|
52
|
+
@body = build(Body, body)
|
|
53
|
+
@buttons = blank_value?(buttons) ? nil : Buttons.new(buttons:, parameter_format: @parameter_format)
|
|
54
|
+
|
|
55
|
+
validate!
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A comparable description of the card's shape, used by {Carousel} to enforce
|
|
59
|
+
# Meta's "all cards must have the same components" rule. Content is
|
|
60
|
+
# deliberately excluded — only structure matters.
|
|
61
|
+
# @return [Array]
|
|
62
|
+
def signature
|
|
63
|
+
[header&.format, !body.nil?, buttons&.api_types]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Hash] The serialized card.
|
|
67
|
+
def serialize
|
|
68
|
+
{ components: [header, body, buttons].compact.map(&:serialize) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# @return [Component::Base, nil]
|
|
74
|
+
def build(klass, value)
|
|
75
|
+
return if blank_value?(value)
|
|
76
|
+
return value if value.is_a?(klass)
|
|
77
|
+
|
|
78
|
+
klass.new(**value, parameter_format: @parameter_format)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @return [void]
|
|
82
|
+
def validate_header_format
|
|
83
|
+
return if header.nil?
|
|
84
|
+
return if ALLOWED_HEADER_FORMATS.include?(header.format)
|
|
85
|
+
|
|
86
|
+
errors.add(:header, "format must be IMAGE or VIDEO for a carousel card, got #{header.format}")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [void]
|
|
90
|
+
def validate_button_count
|
|
91
|
+
return if buttons.nil?
|
|
92
|
+
return if buttons.buttons.size <= Defaults::MAX_BUTTONS
|
|
93
|
+
|
|
94
|
+
errors.add(:buttons, "allow at most #{Defaults::MAX_BUTTONS} buttons per carousel card")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|