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,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
# Builds the `example` payload Meta requires alongside any component text that
|
|
6
|
+
# contains placeholders.
|
|
7
|
+
#
|
|
8
|
+
# This exists because the *key name* of that payload changes with both the
|
|
9
|
+
# component and the template's parameter format — four combinations that are easy
|
|
10
|
+
# to get wrong and are a common source of template rejections:
|
|
11
|
+
#
|
|
12
|
+
# | POSITIONAL | NAMED
|
|
13
|
+
# ---------+-------------------------------+--------------------------------
|
|
14
|
+
# header | header_text: ["Sale"] | header_text_named_params: [...]
|
|
15
|
+
# body | body_text: [["a", "b"]] | body_text_named_params: [...]
|
|
16
|
+
#
|
|
17
|
+
# Note the asymmetry: `header_text` is a flat array while `body_text` is an array
|
|
18
|
+
# *of arrays*. Meta documents no reason for it; it just is.
|
|
19
|
+
#
|
|
20
|
+
# Callers may pass a plain Array (positional), a Hash of name => example (named),
|
|
21
|
+
# Meta's own `[{param_name:, example:}]` array, or a fully-built payload hash
|
|
22
|
+
# copied verbatim out of Meta's docs — all four are accepted so pasting a known-
|
|
23
|
+
# good example from the API reference works without translation.
|
|
24
|
+
# Source: https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/components/
|
|
25
|
+
module Example
|
|
26
|
+
# Maps a component role to its example key for each parameter format.
|
|
27
|
+
ROLES = {
|
|
28
|
+
header: { positional: :header_text, named: :header_text_named_params },
|
|
29
|
+
body: { positional: :body_text, named: :body_text_named_params },
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
# Keys that mean "the caller already built this payload, pass it through".
|
|
33
|
+
# `header_handle` is included because media headers carry an asset handle here
|
|
34
|
+
# rather than text parameters.
|
|
35
|
+
BUILT_KEYS = (ROLES.values.flat_map(&:values) + [:header_handle]).freeze
|
|
36
|
+
|
|
37
|
+
# Roles whose positional example is nested one level deeper.
|
|
38
|
+
NESTED_ROLES = [:body].freeze
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
# @param role [Symbol] `:header` or `:body`.
|
|
42
|
+
# @param parameter_format [String, Symbol, nil] see {ParameterFormats}.
|
|
43
|
+
# @param values [Array, Hash, String, nil] the example value(s).
|
|
44
|
+
# @return [Hash, nil] The example payload, or nil when there are no values.
|
|
45
|
+
# @raise [ArgumentError] if the role is unknown or the values do not match the
|
|
46
|
+
# parameter format.
|
|
47
|
+
def serialize(role:, parameter_format:, values:)
|
|
48
|
+
keys = keys_for(role)
|
|
49
|
+
return if blank_value?(values)
|
|
50
|
+
return symbolize(values) if built?(values)
|
|
51
|
+
|
|
52
|
+
if ParameterFormats.named?(parameter_format)
|
|
53
|
+
{ keys[:named] => named_params(values) }
|
|
54
|
+
else
|
|
55
|
+
{ keys[:positional] => positional_values(role, values) }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The number of example values supplied, whatever shape they arrived in.
|
|
60
|
+
#
|
|
61
|
+
# Used by components to check the example count against the placeholder count
|
|
62
|
+
# without needing to know which of the four shapes the caller used.
|
|
63
|
+
# @return [Integer]
|
|
64
|
+
def count(role:, parameter_format:, values:)
|
|
65
|
+
payload = serialize(role:, parameter_format:, values:)
|
|
66
|
+
return 0 if payload.nil?
|
|
67
|
+
|
|
68
|
+
key, value = payload.first
|
|
69
|
+
return value.size if key.to_s.end_with?("named_params")
|
|
70
|
+
return Array(value.first).size if NESTED_ROLES.any? { |r| ROLES[r][:positional] == key }
|
|
71
|
+
|
|
72
|
+
value.size
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# @raise [ArgumentError] if the role has no example key mapping.
|
|
78
|
+
def keys_for(role)
|
|
79
|
+
ROLES.fetch(role.to_sym) do
|
|
80
|
+
raise ArgumentError, "Unknown example role: #{role.inspect}. Known roles: #{ROLES.keys.join(', ')}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Boolean] true when there is nothing to serialize.
|
|
85
|
+
def blank_value?(values)
|
|
86
|
+
values.nil? || (values.respond_to?(:empty?) && values.empty?)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Whether the hash is already a built example payload rather than raw values.
|
|
90
|
+
#
|
|
91
|
+
# Decided by key name against the closed {BUILT_KEYS} set. A named parameter
|
|
92
|
+
# that happens to be called e.g. `body_text` would be misread as a built
|
|
93
|
+
# payload; that is an accepted trade for being able to paste Meta's examples.
|
|
94
|
+
# @return [Boolean]
|
|
95
|
+
def built?(values)
|
|
96
|
+
return false unless values.is_a?(Hash)
|
|
97
|
+
|
|
98
|
+
values.keys.map(&:to_sym).all? { |key| BUILT_KEYS.include?(key) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @return [Hash]
|
|
102
|
+
def symbolize(values)
|
|
103
|
+
values.transform_keys(&:to_sym)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @return [Array<Hash>] Meta's `[{param_name:, example:}]` shape.
|
|
107
|
+
# @raise [ArgumentError] if the values are not a usable named shape.
|
|
108
|
+
def named_params(values)
|
|
109
|
+
case values
|
|
110
|
+
when Hash then values.map { |name, example| { param_name: name.to_s, example: } }
|
|
111
|
+
when Array then values.map { |entry| named_param_entry(entry) }
|
|
112
|
+
else raise ArgumentError, named_params_error(values)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @return [Hash]
|
|
117
|
+
# @raise [ArgumentError] if the entry is not a param_name/example pair.
|
|
118
|
+
def named_param_entry(entry)
|
|
119
|
+
raise ArgumentError, named_params_error(entry) unless entry.is_a?(Hash)
|
|
120
|
+
|
|
121
|
+
normalized = symbolize(entry)
|
|
122
|
+
raise ArgumentError, named_params_error(entry) unless normalized.key?(:param_name)
|
|
123
|
+
|
|
124
|
+
{ param_name: normalized[:param_name].to_s, example: normalized[:example] }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @return [String]
|
|
128
|
+
def named_params_error(values)
|
|
129
|
+
"Expected named parameter examples as a Hash of name => example, or an " \
|
|
130
|
+
"array of { param_name:, example: } hashes, got: #{values.inspect}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @return [Array] The positional values, nested for roles that require it.
|
|
134
|
+
# @raise [ArgumentError] if a Hash is given for a positional format.
|
|
135
|
+
def positional_values(role, values)
|
|
136
|
+
if values.is_a?(Hash)
|
|
137
|
+
raise ArgumentError,
|
|
138
|
+
"Expected positional parameter examples as an Array, got a Hash: #{values.inspect}. " \
|
|
139
|
+
"Did you mean to set parameter_format to #{ParameterFormats::NAMED}?"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
list = values.is_a?(Array) ? values : [values]
|
|
143
|
+
NESTED_ROLES.include?(role.to_sym) ? [list] : list
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
class LibraryTemplate
|
|
6
|
+
# The optional body content a library template exposes for customisation.
|
|
7
|
+
#
|
|
8
|
+
# Library templates have fixed wording, so these are toggles rather than text:
|
|
9
|
+
# each one asks Meta to include a pre-written line or link in the body.
|
|
10
|
+
# Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/template-library
|
|
11
|
+
class BodyInputs
|
|
12
|
+
include ValueObject
|
|
13
|
+
|
|
14
|
+
module Defaults
|
|
15
|
+
CODE_EXPIRATION_RANGE = (1..90)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @!attribute [rw] add_contact_number
|
|
19
|
+
# @return [Boolean, nil]
|
|
20
|
+
attr_accessor :add_contact_number
|
|
21
|
+
|
|
22
|
+
# @!attribute [rw] add_learn_more_link
|
|
23
|
+
# @return [Boolean, nil]
|
|
24
|
+
attr_accessor :add_learn_more_link
|
|
25
|
+
|
|
26
|
+
# @!attribute [rw] add_security_recommendation
|
|
27
|
+
# @return [Boolean, nil]
|
|
28
|
+
attr_accessor :add_security_recommendation
|
|
29
|
+
|
|
30
|
+
# @!attribute [rw] add_track_package_link
|
|
31
|
+
# @return [Boolean, nil]
|
|
32
|
+
attr_accessor :add_track_package_link
|
|
33
|
+
|
|
34
|
+
# @!attribute [rw] code_expiration_minutes
|
|
35
|
+
# @return [Integer, nil]
|
|
36
|
+
attr_accessor :code_expiration_minutes
|
|
37
|
+
|
|
38
|
+
validate :validate_code_expiration
|
|
39
|
+
|
|
40
|
+
# @param add_contact_number [Boolean, nil]
|
|
41
|
+
# @param add_learn_more_link [Boolean, nil]
|
|
42
|
+
# @param add_security_recommendation [Boolean, nil]
|
|
43
|
+
# @param add_track_package_link [Boolean, nil]
|
|
44
|
+
# @param code_expiration_minutes [Integer, nil] Must be within 1..90.
|
|
45
|
+
# @raise [ActiveModel::ValidationError] if validation fails.
|
|
46
|
+
def initialize(add_contact_number: nil, add_learn_more_link: nil, add_security_recommendation: nil,
|
|
47
|
+
add_track_package_link: nil, code_expiration_minutes: nil)
|
|
48
|
+
@add_contact_number = add_contact_number
|
|
49
|
+
@add_learn_more_link = add_learn_more_link
|
|
50
|
+
@add_security_recommendation = add_security_recommendation
|
|
51
|
+
@add_track_package_link = add_track_package_link
|
|
52
|
+
@code_expiration_minutes = code_expiration_minutes
|
|
53
|
+
|
|
54
|
+
validate!
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Hash] Only the toggles that were explicitly set.
|
|
58
|
+
def serialize
|
|
59
|
+
{
|
|
60
|
+
add_contact_number:,
|
|
61
|
+
add_learn_more_link:,
|
|
62
|
+
add_security_recommendation:,
|
|
63
|
+
add_track_package_link:,
|
|
64
|
+
code_expiration_minutes:,
|
|
65
|
+
}.compact
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# @return [void]
|
|
71
|
+
def validate_code_expiration
|
|
72
|
+
return if code_expiration_minutes.nil?
|
|
73
|
+
return if code_expiration_minutes.is_a?(Integer) &&
|
|
74
|
+
Defaults::CODE_EXPIRATION_RANGE.cover?(code_expiration_minutes)
|
|
75
|
+
|
|
76
|
+
errors.add(:code_expiration_minutes, "must be in #{Defaults::CODE_EXPIRATION_RANGE}")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
class LibraryTemplate
|
|
6
|
+
# One button's customisable values when cloning a library template.
|
|
7
|
+
#
|
|
8
|
+
# Deliberately *not* the same as {Button}: a library template already declares
|
|
9
|
+
# which buttons it has and what they say, so these inputs only fill in the parts
|
|
10
|
+
# Meta cannot know — a destination URL, a phone number, an app signature. There is
|
|
11
|
+
# no `text`, and the shapes differ from the create-a-button-from-scratch ones (a
|
|
12
|
+
# URL arrives as a `{base_url:, url_suffix_example:}` pair rather than a single
|
|
13
|
+
# string with a placeholder).
|
|
14
|
+
# Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/template-library
|
|
15
|
+
class ButtonInputs
|
|
16
|
+
include ValueObject
|
|
17
|
+
|
|
18
|
+
# The button kinds a library template may expose for customisation. Wider than
|
|
19
|
+
# {Button::TYPES} because Meta documents these by name here even where it
|
|
20
|
+
# publishes no create-from-scratch schema for them.
|
|
21
|
+
module Types
|
|
22
|
+
QUICK_REPLY = "QUICK_REPLY"
|
|
23
|
+
URL = "URL"
|
|
24
|
+
PHONE_NUMBER = "PHONE_NUMBER"
|
|
25
|
+
OTP = "OTP"
|
|
26
|
+
MPM = "MPM"
|
|
27
|
+
CATALOG = "CATALOG"
|
|
28
|
+
FLOW = "FLOW"
|
|
29
|
+
VOICE_CALL = "VOICE_CALL"
|
|
30
|
+
APP = "APP"
|
|
31
|
+
|
|
32
|
+
ALL = [QUICK_REPLY, URL, PHONE_NUMBER, OTP, MPM, CATALOG, FLOW, VOICE_CALL, APP].freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @!attribute [rw] type
|
|
36
|
+
# @return [String] one of {Types::ALL}.
|
|
37
|
+
attr_accessor :type
|
|
38
|
+
|
|
39
|
+
# @!attribute [rw] url
|
|
40
|
+
# @return [Hash, nil] `{ base_url:, url_suffix_example: }`.
|
|
41
|
+
attr_accessor :url
|
|
42
|
+
|
|
43
|
+
# @!attribute [rw] phone_number
|
|
44
|
+
# @return [String, nil]
|
|
45
|
+
attr_accessor :phone_number
|
|
46
|
+
|
|
47
|
+
# @!attribute [rw] otp_type
|
|
48
|
+
# @return [String, nil]
|
|
49
|
+
attr_accessor :otp_type
|
|
50
|
+
|
|
51
|
+
# @!attribute [rw] zero_tap_terms_accepted
|
|
52
|
+
# @return [Boolean, nil]
|
|
53
|
+
attr_accessor :zero_tap_terms_accepted
|
|
54
|
+
|
|
55
|
+
# @!attribute [rw] supported_apps
|
|
56
|
+
# @return [Array<Button::Otp::SupportedApp>, nil]
|
|
57
|
+
attr_accessor :supported_apps
|
|
58
|
+
|
|
59
|
+
validates :type, presence: true, inclusion: { in: Types::ALL }
|
|
60
|
+
validate :validate_url
|
|
61
|
+
validate :validate_phone_number
|
|
62
|
+
validate :validate_supported_apps
|
|
63
|
+
|
|
64
|
+
# @param type [String, Symbol] One of {Types::ALL}, any casing.
|
|
65
|
+
# @param url [Hash, nil] Required for URL: `{ base_url:, url_suffix_example: }`.
|
|
66
|
+
# @param phone_number [String, nil] Required for PHONE_NUMBER.
|
|
67
|
+
# @param otp_type [String, Symbol, nil] For OTP inputs.
|
|
68
|
+
# @param zero_tap_terms_accepted [Boolean, nil] For zero-tap OTP inputs.
|
|
69
|
+
# @param supported_apps [Array<Hash, Button::Otp::SupportedApp>, nil] Required
|
|
70
|
+
# for APP.
|
|
71
|
+
# @raise [ActiveModel::ValidationError] if validation fails.
|
|
72
|
+
def initialize(type:, url: nil, phone_number: nil, otp_type: nil,
|
|
73
|
+
zero_tap_terms_accepted: nil, supported_apps: nil)
|
|
74
|
+
@type = normalize_type(type)
|
|
75
|
+
@url = url&.then { |value| symbolize(value) }
|
|
76
|
+
@phone_number = phone_number
|
|
77
|
+
@otp_type = otp_type&.to_s&.upcase
|
|
78
|
+
@zero_tap_terms_accepted = zero_tap_terms_accepted
|
|
79
|
+
@supported_apps = build_supported_apps(supported_apps)
|
|
80
|
+
|
|
81
|
+
validate!
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Hash] The serialized button input.
|
|
85
|
+
def serialize
|
|
86
|
+
{
|
|
87
|
+
type:,
|
|
88
|
+
url:,
|
|
89
|
+
phone_number:,
|
|
90
|
+
otp_type:,
|
|
91
|
+
zero_tap_terms_accepted:,
|
|
92
|
+
supported_apps: supported_apps&.map(&:serialize),
|
|
93
|
+
}.compact
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# @return [String, nil]
|
|
99
|
+
def normalize_type(value)
|
|
100
|
+
return if value.nil?
|
|
101
|
+
|
|
102
|
+
candidate = value.to_s.upcase
|
|
103
|
+
Types::ALL.include?(candidate) ? candidate : value.to_s
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @return [Hash]
|
|
107
|
+
def symbolize(hash)
|
|
108
|
+
hash.transform_keys(&:to_sym)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @return [Array<Button::Otp::SupportedApp>, nil]
|
|
112
|
+
def build_supported_apps(apps)
|
|
113
|
+
return if apps.nil?
|
|
114
|
+
|
|
115
|
+
Array(apps).map do |app|
|
|
116
|
+
app.is_a?(Button::Otp::SupportedApp) ? app : Button::Otp::SupportedApp.new(**app)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @return [void]
|
|
121
|
+
def validate_url
|
|
122
|
+
return unless type == Types::URL
|
|
123
|
+
return unless blank_value?(url) || blank_value?(url[:base_url])
|
|
124
|
+
|
|
125
|
+
errors.add(:url, "requires a base_url for a #{Types::URL} button input")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @return [void]
|
|
129
|
+
def validate_phone_number
|
|
130
|
+
return unless type == Types::PHONE_NUMBER
|
|
131
|
+
return unless blank_value?(phone_number)
|
|
132
|
+
|
|
133
|
+
errors.add(:phone_number, "can't be blank for a #{Types::PHONE_NUMBER} button input")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @return [void]
|
|
137
|
+
def validate_supported_apps
|
|
138
|
+
return unless type == Types::APP
|
|
139
|
+
return unless blank_value?(supported_apps)
|
|
140
|
+
|
|
141
|
+
errors.add(:supported_apps, "can't be blank for an #{Types::APP} button input")
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
# The payload for cloning one of Meta's pre-written library templates.
|
|
6
|
+
#
|
|
7
|
+
# A separate class from {Template} rather than another mode on it, because this
|
|
8
|
+
# payload carries **no components at all** — the library template already defines
|
|
9
|
+
# them, and only the parts Meta cannot know (a URL, a phone number, whether to
|
|
10
|
+
# append an optional line) are supplied as inputs. Folding that into {Template}
|
|
11
|
+
# would mean defeating its central "exactly one BODY" invariant.
|
|
12
|
+
#
|
|
13
|
+
# The payoff is instant approval: library templates are already categorised and
|
|
14
|
+
# reviewed, so the create response usually comes back APPROVED rather than PENDING.
|
|
15
|
+
# Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/template-library
|
|
16
|
+
class LibraryTemplate
|
|
17
|
+
include ValueObject
|
|
18
|
+
|
|
19
|
+
# @!attribute [rw] name
|
|
20
|
+
# @return [String] The name to give *your* copy of the template.
|
|
21
|
+
attr_accessor :name
|
|
22
|
+
|
|
23
|
+
# @!attribute [rw] language
|
|
24
|
+
# @return [String]
|
|
25
|
+
attr_accessor :language
|
|
26
|
+
|
|
27
|
+
# @!attribute [rw] category
|
|
28
|
+
# @return [String]
|
|
29
|
+
attr_accessor :category
|
|
30
|
+
|
|
31
|
+
# @!attribute [rw] library_template_name
|
|
32
|
+
# @return [String] The library template being cloned.
|
|
33
|
+
attr_accessor :library_template_name
|
|
34
|
+
|
|
35
|
+
# @!attribute [rw] library_template_body_inputs
|
|
36
|
+
# @return [LibraryTemplate::BodyInputs, nil]
|
|
37
|
+
attr_accessor :library_template_body_inputs
|
|
38
|
+
|
|
39
|
+
# @!attribute [rw] library_template_button_inputs
|
|
40
|
+
# @return [Array<LibraryTemplate::ButtonInputs>, nil]
|
|
41
|
+
attr_accessor :library_template_button_inputs
|
|
42
|
+
|
|
43
|
+
validates :name, presence: true,
|
|
44
|
+
format: { with: Template::Defaults::NAME_PATTERN,
|
|
45
|
+
message: "must contain only lowercase alphanumeric characters and underscores", },
|
|
46
|
+
length: { maximum: Template::Defaults::MAX_NAME_LENGTH }
|
|
47
|
+
validates :language, presence: true
|
|
48
|
+
validates :category, presence: true, inclusion: { in: Categories::ALL }
|
|
49
|
+
validates :library_template_name, presence: true
|
|
50
|
+
validate :validate_language_code
|
|
51
|
+
|
|
52
|
+
# @param name [String] The name for your copy; same rules as {Template}.
|
|
53
|
+
# @param language [String] A locale code.
|
|
54
|
+
# @param category [String, Symbol] One of {Categories::ALL}.
|
|
55
|
+
# @param library_template_name [String] The library template to clone.
|
|
56
|
+
# @param library_template_body_inputs [Hash, BodyInputs, nil] Optional body toggles.
|
|
57
|
+
# @param library_template_button_inputs [Array<Hash, ButtonInputs>, nil] Optional
|
|
58
|
+
# per-button values.
|
|
59
|
+
# @raise [ActiveModel::ValidationError] if validation fails.
|
|
60
|
+
def initialize(name:, language:, category:, library_template_name:,
|
|
61
|
+
library_template_body_inputs: nil, library_template_button_inputs: nil)
|
|
62
|
+
@name = name
|
|
63
|
+
@language = language
|
|
64
|
+
@category = Categories.normalize(category)
|
|
65
|
+
@library_template_name = library_template_name
|
|
66
|
+
@library_template_body_inputs = build_body_inputs(library_template_body_inputs)
|
|
67
|
+
@library_template_button_inputs = build_button_inputs(library_template_button_inputs)
|
|
68
|
+
|
|
69
|
+
validate!
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Hash] The create-from-library payload.
|
|
73
|
+
#
|
|
74
|
+
# Note: Meta's docs show `library_template_button_inputs` as a JSON-*stringified*
|
|
75
|
+
# array while the Graph API reference types it `array<JSON object>`. A real array
|
|
76
|
+
# is emitted here, which matches the reference and how every other field on this
|
|
77
|
+
# edge behaves. If the API rejects it, this method is the single place to change.
|
|
78
|
+
def serialize
|
|
79
|
+
{
|
|
80
|
+
name:,
|
|
81
|
+
language:,
|
|
82
|
+
category:,
|
|
83
|
+
library_template_name:,
|
|
84
|
+
library_template_body_inputs: library_template_body_inputs&.serialize,
|
|
85
|
+
library_template_button_inputs: library_template_button_inputs&.map(&:serialize),
|
|
86
|
+
}.compact
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# @return [BodyInputs, nil]
|
|
92
|
+
def build_body_inputs(inputs)
|
|
93
|
+
return if blank_value?(inputs)
|
|
94
|
+
return inputs if inputs.is_a?(BodyInputs)
|
|
95
|
+
|
|
96
|
+
BodyInputs.new(**inputs)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @return [Array<ButtonInputs>, nil]
|
|
100
|
+
def build_button_inputs(inputs)
|
|
101
|
+
return if blank_value?(inputs)
|
|
102
|
+
|
|
103
|
+
Array(inputs).map { |input| input.is_a?(ButtonInputs) ? input : ButtonInputs.new(**input) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @return [void]
|
|
107
|
+
def validate_language_code
|
|
108
|
+
return if blank_value?(language) || Utils::LanguageCodes.valid?(language)
|
|
109
|
+
|
|
110
|
+
errors.add(:language, "#{language.inspect} is not a valid WhatsApp language code")
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
# The two placeholder styles a template can declare, set once per template via
|
|
6
|
+
# `parameter_format` and inherited by every component in it.
|
|
7
|
+
#
|
|
8
|
+
# POSITIONAL uses `{{1}}`, `{{2}}` — index numbers starting at 1, and send-side
|
|
9
|
+
# values must be supplied in the same order. NAMED uses `{{first_name}}` —
|
|
10
|
+
# lowercase-and-underscore names, and send-side values may be supplied in any
|
|
11
|
+
# order. POSITIONAL is Meta's default when the field is omitted.
|
|
12
|
+
#
|
|
13
|
+
# The format also decides the *key name* of a component's `example` payload,
|
|
14
|
+
# which is why it has to be threaded down into each component. See {Example}.
|
|
15
|
+
# Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/overview
|
|
16
|
+
module ParameterFormats
|
|
17
|
+
POSITIONAL = "POSITIONAL"
|
|
18
|
+
NAMED = "NAMED"
|
|
19
|
+
|
|
20
|
+
ALL = [POSITIONAL, NAMED].freeze
|
|
21
|
+
|
|
22
|
+
# Normalizes caller input to a canonical uppercase format string.
|
|
23
|
+
#
|
|
24
|
+
# Meta's own docs are inconsistent about enum casing, so callers may
|
|
25
|
+
# reasonably pass `:named`, `"named"`, or `"NAMED"`. Unrecognized values are
|
|
26
|
+
# returned untouched so validations can report them.
|
|
27
|
+
# @param value [String, Symbol, nil]
|
|
28
|
+
# @return [String, nil]
|
|
29
|
+
def self.normalize(value)
|
|
30
|
+
return if value.nil?
|
|
31
|
+
|
|
32
|
+
candidate = value.to_s.upcase
|
|
33
|
+
ALL.include?(candidate) ? candidate : value.to_s
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param value [String, Symbol, nil]
|
|
37
|
+
# @return [Boolean]
|
|
38
|
+
def self.named?(value)
|
|
39
|
+
normalize(value) == NAMED
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param value [String, Symbol, nil]
|
|
43
|
+
# @return [Boolean]
|
|
44
|
+
def self.valid?(value)
|
|
45
|
+
ALL.include?(normalize(value))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
# Parses the `{{...}}` placeholders out of template text.
|
|
6
|
+
#
|
|
7
|
+
# Three components need this (header text, body text, and the single variable a
|
|
8
|
+
# URL button may append), so the pattern and the "what style is this?" question
|
|
9
|
+
# live in one place rather than being re-derived per component.
|
|
10
|
+
#
|
|
11
|
+
# Placeholder names are collapsed to unique values in order of first appearance:
|
|
12
|
+
# Meta counts a repeated `{{1}}` or `{{name}}` as one parameter, so the count a
|
|
13
|
+
# caller must supply examples for is the unique count — that is {.count}. Where a
|
|
14
|
+
# rule is about *position* rather than parameters, {.occurrences} counts repeats
|
|
15
|
+
# instead; see the URL button.
|
|
16
|
+
# Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/overview
|
|
17
|
+
module Placeholders
|
|
18
|
+
# Matches `{{name}}` / `{{1}}`, tolerating internal whitespace. Deliberately
|
|
19
|
+
# restricted to word characters so malformed forms (`{{}}`, `{{a-b}}`) are not
|
|
20
|
+
# mistaken for parameters.
|
|
21
|
+
PATTERN = /\{\{\s*(\w+)\s*\}\}/
|
|
22
|
+
|
|
23
|
+
# @param text [String, nil]
|
|
24
|
+
# @return [Array<String>] Unique placeholder names, in order of appearance.
|
|
25
|
+
def self.extract(text)
|
|
26
|
+
text.to_s.scan(PATTERN).flatten.uniq
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param text [String, nil]
|
|
30
|
+
# @return [Integer] The number of unique placeholders.
|
|
31
|
+
def self.count(text)
|
|
32
|
+
extract(text).size
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param text [String, nil]
|
|
36
|
+
# @return [Integer] The number of placeholder occurrences, counting repeats.
|
|
37
|
+
# Contrast {.count}, which collapses repeated names. The URL button rule is
|
|
38
|
+
# positional — a single variable, appended at the very end — so a second
|
|
39
|
+
# occurrence breaks it even when it names the same parameter.
|
|
40
|
+
def self.occurrences(text)
|
|
41
|
+
text.to_s.scan(PATTERN).size
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param text [String, nil]
|
|
45
|
+
# @return [Symbol, nil] `:positional`, `:named`, `:mixed`, or nil when the text
|
|
46
|
+
# has no placeholders.
|
|
47
|
+
def self.style(text)
|
|
48
|
+
names = extract(text)
|
|
49
|
+
return if names.empty?
|
|
50
|
+
|
|
51
|
+
numeric, rest = names.partition { |name| name.match?(/\A\d+\z/) }
|
|
52
|
+
|
|
53
|
+
return :positional if rest.empty?
|
|
54
|
+
return :named if numeric.empty?
|
|
55
|
+
|
|
56
|
+
:mixed
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @param text [String, nil]
|
|
60
|
+
# @return [Boolean] true when the text has placeholders and all are numeric.
|
|
61
|
+
def self.positional?(text)
|
|
62
|
+
style(text) == :positional
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @param text [String, nil]
|
|
66
|
+
# @return [Boolean] true when the text has placeholders and none are numeric.
|
|
67
|
+
def self.named?(text)
|
|
68
|
+
style(text) == :named
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Whether positional placeholders form the run 1..n that Meta requires.
|
|
72
|
+
#
|
|
73
|
+
# Order of appearance in the text does not matter — `"{{2}} after {{1}}"` is
|
|
74
|
+
# valid — only that the set of indexes has no gaps and starts at 1. Text with
|
|
75
|
+
# no placeholders, or named placeholders, is trivially sequential.
|
|
76
|
+
# @param text [String, nil]
|
|
77
|
+
# @return [Boolean]
|
|
78
|
+
def self.sequential?(text)
|
|
79
|
+
indexes = extract(text).grep(/\A\d+\z/).map(&:to_i)
|
|
80
|
+
return true if indexes.empty?
|
|
81
|
+
|
|
82
|
+
indexes.sort == (1..indexes.size).to_a
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|