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
@@ -14,21 +14,26 @@ module Whatsapp
14
14
  end
15
15
 
16
16
  # @!attribute [rw] title
17
- # @return [String]
17
+ # @return [String, nil]
18
18
  attr_accessor :title
19
19
 
20
20
  # @!attribute [rw] rows
21
21
  # @return [Array<Hash>]
22
22
  attr_accessor :rows
23
23
 
24
- validates :title, presence: true, length: { maximum: Defaults::MAX_TITLE_LENGTH }
24
+ # Optional here: Meta requires a title only when the list has more than one
25
+ # section, which a lone Section cannot see — {ListButtons} enforces that rule.
26
+ validates :title, length: { maximum: Defaults::MAX_TITLE_LENGTH }, allow_nil: true
25
27
  validates :rows, presence: true, length: { minimum: Defaults::MIN_ROWS_LENGTH, maximum: Defaults::MAX_ROWS_LENGTH }
26
28
 
27
- # @param title [String] The title of the section.
29
+ # A blank title (`""` or whitespace) is treated as absent, so it is omitted from
30
+ # the payload rather than sent as an empty heading.
28
31
  # @param rows [Array<Hash>] The rows in the section.
32
+ # @param title [String, nil] The title of the section. Required only when the list
33
+ # has more than one section (enforced by {ListButtons}).
29
34
  # @raise [ActiveModel::ValidationError] if validation fails.
30
- def initialize(title:, rows:)
31
- @title = title
35
+ def initialize(rows:, title: nil)
36
+ @title = title.presence
32
37
  @rows = rows
33
38
 
34
39
  validate!
@@ -47,7 +52,7 @@ module Whatsapp
47
52
  {
48
53
  title:,
49
54
  rows: serialized_rows,
50
- }
55
+ }.compact
51
56
  end
52
57
 
53
58
  private
@@ -23,6 +23,7 @@ module Whatsapp
23
23
  validates :button, presence: true, length: { maximum: Defaults::MAX_BUTTON_LENGTH }
24
24
  validates :sections, presence: true, length: { minimum: Defaults::MIN_SECTIONS_COUNT,
25
25
  maximum: Defaults::MAX_SECTIONS_COUNT, }
26
+ validate :validate_section_titles
26
27
 
27
28
  # @param button [String] The text for the list button.
28
29
  # @param sections [Array<Hash>] The sections of the list button.
@@ -52,6 +53,17 @@ module Whatsapp
52
53
 
53
54
  private
54
55
 
56
+ # Meta requires `sections[].title` only once a list has more than one section, so
57
+ # {Section} leaves it optional and the rule lives here, where the count is known.
58
+ # A blank title counts as missing, matching {Section}, which omits it.
59
+ # @return [void]
60
+ def validate_section_titles
61
+ return if sections.blank? || sections.size <= 1
62
+ return if sections.all? { |section| section[:title].present? }
63
+
64
+ errors.add(:sections, "must each have a title when there is more than one section")
65
+ end
66
+
55
67
  # @return [Array<Hash>] Serialized sections.
56
68
  # @raise [ActiveModel::ValidationError] if validation fails.
57
69
  def serialized_sections
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ class Messages
5
+ class Interactive
6
+ # Asks the recipient to share their phone number, which they hand over with one tap.
7
+ #
8
+ # The interactive-message counterpart to
9
+ # {Whatsapp::MessageTemplates::Button::RequestContactInfo}: use this inside the
10
+ # 24-hour service window, and the template button to open a conversation. Either way
11
+ # this is how you get a phone number back from someone who has adopted a username and
12
+ # reaches you only as a
13
+ # [BSUID](https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids).
14
+ #
15
+ # The only action kind with nothing for the caller to supply — the whole payload is
16
+ # `{ name: "request_contact_info" }` — which is why {Interactive} marks it
17
+ # parameterless and stops requiring an `action:`. Compare
18
+ # {Whatsapp::Messages::LocationRequest}, whose action is the same shape.
19
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
20
+ class RequestContactInfo < Base
21
+ module Defaults
22
+ ACTION_NAME = "request_contact_info"
23
+ end
24
+
25
+ # @!attribute [rw] name
26
+ # @return [String] Always {Defaults::ACTION_NAME}.
27
+ attr_accessor :name
28
+
29
+ validates :name, inclusion: { in: [Defaults::ACTION_NAME] }
30
+
31
+ # @param name [String] The action name. Defaulted, since Meta accepts exactly one
32
+ # value; the keyword exists so a caller spelling out
33
+ # `action: { name: "request_contact_info" }` — mirroring Meta's example payload —
34
+ # still works.
35
+ # @raise [ActiveModel::ValidationError] if `name` is anything else.
36
+ def initialize(name: Defaults::ACTION_NAME)
37
+ @name = name
38
+
39
+ validate!
40
+ end
41
+
42
+ class << self
43
+ # Takes a keyword splat rather than named parameters, because {Interactive} calls
44
+ # it as `serialize(**action)` with an empty hash for this kind.
45
+ # @return [Hash] Serialized representation of the request.
46
+ def serialize(**)
47
+ new(**).serialize
48
+ end
49
+ end
50
+
51
+ # @return [Hash] Serialized representation of the request — no `parameters` key,
52
+ # unlike every other action kind, because there is nothing to parameterize.
53
+ def serialize
54
+ { name: }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -19,6 +19,10 @@ module Whatsapp
19
19
  url_button: { klass: UrlButton, api_type: "cta_url" },
20
20
  media_carousel: { klass: MediaCarousel, api_type: "carousel" },
21
21
  product_carousel: { klass: ProductCarousel, api_type: "product_list" },
22
+ # The one kind whose registry key and wire value coincide — the registry exists
23
+ # because they usually differ, so this is not a copy-paste slip. `parameterless`
24
+ # marks the one kind with nothing for a caller to supply; see #validate_action.
25
+ request_contact_info: { klass: RequestContactInfo, api_type: "request_contact_info", parameterless: true },
22
26
  }.freeze
23
27
 
24
28
  # @!attribute [rw] type
@@ -43,16 +47,17 @@ module Whatsapp
43
47
 
44
48
  validates :type, presence: true, inclusion: { in: ACTION_TYPES.keys }
45
49
  validates :body, presence: true
46
- validates :action, presence: true
50
+ validate :validate_action
47
51
 
48
52
  # @param type [Symbol, String] The action kind (see {ACTION_TYPES}).
49
53
  # @param body [String] The body text of the interactive message.
50
- # @param action [Hash] The action content, forwarded to the action class.
54
+ # @param action [Hash] The action content, forwarded to the action class. Defaults to
55
+ # empty, which only the parameterless `:request_contact_info` kind accepts.
51
56
  # @param header [Hash, nil] The header content (optional).
52
57
  # @param footer [Hash, nil] The footer content (optional).
53
- # @param kwargs [Hash] Additional keyword arguments (e.g. :to).
58
+ # @param kwargs [Hash] Additional keyword arguments passed to {Base} (:to / :recipient).
54
59
  # @raise [ActiveModel::ValidationError] if validation fails.
55
- def initialize(type:, body:, action:, header: nil, footer: nil, **)
60
+ def initialize(type:, body:, action: {}, header: nil, footer: nil, **)
56
61
  super(**)
57
62
 
58
63
  @type = type.to_sym
@@ -87,6 +92,19 @@ module Whatsapp
87
92
  ACTION_TYPES.fetch(type)
88
93
  end
89
94
 
95
+ # An empty `action` is blank, so a plain presence validator would reject the one kind
96
+ # that legitimately has nothing to supply. Reads the registry with `[]` rather than
97
+ # the `fetch`-backed #action_config, so an unknown `type` falls through to the
98
+ # inclusion validator above instead of raising KeyError from inside a validator.
99
+ # @return [void]
100
+ def validate_action
101
+ config = ACTION_TYPES[type]
102
+ return if config.nil? || config[:parameterless]
103
+ return if action.present?
104
+
105
+ errors.add(:action, "can't be blank")
106
+ end
107
+
90
108
  # @return [Hash, nil] The serialized header information.
91
109
  def serialized_header
92
110
  return unless header
@@ -5,18 +5,34 @@ module Whatsapp
5
5
  class Response
6
6
  class Contacts
7
7
  # @!attribute [rw] input
8
- # @return [String]
8
+ # @return [String] Whatever you addressed the message to: the phone number, the
9
+ # BSUID or parent BSUID, or a group ID.
9
10
  attr_accessor :input
10
11
 
11
12
  # @!attribute [rw] wa_id
12
- # @return [String]
13
+ # @return [String, nil] The recipient's WhatsApp ID. Present only if the message
14
+ # was sent to their phone number; omitted otherwise.
13
15
  attr_accessor :wa_id
14
16
 
17
+ # @!attribute [rw] user_id
18
+ # @return [String, nil] The recipient's business-scoped user ID (BSUID), or parent
19
+ # BSUID — Meta returns either in this one field, unlike the webhook classes,
20
+ # which separate `user_id` from `parent_user_id`.
21
+ #
22
+ # Omitted whenever the message went to a phone number, **including when both
23
+ # `to` and `recipient` were supplied**, since Meta gives `to` precedence and
24
+ # answers as though `recipient` had not been sent. A `nil` here therefore does
25
+ # not mean the recipient has no BSUID.
26
+ attr_accessor :user_id
27
+
15
28
  # @param input [String] The input value of the contact.
16
- # @param wa_id [String] The WhatsApp ID of the contact.
17
- def initialize(input:, wa_id:)
29
+ # @param wa_id [String, nil] The WhatsApp ID of the contact, if it was addressed by
30
+ # phone number.
31
+ # @param user_id [String, nil] The contact's BSUID, if it was addressed by one.
32
+ def initialize(input:, wa_id: nil, user_id: nil)
18
33
  @input = input
19
34
  @wa_id = wa_id
35
+ @user_id = user_id
20
36
  end
21
37
 
22
38
  class << self
@@ -26,7 +42,8 @@ module Whatsapp
26
42
  def deserialize(data)
27
43
  new(
28
44
  input: data["input"],
29
- wa_id: data["wa_id"]
45
+ wa_id: data["wa_id"],
46
+ user_id: data["user_id"]
30
47
  )
31
48
  end
32
49
  end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module Schedule
5
+ # Schedules an approved template to be sent to an audience at a set time.
6
+ #
7
+ # A validated instance rather than a bare class method (unlike {List}, which has
8
+ # nothing to check): Meta documents all six body fields as required, so a missing
9
+ # one fails here rather than costing a request. Nothing beyond that is checked —
10
+ # Meta publishes no length limits and no rule that `delivery_time` must be in the
11
+ # future.
12
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/schedules-api
13
+ class Create
14
+ include ActiveModel::Validations
15
+ extend ResponseHandling
16
+ extend Transport
17
+
18
+ # @!attribute [rw] hsm_id
19
+ # @return [String] The ID of the approved template to send.
20
+ attr_accessor :hsm_id
21
+
22
+ # @!attribute [rw] audience_id
23
+ # @return [String] The ID of the audience to send it to.
24
+ attr_accessor :audience_id
25
+
26
+ # @!attribute [rw] waba_cs_id
27
+ # @return [String] What Meta documents only as "WABA customer status ID (phone
28
+ # number)"; whether that means a phone number ID or the number itself is
29
+ # unverified, so it is passed through as given.
30
+ attr_accessor :waba_cs_id
31
+
32
+ # @!attribute [rw] name
33
+ # @return [String] The schedule's name.
34
+ attr_accessor :name
35
+
36
+ # @!attribute [rw] description
37
+ # @return [String] The schedule's description.
38
+ attr_accessor :description
39
+
40
+ # @!attribute [rw] delivery_time
41
+ # @return [Integer] When to send, as a Unix timestamp.
42
+ attr_accessor :delivery_time
43
+
44
+ validates :hsm_id, :audience_id, :waba_cs_id, :name, :description, presence: true
45
+ validates :delivery_time, numericality: { only_integer: true }
46
+
47
+ # @param hsm_id [String] See {#hsm_id}.
48
+ # @param audience_id [String] See {#audience_id}.
49
+ # @param waba_cs_id [String] See {#waba_cs_id}.
50
+ # @param name [String] See {#name}.
51
+ # @param description [String] See {#description}.
52
+ # @param delivery_time [Integer] A Unix timestamp; pass `time.to_i` for a `Time`.
53
+ # @raise [ActiveModel::ValidationError] if a field is blank, or `delivery_time`
54
+ # is not an integer.
55
+ def initialize(hsm_id:, audience_id:, waba_cs_id:, name:, description:, delivery_time:)
56
+ @hsm_id = hsm_id
57
+ @audience_id = audience_id
58
+ @waba_cs_id = waba_cs_id
59
+ @name = name
60
+ @description = description
61
+ @delivery_time = delivery_time
62
+
63
+ validate!
64
+ end
65
+
66
+ # @return [Hash] The schedule payload. Every field is documented required, so
67
+ # none is ever compacted away.
68
+ def serialize
69
+ { hsm_id:, audience_id:, waba_cs_id:, name:, description:, delivery_time: }
70
+ end
71
+
72
+ class << self
73
+ # @example Send a template to an audience in one hour
74
+ # Create.call(
75
+ # hsm_id: "1234567890", audience_id: "2345678901", waba_cs_id: "3456789012",
76
+ # name: "Black Friday", description: "Black Friday promo to VIP customers",
77
+ # delivery_time: Time.now.to_i + 3600
78
+ # ).id
79
+ # @param hsm_id [String] See {#initialize}.
80
+ # @param audience_id [String] See {#initialize}.
81
+ # @param waba_cs_id [String] See {#initialize}.
82
+ # @param name [String] See {#initialize}.
83
+ # @param description [String] See {#initialize}.
84
+ # @param delivery_time [Integer] See {#initialize}.
85
+ # @param client [Whatsapp::Client] The WhatsApp client instance.
86
+ # @return [Response::Created]
87
+ # @raise [ActiveModel::ValidationError] if a field is blank, or `delivery_time`
88
+ # is not an integer.
89
+ # @raise [Error] if no WABA ID is configured, or the request fails.
90
+ def call(hsm_id:, audience_id:, waba_cs_id:, name:, description:, delivery_time:, client: Client.new)
91
+ body = new(hsm_id:, audience_id:, waba_cs_id:, name:, description:, delivery_time:).serialize
92
+ response = client.connection.post(edge_path(client), json: body)
93
+
94
+ Response::Created.deserialize(
95
+ parse_json(handle_response!(response, error_class: Error, action: "create schedule"))
96
+ )
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Whatsapp
4
- VERSION = "0.4.2"
4
+ VERSION = "0.6.2"
5
5
  end