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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +143 -0
- data/README.md +70 -7
- data/docs/README.md +4 -1
- data/docs/business_phone_number/README.md +12 -1
- data/docs/business_phone_number/contact_book.md +90 -0
- data/docs/business_phone_number/username.md +157 -0
- data/docs/configuration.md +6 -1
- data/docs/errors.md +12 -1
- data/docs/message_templates/README.md +1 -1
- data/docs/message_templates/components.md +37 -1
- data/docs/messages/README.md +86 -9
- data/docs/messages/address.md +1 -1
- data/docs/messages/audio.md +1 -1
- data/docs/messages/contacts.md +1 -1
- data/docs/messages/document.md +1 -1
- data/docs/messages/image.md +1 -1
- data/docs/messages/interactive.md +64 -3
- data/docs/messages/location.md +1 -1
- data/docs/messages/location_request.md +1 -1
- data/docs/messages/reaction.md +1 -1
- data/docs/messages/sticker.md +1 -1
- data/docs/messages/template.md +28 -1
- data/docs/messages/text.md +1 -1
- data/docs/messages/video.md +1 -1
- data/docs/schedule/README.md +183 -0
- data/docs/webhooks/README.md +20 -12
- data/docs/webhooks/business_username_updates.md +55 -0
- data/docs/webhooks/history.md +78 -15
- data/docs/webhooks/messages.md +141 -6
- data/docs/webhooks/smb_app_state_sync.md +50 -12
- data/docs/webhooks/smb_message_echoes.md +58 -21
- data/docs/webhooks/user_id_update.md +84 -0
- data/docs/webhooks/user_preferences.md +55 -16
- data/lib/ruby/whatsapp/business_phone_number/contact_book/delete.rb +87 -0
- data/lib/ruby/whatsapp/business_phone_number/contact_book/deletion.rb +50 -0
- data/lib/ruby/whatsapp/business_phone_number/contact_book.rb +25 -0
- data/lib/ruby/whatsapp/business_phone_number/transport.rb +6 -5
- data/lib/ruby/whatsapp/business_phone_number/username/claim.rb +180 -0
- data/lib/ruby/whatsapp/business_phone_number/username/delete.rb +36 -0
- data/lib/ruby/whatsapp/business_phone_number/username/details.rb +66 -0
- data/lib/ruby/whatsapp/business_phone_number/username/get.rb +33 -0
- data/lib/ruby/whatsapp/business_phone_number/username/suggest.rb +37 -0
- data/lib/ruby/whatsapp/business_phone_number/username/suggestions.rb +50 -0
- data/lib/ruby/whatsapp/business_phone_number/username.rb +27 -0
- data/lib/ruby/whatsapp/business_phone_number.rb +9 -0
- data/lib/ruby/whatsapp/message_templates/button/request_contact_info.rb +66 -0
- data/lib/ruby/whatsapp/message_templates/button.rb +5 -2
- data/lib/ruby/whatsapp/message_templates/component_set.rb +28 -0
- data/lib/ruby/whatsapp/message_templates/response/paging.rb +2 -1
- data/lib/ruby/whatsapp/messages/base.rb +47 -5
- data/lib/ruby/whatsapp/messages/interactive/request_contact_info.rb +59 -0
- data/lib/ruby/whatsapp/messages/interactive.rb +22 -4
- data/lib/ruby/whatsapp/messages/response/contacts.rb +22 -5
- data/lib/ruby/whatsapp/schedule/create.rb +101 -0
- data/lib/ruby/whatsapp/schedule/list.rb +50 -0
- data/lib/ruby/whatsapp/schedule/response/collection.rb +61 -0
- data/lib/ruby/whatsapp/schedule/response/created.rb +30 -0
- data/lib/ruby/whatsapp/schedule/response/node.rb +85 -0
- data/lib/ruby/whatsapp/schedule/response.rb +18 -0
- data/lib/ruby/whatsapp/schedule/statuses.rb +19 -0
- data/lib/ruby/whatsapp/schedule/transport.rb +24 -0
- data/lib/ruby/whatsapp/schedule.rb +15 -0
- data/lib/ruby/whatsapp/version.rb +1 -1
- data/lib/ruby/whatsapp/webhook/business_username_updates.rb +51 -0
- data/lib/ruby/whatsapp/webhook/change.rb +3 -1
- data/lib/ruby/whatsapp/webhook/contact.rb +26 -3
- data/lib/ruby/whatsapp/webhook/history/chunk.rb +50 -0
- data/lib/ruby/whatsapp/webhook/history/thread.rb +64 -0
- data/lib/ruby/whatsapp/webhook/history.rb +73 -34
- data/lib/ruby/whatsapp/webhook/message/base.rb +27 -2
- data/lib/ruby/whatsapp/webhook/message/contacts/contact.rb +15 -2
- data/lib/ruby/whatsapp/webhook/message/context.rb +23 -2
- data/lib/ruby/whatsapp/webhook/message/edit.rb +50 -0
- data/lib/ruby/whatsapp/webhook/message/revoke.rb +36 -0
- data/lib/ruby/whatsapp/webhook/message/system.rb +19 -2
- data/lib/ruby/whatsapp/webhook/message/unknown.rb +2 -2
- data/lib/ruby/whatsapp/webhook/message.rb +3 -0
- data/lib/ruby/whatsapp/webhook/messages.rb +4 -3
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync/contact.rb +76 -0
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync/state_sync.rb +18 -4
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync.rb +33 -9
- data/lib/ruby/whatsapp/webhook/smb_message_echoes/echo.rb +60 -0
- data/lib/ruby/whatsapp/webhook/smb_message_echoes.rb +26 -11
- data/lib/ruby/whatsapp/webhook/status.rb +23 -2
- data/lib/ruby/whatsapp/webhook/user_id_update/entry.rb +56 -0
- data/lib/ruby/whatsapp/webhook/user_id_update/id_change.rb +35 -0
- data/lib/ruby/whatsapp/webhook/user_id_update.rb +61 -0
- data/lib/ruby/whatsapp/webhook/user_preferences/entry.rb +76 -0
- data/lib/ruby/whatsapp/webhook/user_preferences.rb +44 -23
- 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
|
|
15
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|