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.
Files changed (166) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +54 -0
  4. data/.ruby-version +1 -0
  5. data/CHANGELOG.md +15 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +643 -0
  9. data/Rakefile +12 -0
  10. data/lib/ruby/whatsapp/client.rb +82 -0
  11. data/lib/ruby/whatsapp/configuration.rb +79 -0
  12. data/lib/ruby/whatsapp/instrumentation.rb +34 -0
  13. data/lib/ruby/whatsapp/media.rb +134 -0
  14. data/lib/ruby/whatsapp/message_templates/button/base.rb +26 -0
  15. data/lib/ruby/whatsapp/message_templates/button/copy_code.rb +59 -0
  16. data/lib/ruby/whatsapp/message_templates/button/otp/supported_app.rb +44 -0
  17. data/lib/ruby/whatsapp/message_templates/button/otp.rb +138 -0
  18. data/lib/ruby/whatsapp/message_templates/button/phone_number.rb +45 -0
  19. data/lib/ruby/whatsapp/message_templates/button/quick_reply.rb +38 -0
  20. data/lib/ruby/whatsapp/message_templates/button/url.rb +110 -0
  21. data/lib/ruby/whatsapp/message_templates/button.rb +56 -0
  22. data/lib/ruby/whatsapp/message_templates/categories.rb +47 -0
  23. data/lib/ruby/whatsapp/message_templates/component/base.rb +112 -0
  24. data/lib/ruby/whatsapp/message_templates/component/body.rb +139 -0
  25. data/lib/ruby/whatsapp/message_templates/component/buttons.rb +95 -0
  26. data/lib/ruby/whatsapp/message_templates/component/carousel/card.rb +100 -0
  27. data/lib/ruby/whatsapp/message_templates/component/carousel.rb +71 -0
  28. data/lib/ruby/whatsapp/message_templates/component/footer.rb +99 -0
  29. data/lib/ruby/whatsapp/message_templates/component/header.rb +188 -0
  30. data/lib/ruby/whatsapp/message_templates/component/limited_time_offer.rb +57 -0
  31. data/lib/ruby/whatsapp/message_templates/component.rb +62 -0
  32. data/lib/ruby/whatsapp/message_templates/component_set.rb +221 -0
  33. data/lib/ruby/whatsapp/message_templates/example.rb +148 -0
  34. data/lib/ruby/whatsapp/message_templates/library_template/body_inputs.rb +81 -0
  35. data/lib/ruby/whatsapp/message_templates/library_template/button_inputs.rb +146 -0
  36. data/lib/ruby/whatsapp/message_templates/library_template.rb +114 -0
  37. data/lib/ruby/whatsapp/message_templates/parameter_formats.rb +49 -0
  38. data/lib/ruby/whatsapp/message_templates/placeholders.rb +86 -0
  39. data/lib/ruby/whatsapp/message_templates/response/collection.rb +74 -0
  40. data/lib/ruby/whatsapp/message_templates/response/created.rb +65 -0
  41. data/lib/ruby/whatsapp/message_templates/response/node.rb +152 -0
  42. data/lib/ruby/whatsapp/message_templates/response/paging.rb +39 -0
  43. data/lib/ruby/whatsapp/message_templates/response/quality_score.rb +49 -0
  44. data/lib/ruby/whatsapp/message_templates/response/summary.rb +68 -0
  45. data/lib/ruby/whatsapp/message_templates/response.rb +22 -0
  46. data/lib/ruby/whatsapp/message_templates/statuses.rb +66 -0
  47. data/lib/ruby/whatsapp/message_templates/template.rb +180 -0
  48. data/lib/ruby/whatsapp/message_templates/value_object.rb +62 -0
  49. data/lib/ruby/whatsapp/message_templates.rb +283 -0
  50. data/lib/ruby/whatsapp/messages/address.rb +103 -0
  51. data/lib/ruby/whatsapp/messages/audio.rb +64 -0
  52. data/lib/ruby/whatsapp/messages/base.rb +46 -0
  53. data/lib/ruby/whatsapp/messages/contacts/address.rb +84 -0
  54. data/lib/ruby/whatsapp/messages/contacts/contact.rb +78 -0
  55. data/lib/ruby/whatsapp/messages/contacts/email.rb +49 -0
  56. data/lib/ruby/whatsapp/messages/contacts/name.rb +68 -0
  57. data/lib/ruby/whatsapp/messages/contacts/org.rb +41 -0
  58. data/lib/ruby/whatsapp/messages/contacts/phone.rb +59 -0
  59. data/lib/ruby/whatsapp/messages/contacts/url.rb +51 -0
  60. data/lib/ruby/whatsapp/messages/contacts.rb +37 -0
  61. data/lib/ruby/whatsapp/messages/document.rb +79 -0
  62. data/lib/ruby/whatsapp/messages/image.rb +72 -0
  63. data/lib/ruby/whatsapp/messages/interactive/base.rb +15 -0
  64. data/lib/ruby/whatsapp/messages/interactive/body.rb +39 -0
  65. data/lib/ruby/whatsapp/messages/interactive/footer.rb +40 -0
  66. data/lib/ruby/whatsapp/messages/interactive/header.rb +102 -0
  67. data/lib/ruby/whatsapp/messages/interactive/list_buttons/section/row.rb +66 -0
  68. data/lib/ruby/whatsapp/messages/interactive/list_buttons/section.rb +64 -0
  69. data/lib/ruby/whatsapp/messages/interactive/list_buttons.rb +63 -0
  70. data/lib/ruby/whatsapp/messages/interactive/media_carousel/card/button/quick_reply.rb +50 -0
  71. data/lib/ruby/whatsapp/messages/interactive/media_carousel/card/button.rb +47 -0
  72. data/lib/ruby/whatsapp/messages/interactive/media_carousel/card.rb +78 -0
  73. data/lib/ruby/whatsapp/messages/interactive/media_carousel.rb +50 -0
  74. data/lib/ruby/whatsapp/messages/interactive/product_carousel/card.rb +55 -0
  75. data/lib/ruby/whatsapp/messages/interactive/product_carousel.rb +50 -0
  76. data/lib/ruby/whatsapp/messages/interactive/reply_buttons/button.rb +59 -0
  77. data/lib/ruby/whatsapp/messages/interactive/reply_buttons.rb +47 -0
  78. data/lib/ruby/whatsapp/messages/interactive/url_button.rb +68 -0
  79. data/lib/ruby/whatsapp/messages/interactive.rb +112 -0
  80. data/lib/ruby/whatsapp/messages/location.rb +67 -0
  81. data/lib/ruby/whatsapp/messages/location_request.rb +41 -0
  82. data/lib/ruby/whatsapp/messages/mark_message_as_read.rb +42 -0
  83. data/lib/ruby/whatsapp/messages/reaction.rb +61 -0
  84. data/lib/ruby/whatsapp/messages/response/contacts.rb +36 -0
  85. data/lib/ruby/whatsapp/messages/response/messages.rb +27 -0
  86. data/lib/ruby/whatsapp/messages/response.rb +49 -0
  87. data/lib/ruby/whatsapp/messages/sticker.rb +62 -0
  88. data/lib/ruby/whatsapp/messages/template/component.rb +67 -0
  89. data/lib/ruby/whatsapp/messages/template/language.rb +55 -0
  90. data/lib/ruby/whatsapp/messages/template/parameter.rb +113 -0
  91. data/lib/ruby/whatsapp/messages/template.rb +62 -0
  92. data/lib/ruby/whatsapp/messages/text.rb +43 -0
  93. data/lib/ruby/whatsapp/messages/video.rb +68 -0
  94. data/lib/ruby/whatsapp/messages.rb +116 -0
  95. data/lib/ruby/whatsapp/railtie.rb +14 -0
  96. data/lib/ruby/whatsapp/request_error.rb +5 -0
  97. data/lib/ruby/whatsapp/response_handling.rb +44 -0
  98. data/lib/ruby/whatsapp/utils/language_codes.rb +96 -0
  99. data/lib/ruby/whatsapp/version.rb +5 -0
  100. data/lib/ruby/whatsapp/webhook/account_alerts.rb +58 -0
  101. data/lib/ruby/whatsapp/webhook/account_review_update.rb +27 -0
  102. data/lib/ruby/whatsapp/webhook/account_update/ban_info.rb +33 -0
  103. data/lib/ruby/whatsapp/webhook/account_update.rb +46 -0
  104. data/lib/ruby/whatsapp/webhook/automatic_events.rb +43 -0
  105. data/lib/ruby/whatsapp/webhook/business_capability_update.rb +36 -0
  106. data/lib/ruby/whatsapp/webhook/change.rb +61 -0
  107. data/lib/ruby/whatsapp/webhook/contact.rb +36 -0
  108. data/lib/ruby/whatsapp/webhook/entry.rb +35 -0
  109. data/lib/ruby/whatsapp/webhook/error.rb +47 -0
  110. data/lib/ruby/whatsapp/webhook/history.rb +54 -0
  111. data/lib/ruby/whatsapp/webhook/installer.rb +61 -0
  112. data/lib/ruby/whatsapp/webhook/message/audio.rb +28 -0
  113. data/lib/ruby/whatsapp/webhook/message/base.rb +61 -0
  114. data/lib/ruby/whatsapp/webhook/message/button.rb +33 -0
  115. data/lib/ruby/whatsapp/webhook/message/contacts/address.rb +68 -0
  116. data/lib/ruby/whatsapp/webhook/message/contacts/contact.rb +68 -0
  117. data/lib/ruby/whatsapp/webhook/message/contacts/email.rb +35 -0
  118. data/lib/ruby/whatsapp/webhook/message/contacts/name.rb +62 -0
  119. data/lib/ruby/whatsapp/webhook/message/contacts/org.rb +40 -0
  120. data/lib/ruby/whatsapp/webhook/message/contacts/phone.rb +40 -0
  121. data/lib/ruby/whatsapp/webhook/message/contacts/url.rb +35 -0
  122. data/lib/ruby/whatsapp/webhook/message/contacts.rb +31 -0
  123. data/lib/ruby/whatsapp/webhook/message/context.rb +49 -0
  124. data/lib/ruby/whatsapp/webhook/message/document.rb +32 -0
  125. data/lib/ruby/whatsapp/webhook/message/image.rb +18 -0
  126. data/lib/ruby/whatsapp/webhook/message/interactive.rb +53 -0
  127. data/lib/ruby/whatsapp/webhook/message/location.rb +49 -0
  128. data/lib/ruby/whatsapp/webhook/message/media.rb +54 -0
  129. data/lib/ruby/whatsapp/webhook/message/order/product_item.rb +50 -0
  130. data/lib/ruby/whatsapp/webhook/message/order.rb +43 -0
  131. data/lib/ruby/whatsapp/webhook/message/reaction.rb +37 -0
  132. data/lib/ruby/whatsapp/webhook/message/referral.rb +80 -0
  133. data/lib/ruby/whatsapp/webhook/message/sticker.rb +32 -0
  134. data/lib/ruby/whatsapp/webhook/message/system.rb +49 -0
  135. data/lib/ruby/whatsapp/webhook/message/text.rb +28 -0
  136. data/lib/ruby/whatsapp/webhook/message/unknown.rb +39 -0
  137. data/lib/ruby/whatsapp/webhook/message/video.rb +18 -0
  138. data/lib/ruby/whatsapp/webhook/message.rb +39 -0
  139. data/lib/ruby/whatsapp/webhook/message_template_components_update.rb +53 -0
  140. data/lib/ruby/whatsapp/webhook/message_template_quality_update.rb +59 -0
  141. data/lib/ruby/whatsapp/webhook/message_template_status_update.rb +54 -0
  142. data/lib/ruby/whatsapp/webhook/messages.rb +55 -0
  143. data/lib/ruby/whatsapp/webhook/metadata.rb +35 -0
  144. data/lib/ruby/whatsapp/webhook/notification.rb +36 -0
  145. data/lib/ruby/whatsapp/webhook/partner_solutions.rb +36 -0
  146. data/lib/ruby/whatsapp/webhook/payment_configuration_update.rb +51 -0
  147. data/lib/ruby/whatsapp/webhook/phone_number_name_update.rb +51 -0
  148. data/lib/ruby/whatsapp/webhook/phone_number_quality_update.rb +45 -0
  149. data/lib/ruby/whatsapp/webhook/security.rb +41 -0
  150. data/lib/ruby/whatsapp/webhook/signature.rb +28 -0
  151. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/state_sync.rb +38 -0
  152. data/lib/ruby/whatsapp/webhook/smb_app_state_sync.rb +33 -0
  153. data/lib/ruby/whatsapp/webhook/smb_message_echoes.rb +48 -0
  154. data/lib/ruby/whatsapp/webhook/status/conversation.rb +42 -0
  155. data/lib/ruby/whatsapp/webhook/status/pricing.rb +38 -0
  156. data/lib/ruby/whatsapp/webhook/status.rb +65 -0
  157. data/lib/ruby/whatsapp/webhook/template_category_update.rb +64 -0
  158. data/lib/ruby/whatsapp/webhook/templates/webhooks_controller.rb.tt +31 -0
  159. data/lib/ruby/whatsapp/webhook/unknown_field.rb +37 -0
  160. data/lib/ruby/whatsapp/webhook/user_preferences.rb +47 -0
  161. data/lib/ruby/whatsapp/webhook/verification.rb +27 -0
  162. data/lib/ruby/whatsapp/webhook.rb +8 -0
  163. data/lib/ruby/whatsapp.rb +49 -0
  164. data/lib/tasks/whatsapp.rake +10 -0
  165. data/sig/ruby/whatsapp.rbs +6 -0
  166. 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