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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +152 -0
- data/README.md +322 -617
- data/docs/README.md +76 -0
- data/docs/business_phone_number/README.md +243 -0
- data/docs/business_phone_number/account.md +170 -0
- data/docs/business_phone_number/contact_book.md +90 -0
- data/docs/business_phone_number/profile.md +220 -0
- data/docs/business_phone_number/username.md +157 -0
- data/docs/configuration.md +177 -0
- data/docs/errors.md +219 -0
- data/docs/media/README.md +161 -0
- data/docs/message_templates/README.md +206 -0
- data/docs/message_templates/authentication.md +107 -0
- data/docs/message_templates/carousel.md +103 -0
- data/docs/message_templates/components.md +215 -0
- data/docs/message_templates/library.md +98 -0
- data/docs/message_templates/limited_time_offer.md +94 -0
- data/docs/message_templates/responses.md +173 -0
- data/docs/message_templates/standard.md +138 -0
- data/docs/messages/README.md +212 -0
- data/docs/messages/address.md +86 -0
- data/docs/messages/audio.md +43 -0
- data/docs/messages/contacts.md +129 -0
- data/docs/messages/document.md +49 -0
- data/docs/messages/image.md +59 -0
- data/docs/messages/interactive.md +342 -0
- data/docs/messages/location.md +51 -0
- data/docs/messages/location_request.md +59 -0
- data/docs/messages/mark_message_as_read.md +84 -0
- data/docs/messages/reaction.md +59 -0
- data/docs/messages/sticker.md +40 -0
- data/docs/messages/template.md +193 -0
- data/docs/messages/text.md +55 -0
- data/docs/messages/video.md +41 -0
- data/docs/schedule/README.md +183 -0
- data/docs/subscribed_app/README.md +139 -0
- data/docs/webhooks/README.md +217 -0
- data/docs/webhooks/account_alerts.md +44 -0
- data/docs/webhooks/account_review_update.md +29 -0
- data/docs/webhooks/account_update.md +54 -0
- data/docs/webhooks/automatic_events.md +47 -0
- data/docs/webhooks/business_capability_update.md +40 -0
- data/docs/webhooks/business_username_updates.md +55 -0
- data/docs/webhooks/history.md +107 -0
- data/docs/webhooks/message_template_components_update.md +46 -0
- data/docs/webhooks/message_template_quality_update.md +49 -0
- data/docs/webhooks/message_template_status_update.md +55 -0
- data/docs/webhooks/messages.md +520 -0
- data/docs/webhooks/partner_solutions.md +35 -0
- data/docs/webhooks/payment_configuration_update.md +40 -0
- data/docs/webhooks/phone_number_name_update.md +44 -0
- data/docs/webhooks/phone_number_quality_update.md +40 -0
- data/docs/webhooks/security.md +43 -0
- data/docs/webhooks/smb_app_state_sync.md +91 -0
- data/docs/webhooks/smb_message_echoes.md +91 -0
- data/docs/webhooks/template_category_update.md +52 -0
- data/docs/webhooks/user_id_update.md +84 -0
- data/docs/webhooks/user_preferences.md +87 -0
- data/lib/ruby/whatsapp/business_phone_number/account/details.rb +149 -0
- data/lib/ruby/whatsapp/business_phone_number/account/get.rb +53 -0
- data/lib/ruby/whatsapp/business_phone_number/account/transport.rb +30 -0
- data/lib/ruby/whatsapp/business_phone_number/account/update.rb +76 -0
- data/lib/ruby/whatsapp/business_phone_number/account.rb +23 -0
- 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/profile/details.rb +105 -0
- data/lib/ruby/whatsapp/business_phone_number/profile/get.rb +56 -0
- data/lib/ruby/whatsapp/business_phone_number/profile/update.rb +148 -0
- data/lib/ruby/whatsapp/business_phone_number/profile/verticals.rb +54 -0
- data/lib/ruby/whatsapp/business_phone_number/profile.rb +22 -0
- data/lib/ruby/whatsapp/business_phone_number/response.rb +3 -3
- data/lib/ruby/whatsapp/business_phone_number/transport.rb +12 -12
- 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 +20 -4
- 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/message_templates.rb +2 -6
- 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/path_building.rb +33 -0
- 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/subscribed_app/transport.rb +6 -5
- 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 +100 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
module BusinessPhoneNumber
|
|
5
|
+
module Profile
|
|
6
|
+
# Updates a business profile: its about text, description, contact details, websites,
|
|
7
|
+
# industry vertical, and profile picture.
|
|
8
|
+
#
|
|
9
|
+
# A validated instance rather than a bare class method (unlike {Get}, which has nothing
|
|
10
|
+
# to check): Meta documents a closed vertical enum and per-field character limits, and
|
|
11
|
+
# since every field is optional an all-nil call would spend a request to change nothing.
|
|
12
|
+
# Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-phone-number/whatsapp-business-profile-api
|
|
13
|
+
class Update
|
|
14
|
+
include ActiveModel::Validations
|
|
15
|
+
extend ResponseHandling
|
|
16
|
+
extend Transport
|
|
17
|
+
|
|
18
|
+
module Defaults
|
|
19
|
+
MESSAGING_PRODUCT = "whatsapp"
|
|
20
|
+
EDGE = "whatsapp_business_profile"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The character and count limits Meta documents for a business profile.
|
|
24
|
+
module Limits
|
|
25
|
+
ABOUT = 139
|
|
26
|
+
ADDRESS = 256
|
|
27
|
+
DESCRIPTION = 512
|
|
28
|
+
EMAIL = 128
|
|
29
|
+
WEBSITES = 2
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Every updatable field, in payload order. Drives both the "change something"
|
|
33
|
+
# guard and its message, so the two cannot drift apart.
|
|
34
|
+
ATTRIBUTES = %i[about address description email vertical websites profile_picture_handle].freeze
|
|
35
|
+
|
|
36
|
+
# @!attribute [rw] about
|
|
37
|
+
# @return [String, nil] The profile's about text, at most {Limits::ABOUT} characters.
|
|
38
|
+
attr_accessor :about
|
|
39
|
+
|
|
40
|
+
# @!attribute [rw] address
|
|
41
|
+
# @return [String, nil] The business's address, at most {Limits::ADDRESS} characters.
|
|
42
|
+
attr_accessor :address
|
|
43
|
+
|
|
44
|
+
# @!attribute [rw] description
|
|
45
|
+
# @return [String, nil] The business description, at most {Limits::DESCRIPTION} characters.
|
|
46
|
+
attr_accessor :description
|
|
47
|
+
|
|
48
|
+
# @!attribute [rw] email
|
|
49
|
+
# @return [String, nil] The contact email, at most {Limits::EMAIL} characters.
|
|
50
|
+
attr_accessor :email
|
|
51
|
+
|
|
52
|
+
# @!attribute [rw] vertical
|
|
53
|
+
# @return [String, nil] One of {Verticals::ALL}.
|
|
54
|
+
attr_accessor :vertical
|
|
55
|
+
|
|
56
|
+
# @!attribute [rw] websites
|
|
57
|
+
# @return [Array<String>, nil] At most {Limits::WEBSITES} URLs.
|
|
58
|
+
attr_accessor :websites
|
|
59
|
+
|
|
60
|
+
# @!attribute [rw] profile_picture_handle
|
|
61
|
+
# @return [String, nil] A picture handle from Meta's Resumable Upload API.
|
|
62
|
+
attr_accessor :profile_picture_handle
|
|
63
|
+
|
|
64
|
+
validate :validate_any_attribute_present
|
|
65
|
+
validate :validate_websites_limit
|
|
66
|
+
validates :about, length: { maximum: Limits::ABOUT }, allow_nil: true
|
|
67
|
+
validates :address, length: { maximum: Limits::ADDRESS }, allow_nil: true
|
|
68
|
+
validates :description, length: { maximum: Limits::DESCRIPTION }, allow_nil: true
|
|
69
|
+
validates :email, length: { maximum: Limits::EMAIL }, allow_nil: true
|
|
70
|
+
validates :vertical, inclusion: { in: Verticals::ALL }, allow_nil: true
|
|
71
|
+
validates :profile_picture_handle, presence: true, allow_nil: true
|
|
72
|
+
|
|
73
|
+
# @param about [String, nil] See {#about}.
|
|
74
|
+
# @param address [String, nil] See {#address}.
|
|
75
|
+
# @param description [String, nil] See {#description}.
|
|
76
|
+
# @param email [String, nil] Length only — Meta validates the address itself
|
|
77
|
+
# server-side, and a format check here would reject valid exotic addresses.
|
|
78
|
+
# @param vertical [String, Symbol, nil] One of {Verticals::ALL}, either casing.
|
|
79
|
+
# @param websites [Array<String>, nil] At most {Limits::WEBSITES} URLs. An empty
|
|
80
|
+
# array is a deliberate instruction to clear the list, not an omission.
|
|
81
|
+
# @param profile_picture_handle [String, nil] Opaque to this gem; presence only.
|
|
82
|
+
# @raise [ActiveModel::ValidationError] if every field is nil, or one breaks a
|
|
83
|
+
# documented limit.
|
|
84
|
+
def initialize(about: nil, address: nil, description: nil, email: nil, vertical: nil,
|
|
85
|
+
websites: nil, profile_picture_handle: nil)
|
|
86
|
+
@about = about
|
|
87
|
+
@address = address
|
|
88
|
+
@description = description
|
|
89
|
+
@email = email
|
|
90
|
+
@vertical = Verticals.normalize(vertical)
|
|
91
|
+
@websites = websites
|
|
92
|
+
@profile_picture_handle = profile_picture_handle
|
|
93
|
+
|
|
94
|
+
validate!
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @return [Hash] The update payload. `messaging_product` is documented required so it
|
|
98
|
+
# is always sent; every other field is optional and an omitted one is compacted away
|
|
99
|
+
# rather than sent as null, which Meta would read as an instruction to blank it.
|
|
100
|
+
# An empty `websites` array survives compaction on purpose — that *is* the clear.
|
|
101
|
+
def serialize
|
|
102
|
+
{ messaging_product: Defaults::MESSAGING_PRODUCT, about:, address:, description:,
|
|
103
|
+
email:, vertical:, websites:, profile_picture_handle:, }.compact
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
class << self
|
|
107
|
+
# @param client [Whatsapp::Client] The WhatsApp client instance.
|
|
108
|
+
# @param about [String, nil] See {#initialize}.
|
|
109
|
+
# @param address [String, nil] See {#initialize}.
|
|
110
|
+
# @param description [String, nil] See {#initialize}.
|
|
111
|
+
# @param email [String, nil] See {#initialize}.
|
|
112
|
+
# @param vertical [String, Symbol, nil] See {#initialize}.
|
|
113
|
+
# @param websites [Array<String>, nil] See {#initialize}.
|
|
114
|
+
# @param profile_picture_handle [String, nil] See {#initialize}.
|
|
115
|
+
# @return [BusinessPhoneNumber::Response]
|
|
116
|
+
# @raise [ActiveModel::ValidationError] if no field is given, or one is invalid.
|
|
117
|
+
# @raise [Error] if no phone number ID is configured, or the request fails.
|
|
118
|
+
def call(client: Client.new, about: nil, address: nil, description: nil, email: nil,
|
|
119
|
+
vertical: nil, websites: nil, profile_picture_handle: nil)
|
|
120
|
+
body = new(about:, address:, description:, email:, vertical:, websites:,
|
|
121
|
+
profile_picture_handle:).serialize
|
|
122
|
+
response = client.connection.post(edge_path(client, Defaults::EDGE), json: body)
|
|
123
|
+
|
|
124
|
+
BusinessPhoneNumber::Response.deserialize(
|
|
125
|
+
parse_json(handle_response!(response, error_class: Error, action: "update business profile"))
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# @return [void]
|
|
133
|
+
def validate_any_attribute_present
|
|
134
|
+
return if ATTRIBUTES.any? { |attribute| !public_send(attribute).nil? }
|
|
135
|
+
|
|
136
|
+
errors.add(:base, "at least one of #{ATTRIBUTES.join(', ')} must be provided")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# @return [void]
|
|
140
|
+
def validate_websites_limit
|
|
141
|
+
return if websites.nil? || websites.size <= Limits::WEBSITES
|
|
142
|
+
|
|
143
|
+
errors.add(:base, "websites accepts at most #{Limits::WEBSITES} URLs")
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
module BusinessPhoneNumber
|
|
5
|
+
module Profile
|
|
6
|
+
# The industry verticals Meta publishes for a business profile.
|
|
7
|
+
#
|
|
8
|
+
# Its own file rather than nested inside {Update}, since two classes use it: {Update}
|
|
9
|
+
# validates against it and {Details} exposes the raw value for comparison against it.
|
|
10
|
+
# That follows {Whatsapp::MessageTemplates::Categories}' precedent rather than
|
|
11
|
+
# {Register::DataLocalizationRegions}', which has a single consumer.
|
|
12
|
+
# Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-phone-number/whatsapp-business-profile-api
|
|
13
|
+
module Verticals
|
|
14
|
+
ALCOHOL = "ALCOHOL"
|
|
15
|
+
APPAREL = "APPAREL"
|
|
16
|
+
AUTO = "AUTO"
|
|
17
|
+
BEAUTY = "BEAUTY"
|
|
18
|
+
EDU = "EDU"
|
|
19
|
+
ENTERTAIN = "ENTERTAIN"
|
|
20
|
+
EVENT_PLAN = "EVENT_PLAN"
|
|
21
|
+
FINANCE = "FINANCE"
|
|
22
|
+
GOVT = "GOVT"
|
|
23
|
+
GROCERY = "GROCERY"
|
|
24
|
+
HEALTH = "HEALTH"
|
|
25
|
+
HOTEL = "HOTEL"
|
|
26
|
+
NONPROFIT = "NONPROFIT"
|
|
27
|
+
ONLINE_GAMBLING = "ONLINE_GAMBLING"
|
|
28
|
+
OTC_DRUGS = "OTC_DRUGS"
|
|
29
|
+
OTHER = "OTHER"
|
|
30
|
+
PHYSICAL_GAMBLING = "PHYSICAL_GAMBLING"
|
|
31
|
+
PROF_SERVICES = "PROF_SERVICES"
|
|
32
|
+
RESTAURANT = "RESTAURANT"
|
|
33
|
+
RETAIL = "RETAIL"
|
|
34
|
+
TRAVEL = "TRAVEL"
|
|
35
|
+
|
|
36
|
+
ALL = [ALCOHOL, APPAREL, AUTO, BEAUTY, EDU, ENTERTAIN, EVENT_PLAN, FINANCE, GOVT,
|
|
37
|
+
GROCERY, HEALTH, HOTEL, NONPROFIT, ONLINE_GAMBLING, OTC_DRUGS, OTHER,
|
|
38
|
+
PHYSICAL_GAMBLING, PROF_SERVICES, RESTAURANT, RETAIL, TRAVEL,].freeze
|
|
39
|
+
|
|
40
|
+
# Normalizes caller input to a canonical uppercase vertical.
|
|
41
|
+
# Unrecognized values are returned untouched so the inclusion validator can
|
|
42
|
+
# report them, matching {Register::DataLocalizationRegions.normalize}.
|
|
43
|
+
# @param value [String, Symbol, nil]
|
|
44
|
+
# @return [String, nil]
|
|
45
|
+
def self.normalize(value)
|
|
46
|
+
return if value.nil?
|
|
47
|
+
|
|
48
|
+
candidate = value.to_s.upcase
|
|
49
|
+
ALL.include?(candidate) ? candidate : value.to_s
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
module BusinessPhoneNumber
|
|
5
|
+
# Reads and updates a business phone number's **business profile** — the public card a
|
|
6
|
+
# WhatsApp user sees before they reply: the about line, description, address, email,
|
|
7
|
+
# websites, industry vertical, and profile picture.
|
|
8
|
+
#
|
|
9
|
+
# Unlike {Account}, this is not an exception to the module's `phone_id` rule: Meta exposes
|
|
10
|
+
# the profile as an edge on the phone number itself
|
|
11
|
+
# (`GET`/`POST /{phone_id}/whatsapp_business_profile`), so {Get} and {Update} address the
|
|
12
|
+
# same ID the onboarding actions do and reuse {BusinessPhoneNumber::Transport} unchanged.
|
|
13
|
+
#
|
|
14
|
+
# Distinct from {Account}, which describes the *account* a number belongs to rather than
|
|
15
|
+
# how that number presents itself, and from {Whatsapp::Messages}, which is what actually
|
|
16
|
+
# reaches a user once the profile is set.
|
|
17
|
+
# See `docs/business_phone_number/profile.md`.
|
|
18
|
+
# Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-phone-number/whatsapp-business-profile-api
|
|
19
|
+
module Profile
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Whatsapp
|
|
4
4
|
module BusinessPhoneNumber
|
|
5
|
-
# The response shared by
|
|
5
|
+
# The response shared by every write action in this module: `{success}`, with an
|
|
6
6
|
# optional `id` that only `VerifyCode` ever populates. One class covers all of
|
|
7
7
|
# them — unlike {Whatsapp::SubscribedApp}'s `Response::*` namespace — since
|
|
8
|
-
# {Register}, {Deregister}, {RequestCode},
|
|
9
|
-
# shape, `id` included or not.
|
|
8
|
+
# {Register}, {Deregister}, {RequestCode}, {VerifyCode}, {Account::Update}, and
|
|
9
|
+
# {Profile::Update} all return this same shape, `id` included or not.
|
|
10
10
|
# Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/registration
|
|
11
11
|
class Response
|
|
12
12
|
# @!attribute [rw] success
|
|
@@ -2,26 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module Whatsapp
|
|
4
4
|
module BusinessPhoneNumber
|
|
5
|
-
# The one piece shared by {Register}, {Deregister}, {RequestCode},
|
|
6
|
-
# {
|
|
7
|
-
# only in edge name and body. Extended (not included) because
|
|
8
|
-
# from class-level `.call` methods (as a private class method).
|
|
5
|
+
# The one piece shared by {Register}, {Deregister}, {RequestCode}, {VerifyCode},
|
|
6
|
+
# {Profile}, {Username}, and {ContactBook}: all of them address a phone-number-scoped
|
|
7
|
+
# edge, differing only in edge name and body. Extended (not included) because
|
|
8
|
+
# {#edge_path} is used from class-level `.call` methods (as a private class method).
|
|
9
|
+
#
|
|
10
|
+
# The guard itself and its message live in {Whatsapp::PathBuilding}, shared with every
|
|
11
|
+
# other ID-scoped feature; what stays here is the ID this module addresses.
|
|
9
12
|
module Transport
|
|
13
|
+
include Whatsapp::PathBuilding
|
|
14
|
+
|
|
10
15
|
private
|
|
11
16
|
|
|
12
17
|
# @param client [Whatsapp::Client]
|
|
13
18
|
# @param action [String] The edge to address ("register", "deregister",
|
|
14
|
-
# "request_code",
|
|
19
|
+
# "request_code", "verify_code", "whatsapp_business_profile", "username",
|
|
20
|
+
# "username_suggestions", or "contact_book").
|
|
15
21
|
# @return [String] The versioned path to the phone number's action edge.
|
|
16
22
|
# @raise [Error] if no phone number ID is configured.
|
|
17
23
|
def edge_path(client, action)
|
|
18
|
-
|
|
19
|
-
raise Error,
|
|
20
|
-
"phone_id is required for the #{action} edge; " \
|
|
21
|
-
"set it via Whatsapp.configure or Client.new(phone_id:)"
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
client.path_for(client.phone_id, action)
|
|
24
|
+
scoped_path(client, :phone_id, action, error_class: Error, purpose: "the #{action} edge")
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
end
|
|
@@ -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
|