whisp-js 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,11 @@
14
14
  * that need one number require. `dailyCap` (phase3-design 12.4) goes with
15
15
  * every send; the server counts and enforces it, never the SDK, since
16
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.
17
22
  */
18
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';
19
24
  import { localError, WhispError } from './errors.js';
@@ -80,6 +85,23 @@ const normalizeFrom = (phone) => {
80
85
  throw localError('OPTIONS_INVALID', `A pool has at most ${FROM_MAX} numbers`);
81
86
  return refs;
82
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
+ };
83
105
  const waitMs = (wait) => wait === true ? WAIT_DEFAULT_MS : typeof wait === 'number' && Number.isFinite(wait) ? wait : 0;
84
106
  const defaultReport = (error) => {
85
107
  console.error(`[whisp-sdk] ${error.name}: ${error.message}`);
@@ -253,7 +275,8 @@ export class Whisp {
253
275
  const cap = checkDailyCap(perCall, 'dailyCap') ?? this.#options.dailyCap;
254
276
  return cap === undefined ? {} : { dailyCap: cap };
255
277
  }
256
- #common(options) {
278
+ /** The body fields every send shares; `mentions` only where the kind has text to carry them. */
279
+ #common(options, { mentions = true } = {}) {
257
280
  const out = { ...this.#pool(), ...this.#cap(options.dailyCap) };
258
281
  if (options.replyTo !== undefined)
259
282
  out.replyTo = options.replyTo;
@@ -261,6 +284,9 @@ export class Whisp {
261
284
  out.reference = options.reference;
262
285
  if (options.wait !== undefined)
263
286
  out.wait = options.wait;
287
+ const listed = mentions ? checkMentions(options.mentions, 'mentions') : undefined;
288
+ if (listed !== undefined)
289
+ out.mentions = listed;
264
290
  return out;
265
291
  }
266
292
  async #send(body, options) {
@@ -277,8 +303,8 @@ export class Whisp {
277
303
  return expect(answer, 'POST /messages', message => typeof message.id === 'string');
278
304
  }
279
305
  async #sendFile(kind, to, media, options, caption) {
280
- // The options first: a bad one fails before any file is uploaded.
281
- const common = this.#common(options);
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' });
282
308
  let input;
283
309
  if (isRecord(media) && typeof media.url === 'string') {
284
310
  input = { url: media.url };
@@ -315,6 +341,90 @@ export class Whisp {
315
341
  }, upload.url, upload.headers, file);
316
342
  return upload.uploadId;
317
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
+ }
318
428
  // Reads ---------------------------------------------------------------------------------------
319
429
  /** Every number of the project, any status. */
320
430
  async numbers() {
@@ -39,5 +39,5 @@ export declare const UPLOAD_MAX_BYTES: {
39
39
  readonly voice: number;
40
40
  readonly document: number;
41
41
  };
42
- /** PLAN's twelve, 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"];
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"];
@@ -39,7 +39,7 @@ export const UPLOAD_MAX_BYTES = {
39
39
  voice: 16 * 1024 * 1024,
40
40
  document: 100 * 1024 * 1024,
41
41
  };
42
- /** PLAN's twelve, in the order the API lists them. */
42
+ /** PLAN's twelve, then `message.edited` (0.3.0), in the order the API lists them. */
43
43
  export const PUBLIC_EVENT_TYPES = [
44
44
  'message.received',
45
45
  'message.sent',
@@ -53,4 +53,5 @@ export const PUBLIC_EVENT_TYPES = [
53
53
  'number.status',
54
54
  'number.risk',
55
55
  'call.received',
56
+ 'message.edited',
56
57
  ];
@@ -9,7 +9,7 @@
9
9
  * await whisp.send('972521112233', 'Hello from Whisp')
10
10
  * whisp.on('message', msg => console.log(msg.from, msg.text))
11
11
  */
12
- export { DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_MS, type DownloadOptions, type MessagesQuery, type Page, type PageResult, type SendFileOptions, type SendOptions, type VerifyResult, Whisp, type WhispOptions, } from './client.js';
12
+ export { DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_MS, type DownloadOptions, type EditOptions, type MessagesQuery, type Page, type PageResult, type SendFileOptions, type SendOptions, type SendVoiceOptions, type VerifyResult, Whisp, type WhispOptions, } from './client.js';
13
13
  export { API_TOKEN_ENV, API_URL_ENV, DEFAULT_BASE_URL, EVENT_CURSOR_PREFIX, PUBLIC_EVENT_TYPES, SDK_PACKAGE_NAME, SIGNATURE_HEADER, SIGNATURE_TOLERANCE_SECONDS, TOKEN_PREFIX, USER_AGENT_PREFIX, WEBHOOK_SECRET_PREFIX, } from './constants.js';
14
14
  export { type ErrorParams, SDK_ERROR_CODES, type SdkErrorCode, type SignatureFailureReason, WhispConnectionError, WhispError, WhispSignatureError, } from './errors.js';
15
15
  export type { AnyListenerEvent, EventHandlers, EventMeta, ListenerEvent, ListenerName, StreamClose, StreamGap, } from './listener.js';
@@ -28,7 +28,7 @@ export type ListenerEvent<T extends PublicEventType> = Omit<EventData[T], 'numbe
28
28
  numberId: string;
29
29
  event: EventMeta<T>;
30
30
  };
31
- /** Any of the twelve; `event.event.type` says which. */
31
+ /** Any of the public types; `event.event.type` says which. */
32
32
  export type AnyListenerEvent = {
33
33
  [T in PublicEventType]: ListenerEvent<T>;
34
34
  }[PublicEventType];
@@ -51,7 +51,7 @@ export type EventHandlers = {
51
51
  } & {
52
52
  /** `message.received`. */
53
53
  message: Handler<ListenerEvent<'message.received'>>;
54
- /** Every one of the twelve types. */
54
+ /** Every one of the public types. */
55
55
  '*': Handler<AnyListenerEvent>;
56
56
  /** A connection reached `ready`. */
57
57
  open: Handler<StreamReady>;
@@ -24,6 +24,7 @@
24
24
  * `'error'` and the stream goes on. The cursor moves when an event's
25
25
  * handlers have settled, and at once for an event nobody listens to.
26
26
  */
27
+ import { PUBLIC_EVENT_TYPES } from './constants.js';
27
28
  import { localError, readApiError, WhispConnectionError, WhispError } from './errors.js';
28
29
  import { sleep } from './runtime.js';
29
30
  import { createSseParser } from './sse.js';
@@ -35,20 +36,8 @@ export const WATCHDOG_MS = 45_000;
35
36
  /** The wait after a 429 (TOO_MANY_STREAMS or RATE_LIMITED), unless `Retry-After` asks for longer (up to a minute). */
36
37
  export const RATE_LIMITED_WAIT_MS = 30_000;
37
38
  const RATE_LIMITED_MAX_WAIT_MS = 60_000;
38
- const KNOWN_TYPES = new Set([
39
- 'message.received',
40
- 'message.sent',
41
- 'message.status',
42
- 'message.reaction',
43
- 'message.deleted',
44
- 'chat.updated',
45
- 'presence.updated',
46
- 'group.updated',
47
- 'group.participants',
48
- 'number.status',
49
- 'number.risk',
50
- 'call.received',
51
- ]);
39
+ /** The types this version delivers; any other is skipped (its cursor still moves). */
40
+ const KNOWN_TYPES = new Set(PUBLIC_EVENT_TYPES);
52
41
  const LIFECYCLE = new Set(['open', 'close', 'gap', 'error']);
53
42
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
54
43
  const asError = (value) => (value instanceof Error ? value : new Error(String(value)));
@@ -34,8 +34,12 @@ export type ChatChange = 'unread' | 'done' | 'pinned' | 'assignee' | 'labels' |
34
34
  export type CursorRejection = 'cursor_unknown' | 'cursor_expired';
35
35
  /** Every message type but the inbox's own `waiting` stubs and `system` lines. */
36
36
  export type MessageType = 'text' | 'image' | 'video' | 'audio' | 'voice' | 'document' | 'sticker' | 'location' | 'contact' | 'poll' | 'unsupported';
37
- /** PLAN's twelve public event types (6.1). */
38
- export type PublicEventType = 'message.received' | 'message.sent' | 'message.status' | 'message.reaction' | 'message.deleted' | 'chat.updated' | 'presence.updated' | 'group.updated' | 'group.participants' | 'number.status' | 'number.risk' | 'call.received';
37
+ /** PLAN's twelve public event types (6.1), then `message.edited` (0.3.0). */
38
+ export type PublicEventType = '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';
39
+ /** What marking a chat read did about read receipts: handed to the server (not delivery), off, or nothing unread. */
40
+ export type ReadReceiptOutcome = 'queued' | 'off' | 'none';
41
+ /** What startTyping shows: `composing` ("typing...") or `recording` ("recording audio..."). */
42
+ export type TypingStartState = 'composing' | 'recording';
39
43
  /** Someone on WhatsApp. `phone` is null for a LID-only contact. */
40
44
  export type Actor = {
41
45
  phone: string | null;
@@ -179,6 +183,11 @@ export type Message = {
179
183
  at: string;
180
184
  editedAt: string | null;
181
185
  deletedAt: string | null;
186
+ /**
187
+ * The people the text mentions, in order (0.3.0); `@` and the digits of their `phone` or `lid` mark each in
188
+ * `text`. Empty for none and for a deleted message.
189
+ */
190
+ mentions: Actor[];
182
191
  };
183
192
  /** The number an event happened on. */
184
193
  export type EventNumber = {
@@ -216,6 +225,19 @@ export type MessageDeletedData = {
216
225
  reference: string | null;
217
226
  at: string;
218
227
  };
228
+ /** A message's text or caption changed, by its sender or by us (0.3.0). `at` is the edit's own time. */
229
+ export type MessageEditedData = {
230
+ messageId: string;
231
+ waId: string | null;
232
+ chatId: string;
233
+ fromMe: boolean;
234
+ /** The new text or caption. */
235
+ text: string | null;
236
+ /** The message's mentions after the edit. */
237
+ mentions: Actor[];
238
+ reference: string | null;
239
+ at: string;
240
+ };
219
241
  export type ChatUpdatedData = {
220
242
  chat: Chat;
221
243
  changes: ChatChange[];
@@ -308,6 +330,7 @@ export type EventData = {
308
330
  'number.status': NumberStatusData;
309
331
  'number.risk': NumberRiskData;
310
332
  'call.received': CallReceivedData;
333
+ 'message.edited': MessageEditedData;
311
334
  };
312
335
  /** The envelope: the SSE `data:`, an item of `GET /events` and a webhook body are this same object. */
313
336
  export type EventOf<T extends PublicEventType> = {
@@ -370,6 +393,7 @@ export type SendMessageBody = {
370
393
  dailyCap?: number;
371
394
  type?: 'text';
372
395
  text: string;
396
+ mentions?: string[];
373
397
  from?: string | string[];
374
398
  strategy?: SendStrategy;
375
399
  sticky?: boolean;
@@ -382,6 +406,7 @@ export type SendMessageBody = {
382
406
  type: 'image' | 'video' | 'document';
383
407
  media: MediaInput;
384
408
  caption?: string;
409
+ mentions?: string[];
385
410
  from?: string | string[];
386
411
  strategy?: SendStrategy;
387
412
  sticky?: boolean;
@@ -406,6 +431,7 @@ export type SendFromNumberBody = {
406
431
  dailyCap?: number;
407
432
  type?: 'text';
408
433
  text: string;
434
+ mentions?: string[];
409
435
  } | {
410
436
  to: string;
411
437
  replyTo?: string;
@@ -415,6 +441,7 @@ export type SendFromNumberBody = {
415
441
  type: 'image' | 'video' | 'document';
416
442
  media: MediaInput;
417
443
  caption?: string;
444
+ mentions?: string[];
418
445
  } | {
419
446
  to: string;
420
447
  replyTo?: string;
@@ -434,6 +461,7 @@ export type SendMediaFromNumberBody = {
434
461
  type: 'image' | 'video' | 'document';
435
462
  media: MediaInput;
436
463
  caption?: string;
464
+ mentions?: string[];
437
465
  } | {
438
466
  to: string;
439
467
  replyTo?: string;
@@ -508,6 +536,45 @@ export type Upload = {
508
536
  headers: Record<string, string>;
509
537
  expiresAt: string;
510
538
  };
539
+ /** `POST /messages/:messageId/reaction`: one emoji, or `''` to remove ours. */
540
+ export type ReactionBody = {
541
+ emoji: string;
542
+ };
543
+ /**
544
+ * `POST /messages/:messageId/edit`: the new text or caption. `mentions` (phones or `<digits>@lid` JIDs, each
545
+ * written `@<digits>` in the text) replaces the message's; left out, the ones still named in the text are kept.
546
+ */
547
+ export type EditMessageBody = {
548
+ text: string;
549
+ mentions?: string[];
550
+ };
551
+ /** 202 for a reaction: queued for the number's socket. */
552
+ export type ActionAccepted = {
553
+ accepted: true;
554
+ };
555
+ /** `POST /numbers/:number/chats/:chat/read`: the chat as it is now, and what happened to read receipts. */
556
+ export type MarkReadResult = {
557
+ chat: Chat;
558
+ readReceipts: ReadReceiptOutcome;
559
+ };
560
+ /** `POST /numbers/:number/chats/:chat/typing`: `composing` when left out. */
561
+ export type StartTypingBody = {
562
+ state?: TypingStartState;
563
+ };
564
+ /** startTyping's options. */
565
+ export interface TypingOptions {
566
+ /** `composing` (the default): "typing...". `recording`: "recording audio...". */
567
+ state?: TypingStartState;
568
+ }
569
+ /**
570
+ * 202 from both typing routes: the state asked for (`paused` for a stop), and when the server stops refreshing it
571
+ * at the latest (2 minutes after this start). Null for a stop, and while the server's worker is an older build that
572
+ * shows typing once, with no keep-alive.
573
+ */
574
+ export type TypingResult = {
575
+ state: TypingStartState | 'paused';
576
+ keptUntil: string | null;
577
+ };
511
578
  /** Every error the API answers. `code` is stable; `params` carries the values it names. */
512
579
  export type ApiError = {
513
580
  code: string;
@@ -1,2 +1,2 @@
1
1
  /** The package's version: sent in the User-Agent. packages/sdk/test/constants.test.ts keeps it equal to package.json's. */
2
- export declare const SDK_VERSION = "0.1.0";
2
+ export declare const SDK_VERSION = "0.3.1";
@@ -1,2 +1,2 @@
1
1
  /** The package's version: sent in the User-Agent. packages/sdk/test/constants.test.ts keeps it equal to package.json's. */
2
- export const SDK_VERSION = '0.1.0';
2
+ export const SDK_VERSION = '0.3.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whisp-js",
3
- "version": "0.1.0",
3
+ "version": "0.3.1",
4
4
  "description": "Send WhatsApp messages from your numbers and listen to their events",
5
5
  "license": "MIT",
6
6
  "type": "module",