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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +143 -0
  3. data/README.md +70 -7
  4. data/docs/README.md +4 -1
  5. data/docs/business_phone_number/README.md +12 -1
  6. data/docs/business_phone_number/contact_book.md +90 -0
  7. data/docs/business_phone_number/username.md +157 -0
  8. data/docs/configuration.md +6 -1
  9. data/docs/errors.md +12 -1
  10. data/docs/message_templates/README.md +1 -1
  11. data/docs/message_templates/components.md +37 -1
  12. data/docs/messages/README.md +86 -9
  13. data/docs/messages/address.md +1 -1
  14. data/docs/messages/audio.md +1 -1
  15. data/docs/messages/contacts.md +1 -1
  16. data/docs/messages/document.md +1 -1
  17. data/docs/messages/image.md +1 -1
  18. data/docs/messages/interactive.md +64 -3
  19. data/docs/messages/location.md +1 -1
  20. data/docs/messages/location_request.md +1 -1
  21. data/docs/messages/reaction.md +1 -1
  22. data/docs/messages/sticker.md +1 -1
  23. data/docs/messages/template.md +28 -1
  24. data/docs/messages/text.md +1 -1
  25. data/docs/messages/video.md +1 -1
  26. data/docs/schedule/README.md +183 -0
  27. data/docs/webhooks/README.md +20 -12
  28. data/docs/webhooks/business_username_updates.md +55 -0
  29. data/docs/webhooks/history.md +78 -15
  30. data/docs/webhooks/messages.md +141 -6
  31. data/docs/webhooks/smb_app_state_sync.md +50 -12
  32. data/docs/webhooks/smb_message_echoes.md +58 -21
  33. data/docs/webhooks/user_id_update.md +84 -0
  34. data/docs/webhooks/user_preferences.md +55 -16
  35. data/lib/ruby/whatsapp/business_phone_number/contact_book/delete.rb +87 -0
  36. data/lib/ruby/whatsapp/business_phone_number/contact_book/deletion.rb +50 -0
  37. data/lib/ruby/whatsapp/business_phone_number/contact_book.rb +25 -0
  38. data/lib/ruby/whatsapp/business_phone_number/transport.rb +6 -5
  39. data/lib/ruby/whatsapp/business_phone_number/username/claim.rb +180 -0
  40. data/lib/ruby/whatsapp/business_phone_number/username/delete.rb +36 -0
  41. data/lib/ruby/whatsapp/business_phone_number/username/details.rb +66 -0
  42. data/lib/ruby/whatsapp/business_phone_number/username/get.rb +33 -0
  43. data/lib/ruby/whatsapp/business_phone_number/username/suggest.rb +37 -0
  44. data/lib/ruby/whatsapp/business_phone_number/username/suggestions.rb +50 -0
  45. data/lib/ruby/whatsapp/business_phone_number/username.rb +27 -0
  46. data/lib/ruby/whatsapp/business_phone_number.rb +9 -0
  47. data/lib/ruby/whatsapp/message_templates/button/request_contact_info.rb +66 -0
  48. data/lib/ruby/whatsapp/message_templates/button.rb +5 -2
  49. data/lib/ruby/whatsapp/message_templates/component_set.rb +28 -0
  50. data/lib/ruby/whatsapp/message_templates/response/paging.rb +2 -1
  51. data/lib/ruby/whatsapp/messages/base.rb +47 -5
  52. data/lib/ruby/whatsapp/messages/interactive/request_contact_info.rb +59 -0
  53. data/lib/ruby/whatsapp/messages/interactive.rb +22 -4
  54. data/lib/ruby/whatsapp/messages/response/contacts.rb +22 -5
  55. data/lib/ruby/whatsapp/schedule/create.rb +101 -0
  56. data/lib/ruby/whatsapp/schedule/list.rb +50 -0
  57. data/lib/ruby/whatsapp/schedule/response/collection.rb +61 -0
  58. data/lib/ruby/whatsapp/schedule/response/created.rb +30 -0
  59. data/lib/ruby/whatsapp/schedule/response/node.rb +85 -0
  60. data/lib/ruby/whatsapp/schedule/response.rb +18 -0
  61. data/lib/ruby/whatsapp/schedule/statuses.rb +19 -0
  62. data/lib/ruby/whatsapp/schedule/transport.rb +24 -0
  63. data/lib/ruby/whatsapp/schedule.rb +15 -0
  64. data/lib/ruby/whatsapp/version.rb +1 -1
  65. data/lib/ruby/whatsapp/webhook/business_username_updates.rb +51 -0
  66. data/lib/ruby/whatsapp/webhook/change.rb +3 -1
  67. data/lib/ruby/whatsapp/webhook/contact.rb +26 -3
  68. data/lib/ruby/whatsapp/webhook/history/chunk.rb +50 -0
  69. data/lib/ruby/whatsapp/webhook/history/thread.rb +64 -0
  70. data/lib/ruby/whatsapp/webhook/history.rb +73 -34
  71. data/lib/ruby/whatsapp/webhook/message/base.rb +27 -2
  72. data/lib/ruby/whatsapp/webhook/message/contacts/contact.rb +15 -2
  73. data/lib/ruby/whatsapp/webhook/message/context.rb +23 -2
  74. data/lib/ruby/whatsapp/webhook/message/edit.rb +50 -0
  75. data/lib/ruby/whatsapp/webhook/message/revoke.rb +36 -0
  76. data/lib/ruby/whatsapp/webhook/message/system.rb +19 -2
  77. data/lib/ruby/whatsapp/webhook/message/unknown.rb +2 -2
  78. data/lib/ruby/whatsapp/webhook/message.rb +3 -0
  79. data/lib/ruby/whatsapp/webhook/messages.rb +4 -3
  80. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/contact.rb +76 -0
  81. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/state_sync.rb +18 -4
  82. data/lib/ruby/whatsapp/webhook/smb_app_state_sync.rb +33 -9
  83. data/lib/ruby/whatsapp/webhook/smb_message_echoes/echo.rb +60 -0
  84. data/lib/ruby/whatsapp/webhook/smb_message_echoes.rb +26 -11
  85. data/lib/ruby/whatsapp/webhook/status.rb +23 -2
  86. data/lib/ruby/whatsapp/webhook/user_id_update/entry.rb +56 -0
  87. data/lib/ruby/whatsapp/webhook/user_id_update/id_change.rb +35 -0
  88. data/lib/ruby/whatsapp/webhook/user_id_update.rb +61 -0
  89. data/lib/ruby/whatsapp/webhook/user_preferences/entry.rb +76 -0
  90. data/lib/ruby/whatsapp/webhook/user_preferences.rb +44 -23
  91. 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: **low-moderate**
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": { "state_sync": [{ "type": "contact",
13
- "action": "add",
14
- "contact": { "full_name": "Jane Doe", "phone_number": "+1" } }] } }
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` | e.g. `contact` |
40
+ | `type` | `"contact"` is the only documented kind |
28
41
  | `action` | e.g. `add`, `update`, `remove` |
29
- | `contact` | **Raw Hash** — the shape is undocumented |
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(phone: sync.contact["phone_number"])&.destroy
76
+ Contact.find_by(bsuid: sync.contact.user_id)&.destroy
43
77
  end
44
78
  end
45
79
  ```
46
80
 
47
- > **Best-effort schema, low-moderate confidence.** `contact` is left as a raw hash
48
- > precisely because its keys are unverified. Validate against a real payload before
49
- > depending on it in production.
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/webhooks/overview
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: **moderate**
8
+ `Whatsapp::Webhook::SmbMessageEchoes` · confidence: **high**
8
9
 
9
10
  ## Payload
10
11
 
11
12
  ```json
12
13
  { "field": "smb_message_echoes",
13
- "value": { "messaging_product": "whatsapp",
14
- "metadata": { "display_phone_number": "15550783881",
15
- "phone_number_id": "106540352242922" },
16
- "message_echoes": [{ "from": "15550783881",
17
- "type": "text",
18
- "text": { "body": "Thanks for shopping with us!" } }] } }
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
- | `message_echoes` | `Array<Message::*>` |
39
+ | `contacts` | `Array<Contact>` — the customers these went to |
40
+ | `message_echoes` | `Array<SmbMessageEchoes::Echo>` |
41
+ | `raw` | The untouched `value` hash |
28
42
 
29
- `message_echoes` reuses the same `Message.deserialize` dispatcher as
30
- [`messages`](messages.md), so every echoed message is a fully typed `Message::Text`,
31
- `Message::Image`, and so on — with the same accessors.
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
- Conversation.for(echo.from).messages.create!(
39
- wamid: echo.id, direction: :outbound, source: :business_app, body: echo.try(:body)
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 here, not the customer's — these are messages
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
- > **Moderate confidence on structure, low on the key name.** Meta states these mirror
48
- > the standard message shape, which is why `Message.deserialize` is reused, but the
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/webhooks/overview
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: **moderate**
7
+ `Whatsapp::Webhook::UserPreferences` · confidence: **confirmed**
8
8
 
9
9
  ## Payload
10
10
 
11
11
  ```json
12
12
  { "field": "user_preferences",
13
- "value": { "wa_id": "16505551234",
14
- "detail": "stopped marketing messages",
15
- "value": "stop",
16
- "timestamp": "1750263773" } }
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
- ## Accessors
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 WhatsApp ID |
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, as a String |
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
- pref = change.value
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
- Customer
35
- .find_by(wa_id: pref.wa_id)
36
- &.update!(marketing_opt_in: pref.value != "stop",
37
- marketing_opt_in_changed_at: Time.at(pref.timestamp.to_i))
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
- > **Best-effort schema.** Meta publishes no JSON example for this field. Validate
44
- > against a real payload before depending on it in production.
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/webhooks/overview
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}, and
6
- # {Profile}: all of them address a phone-number-scoped edge, differing only in edge name
7
- # and body. Extended (not included) because {#edge_path} is used from class-level `.call`
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", or "whatsapp_business_profile").
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)