whisp-js 0.0.0-stage → 0.3.0

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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +739 -2
  3. package/dist/cjs/client.d.ts +213 -0
  4. package/dist/cjs/client.js +570 -0
  5. package/dist/cjs/constants.d.ts +43 -0
  6. package/dist/cjs/constants.js +60 -0
  7. package/dist/cjs/errors.d.ts +96 -0
  8. package/dist/cjs/errors.js +115 -0
  9. package/dist/cjs/index.d.ts +19 -0
  10. package/dist/cjs/index.js +39 -0
  11. package/dist/cjs/listener.d.ts +93 -0
  12. package/dist/cjs/listener.js +457 -0
  13. package/dist/cjs/media.d.ts +40 -0
  14. package/dist/cjs/media.js +181 -0
  15. package/dist/cjs/package.json +3 -0
  16. package/dist/cjs/refs.d.ts +4 -0
  17. package/dist/cjs/refs.js +38 -0
  18. package/dist/cjs/runtime.d.ts +24 -0
  19. package/dist/cjs/runtime.js +93 -0
  20. package/dist/cjs/sse.d.ts +32 -0
  21. package/dist/cjs/sse.js +105 -0
  22. package/dist/cjs/transport.d.ts +61 -0
  23. package/dist/cjs/transport.js +181 -0
  24. package/dist/cjs/types.d.ts +583 -0
  25. package/dist/cjs/types.js +14 -0
  26. package/dist/cjs/version.d.ts +2 -0
  27. package/dist/cjs/version.js +5 -0
  28. package/dist/cjs/webhook.d.ts +27 -0
  29. package/dist/cjs/webhook.js +128 -0
  30. package/dist/esm/client.d.ts +213 -0
  31. package/dist/esm/client.js +566 -0
  32. package/dist/esm/constants.d.ts +43 -0
  33. package/dist/esm/constants.js +57 -0
  34. package/dist/esm/errors.d.ts +96 -0
  35. package/dist/esm/errors.js +107 -0
  36. package/dist/esm/index.d.ts +19 -0
  37. package/dist/esm/index.js +16 -0
  38. package/dist/esm/listener.d.ts +93 -0
  39. package/dist/esm/listener.js +452 -0
  40. package/dist/esm/media.d.ts +40 -0
  41. package/dist/esm/media.js +175 -0
  42. package/dist/esm/refs.d.ts +4 -0
  43. package/dist/esm/refs.js +33 -0
  44. package/dist/esm/runtime.d.ts +24 -0
  45. package/dist/esm/runtime.js +84 -0
  46. package/dist/esm/sse.d.ts +32 -0
  47. package/dist/esm/sse.js +100 -0
  48. package/dist/esm/transport.d.ts +61 -0
  49. package/dist/esm/transport.js +172 -0
  50. package/dist/esm/types.d.ts +583 -0
  51. package/dist/esm/types.js +13 -0
  52. package/dist/esm/version.d.ts +2 -0
  53. package/dist/esm/version.js +2 -0
  54. package/dist/esm/webhook.d.ts +27 -0
  55. package/dist/esm/webhook.js +123 -0
  56. package/package.json +37 -5
@@ -0,0 +1,566 @@
1
+ /**
2
+ * The `Whisp` client (phase2-design 12.2, 12.3).
3
+ *
4
+ * Nothing is called at construction. The token is read when a call needs it
5
+ * (`token`, else WHISP_API_TOKEN), and so is the base URL (`baseUrl`, else
6
+ * WHISP_API_URL, else the hosted API), so a value set after import still
7
+ * counts. Phones are checked at once: a typo throws here, not on the first
8
+ * send.
9
+ *
10
+ * An instance sends from its `phone`: one number, a pool (an array, picked
11
+ * server-side by `strategy`, `healthiest` unless set, sticky by default), or
12
+ * none, the whole project (its default number, else every number as a
13
+ * pool). `number(x)` returns a copy scoped to one number, which the reads
14
+ * that need one number require. `dailyCap` (phase3-design 12.4) goes with
15
+ * every send; the server counts and enforces it, never the SDK, since
16
+ * several app instances share one number.
17
+ *
18
+ * 0.3.0 (api-gaps-design 12, T6): reactions, edits, deletes for everyone,
19
+ * marking a chat read and typing presence, and `mentions` on text and
20
+ * caption sends. None of them carries an `Idempotency-Key`: each is
21
+ * idempotent by state on the server, so they are retried like reads.
22
+ */
23
+ import { API_TOKEN_ENV, API_URL_ENV, DAILY_CAP_MAX, DAILY_CAP_MIN, DEFAULT_BASE_URL, FROM_MAX, SEND_STRATEGIES, TOKEN_PATTERN, WAIT_DEFAULT_MS, } from './constants.js';
24
+ import { localError, WhispError } from './errors.js';
25
+ import { Listener } from './listener.js';
26
+ import { prepareFile, putUpload } from './media.js';
27
+ import { requireNumberRef } from './refs.js';
28
+ import { isBrowser, randomId, readEnv, realTimers, sleep, userAgent } from './runtime.js';
29
+ import { request } from './transport.js';
30
+ import { verifyWebhook } from './webhook.js';
31
+ export const DEFAULT_TIMEOUT_MS = 30_000;
32
+ export const DEFAULT_MAX_RETRIES = 2;
33
+ /** `downloadMedia` waits for a file being prepared at most this long. */
34
+ export const MEDIA_WAIT_MAX_MS = 60_000;
35
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
36
+ const invalidResponse = (what) => new WhispError({ code: 'INVALID_RESPONSE', message: `The API's answer to ${what} is not in the documented shape` });
37
+ /** The body as T when `check` holds, else INVALID_RESPONSE. The API only grows (4.1), so only what the SDK reads is checked. */
38
+ const expect = (body, what, check) => {
39
+ if (!isRecord(body) || !check(body))
40
+ throw invalidResponse(what);
41
+ return body;
42
+ };
43
+ const segment = (value) => encodeURIComponent(value);
44
+ const positiveInt = (value, fallback, name, min, max) => {
45
+ if (value === undefined)
46
+ return fallback;
47
+ if (!Number.isInteger(value) || value < min || value > max) {
48
+ throw localError('OPTIONS_INVALID', `${name} must be a whole number from ${min} to ${max}`);
49
+ }
50
+ return value;
51
+ };
52
+ /** A `dailyCap` as the API takes it (1 to 10,000, whole), or undefined when none was given. Checked before any call. */
53
+ const checkDailyCap = (value, name) => {
54
+ if (value === undefined)
55
+ return undefined;
56
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < DAILY_CAP_MIN || value > DAILY_CAP_MAX) {
57
+ throw localError('OPTIONS_INVALID', `${name} must be a whole number from ${DAILY_CAP_MIN} to 10,000`);
58
+ }
59
+ return value;
60
+ };
61
+ const checkBaseUrl = (value, source) => {
62
+ let protocol = '';
63
+ try {
64
+ protocol = new URL(value).protocol;
65
+ }
66
+ catch {
67
+ // Reported below.
68
+ }
69
+ if (protocol !== 'https:' && protocol !== 'http:') {
70
+ throw localError('OPTIONS_INVALID', `${source} must be an http(s) URL`);
71
+ }
72
+ return value.replace(/\/+$/, '');
73
+ };
74
+ const normalizeFrom = (phone) => {
75
+ if (phone === undefined)
76
+ return null;
77
+ if (typeof phone === 'string')
78
+ return requireNumberRef(phone, 'phone');
79
+ if (!Array.isArray(phone))
80
+ throw localError('OPTIONS_INVALID', 'phone must be a string or an array of strings');
81
+ if (phone.length === 0)
82
+ throw localError('OPTIONS_INVALID', 'phone is an empty list; leave it out to use the whole project');
83
+ const refs = [...new Set(phone.map(ref => requireNumberRef(ref, 'phone')))];
84
+ if (refs.length > FROM_MAX)
85
+ throw localError('OPTIONS_INVALID', `A pool has at most ${FROM_MAX} numbers`);
86
+ return refs;
87
+ };
88
+ /** `mentions` as the API takes them: a list of strings, checked here only for its shape (the server parses each). */
89
+ const checkMentions = (value, name) => {
90
+ if (value === undefined)
91
+ return undefined;
92
+ if (!Array.isArray(value) || value.length === 0 || value.some(item => typeof item !== 'string')) {
93
+ throw localError('OPTIONS_INVALID', `${name} must be a non-empty list of phones or <digits>@lid JIDs`);
94
+ }
95
+ return value;
96
+ };
97
+ /** A message's id: the message itself or its `msg_` id. */
98
+ const messageIdOf = (message, method) => {
99
+ const id = typeof message === 'string' ? message : message?.id;
100
+ if (typeof id !== 'string' || !/^msg_[0-9A-Za-z]{1,64}$/.test(id)) {
101
+ throw localError('OPTIONS_INVALID', `${method}() takes a message or its msg_ id`);
102
+ }
103
+ return id;
104
+ };
105
+ const waitMs = (wait) => wait === true ? WAIT_DEFAULT_MS : typeof wait === 'number' && Number.isFinite(wait) ? wait : 0;
106
+ const defaultReport = (error) => {
107
+ console.error(`[whisp-sdk] ${error.name}: ${error.message}`);
108
+ };
109
+ export class Whisp {
110
+ #options;
111
+ #scope;
112
+ #timeoutMs;
113
+ #maxRetries;
114
+ #timers;
115
+ #listener;
116
+ /** Aborted by close(): retries waiting for their turn give up. Replaced, so later calls retry again. */
117
+ #retries = new AbortController();
118
+ constructor(options = {}) {
119
+ const internal = options;
120
+ if (isBrowser() && options.dangerouslyAllowBrowser !== true) {
121
+ throw localError('TOKEN_IN_BROWSER', 'The Whisp SDK runs on your server: in a browser, every visitor could copy the token and send from your numbers.');
122
+ }
123
+ if (options.strategy !== undefined && !SEND_STRATEGIES.includes(options.strategy)) {
124
+ throw localError('OPTIONS_INVALID', "strategy must be 'healthiest', 'random' or 'round-robin'");
125
+ }
126
+ if (options.sticky !== undefined && typeof options.sticky !== 'boolean') {
127
+ throw localError('OPTIONS_INVALID', 'sticky must be true or false');
128
+ }
129
+ checkDailyCap(options.dailyCap, 'dailyCap');
130
+ if (options.baseUrl !== undefined)
131
+ checkBaseUrl(options.baseUrl, 'baseUrl');
132
+ if (options.cursor !== undefined && (typeof options.cursor !== 'string' || options.cursor === '')) {
133
+ throw localError('OPTIONS_INVALID', 'cursor must be a cursor string saved from whisp.cursor');
134
+ }
135
+ this.#options = { ...internal };
136
+ this.#scope = { from: normalizeFrom(options.phone) };
137
+ this.#timeoutMs = positiveInt(options.timeoutMs, DEFAULT_TIMEOUT_MS, 'timeoutMs', 1, 600_000);
138
+ this.#maxRetries = positiveInt(options.maxRetries, DEFAULT_MAX_RETRIES, 'maxRetries', 0, 10);
139
+ this.#timers = internal.timers ?? realTimers;
140
+ const numbers = this.#scope.from === null ? null : [this.#scope.from].flat();
141
+ this.#listener = new Listener({
142
+ resolve: () => this.#target(),
143
+ numbers,
144
+ cursor: options.cursor ?? null,
145
+ connectTimeoutMs: this.#timeoutMs,
146
+ timers: this.#timers,
147
+ report: internal.report ?? defaultReport,
148
+ });
149
+ }
150
+ /**
151
+ * The cursor after the last event delivered to handlers (6.5): save it and
152
+ * pass it as `cursor` to resume after a restart. Not an event's `evt_` id,
153
+ * which is `event.event.id` in a handler. Before the first event it is
154
+ * where the stream started (its `ready`), so a save then resumes from
155
+ * there; null until the stream first opens, unless `cursor` was given
156
+ * (sdk-reconnect-without-cursor-loses-events).
157
+ */
158
+ get cursor() {
159
+ return this.#listener.cursor;
160
+ }
161
+ /** A copy that sends from, reads and listens on this one number (a phone or a `num_` id). */
162
+ number(phoneOrId) {
163
+ const ref = requireNumberRef(phoneOrId, 'number()');
164
+ return new Whisp({ ...this.#options, phone: ref });
165
+ }
166
+ // Connection ---------------------------------------------------------------------------------
167
+ #fetch() {
168
+ const custom = this.#options.fetch;
169
+ // Looked up per call, so a fetch installed after import is used; called unbound, or window.fetch throws.
170
+ return custom ? (input, init) => custom(input, init) : (input, init) => globalThis.fetch(input, init);
171
+ }
172
+ #target() {
173
+ const token = this.#options.token?.trim() || readEnv(API_TOKEN_ENV);
174
+ if (!token) {
175
+ throw localError('TOKEN_MISSING', `No token: pass { token } or set ${API_TOKEN_ENV} (the project's API & SDK tab)`);
176
+ }
177
+ if (!TOKEN_PATTERN.test(token)) {
178
+ throw localError('TOKEN_INVALID', "The token is not a Whisp project token (wsp_live_…). Copy it again from the project's API & SDK tab.");
179
+ }
180
+ const baseUrl = this.#options.baseUrl ?? readEnv(API_URL_ENV) ?? DEFAULT_BASE_URL;
181
+ return {
182
+ token,
183
+ baseUrl: checkBaseUrl(baseUrl, this.#options.baseUrl !== undefined ? 'baseUrl' : API_URL_ENV),
184
+ fetch: this.#fetch(),
185
+ userAgent: userAgent(),
186
+ };
187
+ }
188
+ #transport() {
189
+ return {
190
+ ...this.#target(),
191
+ timeoutMs: this.#timeoutMs,
192
+ maxRetries: this.#maxRetries,
193
+ timers: this.#timers,
194
+ retrySignal: this.#retries.signal,
195
+ };
196
+ }
197
+ async #call(call) {
198
+ return (await request(this.#transport(), call)).body;
199
+ }
200
+ #single(method) {
201
+ const { from } = this.#scope;
202
+ const single = typeof from === 'string' ? from : from?.length === 1 ? from[0] : undefined;
203
+ if (single === undefined) {
204
+ throw localError('NUMBER_REQUIRED', `${method}() reads one number's data: call it on whisp.number('<phone or num_ id>')`);
205
+ }
206
+ return single;
207
+ }
208
+ // Project -------------------------------------------------------------------------------------
209
+ /**
210
+ * Checks the token and the instance's numbers (GET /project, then GET
211
+ * /numbers): throws NUMBER_NOT_IN_PROJECT listing every phone or id that
212
+ * isn't the token's project's. Optional: the first send or listen reports
213
+ * the same.
214
+ */
215
+ async verify() {
216
+ const info = expect(await this.#call({ method: 'GET', path: '/project' }), 'GET /project', body => isRecord(body.project) && isRecord(body.token));
217
+ const numbers = await this.numbers();
218
+ const { from } = this.#scope;
219
+ const missing = (from === null ? [] : [from].flat()).filter(ref => !numbers.some(number => number.id === ref || number.phone === ref));
220
+ if (missing.length > 0) {
221
+ throw new WhispError({
222
+ code: 'NUMBER_NOT_IN_PROJECT',
223
+ message: `${missing.join(', ')} ${missing.length === 1 ? 'is not a number' : 'are not numbers'} of this project`,
224
+ params: { number: missing.join(',') },
225
+ });
226
+ }
227
+ return { ...info, numbers };
228
+ }
229
+ // Sending -------------------------------------------------------------------------------------
230
+ /** Sends text (`POST /messages`) from the instance's number, pool or project. Resolves the message (`pending`, or its status after `wait`). */
231
+ async send(to, text, options = {}) {
232
+ // Async, so a bad option rejects like every other failure instead of throwing from the call.
233
+ return this.#send({ to, type: 'text', text, ...this.#common(options) }, options);
234
+ }
235
+ sendImage(to, media, options = {}) {
236
+ return this.#sendFile('image', to, media, options, options.caption);
237
+ }
238
+ sendVideo(to, media, options = {}) {
239
+ return this.#sendFile('video', to, media, options, options.caption);
240
+ }
241
+ sendDocument(to, media, options = {}) {
242
+ return this.#sendFile('document', to, media, options, options.caption);
243
+ }
244
+ sendVoice(to, media, options = {}) {
245
+ return this.#sendFile('voice', to, media, options, undefined);
246
+ }
247
+ /**
248
+ * `POST /messages` with a body you build (5.6). The instance's `from`,
249
+ * `strategy`, `sticky` and `dailyCap` fill in what the body leaves out.
250
+ */
251
+ sendMessage(body, options = {}) {
252
+ const defaults = { ...this.#pool(), ...this.#cap(undefined) };
253
+ const filled = { ...body };
254
+ for (const [key, value] of Object.entries(defaults))
255
+ if (filled[key] === undefined)
256
+ filled[key] = value;
257
+ return this.#send(filled, { ...options, wait: body.wait });
258
+ }
259
+ /** `from`, `strategy` and `sticky` for this instance; strategy and sticky only where a pool picks. */
260
+ #pool() {
261
+ const { from } = this.#scope;
262
+ const out = {};
263
+ if (from !== null)
264
+ out.from = from;
265
+ if (from === null || Array.isArray(from)) {
266
+ if (this.#options.strategy !== undefined)
267
+ out.strategy = this.#options.strategy;
268
+ if (this.#options.sticky !== undefined)
269
+ out.sticky = this.#options.sticky;
270
+ }
271
+ return out;
272
+ }
273
+ /** `dailyCap` for one send: the call's, else the instance's; none when neither set one. */
274
+ #cap(perCall) {
275
+ const cap = checkDailyCap(perCall, 'dailyCap') ?? this.#options.dailyCap;
276
+ return cap === undefined ? {} : { dailyCap: cap };
277
+ }
278
+ /** The body fields every send shares; `mentions` only where the kind has text to carry them. */
279
+ #common(options, { mentions = true } = {}) {
280
+ const out = { ...this.#pool(), ...this.#cap(options.dailyCap) };
281
+ if (options.replyTo !== undefined)
282
+ out.replyTo = options.replyTo;
283
+ if (options.reference !== undefined)
284
+ out.reference = options.reference;
285
+ if (options.wait !== undefined)
286
+ out.wait = options.wait;
287
+ const listed = mentions ? checkMentions(options.mentions, 'mentions') : undefined;
288
+ if (listed !== undefined)
289
+ out.mentions = listed;
290
+ return out;
291
+ }
292
+ async #send(body, options) {
293
+ const idempotencyKey = options.idempotencyKey ?? randomId();
294
+ // The server holds a `wait` send open up to 20 s; the attempt's timeout leaves room for it.
295
+ const wait = waitMs(options.wait);
296
+ const answer = await this.#call({
297
+ method: 'POST',
298
+ path: '/messages',
299
+ body,
300
+ idempotencyKey,
301
+ ...(wait > 0 ? { timeoutMs: Math.max(this.#timeoutMs, wait + 10_000) } : {}),
302
+ });
303
+ return expect(answer, 'POST /messages', message => typeof message.id === 'string');
304
+ }
305
+ async #sendFile(kind, to, media, options, caption) {
306
+ // The options first: a bad one fails before any file is uploaded. A voice note never carries mentions.
307
+ const common = this.#common(options, { mentions: kind !== 'voice' });
308
+ let input;
309
+ if (isRecord(media) && typeof media.url === 'string') {
310
+ input = { url: media.url };
311
+ if (typeof media.fileName === 'string')
312
+ input.fileName = media.fileName;
313
+ if (typeof media.mime === 'string')
314
+ input.mime = media.mime;
315
+ }
316
+ else if (isRecord(media) && 'file' in media && media.file !== undefined && media.file !== null) {
317
+ input = { uploadId: await this.#upload(kind, media) };
318
+ }
319
+ else {
320
+ throw localError('MEDIA_INVALID', `send${kind[0]?.toUpperCase()}${kind.slice(1)}() takes { url } or { file }`);
321
+ }
322
+ const body = { to, type: kind, media: input, ...common };
323
+ if (caption !== undefined)
324
+ body.caption = caption;
325
+ return this.#send(body, options);
326
+ }
327
+ /** `POST /uploads`, then the PUT to storage; resolves the upload id a send takes. */
328
+ async #upload(kind, media) {
329
+ const file = await prepareFile(kind, media);
330
+ const upload = expect(await this.#call({
331
+ method: 'POST',
332
+ path: '/uploads',
333
+ body: { kind, mime: file.mime, size: file.size, name: file.name },
334
+ }), 'POST /uploads', body => typeof body.uploadId === 'string' && typeof body.url === 'string' && isRecord(body.headers));
335
+ await putUpload({
336
+ fetch: this.#fetch(),
337
+ timers: this.#timers,
338
+ timeoutMs: this.#timeoutMs,
339
+ maxRetries: this.#maxRetries,
340
+ retrySignal: this.#retries.signal,
341
+ }, upload.url, upload.headers, file);
342
+ return upload.uploadId;
343
+ }
344
+ // Message actions (0.3.0) ---------------------------------------------------------------------
345
+ /**
346
+ * Sets this number's reaction to a message (one emoji), replacing its earlier one. Resolves once the server
347
+ * queued it for the number's connection; a `message.reaction` event with `fromMe: true` follows.
348
+ */
349
+ async react(message, emoji) {
350
+ if (typeof emoji !== 'string' || emoji === '') {
351
+ throw localError('OPTIONS_INVALID', 'react() takes one emoji; unreact() removes the reaction');
352
+ }
353
+ await this.#reaction(messageIdOf(message, 'react'), emoji);
354
+ }
355
+ /** Removes this number's reaction to a message. */
356
+ async unreact(message) {
357
+ await this.#reaction(messageIdOf(message, 'unreact'), '');
358
+ }
359
+ async #reaction(id, emoji) {
360
+ expect(await this.#call({ method: 'POST', path: `/messages/${segment(id)}/reaction`, body: { emoji } }), 'POST /messages/:messageId/reaction', body => body.accepted === true);
361
+ }
362
+ /**
363
+ * Edits the text or caption of a message this number sent, within WhatsApp's 15 minutes
364
+ * (EDIT_WINDOW_PASSED after). Refused on a paused number (NUMBER_PAUSED). Resolves the edited message; the
365
+ * same text without `mentions` resolves it unchanged.
366
+ */
367
+ async edit(message, text, options = {}) {
368
+ const id = messageIdOf(message, 'edit');
369
+ if (typeof text !== 'string' || text === '')
370
+ throw localError('OPTIONS_INVALID', 'edit() takes the new text');
371
+ const body = { text };
372
+ const mentions = checkMentions(options.mentions, 'mentions');
373
+ if (mentions !== undefined)
374
+ body.mentions = mentions;
375
+ return expect(await this.#call({ method: 'POST', path: `/messages/${segment(id)}/edit`, body }), 'POST /messages/:messageId/edit', answer => typeof answer.id === 'string');
376
+ }
377
+ /**
378
+ * Deletes a message this number sent for everyone, within 2 days (REVOKE_WINDOW_PASSED after). Resolves the
379
+ * message with `deletedAt` set, also when it was deleted already.
380
+ */
381
+ async revoke(message) {
382
+ const id = messageIdOf(message, 'revoke');
383
+ return expect(
384
+ // An empty object: the server takes no body, and every runtime then sends a length.
385
+ await this.#call({ method: 'POST', path: `/messages/${segment(id)}/revoke`, body: {} }), 'POST /messages/:messageId/revoke', answer => typeof answer.id === 'string');
386
+ }
387
+ /**
388
+ * Marks a chat of this instance's number read (a phone, a `cht_` id or a group JID). Unread is one count per
389
+ * chat, so this clears it for the panel's members too. `readReceipts` says what happened to the blue ticks:
390
+ * `queued` (handed to the server, not proof WhatsApp got them), `off` (the number's setting) or `none`
391
+ * (nothing was unread).
392
+ */
393
+ async markRead(chat) {
394
+ const number = this.#single('markRead');
395
+ return expect(await this.#call({
396
+ method: 'POST',
397
+ path: `/numbers/${segment(number)}/chats/${segment(chat)}/read`,
398
+ body: {},
399
+ }), 'POST /numbers/:number/chats/:chat/read', body => isRecord(body.chat) && typeof body.readReceipts === 'string');
400
+ }
401
+ /**
402
+ * Shows "typing..." (or "recording audio..." with `state: 'recording'`) in a chat of this instance's number (a
403
+ * phone, a `cht_` id or a group JID). The server keeps it up until a message of the number goes out in that chat
404
+ * (no need to stop before sending), stopTyping(), or 2 minutes from the latest start (call it again to keep
405
+ * going). Refused on a paused number (NUMBER_PAUSED), in a group the number left (NOT_IN_GROUP) and on a number
406
+ * that isn't connected or connecting (NUMBER_NOT_CONNECTED).
407
+ */
408
+ async startTyping(chat, options = {}) {
409
+ const number = this.#single('startTyping');
410
+ const state = options.state ?? 'composing';
411
+ if (state !== 'composing' && state !== 'recording') {
412
+ throw localError('OPTIONS_INVALID', "startTyping()'s state must be 'composing' or 'recording'");
413
+ }
414
+ const body = { state };
415
+ return expect(await this.#call({ method: 'POST', path: `/numbers/${segment(number)}/chats/${segment(chat)}/typing`, body }), 'POST /numbers/:number/chats/:chat/typing', answer => typeof answer.state === 'string');
416
+ }
417
+ /** Stops this number's typing in a chat at once ("paused"). Safe to call when nothing is typing. */
418
+ async stopTyping(chat) {
419
+ const number = this.#single('stopTyping');
420
+ expect(
421
+ // An empty object: the server takes no body, and every runtime then sends a length.
422
+ await this.#call({
423
+ method: 'POST',
424
+ path: `/numbers/${segment(number)}/chats/${segment(chat)}/typing/stop`,
425
+ body: {},
426
+ }), 'POST /numbers/:number/chats/:chat/typing/stop', answer => answer.state === 'paused');
427
+ }
428
+ // Reads ---------------------------------------------------------------------------------------
429
+ /** Every number of the project, any status. */
430
+ async numbers() {
431
+ const list = expect(await this.#call({ method: 'GET', path: '/numbers' }), 'GET /numbers', body => Array.isArray(body.numbers));
432
+ return list.numbers;
433
+ }
434
+ /** One message of the project's numbers; 404 MESSAGE_NOT_FOUND otherwise. */
435
+ async message(messageId) {
436
+ return expect(await this.#call({ method: 'GET', path: `/messages/${segment(messageId)}` }), 'GET /messages/:messageId', body => typeof body.id === 'string');
437
+ }
438
+ /** What earlier sends with this `reference` made, newest first, at most 50. */
439
+ async messages(query) {
440
+ const list = expect(await this.#call({ method: 'GET', path: '/messages', query: { reference: query.reference } }), 'GET /messages', body => Array.isArray(body.messages));
441
+ return list.messages;
442
+ }
443
+ /** Is this phone on WhatsApp, asked through the instance's number. */
444
+ async check(phone) {
445
+ const number = this.#single('check');
446
+ return expect(await this.#call({ method: 'GET', path: `/numbers/${segment(number)}/check/${segment(phone)}` }), 'GET /numbers/:number/check/:phone', body => typeof body.exists === 'boolean');
447
+ }
448
+ /** The number's chats, newest activity first. */
449
+ chats(query = {}) {
450
+ const number = this.#single('chats');
451
+ const fetchPage = async (cursor) => {
452
+ const list = expect(await this.#call({
453
+ method: 'GET',
454
+ path: `/numbers/${segment(number)}/chats`,
455
+ query: { ...query, ...(cursor !== undefined ? { cursor } : {}) },
456
+ }), 'GET /numbers/:number/chats', body => Array.isArray(body.chats));
457
+ return { items: list.chats, nextCursor: list.nextCursor ?? null };
458
+ };
459
+ return paginate(fetchPage);
460
+ }
461
+ /** One chat: a phone, a `cht_` id or a group JID (a merged-away id resolves to the chat it joined). */
462
+ async chat(chat) {
463
+ const number = this.#single('chat');
464
+ return expect(await this.#call({ method: 'GET', path: `/numbers/${segment(number)}/chats/${segment(chat)}` }), 'GET /numbers/:number/chats/:chat', body => typeof body.id === 'string');
465
+ }
466
+ /**
467
+ * A chat's messages, newest first, history included. Each next page is
468
+ * older (`nextCursor` goes in `before`); after an `after` request, each next
469
+ * page is newer (`nextCursor` goes in `after`).
470
+ */
471
+ chatMessages(chat, query = {}) {
472
+ const number = this.#single('chatMessages');
473
+ const direction = query.after !== undefined && query.before === undefined ? 'after' : 'before';
474
+ const fetchPage = async (cursor) => {
475
+ const list = expect(await this.#call({
476
+ method: 'GET',
477
+ path: `/numbers/${segment(number)}/chats/${segment(chat)}/messages`,
478
+ query: { ...query, ...(cursor !== undefined ? { [direction]: cursor } : {}) },
479
+ }), 'GET /numbers/:number/chats/:chat/messages', body => Array.isArray(body.messages));
480
+ return { items: list.messages, nextCursor: list.nextCursor ?? null };
481
+ };
482
+ return paginate(fetchPage);
483
+ }
484
+ /**
485
+ * A message's file as a fetch `Response` (12.3). Asks the API for a
486
+ * 5-minute link with the token, then fetches the link with no token and no
487
+ * redirects. A file still being prepared (409 MEDIA_NOT_READY with
488
+ * Retry-After) is waited for, up to 60 s.
489
+ */
490
+ async downloadMedia(message, options = {}) {
491
+ const id = typeof message === 'string' ? message : message?.id;
492
+ if (typeof id !== 'string' || id === '')
493
+ throw localError('OPTIONS_INVALID', 'downloadMedia() takes a message or its id');
494
+ const started = this.#timers.now();
495
+ let link;
496
+ for (;;) {
497
+ try {
498
+ link = expect(await this.#call({
499
+ method: 'GET',
500
+ path: `/messages/${segment(id)}/media`,
501
+ query: { redirect: '0', variant: options.variant },
502
+ }), 'GET /messages/:messageId/media', body => typeof body.url === 'string');
503
+ break;
504
+ }
505
+ catch (error) {
506
+ const pending = error instanceof WhispError && error.code === 'MEDIA_NOT_READY' && error.retryAfter !== null;
507
+ const waitFor = pending ? Math.max(1, error.retryAfter) * 1_000 : 0;
508
+ if (!pending || this.#timers.now() - started + waitFor > MEDIA_WAIT_MAX_MS)
509
+ throw error;
510
+ await sleep(this.#timers, waitFor, this.#retries.signal);
511
+ if (this.#retries.signal.aborted)
512
+ throw error;
513
+ }
514
+ }
515
+ let response;
516
+ try {
517
+ // A new request: no Authorization, and a redirect is an error rather than a hop.
518
+ response = await this.#fetch()(link.url, { method: 'GET', redirect: 'error' });
519
+ }
520
+ catch (cause) {
521
+ throw new WhispError({ code: 'MEDIA_DOWNLOAD_FAILED', message: 'The file link could not be fetched', cause });
522
+ }
523
+ if (!response.ok) {
524
+ await response.body?.cancel().catch(() => { });
525
+ throw new WhispError({
526
+ code: 'MEDIA_DOWNLOAD_FAILED',
527
+ status: response.status,
528
+ message: `The file link answered ${response.status}`,
529
+ });
530
+ }
531
+ return response;
532
+ }
533
+ on(event, handler) {
534
+ if (typeof handler !== 'function')
535
+ throw localError('OPTIONS_INVALID', `on('${String(event)}') needs a function`);
536
+ this.#listener.add(event, handler);
537
+ return () => this.#listener.remove(event, handler);
538
+ }
539
+ off(event, handler) {
540
+ this.#listener.remove(event, handler);
541
+ }
542
+ /** Ends the stream, forgets every handler, and stops retries still waiting for their turn. */
543
+ close() {
544
+ this.#listener.close();
545
+ this.#retries.abort();
546
+ this.#retries = new AbortController();
547
+ }
548
+ /** The standalone `verifyWebhook`, for convenience. */
549
+ verifyWebhook(input) {
550
+ return verifyWebhook(input);
551
+ }
552
+ }
553
+ /** A first page fetched at once, awaitable, and iterable across every page. */
554
+ const paginate = (fetchPage) => {
555
+ const first = fetchPage(undefined);
556
+ const iterate = async function* () {
557
+ let page = await first;
558
+ for (;;) {
559
+ yield* page.items;
560
+ if (page.nextCursor === null || page.items.length === 0)
561
+ return;
562
+ page = await fetchPage(page.nextCursor);
563
+ }
564
+ };
565
+ return Object.assign(first, { [Symbol.asyncIterator]: iterate });
566
+ };
@@ -0,0 +1,43 @@
1
+ /** The npm name (16.2 decision 1: `@whisphq/sdk` if the `@whisp` scope isn't the owner's). */
2
+ export declare const SDK_PACKAGE_NAME = "whisp-js";
3
+ /** Where the token is read from when none is passed, on the first call. */
4
+ export declare const API_TOKEN_ENV = "WHISP_API_TOKEN";
5
+ /** Where a different base URL is read from (agent stacks, local tests). */
6
+ export declare const API_URL_ENV = "WHISP_API_URL";
7
+ export declare const DEFAULT_BASE_URL = "https://api.whisp.lue.my";
8
+ /** `whisp-sdk/<version> (<runtime>/<version>)`. */
9
+ export declare const USER_AGENT_PREFIX = "whisp-sdk";
10
+ /** The webhook signature header, lowercase as HTTP header names compare. */
11
+ export declare const SIGNATURE_HEADER = "x-whisp-signature";
12
+ /** Every project token starts with it. */
13
+ export declare const TOKEN_PREFIX = "wsp_live_";
14
+ /** Every webhook signing secret starts with it. */
15
+ export declare const WEBHOOK_SECRET_PREFIX = "wsp_whs_";
16
+ /** An event cursor (the SSE `id:`) starts with it; the rest is opaque. */
17
+ export declare const EVENT_CURSOR_PREFIX = "c_";
18
+ /** How old a webhook signature's timestamp may be, by default (seconds). */
19
+ export declare const SIGNATURE_TOLERANCE_SECONDS = 300;
20
+ /** A project token's whole shape: the prefix, 32 base62 and a 6-character checksum (core's `TOKEN_PATTERN`). */
21
+ export declare const TOKEN_PATTERN: RegExp;
22
+ /** A webhook signing secret's whole shape (core's `WEBHOOK_SECRET_PATTERN`). */
23
+ export declare const WEBHOOK_SECRET_PATTERN: RegExp;
24
+ /** A number id: `num_` and 8 characters of core's base58 id alphabet (no 0, O, I or l). */
25
+ export declare const NUMBER_ID_PATTERN: RegExp;
26
+ /** At most this many numbers in one pool (`from`, PUBLIC_LIMITS.fromMax). */
27
+ export declare const FROM_MAX = 20;
28
+ /** `wait: true` waits this long on the server (PUBLIC_LIMITS.waitDefaultMs). */
29
+ export declare const WAIT_DEFAULT_MS = 15000;
30
+ /** How a pool picks its number (core SEND_STRATEGIES); the first, `healthiest`, is the server's default. */
31
+ export declare const SEND_STRATEGIES: readonly ["healthiest", "random", "round-robin"];
32
+ /** A send's own `dailyCap`: a whole number in this range (core SEND_DAILY_CAP). */
33
+ export declare const DAILY_CAP_MIN = 1;
34
+ export declare const DAILY_CAP_MAX = 10000;
35
+ /** The upload cap of each kind (core's `uploadMaxBytes`); a stream is never read past it. */
36
+ export declare const UPLOAD_MAX_BYTES: {
37
+ readonly image: number;
38
+ readonly video: number;
39
+ readonly voice: number;
40
+ readonly document: number;
41
+ };
42
+ /** PLAN's twelve, then `message.edited` (0.3.0), in the order the API lists them. */
43
+ export declare const PUBLIC_EVENT_TYPES: readonly ["message.received", "message.sent", "message.status", "message.reaction", "message.deleted", "chat.updated", "presence.updated", "group.updated", "group.participants", "number.status", "number.risk", "call.received", "message.edited"];
@@ -0,0 +1,57 @@
1
+ /** The npm name (16.2 decision 1: `@whisphq/sdk` if the `@whisp` scope isn't the owner's). */
2
+ export const SDK_PACKAGE_NAME = 'whisp-js';
3
+ /** Where the token is read from when none is passed, on the first call. */
4
+ export const API_TOKEN_ENV = 'WHISP_API_TOKEN';
5
+ /** Where a different base URL is read from (agent stacks, local tests). */
6
+ export const API_URL_ENV = 'WHISP_API_URL';
7
+ export const DEFAULT_BASE_URL = 'https://api.whisp.lue.my';
8
+ /** `whisp-sdk/<version> (<runtime>/<version>)`. */
9
+ export const USER_AGENT_PREFIX = 'whisp-sdk';
10
+ /** The webhook signature header, lowercase as HTTP header names compare. */
11
+ export const SIGNATURE_HEADER = 'x-whisp-signature';
12
+ /** Every project token starts with it. */
13
+ export const TOKEN_PREFIX = 'wsp_live_';
14
+ /** Every webhook signing secret starts with it. */
15
+ export const WEBHOOK_SECRET_PREFIX = 'wsp_whs_';
16
+ /** An event cursor (the SSE `id:`) starts with it; the rest is opaque. */
17
+ export const EVENT_CURSOR_PREFIX = 'c_';
18
+ /** How old a webhook signature's timestamp may be, by default (seconds). */
19
+ export const SIGNATURE_TOLERANCE_SECONDS = 300;
20
+ /** A project token's whole shape: the prefix, 32 base62 and a 6-character checksum (core's `TOKEN_PATTERN`). */
21
+ export const TOKEN_PATTERN = /^wsp_live_[0-9A-Za-z]{38}$/;
22
+ /** A webhook signing secret's whole shape (core's `WEBHOOK_SECRET_PATTERN`). */
23
+ export const WEBHOOK_SECRET_PATTERN = /^wsp_whs_[0-9A-Za-z]{38}$/;
24
+ /** A number id: `num_` and 8 characters of core's base58 id alphabet (no 0, O, I or l). */
25
+ export const NUMBER_ID_PATTERN = /^num_[1-9A-HJ-NP-Za-km-z]{8}$/;
26
+ /** At most this many numbers in one pool (`from`, PUBLIC_LIMITS.fromMax). */
27
+ export const FROM_MAX = 20;
28
+ /** `wait: true` waits this long on the server (PUBLIC_LIMITS.waitDefaultMs). */
29
+ export const WAIT_DEFAULT_MS = 15_000;
30
+ /** How a pool picks its number (core SEND_STRATEGIES); the first, `healthiest`, is the server's default. */
31
+ export const SEND_STRATEGIES = ['healthiest', 'random', 'round-robin'];
32
+ /** A send's own `dailyCap`: a whole number in this range (core SEND_DAILY_CAP). */
33
+ export const DAILY_CAP_MIN = 1;
34
+ export const DAILY_CAP_MAX = 10_000;
35
+ /** The upload cap of each kind (core's `uploadMaxBytes`); a stream is never read past it. */
36
+ export const UPLOAD_MAX_BYTES = {
37
+ image: 16 * 1024 * 1024,
38
+ video: 64 * 1024 * 1024,
39
+ voice: 16 * 1024 * 1024,
40
+ document: 100 * 1024 * 1024,
41
+ };
42
+ /** PLAN's twelve, then `message.edited` (0.3.0), in the order the API lists them. */
43
+ export const PUBLIC_EVENT_TYPES = [
44
+ 'message.received',
45
+ 'message.sent',
46
+ 'message.status',
47
+ 'message.reaction',
48
+ 'message.deleted',
49
+ 'chat.updated',
50
+ 'presence.updated',
51
+ 'group.updated',
52
+ 'group.participants',
53
+ 'number.status',
54
+ 'number.risk',
55
+ 'call.received',
56
+ 'message.edited',
57
+ ];