ruby-whatsapp 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1149fca141867e8a80ee6276d8e39ea1a814630964d6d219119fb80c919de35b
4
- data.tar.gz: a2d1a15a8a17f9b0065cf36ded985a329b35b9a7e7ef6408b0ba817551d90085
3
+ metadata.gz: 8b4ff5c9b9ee2bea36b889af8a3d153bb2e166d233ad9bfc51e20f9ec19ab9e7
4
+ data.tar.gz: 8d419f24aae3b21d0218bb95c68c4f75dec56ebdafb4c0447d7ec29e8def7b7b
5
5
  SHA512:
6
- metadata.gz: 6bd1b41cd41a1745923e23445d1df5fc4af58bcccf52c3e88346b5064276bcc0d046f25e23a7905987393e5dab13571b04f92fcde7fbf1cd8a2bde6ede839330
7
- data.tar.gz: a4e8dbb9bafa710512201d6b1e588c9b0193e64a43f3afc4cb08acec5a5fc45f1ce80ec3f5fe3da98f876b0a4cf26bbf6ef432d9f3305d6cab15ef05502bf14f
6
+ metadata.gz: 9c7d6fbfe3783788238af4ec52c683a122099f85fd7c408d94e09bc9a672b5594167e2de6bc05dfb85148f517bfee8c159d7f3f579191ae66020d05f3797cac5
7
+ data.tar.gz: 0d55554385a2fb98318996292131a59c64bed7206766d3b20d7c3033077232e02d5a24878db7e31b12e66b35270698ad892f1e0759468ae656baeda39e271f92
data/CHANGELOG.md CHANGED
@@ -1,4 +1,14 @@
1
- ## [Unreleased]
1
+ ## [0.4.0] - 2026-08-14
2
+
3
+ - Add subscribed apps management: `Whatsapp::SubscribedApp::List`, `::Subscribe`, and `::Unsubscribe` wrap the `subscribed_apps` edge — the switch that turns a WABA's webhook delivery on or off, as opposed to `Whatsapp::Webhook`, which only deserializes notifications once Meta is already sending them. Responses expose `Collection`, `Subscription`, and `Unsubscription`, each composed from a shared `App` value object.
4
+ - Add business phone number registration: `Whatsapp::BusinessPhoneNumber::Register` and `::Deregister` wrap the two endpoints that make a phone number usable — or not — with Cloud API. `Register` validates the 6-digit two-step verification PIN and the `data_localization_region` locally before any request is made.
5
+ - Add the phone number verification flow that precedes registration: `Whatsapp::BusinessPhoneNumber::RequestCode` sends a verification code by SMS or voice call, and `::VerifyCode` confirms it, completing the `RequestCode` → `VerifyCode` → `Register` onboarding sequence. `Response` now carries an optional `id`, returned by `VerifyCode`.
6
+
7
+ ## [0.3.1] - 2026-08-13
8
+
9
+ - Fix `Client#inspect` leaking the raw `api_key` in its default output — and, through it, any object holding a client (`Media#inspect`, `MessageTemplates#inspect`). Now redacted the same way `Configuration#inspect` already was. If a token may have reached an error tracker or console output that captures local variables, rotate it.
10
+ - Fix `Client#path_for` passing IDs into the request path unencoded. A caller-supplied ID (e.g. `template_id`, `media_id`) could inject a query string or redirect an authenticated request to a different Graph API edge. Segments are now percent-encoded.
11
+ - Fix `Webhook::Signature.valid?` raising `TypeError` instead of returning `false` for a `nil` payload.
2
12
 
3
13
  ## [0.3.0] - 2026-08-05
4
14
 
data/README.md CHANGED
@@ -30,6 +30,8 @@ A small, dependency-light Ruby client for the [Meta WhatsApp Cloud API](https://
30
30
  - [Handling Responses](#handling-responses)
31
31
  - [Media](#media)
32
32
  - [Managing Templates](#managing-templates)
33
+ - [Managing Subscribed Apps](#managing-subscribed-apps)
34
+ - [Registering Business Phone Numbers](#registering-business-phone-numbers)
33
35
  - [Webhooks](#webhooks)
34
36
  - [Development](#development)
35
37
  - [Contributing](#contributing)
@@ -64,7 +66,7 @@ Whatsapp.configure do |config|
64
66
  end
65
67
  ```
66
68
 
67
- `api_key`, `app_secret`, and `verify_token` are redacted from `Configuration#inspect`, so they will not leak into logs.
69
+ `api_key`, `app_secret`, and `verify_token` are redacted from `Configuration#inspect` and `Client#inspect`, so they will not leak into logs or error reports.
68
70
 
69
71
  ## Quick Start
70
72
 
@@ -89,6 +91,8 @@ response.messages.first.id # => "wamid.HBgLMTU1NTU1NTU1NTUV..."
89
91
  - **Hardened media downloads** — `Media#download` refuses to attach the bearer token to non-HTTPS URLs or hosts outside an allowlist, so a token can never leak to an attacker-influenced URL.
90
92
  - **Structured response parsing** — `Whatsapp::Messages::Response` exposes typed `#contacts` and `#messages` instead of raw JSON.
91
93
  - **Template management** — create, list, edit and delete the message templates on your WhatsApp Business Account from Ruby, with Meta's documented rules checked client-side so a rejection costs a validation error instead of a 24-hour review cycle.
94
+ - **Subscribed apps management** — subscribe or unsubscribe this app from a WhatsApp Business Account's webhook notifications, and list who's currently subscribed, straight from Ruby.
95
+ - **Business phone number onboarding** — request and verify a phone number's verification code, then register or deregister it with Cloud API, with the 6-digit two-step verification PIN and local-storage region checked client-side before any request is made.
92
96
  - **Inbound webhook parsing** — a typed object tree for all 19 documented Meta notification field types, plus signature verification.
93
97
 
94
98
  ## Sending Messages
@@ -556,6 +560,155 @@ templates.create_from_library(
556
560
  `message_template_status_update` and friends under [Webhooks](#webhooks).
557
561
  - Text containing `#{{1}}` needs single quotes in Ruby, or `#{` starts interpolation.
558
562
 
563
+ ## Managing Subscribed Apps
564
+
565
+ Before your app receives any [webhook](#webhooks) notifications for a WhatsApp
566
+ Business Account, it needs to be subscribed to it. `Whatsapp::SubscribedApp` wraps the
567
+ `subscribed_apps` edge: one class per action, since — unlike templates — these three
568
+ actions don't share an identity or validation rules to justify one combined class.
569
+
570
+ This addresses your **WhatsApp Business Account** (`waba_id`, not `phone_id`) and needs
571
+ the `whatsapp_business_management` permission, same as [template management](#managing-templates).
572
+
573
+ ```ruby
574
+ Whatsapp.configure do |config|
575
+ config.api_key = ENV["WHATSAPP_API_KEY"]
576
+ config.waba_id = ENV["WHATSAPP_WABA_ID"]
577
+ end
578
+
579
+ Whatsapp::SubscribedApp::Subscribe.call # subscribe this app
580
+ Whatsapp::SubscribedApp::List.call.map(&:name) # => ["My App"]
581
+ Whatsapp::SubscribedApp::Unsubscribe.call # stop all webhook delivery
582
+ ```
583
+
584
+ ### Subscribing
585
+
586
+ ```ruby
587
+ result = Whatsapp::SubscribedApp::Subscribe.call
588
+ result.success # => true
589
+ result.map(&:name) # => ["My App"] — every app now subscribed, Meta's own echo
590
+ ```
591
+
592
+ Tech Providers routing several WABAs' notifications to different callback URLs pass an
593
+ override instead of relying on the one callback URL configured on the app itself:
594
+
595
+ ```ruby
596
+ Whatsapp::SubscribedApp::Subscribe.call(
597
+ override_callback_uri: "https://example.com/webhooks/acme_corp",
598
+ verify_token: "a-per-account-secret"
599
+ )
600
+ ```
601
+
602
+ ### Listing
603
+
604
+ ```ruby
605
+ apps = Whatsapp::SubscribedApp::List.call
606
+ apps.map(&:name) # Collection is Enumerable
607
+ apps.first.link # => "https://www.facebook.com/games/?app_id=..."
608
+
609
+ Whatsapp::SubscribedApp::List.call(fields: %w[id name]) # restrict the fields returned
610
+ ```
611
+
612
+ ### Unsubscribing
613
+
614
+ ```ruby
615
+ Whatsapp::SubscribedApp::Unsubscribe.call.success # => true
616
+ ```
617
+
618
+ Stops all webhook deliveries for this WABA immediately.
619
+
620
+ Every action accepts an optional `client:` keyword (defaults to a new
621
+ `Whatsapp::Client` built from `Whatsapp.configuration`), and raises
622
+ `Whatsapp::SubscribedApp::Error` if no `waba_id` is configured or the API rejects the
623
+ request.
624
+
625
+ ## Registering Business Phone Numbers
626
+
627
+ A business phone number is unusable with Cloud API until it is **registered** —
628
+ registration is the prerequisite that makes sending, media, and templates work for that
629
+ number at all. `Whatsapp::BusinessPhoneNumber` wraps all four endpoints in that
630
+ onboarding flow:
631
+
632
+ ```
633
+ RequestCode -> VerifyCode -> Register (onboarding)
634
+ (send OTP) (confirm it) (activate on Cloud API)
635
+
636
+ Deregister (the reverse switch)
637
+ ```
638
+
639
+ This addresses your **phone number** (`phone_id`, not `waba_id`), like
640
+ [messages](#sending-messages) and [media](#media), and needs the
641
+ `whatsapp_business_messaging` and `whatsapp_business_management` permissions.
642
+
643
+ ```ruby
644
+ Whatsapp.configure do |config|
645
+ config.api_key = ENV["WHATSAPP_API_KEY"]
646
+ config.phone_id = ENV["WHATSAPP_PHONE_ID"]
647
+ end
648
+
649
+ Whatsapp::BusinessPhoneNumber::RequestCode.call(code_method: "SMS", language: "en_US") # send an OTP
650
+ Whatsapp::BusinessPhoneNumber::VerifyCode.call(code: "123456") # confirm it
651
+ Whatsapp::BusinessPhoneNumber::Register.call(pin: "212834") # register with your two-step verification PIN
652
+ Whatsapp::BusinessPhoneNumber::Deregister.call # release the number from Cloud API
653
+ ```
654
+
655
+ ### Requesting a verification code
656
+
657
+ ```ruby
658
+ Whatsapp::BusinessPhoneNumber::RequestCode.call(code_method: "SMS", language: "en_US").success # => true
659
+ ```
660
+
661
+ `code_method` must be `"SMS"` or `"VOICE"`; `language` is the locale for the
662
+ verification message (e.g. `"en_US"`). Both are required — an invalid or missing value
663
+ raises `ActiveModel::ValidationError` before any request is made.
664
+
665
+ ### Verifying the code
666
+
667
+ ```ruby
668
+ result = Whatsapp::BusinessPhoneNumber::VerifyCode.call(code: "123456")
669
+ result.success # => true
670
+ result.id # => the phone number ID, when Meta returns one
671
+ ```
672
+
673
+ ### Registering
674
+
675
+ ```ruby
676
+ result = Whatsapp::BusinessPhoneNumber::Register.call(pin: "212834")
677
+ result.success # => true
678
+ ```
679
+
680
+ `pin` must be exactly 6 digits — the existing two-step verification PIN if one is
681
+ already set, otherwise the PIN to set. An invalid PIN raises
682
+ `ActiveModel::ValidationError` before any request is made.
683
+
684
+ Pass `data_localization_region` to enable local storage in one of Meta's supported
685
+ regions (`AU ID IN JP SG KR DE CH GB BR BH ZA AE CA`):
686
+
687
+ ```ruby
688
+ Whatsapp::BusinessPhoneNumber::Register.call(pin: "212834", data_localization_region: "CH")
689
+ ```
690
+
691
+ ### Deregistering
692
+
693
+ ```ruby
694
+ Whatsapp::BusinessPhoneNumber::Deregister.call.success # => true
695
+ ```
696
+
697
+ Deregistering makes the number unusable with Cloud API and disables local storage — it
698
+ does **not** delete the number or its message history. A number in use with both Cloud
699
+ API and the WhatsApp Business app cannot be deregistered.
700
+
701
+ Every action accepts an optional `client:` keyword (defaults to a new
702
+ `Whatsapp::Client` built from `Whatsapp.configuration` — pass one with a different
703
+ `phone_id:` to target another number), and raises
704
+ `Whatsapp::BusinessPhoneNumber::Error` if no `phone_id` is configured or the API rejects
705
+ the request.
706
+
707
+ > **Rate limits:** `Register`/`Deregister` are capped at 10 requests per business number
708
+ > in a 72-hour moving window; exceeding it returns error `133016` and blocks the
709
+ > operation for the next 72 hours. `RequestCode`/`VerifyCode` document only "standard
710
+ > Graph API rate limits" plus possible additional throttling, with no published number.
711
+
559
712
  ## Webhooks
560
713
 
561
714
  Meta pushes inbound messages, delivery statuses, and ~18 other account/template
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module BusinessPhoneNumber
5
+ # Deregisters a business phone number from Cloud API. This makes the number
6
+ # unusable with Cloud API and disables local storage — it does not delete the
7
+ # number or its message history.
8
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-phone-number/phone-number-deregister-api
9
+ class Deregister
10
+ extend ResponseHandling
11
+ extend Transport
12
+
13
+ module Defaults
14
+ EDGE = "deregister"
15
+ end
16
+
17
+ # Meta documents no parameters for this edge — nothing to validate, so unlike
18
+ # {Register} this class carries no attributes and no `ActiveModel::Validations`.
19
+ # @return [Hash] Always empty.
20
+ def serialize
21
+ {}
22
+ end
23
+
24
+ class << self
25
+ # @param client [Whatsapp::Client] The WhatsApp client instance.
26
+ # @return [Response]
27
+ # @raise [Error] if no phone number ID is configured, or the request fails.
28
+ def call(client: Client.new)
29
+ response = client.connection.post(edge_path(client, Defaults::EDGE), json: new.serialize)
30
+
31
+ Response.deserialize(
32
+ parse_json(handle_response!(response, error_class: Error, action: "deregister business phone number"))
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module BusinessPhoneNumber
5
+ # Registers a business phone number with Cloud API, consuming its two-step
6
+ # verification PIN. This is the prerequisite for every other endpoint in this
7
+ # gem — a number cannot send, receive, or be otherwise addressed until it is
8
+ # registered.
9
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/registration
10
+ class Register
11
+ include ActiveModel::Validations
12
+ extend ResponseHandling
13
+ extend Transport
14
+
15
+ module Defaults
16
+ MESSAGING_PRODUCT = "whatsapp"
17
+ EDGE = "register"
18
+ PIN_PATTERN = /\A\d{6}\z/
19
+ end
20
+
21
+ # The regions Meta supports for local storage, grouped as Meta's docs group
22
+ # them. 2-letter ISO 3166 country codes.
23
+ module DataLocalizationRegions
24
+ APAC = %w[AU ID IN JP SG KR].freeze
25
+ EUROPE = %w[DE CH GB].freeze
26
+ LATAM = %w[BR].freeze
27
+ MEA = %w[BH ZA AE].freeze
28
+ NORAM = %w[CA].freeze
29
+
30
+ ALL = [*APAC, *EUROPE, *LATAM, *MEA, *NORAM].freeze
31
+
32
+ # Normalizes caller input to a canonical uppercase region code.
33
+ # Unrecognized values are returned untouched so the inclusion validator can
34
+ # report them.
35
+ # @param value [String, Symbol, nil]
36
+ # @return [String, nil]
37
+ def self.normalize(value)
38
+ return if value.nil?
39
+
40
+ candidate = value.to_s.upcase
41
+ ALL.include?(candidate) ? candidate : value.to_s
42
+ end
43
+ end
44
+
45
+ # @!attribute [rw] pin
46
+ # @return [String] The 6-digit two-step verification PIN.
47
+ attr_accessor :pin
48
+
49
+ # @!attribute [rw] data_localization_region
50
+ # @return [String, nil] A 2-letter ISO 3166 country code enabling local storage.
51
+ attr_accessor :data_localization_region
52
+
53
+ validate :validate_pin
54
+ validates :data_localization_region, inclusion: { in: DataLocalizationRegions::ALL }, allow_nil: true
55
+
56
+ # @param pin [String, Integer] The 6-digit two-step verification PIN. If
57
+ # two-step verification is already enabled on the number, this must be the
58
+ # existing PIN.
59
+ # @param data_localization_region [String, Symbol, nil] One of
60
+ # {DataLocalizationRegions::ALL}, either casing.
61
+ # @raise [ActiveModel::ValidationError] if validation fails.
62
+ def initialize(pin:, data_localization_region: nil)
63
+ @pin = pin
64
+ @data_localization_region = DataLocalizationRegions.normalize(data_localization_region)
65
+
66
+ validate!
67
+ end
68
+
69
+ # @return [Hash] The registration payload.
70
+ def serialize
71
+ { messaging_product: Defaults::MESSAGING_PRODUCT, pin: pin.to_s, data_localization_region: }.compact
72
+ end
73
+
74
+ # Redacts the pin so it never leaks into logs or console output.
75
+ # @return [String]
76
+ def inspect
77
+ "#<#{self.class.name} pin=[REDACTED] data_localization_region=#{data_localization_region.inspect}>"
78
+ end
79
+
80
+ class << self
81
+ # @param pin [String, Integer] See {#initialize}.
82
+ # @param client [Whatsapp::Client] The WhatsApp client instance.
83
+ # @param data_localization_region [String, Symbol, nil] See {#initialize}.
84
+ # @return [Response]
85
+ # @raise [ActiveModel::ValidationError] if the pin or region is invalid.
86
+ # @raise [Error] if no phone number ID is configured, or the request fails.
87
+ def call(pin:, client: Client.new, data_localization_region: nil)
88
+ body = new(pin:, data_localization_region:).serialize
89
+ response = client.connection.post(edge_path(client, Defaults::EDGE), json: body)
90
+
91
+ Response.deserialize(
92
+ parse_json(handle_response!(response, error_class: Error, action: "register business phone number"))
93
+ )
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ # @return [void]
100
+ def validate_pin
101
+ errors.add(:pin, "must be exactly 6 digits") unless pin.to_s.match?(Defaults::PIN_PATTERN)
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module BusinessPhoneNumber
5
+ # Requests a verification code (by SMS or voice call) for a business phone number —
6
+ # the first step of the onboarding flow this module wraps end to end:
7
+ # `RequestCode` -> {VerifyCode} -> {Register}.
8
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-phone-number/phone-number-verification-request-code-api
9
+ class RequestCode
10
+ include ActiveModel::Validations
11
+ extend ResponseHandling
12
+ extend Transport
13
+
14
+ module Defaults
15
+ EDGE = "request_code"
16
+ end
17
+
18
+ # How Meta delivers the verification code.
19
+ module CodeMethods
20
+ SMS = "SMS"
21
+ VOICE = "VOICE"
22
+
23
+ ALL = [SMS, VOICE].freeze
24
+
25
+ # Normalizes caller input to a canonical uppercase code method.
26
+ # Unrecognized values are returned as a string (preserving the original
27
+ # casing) so the inclusion validator can reject them.
28
+ # @param value [String, Symbol, nil]
29
+ # @return [String, nil]
30
+ def self.normalize(value)
31
+ return if value.nil?
32
+
33
+ candidate = value.to_s.upcase
34
+ ALL.include?(candidate) ? candidate : value.to_s
35
+ end
36
+ end
37
+
38
+ # @!attribute [rw] code_method
39
+ # @return [String] One of {CodeMethods::ALL}.
40
+ attr_accessor :code_method
41
+
42
+ # @!attribute [rw] language
43
+ # @return [String] The locale for the verification message (e.g. `"en_US"`).
44
+ attr_accessor :language
45
+
46
+ validates :code_method, presence: true, inclusion: { in: CodeMethods::ALL }
47
+ validates :language, presence: true
48
+
49
+ # @param code_method [String, Symbol] One of {CodeMethods::ALL}, either casing.
50
+ # @param language [String] The locale for the verification message. Meta
51
+ # publishes no enum for this field, so only presence is checked — unlike
52
+ # {Register}'s `pin`, which has a documented format.
53
+ # @raise [ActiveModel::ValidationError] if validation fails.
54
+ def initialize(code_method:, language:)
55
+ @code_method = CodeMethods.normalize(code_method)
56
+ @language = language
57
+
58
+ validate!
59
+ end
60
+
61
+ # @return [Hash] The request-code payload. Both fields are documented required,
62
+ # so neither is ever compacted away.
63
+ def serialize
64
+ { code_method:, language: }
65
+ end
66
+
67
+ class << self
68
+ # @param code_method [String, Symbol] See {#initialize}.
69
+ # @param language [String] See {#initialize}.
70
+ # @param client [Whatsapp::Client] The WhatsApp client instance.
71
+ # @return [Response]
72
+ # @raise [ActiveModel::ValidationError] if the code method or language is invalid.
73
+ # @raise [Error] if no phone number ID is configured, or the request fails.
74
+ def call(code_method:, language:, client: Client.new)
75
+ body = new(code_method:, language:).serialize
76
+ response = client.connection.post(edge_path(client, Defaults::EDGE), json: body)
77
+
78
+ Response.deserialize(
79
+ parse_json(handle_response!(response, error_class: Error, action: "request verification code"))
80
+ )
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module BusinessPhoneNumber
5
+ # The response shared by all four phone-number actions: `{success}`, with an
6
+ # optional `id` that only `VerifyCode` ever populates. One class covers all of
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.
10
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/registration
11
+ class Response
12
+ # @!attribute [rw] success
13
+ # @return [Boolean] Whether the action succeeded.
14
+ attr_accessor :success
15
+
16
+ # @!attribute [rw] id
17
+ # @return [String, nil] The phone number ID, only ever present on
18
+ # {VerifyCode}'s response.
19
+ attr_accessor :id
20
+
21
+ # @param success [Boolean]
22
+ # @param id [String, nil]
23
+ def initialize(success: false, id: nil)
24
+ @success = success
25
+ @id = id
26
+ end
27
+
28
+ class << self
29
+ # @param response [Hash, nil] The parsed response body.
30
+ # @return [Response]
31
+ def deserialize(response)
32
+ response ||= {}
33
+
34
+ new(success: response["success"] == true, id: response["id"])
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
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).
9
+ module Transport
10
+ private
11
+
12
+ # @param client [Whatsapp::Client]
13
+ # @param action [String] The edge to address ("register", "deregister",
14
+ # "request_code", or "verify_code").
15
+ # @return [String] The versioned path to the phone number's action edge.
16
+ # @raise [Error] if no phone number ID is configured.
17
+ 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)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module BusinessPhoneNumber
5
+ # Confirms the verification code sent by {RequestCode}, the second step of the
6
+ # onboarding flow this module wraps end to end: {RequestCode} -> `VerifyCode` ->
7
+ # {Register}.
8
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-phone-number/verify-code-api
9
+ class VerifyCode
10
+ include ActiveModel::Validations
11
+ extend ResponseHandling
12
+ extend Transport
13
+
14
+ module Defaults
15
+ EDGE = "verify_code"
16
+ end
17
+
18
+ # @!attribute [rw] code
19
+ # @return [String] The verification code received via SMS or voice call.
20
+ attr_accessor :code
21
+
22
+ validates :code, presence: true
23
+
24
+ # @param code [String, Integer] The verification code received via SMS or voice
25
+ # call. Meta documents this as a plain string with no format rule, unlike
26
+ # {Register}'s `pin`, so only presence is checked.
27
+ # @raise [ActiveModel::ValidationError] if validation fails.
28
+ def initialize(code:)
29
+ @code = code
30
+
31
+ validate!
32
+ end
33
+
34
+ # @return [Hash] The verify-code payload.
35
+ def serialize
36
+ { code: code.to_s }
37
+ end
38
+
39
+ # Redacts the code so it never leaks into logs or console output, same as
40
+ # {Register#inspect} redacts the pin.
41
+ # @return [String]
42
+ def inspect
43
+ "#<#{self.class.name} code=[REDACTED]>"
44
+ end
45
+
46
+ class << self
47
+ # @param code [String, Integer] See {#initialize}.
48
+ # @param client [Whatsapp::Client] The WhatsApp client instance.
49
+ # @return [Response]
50
+ # @raise [ActiveModel::ValidationError] if the code is blank.
51
+ # @raise [Error] if no phone number ID is configured, or the request fails.
52
+ def call(code:, client: Client.new)
53
+ body = new(code:).serialize
54
+ response = client.connection.post(edge_path(client, Defaults::EDGE), json: body)
55
+
56
+ Response.deserialize(
57
+ parse_json(handle_response!(response, error_class: Error, action: "verify code"))
58
+ )
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ # Onboards and deboards a business phone number with Cloud API: requesting and
5
+ # verifying a phone number's verification code, then registering or deregistering it.
6
+ #
7
+ # This is the switch that makes a phone number usable — or not — with Cloud API in
8
+ # the first place. It is a different concern from {Whatsapp::SubscribedApp}, which
9
+ # turns webhook delivery on and off for a whole WhatsApp Business Account: this
10
+ # module is per phone number and is a prerequisite for messaging itself, not just
11
+ # for notifications. Addresses `phone_id`, like {Whatsapp::Media} and
12
+ # {Whatsapp::Messages}, not `waba_id`.
13
+ #
14
+ # The full flow, in order: {RequestCode} -> {VerifyCode} -> {Register}, with
15
+ # {Deregister} as the reverse switch.
16
+ # See `lib/ruby/whatsapp/business_phone_number/CLAUDE.md` and
17
+ # `docs/business-phone-number-api.md`.
18
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/registration
19
+ module BusinessPhoneNumber
20
+ class Error < Whatsapp::Error; end
21
+ end
22
+ end
@@ -5,6 +5,9 @@ module Whatsapp
5
5
  # Default request timeout in seconds.
6
6
  DEFAULT_TIMEOUT = 30
7
7
 
8
+ # Characters that must be percent-escaped in a path segment (everything except RFC 3986 "unreserved").
9
+ SEGMENT_UNRESERVED = /[^A-Za-z0-9\-._~]/
10
+
8
11
  # @!attribute [rw] host
9
12
  # @return [String]
10
13
  attr_accessor :host
@@ -69,14 +72,32 @@ module Whatsapp
69
72
  end
70
73
  end
71
74
 
72
- # Builds a versioned request path.
75
+ # Builds a versioned request path. Segments are percent-encoded so a caller-supplied
76
+ # ID can't inject a query string or redirect to a different Graph edge.
73
77
  # @example
74
78
  # path_for(phone_id, "messages") #=> "/v24.0/<phone_id>/messages"
75
79
  # path_for(media_id) #=> "/v24.0/<media_id>"
76
80
  # @param segments [Array<String>] Path segments appended after the version.
77
81
  # @return [String] The full request path.
78
82
  def path_for(*segments)
79
- "/#{[version, *segments].join('/')}"
83
+ "/#{[version, *segments].map { |segment| encode_segment(segment) }.join('/')}"
84
+ end
85
+
86
+ # Redacts the api_key so it never leaks into logs or console output.
87
+ # @return [String]
88
+ def inspect
89
+ redacted_api_key = api_key.nil? ? "nil" : "[REDACTED]"
90
+ "#<#{self.class.name} host=#{host.inspect} version=#{version.inspect} " \
91
+ "api_key=#{redacted_api_key} phone_id=#{phone_id.inspect} waba_id=#{waba_id.inspect}>"
92
+ end
93
+
94
+ private
95
+
96
+ # Percent-encodes one path segment, byte-wise so multibyte input is handled correctly.
97
+ # @param segment [#to_s] The segment to encode.
98
+ # @return [String]
99
+ def encode_segment(segment)
100
+ segment.to_s.b.gsub(SEGMENT_UNRESERVED) { |byte| format("%%%02X", byte.ord) }
80
101
  end
81
102
  end
82
103
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module SubscribedApp
5
+ # Lists the apps currently subscribed to a WABA's webhook notifications.
6
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/subscribed-apps-api
7
+ class List
8
+ extend ResponseHandling
9
+ extend Transport
10
+
11
+ class << self
12
+ # @param client [Whatsapp::Client] The WhatsApp client instance.
13
+ # @param fields [Array<String>, String, nil] Restrict each app to these fields
14
+ # (`id`, `name`, `link`).
15
+ # @return [Response::Collection]
16
+ # @raise [Error] if no WABA ID is configured, or the request fails.
17
+ def call(client: Client.new, fields: nil)
18
+ params = fields ? { fields: Array(fields).join(",") } : {}
19
+ response = client.connection.get(edge_path(client), params:)
20
+
21
+ Response::Collection.deserialize(
22
+ parse_json(handle_response!(response, error_class: Error, action: "list subscribed apps"))
23
+ )
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module SubscribedApp
5
+ module Response
6
+ # One app subscribed to a WABA's webhook notifications.
7
+ #
8
+ # Meta nests `id`/`name`/`link` inside `whatsapp_business_api_data`; this class
9
+ # flattens that away since the wrapper carries no information of its own.
10
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/subscribed-apps-api
11
+ class App
12
+ # @!attribute [rw] id
13
+ # @return [String, nil] The subscribed app's ID.
14
+ attr_accessor :id
15
+
16
+ # @!attribute [rw] name
17
+ # @return [String, nil] The subscribed app's name.
18
+ attr_accessor :name
19
+
20
+ # @!attribute [rw] link
21
+ # @return [String, nil] A link to the app.
22
+ attr_accessor :link
23
+
24
+ # @!attribute [rw] override_callback_uri
25
+ # @return [String, nil] The per-WABA webhook callback override, if one is set.
26
+ attr_accessor :override_callback_uri
27
+
28
+ # @param id [String, nil]
29
+ # @param name [String, nil]
30
+ # @param link [String, nil]
31
+ # @param override_callback_uri [String, nil]
32
+ def initialize(id: nil, name: nil, link: nil, override_callback_uri: nil)
33
+ @id = id
34
+ @name = name
35
+ @link = link
36
+ @override_callback_uri = override_callback_uri
37
+ end
38
+
39
+ class << self
40
+ # @param data [Hash, nil] One element of the `data` array.
41
+ # @return [App]
42
+ def deserialize(data)
43
+ data ||= {}
44
+ api_data = data["whatsapp_business_api_data"] || {}
45
+
46
+ new(
47
+ id: api_data["id"],
48
+ name: api_data["name"],
49
+ link: api_data["link"],
50
+ override_callback_uri: data["override_callback_uri"]
51
+ )
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module SubscribedApp
5
+ module Response
6
+ # The apps currently subscribed to a WABA's webhook notifications: `{data}`.
7
+ #
8
+ # Enumerable over its apps, so `list.map(&:name)` reads naturally without
9
+ # reaching for `.data` first.
10
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/subscribed-apps-api
11
+ class Collection
12
+ include Enumerable
13
+
14
+ # @!attribute [rw] data
15
+ # @return [Array<App>]
16
+ attr_accessor :data
17
+
18
+ # @param data [Array<App>]
19
+ def initialize(data: [])
20
+ @data = data
21
+ end
22
+
23
+ class << self
24
+ # @param response [Hash, nil] The parsed response body.
25
+ # @return [Collection]
26
+ def deserialize(response)
27
+ response ||= {}
28
+
29
+ new(data: Array(response["data"]).map { |entry| App.deserialize(entry) })
30
+ end
31
+ end
32
+
33
+ # @yieldparam app [App]
34
+ # @return [Enumerator]
35
+ def each(&)
36
+ data.each(&)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module SubscribedApp
5
+ module Response
6
+ # The response to subscribing this app to a WABA's webhook notifications:
7
+ # `{success, data}`.
8
+ #
9
+ # `data` mirrors {Collection}'s shape exactly, since Meta echoes back every app
10
+ # now subscribed — deserialization is delegated to {Collection.deserialize}
11
+ # rather than duplicating the `App` mapping.
12
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/subscribed-apps-api
13
+ class Subscription
14
+ include Enumerable
15
+
16
+ # @!attribute [rw] success
17
+ # @return [Boolean] Whether the subscription succeeded.
18
+ attr_accessor :success
19
+
20
+ # @!attribute [rw] data
21
+ # @return [Array<App>]
22
+ attr_accessor :data
23
+
24
+ # @param success [Boolean]
25
+ # @param data [Array<App>]
26
+ def initialize(success: false, data: [])
27
+ @success = success
28
+ @data = data
29
+ end
30
+
31
+ class << self
32
+ # @param response [Hash, nil] The parsed response body.
33
+ # @return [Subscription]
34
+ def deserialize(response)
35
+ response ||= {}
36
+
37
+ new(success: response["success"] == true, data: Collection.deserialize(response).to_a)
38
+ end
39
+ end
40
+
41
+ # @yieldparam app [App]
42
+ # @return [Enumerator]
43
+ def each(&)
44
+ data.each(&)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module SubscribedApp
5
+ module Response
6
+ # The response to unsubscribing this app from a WABA's webhook notifications:
7
+ # `{success}`.
8
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/subscribed-apps-api
9
+ class Unsubscription
10
+ # @!attribute [rw] success
11
+ # @return [Boolean] Whether the app was unsubscribed.
12
+ attr_accessor :success
13
+
14
+ # @param success [Boolean]
15
+ def initialize(success: false)
16
+ @success = success
17
+ end
18
+
19
+ class << self
20
+ # @param response [Hash, nil] The parsed response body.
21
+ # @return [Unsubscription]
22
+ def deserialize(response)
23
+ response ||= {}
24
+
25
+ new(success: response["success"] == true)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module SubscribedApp
5
+ # Deserializers for the responses of the `subscribed_apps` edge.
6
+ #
7
+ # Three response shapes, one per action, so a namespace of small classes rather
8
+ # than a single one with optional fields:
9
+ #
10
+ # {Collection} `{data}` List
11
+ # {Subscription} `{success, data}` Subscribe
12
+ # {Unsubscription} `{success}` Unsubscribe
13
+ #
14
+ # Every class follows the gem's `.deserialize(data)` convention and tolerates a nil
15
+ # or partial payload, so a field Meta stops sending cannot raise.
16
+ module Response
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module SubscribedApp
5
+ # Subscribes this app to a WABA's webhook notifications.
6
+ #
7
+ # `override_callback_uri`/`verify_token` are for Tech Providers routing several
8
+ # WABAs' notifications to different callback URLs instead of the one configured on
9
+ # the app itself.
10
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/subscribed-apps-api
11
+ class Subscribe
12
+ extend ResponseHandling
13
+ extend Transport
14
+
15
+ class << self
16
+ # @param client [Whatsapp::Client] The WhatsApp client instance.
17
+ # @param override_callback_uri [String, nil] A per-WABA webhook callback override.
18
+ # @param verify_token [String, nil] The token Meta echoes back if it re-verifies
19
+ # `override_callback_uri`.
20
+ # @return [Response::Subscription]
21
+ # @raise [Error] if no WABA ID is configured, or the request fails.
22
+ def call(client: Client.new, override_callback_uri: nil, verify_token: nil)
23
+ body = { override_callback_uri:, verify_token: }.compact
24
+ response = client.connection.post(edge_path(client), json: body)
25
+
26
+ Response::Subscription.deserialize(
27
+ parse_json(handle_response!(response, error_class: Error, action: "subscribe app"))
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module SubscribedApp
5
+ # The one piece shared by {List}, {Subscribe}, and {Unsubscribe}: all three address
6
+ # the same WABA-scoped edge, differing only in HTTP verb and body. Extended (not
7
+ # included) since those classes are class-method-only.
8
+ module Transport
9
+ private
10
+
11
+ # @param client [Whatsapp::Client]
12
+ # @return [String] The versioned path to the `subscribed_apps` edge.
13
+ # @raise [Error] if no WABA ID is configured.
14
+ 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
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ module SubscribedApp
5
+ # Unsubscribes this app from a WABA's webhook notifications. All webhook
6
+ # deliveries for that WABA stop immediately.
7
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/subscribed-apps-api
8
+ class Unsubscribe
9
+ extend ResponseHandling
10
+ extend Transport
11
+
12
+ class << self
13
+ # @param client [Whatsapp::Client] The WhatsApp client instance.
14
+ # @return [Response::Unsubscription]
15
+ # @raise [Error] if no WABA ID is configured, or the request fails.
16
+ def call(client: Client.new)
17
+ response = client.connection.delete(edge_path(client))
18
+
19
+ Response::Unsubscription.deserialize(
20
+ parse_json(handle_response!(response, error_class: Error, action: "unsubscribe app"))
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whatsapp
4
+ # Subscribes or unsubscribes this app from a WhatsApp Business Account's webhook
5
+ # notifications, and lists which apps are currently subscribed.
6
+ #
7
+ # This is what turns webhook delivery on and off in the first place — the opposite
8
+ # concern from {Whatsapp::Webhook}, which only deserializes notifications once Meta
9
+ # is already sending them. Addresses `waba_id`, not `phone_id`, like
10
+ # {Whatsapp::MessageTemplates}. See `lib/ruby/whatsapp/subscribed_app/CLAUDE.md`.
11
+ # Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/subscribed-apps-api
12
+ module SubscribedApp
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.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -9,13 +9,14 @@ module Whatsapp
9
9
  PREFIX = "sha256="
10
10
 
11
11
  class << self
12
- # @param payload [String] The raw (unparsed) request body.
12
+ # @param payload [String, nil] The raw (unparsed) request body.
13
13
  # @param header [String, nil] The `X-Hub-Signature-256` header value.
14
14
  # @param app_secret [String, nil] The secret to verify against. Defaults to the
15
15
  # globally configured secret; pass an explicit value for multi-tenant apps where
16
16
  # each account has its own Meta App (and therefore its own secret).
17
17
  # @return [Boolean] Whether the header matches the computed signature.
18
18
  def valid?(payload:, header:, app_secret: Whatsapp.configuration.app_secret)
19
+ return false if payload.nil?
19
20
  return false if header.nil? || header.empty?
20
21
  return false if app_secret.nil? || app_secret.empty?
21
22
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-whatsapp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raniery
@@ -83,6 +83,13 @@ files:
83
83
  - README.md
84
84
  - Rakefile
85
85
  - lib/ruby/whatsapp.rb
86
+ - lib/ruby/whatsapp/business_phone_number.rb
87
+ - lib/ruby/whatsapp/business_phone_number/deregister.rb
88
+ - lib/ruby/whatsapp/business_phone_number/register.rb
89
+ - lib/ruby/whatsapp/business_phone_number/request_code.rb
90
+ - lib/ruby/whatsapp/business_phone_number/response.rb
91
+ - lib/ruby/whatsapp/business_phone_number/transport.rb
92
+ - lib/ruby/whatsapp/business_phone_number/verify_code.rb
86
93
  - lib/ruby/whatsapp/client.rb
87
94
  - lib/ruby/whatsapp/configuration.rb
88
95
  - lib/ruby/whatsapp/instrumentation.rb
@@ -171,6 +178,16 @@ files:
171
178
  - lib/ruby/whatsapp/railtie.rb
172
179
  - lib/ruby/whatsapp/request_error.rb
173
180
  - lib/ruby/whatsapp/response_handling.rb
181
+ - lib/ruby/whatsapp/subscribed_app.rb
182
+ - lib/ruby/whatsapp/subscribed_app/list.rb
183
+ - lib/ruby/whatsapp/subscribed_app/response.rb
184
+ - lib/ruby/whatsapp/subscribed_app/response/app.rb
185
+ - lib/ruby/whatsapp/subscribed_app/response/collection.rb
186
+ - lib/ruby/whatsapp/subscribed_app/response/subscription.rb
187
+ - lib/ruby/whatsapp/subscribed_app/response/unsubscription.rb
188
+ - lib/ruby/whatsapp/subscribed_app/subscribe.rb
189
+ - lib/ruby/whatsapp/subscribed_app/transport.rb
190
+ - lib/ruby/whatsapp/subscribed_app/unsubscribe.rb
174
191
  - lib/ruby/whatsapp/utils/language_codes.rb
175
192
  - lib/ruby/whatsapp/version.rb
176
193
  - lib/ruby/whatsapp/webhook.rb