ruby-whatsapp 0.4.0 → 0.6.1

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 (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +152 -0
  3. data/README.md +322 -617
  4. data/docs/README.md +76 -0
  5. data/docs/business_phone_number/README.md +243 -0
  6. data/docs/business_phone_number/account.md +170 -0
  7. data/docs/business_phone_number/contact_book.md +90 -0
  8. data/docs/business_phone_number/profile.md +220 -0
  9. data/docs/business_phone_number/username.md +157 -0
  10. data/docs/configuration.md +177 -0
  11. data/docs/errors.md +219 -0
  12. data/docs/media/README.md +161 -0
  13. data/docs/message_templates/README.md +206 -0
  14. data/docs/message_templates/authentication.md +107 -0
  15. data/docs/message_templates/carousel.md +103 -0
  16. data/docs/message_templates/components.md +215 -0
  17. data/docs/message_templates/library.md +98 -0
  18. data/docs/message_templates/limited_time_offer.md +94 -0
  19. data/docs/message_templates/responses.md +173 -0
  20. data/docs/message_templates/standard.md +138 -0
  21. data/docs/messages/README.md +212 -0
  22. data/docs/messages/address.md +86 -0
  23. data/docs/messages/audio.md +43 -0
  24. data/docs/messages/contacts.md +129 -0
  25. data/docs/messages/document.md +49 -0
  26. data/docs/messages/image.md +59 -0
  27. data/docs/messages/interactive.md +342 -0
  28. data/docs/messages/location.md +51 -0
  29. data/docs/messages/location_request.md +59 -0
  30. data/docs/messages/mark_message_as_read.md +84 -0
  31. data/docs/messages/reaction.md +59 -0
  32. data/docs/messages/sticker.md +40 -0
  33. data/docs/messages/template.md +193 -0
  34. data/docs/messages/text.md +55 -0
  35. data/docs/messages/video.md +41 -0
  36. data/docs/schedule/README.md +183 -0
  37. data/docs/subscribed_app/README.md +139 -0
  38. data/docs/webhooks/README.md +217 -0
  39. data/docs/webhooks/account_alerts.md +44 -0
  40. data/docs/webhooks/account_review_update.md +29 -0
  41. data/docs/webhooks/account_update.md +54 -0
  42. data/docs/webhooks/automatic_events.md +47 -0
  43. data/docs/webhooks/business_capability_update.md +40 -0
  44. data/docs/webhooks/business_username_updates.md +55 -0
  45. data/docs/webhooks/history.md +107 -0
  46. data/docs/webhooks/message_template_components_update.md +46 -0
  47. data/docs/webhooks/message_template_quality_update.md +49 -0
  48. data/docs/webhooks/message_template_status_update.md +55 -0
  49. data/docs/webhooks/messages.md +520 -0
  50. data/docs/webhooks/partner_solutions.md +35 -0
  51. data/docs/webhooks/payment_configuration_update.md +40 -0
  52. data/docs/webhooks/phone_number_name_update.md +44 -0
  53. data/docs/webhooks/phone_number_quality_update.md +40 -0
  54. data/docs/webhooks/security.md +43 -0
  55. data/docs/webhooks/smb_app_state_sync.md +91 -0
  56. data/docs/webhooks/smb_message_echoes.md +91 -0
  57. data/docs/webhooks/template_category_update.md +52 -0
  58. data/docs/webhooks/user_id_update.md +84 -0
  59. data/docs/webhooks/user_preferences.md +87 -0
  60. data/lib/ruby/whatsapp/business_phone_number/account/details.rb +149 -0
  61. data/lib/ruby/whatsapp/business_phone_number/account/get.rb +53 -0
  62. data/lib/ruby/whatsapp/business_phone_number/account/transport.rb +30 -0
  63. data/lib/ruby/whatsapp/business_phone_number/account/update.rb +76 -0
  64. data/lib/ruby/whatsapp/business_phone_number/account.rb +23 -0
  65. data/lib/ruby/whatsapp/business_phone_number/contact_book/delete.rb +87 -0
  66. data/lib/ruby/whatsapp/business_phone_number/contact_book/deletion.rb +50 -0
  67. data/lib/ruby/whatsapp/business_phone_number/contact_book.rb +25 -0
  68. data/lib/ruby/whatsapp/business_phone_number/profile/details.rb +105 -0
  69. data/lib/ruby/whatsapp/business_phone_number/profile/get.rb +56 -0
  70. data/lib/ruby/whatsapp/business_phone_number/profile/update.rb +148 -0
  71. data/lib/ruby/whatsapp/business_phone_number/profile/verticals.rb +54 -0
  72. data/lib/ruby/whatsapp/business_phone_number/profile.rb +22 -0
  73. data/lib/ruby/whatsapp/business_phone_number/response.rb +3 -3
  74. data/lib/ruby/whatsapp/business_phone_number/transport.rb +12 -12
  75. data/lib/ruby/whatsapp/business_phone_number/username/claim.rb +180 -0
  76. data/lib/ruby/whatsapp/business_phone_number/username/delete.rb +36 -0
  77. data/lib/ruby/whatsapp/business_phone_number/username/details.rb +66 -0
  78. data/lib/ruby/whatsapp/business_phone_number/username/get.rb +33 -0
  79. data/lib/ruby/whatsapp/business_phone_number/username/suggest.rb +37 -0
  80. data/lib/ruby/whatsapp/business_phone_number/username/suggestions.rb +50 -0
  81. data/lib/ruby/whatsapp/business_phone_number/username.rb +27 -0
  82. data/lib/ruby/whatsapp/business_phone_number.rb +20 -4
  83. data/lib/ruby/whatsapp/message_templates/button/request_contact_info.rb +66 -0
  84. data/lib/ruby/whatsapp/message_templates/button.rb +5 -2
  85. data/lib/ruby/whatsapp/message_templates/component_set.rb +28 -0
  86. data/lib/ruby/whatsapp/message_templates/response/paging.rb +2 -1
  87. data/lib/ruby/whatsapp/message_templates.rb +2 -6
  88. data/lib/ruby/whatsapp/messages/base.rb +47 -5
  89. data/lib/ruby/whatsapp/messages/interactive/request_contact_info.rb +59 -0
  90. data/lib/ruby/whatsapp/messages/interactive.rb +22 -4
  91. data/lib/ruby/whatsapp/messages/response/contacts.rb +22 -5
  92. data/lib/ruby/whatsapp/path_building.rb +33 -0
  93. data/lib/ruby/whatsapp/schedule/create.rb +101 -0
  94. data/lib/ruby/whatsapp/schedule/list.rb +50 -0
  95. data/lib/ruby/whatsapp/schedule/response/collection.rb +61 -0
  96. data/lib/ruby/whatsapp/schedule/response/created.rb +30 -0
  97. data/lib/ruby/whatsapp/schedule/response/node.rb +85 -0
  98. data/lib/ruby/whatsapp/schedule/response.rb +18 -0
  99. data/lib/ruby/whatsapp/schedule/statuses.rb +19 -0
  100. data/lib/ruby/whatsapp/schedule/transport.rb +24 -0
  101. data/lib/ruby/whatsapp/schedule.rb +15 -0
  102. data/lib/ruby/whatsapp/subscribed_app/transport.rb +6 -5
  103. data/lib/ruby/whatsapp/version.rb +1 -1
  104. data/lib/ruby/whatsapp/webhook/business_username_updates.rb +51 -0
  105. data/lib/ruby/whatsapp/webhook/change.rb +3 -1
  106. data/lib/ruby/whatsapp/webhook/contact.rb +26 -3
  107. data/lib/ruby/whatsapp/webhook/history/chunk.rb +50 -0
  108. data/lib/ruby/whatsapp/webhook/history/thread.rb +64 -0
  109. data/lib/ruby/whatsapp/webhook/history.rb +73 -34
  110. data/lib/ruby/whatsapp/webhook/message/base.rb +27 -2
  111. data/lib/ruby/whatsapp/webhook/message/contacts/contact.rb +15 -2
  112. data/lib/ruby/whatsapp/webhook/message/context.rb +23 -2
  113. data/lib/ruby/whatsapp/webhook/message/edit.rb +50 -0
  114. data/lib/ruby/whatsapp/webhook/message/revoke.rb +36 -0
  115. data/lib/ruby/whatsapp/webhook/message/system.rb +19 -2
  116. data/lib/ruby/whatsapp/webhook/message/unknown.rb +2 -2
  117. data/lib/ruby/whatsapp/webhook/message.rb +3 -0
  118. data/lib/ruby/whatsapp/webhook/messages.rb +4 -3
  119. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/contact.rb +76 -0
  120. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/state_sync.rb +18 -4
  121. data/lib/ruby/whatsapp/webhook/smb_app_state_sync.rb +33 -9
  122. data/lib/ruby/whatsapp/webhook/smb_message_echoes/echo.rb +60 -0
  123. data/lib/ruby/whatsapp/webhook/smb_message_echoes.rb +26 -11
  124. data/lib/ruby/whatsapp/webhook/status.rb +23 -2
  125. data/lib/ruby/whatsapp/webhook/user_id_update/entry.rb +56 -0
  126. data/lib/ruby/whatsapp/webhook/user_id_update/id_change.rb +35 -0
  127. data/lib/ruby/whatsapp/webhook/user_id_update.rb +61 -0
  128. data/lib/ruby/whatsapp/webhook/user_preferences/entry.rb +76 -0
  129. data/lib/ruby/whatsapp/webhook/user_preferences.rb +44 -23
  130. metadata +100 -1
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module BusinessPhoneNumber
5
+ module Username
6
+ # The usernames Meta has reserved for your business portfolio, returned by {Suggest}.
7
+ #
8
+ # Owns the doubly-wrapped `{"data": [{"username_suggestions": [...]}]}` envelope so
9
+ # callers never reach through it — the same flatten-the-meaningless-wrapper habit as
10
+ # {Profile::Details}, {SubscribedApp::Response::App}, and
11
+ # {MessageTemplates::Response::Paging}. Unlike {Profile::Details}, which reads only
12
+ # `data[0]` because a phone number has exactly one profile, this flattens across
13
+ # *every* entry: Meta's example shows one, but nothing documents that as a limit, and
14
+ # `flat_map` is identical for one entry while degrading correctly for more.
15
+ class Suggestions
16
+ include Enumerable
17
+
18
+ # @!attribute [rw] suggestions
19
+ # @return [Array<String>] Reserved usernames, which have a higher chance of
20
+ # approval than one you invent. Empty when Meta has reserved none.
21
+ attr_accessor :suggestions
22
+
23
+ def initialize(suggestions: [])
24
+ @suggestions = suggestions
25
+ end
26
+
27
+ # @yieldparam suggestion [String]
28
+ # @return [Enumerator, self]
29
+ def each(&)
30
+ return suggestions.each unless block_given?
31
+
32
+ suggestions.each(&)
33
+ self
34
+ end
35
+
36
+ class << self
37
+ # @param response [Hash, nil] The parsed response body.
38
+ # @return [Suggestions]
39
+ def deserialize(response)
40
+ response ||= {}
41
+
42
+ new(
43
+ suggestions: Array(response["data"]).flat_map { |entry| Array((entry || {})["username_suggestions"]) }
44
+ )
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module BusinessPhoneNumber
5
+ # Reads, claims, and deletes the **business username** on a phone number — the
6
+ # searchable `@name` a WhatsApp user can find you by, as opposed to the display name,
7
+ # which they cannot search for.
8
+ #
9
+ # Like {Profile}, and unlike {Account}, this is not an exception to the module's
10
+ # `phone_id` rule: Meta exposes both edges on the phone number itself
11
+ # (`GET`/`POST`/`DELETE /{phone_id}/username` and
12
+ # `GET /{phone_id}/username_suggestions`), so all four actions reuse
13
+ # {BusinessPhoneNumber::Transport} unchanged. It is the first sub-namespace here to
14
+ # address two edges, which is exactly why `edge_path` takes the edge as an argument.
15
+ #
16
+ # A username is 1:1 with a phone number across all of WhatsApp — no two numbers,
17
+ # consumer or business, can hold the same one. Adopting one does **not** hide your
18
+ # phone number in the app; it is a discovery feature, not a privacy one, which is the
19
+ # opposite of what a *user* username does. The asynchronous counterpart is the
20
+ # `business_username_updates` webhook (see `docs/webhooks/business_username_updates.md`),
21
+ # which is also the only place a `"deleted"` status ever appears.
22
+ # See `docs/business_phone_number/username.md`.
23
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
24
+ module Username
25
+ end
26
+ end
27
+ end
@@ -8,13 +8,29 @@ module Whatsapp
8
8
  # the first place. It is a different concern from {Whatsapp::SubscribedApp}, which
9
9
  # turns webhook delivery on and off for a whole WhatsApp Business Account: this
10
10
  # module is per phone number and is a prerequisite for messaging itself, not just
11
- # for notifications. Addresses `phone_id`, like {Whatsapp::Media} and
12
- # {Whatsapp::Messages}, not `waba_id`.
11
+ # for notifications. The onboarding actions address `phone_id`, like
12
+ # {Whatsapp::Media} and {Whatsapp::Messages}.
13
13
  #
14
14
  # The full flow, in order: {RequestCode} -> {VerifyCode} -> {Register}, with
15
15
  # {Deregister} as the reverse switch.
16
- # See `lib/ruby/whatsapp/business_phone_number/CLAUDE.md` and
17
- # `docs/business-phone-number-api.md`.
16
+ #
17
+ # {Profile} reads and updates the public card a WhatsApp user sees before they reply.
18
+ # It addresses `phone_id` like the onboarding actions above, so it shares their transport.
19
+ #
20
+ # {Username} reads, claims, and deletes the searchable `@name` a WhatsApp user can find
21
+ # the number by — the business half of Meta's usernames rollout, whose user half is what
22
+ # makes {Whatsapp::Messages::Base#recipient} necessary. Two `phone_id` edges, one
23
+ # transport, still no exception.
24
+ #
25
+ # {ContactBook} deletes a stored user contact — the store Meta uses to keep putting a
26
+ # user's phone number in your webhooks after they adopt a username. Also a `phone_id`
27
+ # edge, though what it affects is scoped to the whole business portfolio.
28
+ #
29
+ # {Account} is the one exception to the `phone_id` rule: it reads and updates the
30
+ # WhatsApp Business Account a number belongs to, so it addresses `waba_id` and
31
+ # carries its own transport.
32
+ # See `lib/ruby/whatsapp/business_phone_number/CLAUDE.md`,
33
+ # `docs/business_phone_number/README.md`, and `docs/business_phone_number/profile.md`.
18
34
  # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/registration
19
35
  module BusinessPhoneNumber
20
36
  class Error < Whatsapp::Error; end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ class MessageTemplates
5
+ class Button
6
+ # Asks the recipient to share their phone number, which they hand over with one tap.
7
+ #
8
+ # The counterpart to Meta's usernames rollout: once a user adopts a username their
9
+ # phone number may stop reaching you, leaving only their
10
+ # [BSUID](https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids).
11
+ # This is how you ask for the number back — and it is the only way to reach such a
12
+ # user with a one-tap, zero-tap, or copy-code authentication template, since those
13
+ # require a phone number.
14
+ #
15
+ # A tap arrives as an inbound `contacts` message whose
16
+ # {Whatsapp::Webhook::Message::Contacts::Contact#origin} is `"contact_request"`.
17
+ #
18
+ # Allowed on `UTILITY` and `MARKETING` templates only — a cross-component rule, so it
19
+ # lives in {ComponentSet} rather than here.
20
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
21
+ class RequestContactInfo < Base
22
+ module Defaults
23
+ TYPE = "REQUEST_CONTACT_INFO"
24
+ # The only label Meta accepts, if you send one at all. What the recipient sees is
25
+ # rendered in their own app language, so the button cannot be customized.
26
+ TEXT = "Share Contact Info"
27
+ end
28
+
29
+ # @!attribute [rw] text
30
+ # @return [String, nil] Optional, and only ever {Defaults::TEXT}.
31
+ attr_accessor :text
32
+
33
+ validate :validate_text
34
+
35
+ # @param text [String, nil] Omit it — the label is not customizable and Meta
36
+ # localizes what the recipient sees. Accepted only as the exact literal
37
+ # {Defaults::TEXT}, for callers mirroring Meta's own example payload; it is
38
+ # forwarded rather than dropped, since Meta documents the field as accepted.
39
+ # @raise [ActiveModel::ValidationError] if `text` is anything else.
40
+ def initialize(text: nil)
41
+ @text = text
42
+
43
+ validate!
44
+ end
45
+
46
+ # @return [Hash] The serialized button — type alone, unless a label was given.
47
+ def serialize
48
+ { type: Defaults::TYPE, text: }.compact
49
+ end
50
+
51
+ private
52
+
53
+ # Hand-rolled rather than an `inclusion:` validator, because the default message
54
+ # ("is not included in the list") would not tell anyone *why* one value is allowed.
55
+ # @return [void]
56
+ def validate_text
57
+ return if text.nil? || text == Defaults::TEXT
58
+
59
+ errors.add(:text,
60
+ "must be exactly #{Defaults::TEXT.inspect} if given; " \
61
+ "Meta localizes the label the recipient sees, so it cannot be customized")
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -11,8 +11,10 @@ module Whatsapp
11
11
  #
12
12
  # Only the button types with a published schema are registered. Meta's Graph API
13
13
  # enum also lists FLOW, MPM, CATALOG, VOICE_CALL, VIDEO_CALL, POSTBACK,
14
- # BOOKING_STATUS, PAYMENT_REQUEST and REQUEST_CONTACT_INFO, but publishes no
15
- # field reference for them, so they are deliberately absent rather than guessed.
14
+ # BOOKING_STATUS and PAYMENT_REQUEST, but publishes no field reference for them, so
15
+ # they are deliberately absent rather than guessed. REQUEST_CONTACT_INFO used to be on
16
+ # that list; the business-scoped user ID documentation publishes its schema, so it is
17
+ # registered.
16
18
  # Source: https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/components/
17
19
  class Button
18
20
  TYPES = {
@@ -21,6 +23,7 @@ module Whatsapp
21
23
  phone_number: PhoneNumber,
22
24
  copy_code: CopyCode,
23
25
  otp: Otp,
26
+ request_contact_info: RequestContactInfo,
24
27
  }.freeze
25
28
 
26
29
  class << self
@@ -50,6 +50,12 @@ module Whatsapp
50
50
  # Categories that may use a location header.
51
51
  LOCATION_HEADER_CATEGORIES = [Categories::UTILITY, Categories::MARKETING].freeze
52
52
 
53
+ # Categories that may carry a request-contact-info button. Deliberately a separate
54
+ # constant from LOCATION_HEADER_CATEGORIES despite holding the same two values
55
+ # today: they are unrelated Meta rules that merely coincide, and merging them would
56
+ # couple two things that can move independently.
57
+ REQUEST_CONTACT_INFO_CATEGORIES = [Categories::UTILITY, Categories::MARKETING].freeze
58
+
53
59
  # @!attribute [rw] components
54
60
  # @return [Array<Component::Base>]
55
61
  attr_accessor :components
@@ -67,6 +73,7 @@ module Whatsapp
67
73
  validate :validate_singletons
68
74
  validate :validate_category_restrictions
69
75
  validate :validate_location_header
76
+ validate :validate_request_contact_info_category
70
77
  validate :validate_limited_time_offer
71
78
 
72
79
  # @param components [Array<Hash, Component::Base>] The template's components.
@@ -151,6 +158,27 @@ module Whatsapp
151
158
  "a LOCATION header is only supported on #{LOCATION_HEADER_CATEGORIES.join('/')} templates, not #{category}")
152
159
  end
153
160
 
161
+ # Unlike CATEGORY_RESTRICTED_TYPES, which keys on *component* types, this rule is
162
+ # carried by a button nested inside a BUTTONS component — so it drills in the way
163
+ # #validate_location_header drills into a header, using the `api_types` that
164
+ # {Component::Buttons} already exposes.
165
+ #
166
+ # Only the top-level BUTTONS component is scanned, not carousel-card buttons: a
167
+ # CAROUSEL already forces MARKETING via CATEGORY_RESTRICTED_TYPES, which is an
168
+ # allowed category here, so a card-level scan could never fire.
169
+ # @return [void]
170
+ def validate_request_contact_info_category
171
+ return if category.nil?
172
+
173
+ buttons = find(Component::Buttons::Defaults::TYPE)
174
+ return unless buttons&.api_types&.include?(Button::RequestContactInfo::Defaults::TYPE)
175
+ return if REQUEST_CONTACT_INFO_CATEGORIES.include?(category)
176
+
177
+ errors.add(:components,
178
+ "a #{Button::RequestContactInfo::Defaults::TYPE} button is only supported on " \
179
+ "#{REQUEST_CONTACT_INFO_CATEGORIES.join('/')} templates, not #{category}")
180
+ end
181
+
154
182
  # A limited-time offer reshapes the rest of the template: no footer, a shorter
155
183
  # body, a media-only header, and a copy-code button that must come first.
156
184
  # @return [void]
@@ -3,7 +3,8 @@
3
3
  module Whatsapp
4
4
  class MessageTemplates
5
5
  module Response
6
- # Graph API cursor pagination for a template list.
6
+ # Graph API cursor pagination for a template list. Nothing in it is
7
+ # template-specific, so {Whatsapp::Schedule::Response::Collection} reuses it.
7
8
  #
8
9
  # Flattens the nested `paging.cursors` object, since the intermediate level
9
10
  # carries no information of its own.
@@ -25,6 +25,7 @@ module Whatsapp
25
25
  # templates.find(template_id: created.id).status # => "PENDING"
26
26
  # Source: https://developers.facebook.com/docs/graph-api/reference/whats-app-business-account/message_templates/
27
27
  class MessageTemplates
28
+ include PathBuilding
28
29
  include ResponseHandling
29
30
 
30
31
  class TemplateError < Whatsapp::Error; end
@@ -204,12 +205,7 @@ module Whatsapp
204
205
  # @return [String] The versioned path for a WABA-scoped edge.
205
206
  # @raise [TemplateError] if no WABA ID is configured.
206
207
  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)
208
+ scoped_path(client, :waba_id, edge, error_class: TemplateError, purpose: "template management")
213
209
  end
214
210
 
215
211
  # @return [Hash] Only the editable fields that were supplied.
@@ -10,16 +10,41 @@ module Whatsapp
10
10
  RECIPIENT_TYPE = "individual"
11
11
  end
12
12
 
13
+ # The two ways to address a message. Drives both the "at least one" guard and its
14
+ # message, so the rule and its wording cannot drift apart.
15
+ RECIPIENT_ATTRIBUTES = %i[to recipient].freeze
16
+
13
17
  # @!attribute [rw] to
14
- # @return [String]
18
+ # @return [String, nil] The recipient's phone number. Optional since Meta added
19
+ # {#recipient}, but still the identifier that wins if both are given.
15
20
  attr_accessor :to
16
21
 
17
- validates :to, presence: true
22
+ # @!attribute [rw] recipient
23
+ # @return [String, nil] The recipient's business-scoped user ID (BSUID), e.g.
24
+ # `"US.13491208655302741918"`, or a parent BSUID, e.g.
25
+ # `"US.ENT.11815799212886844830"` — a country code, a period, and up to 128
26
+ # alphanumeric characters, with `ENT.` inserted for a parent BSUID. Pass the
27
+ # whole value; omitting or altering any part of it fails the request.
28
+ #
29
+ # Deliberately not format-checked here. Meta describes the shape but publishes
30
+ # no client-side rule for it, and the canonical source of a BSUID is this gem's
31
+ # own webhook side ({Whatsapp::Webhook::Contact#user_id} and friends), which
32
+ # keeps it as a raw string — rejecting on the way out what was accepted on the
33
+ # way in would be the worst possible asymmetry.
34
+ attr_accessor :recipient
35
+
36
+ validate :validate_recipient_present
37
+ validates :to, presence: true, allow_nil: true
38
+ validates :recipient, presence: true, allow_nil: true
18
39
 
19
- # @param to [String] The recipient's phone number.
40
+ # @param to [String, nil] The recipient's phone number.
41
+ # @param recipient [String, nil] The recipient's BSUID or parent BSUID. Supply
42
+ # either or both of `to`/`recipient`; Meta gives `to` precedence when both are
43
+ # present.
20
44
  # @raise [ActiveModel::ValidationError] if validation fails.
21
- def initialize(to:)
45
+ def initialize(to: nil, recipient: nil)
22
46
  @to = to
47
+ @recipient = recipient
23
48
  end
24
49
 
25
50
  def serialize
@@ -36,11 +61,28 @@ module Whatsapp
36
61
  {
37
62
  messaging_product: Messaging::PRODUCT,
38
63
  recipient_type: Messaging::RECIPIENT_TYPE,
39
- to:,
64
+ **recipient_fields,
40
65
  type:,
41
66
  **body,
42
67
  }
43
68
  end
69
+
70
+ private
71
+
72
+ # Only the recipient pair is compacted, not the whole envelope: an absent
73
+ # identifier must be left out rather than sent as null, but compacting the outer
74
+ # hash would also swallow a `nil` a subclass passes in `body`.
75
+ # @return [Hash] Whichever of `to`/`recipient` was given.
76
+ def recipient_fields
77
+ { to:, recipient: }.compact
78
+ end
79
+
80
+ # @return [void]
81
+ def validate_recipient_present
82
+ return if RECIPIENT_ATTRIBUTES.any? { |attribute| !public_send(attribute).nil? }
83
+
84
+ errors.add(:base, "at least one of #{RECIPIENT_ATTRIBUTES.join(' or ')} must be provided")
85
+ end
44
86
  end
45
87
  end
46
88
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ class Messages
5
+ class Interactive
6
+ # Asks the recipient to share their phone number, which they hand over with one tap.
7
+ #
8
+ # The interactive-message counterpart to
9
+ # {Whatsapp::MessageTemplates::Button::RequestContactInfo}: use this inside the
10
+ # 24-hour service window, and the template button to open a conversation. Either way
11
+ # this is how you get a phone number back from someone who has adopted a username and
12
+ # reaches you only as a
13
+ # [BSUID](https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids).
14
+ #
15
+ # The only action kind with nothing for the caller to supply — the whole payload is
16
+ # `{ name: "request_contact_info" }` — which is why {Interactive} marks it
17
+ # parameterless and stops requiring an `action:`. Compare
18
+ # {Whatsapp::Messages::LocationRequest}, whose action is the same shape.
19
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
20
+ class RequestContactInfo < Base
21
+ module Defaults
22
+ ACTION_NAME = "request_contact_info"
23
+ end
24
+
25
+ # @!attribute [rw] name
26
+ # @return [String] Always {Defaults::ACTION_NAME}.
27
+ attr_accessor :name
28
+
29
+ validates :name, inclusion: { in: [Defaults::ACTION_NAME] }
30
+
31
+ # @param name [String] The action name. Defaulted, since Meta accepts exactly one
32
+ # value; the keyword exists so a caller spelling out
33
+ # `action: { name: "request_contact_info" }` — mirroring Meta's example payload —
34
+ # still works.
35
+ # @raise [ActiveModel::ValidationError] if `name` is anything else.
36
+ def initialize(name: Defaults::ACTION_NAME)
37
+ @name = name
38
+
39
+ validate!
40
+ end
41
+
42
+ class << self
43
+ # Takes a keyword splat rather than named parameters, because {Interactive} calls
44
+ # it as `serialize(**action)` with an empty hash for this kind.
45
+ # @return [Hash] Serialized representation of the request.
46
+ def serialize(**)
47
+ new(**).serialize
48
+ end
49
+ end
50
+
51
+ # @return [Hash] Serialized representation of the request — no `parameters` key,
52
+ # unlike every other action kind, because there is nothing to parameterize.
53
+ def serialize
54
+ { name: }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -19,6 +19,10 @@ module Whatsapp
19
19
  url_button: { klass: UrlButton, api_type: "cta_url" },
20
20
  media_carousel: { klass: MediaCarousel, api_type: "carousel" },
21
21
  product_carousel: { klass: ProductCarousel, api_type: "product_list" },
22
+ # The one kind whose registry key and wire value coincide — the registry exists
23
+ # because they usually differ, so this is not a copy-paste slip. `parameterless`
24
+ # marks the one kind with nothing for a caller to supply; see #validate_action.
25
+ request_contact_info: { klass: RequestContactInfo, api_type: "request_contact_info", parameterless: true },
22
26
  }.freeze
23
27
 
24
28
  # @!attribute [rw] type
@@ -43,16 +47,17 @@ module Whatsapp
43
47
 
44
48
  validates :type, presence: true, inclusion: { in: ACTION_TYPES.keys }
45
49
  validates :body, presence: true
46
- validates :action, presence: true
50
+ validate :validate_action
47
51
 
48
52
  # @param type [Symbol, String] The action kind (see {ACTION_TYPES}).
49
53
  # @param body [String] The body text of the interactive message.
50
- # @param action [Hash] The action content, forwarded to the action class.
54
+ # @param action [Hash] The action content, forwarded to the action class. Defaults to
55
+ # empty, which only the parameterless `:request_contact_info` kind accepts.
51
56
  # @param header [Hash, nil] The header content (optional).
52
57
  # @param footer [Hash, nil] The footer content (optional).
53
- # @param kwargs [Hash] Additional keyword arguments (e.g. :to).
58
+ # @param kwargs [Hash] Additional keyword arguments passed to {Base} (:to / :recipient).
54
59
  # @raise [ActiveModel::ValidationError] if validation fails.
55
- def initialize(type:, body:, action:, header: nil, footer: nil, **)
60
+ def initialize(type:, body:, action: {}, header: nil, footer: nil, **)
56
61
  super(**)
57
62
 
58
63
  @type = type.to_sym
@@ -87,6 +92,19 @@ module Whatsapp
87
92
  ACTION_TYPES.fetch(type)
88
93
  end
89
94
 
95
+ # An empty `action` is blank, so a plain presence validator would reject the one kind
96
+ # that legitimately has nothing to supply. Reads the registry with `[]` rather than
97
+ # the `fetch`-backed #action_config, so an unknown `type` falls through to the
98
+ # inclusion validator above instead of raising KeyError from inside a validator.
99
+ # @return [void]
100
+ def validate_action
101
+ config = ACTION_TYPES[type]
102
+ return if config.nil? || config[:parameterless]
103
+ return if action.present?
104
+
105
+ errors.add(:action, "can't be blank")
106
+ end
107
+
90
108
  # @return [Hash, nil] The serialized header information.
91
109
  def serialized_header
92
110
  return unless header
@@ -5,18 +5,34 @@ module Whatsapp
5
5
  class Response
6
6
  class Contacts
7
7
  # @!attribute [rw] input
8
- # @return [String]
8
+ # @return [String] Whatever you addressed the message to: the phone number, the
9
+ # BSUID or parent BSUID, or a group ID.
9
10
  attr_accessor :input
10
11
 
11
12
  # @!attribute [rw] wa_id
12
- # @return [String]
13
+ # @return [String, nil] The recipient's WhatsApp ID. Present only if the message
14
+ # was sent to their phone number; omitted otherwise.
13
15
  attr_accessor :wa_id
14
16
 
17
+ # @!attribute [rw] user_id
18
+ # @return [String, nil] The recipient's business-scoped user ID (BSUID), or parent
19
+ # BSUID — Meta returns either in this one field, unlike the webhook classes,
20
+ # which separate `user_id` from `parent_user_id`.
21
+ #
22
+ # Omitted whenever the message went to a phone number, **including when both
23
+ # `to` and `recipient` were supplied**, since Meta gives `to` precedence and
24
+ # answers as though `recipient` had not been sent. A `nil` here therefore does
25
+ # not mean the recipient has no BSUID.
26
+ attr_accessor :user_id
27
+
15
28
  # @param input [String] The input value of the contact.
16
- # @param wa_id [String] The WhatsApp ID of the contact.
17
- def initialize(input:, wa_id:)
29
+ # @param wa_id [String, nil] The WhatsApp ID of the contact, if it was addressed by
30
+ # phone number.
31
+ # @param user_id [String, nil] The contact's BSUID, if it was addressed by one.
32
+ def initialize(input:, wa_id: nil, user_id: nil)
18
33
  @input = input
19
34
  @wa_id = wa_id
35
+ @user_id = user_id
20
36
  end
21
37
 
22
38
  class << self
@@ -26,7 +42,8 @@ module Whatsapp
26
42
  def deserialize(data)
27
43
  new(
28
44
  input: data["input"],
29
- wa_id: data["wa_id"]
45
+ wa_id: data["wa_id"],
46
+ user_id: data["user_id"]
30
47
  )
31
48
  end
32
49
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ # The ID guard and path build shared by every WABA- or phone-number-scoped feature.
5
+ #
6
+ # Each feature keeps its own transport with a named method — {Whatsapp::SubscribedApp::Transport}'s
7
+ # `edge_path`, {Whatsapp::BusinessPhoneNumber::Account::Transport}'s `node_path` — since those
8
+ # names are what keep an edge and a node from being confused at a call site. This holds only
9
+ # the part that was identical across all of them: refusing to address an unconfigured ID, and
10
+ # phrasing that refusal the same way every time.
11
+ module PathBuilding
12
+ private
13
+
14
+ # @param client [Whatsapp::Client]
15
+ # @param id_name [Symbol] The client attribute to address (`:waba_id` or `:phone_id`).
16
+ # @param segments [Array<String>] Edge segments after the ID; pass none to address the node itself.
17
+ # @param error_class [Class] The feature's own error class, never {Whatsapp::RequestError}.
18
+ # @param purpose [String] What the caller was reaching for, e.g. `"subscribed apps"`.
19
+ # @return [String] The versioned path.
20
+ # @raise [Whatsapp::Error] the given error_class, if the ID is not configured.
21
+ def scoped_path(client, id_name, *segments, error_class:, purpose:)
22
+ id = client.public_send(id_name)
23
+
24
+ if id.nil? || id.to_s.empty?
25
+ raise error_class,
26
+ "#{id_name} is required for #{purpose}; " \
27
+ "set it via Whatsapp.configure or Client.new(#{id_name}:)"
28
+ end
29
+
30
+ client.path_for(id, *segments)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module Schedule
5
+ # Schedules an approved template to be sent to an audience at a set time.
6
+ #
7
+ # A validated instance rather than a bare class method (unlike {List}, which has
8
+ # nothing to check): Meta documents all six body fields as required, so a missing
9
+ # one fails here rather than costing a request. Nothing beyond that is checked —
10
+ # Meta publishes no length limits and no rule that `delivery_time` must be in the
11
+ # future.
12
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/schedules-api
13
+ class Create
14
+ include ActiveModel::Validations
15
+ extend ResponseHandling
16
+ extend Transport
17
+
18
+ # @!attribute [rw] hsm_id
19
+ # @return [String] The ID of the approved template to send.
20
+ attr_accessor :hsm_id
21
+
22
+ # @!attribute [rw] audience_id
23
+ # @return [String] The ID of the audience to send it to.
24
+ attr_accessor :audience_id
25
+
26
+ # @!attribute [rw] waba_cs_id
27
+ # @return [String] What Meta documents only as "WABA customer status ID (phone
28
+ # number)"; whether that means a phone number ID or the number itself is
29
+ # unverified, so it is passed through as given.
30
+ attr_accessor :waba_cs_id
31
+
32
+ # @!attribute [rw] name
33
+ # @return [String] The schedule's name.
34
+ attr_accessor :name
35
+
36
+ # @!attribute [rw] description
37
+ # @return [String] The schedule's description.
38
+ attr_accessor :description
39
+
40
+ # @!attribute [rw] delivery_time
41
+ # @return [Integer] When to send, as a Unix timestamp.
42
+ attr_accessor :delivery_time
43
+
44
+ validates :hsm_id, :audience_id, :waba_cs_id, :name, :description, presence: true
45
+ validates :delivery_time, numericality: { only_integer: true }
46
+
47
+ # @param hsm_id [String] See {#hsm_id}.
48
+ # @param audience_id [String] See {#audience_id}.
49
+ # @param waba_cs_id [String] See {#waba_cs_id}.
50
+ # @param name [String] See {#name}.
51
+ # @param description [String] See {#description}.
52
+ # @param delivery_time [Integer] A Unix timestamp; pass `time.to_i` for a `Time`.
53
+ # @raise [ActiveModel::ValidationError] if a field is blank, or `delivery_time`
54
+ # is not an integer.
55
+ def initialize(hsm_id:, audience_id:, waba_cs_id:, name:, description:, delivery_time:)
56
+ @hsm_id = hsm_id
57
+ @audience_id = audience_id
58
+ @waba_cs_id = waba_cs_id
59
+ @name = name
60
+ @description = description
61
+ @delivery_time = delivery_time
62
+
63
+ validate!
64
+ end
65
+
66
+ # @return [Hash] The schedule payload. Every field is documented required, so
67
+ # none is ever compacted away.
68
+ def serialize
69
+ { hsm_id:, audience_id:, waba_cs_id:, name:, description:, delivery_time: }
70
+ end
71
+
72
+ class << self
73
+ # @example Send a template to an audience in one hour
74
+ # Create.call(
75
+ # hsm_id: "1234567890", audience_id: "2345678901", waba_cs_id: "3456789012",
76
+ # name: "Black Friday", description: "Black Friday promo to VIP customers",
77
+ # delivery_time: Time.now.to_i + 3600
78
+ # ).id
79
+ # @param hsm_id [String] See {#initialize}.
80
+ # @param audience_id [String] See {#initialize}.
81
+ # @param waba_cs_id [String] See {#initialize}.
82
+ # @param name [String] See {#initialize}.
83
+ # @param description [String] See {#initialize}.
84
+ # @param delivery_time [Integer] See {#initialize}.
85
+ # @param client [Whatsapp::Client] The WhatsApp client instance.
86
+ # @return [Response::Created]
87
+ # @raise [ActiveModel::ValidationError] if a field is blank, or `delivery_time`
88
+ # is not an integer.
89
+ # @raise [Error] if no WABA ID is configured, or the request fails.
90
+ def call(hsm_id:, audience_id:, waba_cs_id:, name:, description:, delivery_time:, client: Client.new)
91
+ body = new(hsm_id:, audience_id:, waba_cs_id:, name:, description:, delivery_time:).serialize
92
+ response = client.connection.post(edge_path(client), json: body)
93
+
94
+ Response::Created.deserialize(
95
+ parse_json(handle_response!(response, error_class: Error, action: "create schedule"))
96
+ )
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end