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
|
@@ -3,30 +3,63 @@
|
|
|
3
3
|
Contact-book and app-state changes synced from the WhatsApp Business app. Arrives
|
|
4
4
|
alongside [`history`](history.md) when a number moves onto Cloud API.
|
|
5
5
|
|
|
6
|
-
`Whatsapp::Webhook::SmbAppStateSync` · confidence: **
|
|
6
|
+
`Whatsapp::Webhook::SmbAppStateSync` · confidence: **high**
|
|
7
7
|
|
|
8
8
|
## Payload
|
|
9
9
|
|
|
10
10
|
```json
|
|
11
11
|
{ "field": "smb_app_state_sync",
|
|
12
|
-
"value": {
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
"value": {
|
|
13
|
+
"messaging_product": "whatsapp",
|
|
14
|
+
"metadata": { "display_phone_number": "15550783881", "phone_number_id": "106540352242922" },
|
|
15
|
+
"state_sync": [
|
|
16
|
+
{ "type": "contact",
|
|
17
|
+
"contact": { "full_name": "Pablo Morales",
|
|
18
|
+
"first_name": "Pablo",
|
|
19
|
+
"phone_number": "16505551234",
|
|
20
|
+
"user_id": "US.13491208655302741918",
|
|
21
|
+
"username": "pablomorales" },
|
|
22
|
+
"action": "add",
|
|
23
|
+
"metadata": { "timestamp": "1750030073" } }
|
|
24
|
+
] } }
|
|
15
25
|
```
|
|
16
26
|
|
|
17
27
|
## Accessors
|
|
18
28
|
|
|
19
29
|
| Accessor | Type |
|
|
20
30
|
| --- | --- |
|
|
31
|
+
| `messaging_product` | `"whatsapp"` |
|
|
32
|
+
| `metadata` | `Metadata` — the business phone number whose address book this is |
|
|
21
33
|
| `state_sync` | `Array<SmbAppStateSync::StateSync>` |
|
|
34
|
+
| `raw` | The untouched `value` hash |
|
|
22
35
|
|
|
23
36
|
### `StateSync`
|
|
24
37
|
|
|
25
38
|
| Accessor | Meaning |
|
|
26
39
|
| --- | --- |
|
|
27
|
-
| `type` |
|
|
40
|
+
| `type` | `"contact"` is the only documented kind |
|
|
28
41
|
| `action` | e.g. `add`, `update`, `remove` |
|
|
29
|
-
| `contact` |
|
|
42
|
+
| `contact` | An `SmbAppStateSync::Contact`, or `nil` |
|
|
43
|
+
| `timestamp` | Flattened up from the entry's own `metadata.timestamp` |
|
|
44
|
+
|
|
45
|
+
The entry's `metadata` is **not** the value-level `Metadata` — it carries only a
|
|
46
|
+
timestamp, so the wrapper is dropped rather than typed.
|
|
47
|
+
|
|
48
|
+
### `Contact`
|
|
49
|
+
|
|
50
|
+
| Accessor | Meaning |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| `full_name` | |
|
|
53
|
+
| `first_name` | |
|
|
54
|
+
| `phone_number` | Omitted once the contact adopts a username |
|
|
55
|
+
| `user_id` | Their [BSUID](messages.md#business-scoped-user-ids-bsuid) |
|
|
56
|
+
| `parent_user_id` | Their parent BSUID, present only if enrolled |
|
|
57
|
+
| `username` | Set only if adopted. Read **flat** here, unlike `Contact#username` on `messages`, which Meta nests under `profile` |
|
|
58
|
+
|
|
59
|
+
This is the gem's third `Contact`, and none are interchangeable:
|
|
60
|
+
[`Webhook::Contact`](messages.md#value-accessors) is a notification's sender identity,
|
|
61
|
+
[`Message::Contacts::Contact`](messages.md#contacts) is a shared contact *card*, and this
|
|
62
|
+
is a device address-book row.
|
|
30
63
|
|
|
31
64
|
## Handling it
|
|
32
65
|
|
|
@@ -37,17 +70,22 @@ when "smb_app_state_sync"
|
|
|
37
70
|
|
|
38
71
|
case sync.action
|
|
39
72
|
when "add", "update"
|
|
40
|
-
Contact.upsert_from_meta(sync.contact
|
|
73
|
+
Contact.upsert_from_meta(bsuid: sync.contact.user_id, phone: sync.contact.phone_number,
|
|
74
|
+
name: sync.contact.full_name, username: sync.contact.username)
|
|
41
75
|
when "remove"
|
|
42
|
-
Contact.find_by(
|
|
76
|
+
Contact.find_by(bsuid: sync.contact.user_id)&.destroy
|
|
43
77
|
end
|
|
44
78
|
end
|
|
45
79
|
```
|
|
46
80
|
|
|
47
|
-
> **
|
|
48
|
-
>
|
|
49
|
-
>
|
|
81
|
+
> **Changed in 0.6.0.** `StateSync#contact` used to be a raw Hash, because Meta had
|
|
82
|
+
> published no shape for it. It is now a typed `Contact`, so `sync.contact["phone_number"]`
|
|
83
|
+
> becomes `sync.contact.phone_number`, and an entry carrying no contact returns `nil`
|
|
84
|
+
> rather than `{}`.
|
|
85
|
+
|
|
86
|
+
> The `state_sync` key itself is still inferred from the field name rather than confirmed
|
|
87
|
+
> by a published example — hence `raw`, so a drifted key leaves a trace.
|
|
50
88
|
|
|
51
89
|
---
|
|
52
90
|
|
|
53
|
-
**Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/
|
|
91
|
+
**Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
|
|
@@ -2,20 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
Echoes of messages sent **from the WhatsApp Business app** rather than through the API.
|
|
4
4
|
Without this, a number used by both your code and a human agent in the app would show
|
|
5
|
-
you only half the conversation.
|
|
5
|
+
you only half the conversation. Messages the business *revoked* or *edited* from the app
|
|
6
|
+
arrive here too.
|
|
6
7
|
|
|
7
|
-
`Whatsapp::Webhook::SmbMessageEchoes` · confidence: **
|
|
8
|
+
`Whatsapp::Webhook::SmbMessageEchoes` · confidence: **high**
|
|
8
9
|
|
|
9
10
|
## Payload
|
|
10
11
|
|
|
11
12
|
```json
|
|
12
13
|
{ "field": "smb_message_echoes",
|
|
13
|
-
"value": {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
"value": {
|
|
15
|
+
"messaging_product": "whatsapp",
|
|
16
|
+
"metadata": { "display_phone_number": "15550783881", "phone_number_id": "106540352242922" },
|
|
17
|
+
"contacts": [
|
|
18
|
+
{ "profile": { "username": "pablomorales" },
|
|
19
|
+
"wa_id": "16505551234",
|
|
20
|
+
"user_id": "US.13491208655302741918" }
|
|
21
|
+
],
|
|
22
|
+
"message_echoes": [
|
|
23
|
+
{ "from": "15550783881",
|
|
24
|
+
"to": "16505551234",
|
|
25
|
+
"to_user_id": "US.13491208655302741918",
|
|
26
|
+
"id": "wamid.HBg",
|
|
27
|
+
"timestamp": "1750030073",
|
|
28
|
+
"type": "text",
|
|
29
|
+
"text": { "body": "Thanks for shopping with us!" } }
|
|
30
|
+
] } }
|
|
19
31
|
```
|
|
20
32
|
|
|
21
33
|
## Accessors
|
|
@@ -24,31 +36,56 @@ you only half the conversation.
|
|
|
24
36
|
| --- | --- |
|
|
25
37
|
| `messaging_product` | `"whatsapp"` |
|
|
26
38
|
| `metadata` | `Metadata` — `display_phone_number`, `phone_number_id` |
|
|
27
|
-
| `
|
|
39
|
+
| `contacts` | `Array<Contact>` — the customers these went to |
|
|
40
|
+
| `message_echoes` | `Array<SmbMessageEchoes::Echo>` |
|
|
41
|
+
| `raw` | The untouched `value` hash |
|
|
28
42
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
43
|
+
### `Echo`
|
|
44
|
+
|
|
45
|
+
| Accessor | Meaning |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| `to` | The customer's phone number, or `nil` once they adopt a username |
|
|
48
|
+
| `to_user_id` | Their [BSUID](messages.md#business-scoped-user-ids-bsuid) |
|
|
49
|
+
| `to_parent_user_id` | Their parent BSUID, present only if enrolled |
|
|
50
|
+
| `message` | The echoed `Message::*`, dispatched by `type` |
|
|
51
|
+
|
|
52
|
+
`Echo#message` goes through the same `Message.deserialize` dispatcher as
|
|
53
|
+
[`messages`](messages.md), so an echo is a fully typed `Message::Text`, `Message::Image`,
|
|
54
|
+
`Message::Revoke`, `Message::Edit`, and so on — with the same accessors.
|
|
55
|
+
|
|
56
|
+
Why a wrapper rather than three more accessors on `Message::Base`? Because an *inbound*
|
|
57
|
+
message can never carry a recipient. Putting `to`/`to_user_id`/`to_parent_user_id` on the
|
|
58
|
+
envelope shared by every message type would hand fifteen classes three permanently-`nil`
|
|
59
|
+
readers, and would turn the `NoMethodError` that currently tells you `message.to` is
|
|
60
|
+
meaningless into a silent `nil`.
|
|
32
61
|
|
|
33
62
|
## Handling it
|
|
34
63
|
|
|
35
64
|
```ruby
|
|
36
65
|
when "smb_message_echoes"
|
|
37
66
|
change.value.message_echoes.each do |echo|
|
|
38
|
-
|
|
39
|
-
|
|
67
|
+
# The customer may have no phone number; the BSUID always identifies them
|
|
68
|
+
conversation = Conversation.for(bsuid: echo.to_user_id, wa_id: echo.to)
|
|
69
|
+
|
|
70
|
+
conversation.messages.create!(
|
|
71
|
+
wamid: echo.message.id,
|
|
72
|
+
direction: :outbound,
|
|
73
|
+
source: :business_app,
|
|
74
|
+
body: echo.message.try(:body)
|
|
40
75
|
)
|
|
41
76
|
end
|
|
42
77
|
```
|
|
43
78
|
|
|
44
|
-
Note `from` is *your* business number
|
|
45
|
-
you sent.
|
|
79
|
+
Note `echo.message.from` is *your* business number, not the customer's — these are
|
|
80
|
+
messages you sent. The customer is on `echo.to*`.
|
|
81
|
+
|
|
82
|
+
> **Changed in 0.6.0.** `message_echoes` used to be an `Array<Message::*>` directly;
|
|
83
|
+
> each entry is now an `Echo` wrapping that message, so `echo.body` becomes
|
|
84
|
+
> `echo.message.body`. The wrapper is what carries the new recipient identifiers.
|
|
46
85
|
|
|
47
|
-
>
|
|
48
|
-
>
|
|
49
|
-
> exact `message_echoes` key is unverified. Validate against a real payload before
|
|
50
|
-
> depending on it in production.
|
|
86
|
+
> The `message_echoes` key itself is still inferred from the field name rather than
|
|
87
|
+
> confirmed by a published example — hence `raw`, so a drifted key leaves a trace.
|
|
51
88
|
|
|
52
89
|
---
|
|
53
90
|
|
|
54
|
-
**Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/
|
|
91
|
+
**Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# `user_id_update`
|
|
2
|
+
|
|
3
|
+
A WhatsApp user's business-scoped user ID (BSUID) changed — most commonly because they
|
|
4
|
+
changed their phone number (see the [`system` message type's `user_changed_user_id`
|
|
5
|
+
event](messages.md#system) for the same event surfaced on the `messages` field).
|
|
6
|
+
|
|
7
|
+
`Whatsapp::Webhook::UserIdUpdate` · confidence: **confirmed** — Meta publishes a real
|
|
8
|
+
JSON example
|
|
9
|
+
|
|
10
|
+
## Payload
|
|
11
|
+
|
|
12
|
+
```json
|
|
13
|
+
{ "field": "user_id_update",
|
|
14
|
+
"value": {
|
|
15
|
+
"messaging_product": "whatsapp",
|
|
16
|
+
"metadata": { "display_phone_number": "15550783881", "phone_number_id": "106540352242922" },
|
|
17
|
+
"contacts": [{ "profile": { "name": "Jane Doe" }, "wa_id": "16505551234" }],
|
|
18
|
+
"user_id_update": [
|
|
19
|
+
{
|
|
20
|
+
"wa_id": "16505551234",
|
|
21
|
+
"detail": "User id for Jane Doe has been updated.",
|
|
22
|
+
"user_id": { "previous": "US.OLD", "current": "US.NEW" },
|
|
23
|
+
"parent_user_id": { "previous": "US.ENT.OLD", "current": "US.ENT.NEW" },
|
|
24
|
+
"timestamp": "1750030073"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
} }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`parent_user_id` is present only if you have enrolled in [parent
|
|
31
|
+
BSUIDs](https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids#parent-business-scoped-user-ids).
|
|
32
|
+
|
|
33
|
+
## Accessors
|
|
34
|
+
|
|
35
|
+
| Accessor | Meaning |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `messaging_product` | `"whatsapp"` |
|
|
38
|
+
| `metadata` | `Metadata` — `display_phone_number`, `phone_number_id` |
|
|
39
|
+
| `contacts` | `Array<Contact>` — the [same BSUID-aware `Contact`](messages.md#business-scoped-user-ids-bsuid) used by `messages` |
|
|
40
|
+
| `updates` | `Array<UserIdUpdate::Entry>` |
|
|
41
|
+
|
|
42
|
+
### `Entry`
|
|
43
|
+
|
|
44
|
+
| Accessor | Meaning |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| `wa_id` | The user's phone number, if available |
|
|
47
|
+
| `detail` | Human-readable description of the change |
|
|
48
|
+
| `user_id` | `UserIdUpdate::IdChange` or `nil` — the user's previous and current BSUID |
|
|
49
|
+
| `parent_user_id` | `UserIdUpdate::IdChange` or `nil` — previous and current parent BSUID |
|
|
50
|
+
| `timestamp` | Unix seconds, as a String |
|
|
51
|
+
|
|
52
|
+
### `IdChange`
|
|
53
|
+
|
|
54
|
+
`Whatsapp::Webhook::UserIdUpdate::IdChange` — a sibling of `Entry`, not nested inside it.
|
|
55
|
+
One class serves both `user_id` and `parent_user_id`, since they are the same shape, and
|
|
56
|
+
each is `nil` rather than an all-nil object when its key is absent.
|
|
57
|
+
|
|
58
|
+
| Accessor | Meaning |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| `previous` | The BSUID before the change |
|
|
61
|
+
| `current` | The BSUID after the change |
|
|
62
|
+
|
|
63
|
+
## Handling it
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
when "user_id_update"
|
|
67
|
+
change.value.updates.each do |update|
|
|
68
|
+
next unless update.user_id # nil when the entry carries only a parent BSUID change
|
|
69
|
+
|
|
70
|
+
user = User.find_by(bsuid: update.user_id.previous)
|
|
71
|
+
next Rails.logger.warn("[whatsapp] unknown BSUID #{update.user_id.previous}") unless user
|
|
72
|
+
|
|
73
|
+
user.update!(bsuid: update.user_id.current)
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Report a miss rather than letting `&.update!` swallow it — a rotation you never applied
|
|
78
|
+
leaves you writing to a dead BSUID with no diagnostics. Log or alert; do not `raise`, or
|
|
79
|
+
Meta will retry a request that can never succeed. `change.value.raw` carries the untouched
|
|
80
|
+
payload if you need to see what actually arrived.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
**Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids#user_id_update-webhooks>
|
|
@@ -4,45 +4,84 @@ A customer changed their marketing preferences — most importantly, opting **ou
|
|
|
4
4
|
Respecting this is not optional: continuing to send marketing to someone who stopped
|
|
5
5
|
you damages your quality rating and breaches Meta's policy.
|
|
6
6
|
|
|
7
|
-
`Whatsapp::Webhook::UserPreferences` · confidence: **
|
|
7
|
+
`Whatsapp::Webhook::UserPreferences` · confidence: **confirmed**
|
|
8
8
|
|
|
9
9
|
## Payload
|
|
10
10
|
|
|
11
11
|
```json
|
|
12
12
|
{ "field": "user_preferences",
|
|
13
|
-
"value": {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
"value": {
|
|
14
|
+
"messaging_product": "whatsapp",
|
|
15
|
+
"metadata": { "display_phone_number": "15550783881", "phone_number_id": "106540352242922" },
|
|
16
|
+
"contacts": [
|
|
17
|
+
{ "profile": { "name": "Pablo M.", "username": "pablomorales" },
|
|
18
|
+
"wa_id": "16505551234",
|
|
19
|
+
"user_id": "US.13491208655302741918" }
|
|
20
|
+
],
|
|
21
|
+
"user_preferences": [
|
|
22
|
+
{ "wa_id": "16505551234",
|
|
23
|
+
"user_id": "US.13491208655302741918",
|
|
24
|
+
"detail": "User requested to stop marketing messages",
|
|
25
|
+
"category": "marketing_messages",
|
|
26
|
+
"value": "stop",
|
|
27
|
+
"timestamp": 1750030073 }
|
|
28
|
+
] } }
|
|
17
29
|
```
|
|
18
30
|
|
|
19
|
-
|
|
31
|
+
Note the nesting: the entries sit under a repeat of the field's own name, the same shape
|
|
32
|
+
[`user_id_update`](user_id_update.md) uses.
|
|
33
|
+
|
|
34
|
+
## Value accessors
|
|
35
|
+
|
|
36
|
+
| Accessor | Type |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| `messaging_product` | `String` — always `"whatsapp"` |
|
|
39
|
+
| `metadata` | `Metadata` — the business phone number this arrived on |
|
|
40
|
+
| `contacts` | `Array<Contact>` — `profile_name`, `username`, `wa_id`, `user_id`, `parent_user_id` |
|
|
41
|
+
| `preferences` | `Array<UserPreferences::Entry>` |
|
|
42
|
+
| `raw` | The untouched `value` hash |
|
|
43
|
+
|
|
44
|
+
## Entry accessors
|
|
20
45
|
|
|
21
46
|
| Accessor | Meaning |
|
|
22
47
|
| --- | --- |
|
|
23
|
-
| `wa_id` | The customer's
|
|
48
|
+
| `wa_id` | The customer's phone number, or `nil` once they adopt a username |
|
|
49
|
+
| `user_id` | Their [BSUID](messages.md#business-scoped-user-ids-bsuid) — the identifier that is always there |
|
|
50
|
+
| `parent_user_id` | Their parent BSUID, present only if enrolled |
|
|
24
51
|
| `detail` | Human-readable description |
|
|
52
|
+
| `category` | `"marketing_messages"` is the only documented value |
|
|
25
53
|
| `value` | `"stop"` or `"resume"` |
|
|
26
|
-
| `timestamp` | Unix seconds
|
|
54
|
+
| `timestamp` | Unix seconds. Meta sends a **number** here, unlike the String timestamps on `messages` |
|
|
55
|
+
|
|
56
|
+
> `Entry#value` sits inside the change's own `value` hash, so the full path reads
|
|
57
|
+
> `change.value.preferences.first.value`. Meta's naming, not ours.
|
|
27
58
|
|
|
28
59
|
## Handling it
|
|
29
60
|
|
|
61
|
+
An opt-out from a username adopter may carry no phone number at all, so key off the
|
|
62
|
+
BSUID and fall back to `wa_id` — not the other way round:
|
|
63
|
+
|
|
30
64
|
```ruby
|
|
31
65
|
when "user_preferences"
|
|
32
|
-
|
|
66
|
+
change.value.preferences.each do |preference|
|
|
67
|
+
customer =
|
|
68
|
+
Customer.find_by(bsuid: preference.user_id) ||
|
|
69
|
+
Customer.find_by(wa_id: preference.wa_id)
|
|
33
70
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
71
|
+
customer&.update!(
|
|
72
|
+
marketing_opt_in: preference.value != "stop",
|
|
73
|
+
marketing_opt_in_changed_at: Time.at(preference.timestamp.to_i)
|
|
74
|
+
)
|
|
75
|
+
end
|
|
38
76
|
```
|
|
39
77
|
|
|
40
78
|
Gate every `MARKETING`-category [template send](../messages/template.md) on this flag.
|
|
41
79
|
`UTILITY` and `AUTHENTICATION` templates are unaffected.
|
|
42
80
|
|
|
43
|
-
> **
|
|
44
|
-
>
|
|
81
|
+
> **Changed in 0.6.0.** This field used to expose `wa_id`/`detail`/`value`/`timestamp`
|
|
82
|
+
> directly on `change.value`. Meta nests them one level deeper than the gem assumed, so
|
|
83
|
+
> those accessors could never return anything but `nil`; they are now on `Entry`.
|
|
45
84
|
|
|
46
85
|
---
|
|
47
86
|
|
|
48
|
-
**Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/
|
|
87
|
+
**Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
module BusinessPhoneNumber
|
|
5
|
+
module ContactBook
|
|
6
|
+
# Deletes one user's entry from your contact book.
|
|
7
|
+
#
|
|
8
|
+
# A validated instance rather than a bare class method: Meta documents a rule this
|
|
9
|
+
# can check — parent BSUIDs are not accepted here — and the edge is rate limited, so
|
|
10
|
+
# a request that was never going to be accepted should not be spent.
|
|
11
|
+
# Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
|
|
12
|
+
class Delete
|
|
13
|
+
include ActiveModel::Validations
|
|
14
|
+
extend ResponseHandling
|
|
15
|
+
extend Transport
|
|
16
|
+
|
|
17
|
+
module Defaults
|
|
18
|
+
EDGE = "contact_book"
|
|
19
|
+
MESSAGING_PRODUCT = "whatsapp"
|
|
20
|
+
# A parent BSUID carries an extra segment (`US.ENT.118...`) where a standard one
|
|
21
|
+
# has just the country code and identifier (`US.134...`), so counting separators
|
|
22
|
+
# distinguishes them without asserting a country-code grammar Meta has not
|
|
23
|
+
# published. See {#validate_standard_bsuid}.
|
|
24
|
+
PARENT_SEPARATOR_COUNT = 1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @!attribute [rw] bsuid
|
|
28
|
+
# @return [String] The business-scoped user ID whose entry to remove.
|
|
29
|
+
attr_accessor :bsuid
|
|
30
|
+
|
|
31
|
+
validate :validate_standard_bsuid
|
|
32
|
+
validates :bsuid, presence: true
|
|
33
|
+
|
|
34
|
+
# @param bsuid [String] The user's standard BSUID, e.g.
|
|
35
|
+
# `"US.13491208655302741918"`. Must belong to the same business portfolio as the
|
|
36
|
+
# phone number — Meta checks that server-side, since it is account state this gem
|
|
37
|
+
# cannot see. Parent BSUIDs are not supported and are rejected locally.
|
|
38
|
+
# @raise [ActiveModel::ValidationError] if the BSUID is missing or looks like a parent.
|
|
39
|
+
def initialize(bsuid:)
|
|
40
|
+
@bsuid = bsuid
|
|
41
|
+
|
|
42
|
+
validate!
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Hash] The query parameters. Sent as a query string rather than a body
|
|
46
|
+
# because the edge is a `DELETE` — the one action in this directory whose
|
|
47
|
+
# `#serialize` is not a JSON payload. `messaging_product` is documented required,
|
|
48
|
+
# so it is always sent.
|
|
49
|
+
def serialize
|
|
50
|
+
{ messaging_product: Defaults::MESSAGING_PRODUCT, bsuid: }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class << self
|
|
54
|
+
# @example Forget a user who asked to be forgotten
|
|
55
|
+
# ContactBook::Delete.call(bsuid: "US.13491208655302741918")
|
|
56
|
+
# @param client [Whatsapp::Client] The WhatsApp client instance.
|
|
57
|
+
# @param bsuid [String] See {#initialize}.
|
|
58
|
+
# @return [Deletion] Whose `deleted` is `false` if no entry existed.
|
|
59
|
+
# @raise [ActiveModel::ValidationError] if the BSUID is missing or looks like a parent.
|
|
60
|
+
# @raise [Error] if no phone number ID is configured, or the request fails.
|
|
61
|
+
def call(bsuid:, client: Client.new)
|
|
62
|
+
params = new(bsuid:).serialize
|
|
63
|
+
response = client.connection.delete(edge_path(client, Defaults::EDGE), params:)
|
|
64
|
+
|
|
65
|
+
Deletion.deserialize(
|
|
66
|
+
parse_json(handle_response!(response, error_class: Error, action: "delete contact book entry"))
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Meta states plainly that parent BSUIDs are rejected here, and this release hands
|
|
74
|
+
# callers `user_id` and `parent_user_id` side by side on half a dozen webhook
|
|
75
|
+
# classes, so mixing them up is the predictable mistake. Detected by segment count
|
|
76
|
+
# rather than by matching `ENT`, which would bet that `ENT` is the only marker Meta
|
|
77
|
+
# will ever insert — an inference either way, so the weaker one wins.
|
|
78
|
+
# @return [void]
|
|
79
|
+
def validate_standard_bsuid
|
|
80
|
+
return if bsuid.nil? || bsuid.to_s.count(".") <= Defaults::PARENT_SEPARATOR_COUNT
|
|
81
|
+
|
|
82
|
+
errors.add(:bsuid, "looks like a parent BSUID; the contact book accepts standard BSUIDs only")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
module BusinessPhoneNumber
|
|
5
|
+
module ContactBook
|
|
6
|
+
# The result of a {Delete}: whether the request worked, and whether it found anything.
|
|
7
|
+
#
|
|
8
|
+
# Its own class rather than {BusinessPhoneNumber::Response}, which carries only
|
|
9
|
+
# `success` (plus the `id` {VerifyCode} sets). `deleted` is a second, independent
|
|
10
|
+
# boolean that changes how `success` reads — `success: true, deleted: false` means
|
|
11
|
+
# the request was fine and there was simply no entry — and collapsing the two would
|
|
12
|
+
# lose that. Adding it to the class every onboarding action returns would also put a
|
|
13
|
+
# field on five actions that can never set it.
|
|
14
|
+
class Deletion
|
|
15
|
+
# @!attribute [rw] messaging_product
|
|
16
|
+
# @return [String, nil] Always `"whatsapp"`.
|
|
17
|
+
attr_accessor :messaging_product
|
|
18
|
+
|
|
19
|
+
# @!attribute [rw] success
|
|
20
|
+
# @return [Boolean] Whether the request was processed.
|
|
21
|
+
attr_accessor :success
|
|
22
|
+
|
|
23
|
+
# @!attribute [rw] deleted
|
|
24
|
+
# @return [Boolean] Whether an entry existed and was removed. `false` alongside a
|
|
25
|
+
# `true` {#success} means there was nothing stored for that BSUID — not a failure.
|
|
26
|
+
attr_accessor :deleted
|
|
27
|
+
|
|
28
|
+
def initialize(messaging_product: nil, success: false, deleted: false)
|
|
29
|
+
@messaging_product = messaging_product
|
|
30
|
+
@success = success
|
|
31
|
+
@deleted = deleted
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# @param response [Hash, nil] The parsed response body.
|
|
36
|
+
# @return [Deletion]
|
|
37
|
+
def deserialize(response)
|
|
38
|
+
response ||= {}
|
|
39
|
+
|
|
40
|
+
new(
|
|
41
|
+
messaging_product: response["messaging_product"],
|
|
42
|
+
success: response["success"] == true,
|
|
43
|
+
deleted: response["deleted"] == true
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
module BusinessPhoneNumber
|
|
5
|
+
# Removes a stored user contact from your **contact book** — the store Meta keeps of
|
|
6
|
+
# the phone numbers and BSUIDs you have interacted with, and uses to keep putting a
|
|
7
|
+
# user's phone number in your webhooks after they adopt a username.
|
|
8
|
+
#
|
|
9
|
+
# Addressed as a `phone_id` edge (`DELETE /{phone_id}/contact_book`), so it reuses
|
|
10
|
+
# {BusinessPhoneNumber::Transport} unchanged, like {Profile} and {Username}. It sits
|
|
11
|
+
# here rather than in a top-level module for two reasons: this directory is the
|
|
12
|
+
# administration of one phone number, as opposed to {Whatsapp::Media} and
|
|
13
|
+
# {Whatsapp::Messages}, which are the traffic itself; and a top-level module would
|
|
14
|
+
# need its own error class for a single endpoint, where living here means one
|
|
15
|
+
# `rescue BusinessPhoneNumber::Error` still covers the whole directory.
|
|
16
|
+
#
|
|
17
|
+
# Note the asymmetry between how it is addressed and what it affects: the edge is on
|
|
18
|
+
# one phone number, but the contact book is scoped to the whole **business portfolio**,
|
|
19
|
+
# so a deletion removes that user's phone number from webhooks for *every* number in
|
|
20
|
+
# the portfolio. See `docs/business_phone_number/contact_book.md`.
|
|
21
|
+
# Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
|
|
22
|
+
module ContactBook
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Whatsapp
|
|
4
4
|
module BusinessPhoneNumber
|
|
5
|
-
# The one piece shared by {Register}, {Deregister}, {RequestCode}, {VerifyCode},
|
|
6
|
-
# {Profile}: all of them address a phone-number-scoped
|
|
7
|
-
# and body. Extended (not included) because
|
|
8
|
-
# methods (as a private class method).
|
|
5
|
+
# The one piece shared by {Register}, {Deregister}, {RequestCode}, {VerifyCode},
|
|
6
|
+
# {Profile}, {Username}, and {ContactBook}: all of them address a phone-number-scoped
|
|
7
|
+
# edge, differing only in edge name and body. Extended (not included) because
|
|
8
|
+
# {#edge_path} is used from class-level `.call` methods (as a private class method).
|
|
9
9
|
#
|
|
10
10
|
# The guard itself and its message live in {Whatsapp::PathBuilding}, shared with every
|
|
11
11
|
# other ID-scoped feature; what stays here is the ID this module addresses.
|
|
@@ -16,7 +16,8 @@ module Whatsapp
|
|
|
16
16
|
|
|
17
17
|
# @param client [Whatsapp::Client]
|
|
18
18
|
# @param action [String] The edge to address ("register", "deregister",
|
|
19
|
-
# "request_code", "verify_code",
|
|
19
|
+
# "request_code", "verify_code", "whatsapp_business_profile", "username",
|
|
20
|
+
# "username_suggestions", or "contact_book").
|
|
20
21
|
# @return [String] The versioned path to the phone number's action edge.
|
|
21
22
|
# @raise [Error] if no phone number ID is configured.
|
|
22
23
|
def edge_path(client, action)
|