ruby-whatsapp 0.4.0 → 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +276 -634
- data/docs/README.md +73 -0
- data/docs/business_phone_number/README.md +232 -0
- data/docs/business_phone_number/account.md +170 -0
- data/docs/business_phone_number/profile.md +220 -0
- data/docs/configuration.md +172 -0
- data/docs/errors.md +208 -0
- data/docs/media/README.md +161 -0
- data/docs/message_templates/README.md +206 -0
- data/docs/message_templates/authentication.md +107 -0
- data/docs/message_templates/carousel.md +103 -0
- data/docs/message_templates/components.md +179 -0
- data/docs/message_templates/library.md +98 -0
- data/docs/message_templates/limited_time_offer.md +94 -0
- data/docs/message_templates/responses.md +173 -0
- data/docs/message_templates/standard.md +138 -0
- data/docs/messages/README.md +135 -0
- data/docs/messages/address.md +86 -0
- data/docs/messages/audio.md +43 -0
- data/docs/messages/contacts.md +129 -0
- data/docs/messages/document.md +49 -0
- data/docs/messages/image.md +59 -0
- data/docs/messages/interactive.md +281 -0
- data/docs/messages/location.md +51 -0
- data/docs/messages/location_request.md +59 -0
- data/docs/messages/mark_message_as_read.md +84 -0
- data/docs/messages/reaction.md +59 -0
- data/docs/messages/sticker.md +40 -0
- data/docs/messages/template.md +166 -0
- data/docs/messages/text.md +55 -0
- data/docs/messages/video.md +41 -0
- data/docs/subscribed_app/README.md +139 -0
- data/docs/webhooks/README.md +209 -0
- data/docs/webhooks/account_alerts.md +44 -0
- data/docs/webhooks/account_review_update.md +29 -0
- data/docs/webhooks/account_update.md +54 -0
- data/docs/webhooks/automatic_events.md +47 -0
- data/docs/webhooks/business_capability_update.md +40 -0
- data/docs/webhooks/history.md +44 -0
- data/docs/webhooks/message_template_components_update.md +46 -0
- data/docs/webhooks/message_template_quality_update.md +49 -0
- data/docs/webhooks/message_template_status_update.md +55 -0
- data/docs/webhooks/messages.md +385 -0
- data/docs/webhooks/partner_solutions.md +35 -0
- data/docs/webhooks/payment_configuration_update.md +40 -0
- data/docs/webhooks/phone_number_name_update.md +44 -0
- data/docs/webhooks/phone_number_quality_update.md +40 -0
- data/docs/webhooks/security.md +43 -0
- data/docs/webhooks/smb_app_state_sync.md +53 -0
- data/docs/webhooks/smb_message_echoes.md +54 -0
- data/docs/webhooks/template_category_update.md +52 -0
- data/docs/webhooks/user_preferences.md +48 -0
- data/lib/ruby/whatsapp/business_phone_number/account/details.rb +149 -0
- data/lib/ruby/whatsapp/business_phone_number/account/get.rb +53 -0
- data/lib/ruby/whatsapp/business_phone_number/account/transport.rb +30 -0
- data/lib/ruby/whatsapp/business_phone_number/account/update.rb +76 -0
- data/lib/ruby/whatsapp/business_phone_number/account.rb +23 -0
- data/lib/ruby/whatsapp/business_phone_number/profile/details.rb +105 -0
- data/lib/ruby/whatsapp/business_phone_number/profile/get.rb +56 -0
- data/lib/ruby/whatsapp/business_phone_number/profile/update.rb +148 -0
- data/lib/ruby/whatsapp/business_phone_number/profile/verticals.rb +54 -0
- data/lib/ruby/whatsapp/business_phone_number/profile.rb +22 -0
- data/lib/ruby/whatsapp/business_phone_number/response.rb +3 -3
- data/lib/ruby/whatsapp/business_phone_number/transport.rb +11 -12
- data/lib/ruby/whatsapp/business_phone_number.rb +11 -4
- data/lib/ruby/whatsapp/message_templates.rb +2 -6
- data/lib/ruby/whatsapp/path_building.rb +33 -0
- data/lib/ruby/whatsapp/subscribed_app/transport.rb +6 -5
- data/lib/ruby/whatsapp/version.rb +1 -1
- metadata +63 -1
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
`Whatsapp.configure` sets the global defaults every `Whatsapp::Client` is built from.
|
|
4
|
+
Call it once at boot.
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
Whatsapp.configure do |config|
|
|
8
|
+
config.api_key = ENV.fetch("WHATSAPP_API_KEY")
|
|
9
|
+
config.phone_id = ENV.fetch("WHATSAPP_PHONE_ID")
|
|
10
|
+
end
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Options
|
|
14
|
+
|
|
15
|
+
| Option | Default | Needed for |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| `api_key` | `nil` | Everything — a Meta system-user or app access token |
|
|
18
|
+
| `phone_id` | `nil` | [Messages](messages/README.md), [media](media/README.md), [phone-number onboarding](business_phone_number/README.md) |
|
|
19
|
+
| `waba_id` | `nil` | [Template management](message_templates/README.md), [subscribed apps](subscribed_app/README.md), [the business account](business_phone_number/account.md) |
|
|
20
|
+
| `verify_token` | `nil` | The [webhook GET handshake](webhooks/README.md#verification-the-get-handshake) |
|
|
21
|
+
| `app_secret` | `nil` | [Webhook signature verification](webhooks/README.md#signature-the-post-check) |
|
|
22
|
+
| `host` | `"https://graph.facebook.com"` | Overriding the API host |
|
|
23
|
+
| `version` | `"v24.0"` | Pinning a Graph API version |
|
|
24
|
+
| `media_host_allowlist` | `["lookaside.fbsbx.com", "mmg.whatsapp.net", "graph.facebook.com"]` | [Media download safety](media/README.md#the-token-never-leaves-the-allowlist) |
|
|
25
|
+
|
|
26
|
+
A full configuration:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
Whatsapp.configure do |config|
|
|
30
|
+
config.api_key = ENV.fetch("WHATSAPP_API_KEY")
|
|
31
|
+
config.phone_id = ENV.fetch("WHATSAPP_PHONE_ID")
|
|
32
|
+
config.waba_id = ENV.fetch("WHATSAPP_WABA_ID")
|
|
33
|
+
config.verify_token = ENV.fetch("WHATSAPP_VERIFY_TOKEN")
|
|
34
|
+
config.app_secret = ENV.fetch("WHATSAPP_APP_SECRET")
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
In Rails, put that in `config/initializers/whatsapp.rb` and read from
|
|
39
|
+
`Rails.application.credentials` rather than `ENV`.
|
|
40
|
+
|
|
41
|
+
## Which ID addresses what
|
|
42
|
+
|
|
43
|
+
The most common source of confusion. Two different Meta objects, two different
|
|
44
|
+
permissions:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
phone_id ──► Messages send_text!, send_template!, send_interactive!, ...
|
|
48
|
+
Media upload, get_url, download, delete
|
|
49
|
+
BusinessPhoneNumber RequestCode, VerifyCode, Register, Deregister
|
|
50
|
+
(permission: whatsapp_business_messaging)
|
|
51
|
+
|
|
52
|
+
waba_id ──► MessageTemplates create, list, find, update, delete, upsert
|
|
53
|
+
SubscribedApp List, Subscribe, Unsubscribe
|
|
54
|
+
BusinessPhoneNumber::Account Get, Update
|
|
55
|
+
(permission: whatsapp_business_management)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`BusinessPhoneNumber` is the one module that appears on both sides: its onboarding
|
|
59
|
+
actions address a phone number, while its
|
|
60
|
+
[`Account`](business_phone_number/account.md) actions address the account that number
|
|
61
|
+
belongs to.
|
|
62
|
+
|
|
63
|
+
Calling a `waba_id` API without one raises before any request is made:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
Whatsapp::MessageTemplates.new.list
|
|
67
|
+
# => Whatsapp::MessageTemplates::TemplateError: waba_id is required for template
|
|
68
|
+
# management; set it via Whatsapp.configure or Client.new(waba_id:)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Secrets are redacted
|
|
72
|
+
|
|
73
|
+
`api_key`, `app_secret`, and `verify_token` never appear in `#inspect` output — not on
|
|
74
|
+
`Configuration`, not on `Client`, and therefore not on anything holding a client
|
|
75
|
+
(`Media`, `MessageTemplates`). This matters because error trackers routinely capture
|
|
76
|
+
local variables.
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
Whatsapp.configuration.inspect
|
|
80
|
+
# => "#<Whatsapp::Configuration host=\"https://graph.facebook.com\" version=\"v24.0\"
|
|
81
|
+
# api_key=[REDACTED] phone_id=\"106540352242922\" waba_id=nil
|
|
82
|
+
# verify_token=[REDACTED] app_secret=[REDACTED]>"
|
|
83
|
+
|
|
84
|
+
Whatsapp::Client.new.inspect
|
|
85
|
+
# => "#<Whatsapp::Client host=\"https://graph.facebook.com\" version=\"v24.0\"
|
|
86
|
+
# api_key=[REDACTED] phone_id=\"106540352242922\" waba_id=nil>"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The same treatment applies to credentials in transit:
|
|
90
|
+
[`Register#inspect`](business_phone_number/README.md#registering) redacts the PIN and
|
|
91
|
+
`VerifyCode#inspect` redacts the OTP.
|
|
92
|
+
|
|
93
|
+
## The client
|
|
94
|
+
|
|
95
|
+
Every API call goes through a `Whatsapp::Client`, which wraps a persistent HTTP
|
|
96
|
+
connection. All its constructor keywords default to `Whatsapp.configuration`:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
Whatsapp::Client.new(
|
|
100
|
+
host: Whatsapp.configuration.host,
|
|
101
|
+
version: Whatsapp.configuration.version,
|
|
102
|
+
api_key: Whatsapp.configuration.api_key,
|
|
103
|
+
phone_id: Whatsapp.configuration.phone_id,
|
|
104
|
+
waba_id: Whatsapp.configuration.waba_id,
|
|
105
|
+
timeout: 30, # Client::DEFAULT_TIMEOUT, seconds
|
|
106
|
+
logger: nil
|
|
107
|
+
)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Every entry point in the gem accepts an optional `client:`, which is how you address a
|
|
111
|
+
second phone number or business account without touching global state:
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
Whatsapp::Messages.send_text!(
|
|
115
|
+
to: "+15551234567", body: "Hi",
|
|
116
|
+
client: Whatsapp::Client.new(phone_id: "OTHER_PHONE_ID")
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
Whatsapp::MessageTemplates.new(client: Whatsapp::Client.new(waba_id: "OTHER_WABA_ID")).list
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Connection reuse
|
|
123
|
+
|
|
124
|
+
`Client#connection` memoizes `HTTP.persistent(host)`, so successive calls on the same
|
|
125
|
+
client reuse one TCP connection. Build a client once and hold it for a batch of sends
|
|
126
|
+
rather than constructing one per message:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
client = Whatsapp::Client.new
|
|
130
|
+
|
|
131
|
+
recipients.each { |to| Whatsapp::Messages.send_text!(to:, body: "Hi", client:) }
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Timeouts
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
Whatsapp::Client.new(timeout: 10)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Applies to the whole request. The default is 30 seconds.
|
|
141
|
+
|
|
142
|
+
## Instrumentation
|
|
143
|
+
|
|
144
|
+
Pass a `Logger` to log every request and response:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
client = Whatsapp::Client.new(logger: Logger.new($stdout))
|
|
148
|
+
|
|
149
|
+
Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hi", client: client)
|
|
150
|
+
# I, [...] INFO -- : POST https://graph.facebook.com/v24.0/106540352242922/messages
|
|
151
|
+
# I, [...] INFO -- : 200 OK
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
| Event | Logged |
|
|
155
|
+
| --- | --- |
|
|
156
|
+
| Request start | `<VERB> <url>` at `info` |
|
|
157
|
+
| Response | `<status> <reason>` at `info` |
|
|
158
|
+
| Error | `<name>: <message>` at `error` |
|
|
159
|
+
|
|
160
|
+
> **The query string is deliberately dropped.** Media and template endpoints put IDs
|
|
161
|
+
> in query parameters; logging them whole is how identifiers end up in log aggregators
|
|
162
|
+
> forever. Only the path is logged.
|
|
163
|
+
|
|
164
|
+
In Rails, pass `Rails.logger`:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
Whatsapp::Client.new(logger: Rails.logger)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
See also [errors.md](errors.md) for what the client raises.
|
data/docs/errors.md
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# Errors
|
|
2
|
+
|
|
3
|
+
Every exception this gem raises descends from `Whatsapp::Error`, so one rescue catches
|
|
4
|
+
the lot:
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
begin
|
|
8
|
+
Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hi")
|
|
9
|
+
rescue Whatsapp::Error => e
|
|
10
|
+
Rails.logger.error("WhatsApp call failed: #{e.message}")
|
|
11
|
+
end
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## The hierarchy
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
StandardError
|
|
18
|
+
└── Whatsapp::Error
|
|
19
|
+
├── Whatsapp::RequestError a message send failed
|
|
20
|
+
├── Whatsapp::Messages::PayloadError unknown message kind
|
|
21
|
+
├── Whatsapp::Messages::Interactive::Header::HeaderError malformed interactive header
|
|
22
|
+
├── Whatsapp::Media::MediaError any Media failure
|
|
23
|
+
├── Whatsapp::MessageTemplates::TemplateError any template-management failure
|
|
24
|
+
├── Whatsapp::SubscribedApp::Error any subscribed-apps failure
|
|
25
|
+
└── Whatsapp::BusinessPhoneNumber::Error any phone-number-onboarding or account failure
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Each module raises **its own** error class rather than the generic
|
|
29
|
+
`Whatsapp::RequestError`, so you can rescue narrowly:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
rescue Whatsapp::MessageTemplates::TemplateError => e
|
|
33
|
+
# a template problem specifically — retry, or surface to the operator
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
> **`Whatsapp::Webhook::Error` is not an exception.** Despite the name, it is a value
|
|
38
|
+
> object modelling Meta's error payloads on `Status#errors` and
|
|
39
|
+
> `Message::Unknown#errors`. See
|
|
40
|
+
> [webhooks/messages.md](webhooks/messages.md#errors).
|
|
41
|
+
|
|
42
|
+
## Validation errors are different
|
|
43
|
+
|
|
44
|
+
Local validation failures raise **`ActiveModel::ValidationError`**, not a gem class.
|
|
45
|
+
That is deliberate: a validation failure means *you* built something wrong, before any
|
|
46
|
+
network call happened, and ActiveModel's error object carries the per-attribute
|
|
47
|
+
detail.
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
Whatsapp::Messages.send_text!(to: "+15551234567", body: "x" * 5000)
|
|
51
|
+
# => ActiveModel::ValidationError: Validation failed:
|
|
52
|
+
# Body is too long (maximum is 4096 characters)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Reach the structured detail through the exception's model:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
begin
|
|
59
|
+
Whatsapp::Messages.send_image!(to: "+15551234567", caption: "x" * 2000)
|
|
60
|
+
rescue ActiveModel::ValidationError => e
|
|
61
|
+
e.model.errors.full_messages
|
|
62
|
+
# => ["Either id or link must be present", "Caption is too long (maximum is 1024 characters)"]
|
|
63
|
+
e.model.errors[:caption]
|
|
64
|
+
# => ["is too long (maximum is 1024 characters)"]
|
|
65
|
+
end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Because every class runs `validate!` at the end of `initialize`, this raises at
|
|
69
|
+
**construction** time — the client is never touched.
|
|
70
|
+
|
|
71
|
+
Rescuing both kinds:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
begin
|
|
75
|
+
Whatsapp::Messages.send_template!(to:, name:, language: { code: })
|
|
76
|
+
rescue ActiveModel::ValidationError => e
|
|
77
|
+
# our payload is wrong — a bug, or bad user input
|
|
78
|
+
rescue Whatsapp::Error => e
|
|
79
|
+
# Meta said no, or the network did
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Missing keywords
|
|
84
|
+
|
|
85
|
+
A required Ruby keyword that isn't supplied raises `ArgumentError`, not a validation
|
|
86
|
+
error — it never reaches the validator:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
Whatsapp::Messages.send_text!(body: "Hi")
|
|
90
|
+
# => ArgumentError: missing keyword: :to
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## API failures
|
|
94
|
+
|
|
95
|
+
Any non-2xx response raises the owning module's error class with a consistent message
|
|
96
|
+
shape:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
Failed to <action>: <status> - <body>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
# => Whatsapp::RequestError: Failed to send message: 400 Bad Request -
|
|
104
|
+
# {"error":{"message":"(#131009) Parameter value is not valid","code":131009}}
|
|
105
|
+
|
|
106
|
+
# => Whatsapp::Media::MediaError: Failed to upload media: 401 Unauthorized - {...}
|
|
107
|
+
|
|
108
|
+
# => Whatsapp::MessageTemplates::TemplateError: Failed to create template:
|
|
109
|
+
# 400 Bad Request - {"error":{"message":"Invalid parameter","code":100}}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The response body is truncated at **500** characters, with `… (truncated)` appended —
|
|
113
|
+
Meta's error bodies can be long, and a full one in an exception message tends to end up
|
|
114
|
+
somewhere it shouldn't.
|
|
115
|
+
|
|
116
|
+
Actions used in these messages: `send message`, `mark message as read`,
|
|
117
|
+
`upload media`, `get media URL`, `download media`, `delete media`,
|
|
118
|
+
`list subscribed apps`, `subscribe app`, `unsubscribe app`,
|
|
119
|
+
`request verification code`, `verify code`, `register business phone number`,
|
|
120
|
+
`deregister business phone number`, plus the template CRUD verbs.
|
|
121
|
+
|
|
122
|
+
## Missing configuration
|
|
123
|
+
|
|
124
|
+
The `waba_id` and `phone_id` modules check for their ID **before** building a request,
|
|
125
|
+
and say exactly how to supply it:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
Whatsapp::MessageTemplates.new.list
|
|
129
|
+
# => Whatsapp::MessageTemplates::TemplateError: waba_id is required for template
|
|
130
|
+
# management; set it via Whatsapp.configure or Client.new(waba_id:)
|
|
131
|
+
|
|
132
|
+
Whatsapp::SubscribedApp::List.call
|
|
133
|
+
# => Whatsapp::SubscribedApp::Error: waba_id is required for subscribed apps;
|
|
134
|
+
# set it via Whatsapp.configure or Client.new(waba_id:)
|
|
135
|
+
|
|
136
|
+
Whatsapp::BusinessPhoneNumber::Register.call(pin: "212834")
|
|
137
|
+
# => Whatsapp::BusinessPhoneNumber::Error: phone_id is required for the register edge;
|
|
138
|
+
# set it via Whatsapp.configure or Client.new(phone_id:)
|
|
139
|
+
|
|
140
|
+
Whatsapp::BusinessPhoneNumber::Account::Get.call
|
|
141
|
+
# => Whatsapp::BusinessPhoneNumber::Error: waba_id is required for business account
|
|
142
|
+
# details; set it via Whatsapp.configure or Client.new(waba_id:)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Note the last two: `BusinessPhoneNumber` spans both IDs. Its onboarding actions need
|
|
146
|
+
`phone_id`; its [`Account`](business_phone_number/account.md) actions need `waba_id`.
|
|
147
|
+
|
|
148
|
+
See [configuration.md](configuration.md#which-id-addresses-what).
|
|
149
|
+
|
|
150
|
+
## Argument errors from a module
|
|
151
|
+
|
|
152
|
+
Some misuse is caught before validation and raises the module's own error rather than
|
|
153
|
+
`ActiveModel::ValidationError` — these are wrong *calls*, not wrong *data*:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
templates.upsert(name: "x", language: "en_US", ...)
|
|
157
|
+
# => TemplateError: #upsert requires `languages:` (an array of locale codes);
|
|
158
|
+
# use #create for a single one
|
|
159
|
+
|
|
160
|
+
templates.update(template_id: "123")
|
|
161
|
+
# => TemplateError: nothing to update: pass category, components or message_send_ttl_seconds
|
|
162
|
+
|
|
163
|
+
templates.delete(hsm_ids: %w[1 2], name: "x")
|
|
164
|
+
# => TemplateError: hsm_ids cannot be combined with name or hsm_id
|
|
165
|
+
|
|
166
|
+
Whatsapp::Messages.new(kind: :telepathy, payload: { to: "+1" })
|
|
167
|
+
# => Whatsapp::Messages::PayloadError: Unknown message kind: :telepathy.
|
|
168
|
+
# Known kinds: text, image, audio, video, document, sticker, contacts, reaction,
|
|
169
|
+
# location, address, location_request, template, interactive
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Interactive header errors
|
|
173
|
+
|
|
174
|
+
`Interactive::Header` raises `HeaderError` rather than adding a validation error,
|
|
175
|
+
because a header missing its content cannot be serialized at all:
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
Whatsapp::Messages.send_interactive!(
|
|
179
|
+
to: "+15551234567", type: :reply_buttons, body: "Pick one",
|
|
180
|
+
header: { type: "image" },
|
|
181
|
+
action: { buttons: [{ id: "a", title: "A" }] }
|
|
182
|
+
)
|
|
183
|
+
# => Whatsapp::Messages::Interactive::Header::HeaderError:
|
|
184
|
+
# Image link is required for image header
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Retrying
|
|
188
|
+
|
|
189
|
+
Meta's transient failures are ordinary HTTP: 429 for rate limiting, 5xx for outages.
|
|
190
|
+
The gem does not retry — that policy belongs to your job queue:
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
class SendWhatsappJob < ApplicationJob
|
|
194
|
+
retry_on Whatsapp::RequestError, wait: :polynomially_longer, attempts: 5
|
|
195
|
+
discard_on ActiveModel::ValidationError # our payload is wrong; retrying won't fix it
|
|
196
|
+
|
|
197
|
+
def perform(to:, body:)
|
|
198
|
+
Whatsapp::Messages.send_text!(to:, body:)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Discarding on `ActiveModel::ValidationError` is the important half: a locally invalid
|
|
204
|
+
payload will fail identically on every attempt.
|
|
205
|
+
|
|
206
|
+
> **Rate limits worth knowing.** Template edits are capped at 10 per 30 days and 1 per
|
|
207
|
+
> 24 hours; `Register`/`Deregister` at 10 per number per 72-hour window (error
|
|
208
|
+
> `133016`). None is observable client-side — they arrive as API errors.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Media
|
|
2
|
+
|
|
3
|
+
Upload, locate, download, and delete media assets on the Cloud API. An uploaded asset
|
|
4
|
+
gets a **media ID** you can pass to [image](../messages/image.md),
|
|
5
|
+
[video](../messages/video.md), [audio](../messages/audio.md),
|
|
6
|
+
[document](../messages/document.md), and [sticker](../messages/sticker.md) messages
|
|
7
|
+
instead of a public URL.
|
|
8
|
+
|
|
9
|
+
Addresses your **phone number** (`phone_id`), like
|
|
10
|
+
[messages](../messages/README.md).
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
media = Whatsapp::Media.new # or .new(client: my_client)
|
|
14
|
+
|
|
15
|
+
media_id = media.upload(file_path: "photo.jpg", type: "image/jpeg")
|
|
16
|
+
info = media.get_url(media_id: media_id)
|
|
17
|
+
media.download(url: info["url"], save_to: "photo.jpg")
|
|
18
|
+
media.delete(media_id: media_id)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| Method | Request | Returns |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| `upload(file_path:, type:)` | `POST /{phone_id}/media` (multipart) | `String` media ID |
|
|
24
|
+
| `get_url(media_id:)` | `GET /{media_id}?phone_number_id=` | `Hash` |
|
|
25
|
+
| `download(url:, save_to:)` | `GET <url>` with the bearer token | `String` — the path written |
|
|
26
|
+
| `delete(media_id:)` | `DELETE /{media_id}?phone_number_id=` | `Boolean` |
|
|
27
|
+
|
|
28
|
+
## Uploading
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
media_id = Whatsapp::Media.new.upload(file_path: "photo.jpg", type: "image/jpeg")
|
|
32
|
+
# => "1234567890123456"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The file must exist — checked **before** the request, so a typo costs nothing:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
Whatsapp::Media.new.upload(file_path: "nope.jpg", type: "image/jpeg")
|
|
39
|
+
# => Whatsapp::Media::MediaError: File not found: nope.jpg
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Why prefer an ID over a link
|
|
43
|
+
|
|
44
|
+
Sending by `link:` makes Meta re-download the file on **every send**. Uploading once
|
|
45
|
+
and reusing the ID is faster, cheaper, and avoids your CDN being hammered:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
media_id = media.upload(file_path: "catalogue.pdf", type: "application/pdf")
|
|
49
|
+
|
|
50
|
+
recipients.each do |to|
|
|
51
|
+
Whatsapp::Messages.send_document!(to: to, id: media_id, filename: "catalogue.pdf")
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
> **Not for template headers.** A media header on a *template* takes a
|
|
56
|
+
> `header_handle` from Meta's Resumable Upload API, which this gem does not wrap.
|
|
57
|
+
> These media IDs are for **sending**, not template creation — see
|
|
58
|
+
> [../message_templates/README.md](../message_templates/README.md#not-wrapped).
|
|
59
|
+
|
|
60
|
+
## Getting a download URL
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
info = Whatsapp::Media.new.get_url(media_id: "1234567890123456")
|
|
64
|
+
# => { "url" => "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=...",
|
|
65
|
+
# "mime_type" => "image/jpeg", "sha256" => "...", "file_size" => 12345,
|
|
66
|
+
# "id" => "1234567890123456" }
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The URL is short-lived and requires the bearer token — you cannot hand it to a browser.
|
|
70
|
+
Fetch it with `#download`.
|
|
71
|
+
|
|
72
|
+
This is the step after receiving an inbound media message:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
message.media_id # from a webhook Message::Image / Video / Audio / Document / Sticker
|
|
76
|
+
info = media.get_url(media_id: message.media_id)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Downloading
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
Whatsapp::Media.new.download(url: info["url"], save_to: "photo.jpg")
|
|
83
|
+
# => "photo.jpg"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The response body is streamed to disk chunk by chunk rather than buffered, so a large
|
|
87
|
+
video doesn't have to fit in memory.
|
|
88
|
+
|
|
89
|
+
### The token never leaves the allowlist
|
|
90
|
+
|
|
91
|
+
`#download` attaches your API token to the request, which makes the destination URL
|
|
92
|
+
security-relevant. Two guards run before anything is sent:
|
|
93
|
+
|
|
94
|
+
1. **HTTPS only.** A plain-HTTP URL is refused outright.
|
|
95
|
+
2. **Allowlisted hosts only.** The host must be on
|
|
96
|
+
`Configuration#media_host_allowlist`, which defaults to
|
|
97
|
+
`lookaside.fbsbx.com`, `mmg.whatsapp.net`, and `graph.facebook.com`.
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
media.download(url: "http://lookaside.fbsbx.com/x", save_to: "f")
|
|
101
|
+
# => Whatsapp::Media::MediaError: Refusing to download from non-HTTPS URL: http://...
|
|
102
|
+
|
|
103
|
+
media.download(url: "https://evil.example.com/x", save_to: "f")
|
|
104
|
+
# => Whatsapp::Media::MediaError: Refusing to send credentials to non-allowlisted host:
|
|
105
|
+
# evil.example.com
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Matching is exact host or a dotted subdomain, so `evil-fbsbx.com` and
|
|
109
|
+
`fbsbx.com.attacker.com` are both rejected — a substring check would have let both
|
|
110
|
+
through.
|
|
111
|
+
|
|
112
|
+
Override the list if Meta serves you from a different host:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
Whatsapp.configure do |config|
|
|
116
|
+
config.media_host_allowlist = %w[lookaside.fbsbx.com mmg.whatsapp.net my-proxy.internal]
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Deleting
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
Whatsapp::Media.new.delete(media_id: "1234567890123456") # => true
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Returns a strict boolean from Meta's `{"success": true}`. A non-2xx response raises
|
|
127
|
+
instead of returning `false`, so `false` genuinely means Meta declined.
|
|
128
|
+
|
|
129
|
+
## Errors
|
|
130
|
+
|
|
131
|
+
Every failure raises `Whatsapp::Media::MediaError`:
|
|
132
|
+
|
|
133
|
+
| Message | Cause |
|
|
134
|
+
| --- | --- |
|
|
135
|
+
| `File not found: <path>` | `upload` — checked locally before any request |
|
|
136
|
+
| `Invalid media URL: <detail>` | `download` — the URL didn't parse |
|
|
137
|
+
| `Refusing to download from non-HTTPS URL: <url>` | `download` — HTTP scheme |
|
|
138
|
+
| `Refusing to send credentials to non-allowlisted host: <host>` | `download` — host not on the allowlist |
|
|
139
|
+
| `Failed to upload media: 400 ...` | Non-2xx from the API |
|
|
140
|
+
| `Failed to get media URL: ...` / `Failed to download media: ...` / `Failed to delete media: ...` | Non-2xx from the API |
|
|
141
|
+
|
|
142
|
+
Error bodies are truncated at 500 characters. See [../errors.md](../errors.md).
|
|
143
|
+
|
|
144
|
+
## Round trip: receive, download, reply
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
media = Whatsapp::Media.new
|
|
148
|
+
|
|
149
|
+
change.value.messages.each do |message|
|
|
150
|
+
next unless message.respond_to?(:media_id)
|
|
151
|
+
|
|
152
|
+
info = media.get_url(media_id: message.media_id)
|
|
153
|
+
path = media.download(url: info["url"], save_to: Rails.root.join("tmp", message.id))
|
|
154
|
+
|
|
155
|
+
Whatsapp::Messages.send_text!(to: message.from, body: "Got your #{message.mime_type}, thanks!")
|
|
156
|
+
end
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
**Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/media>
|