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,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