ruby-whatsapp 0.4.2 → 0.6.2

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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +158 -0
  3. data/README.md +70 -7
  4. data/docs/README.md +4 -1
  5. data/docs/business_phone_number/README.md +12 -1
  6. data/docs/business_phone_number/contact_book.md +90 -0
  7. data/docs/business_phone_number/username.md +157 -0
  8. data/docs/configuration.md +6 -1
  9. data/docs/errors.md +12 -1
  10. data/docs/message_templates/README.md +1 -1
  11. data/docs/message_templates/components.md +37 -1
  12. data/docs/messages/README.md +86 -9
  13. data/docs/messages/address.md +1 -1
  14. data/docs/messages/audio.md +1 -1
  15. data/docs/messages/contacts.md +1 -1
  16. data/docs/messages/document.md +1 -1
  17. data/docs/messages/image.md +1 -1
  18. data/docs/messages/interactive.md +84 -4
  19. data/docs/messages/location.md +1 -1
  20. data/docs/messages/location_request.md +1 -1
  21. data/docs/messages/reaction.md +1 -1
  22. data/docs/messages/sticker.md +1 -1
  23. data/docs/messages/template.md +28 -1
  24. data/docs/messages/text.md +1 -1
  25. data/docs/messages/video.md +1 -1
  26. data/docs/schedule/README.md +183 -0
  27. data/docs/webhooks/README.md +20 -12
  28. data/docs/webhooks/business_username_updates.md +55 -0
  29. data/docs/webhooks/history.md +78 -15
  30. data/docs/webhooks/messages.md +141 -6
  31. data/docs/webhooks/smb_app_state_sync.md +50 -12
  32. data/docs/webhooks/smb_message_echoes.md +58 -21
  33. data/docs/webhooks/user_id_update.md +84 -0
  34. data/docs/webhooks/user_preferences.md +55 -16
  35. data/lib/ruby/whatsapp/business_phone_number/contact_book/delete.rb +87 -0
  36. data/lib/ruby/whatsapp/business_phone_number/contact_book/deletion.rb +50 -0
  37. data/lib/ruby/whatsapp/business_phone_number/contact_book.rb +25 -0
  38. data/lib/ruby/whatsapp/business_phone_number/transport.rb +6 -5
  39. data/lib/ruby/whatsapp/business_phone_number/username/claim.rb +180 -0
  40. data/lib/ruby/whatsapp/business_phone_number/username/delete.rb +36 -0
  41. data/lib/ruby/whatsapp/business_phone_number/username/details.rb +66 -0
  42. data/lib/ruby/whatsapp/business_phone_number/username/get.rb +33 -0
  43. data/lib/ruby/whatsapp/business_phone_number/username/suggest.rb +37 -0
  44. data/lib/ruby/whatsapp/business_phone_number/username/suggestions.rb +50 -0
  45. data/lib/ruby/whatsapp/business_phone_number/username.rb +27 -0
  46. data/lib/ruby/whatsapp/business_phone_number.rb +9 -0
  47. data/lib/ruby/whatsapp/message_templates/button/request_contact_info.rb +66 -0
  48. data/lib/ruby/whatsapp/message_templates/button.rb +5 -2
  49. data/lib/ruby/whatsapp/message_templates/component_set.rb +28 -0
  50. data/lib/ruby/whatsapp/message_templates/response/paging.rb +2 -1
  51. data/lib/ruby/whatsapp/messages/base.rb +47 -5
  52. data/lib/ruby/whatsapp/messages/interactive/list_buttons/section.rb +11 -6
  53. data/lib/ruby/whatsapp/messages/interactive/list_buttons.rb +12 -0
  54. data/lib/ruby/whatsapp/messages/interactive/request_contact_info.rb +59 -0
  55. data/lib/ruby/whatsapp/messages/interactive.rb +22 -4
  56. data/lib/ruby/whatsapp/messages/response/contacts.rb +22 -5
  57. data/lib/ruby/whatsapp/schedule/create.rb +101 -0
  58. data/lib/ruby/whatsapp/schedule/list.rb +50 -0
  59. data/lib/ruby/whatsapp/schedule/response/collection.rb +61 -0
  60. data/lib/ruby/whatsapp/schedule/response/created.rb +30 -0
  61. data/lib/ruby/whatsapp/schedule/response/node.rb +85 -0
  62. data/lib/ruby/whatsapp/schedule/response.rb +18 -0
  63. data/lib/ruby/whatsapp/schedule/statuses.rb +19 -0
  64. data/lib/ruby/whatsapp/schedule/transport.rb +24 -0
  65. data/lib/ruby/whatsapp/schedule.rb +15 -0
  66. data/lib/ruby/whatsapp/version.rb +1 -1
  67. data/lib/ruby/whatsapp/webhook/business_username_updates.rb +51 -0
  68. data/lib/ruby/whatsapp/webhook/change.rb +3 -1
  69. data/lib/ruby/whatsapp/webhook/contact.rb +26 -3
  70. data/lib/ruby/whatsapp/webhook/history/chunk.rb +50 -0
  71. data/lib/ruby/whatsapp/webhook/history/thread.rb +64 -0
  72. data/lib/ruby/whatsapp/webhook/history.rb +73 -34
  73. data/lib/ruby/whatsapp/webhook/message/base.rb +27 -2
  74. data/lib/ruby/whatsapp/webhook/message/contacts/contact.rb +15 -2
  75. data/lib/ruby/whatsapp/webhook/message/context.rb +23 -2
  76. data/lib/ruby/whatsapp/webhook/message/edit.rb +50 -0
  77. data/lib/ruby/whatsapp/webhook/message/revoke.rb +36 -0
  78. data/lib/ruby/whatsapp/webhook/message/system.rb +19 -2
  79. data/lib/ruby/whatsapp/webhook/message/unknown.rb +2 -2
  80. data/lib/ruby/whatsapp/webhook/message.rb +3 -0
  81. data/lib/ruby/whatsapp/webhook/messages.rb +4 -3
  82. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/contact.rb +76 -0
  83. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/state_sync.rb +18 -4
  84. data/lib/ruby/whatsapp/webhook/smb_app_state_sync.rb +33 -9
  85. data/lib/ruby/whatsapp/webhook/smb_message_echoes/echo.rb +60 -0
  86. data/lib/ruby/whatsapp/webhook/smb_message_echoes.rb +26 -11
  87. data/lib/ruby/whatsapp/webhook/status.rb +23 -2
  88. data/lib/ruby/whatsapp/webhook/user_id_update/entry.rb +56 -0
  89. data/lib/ruby/whatsapp/webhook/user_id_update/id_change.rb +35 -0
  90. data/lib/ruby/whatsapp/webhook/user_id_update.rb +61 -0
  91. data/lib/ruby/whatsapp/webhook/user_preferences/entry.rb +76 -0
  92. data/lib/ruby/whatsapp/webhook/user_preferences.rb +44 -23
  93. metadata +38 -1
@@ -2,9 +2,10 @@
2
2
 
3
3
  module Whatsapp
4
4
  module Webhook
5
- # The `messages` webhook field's `value` payload — the only field with a
6
- # confirmed, JSON-example-backed schema in Meta's docs. Carries inbound
7
- # messages and/or delivery status updates for one of your business phone numbers.
5
+ # The `messages` webhook field's `value` payload — one of five fields with a
6
+ # confirmed, JSON-example-backed schema in Meta's docs, alongside {UserIdUpdate}
7
+ # and {BusinessUsernameUpdates}. Carries inbound messages and/or delivery status
8
+ # updates for one of your business phone numbers.
8
9
  # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview
9
10
  class Messages
10
11
  # @!attribute [rw] messaging_product
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module Webhook
5
+ class SmbAppStateSync
6
+ # A synced address-book entry from the WhatsApp Business app.
7
+ #
8
+ # The gem's third `Contact`, and none of them are interchangeable:
9
+ # {Whatsapp::Webhook::Contact} is a notification's sender identity (`profile.name`,
10
+ # `wa_id`), {Whatsapp::Webhook::Message::Contacts::Contact} is a shared contact
11
+ # *card* of vCard-shaped arrays, and this is a device address-book row
12
+ # (`full_name`, `first_name`, `phone_number`). Forcing any two together would mean
13
+ # dual-key reads on both sides for no gain.
14
+ #
15
+ # A sibling of {StateSync} rather than nested inside it, following
16
+ # {UserIdUpdate::IdChange}.
17
+ class Contact
18
+ # @!attribute [rw] full_name
19
+ # @return [String, nil]
20
+ attr_accessor :full_name
21
+
22
+ # @!attribute [rw] first_name
23
+ # @return [String, nil]
24
+ attr_accessor :first_name
25
+
26
+ # @!attribute [rw] phone_number
27
+ # @return [String, nil] Omitted once the contact adopts a username and their
28
+ # number is no longer shareable. Fall back to {#user_id}.
29
+ attr_accessor :phone_number
30
+
31
+ # @!attribute [rw] user_id
32
+ # @return [String, nil] The contact's business-scoped user ID (BSUID).
33
+ attr_accessor :user_id
34
+
35
+ # @!attribute [rw] parent_user_id
36
+ # @return [String, nil] The contact's parent BSUID, present only when enrolled.
37
+ attr_accessor :parent_user_id
38
+
39
+ # @!attribute [rw] username
40
+ # @return [String, nil] Set only if the contact has adopted a username. Read
41
+ # flat, unlike {Whatsapp::Webhook::Contact#username}, which Meta nests under
42
+ # `profile` — do not copy that dual read here.
43
+ attr_accessor :username
44
+
45
+ def initialize(
46
+ full_name: nil, first_name: nil, phone_number: nil,
47
+ user_id: nil, parent_user_id: nil, username: nil
48
+ )
49
+ @full_name = full_name
50
+ @first_name = first_name
51
+ @phone_number = phone_number
52
+ @user_id = user_id
53
+ @parent_user_id = parent_user_id
54
+ @username = username
55
+ end
56
+
57
+ class << self
58
+ # @param data [Hash, nil] A raw `contact` hash.
59
+ # @return [Contact]
60
+ def deserialize(data)
61
+ data ||= {}
62
+
63
+ new(
64
+ full_name: data["full_name"],
65
+ first_name: data["first_name"],
66
+ phone_number: data["phone_number"],
67
+ user_id: data["user_id"],
68
+ parent_user_id: data["parent_user_id"],
69
+ username: data["username"]
70
+ )
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -6,7 +6,7 @@ module Whatsapp
6
6
  # A single contact-sync entry within an `smb_app_state_sync` notification.
7
7
  class StateSync
8
8
  # @!attribute [rw] type
9
- # @return [String, nil]
9
+ # @return [String, nil] `"contact"` is the only kind Meta documents.
10
10
  attr_accessor :type
11
11
 
12
12
  # @!attribute [rw] action
@@ -14,13 +14,22 @@ module Whatsapp
14
14
  attr_accessor :action
15
15
 
16
16
  # @!attribute [rw] contact
17
- # @return [Hash] Left untyped — Meta's docs don't publish this entry's schema.
17
+ # @return [Contact, nil] The synced address-book entry.
18
18
  attr_accessor :contact
19
19
 
20
- def initialize(type:, action:, contact:)
20
+ # @!attribute [rw] timestamp
21
+ # @return [String, nil] Flattened up from this entry's own `metadata.timestamp`.
22
+ # That nested object is *not* {Whatsapp::Webhook::Metadata} — it carries only a
23
+ # timestamp, so the wrapper is dropped rather than typed, the same habit as
24
+ # {Contact#profile_name}. Should Meta add a sibling key there, this becomes a
25
+ # class instead.
26
+ attr_accessor :timestamp
27
+
28
+ def initialize(type:, action:, contact: nil, timestamp: nil)
21
29
  @type = type
22
30
  @action = action
23
31
  @contact = contact
32
+ @timestamp = timestamp
24
33
  end
25
34
 
26
35
  class << self
@@ -29,7 +38,12 @@ module Whatsapp
29
38
  def deserialize(data)
30
39
  data ||= {}
31
40
 
32
- new(type: data["type"], action: data["action"], contact: data["contact"] || {})
41
+ new(
42
+ type: data["type"],
43
+ action: data["action"],
44
+ contact: data["contact"] ? Contact.deserialize(data["contact"]) : nil,
45
+ timestamp: data.dig("metadata", "timestamp")
46
+ )
33
47
  end
34
48
  end
35
49
  end
@@ -2,21 +2,40 @@
2
2
 
3
3
  module Whatsapp
4
4
  module Webhook
5
- # Contact synchronization for onboarded WhatsApp Business app users.
5
+ # Contact synchronization for onboarded WhatsApp Business app users: the address book
6
+ # on the device, mirrored to your integration as entries are added, changed, or removed.
6
7
  #
7
- # Best-effort schema: Meta's public docs describe this field in one line with
8
- # no published JSON example; each entry's `contact` shape isn't confidently
9
- # typeable from that description alone, so it's kept as a raw hash rather
10
- # than guessing further. Validate against a real payload before relying on
11
- # this in production.
12
- # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview
8
+ # Meta's BSUID documentation publishes the synced contact's shape, so
9
+ # {StateSync#contact} is a typed {Contact} rather than a raw hash. The array key
10
+ # itself (`state_sync`) is still inferred from the field name — hence {#raw}.
11
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
13
12
  class SmbAppStateSync
13
+ # @!attribute [rw] messaging_product
14
+ # @return [String, nil]
15
+ attr_accessor :messaging_product
16
+
17
+ # @!attribute [rw] metadata
18
+ # @return [Metadata, nil] The business phone number whose address book this is —
19
+ # not the per-entry metadata, whose timestamp is flattened onto {StateSync}.
20
+ attr_accessor :metadata
21
+
14
22
  # @!attribute [rw] state_sync
15
23
  # @return [Array<StateSync>]
16
24
  attr_accessor :state_sync
17
25
 
18
- def initialize(state_sync:)
26
+ # @!attribute [r] raw
27
+ # @return [Hash] The untouched `value` hash. A diagnostic escape hatch, not part of
28
+ # the supported API: {#state_sync} reads `value["state_sync"]`, a key inferred
29
+ # from the field's own name rather than confirmed by a published example, so a
30
+ # key that drifts would leave the array empty with no other trace. Read-only —
31
+ # it is the object `JSON.parse` returned, not a copy.
32
+ attr_reader :raw
33
+
34
+ def initialize(state_sync:, messaging_product: nil, metadata: nil, raw: {})
35
+ @messaging_product = messaging_product
36
+ @metadata = metadata
19
37
  @state_sync = state_sync
38
+ @raw = raw
20
39
  end
21
40
 
22
41
  class << self
@@ -25,7 +44,12 @@ module Whatsapp
25
44
  def deserialize(data)
26
45
  data ||= {}
27
46
 
28
- new(state_sync: Array(data["state_sync"]).map { |entry| StateSync.deserialize(entry) })
47
+ new(
48
+ messaging_product: data["messaging_product"],
49
+ metadata: data["metadata"] ? Metadata.deserialize(data["metadata"]) : nil,
50
+ state_sync: Array(data["state_sync"]).map { |entry| StateSync.deserialize(entry) },
51
+ raw: data
52
+ )
29
53
  end
30
54
  end
31
55
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module Webhook
5
+ class SmbMessageEchoes
6
+ # One echoed message: who the business sent it *to*, plus the message itself.
7
+ #
8
+ # A wrapper rather than three more accessors on {Message::Base}, deliberately.
9
+ # `Base` is the envelope shared by every inbound message type, and an inbound
10
+ # message can never carry a recipient — Meta's own convention is that a keyword
11
+ # defaults to `nil` when a field is *conditional*, not when it is impossible.
12
+ # Putting `to`/`to_user_id`/`to_parent_user_id` there to serve this one field would
13
+ # hand fifteen classes three permanently-`nil` readers, and would turn the
14
+ # `NoMethodError` that currently tells you `message.to` is meaningless into a silent
15
+ # `nil`. Composing instead keeps {Message.deserialize}'s dispatch untouched, so an
16
+ # echoed `revoke` or `edit` resolves exactly as an inbound one does.
17
+ class Echo
18
+ # @!attribute [rw] to
19
+ # @return [String, nil] The customer's phone number. Omitted once they adopt a
20
+ # username and it is no longer shareable — fall back to {#to_user_id}.
21
+ attr_accessor :to
22
+
23
+ # @!attribute [rw] to_user_id
24
+ # @return [String, nil] The customer's business-scoped user ID (BSUID).
25
+ attr_accessor :to_user_id
26
+
27
+ # @!attribute [rw] to_parent_user_id
28
+ # @return [String, nil] The customer's parent BSUID, present only when enrolled.
29
+ attr_accessor :to_parent_user_id
30
+
31
+ # @!attribute [rw] message
32
+ # @return [Message::Base] The echoed message, dispatched by `type` like any
33
+ # inbound one. Its `from` is the *business* number, not the customer's.
34
+ attr_accessor :message
35
+
36
+ def initialize(message:, to: nil, to_user_id: nil, to_parent_user_id: nil)
37
+ @to = to
38
+ @to_user_id = to_user_id
39
+ @to_parent_user_id = to_parent_user_id
40
+ @message = message
41
+ end
42
+
43
+ class << self
44
+ # @param data [Hash, nil] A raw `message_echoes[]` entry.
45
+ # @return [Echo]
46
+ def deserialize(data)
47
+ data ||= {}
48
+
49
+ new(
50
+ to: data["to"],
51
+ to_user_id: data["to_user_id"],
52
+ to_parent_user_id: data["to_parent_user_id"],
53
+ message: Message.deserialize(data)
54
+ )
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -2,15 +2,14 @@
2
2
 
3
3
  module Whatsapp
4
4
  module Webhook
5
- # Messages sent via the WhatsApp Business App or a linked device, echoed back
6
- # so a Cloud API integration stays in sync with what the business itself sent.
5
+ # Messages sent via the WhatsApp Business App or a linked device, echoed back so a
6
+ # Cloud API integration stays in sync with what the business itself sent — including
7
+ # messages the business revoked or edited from the app.
7
8
  #
8
- # Best-effort schema: Meta's public docs describe this field in one line with
9
- # no published JSON example, but do state these mirror the standard inbound
10
- # `messages` shape — hence reusing {Message.deserialize} here. The exact key
11
- # name (`message_echoes`) is a best-effort guess; validate against a real
12
- # payload before relying on it in production.
13
- # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview
9
+ # Meta's BSUID documentation publishes the value-level `contacts` array and the
10
+ # per-echo recipient identifiers. The array key itself (`message_echoes`) remains
11
+ # inferred, hence {#raw}.
12
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
14
13
  class SmbMessageEchoes
15
14
  # @!attribute [rw] messaging_product
16
15
  # @return [String, nil]
@@ -20,14 +19,28 @@ module Whatsapp
20
19
  # @return [Metadata, nil]
21
20
  attr_accessor :metadata
22
21
 
22
+ # @!attribute [rw] contacts
23
+ # @return [Array<Contact>] The customers these messages went to.
24
+ attr_accessor :contacts
25
+
23
26
  # @!attribute [rw] message_echoes
24
- # @return [Array<Message::Base>]
27
+ # @return [Array<Echo>] Each pairs the echoed message with who it was sent to.
25
28
  attr_accessor :message_echoes
26
29
 
27
- def initialize(messaging_product:, metadata:, message_echoes:)
30
+ # @!attribute [r] raw
31
+ # @return [Hash] The untouched `value` hash. A diagnostic escape hatch, not part of
32
+ # the supported API: {#message_echoes} reads `value["message_echoes"]`, a key
33
+ # inferred from the field's own name rather than confirmed by a published
34
+ # example, so a key that drifts would leave the array empty with no other trace.
35
+ # Read-only — it is the object `JSON.parse` returned, not a copy.
36
+ attr_reader :raw
37
+
38
+ def initialize(messaging_product:, metadata:, message_echoes:, contacts: [], raw: {})
28
39
  @messaging_product = messaging_product
29
40
  @metadata = metadata
41
+ @contacts = contacts
30
42
  @message_echoes = message_echoes
43
+ @raw = raw
31
44
  end
32
45
 
33
46
  class << self
@@ -39,7 +52,9 @@ module Whatsapp
39
52
  new(
40
53
  messaging_product: data["messaging_product"],
41
54
  metadata: data["metadata"] ? Metadata.deserialize(data["metadata"]) : nil,
42
- message_echoes: Array(data["message_echoes"]).map { |echo_data| Message.deserialize(echo_data) }
55
+ contacts: Array(data["contacts"]).map { |contact_data| Contact.deserialize(contact_data) },
56
+ message_echoes: Array(data["message_echoes"]).map { |echo_data| Echo.deserialize(echo_data) },
57
+ raw: data
43
58
  )
44
59
  end
45
60
  end
@@ -18,9 +18,22 @@ module Whatsapp
18
18
  attr_accessor :timestamp
19
19
 
20
20
  # @!attribute [rw] recipient_id
21
- # @return [String, nil]
21
+ # @return [String, nil] Omitted when sent to the recipient's BSUID and their
22
+ # phone number is unavailable.
22
23
  attr_accessor :recipient_id
23
24
 
25
+ # @!attribute [rw] recipient_user_id
26
+ # @return [String, nil] The recipient's business-scoped user ID (BSUID). Meta sends
27
+ # it on every status, however the message was addressed — a phone number in `to`
28
+ # does not suppress it. The single exception is a `"failed"` status for a message
29
+ # sent to a phone number, where it is omitted; a `"failed"` status for a
30
+ # BSUID-addressed message still carries it.
31
+ attr_accessor :recipient_user_id
32
+
33
+ # @!attribute [rw] recipient_parent_user_id
34
+ # @return [String, nil] The recipient's parent BSUID, present only when enrolled.
35
+ attr_accessor :recipient_parent_user_id
36
+
24
37
  # @!attribute [rw] conversation
25
38
  # @return [Conversation, nil]
26
39
  attr_accessor :conversation
@@ -33,11 +46,17 @@ module Whatsapp
33
46
  # @return [Array<Error>]
34
47
  attr_accessor :errors
35
48
 
36
- def initialize(id:, status:, timestamp:, recipient_id:, conversation: nil, pricing: nil, errors: [])
49
+ def initialize(
50
+ id:, status:, timestamp:,
51
+ recipient_id: nil, recipient_user_id: nil, recipient_parent_user_id: nil,
52
+ conversation: nil, pricing: nil, errors: []
53
+ )
37
54
  @id = id
38
55
  @status = status
39
56
  @timestamp = timestamp
40
57
  @recipient_id = recipient_id
58
+ @recipient_user_id = recipient_user_id
59
+ @recipient_parent_user_id = recipient_parent_user_id
41
60
  @conversation = conversation
42
61
  @pricing = pricing
43
62
  @errors = errors
@@ -54,6 +73,8 @@ module Whatsapp
54
73
  status: data["status"],
55
74
  timestamp: data["timestamp"],
56
75
  recipient_id: data["recipient_id"],
76
+ recipient_user_id: data["recipient_user_id"],
77
+ recipient_parent_user_id: data["recipient_parent_user_id"],
57
78
  conversation: data["conversation"] ? Conversation.deserialize(data["conversation"]) : nil,
58
79
  pricing: data["pricing"] ? Pricing.deserialize(data["pricing"]) : nil,
59
80
  errors: Array(data["errors"]).map { |error_data| Error.deserialize(error_data) }
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module Webhook
5
+ class UserIdUpdate
6
+ # A single `user_id_update[]` entry: one user's BSUID (and parent BSUID, if
7
+ # enrolled) changed.
8
+ class Entry
9
+ # @!attribute [rw] wa_id
10
+ # @return [String, nil] The user's phone number, if available.
11
+ attr_accessor :wa_id
12
+
13
+ # @!attribute [rw] detail
14
+ # @return [String, nil] Human-readable description of the change.
15
+ attr_accessor :detail
16
+
17
+ # @!attribute [rw] user_id
18
+ # @return [IdChange, nil] The user's previous and current BSUID.
19
+ attr_accessor :user_id
20
+
21
+ # @!attribute [rw] parent_user_id
22
+ # @return [IdChange, nil] The user's previous and current parent BSUID,
23
+ # present only when enrolled.
24
+ attr_accessor :parent_user_id
25
+
26
+ # @!attribute [rw] timestamp
27
+ # @return [String, nil]
28
+ attr_accessor :timestamp
29
+
30
+ def initialize(timestamp:, wa_id: nil, detail: nil, user_id: nil, parent_user_id: nil)
31
+ @wa_id = wa_id
32
+ @detail = detail
33
+ @user_id = user_id
34
+ @parent_user_id = parent_user_id
35
+ @timestamp = timestamp
36
+ end
37
+
38
+ class << self
39
+ # @param data [Hash] A raw `user_id_update[]` entry.
40
+ # @return [Entry]
41
+ def deserialize(data)
42
+ data ||= {}
43
+
44
+ new(
45
+ wa_id: data["wa_id"],
46
+ detail: data["detail"],
47
+ user_id: IdChange.deserialize(data["user_id"]),
48
+ parent_user_id: IdChange.deserialize(data["parent_user_id"]),
49
+ timestamp: data["timestamp"]
50
+ )
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module Webhook
5
+ class UserIdUpdate
6
+ # The `{previous, current}` shape shared by `user_id` and `parent_user_id` on a
7
+ # `user_id_update[]` entry.
8
+ class IdChange
9
+ # @!attribute [rw] previous
10
+ # @return [String, nil]
11
+ attr_accessor :previous
12
+
13
+ # @!attribute [rw] current
14
+ # @return [String, nil]
15
+ attr_accessor :current
16
+
17
+ def initialize(previous:, current:)
18
+ @previous = previous
19
+ @current = current
20
+ end
21
+
22
+ class << self
23
+ # @param data [Hash, nil] The raw `user_id` or `parent_user_id` hash.
24
+ # @return [IdChange, nil] `nil` when the key is absent — an id change that did
25
+ # not happen is not a change from nothing to nothing.
26
+ def deserialize(data)
27
+ return if data.nil?
28
+
29
+ new(previous: data["previous"], current: data["current"])
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module Webhook
5
+ # A WhatsApp user's business-scoped user ID (BSUID) changed — for example, after
6
+ # they changed their phone number (see the `system` message type's
7
+ # `user_changed_user_id` event) or were re-scoped by Meta.
8
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
9
+ class UserIdUpdate
10
+ # @!attribute [rw] messaging_product
11
+ # @return [String, nil]
12
+ attr_accessor :messaging_product
13
+
14
+ # @!attribute [rw] metadata
15
+ # @return [Metadata, nil]
16
+ attr_accessor :metadata
17
+
18
+ # @!attribute [rw] contacts
19
+ # @return [Array<Contact>]
20
+ attr_accessor :contacts
21
+
22
+ # @!attribute [rw] updates
23
+ # @return [Array<Entry>]
24
+ attr_accessor :updates
25
+
26
+ # @!attribute [r] raw
27
+ # @return [Hash] The untouched `value` hash. A diagnostic escape hatch, not part
28
+ # of the supported API: {#updates} reads `value["user_id_update"]` — entries
29
+ # nested under a repeat of the field's own name, which is what Meta's published
30
+ # example shows — so a key that drifts would otherwise leave `updates` empty
31
+ # with no other trace. Reach for it when a typed accessor is unexpectedly
32
+ # `nil`, log what you find, and treat it as read-only: it is the object
33
+ # `JSON.parse` returned, not a copy.
34
+ attr_reader :raw
35
+
36
+ def initialize(messaging_product:, metadata:, contacts:, updates:, raw: {})
37
+ @messaging_product = messaging_product
38
+ @metadata = metadata
39
+ @contacts = contacts
40
+ @updates = updates
41
+ @raw = raw
42
+ end
43
+
44
+ class << self
45
+ # @param data [Hash] The raw `user_id_update` field's `value` hash.
46
+ # @return [UserIdUpdate]
47
+ def deserialize(data)
48
+ data ||= {}
49
+
50
+ new(
51
+ messaging_product: data["messaging_product"],
52
+ metadata: data["metadata"] ? Metadata.deserialize(data["metadata"]) : nil,
53
+ contacts: Array(data["contacts"]).map { |contact_data| Contact.deserialize(contact_data) },
54
+ updates: Array(data["user_id_update"]).map { |entry_data| Entry.deserialize(entry_data) },
55
+ raw: data
56
+ )
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module Webhook
5
+ class UserPreferences
6
+ # A single `user_preferences[]` entry: one user changed one preference.
7
+ class Entry
8
+ # @!attribute [rw] wa_id
9
+ # @return [String, nil] The user's phone number, omitted once they adopt a
10
+ # username and it is no longer shareable. Fall back to {#user_id}.
11
+ attr_accessor :wa_id
12
+
13
+ # @!attribute [rw] user_id
14
+ # @return [String, nil] The user's business-scoped user ID (BSUID).
15
+ attr_accessor :user_id
16
+
17
+ # @!attribute [rw] parent_user_id
18
+ # @return [String, nil] The user's parent BSUID, present only when enrolled.
19
+ attr_accessor :parent_user_id
20
+
21
+ # @!attribute [rw] detail
22
+ # @return [String, nil] Human-readable description of the change.
23
+ attr_accessor :detail
24
+
25
+ # @!attribute [rw] category
26
+ # @return [String, nil] The preference category. `"marketing_messages"` is the
27
+ # only value Meta documents; kept a raw String, never validated on the way in,
28
+ # so a category added later still round-trips.
29
+ attr_accessor :category
30
+
31
+ # @!attribute [rw] value
32
+ # @return [String, nil] The preference itself, e.g. `"stop"` or `"resume"`. Note
33
+ # the name: this sits *inside* the change's own `value` hash, so the full path
34
+ # reads `change.value.preferences.first.value`. Meta's naming, not ours.
35
+ attr_accessor :value
36
+
37
+ # @!attribute [rw] timestamp
38
+ # @return [Integer, String, nil] Unix seconds. Meta's example sends a number
39
+ # here, unlike the string timestamps on `messages`, so it is passed through
40
+ # as-is rather than coerced.
41
+ attr_accessor :timestamp
42
+
43
+ def initialize(
44
+ wa_id: nil, user_id: nil, parent_user_id: nil,
45
+ detail: nil, category: nil, value: nil, timestamp: nil
46
+ )
47
+ @wa_id = wa_id
48
+ @user_id = user_id
49
+ @parent_user_id = parent_user_id
50
+ @detail = detail
51
+ @category = category
52
+ @value = value
53
+ @timestamp = timestamp
54
+ end
55
+
56
+ class << self
57
+ # @param data [Hash, nil] A raw `user_preferences[]` entry.
58
+ # @return [Entry]
59
+ def deserialize(data)
60
+ data ||= {}
61
+
62
+ new(
63
+ wa_id: data["wa_id"],
64
+ user_id: data["user_id"],
65
+ parent_user_id: data["parent_user_id"],
66
+ detail: data["detail"],
67
+ category: data["category"],
68
+ value: data["value"],
69
+ timestamp: data["timestamp"]
70
+ )
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end