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,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ class MessageTemplates
5
+ class Component
6
+ # A horizontally scrollable set of 2 to 10 cards, each with its own media header
7
+ # and buttons. Marketing templates only, which {Template} enforces.
8
+ #
9
+ # The card count is fixed at creation: an approved carousel template must be sent
10
+ # with exactly the number of cards it was created with.
11
+ #
12
+ # Every card must share the same structure — same header format, same button
13
+ # types, and body text either on all of them or none. Meta's stated reason for
14
+ # the body rule is keeping card heights consistent. {Card#signature} is what makes
15
+ # this checkable.
16
+ # Source: https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/carousel-templates
17
+ class Carousel < Base
18
+ module Defaults
19
+ TYPE = "CAROUSEL"
20
+ MIN_CARDS = 2
21
+ MAX_CARDS = 10
22
+ end
23
+
24
+ # @!attribute [rw] cards
25
+ # @return [Array<Carousel::Card>]
26
+ attr_accessor :cards
27
+
28
+ validates :cards, presence: true,
29
+ length: { minimum: Defaults::MIN_CARDS, maximum: Defaults::MAX_CARDS }
30
+ validate :validate_identical_structure
31
+
32
+ # @param cards [Array<Hash, Carousel::Card>] Between 2 and 10 cards.
33
+ # @param kwargs [Hash] Forwarded to {Base} (`parameter_format`).
34
+ # @raise [ActiveModel::ValidationError] if validation fails.
35
+ def initialize(cards: [], **)
36
+ super(**)
37
+
38
+ @cards = build_cards(cards)
39
+
40
+ validate!
41
+ end
42
+
43
+ # @return [Hash] The serialized component.
44
+ def serialize
45
+ { type: Defaults::TYPE, cards: cards.map(&:serialize) }
46
+ end
47
+
48
+ private
49
+
50
+ # @return [Array<Carousel::Card>]
51
+ # @raise [ActiveModel::ValidationError] if a card is invalid.
52
+ def build_cards(list)
53
+ Array(list).map do |card|
54
+ card.is_a?(Card) ? card : Card.new(**card, parameter_format:)
55
+ end
56
+ end
57
+
58
+ # @return [void]
59
+ def validate_identical_structure
60
+ return if cards.size < 2
61
+
62
+ signatures = cards.map(&:signature).uniq
63
+ return if signatures.size == 1
64
+
65
+ errors.add(:cards, "must all have an identical structure: same header format, same button types, " \
66
+ "and body text on either every card or none")
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ class MessageTemplates
5
+ class Component
6
+ # The optional small-print line below the body. At most one per template.
7
+ #
8
+ # Two mutually exclusive shapes, mirroring {Body}:
9
+ #
10
+ # standard — `text` (60 chars, and no placeholders at all)
11
+ # authentication — `code_expiration_minutes`, which renders Meta's own
12
+ # localised "This code expires in N minutes." Omit the footer
13
+ # entirely to omit that notice.
14
+ #
15
+ # Forbidden altogether in limited-time-offer templates, which {Template} enforces.
16
+ # Source: https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/components/
17
+ class Footer < Base
18
+ module Defaults
19
+ TYPE = "FOOTER"
20
+ MAX_TEXT_LENGTH = 60
21
+ CODE_EXPIRATION_RANGE = (1..90)
22
+ end
23
+
24
+ # @!attribute [rw] text
25
+ # @return [String, nil]
26
+ attr_accessor :text
27
+
28
+ # @!attribute [rw] code_expiration_minutes
29
+ # @return [Integer, nil] Authentication templates only.
30
+ attr_accessor :code_expiration_minutes
31
+
32
+ validate :validate_shape
33
+ validate :validate_text
34
+ validate :validate_code_expiration
35
+
36
+ # @param text [String, nil] The footer text (max 60 characters, no placeholders).
37
+ # @param code_expiration_minutes [Integer, nil] Authentication templates only;
38
+ # must be within 1..90. Mutually exclusive with `text`.
39
+ # @param kwargs [Hash] Forwarded to {Base} (`parameter_format`).
40
+ # @raise [ActiveModel::ValidationError] if validation fails.
41
+ def initialize(text: nil, code_expiration_minutes: nil, **)
42
+ super(**)
43
+
44
+ @text = text
45
+ @code_expiration_minutes = code_expiration_minutes
46
+
47
+ validate!
48
+ end
49
+
50
+ # @return [Boolean] Whether this is the expiry-based authentication shape.
51
+ def authentication?
52
+ !code_expiration_minutes.nil?
53
+ end
54
+
55
+ # @return [Hash] The serialized component.
56
+ def serialize
57
+ return { type: Defaults::TYPE, code_expiration_minutes: } if authentication?
58
+
59
+ { type: Defaults::TYPE, text: }
60
+ end
61
+
62
+ private
63
+
64
+ # @return [void]
65
+ def validate_shape
66
+ if authentication? && !blank_value?(text)
67
+ return errors.add(:text, "cannot be combined with code_expiration_minutes")
68
+ end
69
+
70
+ return unless blank_value?(text) && !authentication?
71
+
72
+ errors.add(:base, "requires either text or code_expiration_minutes")
73
+ end
74
+
75
+ # @return [void]
76
+ def validate_text
77
+ return if authentication? || blank_value?(text)
78
+
79
+ if text.length > Defaults::MAX_TEXT_LENGTH
80
+ errors.add(:text, "is too long (maximum is #{Defaults::MAX_TEXT_LENGTH} characters)")
81
+ end
82
+
83
+ return if Placeholders.count(text).zero?
84
+
85
+ errors.add(:text, "does not support placeholders")
86
+ end
87
+
88
+ # @return [void]
89
+ def validate_code_expiration
90
+ return unless authentication?
91
+ return if code_expiration_minutes.is_a?(Integer) &&
92
+ Defaults::CODE_EXPIRATION_RANGE.cover?(code_expiration_minutes)
93
+
94
+ errors.add(:code_expiration_minutes, "must be in #{Defaults::CODE_EXPIRATION_RANGE}")
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ class MessageTemplates
5
+ class Component
6
+ # The optional component above the body: a line of text, a media attachment, or
7
+ # a location pin. At most one per template.
8
+ #
9
+ # Three shapes share one class because they all serialize to the same
10
+ # `{type:, format:, ...}` envelope and differ only in which fields are legal.
11
+ # The `format` decides:
12
+ #
13
+ # TEXT — `text` (60 chars, at most ONE placeholder) plus an `example`
14
+ # media — `header_handle` from the Resumable Upload API; no text
15
+ # LOCATION — nothing else; coordinates are supplied when the message is sent
16
+ #
17
+ # This mirrors the conditional-validation approach already used by
18
+ # {Whatsapp::Messages::Video} for its id-or-link choice.
19
+ # Source: https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/components/
20
+ class Header < Base
21
+ module Defaults
22
+ TYPE = "HEADER"
23
+ MAX_TEXT_LENGTH = 60
24
+ MAX_VARIABLES = 1
25
+ end
26
+
27
+ module Formats
28
+ TEXT = "TEXT"
29
+ IMAGE = "IMAGE"
30
+ VIDEO = "VIDEO"
31
+ DOCUMENT = "DOCUMENT"
32
+ GIF = "GIF"
33
+ LOCATION = "LOCATION"
34
+
35
+ # Formats that carry an uploaded asset handle rather than text.
36
+ MEDIA = [IMAGE, VIDEO, DOCUMENT, GIF].freeze
37
+ ALL = [TEXT, *MEDIA, LOCATION].freeze
38
+ end
39
+
40
+ # Characters Meta rejects in a header, which does not support Markdown.
41
+ MARKDOWN_CHARACTERS = ["*", "_", "~", "`"].freeze
42
+
43
+ # @!attribute [rw] format
44
+ # @return [String] one of {Formats::ALL}.
45
+ attr_accessor :format
46
+
47
+ # @!attribute [rw] text
48
+ # @return [String, nil] TEXT headers only.
49
+ attr_accessor :text
50
+
51
+ # @!attribute [rw] example
52
+ # @return [Hash, nil] The serialized example payload.
53
+ attr_accessor :example
54
+
55
+ # @!attribute [rw] header_handle
56
+ # @return [String, Array<String>, nil] Media headers only.
57
+ attr_accessor :header_handle
58
+
59
+ validates :format, presence: true, inclusion: { in: Formats::ALL }
60
+ validate :validate_text_shape
61
+ validate :validate_media_shape
62
+ validate :validate_location_shape
63
+
64
+ # @param format [String, Symbol] one of {Formats::ALL}, any casing.
65
+ # @param text [String, nil] The header text, for the TEXT format.
66
+ # @param example [Array, Hash, String, nil] A sample value for the text
67
+ # placeholder, or a pre-built example payload.
68
+ # @param header_handle [String, Array<String>, nil] The uploaded asset handle,
69
+ # for media formats. A convenience alternative to
70
+ # `example: { header_handle: [...] }`.
71
+ # @param kwargs [Hash] Forwarded to {Base} (`parameter_format`).
72
+ # @raise [ActiveModel::ValidationError] if validation fails.
73
+ def initialize(format:, text: nil, example: nil, header_handle: nil, **)
74
+ super(**)
75
+
76
+ @format = normalize_format(format)
77
+ @text = text
78
+ @header_handle = header_handle
79
+ @example = build_example(example)
80
+
81
+ validate!
82
+ end
83
+
84
+ # @return [Boolean] Whether this header carries an uploaded asset.
85
+ def media?
86
+ Formats::MEDIA.include?(format)
87
+ end
88
+
89
+ # @return [Hash] The serialized component.
90
+ def serialize
91
+ {
92
+ type: Defaults::TYPE,
93
+ format:,
94
+ text:,
95
+ example:,
96
+ }.compact
97
+ end
98
+
99
+ private
100
+
101
+ # Meta's docs are inconsistent about enum casing; accept either and emit
102
+ # uppercase, which is what responses come back as.
103
+ # @return [String, nil]
104
+ def normalize_format(value)
105
+ return if value.nil?
106
+
107
+ candidate = value.to_s.upcase
108
+ Formats::ALL.include?(candidate) ? candidate : value.to_s
109
+ end
110
+
111
+ # Builds the example payload from whichever input the caller used.
112
+ # @return [Hash, nil]
113
+ def build_example(value)
114
+ return { header_handle: Array(header_handle) } unless blank_value?(header_handle)
115
+
116
+ build_example_payload(role: :header, values: value)
117
+ end
118
+
119
+ # @return [void]
120
+ def validate_text_shape
121
+ return unless format == Formats::TEXT
122
+
123
+ reject_unsupported(:header_handle, "cannot be set on a #{Formats::TEXT} header")
124
+ validate_text_presence_and_length
125
+ validate_text_variables
126
+ validate_placeholder_style(text)
127
+ validate_no_markdown
128
+ end
129
+
130
+ # @return [void]
131
+ def validate_text_presence_and_length
132
+ return errors.add(:text, "can't be blank") if blank_value?(text)
133
+ return if text.length <= Defaults::MAX_TEXT_LENGTH
134
+
135
+ errors.add(:text, "is too long (maximum is #{Defaults::MAX_TEXT_LENGTH} characters)")
136
+ end
137
+
138
+ # A text header supports at most one placeholder, and needs an example for it.
139
+ # @return [void]
140
+ def validate_text_variables
141
+ count = Placeholders.count(text)
142
+ return if count.zero?
143
+
144
+ if count > Defaults::MAX_VARIABLES
145
+ errors.add(:text, "supports at most one variable, found #{count}")
146
+ return
147
+ end
148
+
149
+ errors.add(:example, "can't be blank when the header text contains a variable") if blank_value?(example)
150
+ end
151
+
152
+ # Checks only the literal text, with placeholders removed first.
153
+ #
154
+ # Named placeholders are lowercase-and-underscores by definition, so scanning
155
+ # the raw text would reject `{{sale_start_date}}` for containing an underscore
156
+ # and make named parameters unusable in a text header.
157
+ # @return [void]
158
+ def validate_no_markdown
159
+ return if blank_value?(text)
160
+
161
+ literal = text.gsub(Placeholders::PATTERN, "")
162
+ found = MARKDOWN_CHARACTERS.select { |char| literal.include?(char) }
163
+ return if found.empty?
164
+
165
+ errors.add(:text, "cannot contain Markdown characters (found #{found.join(' ')})")
166
+ end
167
+
168
+ # @return [void]
169
+ def validate_media_shape
170
+ return unless media?
171
+
172
+ reject_unsupported(:text, "cannot be set on a #{format} header")
173
+ return unless blank_value?(example)
174
+
175
+ errors.add(:header_handle, "can't be blank for a #{format} header")
176
+ end
177
+
178
+ # @return [void]
179
+ def validate_location_shape
180
+ return unless format == Formats::LOCATION
181
+
182
+ reject_unsupported(:text, "cannot be set on a #{Formats::LOCATION} header")
183
+ reject_unsupported(:header_handle, "cannot be set on a #{Formats::LOCATION} header")
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ class MessageTemplates
5
+ class Component
6
+ # The offer banner that turns a marketing template into a limited-time offer,
7
+ # optionally with a live countdown timer.
8
+ #
9
+ # Its presence changes the rules for the rest of the template: a footer becomes
10
+ # forbidden, the body drops to 600 characters, the header narrows to image or
11
+ # video, and the category must be MARKETING. Those are all cross-component rules,
12
+ # so {Template} enforces them.
13
+ #
14
+ # The expiry timestamp itself is *not* set here — it is supplied per message when
15
+ # sending, as `expiration_time_ms`. This component only declares that the
16
+ # template has one.
17
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/marketing-templates/limited-time-offer-templates
18
+ class LimitedTimeOffer < Base
19
+ module Defaults
20
+ TYPE = "LIMITED_TIME_OFFER"
21
+ MAX_TEXT_LENGTH = 16
22
+ end
23
+
24
+ # @!attribute [rw] text
25
+ # @return [String]
26
+ attr_accessor :text
27
+
28
+ # @!attribute [rw] has_expiration
29
+ # @return [Boolean, nil]
30
+ attr_accessor :has_expiration
31
+
32
+ validates :text, presence: true, length: { maximum: Defaults::MAX_TEXT_LENGTH }
33
+
34
+ # @param text [String] The offer label (max 16 characters).
35
+ # @param has_expiration [Boolean, nil] Whether to show a countdown timer.
36
+ # @param kwargs [Hash] Forwarded to {Base} (`parameter_format`).
37
+ # @raise [ActiveModel::ValidationError] if validation fails.
38
+ def initialize(text:, has_expiration: nil, **)
39
+ super(**)
40
+
41
+ @text = text
42
+ @has_expiration = has_expiration
43
+
44
+ validate!
45
+ end
46
+
47
+ # @return [Hash] The serialized component.
48
+ def serialize
49
+ {
50
+ type: Defaults::TYPE,
51
+ limited_time_offer: { text:, has_expiration: }.compact,
52
+ }
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ class MessageTemplates
5
+ # Resolves a component 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 {Button::TYPES}.
10
+ #
11
+ # Only the component types with a published schema are registered. Meta's Graph API
12
+ # enum also lists GREETING, ALBUM, CALL_PERMISSION_REQUEST, TAP_TARGET_CONFIGURATION
13
+ # and ATTACHMENT, but publishes no field reference for them, so they are
14
+ # deliberately absent rather than guessed.
15
+ #
16
+ # Note that a component validates only *itself* here. Rules that span components —
17
+ # a footer being forbidden alongside a limited-time offer, a carousel implying the
18
+ # MARKETING category — live on {Template}, which is the only object that can see
19
+ # all the siblings.
20
+ # Source: https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/components/
21
+ class Component
22
+ TYPES = {
23
+ header: Header,
24
+ body: Body,
25
+ footer: Footer,
26
+ buttons: Buttons,
27
+ carousel: Carousel,
28
+ limited_time_offer: LimitedTimeOffer,
29
+ }.freeze
30
+
31
+ class << self
32
+ # @param type [Symbol, String] The component kind, in either casing
33
+ # (`:body`, `"body"`, or Meta's `"BODY"`).
34
+ # @param attrs [Hash] Forwarded to the resolved component class, including the
35
+ # owning template's `parameter_format`.
36
+ # @return [Component::Base]
37
+ # @raise [TemplateError] if the type is unknown.
38
+ # @raise [ActiveModel::ValidationError] if the component is invalid.
39
+ def build(type:, **attrs)
40
+ klass = TYPES.fetch(normalize(type)) do
41
+ raise TemplateError,
42
+ "Unknown component type: #{type.inspect}. Known types: #{TYPES.keys.join(', ')}"
43
+ end
44
+
45
+ klass.new(**attrs)
46
+ end
47
+
48
+ # Builds and serializes in one step.
49
+ # @return [Hash]
50
+ def serialize(type:, **attrs)
51
+ build(type:, **attrs).serialize
52
+ end
53
+
54
+ # @param type [Symbol, String]
55
+ # @return [Symbol] The registry key for the given type.
56
+ def normalize(type)
57
+ type.to_s.downcase.to_sym
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ class MessageTemplates
5
+ # A template's components, plus every rule that spans more than one of them.
6
+ #
7
+ # This is the other half of the split described in {Component}: a component class
8
+ # validates itself, and everything about how components *relate* lives here. That
9
+ # keeps rules like "a footer is forbidden alongside a limited-time offer" in one
10
+ # place instead of duplicated across the sibling classes that would each only see
11
+ # half the picture.
12
+ #
13
+ # It is a separate object from {Template} because editing a template replaces its
14
+ # components wholesale without re-supplying a name or language, so `update` needs
15
+ # exactly these checks and none of the template-identity ones.
16
+ #
17
+ # `category` is optional for that reason: when it is absent — as when editing — the
18
+ # category-dependent rules are skipped rather than guessed at.
19
+ class ComponentSet
20
+ include ValueObject
21
+
22
+ module Defaults
23
+ # A limited-time-offer template caps the body well below the usual 1024.
24
+ LIMITED_TIME_OFFER_MAX_BODY_LENGTH = 600
25
+ # ...and its copy-code button below the usual 20.
26
+ LIMITED_TIME_OFFER_MAX_COPY_CODE_LENGTH = 15
27
+ end
28
+
29
+ # Wire types that may appear at most once in a template.
30
+ SINGLETON_TYPES = [
31
+ Component::Header::Defaults::TYPE,
32
+ Component::Footer::Defaults::TYPE,
33
+ Component::Buttons::Defaults::TYPE,
34
+ Component::Carousel::Defaults::TYPE,
35
+ Component::LimitedTimeOffer::Defaults::TYPE,
36
+ ].freeze
37
+
38
+ # Components restricted to a subset of categories, and which ones.
39
+ CATEGORY_RESTRICTED_TYPES = {
40
+ Component::Carousel::Defaults::TYPE => [Categories::MARKETING].freeze,
41
+ Component::LimitedTimeOffer::Defaults::TYPE => [Categories::MARKETING].freeze,
42
+ }.freeze
43
+
44
+ # Header formats a limited-time-offer template may use.
45
+ LIMITED_TIME_OFFER_HEADER_FORMATS = [
46
+ Component::Header::Formats::IMAGE,
47
+ Component::Header::Formats::VIDEO,
48
+ ].freeze
49
+
50
+ # Categories that may use a location header.
51
+ LOCATION_HEADER_CATEGORIES = [Categories::UTILITY, Categories::MARKETING].freeze
52
+
53
+ # @!attribute [rw] components
54
+ # @return [Array<Component::Base>]
55
+ attr_accessor :components
56
+
57
+ # @!attribute [rw] category
58
+ # @return [String, nil]
59
+ attr_accessor :category
60
+
61
+ # @!attribute [rw] parameter_format
62
+ # @return [String]
63
+ attr_accessor :parameter_format
64
+
65
+ validates :components, presence: true
66
+ validate :validate_body_count
67
+ validate :validate_singletons
68
+ validate :validate_category_restrictions
69
+ validate :validate_location_header
70
+ validate :validate_limited_time_offer
71
+
72
+ # @param components [Array<Hash, Component::Base>] The template's components.
73
+ # @param category [String, Symbol, nil] The owning template's category. Omit to
74
+ # skip the category-dependent rules.
75
+ # @param parameter_format [String, Symbol] The placeholder style, threaded into
76
+ # every component.
77
+ # @raise [ActiveModel::ValidationError] if validation fails.
78
+ # @raise [TemplateError] if a component type is unknown.
79
+ def initialize(components:, category: nil, parameter_format: ParameterFormats::POSITIONAL)
80
+ @category = Categories.normalize(category)
81
+ @parameter_format = ParameterFormats.normalize(parameter_format)
82
+ @components = build_components(components)
83
+
84
+ validate!
85
+ end
86
+
87
+ # @param api_type [String] A component wire type, e.g. `"BODY"`.
88
+ # @return [Component::Base, nil]
89
+ def find(api_type)
90
+ components.find { |component| component.api_type == api_type }
91
+ end
92
+
93
+ # @return [Array<String>] The wire types present, in declared order.
94
+ def api_types
95
+ components.map(&:api_type)
96
+ end
97
+
98
+ # @return [Array<Hash>] The serialized components.
99
+ def serialize
100
+ components.map(&:serialize)
101
+ end
102
+
103
+ private
104
+
105
+ # @return [Array<Component::Base>]
106
+ def build_components(list)
107
+ Array(list).map do |component|
108
+ next component if component.is_a?(Component::Base)
109
+
110
+ Component.build(**component, parameter_format:)
111
+ end
112
+ end
113
+
114
+ # @return [void]
115
+ def validate_body_count
116
+ return if api_types.count(Component::Body::Defaults::TYPE) == 1
117
+
118
+ errors.add(:components, "requires exactly one BODY component")
119
+ end
120
+
121
+ # @return [void]
122
+ def validate_singletons
123
+ SINGLETON_TYPES.each do |type|
124
+ next if api_types.count(type) <= 1
125
+
126
+ errors.add(:components, "allow at most one #{type} component")
127
+ end
128
+ end
129
+
130
+ # @return [void]
131
+ def validate_category_restrictions
132
+ return if category.nil?
133
+
134
+ CATEGORY_RESTRICTED_TYPES.each do |type, allowed|
135
+ next unless api_types.include?(type)
136
+ next if allowed.include?(category)
137
+
138
+ errors.add(:components, "#{type} is only supported on #{allowed.join('/')} templates, not #{category}")
139
+ end
140
+ end
141
+
142
+ # @return [void]
143
+ def validate_location_header
144
+ return if category.nil?
145
+
146
+ header = find(Component::Header::Defaults::TYPE)
147
+ return unless header&.format == Component::Header::Formats::LOCATION
148
+ return if LOCATION_HEADER_CATEGORIES.include?(category)
149
+
150
+ errors.add(:components,
151
+ "a LOCATION header is only supported on #{LOCATION_HEADER_CATEGORIES.join('/')} templates, not #{category}")
152
+ end
153
+
154
+ # A limited-time offer reshapes the rest of the template: no footer, a shorter
155
+ # body, a media-only header, and a copy-code button that must come first.
156
+ # @return [void]
157
+ def validate_limited_time_offer
158
+ return unless api_types.include?(Component::LimitedTimeOffer::Defaults::TYPE)
159
+
160
+ validate_offer_has_no_footer
161
+ validate_offer_body_length
162
+ validate_offer_header_format
163
+ validate_offer_buttons
164
+ end
165
+
166
+ # @return [void]
167
+ def validate_offer_has_no_footer
168
+ return unless api_types.include?(Component::Footer::Defaults::TYPE)
169
+
170
+ errors.add(:components, "a FOOTER is not allowed in a LIMITED_TIME_OFFER template")
171
+ end
172
+
173
+ # @return [void]
174
+ def validate_offer_body_length
175
+ text = find(Component::Body::Defaults::TYPE)&.text
176
+ return if text.nil? || text.length <= Defaults::LIMITED_TIME_OFFER_MAX_BODY_LENGTH
177
+
178
+ errors.add(:components,
179
+ "the BODY of a LIMITED_TIME_OFFER template is limited to " \
180
+ "#{Defaults::LIMITED_TIME_OFFER_MAX_BODY_LENGTH} characters")
181
+ end
182
+
183
+ # @return [void]
184
+ def validate_offer_header_format
185
+ header = find(Component::Header::Defaults::TYPE)
186
+ return if header.nil? || LIMITED_TIME_OFFER_HEADER_FORMATS.include?(header.format)
187
+
188
+ errors.add(:components,
189
+ "the header must be IMAGE or VIDEO in a LIMITED_TIME_OFFER template, got #{header.format}")
190
+ end
191
+
192
+ # @return [void]
193
+ def validate_offer_buttons
194
+ buttons = find(Component::Buttons::Defaults::TYPE)&.buttons
195
+ return if buttons.nil?
196
+
197
+ copy_code = buttons.find { |button| button.is_a?(Button::CopyCode) }
198
+ return if copy_code.nil?
199
+
200
+ validate_offer_copy_code_position(buttons, copy_code)
201
+ validate_offer_copy_code_length(copy_code)
202
+ end
203
+
204
+ # @return [void]
205
+ def validate_offer_copy_code_position(buttons, copy_code)
206
+ return if buttons.index(copy_code).zero?
207
+
208
+ errors.add(:components, "the copy-code button must be first in a LIMITED_TIME_OFFER template")
209
+ end
210
+
211
+ # @return [void]
212
+ def validate_offer_copy_code_length(copy_code)
213
+ maximum = Defaults::LIMITED_TIME_OFFER_MAX_COPY_CODE_LENGTH
214
+ return if copy_code.example.to_s.length <= maximum
215
+
216
+ errors.add(:components,
217
+ "the copy-code example is limited to #{maximum} characters in a LIMITED_TIME_OFFER template")
218
+ end
219
+ end
220
+ end
221
+ end