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 +4 -4
- data/CHANGELOG.md +11 -1
- data/README.md +154 -1
- data/lib/ruby/whatsapp/business_phone_number/deregister.rb +38 -0
- data/lib/ruby/whatsapp/business_phone_number/register.rb +105 -0
- data/lib/ruby/whatsapp/business_phone_number/request_code.rb +85 -0
- data/lib/ruby/whatsapp/business_phone_number/response.rb +39 -0
- data/lib/ruby/whatsapp/business_phone_number/transport.rb +28 -0
- data/lib/ruby/whatsapp/business_phone_number/verify_code.rb +63 -0
- data/lib/ruby/whatsapp/business_phone_number.rb +22 -0
- data/lib/ruby/whatsapp/client.rb +23 -2
- data/lib/ruby/whatsapp/subscribed_app/list.rb +28 -0
- data/lib/ruby/whatsapp/subscribed_app/response/app.rb +57 -0
- data/lib/ruby/whatsapp/subscribed_app/response/collection.rb +41 -0
- data/lib/ruby/whatsapp/subscribed_app/response/subscription.rb +49 -0
- data/lib/ruby/whatsapp/subscribed_app/response/unsubscription.rb +31 -0
- data/lib/ruby/whatsapp/subscribed_app/response.rb +19 -0
- data/lib/ruby/whatsapp/subscribed_app/subscribe.rb +33 -0
- data/lib/ruby/whatsapp/subscribed_app/transport.rb +23 -0
- data/lib/ruby/whatsapp/subscribed_app/unsubscribe.rb +26 -0
- data/lib/ruby/whatsapp/subscribed_app.rb +15 -0
- data/lib/ruby/whatsapp/version.rb +1 -1
- data/lib/ruby/whatsapp/webhook/signature.rb +2 -1
- metadata +18 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8b4ff5c9b9ee2bea36b889af8a3d153bb2e166d233ad9bfc51e20f9ec19ab9e7
|
|
4
|
+
data.tar.gz: 8d419f24aae3b21d0218bb95c68c4f75dec56ebdafb4c0447d7ec29e8def7b7b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9c7d6fbfe3783788238af4ec52c683a122099f85fd7c408d94e09bc9a672b5594167e2de6bc05dfb85148f517bfee8c159d7f3f579191ae66020d05f3797cac5
|
|
7
|
+
data.tar.gz: 0d55554385a2fb98318996292131a59c64bed7206766d3b20d7c3033077232e02d5a24878db7e31b12e66b35270698ad892f1e0759468ae656baeda39e271f92
|
data/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
## [
|
|
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
|
data/lib/ruby/whatsapp/client.rb
CHANGED
|
@@ -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
|
|
@@ -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.
|
|
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
|