ruby-whatsapp 0.4.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +152 -0
  3. data/README.md +322 -617
  4. data/docs/README.md +76 -0
  5. data/docs/business_phone_number/README.md +243 -0
  6. data/docs/business_phone_number/account.md +170 -0
  7. data/docs/business_phone_number/contact_book.md +90 -0
  8. data/docs/business_phone_number/profile.md +220 -0
  9. data/docs/business_phone_number/username.md +157 -0
  10. data/docs/configuration.md +177 -0
  11. data/docs/errors.md +219 -0
  12. data/docs/media/README.md +161 -0
  13. data/docs/message_templates/README.md +206 -0
  14. data/docs/message_templates/authentication.md +107 -0
  15. data/docs/message_templates/carousel.md +103 -0
  16. data/docs/message_templates/components.md +215 -0
  17. data/docs/message_templates/library.md +98 -0
  18. data/docs/message_templates/limited_time_offer.md +94 -0
  19. data/docs/message_templates/responses.md +173 -0
  20. data/docs/message_templates/standard.md +138 -0
  21. data/docs/messages/README.md +212 -0
  22. data/docs/messages/address.md +86 -0
  23. data/docs/messages/audio.md +43 -0
  24. data/docs/messages/contacts.md +129 -0
  25. data/docs/messages/document.md +49 -0
  26. data/docs/messages/image.md +59 -0
  27. data/docs/messages/interactive.md +342 -0
  28. data/docs/messages/location.md +51 -0
  29. data/docs/messages/location_request.md +59 -0
  30. data/docs/messages/mark_message_as_read.md +84 -0
  31. data/docs/messages/reaction.md +59 -0
  32. data/docs/messages/sticker.md +40 -0
  33. data/docs/messages/template.md +193 -0
  34. data/docs/messages/text.md +55 -0
  35. data/docs/messages/video.md +41 -0
  36. data/docs/schedule/README.md +183 -0
  37. data/docs/subscribed_app/README.md +139 -0
  38. data/docs/webhooks/README.md +217 -0
  39. data/docs/webhooks/account_alerts.md +44 -0
  40. data/docs/webhooks/account_review_update.md +29 -0
  41. data/docs/webhooks/account_update.md +54 -0
  42. data/docs/webhooks/automatic_events.md +47 -0
  43. data/docs/webhooks/business_capability_update.md +40 -0
  44. data/docs/webhooks/business_username_updates.md +55 -0
  45. data/docs/webhooks/history.md +107 -0
  46. data/docs/webhooks/message_template_components_update.md +46 -0
  47. data/docs/webhooks/message_template_quality_update.md +49 -0
  48. data/docs/webhooks/message_template_status_update.md +55 -0
  49. data/docs/webhooks/messages.md +520 -0
  50. data/docs/webhooks/partner_solutions.md +35 -0
  51. data/docs/webhooks/payment_configuration_update.md +40 -0
  52. data/docs/webhooks/phone_number_name_update.md +44 -0
  53. data/docs/webhooks/phone_number_quality_update.md +40 -0
  54. data/docs/webhooks/security.md +43 -0
  55. data/docs/webhooks/smb_app_state_sync.md +91 -0
  56. data/docs/webhooks/smb_message_echoes.md +91 -0
  57. data/docs/webhooks/template_category_update.md +52 -0
  58. data/docs/webhooks/user_id_update.md +84 -0
  59. data/docs/webhooks/user_preferences.md +87 -0
  60. data/lib/ruby/whatsapp/business_phone_number/account/details.rb +149 -0
  61. data/lib/ruby/whatsapp/business_phone_number/account/get.rb +53 -0
  62. data/lib/ruby/whatsapp/business_phone_number/account/transport.rb +30 -0
  63. data/lib/ruby/whatsapp/business_phone_number/account/update.rb +76 -0
  64. data/lib/ruby/whatsapp/business_phone_number/account.rb +23 -0
  65. data/lib/ruby/whatsapp/business_phone_number/contact_book/delete.rb +87 -0
  66. data/lib/ruby/whatsapp/business_phone_number/contact_book/deletion.rb +50 -0
  67. data/lib/ruby/whatsapp/business_phone_number/contact_book.rb +25 -0
  68. data/lib/ruby/whatsapp/business_phone_number/profile/details.rb +105 -0
  69. data/lib/ruby/whatsapp/business_phone_number/profile/get.rb +56 -0
  70. data/lib/ruby/whatsapp/business_phone_number/profile/update.rb +148 -0
  71. data/lib/ruby/whatsapp/business_phone_number/profile/verticals.rb +54 -0
  72. data/lib/ruby/whatsapp/business_phone_number/profile.rb +22 -0
  73. data/lib/ruby/whatsapp/business_phone_number/response.rb +3 -3
  74. data/lib/ruby/whatsapp/business_phone_number/transport.rb +12 -12
  75. data/lib/ruby/whatsapp/business_phone_number/username/claim.rb +180 -0
  76. data/lib/ruby/whatsapp/business_phone_number/username/delete.rb +36 -0
  77. data/lib/ruby/whatsapp/business_phone_number/username/details.rb +66 -0
  78. data/lib/ruby/whatsapp/business_phone_number/username/get.rb +33 -0
  79. data/lib/ruby/whatsapp/business_phone_number/username/suggest.rb +37 -0
  80. data/lib/ruby/whatsapp/business_phone_number/username/suggestions.rb +50 -0
  81. data/lib/ruby/whatsapp/business_phone_number/username.rb +27 -0
  82. data/lib/ruby/whatsapp/business_phone_number.rb +20 -4
  83. data/lib/ruby/whatsapp/message_templates/button/request_contact_info.rb +66 -0
  84. data/lib/ruby/whatsapp/message_templates/button.rb +5 -2
  85. data/lib/ruby/whatsapp/message_templates/component_set.rb +28 -0
  86. data/lib/ruby/whatsapp/message_templates/response/paging.rb +2 -1
  87. data/lib/ruby/whatsapp/message_templates.rb +2 -6
  88. data/lib/ruby/whatsapp/messages/base.rb +47 -5
  89. data/lib/ruby/whatsapp/messages/interactive/request_contact_info.rb +59 -0
  90. data/lib/ruby/whatsapp/messages/interactive.rb +22 -4
  91. data/lib/ruby/whatsapp/messages/response/contacts.rb +22 -5
  92. data/lib/ruby/whatsapp/path_building.rb +33 -0
  93. data/lib/ruby/whatsapp/schedule/create.rb +101 -0
  94. data/lib/ruby/whatsapp/schedule/list.rb +50 -0
  95. data/lib/ruby/whatsapp/schedule/response/collection.rb +61 -0
  96. data/lib/ruby/whatsapp/schedule/response/created.rb +30 -0
  97. data/lib/ruby/whatsapp/schedule/response/node.rb +85 -0
  98. data/lib/ruby/whatsapp/schedule/response.rb +18 -0
  99. data/lib/ruby/whatsapp/schedule/statuses.rb +19 -0
  100. data/lib/ruby/whatsapp/schedule/transport.rb +24 -0
  101. data/lib/ruby/whatsapp/schedule.rb +15 -0
  102. data/lib/ruby/whatsapp/subscribed_app/transport.rb +6 -5
  103. data/lib/ruby/whatsapp/version.rb +1 -1
  104. data/lib/ruby/whatsapp/webhook/business_username_updates.rb +51 -0
  105. data/lib/ruby/whatsapp/webhook/change.rb +3 -1
  106. data/lib/ruby/whatsapp/webhook/contact.rb +26 -3
  107. data/lib/ruby/whatsapp/webhook/history/chunk.rb +50 -0
  108. data/lib/ruby/whatsapp/webhook/history/thread.rb +64 -0
  109. data/lib/ruby/whatsapp/webhook/history.rb +73 -34
  110. data/lib/ruby/whatsapp/webhook/message/base.rb +27 -2
  111. data/lib/ruby/whatsapp/webhook/message/contacts/contact.rb +15 -2
  112. data/lib/ruby/whatsapp/webhook/message/context.rb +23 -2
  113. data/lib/ruby/whatsapp/webhook/message/edit.rb +50 -0
  114. data/lib/ruby/whatsapp/webhook/message/revoke.rb +36 -0
  115. data/lib/ruby/whatsapp/webhook/message/system.rb +19 -2
  116. data/lib/ruby/whatsapp/webhook/message/unknown.rb +2 -2
  117. data/lib/ruby/whatsapp/webhook/message.rb +3 -0
  118. data/lib/ruby/whatsapp/webhook/messages.rb +4 -3
  119. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/contact.rb +76 -0
  120. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/state_sync.rb +18 -4
  121. data/lib/ruby/whatsapp/webhook/smb_app_state_sync.rb +33 -9
  122. data/lib/ruby/whatsapp/webhook/smb_message_echoes/echo.rb +60 -0
  123. data/lib/ruby/whatsapp/webhook/smb_message_echoes.rb +26 -11
  124. data/lib/ruby/whatsapp/webhook/status.rb +23 -2
  125. data/lib/ruby/whatsapp/webhook/user_id_update/entry.rb +56 -0
  126. data/lib/ruby/whatsapp/webhook/user_id_update/id_change.rb +35 -0
  127. data/lib/ruby/whatsapp/webhook/user_id_update.rb +61 -0
  128. data/lib/ruby/whatsapp/webhook/user_preferences/entry.rb +76 -0
  129. data/lib/ruby/whatsapp/webhook/user_preferences.rb +44 -23
  130. metadata +100 -1
@@ -0,0 +1,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 all four phone-number actions: `{success}`, with an
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}, and {VerifyCode} all return this same
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}, and
6
- # {VerifyCode}: all four address the same phone-number-scoped resource, differing
7
- # only in edge name and body. Extended (not included) because {#edge_path} is used
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", or "verify_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
- if client.phone_id.nil? || client.phone_id.to_s.empty?
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