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.
- package/README.md +121 -21
- package/dist/client.d.ts +38 -0
- package/dist/client.js +49 -5
- package/dist/client.js.map +1 -1
- package/dist/http.d.ts +15 -0
- package/dist/http.js +21 -1
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +11 -0
- package/dist/index.js +24 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/bytes.d.ts +38 -0
- package/dist/internal/bytes.js +84 -0
- package/dist/internal/bytes.js.map +1 -0
- package/dist/internal/device.d.ts +24 -0
- package/dist/internal/device.js +91 -0
- package/dist/internal/device.js.map +1 -0
- package/dist/internal/md5.d.ts +4 -0
- package/dist/internal/md5.js +109 -0
- package/dist/internal/md5.js.map +1 -0
- package/dist/internal/receiver-file.d.ts +106 -0
- package/dist/internal/receiver-file.js +128 -0
- package/dist/internal/receiver-file.js.map +1 -0
- package/dist/realtime/client.d.ts +85 -0
- package/dist/realtime/client.js +126 -0
- package/dist/realtime/client.js.map +1 -0
- package/dist/realtime/events.d.ts +147 -0
- package/dist/realtime/events.js +3 -0
- package/dist/realtime/events.js.map +1 -0
- package/dist/realtime/inbox.d.ts +173 -0
- package/dist/realtime/inbox.js +263 -0
- package/dist/realtime/inbox.js.map +1 -0
- package/dist/realtime/index.d.ts +16 -0
- package/dist/realtime/index.js +37 -0
- package/dist/realtime/index.js.map +1 -0
- package/dist/resources/audience/contacts.d.ts +19 -3
- package/dist/resources/audience/contacts.js +19 -3
- package/dist/resources/audience/contacts.js.map +1 -1
- package/dist/resources/audience/inbox-media.d.ts +92 -0
- package/dist/resources/audience/inbox-media.js +127 -0
- package/dist/resources/audience/inbox-media.js.map +1 -0
- package/dist/resources/audience/notifications.d.ts +171 -0
- package/dist/resources/audience/notifications.js +215 -0
- package/dist/resources/audience/notifications.js.map +1 -0
- package/dist/resources/audience/scheduled-messages.d.ts +234 -0
- package/dist/resources/audience/scheduled-messages.js +259 -0
- package/dist/resources/audience/scheduled-messages.js.map +1 -0
- package/dist/resources/campaign-audience.d.ts +132 -0
- package/dist/resources/campaign-audience.js +224 -0
- package/dist/resources/campaign-audience.js.map +1 -0
- package/dist/resources/campaign-report-exports.d.ts +14 -0
- package/dist/resources/campaign-report-exports.js +28 -0
- package/dist/resources/campaign-report-exports.js.map +1 -0
- package/dist/resources/campaign-reports.d.ts +15 -0
- package/dist/resources/campaign-reports.js +25 -0
- package/dist/resources/campaign-reports.js.map +1 -0
- package/dist/resources/campaigns.d.ts +239 -0
- package/dist/resources/campaigns.js +300 -0
- package/dist/resources/campaigns.js.map +1 -0
- package/dist/types/audience.d.ts +93 -1
- package/dist/types/campaign.d.ts +229 -0
- package/dist/types/campaign.js +3 -0
- package/dist/types/campaign.js.map +1 -0
- package/dist/types/contact-filter.d.ts +3 -2
- package/dist/types/contact-filter.js +3 -2
- package/dist/types/contact-filter.js.map +1 -1
- package/dist/types/inbox.d.ts +391 -0
- package/dist/types/inbox.js +16 -0
- package/dist/types/inbox.js.map +1 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.js +5 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/scheduled-message.d.ts +221 -0
- package/dist/types/scheduled-message.js +16 -0
- package/dist/types/scheduled-message.js.map +1 -0
- package/docs/audience/contacts.md +34 -4
- package/docs/audience/notifications.md +263 -0
- package/docs/audience/scheduled-messages.md +557 -0
- package/docs/campaign/campaigns.md +489 -0
- package/docs/contact-filter.md +2 -1
- package/docs/inbox/chats.md +356 -0
- package/docs/inbox/connection.md +171 -0
- package/docs/inbox/media.md +202 -0
- package/docs/inbox/messages.md +338 -0
- package/docs/inbox/referrals.md +72 -0
- package/docs/inbox/sessions.md +74 -0
- 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.
|