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