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,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ class MessageTemplates
5
+ # The payload for creating a template, and for the multi-language upsert form.
6
+ #
7
+ # Owns the template's *identity* — name, language, category and the flags that
8
+ # apply to the whole template. Everything about the components, including the rules
9
+ # that span several of them, is delegated to {ComponentSet}; this class only passes
10
+ # the category and parameter format down so those rules can be applied.
11
+ #
12
+ # Supports two language forms, which is why they are validated as an exclusive
13
+ # choice: a single `language` for {MessageTemplates#create}, or a `languages` array
14
+ # for {MessageTemplates#upsert}, which creates or updates the same template across
15
+ # several locales in one call.
16
+ #
17
+ # Cloning a pre-written template from Meta's library is a different payload with no
18
+ # components at all — see {LibraryTemplate}.
19
+ # Source: https://developers.facebook.com/docs/graph-api/reference/whats-app-business-account/message_templates/
20
+ class Template
21
+ include ValueObject
22
+
23
+ module Defaults
24
+ # Meta accepts lowercase alphanumerics and underscores, nothing else.
25
+ NAME_PATTERN = /\A[a-z0-9_]+\z/
26
+ MAX_NAME_LENGTH = 512
27
+ end
28
+
29
+ # Refinements Meta recognises for order-related utility templates.
30
+ module SubCategories
31
+ ORDER_DETAILS = "ORDER_DETAILS"
32
+ ORDER_STATUS = "ORDER_STATUS"
33
+ RICH_ORDER_STATUS = "RICH_ORDER_STATUS"
34
+
35
+ ALL = [ORDER_DETAILS, ORDER_STATUS, RICH_ORDER_STATUS].freeze
36
+ end
37
+
38
+ # @!attribute [rw] name
39
+ # @return [String]
40
+ attr_accessor :name
41
+
42
+ # @!attribute [rw] language
43
+ # @return [String, nil] A single locale code, for create.
44
+ attr_accessor :language
45
+
46
+ # @!attribute [rw] languages
47
+ # @return [Array<String>, nil] Several locale codes, for upsert.
48
+ attr_accessor :languages
49
+
50
+ # @!attribute [rw] category
51
+ # @return [String]
52
+ attr_accessor :category
53
+
54
+ # @!attribute [rw] parameter_format
55
+ # @return [String]
56
+ attr_accessor :parameter_format
57
+
58
+ # @!attribute [rw] component_set
59
+ # @return [ComponentSet]
60
+ attr_accessor :component_set
61
+
62
+ # @!attribute [rw] sub_category
63
+ # @return [String, nil]
64
+ attr_accessor :sub_category
65
+
66
+ # @!attribute [rw] message_send_ttl_seconds
67
+ # @return [Integer, nil]
68
+ attr_accessor :message_send_ttl_seconds
69
+
70
+ # @!attribute [rw] allow_category_change
71
+ # Effectively a no-op since 2025-04-09, when Meta made automatic
72
+ # recategorisation the default regardless of this flag. Kept because it is
73
+ # still a documented parameter.
74
+ # @return [Boolean, nil]
75
+ attr_accessor :allow_category_change
76
+
77
+ # @!attribute [rw] cta_url_link_tracking_opted_out
78
+ # @return [Boolean, nil]
79
+ attr_accessor :cta_url_link_tracking_opted_out
80
+
81
+ validates :name, presence: true,
82
+ format: { with: Defaults::NAME_PATTERN,
83
+ message: "must contain only lowercase alphanumeric characters and underscores", },
84
+ length: { maximum: Defaults::MAX_NAME_LENGTH }
85
+ validates :category, presence: true, inclusion: { in: Categories::ALL }
86
+ validates :parameter_format, inclusion: { in: ParameterFormats::ALL }
87
+ validates :sub_category, inclusion: { in: SubCategories::ALL }, allow_nil: true
88
+ validate :validate_language_choice
89
+ validate :validate_language_codes
90
+ validate :validate_message_send_ttl
91
+
92
+ # @param name [String] The template name; lowercase alphanumerics and underscores.
93
+ # @param category [String, Symbol] One of {Categories::ALL}.
94
+ # @param components [Array<Hash, Component::Base>] The template's components.
95
+ # @param language [String, nil] A single locale code. Mutually exclusive with
96
+ # `languages`.
97
+ # @param languages [Array<String>, nil] Locale codes, for the upsert form.
98
+ # @param parameter_format [String, Symbol] `POSITIONAL` (default) or `NAMED`.
99
+ # @param sub_category [String, nil] One of {SubCategories::ALL}.
100
+ # @param message_send_ttl_seconds [Integer, nil] Delivery-retry TTL override.
101
+ # @param allow_category_change [Boolean, nil] See the attribute note.
102
+ # @param cta_url_link_tracking_opted_out [Boolean, nil] Opt out of link tracking.
103
+ # @raise [ActiveModel::ValidationError] if validation fails.
104
+ # @raise [TemplateError] if a component or button type is unknown.
105
+ def initialize(name:, category:, components:, language: nil, languages: nil,
106
+ parameter_format: ParameterFormats::POSITIONAL, sub_category: nil,
107
+ message_send_ttl_seconds: nil, allow_category_change: nil,
108
+ cta_url_link_tracking_opted_out: nil)
109
+ @name = name
110
+ @language = language
111
+ @languages = languages
112
+ @category = Categories.normalize(category)
113
+ @parameter_format = ParameterFormats.normalize(parameter_format)
114
+ @sub_category = sub_category
115
+ @message_send_ttl_seconds = message_send_ttl_seconds
116
+ @allow_category_change = allow_category_change
117
+ @cta_url_link_tracking_opted_out = cta_url_link_tracking_opted_out
118
+ @component_set = ComponentSet.new(components:, category: @category, parameter_format: @parameter_format)
119
+
120
+ validate!
121
+ end
122
+
123
+ # @return [Array<Component::Base>]
124
+ def components
125
+ component_set.components
126
+ end
127
+
128
+ # @return [Hash] The create/upsert payload.
129
+ def serialize
130
+ {
131
+ name:,
132
+ **language_payload,
133
+ category:,
134
+ parameter_format:,
135
+ components: component_set.serialize,
136
+ sub_category:,
137
+ message_send_ttl_seconds:,
138
+ allow_category_change:,
139
+ cta_url_link_tracking_opted_out:,
140
+ }.compact
141
+ end
142
+
143
+ private
144
+
145
+ # @return [Hash] Either `language` or `languages`, never both.
146
+ def language_payload
147
+ return { languages: } unless blank_value?(languages)
148
+
149
+ { language: }
150
+ end
151
+
152
+ # @return [void]
153
+ def validate_language_choice
154
+ single = !blank_value?(language)
155
+ multiple = !blank_value?(languages)
156
+
157
+ return errors.add(:base, "language and languages cannot be combined") if single && multiple
158
+ return if single || multiple
159
+
160
+ errors.add(:base, "requires either language or languages")
161
+ end
162
+
163
+ # @return [void]
164
+ def validate_language_codes
165
+ codes = blank_value?(languages) ? [language] : Array(languages)
166
+
167
+ codes.compact.reject { |code| Utils::LanguageCodes.valid?(code) }.each do |code|
168
+ errors.add(:language, "#{code.inspect} is not a valid WhatsApp language code")
169
+ end
170
+ end
171
+
172
+ # @return [void]
173
+ def validate_message_send_ttl
174
+ return if message_send_ttl_seconds.nil? || message_send_ttl_seconds.is_a?(Integer)
175
+
176
+ errors.add(:message_send_ttl_seconds, "must be an integer number of seconds")
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ class MessageTemplates
5
+ # Shared behaviour for the nested value objects that make up a template payload:
6
+ # components, buttons, carousel cards, and library inputs.
7
+ #
8
+ # Pulls together the three things every one of them needs — ActiveModel
9
+ # validations, the class-level `.serialize` shorthand used throughout this gem,
10
+ # and a couple of validation helpers — so they are declared once rather than
11
+ # re-stated in each `Base`.
12
+ module ValueObject
13
+ # @param base [Class]
14
+ # @return [void]
15
+ def self.included(base)
16
+ base.include(ActiveModel::Validations)
17
+ base.extend(ClassMethods)
18
+ end
19
+
20
+ module ClassMethods
21
+ # Builds an instance and serializes it in one step.
22
+ # @return [Hash]
23
+ def serialize(**)
24
+ new(**).serialize
25
+ end
26
+ end
27
+
28
+ # @return [Hash] The API payload for this object.
29
+ def serialize
30
+ raise NotImplementedError, "Subclasses must implement the serialize method"
31
+ end
32
+
33
+ protected
34
+
35
+ # Plain-Ruby blank check, spelled out because the rest of this gem avoids
36
+ # ActiveSupport's core extensions.
37
+ #
38
+ # Deliberately *not* named `blank?`: ActiveModel's presence validator calls
39
+ # `value.blank?` on attribute values, so defining a one-argument `blank?` here
40
+ # would shadow `Object#blank?` on every component and make
41
+ # `validates :header, presence: true` raise instead of validate.
42
+ # @param value [Object]
43
+ # @return [Boolean]
44
+ def blank_value?(value)
45
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
46
+ end
47
+
48
+ # Records an error for a field Meta does not accept at template creation time.
49
+ #
50
+ # Used where Meta supplies the value itself (OTP and copy-code button labels) or
51
+ # where the field is meaningless for the chosen shape (text on a location
52
+ # header): silently dropping a caller's value would be worse than saying why it
53
+ # is not allowed.
54
+ # @param field [Symbol]
55
+ # @param reason [String]
56
+ # @return [void]
57
+ def reject_unsupported(field, reason)
58
+ errors.add(field, reason) unless blank_value?(public_send(field))
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ # Manages the message templates on a WhatsApp Business Account: create, list, read,
5
+ # edit, and delete.
6
+ #
7
+ # This is the opposite side of {Whatsapp::Messages::Template}, which *sends* an
8
+ # already-approved template. The two are separate APIs with incompatible payload
9
+ # schemas — different endpoint, different ID (`waba_id`, not `phone_id`), different
10
+ # permission (`whatsapp_business_management`), and components that carry `text` plus
11
+ # an `example` here versus `parameters` when sending. See
12
+ # `lib/ruby/whatsapp/message_templates/CLAUDE.md`.
13
+ #
14
+ # This class is only the transport: it builds paths, issues requests, and hands the
15
+ # bodies to {Response}. Every rule about what a valid template looks like lives in
16
+ # {Template} / {ComponentSet} / {Component} / {Button}, so nothing here needs to know
17
+ # what a carousel is.
18
+ #
19
+ # @example
20
+ # templates = Whatsapp::MessageTemplates.new
21
+ # created = templates.create(
22
+ # name: "order_confirmation", language: "en_US", category: "UTILITY",
23
+ # components: [{ type: :body, text: "Thanks, {{1}}!", example: ["Pablo"] }]
24
+ # )
25
+ # templates.find(template_id: created.id).status # => "PENDING"
26
+ # Source: https://developers.facebook.com/docs/graph-api/reference/whats-app-business-account/message_templates/
27
+ class MessageTemplates
28
+ include ResponseHandling
29
+
30
+ class TemplateError < Whatsapp::Error; end
31
+
32
+ # The API edges these operations address.
33
+ module Edges
34
+ MESSAGE_TEMPLATES = "message_templates"
35
+ UPSERT_MESSAGE_TEMPLATES = "upsert_message_templates"
36
+ end
37
+
38
+ module Defaults
39
+ # Meta's cap on a single bulk delete.
40
+ MAX_DELETE_IDS = 100
41
+ # Filters whose value is a list of names rather than a JSON array.
42
+ COMMA_JOINED_FILTERS = [:fields].freeze
43
+ end
44
+
45
+ # @!attribute [rw] client
46
+ # @return [Whatsapp::Client]
47
+ attr_accessor :client
48
+
49
+ # @param client [Whatsapp::Client] The WhatsApp client instance.
50
+ def initialize(client: Client.new)
51
+ @client = client
52
+ end
53
+
54
+ # Creates a template.
55
+ #
56
+ # Validation happens before the request, so an invalid template costs nothing. The
57
+ # response `status` is usually PENDING — Meta reviews templates asynchronously and
58
+ # reports the outcome via the `message_template_status_update` webhook.
59
+ # @param attrs [Hash] Forwarded to {Template} (`name:`, `language:`, `category:`,
60
+ # `components:`, and the optional fields).
61
+ # @return [Response::Created]
62
+ # @raise [ActiveModel::ValidationError] if the template is invalid.
63
+ # @raise [TemplateError] if the request fails.
64
+ def create(**attrs)
65
+ created_from(edge_path(Edges::MESSAGE_TEMPLATES), Template.new(**attrs), action: "create template")
66
+ end
67
+
68
+ # Creates a template by cloning one of Meta's pre-written library templates.
69
+ #
70
+ # Uses the same edge as {#create} but a different payload — no components, just the
71
+ # library template's name and the inputs that customise it. Library templates are
72
+ # pre-categorised and pre-reviewed, so the response is usually APPROVED immediately.
73
+ # @param attrs [Hash] Forwarded to {LibraryTemplate}.
74
+ # @return [Response::Created]
75
+ # @raise [ActiveModel::ValidationError] if the payload is invalid.
76
+ # @raise [TemplateError] if the request fails.
77
+ def create_from_library(**attrs)
78
+ created_from(
79
+ edge_path(Edges::MESSAGE_TEMPLATES), LibraryTemplate.new(**attrs),
80
+ action: "create template from library"
81
+ )
82
+ end
83
+
84
+ # Creates or updates the same template across several languages in one call.
85
+ #
86
+ # Matching is on `(name, language)`: an existing pair is updated, a missing one is
87
+ # created. Primarily documented for authentication templates.
88
+ # @param attrs [Hash] Forwarded to {Template}; must include `languages:`.
89
+ # @return [Response::Created]
90
+ # @raise [TemplateError] if `languages:` is missing or the request fails.
91
+ # @raise [ActiveModel::ValidationError] if the template is invalid.
92
+ def upsert(**attrs)
93
+ if blank_value?(attrs[:languages])
94
+ raise TemplateError, "#upsert requires `languages:` (an array of locale codes); use #create for a single one"
95
+ end
96
+
97
+ created_from(
98
+ edge_path(Edges::UPSERT_MESSAGE_TEMPLATES), Template.new(**attrs), action: "upsert templates"
99
+ )
100
+ end
101
+
102
+ # Lists the account's templates.
103
+ #
104
+ # @param filters [Hash] Any documented filter: `name`, `name_or_content`, `content`,
105
+ # `language`, `category`, `status`, `quality_score`, `since`, `until`, `fields`,
106
+ # `limit`, `after`, `before`. Array values are encoded for you.
107
+ # @return [Response::Collection] Enumerable over the templates on this page.
108
+ # @raise [TemplateError] if the request fails.
109
+ def list(**filters)
110
+ response = client.connection.get(edge_path(Edges::MESSAGE_TEMPLATES), params: encode_filters(filters))
111
+
112
+ Response::Collection.deserialize(
113
+ parse_json(handle_response!(response, error_class: TemplateError, action: "list templates"))
114
+ )
115
+ end
116
+
117
+ # Reads a single template by ID.
118
+ # @param template_id [String] The template's numeric ID.
119
+ # @param fields [Array<String>, String, nil] Restrict the response to these fields.
120
+ # @return [Response::Node]
121
+ # @raise [TemplateError] if the id is missing or the request fails.
122
+ def find(template_id:, fields: nil)
123
+ raise TemplateError, "template_id can't be blank" if blank_value?(template_id)
124
+
125
+ response = client.connection.get(
126
+ client.path_for(template_id), params: encode_filters(fields:)
127
+ )
128
+
129
+ Response::Node.deserialize(
130
+ parse_json(handle_response!(response, error_class: TemplateError, action: "find template"))
131
+ )
132
+ end
133
+
134
+ # Edits a template.
135
+ #
136
+ # Only `category`, `components` and `message_send_ttl_seconds` are editable, and
137
+ # `components` is a **full replacement** — Meta has no way to patch one component.
138
+ # The edit goes to the template's own ID with POST; PUT and PATCH are not supported
139
+ # on this edge.
140
+ #
141
+ # Two limits cannot be checked locally, so they surface as API errors: only
142
+ # APPROVED, REJECTED and PAUSED templates may be edited (see {Response::Node#editable?}),
143
+ # and an approved template allows 10 edits per 30 days and 1 per 24 hours. Editing an
144
+ # approved template also re-submits it for review, though it keeps working meanwhile.
145
+ #
146
+ # @param template_id [String] The template's numeric ID.
147
+ # @param category [String, Symbol, nil] Cannot be changed on an APPROVED template.
148
+ # @param components [Array<Hash, Component::Base>, nil] The full replacement set.
149
+ # @param message_send_ttl_seconds [Integer, nil]
150
+ # @param parameter_format [String, Symbol] Used to build and validate `components`
151
+ # locally; not sent, since Meta does not list it as editable.
152
+ # @return [Boolean] Whether the API reported success.
153
+ # @raise [TemplateError] if there is nothing to update, or the request fails.
154
+ # @raise [ActiveModel::ValidationError] if the replacement components are invalid.
155
+ def update(template_id:, category: nil, components: nil, message_send_ttl_seconds: nil,
156
+ parameter_format: ParameterFormats::POSITIONAL)
157
+ raise TemplateError, "template_id can't be blank" if blank_value?(template_id)
158
+
159
+ payload = update_payload(category:, components:, message_send_ttl_seconds:, parameter_format:)
160
+ raise TemplateError, "nothing to update: pass category, components or message_send_ttl_seconds" if payload.empty?
161
+
162
+ response = client.connection.post(client.path_for(template_id), json: payload)
163
+
164
+ success?(handle_response!(response, error_class: TemplateError, action: "update template"))
165
+ end
166
+
167
+ # Deletes templates.
168
+ #
169
+ # Three mutually exclusive ways to address them, and the choice matters:
170
+ #
171
+ # `name:` alone — deletes **every language variant** with that name
172
+ # `hsm_id:` — one template; Meta's docs pass `name:` alongside it
173
+ # `hsm_ids:` — up to 100 templates, and cannot be mixed with the other two
174
+ #
175
+ # Deleting an approved template blocks reuse of its name for 30 days, and messages
176
+ # already in flight get a 30-day delivery window under the PENDING_DELETION status.
177
+ # DISABLED templates cannot be deleted at all.
178
+ #
179
+ # @param name [String, nil] The template name.
180
+ # @param hsm_id [String, nil] A single template ID.
181
+ # @param hsm_ids [Array<String>, nil] Up to 100 template IDs.
182
+ # @return [Boolean] Whether the API reported success.
183
+ # @raise [TemplateError] if the arguments are unusable, or the request fails.
184
+ def delete(name: nil, hsm_id: nil, hsm_ids: nil)
185
+ params = delete_params(name:, hsm_id:, hsm_ids:)
186
+
187
+ response = client.connection.delete(edge_path(Edges::MESSAGE_TEMPLATES), params:)
188
+
189
+ success?(handle_response!(response, error_class: TemplateError, action: "delete template"))
190
+ end
191
+
192
+ private
193
+
194
+ # Posts a serialized payload and deserializes the create-style response.
195
+ # @return [Response::Created]
196
+ def created_from(path, payload, action:)
197
+ response = client.connection.post(path, json: payload.serialize)
198
+
199
+ Response::Created.deserialize(
200
+ parse_json(handle_response!(response, error_class: TemplateError, action:))
201
+ )
202
+ end
203
+
204
+ # @return [String] The versioned path for a WABA-scoped edge.
205
+ # @raise [TemplateError] if no WABA ID is configured.
206
+ def edge_path(edge)
207
+ if blank_value?(client.waba_id)
208
+ raise TemplateError,
209
+ "waba_id is required for template management; set it via Whatsapp.configure or Client.new(waba_id:)"
210
+ end
211
+
212
+ client.path_for(client.waba_id, edge)
213
+ end
214
+
215
+ # @return [Hash] Only the editable fields that were supplied.
216
+ def update_payload(category:, components:, message_send_ttl_seconds:, parameter_format:)
217
+ {
218
+ category: Categories.normalize(category),
219
+ components: components && ComponentSet.new(
220
+ components:, category: Categories.normalize(category), parameter_format:
221
+ ).serialize,
222
+ message_send_ttl_seconds:,
223
+ }.compact
224
+ end
225
+
226
+ # @return [Hash] The delete query parameters.
227
+ # @raise [TemplateError] if the addressing modes are unusable.
228
+ def delete_params(name:, hsm_id:, hsm_ids:)
229
+ return single_delete_params(name:, hsm_id:) if blank_value?(hsm_ids)
230
+
231
+ unless blank_value?(name) && blank_value?(hsm_id)
232
+ raise TemplateError, "hsm_ids cannot be combined with name or hsm_id"
233
+ end
234
+
235
+ if hsm_ids.size > Defaults::MAX_DELETE_IDS
236
+ raise TemplateError, "#delete accepts at most #{Defaults::MAX_DELETE_IDS} hsm_ids, got #{hsm_ids.size}"
237
+ end
238
+
239
+ # Meta documents this as `hsm_ids=[123,456]` — bare numbers, not a quoted
240
+ # JSON array of strings.
241
+ { hsm_ids: "[#{hsm_ids.join(',')}]" }
242
+ end
243
+
244
+ # @return [Hash]
245
+ # @raise [TemplateError] if neither addressing field was given.
246
+ def single_delete_params(name:, hsm_id:)
247
+ raise TemplateError, "#delete requires name, hsm_id or hsm_ids" if blank_value?(name) && blank_value?(hsm_id)
248
+
249
+ { name:, hsm_id: }.compact
250
+ end
251
+
252
+ # Graph API wants array filters as JSON arrays in the query string, not the
253
+ # `key[]=value` form an HTTP library would produce by default. `fields` is the
254
+ # exception: it is a comma-separated list.
255
+ # @return [Hash]
256
+ def encode_filters(filters)
257
+ filters.compact.to_h do |key, value|
258
+ [key, encode_filter_value(key, value)]
259
+ end
260
+ end
261
+
262
+ # @return [Object]
263
+ def encode_filter_value(key, value)
264
+ return value unless value.is_a?(Array)
265
+ return value.join(",") if Defaults::COMMA_JOINED_FILTERS.include?(key)
266
+
267
+ value.to_json
268
+ end
269
+
270
+ # @return [Boolean] The `{ "success": true }` flag, as {Whatsapp::Media#delete} does.
271
+ def success?(response)
272
+ parsed = parse_json(response)
273
+
274
+ parsed.is_a?(Hash) && parsed["success"] == true
275
+ end
276
+
277
+ # Plain-Ruby blank check; see the note in {ValueObject#blank_value?}.
278
+ # @return [Boolean]
279
+ def blank_value?(value)
280
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
281
+ end
282
+ end
283
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ class Messages
5
+ # Address messages prompt a WhatsApp user to provide or confirm a delivery address.
6
+ # This feature is only available for businesses based in India (IN) and Singapore (SG).
7
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/address-messages
8
+ #
9
+ # Despite being a distinct message type in this library, address messages are sent
10
+ # over the wire as interactive messages (type: "interactive", interactive.type: "address_message").
11
+ class Address < Base
12
+ module Defaults
13
+ TYPE = "interactive"
14
+ INTERACTIVE_TYPE = "address_message"
15
+ ACTION_NAME = "address_message"
16
+ end
17
+
18
+ module Countries
19
+ IN = "IN"
20
+ SG = "SG"
21
+
22
+ ALL = [IN, SG].freeze
23
+ end
24
+
25
+ # @!attribute [rw] body
26
+ # @return [String] The message body text shown above the address form.
27
+ attr_accessor :body
28
+
29
+ # @!attribute [rw] footer
30
+ # @return [String, nil] Optional footer text.
31
+ attr_accessor :footer
32
+
33
+ # @!attribute [rw] country
34
+ # @return [String] Country code. One of IN (India) or SG (Singapore).
35
+ attr_accessor :country
36
+
37
+ # @!attribute [rw] values
38
+ # @return [Hash, nil] Pre-filled address field values shown in the form.
39
+ attr_accessor :values
40
+
41
+ # @!attribute [rw] saved_addresses
42
+ # @return [Array<Hash>] Previously saved addresses the user can select from.
43
+ attr_accessor :saved_addresses
44
+
45
+ validates :body, presence: true
46
+ validates :country, presence: true, inclusion: { in: Countries::ALL }
47
+
48
+ # @param body [String] The message body text.
49
+ # @param country [String] Country code — "IN" for India, "SG" for Singapore.
50
+ # @param footer [String, nil] Optional footer text.
51
+ # @param values [Hash, nil] Pre-filled address fields (keys vary by country).
52
+ # @param saved_addresses [Array<Hash>] Previously saved addresses.
53
+ # @param kwargs [Hash] Additional keyword arguments passed to Base (:to).
54
+ # @raise [ActiveModel::ValidationError] if validation fails.
55
+ def initialize(body:, country:, footer: nil, values: nil, saved_addresses: [], **)
56
+ super(**)
57
+
58
+ @body = body
59
+ @footer = footer
60
+ @country = country
61
+ @values = values
62
+ @saved_addresses = saved_addresses
63
+
64
+ validate!
65
+ end
66
+
67
+ # Serializes the address message to a hash format suitable for the WhatsApp API.
68
+ # @return [Hash] The serialized address message.
69
+ def serialize
70
+ envelope(type: Defaults::TYPE, interactive: interactive_payload)
71
+ end
72
+
73
+ private
74
+
75
+ # @return [Hash] The serialized interactive payload.
76
+ def interactive_payload
77
+ payload = {
78
+ type: Defaults::INTERACTIVE_TYPE,
79
+ body: { text: body },
80
+ action: action_payload,
81
+ }
82
+ payload[:footer] = { text: footer } if footer
83
+ payload
84
+ end
85
+
86
+ # @return [Hash] The serialized action payload.
87
+ def action_payload
88
+ {
89
+ name: Defaults::ACTION_NAME,
90
+ parameters: parameters_payload,
91
+ }
92
+ end
93
+
94
+ # @return [Hash] The serialized parameters payload.
95
+ def parameters_payload
96
+ params = { country: }
97
+ params[:values] = values if values
98
+ params[:saved_addresses] = saved_addresses if saved_addresses.any?
99
+ params
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ class Messages
5
+ # Audio messages allow you to send an audio file to a WhatsApp user.
6
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/audio-messages
7
+ #
8
+ # Audio messages do not support captions. To send audio you must either:
9
+ # - Upload the audio via Whatsapp::Media#upload to obtain a media_id, then pass it as `id`
10
+ # - Pass a publicly hosted URL as `link` (not recommended by Meta)
11
+ class Audio < Base
12
+ module Defaults
13
+ TYPE = "audio"
14
+ end
15
+
16
+ # @!attribute [rw] id
17
+ # @return [String, nil]
18
+ attr_accessor :id
19
+
20
+ # @!attribute [rw] link
21
+ # @return [String, nil]
22
+ attr_accessor :link
23
+
24
+ validate :validate_id_or_link
25
+
26
+ # @param id [String, nil] ID of the uploaded media asset (from Media#upload).
27
+ # @param link [String, nil] URL of the audio file hosted on a public server.
28
+ # @param kwargs [Hash] Additional keyword arguments passed to Base (:to).
29
+ # @raise [ActiveModel::ValidationError] if validation fails.
30
+ def initialize(id: nil, link: nil, **)
31
+ super(**)
32
+
33
+ @id = id
34
+ @link = link
35
+
36
+ validate!
37
+ end
38
+
39
+ # Serializes the audio message to a hash format suitable for the WhatsApp API.
40
+ # @return [Hash] The serialized audio message.
41
+ def serialize
42
+ envelope(type: Defaults::TYPE, audio: audio_payload)
43
+ end
44
+
45
+ private
46
+
47
+ # @return [Hash] The serialized audio payload.
48
+ def audio_payload
49
+ {
50
+ id:,
51
+ link:,
52
+ }.compact
53
+ end
54
+
55
+ # Validates that either id or link is present.
56
+ # @return [void]
57
+ def validate_id_or_link
58
+ return if id.present? || link.present?
59
+
60
+ errors.add(:base, "Either id or link must be present")
61
+ end
62
+ end
63
+ end
64
+ end