ab-ecosystem-cpass 1.0.0 → 1.0.6

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 (86) hide show
  1. package/README.md +121 -21
  2. package/dist/client.d.ts +38 -0
  3. package/dist/client.js +49 -5
  4. package/dist/client.js.map +1 -1
  5. package/dist/http.d.ts +15 -0
  6. package/dist/http.js +21 -1
  7. package/dist/http.js.map +1 -1
  8. package/dist/index.d.ts +11 -0
  9. package/dist/index.js +24 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/internal/bytes.d.ts +38 -0
  12. package/dist/internal/bytes.js +84 -0
  13. package/dist/internal/bytes.js.map +1 -0
  14. package/dist/internal/device.d.ts +24 -0
  15. package/dist/internal/device.js +91 -0
  16. package/dist/internal/device.js.map +1 -0
  17. package/dist/internal/md5.d.ts +4 -0
  18. package/dist/internal/md5.js +109 -0
  19. package/dist/internal/md5.js.map +1 -0
  20. package/dist/internal/receiver-file.d.ts +106 -0
  21. package/dist/internal/receiver-file.js +128 -0
  22. package/dist/internal/receiver-file.js.map +1 -0
  23. package/dist/realtime/client.d.ts +85 -0
  24. package/dist/realtime/client.js +126 -0
  25. package/dist/realtime/client.js.map +1 -0
  26. package/dist/realtime/events.d.ts +147 -0
  27. package/dist/realtime/events.js +3 -0
  28. package/dist/realtime/events.js.map +1 -0
  29. package/dist/realtime/inbox.d.ts +173 -0
  30. package/dist/realtime/inbox.js +263 -0
  31. package/dist/realtime/inbox.js.map +1 -0
  32. package/dist/realtime/index.d.ts +16 -0
  33. package/dist/realtime/index.js +37 -0
  34. package/dist/realtime/index.js.map +1 -0
  35. package/dist/resources/audience/contacts.d.ts +19 -3
  36. package/dist/resources/audience/contacts.js +19 -3
  37. package/dist/resources/audience/contacts.js.map +1 -1
  38. package/dist/resources/audience/inbox-media.d.ts +92 -0
  39. package/dist/resources/audience/inbox-media.js +127 -0
  40. package/dist/resources/audience/inbox-media.js.map +1 -0
  41. package/dist/resources/audience/notifications.d.ts +171 -0
  42. package/dist/resources/audience/notifications.js +215 -0
  43. package/dist/resources/audience/notifications.js.map +1 -0
  44. package/dist/resources/audience/scheduled-messages.d.ts +234 -0
  45. package/dist/resources/audience/scheduled-messages.js +259 -0
  46. package/dist/resources/audience/scheduled-messages.js.map +1 -0
  47. package/dist/resources/campaign-audience.d.ts +132 -0
  48. package/dist/resources/campaign-audience.js +224 -0
  49. package/dist/resources/campaign-audience.js.map +1 -0
  50. package/dist/resources/campaign-report-exports.d.ts +14 -0
  51. package/dist/resources/campaign-report-exports.js +28 -0
  52. package/dist/resources/campaign-report-exports.js.map +1 -0
  53. package/dist/resources/campaign-reports.d.ts +15 -0
  54. package/dist/resources/campaign-reports.js +25 -0
  55. package/dist/resources/campaign-reports.js.map +1 -0
  56. package/dist/resources/campaigns.d.ts +239 -0
  57. package/dist/resources/campaigns.js +300 -0
  58. package/dist/resources/campaigns.js.map +1 -0
  59. package/dist/types/audience.d.ts +93 -1
  60. package/dist/types/campaign.d.ts +229 -0
  61. package/dist/types/campaign.js +3 -0
  62. package/dist/types/campaign.js.map +1 -0
  63. package/dist/types/contact-filter.d.ts +3 -2
  64. package/dist/types/contact-filter.js +3 -2
  65. package/dist/types/contact-filter.js.map +1 -1
  66. package/dist/types/inbox.d.ts +391 -0
  67. package/dist/types/inbox.js +16 -0
  68. package/dist/types/inbox.js.map +1 -0
  69. package/dist/types/index.d.ts +3 -0
  70. package/dist/types/index.js +5 -0
  71. package/dist/types/index.js.map +1 -1
  72. package/dist/types/scheduled-message.d.ts +221 -0
  73. package/dist/types/scheduled-message.js +16 -0
  74. package/dist/types/scheduled-message.js.map +1 -0
  75. package/docs/audience/contacts.md +34 -4
  76. package/docs/audience/notifications.md +263 -0
  77. package/docs/audience/scheduled-messages.md +557 -0
  78. package/docs/campaign/campaigns.md +489 -0
  79. package/docs/contact-filter.md +2 -1
  80. package/docs/inbox/chats.md +356 -0
  81. package/docs/inbox/connection.md +171 -0
  82. package/docs/inbox/media.md +202 -0
  83. package/docs/inbox/messages.md +338 -0
  84. package/docs/inbox/referrals.md +72 -0
  85. package/docs/inbox/sessions.md +74 -0
  86. package/package.json +28 -2
@@ -0,0 +1,202 @@
1
+ # Inbox — media
2
+
3
+ Images, video, audio, voice notes, stickers and documents in a live chat.
4
+
5
+ Uploads do not pass through the API. A client describes the file, gets a presigned
6
+ URL, and sends the bytes **straight to object storage** — so a 90 MB document
7
+ costs the gateway a signature rather than 90 MB of transfer, and a stalled upload
8
+ holds the browser's connection rather than one of ours.
9
+
10
+ Storage is Wasabi, S3-compatible.
11
+
12
+ ## The short version
13
+
14
+ ```ts
15
+ const link = await crm.inboxMedia.upload(file, { kind: 'image' });
16
+
17
+ await inbox.messages.sendMedia({
18
+ channel: channelId,
19
+ contact: { id: chatId, identifier: '919000000000' },
20
+ kind: 'image',
21
+ link,
22
+ caption: 'Your order has shipped',
23
+ });
24
+ ```
25
+
26
+ Or in one call, if the `Inbox` was given an uploader:
27
+
28
+ ```ts
29
+ const inbox = new Inbox({
30
+ url, clientId, token,
31
+ uploadMedia: crm.inboxMedia.upload.bind(crm.inboxMedia),
32
+ });
33
+
34
+ await inbox.messages.sendMedia({ channel, contact, kind: 'image', file, caption: 'Your order' });
35
+ ```
36
+
37
+ A socket cannot presign — the ticket comes from the REST API — so `uploadMedia`
38
+ is the seam between the two. It is a function rather than a client, so an app that
39
+ only ever sends an already-uploaded `link` pulls in none of the REST surface.
40
+
41
+ ## What may be sent
42
+
43
+ | Kind | Limit | Accepts |
44
+ |---|---|---|
45
+ | `image` | 5 MB | `image/jpeg` `image/png` `image/webp` |
46
+ | `video` | 16 MB | `video/mp4` `video/3gpp` `video/quicktime` |
47
+ | `audio` | 16 MB | `audio/aac` `audio/mp4` `audio/mpeg` `audio/amr` `audio/ogg` |
48
+ | `voice` | 16 MB | same as `audio` |
49
+ | `sticker` | 100 KB | `image/webp` |
50
+ | `document` | 100 MB | anything a browser will not execute |
51
+
52
+ These are WhatsApp's ceilings, checked when the upload is **requested** rather
53
+ than when the message is sent. That is the only point where the answer is still
54
+ useful: a file rejected here is one the agent can swap, while one that passes and
55
+ then fails at the provider has already been uploaded, already drawn on screen, and
56
+ comes back as a provider error against a bubble that looks sent.
57
+
58
+ `text/html`, `image/svg+xml` and the JavaScript types are refused for every kind,
59
+ `document` included. They would be served as live pages from the bucket's own
60
+ origin.
61
+
62
+ ## upload
63
+
64
+ ```ts
65
+ upload(file: Blob | UploadMediaSource, options: UploadMediaOptions): Promise<string>
66
+ ```
67
+
68
+ Three steps in one call — ask for a ticket, `PUT` the bytes, return the link.
69
+
70
+ | Option | Type | Required | Notes |
71
+ |---|---|---|---|
72
+ | `kind` | `MediaKind` | yes | Decides the size and type limits. |
73
+ | `filename` | `string` | for a bare `Blob` | Overrides a `File`'s own name. |
74
+ | `contentType` | `string` | for a bare `Blob` | Overrides a `File`'s own type. |
75
+ | `signal` | `AbortSignal` | no | Cancels the transfer. |
76
+
77
+ In Node there is no `File`, so describe the bytes:
78
+
79
+ ```ts
80
+ const bytes = await readFile('invoice.pdf');
81
+
82
+ const link = await crm.inboxMedia.upload(
83
+ { body: bytes, filename: 'invoice.pdf', contentType: 'application/pdf', size: bytes.byteLength },
84
+ { kind: 'document' },
85
+ );
86
+ ```
87
+
88
+ `size` must be the real byte length. It is signed into the URL, so a mismatch
89
+ fails the upload rather than being silently corrected.
90
+
91
+ ### The returned link is permanent
92
+
93
+ Only `upload_url` expires, and only if it is never used. The link itself does not,
94
+ which is what lets a thread still render its media months later.
95
+
96
+ Objects are `public-read` with an unguessable UUID key — the provider fetches the
97
+ media by URL when the message is sent, and the inbox renders it from the stored
98
+ link for the life of the conversation. **Anyone holding the URL can read the
99
+ object**, so treat the link as the secret it is.
100
+
101
+ ## createUpload
102
+
103
+ ```ts
104
+ createUpload(payload: CreateMediaUploadPayload): Promise<MediaUpload>
105
+ ```
106
+
107
+ `POST /v1/audience/inbox/media/upload-url` — the ticket on its own, no bytes.
108
+ Reach for this when you need the `key`, or when something other than this SDK
109
+ performs the upload.
110
+
111
+ | Field | Type | Notes |
112
+ |---|---|---|
113
+ | `kind` | `MediaKind` | |
114
+ | `filename` | `string` | Only its extension and display name survive. |
115
+ | `content_type` | `string` | Signed. Becomes the object's stored type. |
116
+ | `size` | `number` | Signed. Exact byte length. |
117
+
118
+ Answers:
119
+
120
+ | Field | Notes |
121
+ |---|---|
122
+ | `upload_url` | `PUT` here. Expires in 15 minutes. |
123
+ | `headers` | Send **exactly** these. One extra or missing header fails the signature. |
124
+ | `link` | Where the object will be readable. Permanent. |
125
+ | `key` | Path in the bucket: `inbox/<ownerId>/<yyyy>/<mm>/<uuid><ext>` |
126
+ | `expires_at` / `expires_in` | When `upload_url` stops working. |
127
+
128
+ Doing the `PUT` yourself:
129
+
130
+ ```ts
131
+ const ticket = await crm.inboxMedia.createUpload({
132
+ kind: 'image', filename: 'order.jpg', content_type: 'image/jpeg', size: file.size,
133
+ });
134
+
135
+ await fetch(ticket.upload_url, { method: 'PUT', headers: ticket.headers, body: file });
136
+ // ticket.link is now live
137
+ ```
138
+
139
+ Send **no** authorization header on that `PUT`. The signature is the
140
+ authorisation, and an extra header breaks it — besides handing your bearer token
141
+ to a third party.
142
+
143
+ ### Your filename never becomes the key
144
+
145
+ The key is always a UUID. Two agents sending `photo.jpg` would otherwise collide,
146
+ with the second silently replacing the first inside a live conversation, and a
147
+ name like `../../other` would escape the account's prefix. The name you gave is
148
+ kept as the object's `Content-Disposition`, so a download still offers it.
149
+
150
+ The owner id in the key comes from the verified token, never from the payload.
151
+
152
+ ## sendMedia
153
+
154
+ ```ts
155
+ sendMedia(payload: SendMediaPayload): Promise<Message>
156
+ ```
157
+
158
+ `message.send` · scope `messages:write`
159
+
160
+ Assembles the content and sends it. Everything `send()` does applies — the reply
161
+ is an optimistic echo with a queue id, and sending by hand pauses the chat's bot.
162
+ See [messages.md](https://github.com/udghatan-dev/crm-sdk/blob/main/packages/sdk-js/docs/inbox/messages.md).
163
+
164
+ | Field | Type | Required | Notes |
165
+ |---|---|---|---|
166
+ | `channel` | `string` | yes | |
167
+ | `contact` | `{id, identifier}` | yes | |
168
+ | `kind` | `MediaKind` | yes | |
169
+ | `link` | `string` | one of | Already uploaded. Takes precedence over `file`. |
170
+ | `file` | `MediaFile` | one of | Uploaded first. Needs `uploadMedia` on the Inbox. |
171
+ | `caption` | `string` | no | Shown under the media. Ignored for a `sticker`. |
172
+ | `filename` | `string` | no | What the recipient sees for a `document`. |
173
+ | `context` | `JsonObject` | no | `{ message_id }` to reply to a specific message. |
174
+ | `agent` | `string` | no | Needs `live_chat.send.AS_OTHER`. |
175
+
176
+ What goes on the wire:
177
+
178
+ ```ts
179
+ { type: 'image', image: { link, caption } }
180
+ { type: 'document', document: { link, caption, filename } }
181
+ { type: 'sticker', sticker: { link } }
182
+ ```
183
+
184
+ A caption belongs on the media object rather than beside it, and a sticker takes
185
+ neither a caption nor a name — both are dropped rather than rejected.
186
+
187
+ Set `filename` on a document: it is what the recipient sees in their chat, where a
188
+ bare UUID would otherwise appear.
189
+
190
+ ## Errors
191
+
192
+ | Status | Code | Means |
193
+ |---|---|---|
194
+ | 422 | `validation_failed` | Too large, or the type is not allowed for the kind. `details` names the field. |
195
+ | 503 | `media_unavailable` | The deployment has no storage configured. Every other inbox action still works. |
196
+ | — | `media_upload_failed` | The `PUT` to storage failed. Almost always the bytes not matching the declared `size` or `content_type`. |
197
+
198
+ ## Inbound media
199
+
200
+ Media a contact sends arrives already downloaded, with a `link` under the content
201
+ key. When that first download failed, `messages.retryDownload()` re-fetches it —
202
+ inbound only, since an outbound attachment was ours to begin with.
@@ -0,0 +1,338 @@
1
+ # Inbox — messages
2
+
3
+ `inbox.messages.*` · see [connection.md](https://github.com/udghatan-dev/crm-sdk/blob/main/packages/sdk-js/docs/inbox/connection.md) first
4
+
5
+ | Action | Event | Scope |
6
+ |---|---|---|
7
+ | `list` | `message.list` | `messages:read` |
8
+ | `get` | `message.fetch` | `messages:read` |
9
+ | `send` | `message.send` | `messages:write` |
10
+ | `delete` | `message.delete` | `messages:write` |
11
+ | `retryDownload` | `message.retry_download` | `messages:write` |
12
+
13
+ ## The message shape
14
+
15
+ ```ts
16
+ interface Message<C extends MessageContent = MessageContent, X = JsonObject> {
17
+ id: string;
18
+ contact: string;
19
+ channel: string; // id only: channels live in another service
20
+ content: C; // open; see below
21
+ context: X; // replied-to id, forwarding, CTWA referral
22
+ identifier: string; // the contact's identity on this channel
23
+ message_id: string; // the provider's id, unique per account
24
+ status: 'received' | 'sent' | 'delivered' | 'read' | 'failed';
25
+ timestamp: number; // unix seconds, as the provider reported it
26
+ direction: 'inbound' | 'outbound';
27
+ source: 'contact' | 'agent' | 'bot' | 'campaign' | 'api' | 'business_app';
28
+ sent_by_api: boolean;
29
+ sent_by_biz_app: boolean;
30
+ sent_by_campaign: boolean;
31
+ sent_by_bot: boolean;
32
+ agent: string | null;
33
+ receipts: { sent; read; failed; replied; received; delivered }; // unix seconds, 0 = not yet
34
+ meta: { bookmarked: boolean; hide: boolean };
35
+ campaign: { id: string | null; cmid: number };
36
+ bot: { id: string | null; node: string };
37
+ error: string;
38
+ createdAt: string;
39
+ updatedAt: string;
40
+ }
41
+ ```
42
+
43
+ ### direction and source are derived
44
+
45
+ The stored document has no direction field: `received` is the only inbound state,
46
+ and the sender is encoded as four booleans where an agent send is the *absence*
47
+ of all of them. `direction` and `source` are computed for you so no consumer has
48
+ to re-derive that. The raw flags are still there if you need them.
49
+
50
+ | `status` | `sent_by_*` | `direction` | `source` |
51
+ |---|---|---|---|
52
+ | `received` | — | `inbound` | `contact` |
53
+ | anything else | `sent_by_campaign` | `outbound` | `campaign` |
54
+ | | `sent_by_bot` | `outbound` | `bot` |
55
+ | | `sent_by_api` | `outbound` | `api` |
56
+ | | `sent_by_biz_app` | `outbound` | `business_app` |
57
+ | | none set | `outbound` | `agent` |
58
+
59
+ ### receipts are timestamps, not counts
60
+
61
+ Each is the unix second at which the message reached that state, and `0` means it
62
+ never did. `receipts.read` is the "seen at 10:42" a chat window renders.
63
+
64
+ ### content is open, on purpose
65
+
66
+ This is a multi-channel platform. A WhatsApp interactive reply, an email part
67
+ tree and an RCS card share no structure worth forcing into one schema, so
68
+ `content` is whatever the channel says it is.
69
+
70
+ One thing is promised: **`content.type` is always present**, and by convention the
71
+ body for that kind sits under a key of the same name.
72
+
73
+ ```ts
74
+ { type: 'text', text: { body: 'Hello' } }
75
+ { type: 'image', image: { id: '123', link: 'https://...', caption: 'Invoice' } }
76
+ { type: 'interactive', interactive: { type: 'button_reply', button_reply: { id, title } } }
77
+ ```
78
+
79
+ That single field is what makes previews, renderer selection, filtering and
80
+ narrowing possible. Everything else is yours.
81
+
82
+ Narrow it where you know the channel:
83
+
84
+ ```ts
85
+ type WhatsAppText = { type: 'text'; text: { body: string } };
86
+ type WhatsAppImage = { type: 'image'; image: { id: string; link?: string; caption?: string } };
87
+
88
+ const page = await inbox.messages.list<WhatsAppText | WhatsAppImage>(chatId);
89
+
90
+ for (const message of page) {
91
+ if (message.content.type === 'text') render(message.content.text.body);
92
+ }
93
+ ```
94
+
95
+ Observed shapes are per channel kind and not normative — they are whatever the
96
+ provider sent. Treat an unrecognised `content.type` as something to skip, not as
97
+ an error.
98
+
99
+ ## list
100
+
101
+ ```ts
102
+ const page = await inbox.messages.list(chatId, { limit: 20 });
103
+ ```
104
+
105
+ `message.list` · scope `messages:read`
106
+
107
+ | Field | Type | Required | Default | Notes |
108
+ |---|---|---|---|---|
109
+ | `id` | `string` | yes | — | Contact whose thread to read. |
110
+ | `limit` | `number` | no | `20` | Max 100. |
111
+ | `last_message_id` | `string` | no | — | The `id` of the oldest message you hold. |
112
+
113
+ Returns **oldest-first**, ready to prepend above what you already have.
114
+
115
+ Also **subscribes to the thread**: whatever arrives next lands on this socket
116
+ without asking again.
117
+
118
+ ### Walking back through history
119
+
120
+ ```ts
121
+ let oldest: string | undefined;
122
+ const thread: Message[] = [];
123
+
124
+ for (;;) {
125
+ const page = await inbox.messages.list(chatId, { limit: 50, last_message_id: oldest });
126
+ if (!page.length) break;
127
+
128
+ thread.unshift(...page);
129
+ oldest = page[0].id; // oldest-first, so the first element is the cursor
130
+ }
131
+ ```
132
+
133
+ Paging is keyset on `(timestamp, id)`, which matches the ordering exactly. A
134
+ message that arrives out of order — a redelivered webhook, a backdated provider
135
+ timestamp — cannot be skipped or served twice.
136
+
137
+ ## get
138
+
139
+ ```ts
140
+ const message = await inbox.messages.get('wamid.HBgM...');
141
+ ```
142
+
143
+ `message.fetch` · scope `messages:read` · `404` if no such message for this account
144
+
145
+ Takes the **provider id**, not the document id — that is what a webhook or a
146
+ delivery receipt quotes.
147
+
148
+ ## send
149
+
150
+ ```ts
151
+ const echo = await inbox.messages.send({
152
+ channel: channelId,
153
+ contact: { id: chatId, identifier: '919000000000' },
154
+ content: { type: 'text', text: { body: 'On it, checking now.' } },
155
+ });
156
+ ```
157
+
158
+ `message.send` · scope `messages:write`
159
+
160
+ | Field | Type | Required | Notes |
161
+ |---|---|---|---|
162
+ | `channel` | `string` | yes | Channel to send over. |
163
+ | `contact.id` | `string` | yes | |
164
+ | `contact.identifier` | `string` | yes | The contact's identity on that channel. |
165
+ | `content` | `MessageContent` | yes | Must carry `type`. Otherwise passed through. |
166
+ | `context` | `JsonObject` | no | `{ message_id }` to reply to a specific message. |
167
+ | `agent` | `string` | no | Attribute to another member. Needs `live_chat.send.AS_OTHER`, else `403`. |
168
+
169
+ ### What comes back is not the stored message
170
+
171
+ The reply is an **echo** for drawing the bubble immediately. Its `message_id` is
172
+ a queue id, not a provider id, and nothing has been written yet.
173
+
174
+ The real row arrives moments later as a `message.received` push, carrying the
175
+ provider's id. **Correlate on the queue id you got back here** — the final
176
+ `message_id` is different by design:
177
+
178
+ ```ts
179
+ const echo = await inbox.messages.send({ /* ... */ });
180
+ draw(echo, { pending: true });
181
+
182
+ const off = inbox.on('message.received', (message) => {
183
+ if (message.contact === chatId) replacePending(echo.id, message);
184
+ });
185
+ ```
186
+
187
+ Why: the provider's id is uniquely indexed, and it is not known until the
188
+ provider answers. Writing a placeholder row first would mean a null id that the
189
+ index refuses for every concurrent send.
190
+
191
+ The echo goes to the sender only. Other agents see the message when the stored
192
+ row is pushed, so nobody is shown a bubble that is about to be replaced.
193
+
194
+ ### The full lifecycle of one send
195
+
196
+ | Step | What happens | What you can observe |
197
+ |---|---|---|
198
+ | 1 | `send()` validates, checks `AS_OTHER`, records the send | the echo, with a queue id |
199
+ | 2 | The chat comes off its bot | `chat.action` / `pause_automation`, account-wide |
200
+ | 3 | The send is handed to the service that holds provider credentials | — |
201
+ | 4 | That service calls the provider and writes the row | `message.received` with the provider's `message_id` |
202
+ | 5 | The provider reports delivery | `message.status`, once per state change |
203
+ | 6 | The pause expires | `chat.action` / `resume_automation` |
204
+
205
+ Step 3 is durable: the send is recorded before it is handed over, so a broker
206
+ that is briefly away delays a message rather than losing it. A send is never
207
+ dropped silently.
208
+
209
+ ### Delivery ticks
210
+
211
+ `message.status` is a separate push from `message.received` so you can update the
212
+ ticks in place without re-rendering the bubble:
213
+
214
+ ```ts
215
+ inbox.on('message.status', (update) => {
216
+ setTicks(update.message_id, update.status);
217
+ if (update.status === 'failed') showError(update.message_id, update.error);
218
+ });
219
+ ```
220
+
221
+ | Field | Type | Notes |
222
+ |---|---|---|
223
+ | `message_id` | `string` | The provider's id — what the echo's queue id became. |
224
+ | `status` | `MessageStatus` | `sent` `delivered` `read` `failed` `received` |
225
+ | `receipts` | `Partial<MessageReceipts>` | Unix second each state was reached. |
226
+ | `error` | `string` | Provider text, when `status` is `failed`. |
227
+
228
+ ### Sending takes the chat off its bot
229
+
230
+ An agent replying by hand pauses automation. That arrives as a `chat.action` with
231
+ `action: 'pause_automation'`, broadcast to the whole account — the automation
232
+ badge has to change on every open inbox, not just the sender's.
233
+
234
+ An already-paused chat keeps its original pause: re-stamping on every message
235
+ would push the resume out indefinitely while an agent types.
236
+
237
+ The pause lifts itself after `chat_metadata.pause_duration` seconds (an hour
238
+ unless the chat says otherwise), and arrives as the matching
239
+ `chat.action` / `resume_automation` — the same name and shape, so one handler
240
+ covers both directions:
241
+
242
+ ```ts
243
+ inbox.on('chat.action', ({ action, chat }) => {
244
+ if (action === 'pause_automation' || action === 'resume_automation') {
245
+ setAutomationBadge(chat.id, chat.bot?.pause === true);
246
+ }
247
+ });
248
+ ```
249
+
250
+ You do not have to call `resumeAutomation()` to end an agent-reply pause; that
251
+ method is for lifting one early.
252
+
253
+ ### Media
254
+
255
+ Use `sendMedia()` rather than assembling the content yourself — it uploads the
256
+ file if you hand it one, and puts the caption and filename where the provider
257
+ expects them. See
258
+ [media.md](https://github.com/udghatan-dev/crm-sdk/blob/main/packages/sdk-js/docs/inbox/media.md).
259
+
260
+ ```ts
261
+ const link = await crm.inboxMedia.upload(file, { kind: 'image' });
262
+ await inbox.messages.sendMedia({ channel, contact, kind: 'image', link, caption: 'Your order' });
263
+ ```
264
+
265
+ ### Templates
266
+
267
+ A template send carries parameters, not prose. Pass the approved definition
268
+ alongside them and the echo comes back rendered — header and body variables
269
+ substituted, dynamic button urls filled in:
270
+
271
+ ```ts
272
+ await inbox.messages.send({
273
+ channel: channelId,
274
+ contact: { id: chatId, identifier: '919000000000' },
275
+ content: {
276
+ type: 'template',
277
+ template: {
278
+ name: 'order_update',
279
+ language: { code: 'en' },
280
+ components: [{ type: 'body', parameters: [{ text: 'Asha' }, { text: '4821' }] }],
281
+ },
282
+ json: approvedTemplate, // from channels.templates.get()
283
+ },
284
+ });
285
+ ```
286
+
287
+ `json` (or `components`) is the approved template. Without it the echo carries the
288
+ parameters unrendered — the send itself still works.
289
+
290
+ Three fields are stripped from `template` before the provider sees it: `json`,
291
+ `order`, and `isOrder`. They belong to the composer, not the message. Your own
292
+ payload object is left untouched, so you can reuse it.
293
+
294
+ ## delete
295
+
296
+ ```ts
297
+ await inbox.messages.delete(chatId, messageId);
298
+ ```
299
+
300
+ `message.delete` · scope `messages:write` · `404` if not in that thread
301
+
302
+ | Field | Type | Required | Notes |
303
+ |---|---|---|---|
304
+ | `id` | `string` | yes | Contact whose thread the message belongs to. |
305
+ | `message_id` | `string` | yes | Either the document id or the provider id. |
306
+
307
+ A **hard delete** — the row is gone. `meta.hide` exists for messages the UI
308
+ should skip, and is not what this sets.
309
+
310
+ Broadcast to everyone with the thread open: a message still rendered after it is
311
+ gone is worse than a missing one.
312
+
313
+ ## retryDownload
314
+
315
+ ```ts
316
+ const message = await inbox.messages.retryDownload(messageId);
317
+ ```
318
+
319
+ `message.retry_download` · scope `messages:write`
320
+
321
+ | Field | Type | Required | Notes |
322
+ |---|---|---|---|
323
+ | `message_id` | `string` | yes | The **document** id — a retry targets a stored row. |
324
+
325
+ Re-fetches media whose first download failed, through the channels service, which
326
+ owns the provider credentials. On success the stored link is replaced at
327
+ `content.<type>.link` and the updated message is returned and broadcast to the
328
+ thread.
329
+
330
+ | Condition | Result |
331
+ |---|---|
332
+ | `content.type` not one of `image` `video` `audio` `document` `voice` `sticker` | `422` |
333
+ | `status` is not `received` | `422` — an outbound attachment was ours to begin with |
334
+ | no `content.<type>.id` | `422` — no provider media id to fetch |
335
+ | the re-fetch declines | the message comes back unchanged, no error |
336
+
337
+ A failed re-fetch is an outcome, not an error: the message keeps its broken link
338
+ and the agent can ask again.
@@ -0,0 +1,72 @@
1
+ # Inbox — referrals (click-to-WhatsApp)
2
+
3
+ Where a lead came from. When a contact opens a conversation by tapping a
4
+ click-to-WhatsApp ad, the provider attaches the ad's referral payload to that
5
+ first inbound message, and it is stored as a row of its own — one per contact per
6
+ channel.
7
+
8
+ `inbox.referrals(...)` · see [connection.md](https://github.com/udghatan-dev/crm-sdk/blob/main/packages/sdk-js/docs/inbox/connection.md) first
9
+
10
+ | Action | Event | Scope |
11
+ |---|---|---|
12
+ | `referrals` | `ctwa.list` | `contacts:read` |
13
+
14
+ ## The shape
15
+
16
+ ```ts
17
+ interface CtwaReferral {
18
+ id: string;
19
+ contact: string;
20
+ channel: string | null;
21
+ /** The provider's referral object, carried through untouched. */
22
+ payload: JsonObject;
23
+ createdAt: string;
24
+ updatedAt: string;
25
+ }
26
+ ```
27
+
28
+ `payload` is whatever the provider sent, unmodified. For WhatsApp that typically
29
+ carries `source_id` (the ad id), `source_type`, `source_url`, `headline`, `body`,
30
+ `media_type` and a thumbnail or video url. It is **not** normalised: read it
31
+ defensively, and treat a missing key as a referral that did not include it.
32
+
33
+ `payload.source_id` is the one key the platform itself relies on — it is what the
34
+ `REFERRAL_ID` filter field matches against.
35
+
36
+ ## referrals
37
+
38
+ ```ts
39
+ const referrals = await inbox.referrals(chatId);
40
+
41
+ for (const { payload } of referrals) {
42
+ console.log(payload.headline, payload.source_url);
43
+ }
44
+ ```
45
+
46
+ `ctwa.list` · scope `contacts:read`
47
+
48
+ | Field | Type | Required | Default | Notes |
49
+ |---|---|---|---|---|
50
+ | `id` | `string` | yes | — | Contact whose referrals to read. |
51
+ | `limit` | `number` | no | `50` | Max 100. |
52
+
53
+ Newest first. An empty array means the contact did not arrive through an ad, which
54
+ is the normal case — not an error.
55
+
56
+ ## Filtering chats by ad
57
+
58
+ `REFERRAL_ID` is a filter field available on the inbox chat list and **not** on
59
+ the REST contact list: it resolves through this collection, which only the inbox
60
+ reads.
61
+
62
+ ```ts
63
+ const { contacts } = await inbox.chats.list({
64
+ conditions: [
65
+ { field_id: 'REFERRAL_ID', field_type: 'string', condition: 'equal to', value: '120210000000', operator: null },
66
+ ],
67
+ });
68
+ ```
69
+
70
+ The condition is membership, whatever comparison you name: a row holds exactly
71
+ one `source_id`, so there is nothing to compare but whether a contact came from
72
+ that ad.
@@ -0,0 +1,74 @@
1
+ # Inbox — chat sessions
2
+
3
+ Whether you can still reply freely.
4
+
5
+ Providers only allow unprompted messages inside a window from the contact's last
6
+ inbound message — 24 hours for WhatsApp. Outside it, a free-form message is
7
+ refused and the conversation has to restart from an approved template.
8
+
9
+ A contact reachable on several channels has **one window per channel**, which is
10
+ why these are rows of their own rather than the single `last_seen` the chat
11
+ carries.
12
+
13
+ `inbox.sessions(...)` · see [connection.md](https://github.com/udghatan-dev/crm-sdk/blob/main/packages/sdk-js/docs/inbox/connection.md) first
14
+
15
+ | Action | Event | Scope |
16
+ |---|---|---|
17
+ | `sessions` | `chat_session.list` | `contacts:read` |
18
+
19
+ ## The shape
20
+
21
+ ```ts
22
+ interface ChatSession {
23
+ id: string;
24
+ channel: string;
25
+ /** Unix seconds of the last inbound message on this channel. */
26
+ last_seen: number;
27
+ }
28
+ ```
29
+
30
+ ## sessions
31
+
32
+ ```ts
33
+ const sessions = await inbox.sessions(chatId);
34
+ ```
35
+
36
+ `chat_session.list` · scope `contacts:read`
37
+
38
+ | Field | Type | Required | Notes |
39
+ |---|---|---|---|
40
+ | `id` | `string` | yes | Contact whose sessions to read. |
41
+ | `channel` | `string` | no | Narrow to one channel. Omitted, every channel the contact has used. |
42
+
43
+ Ordered by `last_seen` descending, so the channel the contact used most recently
44
+ comes first. A channel with no row has never had an inbound message — its window
45
+ was never open.
46
+
47
+ ## Working out whether a window is open
48
+
49
+ ```ts
50
+ const WINDOW = 24 * 60 * 60; // seconds
51
+
52
+ const sessions = await inbox.sessions(chatId);
53
+ const now = Math.floor(Date.now() / 1000);
54
+
55
+ const open = sessions.filter((session) => now - session.last_seen < WINDOW);
56
+
57
+ if (!open.length) {
58
+ // every window has lapsed: send a template, not free text
59
+ }
60
+ ```
61
+
62
+ The same window drives the chat list's `session` filter, so
63
+ `chats.list({ filter: { session: 'active' } })` is the set of chats you can reply
64
+ to right now:
65
+
66
+ ```ts
67
+ const { contacts } = await inbox.chats.list({
68
+ filter: { session: 'active', assignment: 'me' },
69
+ });
70
+ ```
71
+
72
+ Note that filter reads the chat's own `last_seen` — the most recent inbound
73
+ message across every channel — so it answers "is any window open". Read the
74
+ sessions themselves when you need to know *which* channel is still replyable.