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,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module Schedule
5
+ # Lists a WABA's campaign schedules, one page at a time.
6
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/schedules-api
7
+ class List
8
+ extend ResponseHandling
9
+ extend Transport
10
+
11
+ # The fields Meta documents for a schedule. Meta's default set is every one of
12
+ # these except `id`, so ask for it explicitly when you need it. Offered for
13
+ # convenience, not enforcement — {.call} does not validate `fields` against this
14
+ # list, so a field Meta adds later works without a gem release.
15
+ module Fields
16
+ ID = "id"
17
+ NAME = "name"
18
+ DESCRIPTION = "description"
19
+ DELIVERY_TIME = "delivery_time"
20
+ STATUS = "status"
21
+
22
+ ALL = [ID, NAME, DESCRIPTION, DELIVERY_TIME, STATUS].freeze
23
+ end
24
+
25
+ class << self
26
+ # @example Read every field, including the id the default set leaves out
27
+ # List.call(fields: List::Fields::ALL)
28
+ # @example Fetch the next page
29
+ # page = List.call(limit: 50)
30
+ # List.call(limit: 50, after: page.next_cursor) if page.next_cursor
31
+ # @param client [Whatsapp::Client] The WhatsApp client instance.
32
+ # @param fields [Array<String>, String, nil] Restrict each schedule to these
33
+ # fields; see {Fields::ALL}. Omitted when nil, so Meta returns its defaults.
34
+ # @param limit [Integer, nil] Schedules per page; Meta accepts 1 to 100.
35
+ # @param after [String, nil] Cursor for the page after this one.
36
+ # @param before [String, nil] Cursor for the page before this one.
37
+ # @return [Response::Collection]
38
+ # @raise [Error] if no WABA ID is configured, or the request fails.
39
+ def call(client: Client.new, fields: nil, limit: nil, after: nil, before: nil)
40
+ params = { fields: fields && Array(fields).join(","), limit:, after:, before: }.compact
41
+ response = client.connection.get(edge_path(client), params:)
42
+
43
+ Response::Collection.deserialize(
44
+ parse_json(handle_response!(response, error_class: Error, action: "list schedules"))
45
+ )
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module Schedule
5
+ module Response
6
+ # A page of campaign schedules: `{data, paging}`.
7
+ #
8
+ # Enumerable over its schedules, so `list.select(&:scheduled?)` reads naturally
9
+ # without reaching for `.data` first.
10
+ #
11
+ # `paging` reuses {Whatsapp::MessageTemplates::Response::Paging}: the Graph API
12
+ # cursor shape is identical, and only the cursors are kept, not the
13
+ # `previous`/`next` URLs. As there, paging is exposed rather than followed
14
+ # automatically; pass {#next_cursor} back as `after:` to fetch the next page.
15
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/schedules-api
16
+ class Collection
17
+ include Enumerable
18
+
19
+ # @!attribute [rw] data
20
+ # @return [Array<Node>]
21
+ attr_accessor :data
22
+
23
+ # @!attribute [rw] paging
24
+ # @return [Whatsapp::MessageTemplates::Response::Paging, nil]
25
+ attr_accessor :paging
26
+
27
+ # @param data [Array<Node>]
28
+ # @param paging [Whatsapp::MessageTemplates::Response::Paging, nil]
29
+ def initialize(data: [], paging: nil)
30
+ @data = data
31
+ @paging = paging
32
+ end
33
+
34
+ class << self
35
+ # @param response [Hash, nil] The parsed response body.
36
+ # @return [Collection]
37
+ def deserialize(response)
38
+ response ||= {}
39
+
40
+ new(
41
+ data: Array(response["data"]).map { |node| Node.deserialize(node) },
42
+ paging: response["paging"] && MessageTemplates::Response::Paging.deserialize(response["paging"])
43
+ )
44
+ end
45
+ end
46
+
47
+ # @yieldparam schedule [Node]
48
+ # @return [Enumerator]
49
+ def each(&)
50
+ data.each(&)
51
+ end
52
+
53
+ # The cursor to pass as `after:` to fetch the next page.
54
+ # @return [String, nil]
55
+ def next_cursor
56
+ paging&.after
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module Schedule
5
+ module Response
6
+ # The response to creating a campaign schedule: `{id}`.
7
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/schedules-api
8
+ class Created
9
+ # @!attribute [rw] id
10
+ # @return [String, nil] The new schedule's unique identifier.
11
+ attr_accessor :id
12
+
13
+ # @param id [String, nil]
14
+ def initialize(id: nil)
15
+ @id = id
16
+ end
17
+
18
+ class << self
19
+ # @param data [Hash, nil] The parsed response body.
20
+ # @return [Created]
21
+ def deserialize(data)
22
+ data ||= {}
23
+
24
+ new(id: data["id"])
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module Schedule
5
+ module Response
6
+ # One campaign schedule, as listed by {List}.
7
+ #
8
+ # Every attribute tolerates being absent: Meta returns only the fields asked for,
9
+ # and its default set omits `id`. `status` is kept as the raw string and never
10
+ # validated on the way in; compare it through the predicates or {Statuses}.
11
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/schedules-api
12
+ class Node
13
+ # @!attribute [rw] id
14
+ # @return [String, nil] The schedule's unique identifier.
15
+ attr_accessor :id
16
+
17
+ # @!attribute [rw] name
18
+ # @return [String, nil] The schedule's name.
19
+ attr_accessor :name
20
+
21
+ # @!attribute [rw] description
22
+ # @return [String, nil] The schedule's description.
23
+ attr_accessor :description
24
+
25
+ # @!attribute [rw] delivery_time
26
+ # @return [Integer, nil] The scheduled delivery time, as a Unix timestamp.
27
+ attr_accessor :delivery_time
28
+
29
+ # @!attribute [rw] status
30
+ # @return [String, nil] The raw status string Meta sent; {Statuses::ALL} lists the
31
+ # documented values, and anything else is kept as-is.
32
+ attr_accessor :status
33
+
34
+ # @param id [String, nil]
35
+ # @param name [String, nil]
36
+ # @param description [String, nil]
37
+ # @param delivery_time [Integer, nil]
38
+ # @param status [String, nil]
39
+ def initialize(id: nil, name: nil, description: nil, delivery_time: nil, status: nil)
40
+ @id = id
41
+ @name = name
42
+ @description = description
43
+ @delivery_time = delivery_time
44
+ @status = status
45
+ end
46
+
47
+ class << self
48
+ # @param data [Hash, nil] One element of the `data` array.
49
+ # @return [Node]
50
+ def deserialize(data)
51
+ data ||= {}
52
+
53
+ new(
54
+ id: data["id"],
55
+ name: data["name"],
56
+ description: data["description"],
57
+ delivery_time: data["delivery_time"],
58
+ status: data["status"]
59
+ )
60
+ end
61
+ end
62
+
63
+ # @return [Boolean] Whether the schedule is still waiting for its delivery time.
64
+ def scheduled?
65
+ status == Statuses::SCHEDULED
66
+ end
67
+
68
+ # @return [Boolean] Whether the campaign is being sent right now.
69
+ def sending?
70
+ status == Statuses::SENDING
71
+ end
72
+
73
+ # @return [Boolean] Whether the campaign finished sending.
74
+ def completed?
75
+ status == Statuses::COMPLETED
76
+ end
77
+
78
+ # @return [Boolean] Whether the campaign failed.
79
+ def failed?
80
+ status == Statuses::FAILED
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module Schedule
5
+ # Deserializers for the responses of the `schedules` edge.
6
+ #
7
+ # Two response shapes, one per action, plus the element type of the list:
8
+ #
9
+ # {Collection} `{data, paging}` List
10
+ # {Created} `{id}` Create
11
+ # {Node} one element of `data`
12
+ #
13
+ # Every class follows the gem's `.deserialize(data)` convention and tolerates a nil
14
+ # or partial payload, so a field Meta stops sending cannot raise.
15
+ module Response
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module Schedule
5
+ # The documented states of a campaign schedule.
6
+ #
7
+ # For comparison, not enforcement: {Response::Node#status} keeps whatever string
8
+ # Meta sends, so a status added later still round-trips.
9
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/schedules-api
10
+ module Statuses
11
+ COMPLETED = "COMPLETED"
12
+ FAILED = "FAILED"
13
+ SCHEDULED = "SCHEDULED"
14
+ SENDING = "SENDING"
15
+
16
+ ALL = [COMPLETED, FAILED, SCHEDULED, SENDING].freeze
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module Schedule
5
+ # The one piece shared by {List} and {Create}: both address the same WABA-scoped
6
+ # edge, differing only in HTTP verb and body. Extended (not included) because
7
+ # {#edge_path} is used from class-level `.call` methods (as a private class method).
8
+ #
9
+ # The guard itself and its message live in {Whatsapp::PathBuilding}, shared with every
10
+ # other ID-scoped feature; what stays here is the edge this module addresses.
11
+ module Transport
12
+ include Whatsapp::PathBuilding
13
+
14
+ private
15
+
16
+ # @param client [Whatsapp::Client]
17
+ # @return [String] The versioned path to the `schedules` edge.
18
+ # @raise [Error] if no WABA ID is configured.
19
+ def edge_path(client)
20
+ scoped_path(client, :waba_id, "schedules", error_class: Error, purpose: "schedules")
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ # Lists and creates a WhatsApp Business Account's campaign schedules: an approved
5
+ # template sent to an audience at a set time.
6
+ #
7
+ # Schedules *send* templates that already exist; {Whatsapp::MessageTemplates} is what
8
+ # creates and manages the templates themselves. Addresses `waba_id`, not `phone_id`,
9
+ # like {Whatsapp::MessageTemplates} and {Whatsapp::SubscribedApp}.
10
+ # See `lib/ruby/whatsapp/schedule/CLAUDE.md` and `docs/schedule/README.md`.
11
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/schedules-api
12
+ module Schedule
13
+ class Error < Whatsapp::Error; end
14
+ end
15
+ end
@@ -5,18 +5,19 @@ module Whatsapp
5
5
  # The one piece shared by {List}, {Subscribe}, and {Unsubscribe}: all three address
6
6
  # the same WABA-scoped edge, differing only in HTTP verb and body. Extended (not
7
7
  # included) since those classes are class-method-only.
8
+ #
9
+ # The guard itself and its message live in {Whatsapp::PathBuilding}, shared with every
10
+ # other ID-scoped feature; what stays here is the edge this module addresses.
8
11
  module Transport
12
+ include Whatsapp::PathBuilding
13
+
9
14
  private
10
15
 
11
16
  # @param client [Whatsapp::Client]
12
17
  # @return [String] The versioned path to the `subscribed_apps` edge.
13
18
  # @raise [Error] if no WABA ID is configured.
14
19
  def edge_path(client)
15
- if client.waba_id.nil? || client.waba_id.to_s.empty?
16
- raise Error, "waba_id is required for subscribed apps; set it via Whatsapp.configure or Client.new(waba_id:)"
17
- end
18
-
19
- client.path_for(client.waba_id, "subscribed_apps")
20
+ scoped_path(client, :waba_id, "subscribed_apps", error_class: Error, purpose: "subscribed apps")
20
21
  end
21
22
  end
22
23
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Whatsapp
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.1"
5
5
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module Webhook
5
+ # A business phone number's username status changed — approved, reserved, or deleted.
6
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
7
+ class BusinessUsernameUpdates
8
+ # @!attribute [rw] display_phone_number
9
+ # @return [String, nil]
10
+ attr_accessor :display_phone_number
11
+
12
+ # @!attribute [rw] username
13
+ # @return [String, nil] Omitted when `status` is `"deleted"`.
14
+ attr_accessor :username
15
+
16
+ # @!attribute [rw] status
17
+ # @return [String, nil] `"approved"`, `"reserved"`, or `"deleted"`.
18
+ attr_accessor :status
19
+
20
+ # @!attribute [r] raw
21
+ # @return [Hash] The untouched `value` hash. A diagnostic escape hatch, not part
22
+ # of the supported API: every attribute above is a flat top-level read, so a
23
+ # single renamed key yields an all-`nil` object with no other signal. Reach for
24
+ # it when a typed accessor is unexpectedly `nil`, log what you find, and treat
25
+ # it as read-only: it is the object `JSON.parse` returned, not a copy.
26
+ attr_reader :raw
27
+
28
+ def initialize(display_phone_number: nil, status: nil, username: nil, raw: {})
29
+ @display_phone_number = display_phone_number
30
+ @username = username
31
+ @status = status
32
+ @raw = raw
33
+ end
34
+
35
+ class << self
36
+ # @param data [Hash] The raw `value` hash.
37
+ # @return [BusinessUsernameUpdates]
38
+ def deserialize(data)
39
+ data ||= {}
40
+
41
+ new(
42
+ display_phone_number: data["display_phone_number"],
43
+ username: data["username"],
44
+ status: data["status"],
45
+ raw: data
46
+ )
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Whatsapp
4
4
  module Webhook
5
- # A single `entry[].changes[]` item: `field` names which of Meta's ~19 documented
5
+ # A single `entry[].changes[]` item: `field` names which of Meta's 21 documented
6
6
  # webhook notification types this is, `value` is the deserialized payload for it.
7
7
  #
8
8
  # `FIELDS` is populated incrementally as each field's value class is implemented
@@ -17,6 +17,7 @@ module Whatsapp
17
17
  account_update: AccountUpdate,
18
18
  automatic_events: AutomaticEvents,
19
19
  business_capability_update: BusinessCapabilityUpdate,
20
+ business_username_updates: BusinessUsernameUpdates,
20
21
  history: History,
21
22
  message_template_components_update: MessageTemplateComponentsUpdate,
22
23
  message_template_quality_update: MessageTemplateQualityUpdate,
@@ -29,6 +30,7 @@ module Whatsapp
29
30
  smb_app_state_sync: SmbAppStateSync,
30
31
  smb_message_echoes: SmbMessageEchoes,
31
32
  template_category_update: TemplateCategoryUpdate,
33
+ user_id_update: UserIdUpdate,
32
34
  user_preferences: UserPreferences,
33
35
  }.freeze
34
36
 
@@ -10,13 +10,33 @@ module Whatsapp
10
10
  # @return [String, nil]
11
11
  attr_accessor :profile_name
12
12
 
13
+ # @!attribute [rw] username
14
+ # @return [String, nil] Set only if the user has adopted a username. Read from
15
+ # `profile.username`, where Meta's App Dashboard test payloads put it, falling
16
+ # back to a flat top-level `username` — `user_id`/`parent_user_id` arrive flat
17
+ # on this same hash and Meta publishes no reference schema for the contact
18
+ # object, so both locations are read rather than betting on one.
19
+ attr_accessor :username
20
+
13
21
  # @!attribute [rw] wa_id
14
- # @return [String, nil]
22
+ # @return [String, nil] Omitted once the user adopts a username, unless a
23
+ # recent interaction or the contact book still allows sharing the phone number.
15
24
  attr_accessor :wa_id
16
25
 
17
- def initialize(profile_name:, wa_id:)
26
+ # @!attribute [rw] user_id
27
+ # @return [String, nil] The user's business-scoped user ID (BSUID).
28
+ attr_accessor :user_id
29
+
30
+ # @!attribute [rw] parent_user_id
31
+ # @return [String, nil] The user's parent BSUID, present only when enrolled.
32
+ attr_accessor :parent_user_id
33
+
34
+ def initialize(profile_name: nil, username: nil, wa_id: nil, user_id: nil, parent_user_id: nil)
18
35
  @profile_name = profile_name
36
+ @username = username
19
37
  @wa_id = wa_id
38
+ @user_id = user_id
39
+ @parent_user_id = parent_user_id
20
40
  end
21
41
 
22
42
  class << self
@@ -27,7 +47,10 @@ module Whatsapp
27
47
 
28
48
  new(
29
49
  profile_name: data.dig("profile", "name"),
30
- wa_id: data["wa_id"]
50
+ username: data.dig("profile", "username") || data["username"],
51
+ wa_id: data["wa_id"],
52
+ user_id: data["user_id"],
53
+ parent_user_id: data["parent_user_id"]
31
54
  )
32
55
  end
33
56
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module Webhook
5
+ class History
6
+ # A single `history[]` entry: one chunk of a chat-history sync, plus the counters
7
+ # that tell you how far through the sync it is.
8
+ class Chunk
9
+ # @!attribute [rw] phase
10
+ # @return [Integer, nil] Which sync phase this chunk belongs to.
11
+ attr_accessor :phase
12
+
13
+ # @!attribute [rw] chunk_order
14
+ # @return [Integer, nil] This chunk's position within its phase.
15
+ attr_accessor :chunk_order
16
+
17
+ # @!attribute [rw] progress
18
+ # @return [Integer, nil] Percent complete, 0-100.
19
+ attr_accessor :progress
20
+
21
+ # @!attribute [rw] threads
22
+ # @return [Array<Thread>]
23
+ attr_accessor :threads
24
+
25
+ def initialize(phase: nil, chunk_order: nil, progress: nil, threads: [])
26
+ @phase = phase
27
+ @chunk_order = chunk_order
28
+ @progress = progress
29
+ @threads = threads
30
+ end
31
+
32
+ class << self
33
+ # @param data [Hash, nil] A raw `history[]` entry.
34
+ # @return [Chunk]
35
+ def deserialize(data)
36
+ data ||= {}
37
+ metadata = data["metadata"] || {}
38
+
39
+ new(
40
+ phase: metadata["phase"],
41
+ chunk_order: metadata["chunk_order"],
42
+ progress: metadata["progress"],
43
+ threads: Array(data["threads"]).map { |thread_data| Thread.deserialize(thread_data) }
44
+ )
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module Webhook
5
+ class History
6
+ # One conversation within a history {Chunk}: who it is with, and the messages in it.
7
+ #
8
+ # Named for Meta's `threads[]` key. Nested under {History}, so it never shadows
9
+ # `::Thread` for anything outside this class.
10
+ class Thread
11
+ # @!attribute [rw] id
12
+ # @return [String, nil] The thread's identifier — the user's phone number, omitted
13
+ # if they had already adopted a username when the sync was requested and their
14
+ # number was no longer shareable. Use {#context} to identify them instead.
15
+ attr_accessor :id
16
+
17
+ # @!attribute [rw] context
18
+ # @return [Contact, nil] Who the thread is with (`wa_id`, `user_id`,
19
+ # `parent_user_id`, `username`). Deserialized with the same
20
+ # {Whatsapp::Webhook::Contact} as every other contact entry — its `username`
21
+ # read already falls back to a flat key, which is where Meta puts it here, and
22
+ # its `profile_name` is simply `nil` since a thread context carries no profile.
23
+ attr_accessor :context
24
+
25
+ # @!attribute [rw] messages
26
+ # @return [Array<Message::Base>] The synced messages, dispatched by `type` like
27
+ # any inbound message.
28
+ #
29
+ # Each one also carries a `history_context.status` (its delivery state at sync
30
+ # time), which is **not** modelled: it would put a history-only field on
31
+ # `Message::Base`, the envelope shared by every inbound message type, where it
32
+ # could never appear. Read it from {#raw} instead.
33
+ attr_accessor :messages
34
+
35
+ # @!attribute [r] raw
36
+ # @return [Hash] The untouched thread hash, so the unmodelled per-message
37
+ # `history_context` stays reachable. Read-only; not a copy.
38
+ attr_reader :raw
39
+
40
+ def initialize(id: nil, context: nil, messages: [], raw: {})
41
+ @id = id
42
+ @context = context
43
+ @messages = messages
44
+ @raw = raw
45
+ end
46
+
47
+ class << self
48
+ # @param data [Hash, nil] A raw `threads[]` entry.
49
+ # @return [Thread]
50
+ def deserialize(data)
51
+ data ||= {}
52
+
53
+ new(
54
+ id: data["id"],
55
+ context: data["context"] ? Contact.deserialize(data["context"]) : nil,
56
+ messages: Array(data["messages"]).map { |message_data| Message.deserialize(message_data) },
57
+ raw: data
58
+ )
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end