ruby-whatsapp 0.4.2 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +143 -0
- data/README.md +70 -7
- data/docs/README.md +4 -1
- data/docs/business_phone_number/README.md +12 -1
- data/docs/business_phone_number/contact_book.md +90 -0
- data/docs/business_phone_number/username.md +157 -0
- data/docs/configuration.md +6 -1
- data/docs/errors.md +12 -1
- data/docs/message_templates/README.md +1 -1
- data/docs/message_templates/components.md +37 -1
- data/docs/messages/README.md +86 -9
- data/docs/messages/address.md +1 -1
- data/docs/messages/audio.md +1 -1
- data/docs/messages/contacts.md +1 -1
- data/docs/messages/document.md +1 -1
- data/docs/messages/image.md +1 -1
- data/docs/messages/interactive.md +64 -3
- data/docs/messages/location.md +1 -1
- data/docs/messages/location_request.md +1 -1
- data/docs/messages/reaction.md +1 -1
- data/docs/messages/sticker.md +1 -1
- data/docs/messages/template.md +28 -1
- data/docs/messages/text.md +1 -1
- data/docs/messages/video.md +1 -1
- data/docs/schedule/README.md +183 -0
- data/docs/webhooks/README.md +20 -12
- data/docs/webhooks/business_username_updates.md +55 -0
- data/docs/webhooks/history.md +78 -15
- data/docs/webhooks/messages.md +141 -6
- data/docs/webhooks/smb_app_state_sync.md +50 -12
- data/docs/webhooks/smb_message_echoes.md +58 -21
- data/docs/webhooks/user_id_update.md +84 -0
- data/docs/webhooks/user_preferences.md +55 -16
- data/lib/ruby/whatsapp/business_phone_number/contact_book/delete.rb +87 -0
- data/lib/ruby/whatsapp/business_phone_number/contact_book/deletion.rb +50 -0
- data/lib/ruby/whatsapp/business_phone_number/contact_book.rb +25 -0
- data/lib/ruby/whatsapp/business_phone_number/transport.rb +6 -5
- data/lib/ruby/whatsapp/business_phone_number/username/claim.rb +180 -0
- data/lib/ruby/whatsapp/business_phone_number/username/delete.rb +36 -0
- data/lib/ruby/whatsapp/business_phone_number/username/details.rb +66 -0
- data/lib/ruby/whatsapp/business_phone_number/username/get.rb +33 -0
- data/lib/ruby/whatsapp/business_phone_number/username/suggest.rb +37 -0
- data/lib/ruby/whatsapp/business_phone_number/username/suggestions.rb +50 -0
- data/lib/ruby/whatsapp/business_phone_number/username.rb +27 -0
- data/lib/ruby/whatsapp/business_phone_number.rb +9 -0
- data/lib/ruby/whatsapp/message_templates/button/request_contact_info.rb +66 -0
- data/lib/ruby/whatsapp/message_templates/button.rb +5 -2
- data/lib/ruby/whatsapp/message_templates/component_set.rb +28 -0
- data/lib/ruby/whatsapp/message_templates/response/paging.rb +2 -1
- data/lib/ruby/whatsapp/messages/base.rb +47 -5
- data/lib/ruby/whatsapp/messages/interactive/request_contact_info.rb +59 -0
- data/lib/ruby/whatsapp/messages/interactive.rb +22 -4
- data/lib/ruby/whatsapp/messages/response/contacts.rb +22 -5
- data/lib/ruby/whatsapp/schedule/create.rb +101 -0
- data/lib/ruby/whatsapp/schedule/list.rb +50 -0
- data/lib/ruby/whatsapp/schedule/response/collection.rb +61 -0
- data/lib/ruby/whatsapp/schedule/response/created.rb +30 -0
- data/lib/ruby/whatsapp/schedule/response/node.rb +85 -0
- data/lib/ruby/whatsapp/schedule/response.rb +18 -0
- data/lib/ruby/whatsapp/schedule/statuses.rb +19 -0
- data/lib/ruby/whatsapp/schedule/transport.rb +24 -0
- data/lib/ruby/whatsapp/schedule.rb +15 -0
- data/lib/ruby/whatsapp/version.rb +1 -1
- data/lib/ruby/whatsapp/webhook/business_username_updates.rb +51 -0
- data/lib/ruby/whatsapp/webhook/change.rb +3 -1
- data/lib/ruby/whatsapp/webhook/contact.rb +26 -3
- data/lib/ruby/whatsapp/webhook/history/chunk.rb +50 -0
- data/lib/ruby/whatsapp/webhook/history/thread.rb +64 -0
- data/lib/ruby/whatsapp/webhook/history.rb +73 -34
- data/lib/ruby/whatsapp/webhook/message/base.rb +27 -2
- data/lib/ruby/whatsapp/webhook/message/contacts/contact.rb +15 -2
- data/lib/ruby/whatsapp/webhook/message/context.rb +23 -2
- data/lib/ruby/whatsapp/webhook/message/edit.rb +50 -0
- data/lib/ruby/whatsapp/webhook/message/revoke.rb +36 -0
- data/lib/ruby/whatsapp/webhook/message/system.rb +19 -2
- data/lib/ruby/whatsapp/webhook/message/unknown.rb +2 -2
- data/lib/ruby/whatsapp/webhook/message.rb +3 -0
- data/lib/ruby/whatsapp/webhook/messages.rb +4 -3
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync/contact.rb +76 -0
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync/state_sync.rb +18 -4
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync.rb +33 -9
- data/lib/ruby/whatsapp/webhook/smb_message_echoes/echo.rb +60 -0
- data/lib/ruby/whatsapp/webhook/smb_message_echoes.rb +26 -11
- data/lib/ruby/whatsapp/webhook/status.rb +23 -2
- data/lib/ruby/whatsapp/webhook/user_id_update/entry.rb +56 -0
- data/lib/ruby/whatsapp/webhook/user_id_update/id_change.rb +35 -0
- data/lib/ruby/whatsapp/webhook/user_id_update.rb +61 -0
- data/lib/ruby/whatsapp/webhook/user_preferences/entry.rb +76 -0
- data/lib/ruby/whatsapp/webhook/user_preferences.rb +44 -23
- metadata +38 -1
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Scheduling Campaigns
|
|
2
|
+
|
|
3
|
+
A **campaign schedule** sends an approved [template](../message_templates/README.md) to an
|
|
4
|
+
audience at a time you choose. `Whatsapp::Schedule` wraps the `schedules` edge, which
|
|
5
|
+
lists an account's schedules and creates new ones.
|
|
6
|
+
|
|
7
|
+
> **Schedules send templates; they don't create them.** The template behind `hsm_id`
|
|
8
|
+
> must already exist and be approved. Create and manage it with
|
|
9
|
+
> [`Whatsapp::MessageTemplates`](../message_templates/README.md).
|
|
10
|
+
|
|
11
|
+
Addresses your **WhatsApp Business Account** (`waba_id`, not `phone_id`), the same as
|
|
12
|
+
[template management](../message_templates/README.md) and
|
|
13
|
+
[subscribed apps](../subscribed_app/README.md).
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
Whatsapp.configure do |config|
|
|
17
|
+
config.api_key = ENV.fetch("WHATSAPP_API_KEY")
|
|
18
|
+
config.waba_id = ENV.fetch("WHATSAPP_WABA_ID")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Whatsapp::Schedule::Create.call(
|
|
22
|
+
hsm_id: "1234567890", audience_id: "2345678901", waba_cs_id: "3456789012",
|
|
23
|
+
name: "Black Friday", description: "Black Friday promo to VIP customers",
|
|
24
|
+
delivery_time: Time.now.to_i + 3600
|
|
25
|
+
).id # => "9876543210"
|
|
26
|
+
|
|
27
|
+
Whatsapp::Schedule::List.call.map(&:name) # => ["Black Friday"]
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
| Method | Request | Returns |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| `List.call(client:, fields:, limit:, after:, before:)` | `GET /{waba_id}/schedules` | `Response::Collection` |
|
|
33
|
+
| `Create.call(hsm_id:, audience_id:, waba_cs_id:, name:, description:, delivery_time:, client:)` | `POST /{waba_id}/schedules` | `Response::Created` |
|
|
34
|
+
|
|
35
|
+
## Creating
|
|
36
|
+
|
|
37
|
+
Every field is required:
|
|
38
|
+
|
|
39
|
+
| Field | Type | Meaning |
|
|
40
|
+
| --- | --- | --- |
|
|
41
|
+
| `hsm_id` | String | The approved template to send |
|
|
42
|
+
| `audience_id` | String | The audience to send it to |
|
|
43
|
+
| `waba_cs_id` | String | Meta: "WABA customer status ID (phone number)" |
|
|
44
|
+
| `name` | String | The schedule's name |
|
|
45
|
+
| `description` | String | The schedule's description |
|
|
46
|
+
| `delivery_time` | Integer | When to send, as a Unix timestamp |
|
|
47
|
+
|
|
48
|
+
The examples below share this hash:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
attrs = {
|
|
52
|
+
hsm_id: "1234567890", audience_id: "2345678901", waba_cs_id: "3456789012",
|
|
53
|
+
name: "Black Friday", description: "Black Friday promo to VIP customers",
|
|
54
|
+
delivery_time: 1_764_000_000,
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`delivery_time` must be an integer. For a `Time`, pass `time.to_i`:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
Whatsapp::Schedule::Create.call(**attrs, delivery_time: Time.utc(2026, 11, 27, 9).to_i)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
All six are checked before the request, so a missing one costs nothing:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
Whatsapp::Schedule::Create.call(**attrs, name: "")
|
|
68
|
+
# => ActiveModel::ValidationError: Validation failed: Name can't be blank
|
|
69
|
+
|
|
70
|
+
Whatsapp::Schedule::Create.call(**attrs, delivery_time: 1_764_000_000.5)
|
|
71
|
+
# => ActiveModel::ValidationError: Validation failed: Delivery time must be an integer
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Nothing beyond that is checked. Meta publishes no length limits and no rule that
|
|
75
|
+
`delivery_time` must be in the future; anything it rejects comes back as a
|
|
76
|
+
`Whatsapp::Schedule::Error`.
|
|
77
|
+
|
|
78
|
+
> `waba_cs_id` is passed through exactly as given. Meta's description doesn't say
|
|
79
|
+
> whether it means a phone number ID or the number itself.
|
|
80
|
+
|
|
81
|
+
To inspect the payload without sending it, build the object yourself:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
Whatsapp::Schedule::Create.new(**attrs).serialize
|
|
85
|
+
# => { hsm_id: "1234567890", audience_id: "2345678901", waba_cs_id: "3456789012",
|
|
86
|
+
# name: "Black Friday", description: "Black Friday promo to VIP customers",
|
|
87
|
+
# delivery_time: 1764000000 }
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Listing
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
schedules = Whatsapp::Schedule::List.call
|
|
94
|
+
|
|
95
|
+
schedules.map(&:name) # Collection is Enumerable
|
|
96
|
+
schedules.first.delivery_time # => 1764000000
|
|
97
|
+
schedules.first.status # => "SCHEDULED"
|
|
98
|
+
schedules.first.id # => nil (see below)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Ask for `id` explicitly
|
|
102
|
+
|
|
103
|
+
**Meta's default fields leave out `id`.** The defaults are `name`, `description`,
|
|
104
|
+
`delivery_time`, and `status`. Request `id` with `fields:`, either as an Array or a
|
|
105
|
+
comma-joined String:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
Whatsapp::Schedule::List.call(fields: Whatsapp::Schedule::List::Fields::ALL)
|
|
109
|
+
Whatsapp::Schedule::List.call(fields: %w[id name status])
|
|
110
|
+
Whatsapp::Schedule::List.call(fields: "id,name,status")
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`List::Fields::ALL` is a convenience, not a whitelist. A field Meta adds later works
|
|
114
|
+
without a gem release.
|
|
115
|
+
|
|
116
|
+
### Paging
|
|
117
|
+
|
|
118
|
+
`limit:` sets the page size (Meta accepts 1–100). `after:` and `before:` take cursors.
|
|
119
|
+
Paging is exposed rather than followed automatically, so fetching every page is your
|
|
120
|
+
choice:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
page = Whatsapp::Schedule::List.call(limit: 50)
|
|
124
|
+
|
|
125
|
+
while page.next_cursor
|
|
126
|
+
page = Whatsapp::Schedule::List.call(limit: 50, after: page.next_cursor)
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`Collection#paging` exposes both cursors (`paging.before`, `paging.after`). It is the
|
|
131
|
+
same Graph API paging object [template listing](../message_templates/README.md) returns.
|
|
132
|
+
|
|
133
|
+
### Statuses
|
|
134
|
+
|
|
135
|
+
| Status | Predicate |
|
|
136
|
+
| --- | --- |
|
|
137
|
+
| `SCHEDULED` | `scheduled?` |
|
|
138
|
+
| `SENDING` | `sending?` |
|
|
139
|
+
| `COMPLETED` | `completed?` |
|
|
140
|
+
| `FAILED` | `failed?` |
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
Whatsapp::Schedule::List.call.select(&:failed?).map(&:name)
|
|
144
|
+
schedule.status == Whatsapp::Schedule::Statuses::SCHEDULED
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`status` is the raw string Meta sent and is never validated, so a status Meta adds later
|
|
148
|
+
still comes through. Every predicate just answers `false` for it.
|
|
149
|
+
|
|
150
|
+
## Response shapes
|
|
151
|
+
|
|
152
|
+
| Class | Holds | From |
|
|
153
|
+
| --- | --- | --- |
|
|
154
|
+
| `Response::Collection` | `data: [Node]`, `paging`, `next_cursor`, `Enumerable` | `List` |
|
|
155
|
+
| `Response::Node` | `id`, `name`, `description`, `delivery_time`, `status` | each element of `List` |
|
|
156
|
+
| `Response::Created` | `id` | `Create` |
|
|
157
|
+
|
|
158
|
+
Every attribute tolerates being absent, since Meta returns only the fields you asked for.
|
|
159
|
+
|
|
160
|
+
## Errors
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
Whatsapp::Schedule::List.call
|
|
164
|
+
# => Whatsapp::Schedule::Error: waba_id is required for schedules;
|
|
165
|
+
# set it via Whatsapp.configure or Client.new(waba_id:)
|
|
166
|
+
|
|
167
|
+
# non-2xx response
|
|
168
|
+
# => Whatsapp::Schedule::Error: Failed to create schedule: 403 Forbidden - {...}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Every API failure raises `Whatsapp::Schedule::Error`, never the generic
|
|
172
|
+
`Whatsapp::RequestError`. Local validation raises `ActiveModel::ValidationError` before
|
|
173
|
+
any request. See [../errors.md](../errors.md).
|
|
174
|
+
|
|
175
|
+
## Targeting a different account
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
Whatsapp::Schedule::List.call(client: Whatsapp::Client.new(waba_id: "OTHER_WABA_ID"))
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
**Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/schedules-api>
|
data/docs/webhooks/README.md
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
# Webhooks
|
|
2
2
|
|
|
3
|
-
Meta pushes inbound messages, delivery statuses, and
|
|
3
|
+
Meta pushes inbound messages, delivery statuses, and 20 other account and template
|
|
4
4
|
notifications to a callback URL you register. This directory covers the *inbound*
|
|
5
5
|
direction — the opposite of everything under [../messages/](../messages/README.md).
|
|
6
6
|
|
|
7
|
+
> **Business-scoped user IDs (BSUID).** Meta is rolling out usernames, after which a
|
|
8
|
+
> WhatsApp user's phone number may stop appearing in webhooks entirely. `Contact`,
|
|
9
|
+
> `Message::Base`, and `Status` all carry BSUID fallback identifiers now — see
|
|
10
|
+
> [messages.md's BSUID section](messages.md#business-scoped-user-ids-bsuid).
|
|
11
|
+
|
|
7
12
|
> **Two different things.** `Whatsapp::Webhook` deserializes notifications once Meta is
|
|
8
13
|
> already sending them. Turning that delivery **on** for a WhatsApp Business Account is
|
|
9
14
|
> [`Whatsapp::SubscribedApp`](../subscribed_app/README.md). You need both.
|
|
@@ -128,7 +133,7 @@ change.field # => "messages"
|
|
|
128
133
|
change.value # => #<Whatsapp::Webhook::Messages ...>
|
|
129
134
|
```
|
|
130
135
|
|
|
131
|
-
`change.field` is one of Meta's
|
|
136
|
+
`change.field` is one of Meta's 21 documented field names; `change.value` is that
|
|
132
137
|
field's deserialized payload, resolved through a frozen `Change::FIELDS` registry —
|
|
133
138
|
**never `const_get` on the field name**, since it comes straight off the internet.
|
|
134
139
|
|
|
@@ -142,17 +147,20 @@ change.value["some_key"] # reach into the raw hash
|
|
|
142
147
|
change.value.to_h # the whole thing
|
|
143
148
|
```
|
|
144
149
|
|
|
145
|
-
## The
|
|
150
|
+
## The 21 fields
|
|
146
151
|
|
|
147
152
|
| Field | Page | Confidence |
|
|
148
153
|
| --- | --- | --- |
|
|
149
154
|
| `messages` | [messages.md](messages.md) | **Confirmed** — Meta publishes a real payload |
|
|
155
|
+
| `user_id_update` | [user_id_update.md](user_id_update.md) | **Confirmed** — Meta publishes a real payload |
|
|
156
|
+
| `business_username_updates` | [business_username_updates.md](business_username_updates.md) | **Confirmed** — Meta publishes a real payload |
|
|
157
|
+
| `user_preferences` | [user_preferences.md](user_preferences.md) | **Confirmed** — Meta publishes a real payload |
|
|
158
|
+
| `history` | [history.md](history.md) | **Confirmed** — Meta publishes a real payload |
|
|
150
159
|
| `account_alerts` | [account_alerts.md](account_alerts.md) | Moderate |
|
|
151
160
|
| `account_review_update` | [account_review_update.md](account_review_update.md) | High |
|
|
152
161
|
| `account_update` | [account_update.md](account_update.md) | Moderate |
|
|
153
162
|
| `automatic_events` | [automatic_events.md](automatic_events.md) | Low |
|
|
154
163
|
| `business_capability_update` | [business_capability_update.md](business_capability_update.md) | Moderate |
|
|
155
|
-
| `history` | [history.md](history.md) | Low |
|
|
156
164
|
| `message_template_components_update` | [message_template_components_update.md](message_template_components_update.md) | Moderate |
|
|
157
165
|
| `message_template_quality_update` | [message_template_quality_update.md](message_template_quality_update.md) | Moderate-high |
|
|
158
166
|
| `message_template_status_update` | [message_template_status_update.md](message_template_status_update.md) | High |
|
|
@@ -161,16 +169,16 @@ change.value.to_h # the whole thing
|
|
|
161
169
|
| `phone_number_name_update` | [phone_number_name_update.md](phone_number_name_update.md) | Moderate-high |
|
|
162
170
|
| `phone_number_quality_update` | [phone_number_quality_update.md](phone_number_quality_update.md) | Moderate |
|
|
163
171
|
| `security` | [security.md](security.md) | Low |
|
|
164
|
-
| `smb_app_state_sync` | [smb_app_state_sync.md](smb_app_state_sync.md) |
|
|
165
|
-
| `smb_message_echoes` | [smb_message_echoes.md](smb_message_echoes.md) |
|
|
172
|
+
| `smb_app_state_sync` | [smb_app_state_sync.md](smb_app_state_sync.md) | High |
|
|
173
|
+
| `smb_message_echoes` | [smb_message_echoes.md](smb_message_echoes.md) | High |
|
|
166
174
|
| `template_category_update` | [template_category_update.md](template_category_update.md) | Moderate-high |
|
|
167
|
-
| `user_preferences` | [user_preferences.md](user_preferences.md) | Moderate |
|
|
168
175
|
|
|
169
|
-
> **On confidence.**
|
|
170
|
-
>
|
|
171
|
-
>
|
|
172
|
-
>
|
|
173
|
-
>
|
|
176
|
+
> **On confidence.** The five **Confirmed** fields each have a Meta-published JSON
|
|
177
|
+
> example, and `smb_message_echoes`/`smb_app_state_sync` have a published entry shape but
|
|
178
|
+
> an array key still inferred from the field name. The other 14 field classes are
|
|
179
|
+
> reconstructed from the one-line descriptions in Meta's docs plus general Cloud API knowledge. They are
|
|
180
|
+
> real, tested classes — but validate one against a real payload (App Dashboard → "send
|
|
181
|
+
> test payload") before depending on it in production.
|
|
174
182
|
|
|
175
183
|
## Dispatching
|
|
176
184
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# `business_username_updates`
|
|
2
|
+
|
|
3
|
+
A business phone number's username status changed — approved, reserved, or deleted.
|
|
4
|
+
Subscribe to this field to stay in sync whenever a username you claimed moves through
|
|
5
|
+
review, or is removed via the WhatsApp Business app.
|
|
6
|
+
|
|
7
|
+
`Whatsapp::Webhook::BusinessUsernameUpdates` · confidence: **confirmed** — Meta
|
|
8
|
+
publishes a real JSON example
|
|
9
|
+
|
|
10
|
+
## Payload
|
|
11
|
+
|
|
12
|
+
```json
|
|
13
|
+
{ "field": "business_username_updates",
|
|
14
|
+
"value": {
|
|
15
|
+
"display_phone_number": "15550783881",
|
|
16
|
+
"username": "jaspersmarket",
|
|
17
|
+
"status": "approved"
|
|
18
|
+
} }
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`username` is omitted when `status` is `"deleted"`.
|
|
22
|
+
|
|
23
|
+
## Accessors
|
|
24
|
+
|
|
25
|
+
| Accessor | Meaning |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `display_phone_number` | The business phone number's display number |
|
|
28
|
+
| `username` | The username the status concerns; `nil` when `status` is `"deleted"` |
|
|
29
|
+
| `status` | `"approved"`, `"reserved"`, or `"deleted"` |
|
|
30
|
+
|
|
31
|
+
## Handling it
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
when "business_username_updates"
|
|
35
|
+
update = change.value
|
|
36
|
+
digits = update.display_phone_number.to_s.gsub(/\D/, "")
|
|
37
|
+
number = PhoneNumber.find_by(display_phone_number_digits: digits)
|
|
38
|
+
|
|
39
|
+
if number
|
|
40
|
+
number.update!(username: update.username, username_status: update.status)
|
|
41
|
+
else
|
|
42
|
+
Rails.logger.error("[whatsapp] no PhoneNumber matching #{update.display_phone_number.inspect}")
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
This field carries no `phone_number_id`, so `display_phone_number` is the only join key
|
|
47
|
+
you get — and Meta formats it for display, inconsistently (`"15550783881"` in one payload,
|
|
48
|
+
`"+1 555-078-3881"` in another). Match on digits, and report a miss instead of letting
|
|
49
|
+
`&.update!` swallow it: a silent no-op means you never learn the username moved. Log or
|
|
50
|
+
alert; do not `raise`, or a permanent formatting mismatch puts Meta into a retry loop
|
|
51
|
+
against a request that can never succeed. `update.raw` carries the untouched payload.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
**Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids#business_username_updates-webhook>
|
data/docs/webhooks/history.md
CHANGED
|
@@ -4,27 +4,81 @@ Conversation history synced from the WhatsApp Business app when a number moves o
|
|
|
4
4
|
Cloud API. It arrives in chunks, so `phase`, `chunk_order`, and `progress` are how you
|
|
5
5
|
track completeness.
|
|
6
6
|
|
|
7
|
-
`Whatsapp::Webhook::History` · confidence: **
|
|
7
|
+
`Whatsapp::Webhook::History` · confidence: **confirmed**
|
|
8
8
|
|
|
9
9
|
## Payload
|
|
10
10
|
|
|
11
11
|
```json
|
|
12
12
|
{ "field": "history",
|
|
13
|
-
"value": {
|
|
14
|
-
|
|
13
|
+
"value": {
|
|
14
|
+
"messaging_product": "whatsapp",
|
|
15
|
+
"metadata": { "display_phone_number": "15550783881", "phone_number_id": "106540352242922" },
|
|
16
|
+
"history": [
|
|
17
|
+
{ "metadata": { "phase": 2, "chunk_order": 1, "progress": 50 },
|
|
18
|
+
"threads": [
|
|
19
|
+
{ "id": "16505551234",
|
|
20
|
+
"context": { "wa_id": "16505551234",
|
|
21
|
+
"user_id": "US.13491208655302741918",
|
|
22
|
+
"username": "pablomorales" },
|
|
23
|
+
"messages": [
|
|
24
|
+
{ "from": "16505551234",
|
|
25
|
+
"from_user_id": "US.13491208655302741918",
|
|
26
|
+
"id": "wamid.HBg",
|
|
27
|
+
"timestamp": "1749416383",
|
|
28
|
+
"type": "text",
|
|
29
|
+
"text": { "body": "Does it come in another color?" },
|
|
30
|
+
"history_context": { "status": "DELIVERED" } }
|
|
31
|
+
] }
|
|
32
|
+
] }
|
|
33
|
+
] } }
|
|
15
34
|
```
|
|
16
35
|
|
|
17
|
-
|
|
36
|
+
Two `metadata` objects, and they are different things: the value-level one is the
|
|
37
|
+
ordinary business-phone-number `Metadata`, while each `history[]` entry has its own
|
|
38
|
+
carrying the sync counters.
|
|
39
|
+
|
|
40
|
+
## Value accessors
|
|
41
|
+
|
|
42
|
+
| Accessor | Type |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| `messaging_product` | `String` |
|
|
45
|
+
| `metadata` | `Metadata` — the business phone number, **not** the sync counters |
|
|
46
|
+
| `chunks` | `Array<History::Chunk>` |
|
|
47
|
+
| `phase` / `chunk_order` / `progress` | Read through to `chunks.first`, since a delivery normally carries one chunk |
|
|
48
|
+
| `contacts` | `Array<Contact>` — populated only by the media-asset variant |
|
|
49
|
+
| `messages` | `Array<Message::*>` — populated only by the media-asset variant |
|
|
50
|
+
| `raw` | The untouched `value` hash |
|
|
51
|
+
|
|
52
|
+
## Chunk accessors
|
|
18
53
|
|
|
19
54
|
| Accessor | Meaning |
|
|
20
55
|
| --- | --- |
|
|
21
|
-
| `phase` | Sync phase
|
|
22
|
-
| `chunk_order` | This chunk's position
|
|
23
|
-
| `progress` | Percent complete, 0–100
|
|
24
|
-
| `threads` |
|
|
56
|
+
| `phase` | Sync phase |
|
|
57
|
+
| `chunk_order` | This chunk's position within its phase |
|
|
58
|
+
| `progress` | Percent complete, 0–100 |
|
|
59
|
+
| `threads` | `Array<History::Thread>` |
|
|
25
60
|
|
|
26
|
-
|
|
27
|
-
|
|
61
|
+
## Thread accessors
|
|
62
|
+
|
|
63
|
+
| Accessor | Meaning |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| `id` | The thread identifier — the user's phone number, omitted for a username adopter |
|
|
66
|
+
| `context` | A `Contact` — `wa_id`, `user_id`, `parent_user_id`, `username` |
|
|
67
|
+
| `messages` | `Array<Message::*>`, dispatched by `type` like any inbound message |
|
|
68
|
+
| `raw` | The untouched thread hash |
|
|
69
|
+
|
|
70
|
+
`context` reuses the same [`Contact`](messages.md#value-accessors) as every other contact
|
|
71
|
+
entry: its `username` read already falls back to a flat key, which is where Meta puts it
|
|
72
|
+
here, and `profile_name` is simply `nil` since a thread context carries no profile.
|
|
73
|
+
|
|
74
|
+
Each synced message also has a `history_context.status` — its delivery state at sync
|
|
75
|
+
time. That is deliberately **not** modelled: it would put a history-only field on
|
|
76
|
+
`Message::Base`, the envelope shared by every inbound message type, where it could never
|
|
77
|
+
appear. Read it off `thread.raw`:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
thread.raw.dig("messages", 0, "history_context", "status") # => "DELIVERED"
|
|
81
|
+
```
|
|
28
82
|
|
|
29
83
|
## Handling it
|
|
30
84
|
|
|
@@ -32,13 +86,22 @@ Note the three scalars are flattened up from the nested `metadata` object, while
|
|
|
32
86
|
when "history"
|
|
33
87
|
sync = change.value
|
|
34
88
|
|
|
35
|
-
|
|
36
|
-
|
|
89
|
+
sync.chunks.each do |chunk|
|
|
90
|
+
chunk.threads.each do |thread|
|
|
91
|
+
# A username adopter has no phone number here — the BSUID is the join key
|
|
92
|
+
customer = Customer.find_or_create_by!(bsuid: thread.context&.user_id)
|
|
93
|
+
customer.import_history!(thread.messages)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
Importer.finish! if chunk.progress == 100
|
|
97
|
+
end
|
|
37
98
|
```
|
|
38
99
|
|
|
39
|
-
> **
|
|
40
|
-
>
|
|
100
|
+
> **Changed in 0.6.0.** `phase`/`chunk_order`/`progress` used to be read from
|
|
101
|
+
> `value["metadata"]` and threads from `value["threads"]` — one level too high, so every
|
|
102
|
+
> accessor was permanently empty. `threads` has moved onto `Chunk`, and the three
|
|
103
|
+
> counters now work.
|
|
41
104
|
|
|
42
105
|
---
|
|
43
106
|
|
|
44
|
-
**Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/
|
|
107
|
+
**Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
|
data/docs/webhooks/messages.md
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# `messages`
|
|
2
2
|
|
|
3
3
|
Inbound customer messages and outbound delivery statuses — the field you will spend
|
|
4
|
-
almost all your time on, and
|
|
4
|
+
almost all your time on, and one of only five fields with a Meta-published JSON
|
|
5
|
+
schema (alongside [`user_id_update`](user_id_update.md) and
|
|
6
|
+
[`business_username_updates`](business_username_updates.md)).
|
|
5
7
|
|
|
6
|
-
`Whatsapp::Webhook::Messages`
|
|
8
|
+
`Whatsapp::Webhook::Messages` · confidence: **confirmed** — Meta publishes a real JSON
|
|
9
|
+
example
|
|
7
10
|
|
|
8
11
|
## A full notification
|
|
9
12
|
|
|
@@ -49,7 +52,7 @@ value.statuses # => []
|
|
|
49
52
|
| --- | --- |
|
|
50
53
|
| `messaging_product` | `"whatsapp"` |
|
|
51
54
|
| `metadata` | `Metadata` — `display_phone_number`, `phone_number_id` |
|
|
52
|
-
| `contacts` | `Array<Contact>` — `profile_name`, `wa_id` |
|
|
55
|
+
| `contacts` | `Array<Contact>` — `profile_name`, `username`, `wa_id`, `user_id`, `parent_user_id` |
|
|
53
56
|
| `messages` | `Array<Message::*>` |
|
|
54
57
|
| `statuses` | `Array<Status>` |
|
|
55
58
|
|
|
@@ -60,6 +63,32 @@ both accessors always return an array, so you can iterate either without a nil c
|
|
|
60
63
|
> different thing from [`Message::Contacts`](#contacts) below, which is a contact
|
|
61
64
|
> *card* someone shared with you.
|
|
62
65
|
|
|
66
|
+
### Business-scoped user IDs (BSUID)
|
|
67
|
+
|
|
68
|
+
Once a WhatsApp user adopts a username, their phone number may stop appearing in
|
|
69
|
+
webhooks — `wa_id`/`from`/`recipient_id` are omitted, and the only stable identifier
|
|
70
|
+
left is their **business-scoped user ID** (BSUID), a country-code-prefixed string like
|
|
71
|
+
`"US.13491208655302741918"` assigned to `user_id`/`from_user_id`/`recipient_user_id`.
|
|
72
|
+
Meta sends `user_id`/`from_user_id` with every contact entry and inbound message in its
|
|
73
|
+
published examples, whether or not a username has been adopted, and sends
|
|
74
|
+
`recipient_user_id` on every delivery status regardless of how you addressed the
|
|
75
|
+
message — see [what arrives with which status](#what-arrives-with-which-status) for the
|
|
76
|
+
one exception. All of
|
|
77
|
+
them deserialize to `nil` rather than raising when absent, so read them as optional. A
|
|
78
|
+
`parent_user_id`/`from_parent_user_id`/`recipient_parent_user_id` variant additionally
|
|
79
|
+
appears if you have enrolled in [parent BSUIDs](https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids#parent-business-scoped-user-ids)
|
|
80
|
+
(shared across business portfolios). See
|
|
81
|
+
[Business-scoped user IDs](https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids)
|
|
82
|
+
for the full rollout details.
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
contact = value.contacts.first
|
|
86
|
+
contact.wa_id # => "16315551181" or nil once phone number sharing stops
|
|
87
|
+
contact.user_id # => "US.13491208655302741918" — sent with every contact entry
|
|
88
|
+
contact.username # => "@testusername" or nil — set only once adopted
|
|
89
|
+
contact.parent_user_id # => set only when parent BSUIDs are enabled
|
|
90
|
+
```
|
|
91
|
+
|
|
63
92
|
---
|
|
64
93
|
|
|
65
94
|
## Message types
|
|
@@ -72,7 +101,10 @@ All message classes share this envelope from `Message::Base`:
|
|
|
72
101
|
|
|
73
102
|
| Accessor | Meaning |
|
|
74
103
|
| --- | --- |
|
|
75
|
-
| `from` | The customer's WhatsApp ID |
|
|
104
|
+
| `from` | The customer's WhatsApp ID, or `nil` once they adopt a username (see [BSUID](#business-scoped-user-ids-bsuid)) |
|
|
105
|
+
| `from_user_id` | The customer's BSUID — sent on every inbound message; `nil` if absent |
|
|
106
|
+
| `from_parent_user_id` | The customer's parent BSUID, present only if enabled |
|
|
107
|
+
| `group_id` | Set only if the message was sent in a group chat; `nil` otherwise |
|
|
76
108
|
| `id` | The WAMID — pass it to [`mark_message_as_read!`](../messages/mark_message_as_read.md) |
|
|
77
109
|
| `timestamp` | Unix seconds, as a String |
|
|
78
110
|
| `type` | `"text"`, `"image"`, … |
|
|
@@ -169,6 +201,15 @@ card.phones.first.phone # => "+1"
|
|
|
169
201
|
Nested objects mirror the outbound [contacts message](../messages/contacts.md)
|
|
170
202
|
field-for-field: `Name`, `Phone`, `Email`, `Address`, `Org`, `Url`, plus `birthday`.
|
|
171
203
|
|
|
204
|
+
When the card arrives from a [`REQUEST_CONTACT_INFO`
|
|
205
|
+
button](https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids#requesting-phone-numbers-from-users)
|
|
206
|
+
or a direct in-chat share, two more fields are present:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
card.origin # => "contact_request" (button tap) or "other" (shared from the chat)
|
|
210
|
+
card.vcard # => the raw vCard string when origin is "other"; nil for "contact_request"
|
|
211
|
+
```
|
|
212
|
+
|
|
172
213
|
### Interactive
|
|
173
214
|
|
|
174
215
|
A tap on [reply buttons or a list](../messages/interactive.md).
|
|
@@ -253,6 +294,14 @@ message.change_type # => "customer_changed_number"
|
|
|
253
294
|
|
|
254
295
|
Note `change_type`, not `type` — `type` is taken by the message envelope.
|
|
255
296
|
|
|
297
|
+
When a user's BSUID itself changes (`change_type == "user_changed_user_id"`), the new
|
|
298
|
+
identifiers are also present:
|
|
299
|
+
|
|
300
|
+
```ruby
|
|
301
|
+
message.user_id # => the customer's new BSUID
|
|
302
|
+
message.parent_user_id # => the customer's new parent BSUID, if enabled
|
|
303
|
+
```
|
|
304
|
+
|
|
256
305
|
### Reaction
|
|
257
306
|
|
|
258
307
|
```json
|
|
@@ -264,6 +313,52 @@ message.message_id # => "wamid.OLD" — the message being reacted to
|
|
|
264
313
|
message.emoji # => "👍" — empty String when a reaction is removed
|
|
265
314
|
```
|
|
266
315
|
|
|
316
|
+
### Revoke
|
|
317
|
+
|
|
318
|
+
The customer deleted a message they had sent you. A *control* message rather than a
|
|
319
|
+
content one: it names a message you already have instead of carrying a body.
|
|
320
|
+
|
|
321
|
+
```json
|
|
322
|
+
{ "from": "16505551234", "id": "wamid.HBg", "timestamp": "1749416383",
|
|
323
|
+
"type": "revoke", "revoke": { "original_message_id": "wamid.ORIGINAL" } }
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
message.original_message_id # => "wamid.ORIGINAL" — the message to delete on your side
|
|
328
|
+
message.id # => "wamid.HBg" — the revoke notification's own wamid
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Also arrives through [`smb_message_echoes`](smb_message_echoes.md) when the *business*
|
|
332
|
+
revokes a message from the WhatsApp Business app.
|
|
333
|
+
|
|
334
|
+
### Edit
|
|
335
|
+
|
|
336
|
+
The customer edited a message they had sent you. Names the message being replaced and
|
|
337
|
+
carries the replacement content.
|
|
338
|
+
|
|
339
|
+
```json
|
|
340
|
+
{ "from": "16505551234", "id": "wamid.HBg", "timestamp": "1749416383",
|
|
341
|
+
"type": "edit",
|
|
342
|
+
"edit": { "original_message_id": "wamid.ORIGINAL",
|
|
343
|
+
"message": { "context": { "id": "wamid.CONTEXT" },
|
|
344
|
+
"type": "image",
|
|
345
|
+
"image": { "caption": "Now in blue", "id": "media.1" } } } }
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
```ruby
|
|
349
|
+
message.original_message_id # => "wamid.ORIGINAL"
|
|
350
|
+
message.message # => #<Whatsapp::Webhook::Message::Image ...>
|
|
351
|
+
message.message.caption # => "Now in blue"
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
`message` is a full nested `Message::*`, dispatched by its own `type` — so an edited
|
|
355
|
+
image behaves exactly like an inbound one. It carries **content only**: the nested
|
|
356
|
+
payload has no `from`/`id`/`timestamp` of its own, so those read `nil`. Use the outer
|
|
357
|
+
`message.id` and `message.from`.
|
|
358
|
+
|
|
359
|
+
Also arrives through [`smb_message_echoes`](smb_message_echoes.md) when the *business*
|
|
360
|
+
edits a message from the WhatsApp Business app.
|
|
361
|
+
|
|
267
362
|
### Unknown
|
|
268
363
|
|
|
269
364
|
The fallback for a type this gem doesn't model — including anything Meta flags as
|
|
@@ -289,7 +384,10 @@ Present when the customer replied to, or forwarded, a message.
|
|
|
289
384
|
|
|
290
385
|
```ruby
|
|
291
386
|
message.context&.id # => "wamid.HBg..." — the message replied to
|
|
292
|
-
message.context&.from # => the sender of that message
|
|
387
|
+
message.context&.from # => the sender of that message, or nil once
|
|
388
|
+
# they adopt a username
|
|
389
|
+
message.context&.from_user_id # => that sender's BSUID
|
|
390
|
+
message.context&.from_parent_user_id # => their parent BSUID, if enrolled
|
|
293
391
|
message.context&.forwarded # => true | false
|
|
294
392
|
message.context&.frequently_forwarded # => true | false
|
|
295
393
|
```
|
|
@@ -297,6 +395,12 @@ message.context&.frequently_forwarded # => true | false
|
|
|
297
395
|
`context` is `nil` — not an all-nil object — when the key is absent, so `&.` is the
|
|
298
396
|
right idiom.
|
|
299
397
|
|
|
398
|
+
`from_user_id`/`from_parent_user_id` carry the quoted sender's identity when their phone
|
|
399
|
+
number is gone, the same way [`from_user_id` does on the message itself](#business-scoped-user-ids-bsuid).
|
|
400
|
+
**Their key names are inferred** by symmetry with `Message::Base` — Meta has not published
|
|
401
|
+
a `context` example carrying them, so validate against a real payload before depending on
|
|
402
|
+
them.
|
|
403
|
+
|
|
300
404
|
## Referral
|
|
301
405
|
|
|
302
406
|
Present when the conversation started from a click-to-WhatsApp ad or post.
|
|
@@ -336,15 +440,46 @@ status = value.statuses.first
|
|
|
336
440
|
status.id # => "wamid.HBg" — matches what send! returned
|
|
337
441
|
status.status # => "delivered" — sent | delivered | read | failed
|
|
338
442
|
status.timestamp # => "1750263773"
|
|
339
|
-
status.recipient_id # => "16505551234"
|
|
443
|
+
status.recipient_id # => "16505551234", or nil once sent to a BSUID (see below)
|
|
340
444
|
```
|
|
341
445
|
|
|
342
446
|
| Accessor | Type |
|
|
343
447
|
| --- | --- |
|
|
448
|
+
| `recipient_user_id` | The recipient's BSUID — sent on every status; see the table below for the `failed` exception |
|
|
449
|
+
| `recipient_parent_user_id` | The recipient's parent BSUID, present only if enabled |
|
|
344
450
|
| `conversation` | `Status::Conversation` — `id`, `origin_type`, `expiration_timestamp` |
|
|
345
451
|
| `pricing` | `Status::Pricing` — `billable`, `pricing_model`, `category` |
|
|
346
452
|
| `errors` | `Array<Webhook::Error>` — populated on `"failed"` |
|
|
347
453
|
|
|
454
|
+
`recipient_id` is present when you sent the message to a phone number (or when the
|
|
455
|
+
recipient's phone number is otherwise available). `recipient_user_id` is **not** the
|
|
456
|
+
mirror image of that: Meta sends it on every status whatever you addressed, so the two
|
|
457
|
+
routinely arrive together.
|
|
458
|
+
|
|
459
|
+
### What arrives with which status
|
|
460
|
+
|
|
461
|
+
| | `sent` | `delivered` | `read` | `failed` |
|
|
462
|
+
| --- | :---: | :---: | :---: | :---: |
|
|
463
|
+
| `recipient_user_id` | always | always | always | only if addressed to a BSUID |
|
|
464
|
+
| `contacts[]` | yes | yes | yes | **omitted entirely** |
|
|
465
|
+
| `contacts[].profile.username` | omitted | if adopted | if adopted | — |
|
|
466
|
+
| `errors` | — | — | — | populated |
|
|
467
|
+
|
|
468
|
+
Two traps in that table. A `failed` status is the only one that arrives with no
|
|
469
|
+
`contacts` array at all, so code that reads `value.contacts.first` while handling
|
|
470
|
+
failures must tolerate `nil` — `contacts` is always an Array, but an empty one. And a
|
|
471
|
+
`failed` status for a message you addressed to a phone number carries **neither**
|
|
472
|
+
identifier beyond `recipient_id`, so a BSUID-keyed lookup has to fall back to the phone
|
|
473
|
+
number on that path.
|
|
474
|
+
|
|
475
|
+
```ruby
|
|
476
|
+
# Correlating a failure back to a customer, whichever identifier arrived
|
|
477
|
+
status = value.statuses.first
|
|
478
|
+
customer =
|
|
479
|
+
Customer.find_by(bsuid: status.recipient_user_id) ||
|
|
480
|
+
Customer.find_by(wa_id: status.recipient_id)
|
|
481
|
+
```
|
|
482
|
+
|
|
348
483
|
```ruby
|
|
349
484
|
status.conversation.id # => "6ceb9d9"
|
|
350
485
|
status.conversation.origin_type # => "service" | marketing | utility | authentication
|