ruby-whatsapp 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +54 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +15 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +643 -0
- data/Rakefile +12 -0
- data/lib/ruby/whatsapp/client.rb +82 -0
- data/lib/ruby/whatsapp/configuration.rb +79 -0
- data/lib/ruby/whatsapp/instrumentation.rb +34 -0
- data/lib/ruby/whatsapp/media.rb +134 -0
- data/lib/ruby/whatsapp/message_templates/button/base.rb +26 -0
- data/lib/ruby/whatsapp/message_templates/button/copy_code.rb +59 -0
- data/lib/ruby/whatsapp/message_templates/button/otp/supported_app.rb +44 -0
- data/lib/ruby/whatsapp/message_templates/button/otp.rb +138 -0
- data/lib/ruby/whatsapp/message_templates/button/phone_number.rb +45 -0
- data/lib/ruby/whatsapp/message_templates/button/quick_reply.rb +38 -0
- data/lib/ruby/whatsapp/message_templates/button/url.rb +110 -0
- data/lib/ruby/whatsapp/message_templates/button.rb +56 -0
- data/lib/ruby/whatsapp/message_templates/categories.rb +47 -0
- data/lib/ruby/whatsapp/message_templates/component/base.rb +112 -0
- data/lib/ruby/whatsapp/message_templates/component/body.rb +139 -0
- data/lib/ruby/whatsapp/message_templates/component/buttons.rb +95 -0
- data/lib/ruby/whatsapp/message_templates/component/carousel/card.rb +100 -0
- data/lib/ruby/whatsapp/message_templates/component/carousel.rb +71 -0
- data/lib/ruby/whatsapp/message_templates/component/footer.rb +99 -0
- data/lib/ruby/whatsapp/message_templates/component/header.rb +188 -0
- data/lib/ruby/whatsapp/message_templates/component/limited_time_offer.rb +57 -0
- data/lib/ruby/whatsapp/message_templates/component.rb +62 -0
- data/lib/ruby/whatsapp/message_templates/component_set.rb +221 -0
- data/lib/ruby/whatsapp/message_templates/example.rb +148 -0
- data/lib/ruby/whatsapp/message_templates/library_template/body_inputs.rb +81 -0
- data/lib/ruby/whatsapp/message_templates/library_template/button_inputs.rb +146 -0
- data/lib/ruby/whatsapp/message_templates/library_template.rb +114 -0
- data/lib/ruby/whatsapp/message_templates/parameter_formats.rb +49 -0
- data/lib/ruby/whatsapp/message_templates/placeholders.rb +86 -0
- data/lib/ruby/whatsapp/message_templates/response/collection.rb +74 -0
- data/lib/ruby/whatsapp/message_templates/response/created.rb +65 -0
- data/lib/ruby/whatsapp/message_templates/response/node.rb +152 -0
- data/lib/ruby/whatsapp/message_templates/response/paging.rb +39 -0
- data/lib/ruby/whatsapp/message_templates/response/quality_score.rb +49 -0
- data/lib/ruby/whatsapp/message_templates/response/summary.rb +68 -0
- data/lib/ruby/whatsapp/message_templates/response.rb +22 -0
- data/lib/ruby/whatsapp/message_templates/statuses.rb +66 -0
- data/lib/ruby/whatsapp/message_templates/template.rb +180 -0
- data/lib/ruby/whatsapp/message_templates/value_object.rb +62 -0
- data/lib/ruby/whatsapp/message_templates.rb +283 -0
- data/lib/ruby/whatsapp/messages/address.rb +103 -0
- data/lib/ruby/whatsapp/messages/audio.rb +64 -0
- data/lib/ruby/whatsapp/messages/base.rb +46 -0
- data/lib/ruby/whatsapp/messages/contacts/address.rb +84 -0
- data/lib/ruby/whatsapp/messages/contacts/contact.rb +78 -0
- data/lib/ruby/whatsapp/messages/contacts/email.rb +49 -0
- data/lib/ruby/whatsapp/messages/contacts/name.rb +68 -0
- data/lib/ruby/whatsapp/messages/contacts/org.rb +41 -0
- data/lib/ruby/whatsapp/messages/contacts/phone.rb +59 -0
- data/lib/ruby/whatsapp/messages/contacts/url.rb +51 -0
- data/lib/ruby/whatsapp/messages/contacts.rb +37 -0
- data/lib/ruby/whatsapp/messages/document.rb +79 -0
- data/lib/ruby/whatsapp/messages/image.rb +72 -0
- data/lib/ruby/whatsapp/messages/interactive/base.rb +15 -0
- data/lib/ruby/whatsapp/messages/interactive/body.rb +39 -0
- data/lib/ruby/whatsapp/messages/interactive/footer.rb +40 -0
- data/lib/ruby/whatsapp/messages/interactive/header.rb +102 -0
- data/lib/ruby/whatsapp/messages/interactive/list_buttons/section/row.rb +66 -0
- data/lib/ruby/whatsapp/messages/interactive/list_buttons/section.rb +64 -0
- data/lib/ruby/whatsapp/messages/interactive/list_buttons.rb +63 -0
- data/lib/ruby/whatsapp/messages/interactive/media_carousel/card/button/quick_reply.rb +50 -0
- data/lib/ruby/whatsapp/messages/interactive/media_carousel/card/button.rb +47 -0
- data/lib/ruby/whatsapp/messages/interactive/media_carousel/card.rb +78 -0
- data/lib/ruby/whatsapp/messages/interactive/media_carousel.rb +50 -0
- data/lib/ruby/whatsapp/messages/interactive/product_carousel/card.rb +55 -0
- data/lib/ruby/whatsapp/messages/interactive/product_carousel.rb +50 -0
- data/lib/ruby/whatsapp/messages/interactive/reply_buttons/button.rb +59 -0
- data/lib/ruby/whatsapp/messages/interactive/reply_buttons.rb +47 -0
- data/lib/ruby/whatsapp/messages/interactive/url_button.rb +68 -0
- data/lib/ruby/whatsapp/messages/interactive.rb +112 -0
- data/lib/ruby/whatsapp/messages/location.rb +67 -0
- data/lib/ruby/whatsapp/messages/location_request.rb +41 -0
- data/lib/ruby/whatsapp/messages/mark_message_as_read.rb +42 -0
- data/lib/ruby/whatsapp/messages/reaction.rb +61 -0
- data/lib/ruby/whatsapp/messages/response/contacts.rb +36 -0
- data/lib/ruby/whatsapp/messages/response/messages.rb +27 -0
- data/lib/ruby/whatsapp/messages/response.rb +49 -0
- data/lib/ruby/whatsapp/messages/sticker.rb +62 -0
- data/lib/ruby/whatsapp/messages/template/component.rb +67 -0
- data/lib/ruby/whatsapp/messages/template/language.rb +55 -0
- data/lib/ruby/whatsapp/messages/template/parameter.rb +113 -0
- data/lib/ruby/whatsapp/messages/template.rb +62 -0
- data/lib/ruby/whatsapp/messages/text.rb +43 -0
- data/lib/ruby/whatsapp/messages/video.rb +68 -0
- data/lib/ruby/whatsapp/messages.rb +116 -0
- data/lib/ruby/whatsapp/railtie.rb +14 -0
- data/lib/ruby/whatsapp/request_error.rb +5 -0
- data/lib/ruby/whatsapp/response_handling.rb +44 -0
- data/lib/ruby/whatsapp/utils/language_codes.rb +96 -0
- data/lib/ruby/whatsapp/version.rb +5 -0
- data/lib/ruby/whatsapp/webhook/account_alerts.rb +58 -0
- data/lib/ruby/whatsapp/webhook/account_review_update.rb +27 -0
- data/lib/ruby/whatsapp/webhook/account_update/ban_info.rb +33 -0
- data/lib/ruby/whatsapp/webhook/account_update.rb +46 -0
- data/lib/ruby/whatsapp/webhook/automatic_events.rb +43 -0
- data/lib/ruby/whatsapp/webhook/business_capability_update.rb +36 -0
- data/lib/ruby/whatsapp/webhook/change.rb +61 -0
- data/lib/ruby/whatsapp/webhook/contact.rb +36 -0
- data/lib/ruby/whatsapp/webhook/entry.rb +35 -0
- data/lib/ruby/whatsapp/webhook/error.rb +47 -0
- data/lib/ruby/whatsapp/webhook/history.rb +54 -0
- data/lib/ruby/whatsapp/webhook/installer.rb +61 -0
- data/lib/ruby/whatsapp/webhook/message/audio.rb +28 -0
- data/lib/ruby/whatsapp/webhook/message/base.rb +61 -0
- data/lib/ruby/whatsapp/webhook/message/button.rb +33 -0
- data/lib/ruby/whatsapp/webhook/message/contacts/address.rb +68 -0
- data/lib/ruby/whatsapp/webhook/message/contacts/contact.rb +68 -0
- data/lib/ruby/whatsapp/webhook/message/contacts/email.rb +35 -0
- data/lib/ruby/whatsapp/webhook/message/contacts/name.rb +62 -0
- data/lib/ruby/whatsapp/webhook/message/contacts/org.rb +40 -0
- data/lib/ruby/whatsapp/webhook/message/contacts/phone.rb +40 -0
- data/lib/ruby/whatsapp/webhook/message/contacts/url.rb +35 -0
- data/lib/ruby/whatsapp/webhook/message/contacts.rb +31 -0
- data/lib/ruby/whatsapp/webhook/message/context.rb +49 -0
- data/lib/ruby/whatsapp/webhook/message/document.rb +32 -0
- data/lib/ruby/whatsapp/webhook/message/image.rb +18 -0
- data/lib/ruby/whatsapp/webhook/message/interactive.rb +53 -0
- data/lib/ruby/whatsapp/webhook/message/location.rb +49 -0
- data/lib/ruby/whatsapp/webhook/message/media.rb +54 -0
- data/lib/ruby/whatsapp/webhook/message/order/product_item.rb +50 -0
- data/lib/ruby/whatsapp/webhook/message/order.rb +43 -0
- data/lib/ruby/whatsapp/webhook/message/reaction.rb +37 -0
- data/lib/ruby/whatsapp/webhook/message/referral.rb +80 -0
- data/lib/ruby/whatsapp/webhook/message/sticker.rb +32 -0
- data/lib/ruby/whatsapp/webhook/message/system.rb +49 -0
- data/lib/ruby/whatsapp/webhook/message/text.rb +28 -0
- data/lib/ruby/whatsapp/webhook/message/unknown.rb +39 -0
- data/lib/ruby/whatsapp/webhook/message/video.rb +18 -0
- data/lib/ruby/whatsapp/webhook/message.rb +39 -0
- data/lib/ruby/whatsapp/webhook/message_template_components_update.rb +53 -0
- data/lib/ruby/whatsapp/webhook/message_template_quality_update.rb +59 -0
- data/lib/ruby/whatsapp/webhook/message_template_status_update.rb +54 -0
- data/lib/ruby/whatsapp/webhook/messages.rb +55 -0
- data/lib/ruby/whatsapp/webhook/metadata.rb +35 -0
- data/lib/ruby/whatsapp/webhook/notification.rb +36 -0
- data/lib/ruby/whatsapp/webhook/partner_solutions.rb +36 -0
- data/lib/ruby/whatsapp/webhook/payment_configuration_update.rb +51 -0
- data/lib/ruby/whatsapp/webhook/phone_number_name_update.rb +51 -0
- data/lib/ruby/whatsapp/webhook/phone_number_quality_update.rb +45 -0
- data/lib/ruby/whatsapp/webhook/security.rb +41 -0
- data/lib/ruby/whatsapp/webhook/signature.rb +28 -0
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync/state_sync.rb +38 -0
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync.rb +33 -0
- data/lib/ruby/whatsapp/webhook/smb_message_echoes.rb +48 -0
- data/lib/ruby/whatsapp/webhook/status/conversation.rb +42 -0
- data/lib/ruby/whatsapp/webhook/status/pricing.rb +38 -0
- data/lib/ruby/whatsapp/webhook/status.rb +65 -0
- data/lib/ruby/whatsapp/webhook/template_category_update.rb +64 -0
- data/lib/ruby/whatsapp/webhook/templates/webhooks_controller.rb.tt +31 -0
- data/lib/ruby/whatsapp/webhook/unknown_field.rb +37 -0
- data/lib/ruby/whatsapp/webhook/user_preferences.rb +47 -0
- data/lib/ruby/whatsapp/webhook/verification.rb +27 -0
- data/lib/ruby/whatsapp/webhook.rb +8 -0
- data/lib/ruby/whatsapp.rb +49 -0
- data/lib/tasks/whatsapp.rake +10 -0
- data/sig/ruby/whatsapp.rbs +6 -0
- metadata +267 -0
|
@@ -0,0 +1,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
|