ruby-whatsapp 0.4.2 → 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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +143 -0
  3. data/README.md +70 -7
  4. data/docs/README.md +4 -1
  5. data/docs/business_phone_number/README.md +12 -1
  6. data/docs/business_phone_number/contact_book.md +90 -0
  7. data/docs/business_phone_number/username.md +157 -0
  8. data/docs/configuration.md +6 -1
  9. data/docs/errors.md +12 -1
  10. data/docs/message_templates/README.md +1 -1
  11. data/docs/message_templates/components.md +37 -1
  12. data/docs/messages/README.md +86 -9
  13. data/docs/messages/address.md +1 -1
  14. data/docs/messages/audio.md +1 -1
  15. data/docs/messages/contacts.md +1 -1
  16. data/docs/messages/document.md +1 -1
  17. data/docs/messages/image.md +1 -1
  18. data/docs/messages/interactive.md +64 -3
  19. data/docs/messages/location.md +1 -1
  20. data/docs/messages/location_request.md +1 -1
  21. data/docs/messages/reaction.md +1 -1
  22. data/docs/messages/sticker.md +1 -1
  23. data/docs/messages/template.md +28 -1
  24. data/docs/messages/text.md +1 -1
  25. data/docs/messages/video.md +1 -1
  26. data/docs/schedule/README.md +183 -0
  27. data/docs/webhooks/README.md +20 -12
  28. data/docs/webhooks/business_username_updates.md +55 -0
  29. data/docs/webhooks/history.md +78 -15
  30. data/docs/webhooks/messages.md +141 -6
  31. data/docs/webhooks/smb_app_state_sync.md +50 -12
  32. data/docs/webhooks/smb_message_echoes.md +58 -21
  33. data/docs/webhooks/user_id_update.md +84 -0
  34. data/docs/webhooks/user_preferences.md +55 -16
  35. data/lib/ruby/whatsapp/business_phone_number/contact_book/delete.rb +87 -0
  36. data/lib/ruby/whatsapp/business_phone_number/contact_book/deletion.rb +50 -0
  37. data/lib/ruby/whatsapp/business_phone_number/contact_book.rb +25 -0
  38. data/lib/ruby/whatsapp/business_phone_number/transport.rb +6 -5
  39. data/lib/ruby/whatsapp/business_phone_number/username/claim.rb +180 -0
  40. data/lib/ruby/whatsapp/business_phone_number/username/delete.rb +36 -0
  41. data/lib/ruby/whatsapp/business_phone_number/username/details.rb +66 -0
  42. data/lib/ruby/whatsapp/business_phone_number/username/get.rb +33 -0
  43. data/lib/ruby/whatsapp/business_phone_number/username/suggest.rb +37 -0
  44. data/lib/ruby/whatsapp/business_phone_number/username/suggestions.rb +50 -0
  45. data/lib/ruby/whatsapp/business_phone_number/username.rb +27 -0
  46. data/lib/ruby/whatsapp/business_phone_number.rb +9 -0
  47. data/lib/ruby/whatsapp/message_templates/button/request_contact_info.rb +66 -0
  48. data/lib/ruby/whatsapp/message_templates/button.rb +5 -2
  49. data/lib/ruby/whatsapp/message_templates/component_set.rb +28 -0
  50. data/lib/ruby/whatsapp/message_templates/response/paging.rb +2 -1
  51. data/lib/ruby/whatsapp/messages/base.rb +47 -5
  52. data/lib/ruby/whatsapp/messages/interactive/request_contact_info.rb +59 -0
  53. data/lib/ruby/whatsapp/messages/interactive.rb +22 -4
  54. data/lib/ruby/whatsapp/messages/response/contacts.rb +22 -5
  55. data/lib/ruby/whatsapp/schedule/create.rb +101 -0
  56. data/lib/ruby/whatsapp/schedule/list.rb +50 -0
  57. data/lib/ruby/whatsapp/schedule/response/collection.rb +61 -0
  58. data/lib/ruby/whatsapp/schedule/response/created.rb +30 -0
  59. data/lib/ruby/whatsapp/schedule/response/node.rb +85 -0
  60. data/lib/ruby/whatsapp/schedule/response.rb +18 -0
  61. data/lib/ruby/whatsapp/schedule/statuses.rb +19 -0
  62. data/lib/ruby/whatsapp/schedule/transport.rb +24 -0
  63. data/lib/ruby/whatsapp/schedule.rb +15 -0
  64. data/lib/ruby/whatsapp/version.rb +1 -1
  65. data/lib/ruby/whatsapp/webhook/business_username_updates.rb +51 -0
  66. data/lib/ruby/whatsapp/webhook/change.rb +3 -1
  67. data/lib/ruby/whatsapp/webhook/contact.rb +26 -3
  68. data/lib/ruby/whatsapp/webhook/history/chunk.rb +50 -0
  69. data/lib/ruby/whatsapp/webhook/history/thread.rb +64 -0
  70. data/lib/ruby/whatsapp/webhook/history.rb +73 -34
  71. data/lib/ruby/whatsapp/webhook/message/base.rb +27 -2
  72. data/lib/ruby/whatsapp/webhook/message/contacts/contact.rb +15 -2
  73. data/lib/ruby/whatsapp/webhook/message/context.rb +23 -2
  74. data/lib/ruby/whatsapp/webhook/message/edit.rb +50 -0
  75. data/lib/ruby/whatsapp/webhook/message/revoke.rb +36 -0
  76. data/lib/ruby/whatsapp/webhook/message/system.rb +19 -2
  77. data/lib/ruby/whatsapp/webhook/message/unknown.rb +2 -2
  78. data/lib/ruby/whatsapp/webhook/message.rb +3 -0
  79. data/lib/ruby/whatsapp/webhook/messages.rb +4 -3
  80. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/contact.rb +76 -0
  81. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/state_sync.rb +18 -4
  82. data/lib/ruby/whatsapp/webhook/smb_app_state_sync.rb +33 -9
  83. data/lib/ruby/whatsapp/webhook/smb_message_echoes/echo.rb +60 -0
  84. data/lib/ruby/whatsapp/webhook/smb_message_echoes.rb +26 -11
  85. data/lib/ruby/whatsapp/webhook/status.rb +23 -2
  86. data/lib/ruby/whatsapp/webhook/user_id_update/entry.rb +56 -0
  87. data/lib/ruby/whatsapp/webhook/user_id_update/id_change.rb +35 -0
  88. data/lib/ruby/whatsapp/webhook/user_id_update.rb +61 -0
  89. data/lib/ruby/whatsapp/webhook/user_preferences/entry.rb +76 -0
  90. data/lib/ruby/whatsapp/webhook/user_preferences.rb +44 -23
  91. metadata +38 -1
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module BusinessPhoneNumber
5
+ module Username
6
+ # Claims a business username for a phone number, or moves one you already hold onto
7
+ # a different number of yours.
8
+ #
9
+ # A validated instance rather than a bare class method: Meta publishes the username
10
+ # format as a set of client-side rules, and the edge is rate limited, so a request
11
+ # that was never going to be accepted should not be spent. Named {Claim} rather than
12
+ # `Create`/`Update`/`Set` — posting again *replaces* the current username, `Update`
13
+ # would suggest the partial-update semantics of {Profile::Update}, and `Set` would
14
+ # shadow `::Set` inside this namespace.
15
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
16
+ class Claim
17
+ include ActiveModel::Validations
18
+ extend ResponseHandling
19
+ extend Transport
20
+
21
+ module Defaults
22
+ EDGE = "username"
23
+ MIN_LENGTH = 3
24
+ MAX_LENGTH = 35
25
+ CHARSET_PATTERN = /\A[a-z0-9._]+\z/
26
+ LETTER_PATTERN = /[a-z]/
27
+ RESERVED_PREFIX = "www"
28
+ end
29
+
30
+ # What happens when the username is already in use on another business phone
31
+ # number in the *same* portfolio.
32
+ module TransferActions
33
+ NONE = "none"
34
+ FORCE_TRANSFER = "force_transfer"
35
+
36
+ ALL = [NONE, FORCE_TRANSFER].freeze
37
+
38
+ # Normalizes caller input to Meta's documented casing, which is lowercase here —
39
+ # unlike this module's other enums. Unrecognized values are returned untouched so
40
+ # the inclusion validator can report them.
41
+ # @param value [String, Symbol, nil]
42
+ # @return [String, nil]
43
+ def self.normalize(value)
44
+ return if value.nil?
45
+
46
+ candidate = value.to_s.downcase
47
+ ALL.include?(candidate) ? candidate : value.to_s
48
+ end
49
+ end
50
+
51
+ # The domain-like endings Meta lists. **Its list is explicitly open-ended** ("and
52
+ # so on"), so this cannot be complete: a suffix outside it is rejected server-side
53
+ # with error `100` instead. Checked locally anyway, because catching the common
54
+ # cases without spending a rate-limited request is worth more than the false sense
55
+ # of completeness costs.
56
+ DOMAIN_SUFFIXES = %w[.com .org .net .int .edu .gov .mil .us .in .html].freeze
57
+
58
+ # @!attribute [rw] username
59
+ # @return [String] The desired username, downcased.
60
+ attr_accessor :username
61
+
62
+ # @!attribute [rw] transfer_action
63
+ # @return [String] One of {TransferActions::ALL}.
64
+ attr_accessor :transfer_action
65
+
66
+ validate :validate_username_format
67
+ validates :username, presence: true
68
+ # Length is nil-exempt so a missing username reports only "can't be blank" — the
69
+ # presence validator above is what must catch nil, and a shared `allow_nil` would
70
+ # have exempted it too.
71
+ validates :username, length: { minimum: Defaults::MIN_LENGTH, maximum: Defaults::MAX_LENGTH },
72
+ allow_nil: true
73
+ validates :transfer_action, inclusion: { in: TransferActions::ALL }
74
+
75
+ # @param username [String] The desired username. Meta compares usernames
76
+ # case-insensitively, so a mixed-case value is downcased here rather than
77
+ # rejected — `"JaspersMarket"` and `"jaspersmarket"` are the same name, and
78
+ # refusing one Meta would have accepted is the failure mode this module avoids.
79
+ # `.` and `_` *are* significant: `myid`, `my.id`, and `my_id` are three names.
80
+ # @param transfer_action [String, Symbol] `"none"` (the default) fails with error
81
+ # `147005` if another of your numbers holds the name; `"force_transfer"` moves it.
82
+ # @raise [ActiveModel::ValidationError] if the username breaks a documented rule.
83
+ def initialize(username:, transfer_action: TransferActions::NONE)
84
+ @username = username&.to_s&.downcase
85
+ @transfer_action = TransferActions.normalize(transfer_action)
86
+
87
+ validate!
88
+ end
89
+
90
+ # @return [Hash] The claim payload. `transfer_action` is always sent rather than
91
+ # compacted away when it is the default: it is a per-request behavior flag, not a
92
+ # stored field, so convention 7's "nil means leave alone" does not apply — and
93
+ # sending it explicitly pins the behavior if Meta ever changes its own default,
94
+ # which matters for an operation whose other value takes a name off another number.
95
+ def serialize
96
+ { username:, transfer_action: }
97
+ end
98
+
99
+ class << self
100
+ # @example Move a username onto this number from another of yours
101
+ # Claim.call(username: "jaspersmarket", transfer_action: :force_transfer)
102
+ # @param client [Whatsapp::Client] The WhatsApp client instance.
103
+ # @param username [String] See {#initialize}.
104
+ # @param transfer_action [String, Symbol] See {#initialize}.
105
+ # @return [Details] Carrying `status` only; `username` is `nil`, since Meta
106
+ # answers with the state rather than echoing the name back.
107
+ # @raise [ActiveModel::ValidationError] if the username breaks a documented rule.
108
+ # @raise [Error] if no phone number ID is configured, or the request fails. Error
109
+ # `147005` means another of your numbers holds the name — retry with
110
+ # `transfer_action: "force_transfer"`. `147001` means it is unavailable,
111
+ # `147002` that the portfolio's messaging limit is too low, and
112
+ # `147003`/`147004` that the number must first be linked to the Facebook Page
113
+ # or Instagram account already using it.
114
+ def call(username:, client: Client.new, transfer_action: TransferActions::NONE)
115
+ body = new(username:, transfer_action:).serialize
116
+ response = client.connection.post(edge_path(client, Defaults::EDGE), json: body)
117
+
118
+ Details.deserialize(
119
+ parse_json(handle_response!(response, error_class: Error, action: "claim business username"))
120
+ )
121
+ end
122
+ end
123
+
124
+ private
125
+
126
+ # Meta's published format rules, minus the open-ended domain-suffix list, which is
127
+ # handled separately.
128
+ # @return [void]
129
+ def validate_username_format
130
+ return if username.nil? || username.empty?
131
+
132
+ validate_charset
133
+ validate_has_letter
134
+ validate_period_placement
135
+ validate_not_reserved_prefix
136
+ validate_no_domain_suffix
137
+ end
138
+
139
+ # @return [void]
140
+ def validate_charset
141
+ return if username.match?(Defaults::CHARSET_PATTERN)
142
+
143
+ errors.add(:username, "may only contain a-z, 0-9, periods and underscores")
144
+ end
145
+
146
+ # @return [void]
147
+ def validate_has_letter
148
+ return if username.match?(Defaults::LETTER_PATTERN)
149
+
150
+ errors.add(:username, "must contain at least one letter")
151
+ end
152
+
153
+ # @return [void]
154
+ def validate_period_placement
155
+ errors.add(:username, "must not start or end with a period") if
156
+ username.start_with?(".") || username.end_with?(".")
157
+ errors.add(:username, "must not contain two consecutive periods") if username.include?("..")
158
+ end
159
+
160
+ # Read as Meta writes it: a bare `www` prefix, so `wwwidgets` is rejected too. A
161
+ # local false rejection is cheap and reversible; a wasted request against a rate
162
+ # limited edge is not, and this way the error names the exact rule.
163
+ # @return [void]
164
+ def validate_not_reserved_prefix
165
+ return unless username.start_with?(Defaults::RESERVED_PREFIX)
166
+
167
+ errors.add(:username, "must not start with #{Defaults::RESERVED_PREFIX}")
168
+ end
169
+
170
+ # @return [void]
171
+ def validate_no_domain_suffix
172
+ suffix = DOMAIN_SUFFIXES.find { |candidate| username.end_with?(candidate) }
173
+ return if suffix.nil?
174
+
175
+ errors.add(:username, "must not end with a domain suffix such as #{suffix}")
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module BusinessPhoneNumber
5
+ module Username
6
+ # Removes the business username from a phone number, releasing it.
7
+ #
8
+ # A bare class method with nothing to validate, like {Deregister} and {Profile::Get}:
9
+ # the phone number is the only input and {Transport} already guards it.
10
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
11
+ class Delete
12
+ extend ResponseHandling
13
+ extend Transport
14
+
15
+ module Defaults
16
+ EDGE = "username"
17
+ end
18
+
19
+ class << self
20
+ # @param client [Whatsapp::Client] The WhatsApp client instance.
21
+ # @return [BusinessPhoneNumber::Response] Reused rather than a `Username::`-specific
22
+ # class, since this endpoint answers the same bare `{success}` as
23
+ # {Profile::Update} and {Account::Update}.
24
+ # @raise [Error] if no phone number ID is configured, or the request fails.
25
+ def call(client: Client.new)
26
+ response = client.connection.delete(edge_path(client, Defaults::EDGE))
27
+
28
+ BusinessPhoneNumber::Response.deserialize(
29
+ parse_json(handle_response!(response, error_class: Error, action: "delete business username"))
30
+ )
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module BusinessPhoneNumber
5
+ module Username
6
+ # The username state returned by {Get} and {Claim}.
7
+ #
8
+ # One class for both, rather than a response per endpoint: `GET` answers
9
+ # `{username, status}` and `POST` answers `{status}` — a subset, not a different
10
+ # shape — so `username` simply tolerates being absent, the same way
11
+ # {BusinessPhoneNumber::Response#id} tolerates only ever being set by {VerifyCode}.
12
+ # `DELETE` is the one that genuinely differs, and it reuses
13
+ # {BusinessPhoneNumber::Response} because a bare `{success}` is what that class is for.
14
+ class Details
15
+ # The states Meta's own responses report. Raw strings, never validated on the way
16
+ # in, so a value added later still round-trips — the {Account::Details} convention.
17
+ #
18
+ # The `business_username_updates` webhook reports a third state, `"deleted"`,
19
+ # which no API response here can return; it is deliberately absent rather than
20
+ # listed in an enum the write side cannot produce.
21
+ module Statuses
22
+ APPROVED = "approved"
23
+ RESERVED = "reserved"
24
+
25
+ ALL = [APPROVED, RESERVED].freeze
26
+ end
27
+
28
+ # @!attribute [rw] username
29
+ # @return [String, nil] The current username. Omitted by {Claim}, which answers
30
+ # with a status only — you already hold the name you passed it — and by {Get}
31
+ # when the number has no username at all.
32
+ attr_accessor :username
33
+
34
+ # @!attribute [rw] status
35
+ # @return [String, nil] One of {Statuses::ALL}.
36
+ attr_accessor :status
37
+
38
+ def initialize(username: nil, status: nil)
39
+ @username = username
40
+ @status = status
41
+ end
42
+
43
+ # @return [Boolean] Whether the username is live and visible to WhatsApp users.
44
+ def approved?
45
+ status == Statuses::APPROVED
46
+ end
47
+
48
+ # @return [Boolean] Whether the username is held for this number but not yet
49
+ # visible — it becomes visible when Meta makes the feature generally available.
50
+ def reserved?
51
+ status == Statuses::RESERVED
52
+ end
53
+
54
+ class << self
55
+ # @param response [Hash, nil] The parsed response body.
56
+ # @return [Details]
57
+ def deserialize(response)
58
+ response ||= {}
59
+
60
+ new(username: response["username"], status: response["status"])
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module BusinessPhoneNumber
5
+ module Username
6
+ # Reads the business username currently on a phone number, and its status.
7
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
8
+ class Get
9
+ extend ResponseHandling
10
+ extend Transport
11
+
12
+ module Defaults
13
+ EDGE = "username"
14
+ end
15
+
16
+ class << self
17
+ # @example Check whether the username is live yet
18
+ # Get.call.approved? # => false, while still `reserved`
19
+ # @param client [Whatsapp::Client] The WhatsApp client instance.
20
+ # @return [Details] Whose `username` is `nil` if the number has none set.
21
+ # @raise [Error] if no phone number ID is configured, or the request fails.
22
+ def call(client: Client.new)
23
+ response = client.connection.get(edge_path(client, Defaults::EDGE))
24
+
25
+ Details.deserialize(
26
+ parse_json(handle_response!(response, error_class: Error, action: "get business username"))
27
+ )
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module BusinessPhoneNumber
5
+ module Username
6
+ # Lists the usernames Meta has reserved for your business portfolio.
7
+ #
8
+ # Named for the verb, like {Register}/{Subscribe}, leaving the noun {Suggestions} to
9
+ # the value object it returns. The one action here addressing the
10
+ # `username_suggestions` edge rather than `username`.
11
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
12
+ class Suggest
13
+ extend ResponseHandling
14
+ extend Transport
15
+
16
+ module Defaults
17
+ EDGE = "username_suggestions"
18
+ end
19
+
20
+ class << self
21
+ # @example Claim the first name Meta reserved for you
22
+ # Username::Claim.call(username: Username::Suggest.call.first)
23
+ # @param client [Whatsapp::Client] The WhatsApp client instance.
24
+ # @return [Suggestions] Enumerable over the reserved usernames; empty if none.
25
+ # @raise [Error] if no phone number ID is configured, or the request fails.
26
+ def call(client: Client.new)
27
+ response = client.connection.get(edge_path(client, Defaults::EDGE))
28
+
29
+ Suggestions.deserialize(
30
+ parse_json(handle_response!(response, error_class: Error, action: "get username suggestions"))
31
+ )
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -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
@@ -17,6 +17,15 @@ module Whatsapp
17
17
  # {Profile} reads and updates the public card a WhatsApp user sees before they reply.
18
18
  # It addresses `phone_id` like the onboarding actions above, so it shares their transport.
19
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
+ #
20
29
  # {Account} is the one exception to the `phone_id` rule: it reads and updates the
21
30
  # WhatsApp Business Account a number belongs to, so it addresses `waba_id` and
22
31
  # carries its own transport.
@@ -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.
@@ -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