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,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class Client
|
|
5
|
+
# Default request timeout in seconds.
|
|
6
|
+
DEFAULT_TIMEOUT = 30
|
|
7
|
+
|
|
8
|
+
# @!attribute [rw] host
|
|
9
|
+
# @return [String]
|
|
10
|
+
attr_accessor :host
|
|
11
|
+
|
|
12
|
+
# @!attribute [rw] version
|
|
13
|
+
# @return [String]
|
|
14
|
+
attr_accessor :version
|
|
15
|
+
|
|
16
|
+
# @!attribute [rw] api_key
|
|
17
|
+
# @return [String]
|
|
18
|
+
attr_accessor :api_key
|
|
19
|
+
|
|
20
|
+
# @!attribute [rw] phone_id
|
|
21
|
+
# @return [String]
|
|
22
|
+
attr_accessor :phone_id
|
|
23
|
+
|
|
24
|
+
# @!attribute [rw] waba_id
|
|
25
|
+
# The WhatsApp Business Account ID. Used by account-scoped endpoints such as
|
|
26
|
+
# template management, which address the WABA rather than a phone number.
|
|
27
|
+
# @return [String, nil]
|
|
28
|
+
attr_accessor :waba_id
|
|
29
|
+
|
|
30
|
+
# @param host [String] The API origin (e.g. "https://graph.facebook.com")
|
|
31
|
+
# @param version [String] The API version (e.g. "v24.0")
|
|
32
|
+
# @param api_key [String] The API key for authentication
|
|
33
|
+
# @param phone_id [String] The WhatsApp phone number ID
|
|
34
|
+
# @param waba_id [String] The WhatsApp Business Account ID
|
|
35
|
+
# @param timeout [Integer] The request timeout in seconds
|
|
36
|
+
# @param logger [Logger, nil] The logger instance for instrumentation (optional)
|
|
37
|
+
def initialize(
|
|
38
|
+
host: Whatsapp.configuration.host,
|
|
39
|
+
version: Whatsapp.configuration.version,
|
|
40
|
+
api_key: Whatsapp.configuration.api_key,
|
|
41
|
+
phone_id: Whatsapp.configuration.phone_id,
|
|
42
|
+
waba_id: Whatsapp.configuration.waba_id,
|
|
43
|
+
timeout: DEFAULT_TIMEOUT,
|
|
44
|
+
logger: nil
|
|
45
|
+
)
|
|
46
|
+
@host = host
|
|
47
|
+
@version = version
|
|
48
|
+
@api_key = api_key
|
|
49
|
+
@phone_id = phone_id
|
|
50
|
+
@waba_id = waba_id
|
|
51
|
+
@timeout = timeout
|
|
52
|
+
@logger = logger
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Sets up and returns the persistent HTTP client, authenticated and instrumented.
|
|
56
|
+
#
|
|
57
|
+
# The persistent base is the origin only — `HTTP.persistent` normalizes its
|
|
58
|
+
# argument to the origin, so the API version must NOT be baked into it or it
|
|
59
|
+
# is silently dropped. The version is carried per-request via {#path_for}.
|
|
60
|
+
# @return [HTTP::Client] The configured HTTP client
|
|
61
|
+
def connection
|
|
62
|
+
@connection ||= begin
|
|
63
|
+
http = HTTP.persistent(@host)
|
|
64
|
+
http = http.auth("Bearer #{@api_key}") if @api_key
|
|
65
|
+
http = http.use(instrumentation: { instrumenter: Instrumentation.new(@logger) }) if @logger
|
|
66
|
+
http = http.timeout(@timeout) if @timeout
|
|
67
|
+
|
|
68
|
+
http
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Builds a versioned request path.
|
|
73
|
+
# @example
|
|
74
|
+
# path_for(phone_id, "messages") #=> "/v24.0/<phone_id>/messages"
|
|
75
|
+
# path_for(media_id) #=> "/v24.0/<media_id>"
|
|
76
|
+
# @param segments [Array<String>] Path segments appended after the version.
|
|
77
|
+
# @return [String] The full request path.
|
|
78
|
+
def path_for(*segments)
|
|
79
|
+
"/#{[version, *segments].join('/')}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class Configuration
|
|
5
|
+
module Defaults
|
|
6
|
+
HOST = "https://graph.facebook.com"
|
|
7
|
+
VERSION = "v24.0"
|
|
8
|
+
# Hosts allowed to receive the API bearer token during media downloads.
|
|
9
|
+
# Meta serves media from these hosts; anything else is refused so the token
|
|
10
|
+
# is never sent to an attacker-influenced URL.
|
|
11
|
+
MEDIA_HOST_ALLOWLIST = %w[lookaside.fbsbx.com mmg.whatsapp.net graph.facebook.com].freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @!attribute [rw] host
|
|
15
|
+
# @return [String]
|
|
16
|
+
attr_accessor :host
|
|
17
|
+
|
|
18
|
+
# @!attribute [rw] version
|
|
19
|
+
# @return [String]
|
|
20
|
+
attr_accessor :version
|
|
21
|
+
|
|
22
|
+
# @!attribute [rw] api_key
|
|
23
|
+
# @return [String]
|
|
24
|
+
attr_accessor :api_key
|
|
25
|
+
|
|
26
|
+
# @!attribute [rw] phone_id
|
|
27
|
+
# @return [String]
|
|
28
|
+
attr_accessor :phone_id
|
|
29
|
+
|
|
30
|
+
# @!attribute [rw] waba_id
|
|
31
|
+
# @return [String]
|
|
32
|
+
attr_accessor :waba_id
|
|
33
|
+
|
|
34
|
+
# @!attribute [rw] media_host_allowlist
|
|
35
|
+
# @return [Array<String>]
|
|
36
|
+
attr_accessor :media_host_allowlist
|
|
37
|
+
|
|
38
|
+
# @!attribute [rw] verify_token
|
|
39
|
+
# The token Meta must echo back during the webhook GET verification handshake.
|
|
40
|
+
# @return [String, nil]
|
|
41
|
+
attr_accessor :verify_token
|
|
42
|
+
|
|
43
|
+
# @!attribute [rw] app_secret
|
|
44
|
+
# Used to verify the `X-Hub-Signature-256` header on incoming webhook POSTs.
|
|
45
|
+
# @return [String, nil]
|
|
46
|
+
attr_accessor :app_secret
|
|
47
|
+
|
|
48
|
+
# @param host [String] The API origin
|
|
49
|
+
# @param version [String] The API version
|
|
50
|
+
# @param api_key [String] The API key for authentication
|
|
51
|
+
# @param phone_id [String] The phone ID associated with the WhatsApp Business Account
|
|
52
|
+
# @param waba_id [String] The WhatsApp Business Account ID
|
|
53
|
+
# @param media_host_allowlist [Array<String>] Hosts allowed for media downloads
|
|
54
|
+
# @param verify_token [String, nil] The default webhook verification token
|
|
55
|
+
# @param app_secret [String, nil] The default webhook signing secret
|
|
56
|
+
def initialize(host: Defaults::HOST, version: Defaults::VERSION, api_key: nil, phone_id: nil,
|
|
57
|
+
waba_id: nil, media_host_allowlist: Defaults::MEDIA_HOST_ALLOWLIST, verify_token: nil, app_secret: nil)
|
|
58
|
+
@host = host
|
|
59
|
+
@version = version
|
|
60
|
+
@api_key = api_key
|
|
61
|
+
@phone_id = phone_id
|
|
62
|
+
@waba_id = waba_id
|
|
63
|
+
@media_host_allowlist = media_host_allowlist
|
|
64
|
+
@verify_token = verify_token
|
|
65
|
+
@app_secret = app_secret
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Redacts the api_key, app_secret, and verify_token so they never leak into logs or console output.
|
|
69
|
+
# @return [String]
|
|
70
|
+
def inspect
|
|
71
|
+
redacted_api_key = api_key.nil? ? "nil" : "[REDACTED]"
|
|
72
|
+
redacted_app_secret = app_secret.nil? ? "nil" : "[REDACTED]"
|
|
73
|
+
redacted_verify_token = verify_token.nil? ? "nil" : "[REDACTED]"
|
|
74
|
+
"#<#{self.class.name} host=#{host.inspect} version=#{version.inspect} " \
|
|
75
|
+
"api_key=#{redacted_api_key} phone_id=#{phone_id.inspect} waba_id=#{waba_id.inspect} " \
|
|
76
|
+
"verify_token=#{redacted_verify_token} app_secret=#{redacted_app_secret}>"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
# Instrumentation for logging HTTP requests and responses.
|
|
5
|
+
class Instrumentation
|
|
6
|
+
# @param logger [Logger] The logger instance to use for logging.
|
|
7
|
+
def initialize(logger)
|
|
8
|
+
@logger = logger
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# @param name [String] The name of the event.
|
|
12
|
+
# @param payload [Hash] - optional - The payload containing event data.
|
|
13
|
+
def instrument(name, payload = {})
|
|
14
|
+
error = payload[:error]
|
|
15
|
+
return unless error
|
|
16
|
+
|
|
17
|
+
@logger.error("#{name}: #{error.message}")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param payload [Hash] The payload containing event data.
|
|
21
|
+
def start(_, payload)
|
|
22
|
+
request = payload[:request]
|
|
23
|
+
uri = request.uri
|
|
24
|
+
# Log without the query string so identifiers/secrets in params are not logged.
|
|
25
|
+
@logger.info("#{request.verb.to_s.upcase} #{uri.scheme}://#{uri.host}#{uri.path}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param payload [Hash] The payload containing event data.
|
|
29
|
+
def finish(_, payload)
|
|
30
|
+
response = payload[:response]
|
|
31
|
+
@logger.info("#{response.status.code} #{response.status.reason}")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
# Media class for uploading, retrieving, downloading, and deleting media assets.
|
|
5
|
+
# Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/media
|
|
6
|
+
class Media
|
|
7
|
+
include ResponseHandling
|
|
8
|
+
|
|
9
|
+
class MediaError < Whatsapp::Error; end
|
|
10
|
+
|
|
11
|
+
# @!attribute [rw] client
|
|
12
|
+
# @return [Whatsapp::Client]
|
|
13
|
+
attr_accessor :client
|
|
14
|
+
|
|
15
|
+
# @param client [Whatsapp::Client] The WhatsApp client instance
|
|
16
|
+
# @param media_host_allowlist [Array<String>] Hosts allowed to receive the
|
|
17
|
+
# bearer token during downloads (defaults to the global configuration).
|
|
18
|
+
def initialize(client: Client.new, media_host_allowlist: Whatsapp.configuration.media_host_allowlist)
|
|
19
|
+
@client = client
|
|
20
|
+
@media_host_allowlist = media_host_allowlist
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Uploads a media file to WhatsApp.
|
|
24
|
+
# @param file_path [String] Path to the file to upload
|
|
25
|
+
# @param type [String] MIME type of the file (e.g., "image/jpeg", "video/mp4")
|
|
26
|
+
# @return [String] The media ID
|
|
27
|
+
# @raise [MediaError] if the file is missing or the upload fails
|
|
28
|
+
def upload(file_path:, type:)
|
|
29
|
+
raise MediaError, "File not found: #{file_path}" unless File.exist?(file_path)
|
|
30
|
+
|
|
31
|
+
response = client.connection.post(
|
|
32
|
+
client.path_for(client.phone_id, "media"),
|
|
33
|
+
form: {
|
|
34
|
+
messaging_product: "whatsapp",
|
|
35
|
+
file: HTTP::FormData::File.new(file_path, content_type: type),
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
parse_json(handle_response!(response, error_class: MediaError, action: "upload media"))["id"]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Retrieves the URL for a media asset.
|
|
43
|
+
# @param media_id [String] The media ID
|
|
44
|
+
# @return [Hash] Hash containing url, mime_type, sha256, file_size, and id
|
|
45
|
+
# @raise [MediaError] if the retrieval fails
|
|
46
|
+
def get_url(media_id:)
|
|
47
|
+
response = client.connection.get(
|
|
48
|
+
client.path_for(media_id),
|
|
49
|
+
params: { phone_number_id: client.phone_id }
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
parse_json(handle_response!(response, error_class: MediaError, action: "get media URL"))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Downloads media from a media URL.
|
|
56
|
+
#
|
|
57
|
+
# The bearer token is attached to the request, so the URL is validated first:
|
|
58
|
+
# it must be HTTPS and its host must be on the allowlist. This prevents the
|
|
59
|
+
# token being sent to an attacker-influenced URL (media URLs commonly arrive
|
|
60
|
+
# from inbound webhooks).
|
|
61
|
+
# @param url [String] The media URL (valid for 5 minutes)
|
|
62
|
+
# @param save_to [String] Path where to save the downloaded file
|
|
63
|
+
# @return [String] Path to the saved file
|
|
64
|
+
# @raise [MediaError] if the URL is untrusted or the download fails
|
|
65
|
+
def download(url:, save_to:)
|
|
66
|
+
uri = parse_download_url(url)
|
|
67
|
+
|
|
68
|
+
response = HTTP.timeout(Client::DEFAULT_TIMEOUT)
|
|
69
|
+
.auth("Bearer #{client.api_key}")
|
|
70
|
+
.get(uri.to_s)
|
|
71
|
+
|
|
72
|
+
handle_response!(response, error_class: MediaError, action: "download media")
|
|
73
|
+
stream_to_file(response, save_to)
|
|
74
|
+
|
|
75
|
+
save_to
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Deletes a media asset.
|
|
79
|
+
# @param media_id [String] The media ID
|
|
80
|
+
# @return [Boolean] true if successful
|
|
81
|
+
# @raise [MediaError] if the deletion fails
|
|
82
|
+
def delete(media_id:)
|
|
83
|
+
response = client.connection.delete(
|
|
84
|
+
client.path_for(media_id),
|
|
85
|
+
params: { phone_number_id: client.phone_id }
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
parsed = parse_json(handle_response!(response, error_class: MediaError, action: "delete media"))
|
|
89
|
+
parsed.is_a?(Hash) && parsed["success"] == true
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# Parses and validates a download URL (HTTPS + allowlisted host).
|
|
95
|
+
# @return [URI::HTTPS]
|
|
96
|
+
# @raise [MediaError] if the URL is invalid, not HTTPS, or not allowlisted
|
|
97
|
+
def parse_download_url(url)
|
|
98
|
+
uri =
|
|
99
|
+
begin
|
|
100
|
+
URI.parse(url)
|
|
101
|
+
rescue URI::InvalidURIError => e
|
|
102
|
+
raise MediaError, "Invalid media URL: #{e.message}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
raise MediaError, "Refusing to download from non-HTTPS URL: #{url}" unless uri.is_a?(URI::HTTPS)
|
|
106
|
+
|
|
107
|
+
unless allowed_host?(uri.host)
|
|
108
|
+
raise MediaError,
|
|
109
|
+
"Refusing to send credentials to non-allowlisted host: #{uri.host}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
uri
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Bypass-safe host check: exact match or a dotted subdomain of an allowed host.
|
|
116
|
+
# Rejects look-alikes such as "evil-fbsbx.com" or "fbsbx.com.attacker.com".
|
|
117
|
+
# @return [Boolean]
|
|
118
|
+
def allowed_host?(host)
|
|
119
|
+
return false if host.nil? || host.empty?
|
|
120
|
+
|
|
121
|
+
@media_host_allowlist.any? do |allowed|
|
|
122
|
+
host == allowed || host.end_with?(".#{allowed}")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Streams the response body to disk in chunks rather than buffering it.
|
|
127
|
+
# @return [void]
|
|
128
|
+
def stream_to_file(response, save_to)
|
|
129
|
+
File.open(save_to, "wb") do |file|
|
|
130
|
+
response.body.each { |chunk| file.write(chunk) }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
class Button
|
|
6
|
+
# Shared behaviour for every button a template can declare.
|
|
7
|
+
#
|
|
8
|
+
# Each subclass owns its own wire type as `Defaults::TYPE` rather than having it
|
|
9
|
+
# supplied by the {Button::TYPES} registry — a button's type is intrinsic to the
|
|
10
|
+
# button, so keeping it on the class means one source of truth for both
|
|
11
|
+
# serialization and the per-type cap checks in {Component::Buttons}.
|
|
12
|
+
class Base
|
|
13
|
+
include ValueObject
|
|
14
|
+
|
|
15
|
+
# Character limit shared by every button that carries a tappable label.
|
|
16
|
+
MAX_TEXT_LENGTH = 25
|
|
17
|
+
|
|
18
|
+
# The uppercase value Meta expects in the button's `type` field.
|
|
19
|
+
# @return [String]
|
|
20
|
+
def api_type
|
|
21
|
+
self.class::Defaults::TYPE
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
class Button
|
|
6
|
+
# Copies a string — typically a coupon code — to the recipient's clipboard.
|
|
7
|
+
#
|
|
8
|
+
# Two things are unusual about this button: it carries no `text`, because Meta
|
|
9
|
+
# supplies and localises the label itself; and its `example` is a bare string
|
|
10
|
+
# rather than an array. At most one per template.
|
|
11
|
+
#
|
|
12
|
+
# In a limited-time-offer template the example is capped at 15 characters rather
|
|
13
|
+
# than 20, and the button must sit at index 0 — both enforced by {Template},
|
|
14
|
+
# which is the only place that can see the sibling components.
|
|
15
|
+
# Source: https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/components/
|
|
16
|
+
class CopyCode < Base
|
|
17
|
+
module Defaults
|
|
18
|
+
TYPE = "COPY_CODE"
|
|
19
|
+
MAX_EXAMPLE_LENGTH = 20
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @!attribute [rw] example
|
|
23
|
+
# @return [String]
|
|
24
|
+
attr_accessor :example
|
|
25
|
+
|
|
26
|
+
# @!attribute [rw] text
|
|
27
|
+
# Always nil. Accepted only so a caller who supplies one gets told why it is
|
|
28
|
+
# not allowed, rather than an opaque ArgumentError.
|
|
29
|
+
# @return [nil]
|
|
30
|
+
attr_accessor :text
|
|
31
|
+
|
|
32
|
+
validates :example, presence: true, length: { maximum: Defaults::MAX_EXAMPLE_LENGTH }
|
|
33
|
+
validate :validate_no_text
|
|
34
|
+
|
|
35
|
+
# @param example [String] The code to copy (max 20 characters).
|
|
36
|
+
# @param text [String, nil] Not supported — Meta supplies the label.
|
|
37
|
+
# @raise [ActiveModel::ValidationError] if validation fails.
|
|
38
|
+
def initialize(example:, text: nil)
|
|
39
|
+
@example = example
|
|
40
|
+
@text = text
|
|
41
|
+
|
|
42
|
+
validate!
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Hash] The serialized button.
|
|
46
|
+
def serialize
|
|
47
|
+
{ type: Defaults::TYPE, example: }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# @return [void]
|
|
53
|
+
def validate_no_text
|
|
54
|
+
reject_unsupported(:text, "cannot be set on a copy-code button; Meta supplies the label")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
class Button
|
|
6
|
+
class Otp
|
|
7
|
+
# An Android app permitted to receive an autofilled one-time passcode.
|
|
8
|
+
#
|
|
9
|
+
# Required for the ONE_TAP and ZERO_TAP OTP types: Meta matches the delivering
|
|
10
|
+
# app against these entries before handing over the code.
|
|
11
|
+
# Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/authentication-templates/authentication-templates
|
|
12
|
+
class SupportedApp
|
|
13
|
+
include ValueObject
|
|
14
|
+
|
|
15
|
+
# @!attribute [rw] package_name
|
|
16
|
+
# @return [String]
|
|
17
|
+
attr_accessor :package_name
|
|
18
|
+
|
|
19
|
+
# @!attribute [rw] signature_hash
|
|
20
|
+
# @return [String]
|
|
21
|
+
attr_accessor :signature_hash
|
|
22
|
+
|
|
23
|
+
validates :package_name, presence: true
|
|
24
|
+
validates :signature_hash, presence: true
|
|
25
|
+
|
|
26
|
+
# @param package_name [String] The Android package name.
|
|
27
|
+
# @param signature_hash [String] The app's signing signature hash.
|
|
28
|
+
# @raise [ActiveModel::ValidationError] if validation fails.
|
|
29
|
+
def initialize(package_name:, signature_hash:)
|
|
30
|
+
@package_name = package_name
|
|
31
|
+
@signature_hash = signature_hash
|
|
32
|
+
|
|
33
|
+
validate!
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Hash] The serialized supported app.
|
|
37
|
+
def serialize
|
|
38
|
+
{ package_name:, signature_hash: }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
class Button
|
|
6
|
+
# The one-time-passcode button that authentication templates are built around.
|
|
7
|
+
#
|
|
8
|
+
# Unlike every other button, its label is not settable: Meta supplies and
|
|
9
|
+
# localises both `text` and `autofill_text` per language. Passing either is
|
|
10
|
+
# therefore an error rather than something silently dropped.
|
|
11
|
+
# Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/authentication-templates/authentication-templates
|
|
12
|
+
class Otp < Base
|
|
13
|
+
module Defaults
|
|
14
|
+
TYPE = "OTP"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# How the recipient hands the code back to the business.
|
|
18
|
+
module OtpTypes
|
|
19
|
+
# Copies the code to the clipboard.
|
|
20
|
+
COPY_CODE = "COPY_CODE"
|
|
21
|
+
# Autofills the code into the requesting app.
|
|
22
|
+
ONE_TAP = "ONE_TAP"
|
|
23
|
+
# Delivers the code to a broadcast receiver with no user action.
|
|
24
|
+
ZERO_TAP = "ZERO_TAP"
|
|
25
|
+
# Renders no button at all.
|
|
26
|
+
NO_BUTTONS = "NO_BUTTONS"
|
|
27
|
+
|
|
28
|
+
ALL = [COPY_CODE, ONE_TAP, ZERO_TAP, NO_BUTTONS].freeze
|
|
29
|
+
|
|
30
|
+
# OTP types that hand the code to an app and so must declare which apps
|
|
31
|
+
# are allowed to receive it.
|
|
32
|
+
REQUIRING_SUPPORTED_APPS = [ONE_TAP, ZERO_TAP].freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @!attribute [rw] otp_type
|
|
36
|
+
# @return [String]
|
|
37
|
+
attr_accessor :otp_type
|
|
38
|
+
|
|
39
|
+
# @!attribute [rw] supported_apps
|
|
40
|
+
# @return [Array<Otp::SupportedApp>]
|
|
41
|
+
attr_accessor :supported_apps
|
|
42
|
+
|
|
43
|
+
# @!attribute [rw] zero_tap_terms_accepted
|
|
44
|
+
# @return [Boolean, nil]
|
|
45
|
+
attr_accessor :zero_tap_terms_accepted
|
|
46
|
+
|
|
47
|
+
# @!attribute [rw] text
|
|
48
|
+
# Always nil — see the class comment.
|
|
49
|
+
# @return [nil]
|
|
50
|
+
attr_accessor :text
|
|
51
|
+
|
|
52
|
+
# @!attribute [rw] autofill_text
|
|
53
|
+
# Always nil — see the class comment.
|
|
54
|
+
# @return [nil]
|
|
55
|
+
attr_accessor :autofill_text
|
|
56
|
+
|
|
57
|
+
validates :otp_type, presence: true, inclusion: { in: OtpTypes::ALL }
|
|
58
|
+
validate :validate_supported_apps
|
|
59
|
+
validate :validate_zero_tap_terms
|
|
60
|
+
validate :validate_no_labels
|
|
61
|
+
|
|
62
|
+
# @param otp_type [String, Symbol] One of {OtpTypes::ALL}, any casing.
|
|
63
|
+
# @param supported_apps [Array<Hash, Otp::SupportedApp>] Apps allowed to
|
|
64
|
+
# receive the code. Required for ONE_TAP and ZERO_TAP.
|
|
65
|
+
# @param zero_tap_terms_accepted [Boolean, nil] Must be true for ZERO_TAP.
|
|
66
|
+
# @param text [String, nil] Not supported — Meta localises the label.
|
|
67
|
+
# @param autofill_text [String, nil] Not supported — Meta localises the label.
|
|
68
|
+
# @raise [ActiveModel::ValidationError] if validation fails.
|
|
69
|
+
def initialize(otp_type:, supported_apps: [], zero_tap_terms_accepted: nil, text: nil, autofill_text: nil)
|
|
70
|
+
@otp_type = normalize_otp_type(otp_type)
|
|
71
|
+
@supported_apps = build_supported_apps(supported_apps)
|
|
72
|
+
@zero_tap_terms_accepted = zero_tap_terms_accepted
|
|
73
|
+
@text = text
|
|
74
|
+
@autofill_text = autofill_text
|
|
75
|
+
|
|
76
|
+
validate!
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [Hash] The serialized button.
|
|
80
|
+
def serialize
|
|
81
|
+
{
|
|
82
|
+
type: Defaults::TYPE,
|
|
83
|
+
otp_type:,
|
|
84
|
+
supported_apps: serialized_supported_apps,
|
|
85
|
+
zero_tap_terms_accepted:,
|
|
86
|
+
}.compact
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# Meta's docs are inconsistent about enum casing; accept either and emit
|
|
92
|
+
# uppercase, which is what responses come back as.
|
|
93
|
+
# @return [String, nil]
|
|
94
|
+
def normalize_otp_type(value)
|
|
95
|
+
return if value.nil?
|
|
96
|
+
|
|
97
|
+
candidate = value.to_s.upcase
|
|
98
|
+
OtpTypes::ALL.include?(candidate) ? candidate : value.to_s
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @return [Array<Otp::SupportedApp>]
|
|
102
|
+
# @raise [ActiveModel::ValidationError] if a supported app is invalid.
|
|
103
|
+
def build_supported_apps(apps)
|
|
104
|
+
Array(apps).map { |app| app.is_a?(SupportedApp) ? app : SupportedApp.new(**app) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @return [Array<Hash>, nil] nil when empty, so `compact` drops the key.
|
|
108
|
+
def serialized_supported_apps
|
|
109
|
+
return if supported_apps.empty?
|
|
110
|
+
|
|
111
|
+
supported_apps.map(&:serialize)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @return [void]
|
|
115
|
+
def validate_supported_apps
|
|
116
|
+
return unless OtpTypes::REQUIRING_SUPPORTED_APPS.include?(otp_type)
|
|
117
|
+
return unless supported_apps.empty?
|
|
118
|
+
|
|
119
|
+
errors.add(:supported_apps, "can't be blank for the #{otp_type} OTP type")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [void]
|
|
123
|
+
def validate_zero_tap_terms
|
|
124
|
+
return unless otp_type == OtpTypes::ZERO_TAP
|
|
125
|
+
return if zero_tap_terms_accepted == true
|
|
126
|
+
|
|
127
|
+
errors.add(:zero_tap_terms_accepted, "must be accepted for the ZERO_TAP OTP type")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @return [void]
|
|
131
|
+
def validate_no_labels
|
|
132
|
+
reject_unsupported(:text, "cannot be set on an OTP button; Meta localises the label")
|
|
133
|
+
reject_unsupported(:autofill_text, "cannot be set on an OTP button; Meta localises the label")
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
class Button
|
|
6
|
+
# Places a phone call to the business when tapped.
|
|
7
|
+
#
|
|
8
|
+
# At most one per template. Meta may strip leading zeros that follow the country
|
|
9
|
+
# code, so store the number in full international form.
|
|
10
|
+
# Source: https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/components/
|
|
11
|
+
class PhoneNumber < Base
|
|
12
|
+
module Defaults
|
|
13
|
+
TYPE = "PHONE_NUMBER"
|
|
14
|
+
MAX_PHONE_NUMBER_LENGTH = 20
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @!attribute [rw] text
|
|
18
|
+
# @return [String]
|
|
19
|
+
attr_accessor :text
|
|
20
|
+
|
|
21
|
+
# @!attribute [rw] phone_number
|
|
22
|
+
# @return [String]
|
|
23
|
+
attr_accessor :phone_number
|
|
24
|
+
|
|
25
|
+
validates :text, presence: true, length: { maximum: MAX_TEXT_LENGTH }
|
|
26
|
+
validates :phone_number, presence: true, length: { maximum: Defaults::MAX_PHONE_NUMBER_LENGTH }
|
|
27
|
+
|
|
28
|
+
# @param text [String] The button label (max 25 characters).
|
|
29
|
+
# @param phone_number [String] The number to call (max 20 characters).
|
|
30
|
+
# @raise [ActiveModel::ValidationError] if validation fails.
|
|
31
|
+
def initialize(text:, phone_number:)
|
|
32
|
+
@text = text
|
|
33
|
+
@phone_number = phone_number
|
|
34
|
+
|
|
35
|
+
validate!
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Hash] The serialized button.
|
|
39
|
+
def serialize
|
|
40
|
+
{ type: Defaults::TYPE, text:, phone_number: }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
class Button
|
|
6
|
+
# A tappable label that sends its own text back as a reply.
|
|
7
|
+
#
|
|
8
|
+
# The most common use is an opt-out ("Unsubscribe from Promos"), which Meta
|
|
9
|
+
# expects on marketing templates. Up to 10 per template, but they must be
|
|
10
|
+
# grouped contiguously — see {Component::Buttons}.
|
|
11
|
+
# Source: https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/components/
|
|
12
|
+
class QuickReply < Base
|
|
13
|
+
module Defaults
|
|
14
|
+
TYPE = "QUICK_REPLY"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @!attribute [rw] text
|
|
18
|
+
# @return [String]
|
|
19
|
+
attr_accessor :text
|
|
20
|
+
|
|
21
|
+
validates :text, presence: true, length: { maximum: MAX_TEXT_LENGTH }
|
|
22
|
+
|
|
23
|
+
# @param text [String] The button label (max 25 characters).
|
|
24
|
+
# @raise [ActiveModel::ValidationError] if validation fails.
|
|
25
|
+
def initialize(text:)
|
|
26
|
+
@text = text
|
|
27
|
+
|
|
28
|
+
validate!
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Hash] The serialized button.
|
|
32
|
+
def serialize
|
|
33
|
+
{ type: Defaults::TYPE, text: }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|