@arcaelas/whatsapp 7.4.2 → 8.2.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.
@@ -288,6 +288,51 @@ export default class Message {
288
288
  await this._init.engine.set(`/chat/${doc.cid}/message/${doc.id}`, serialize(doc), doc.created_at);
289
289
  return true;
290
290
  }
291
+ /**
292
+ * Supervisa qué le pasa a este mensaje: que lo lean, que reproduzcan su audio o que lo
293
+ * retiren. Vive en la clase base, así que todo tipo de mensaje lo hereda.
294
+ *
295
+ * Por dentro escucha las actualizaciones del mensaje y las traduce: `message:updated` no
296
+ * distingue leído de reproducido —los dos son un cambio de estado— y quien supervisa un
297
+ * mensaje quiere saber cuál de los dos ocurrió, no que «algo cambió».
298
+ * Watches what happens to this message: that it gets read, that its audio gets played, or
299
+ * that it is retired. It lives on the base class, so every message type inherits it.
300
+ *
301
+ * Under the hood it listens to the message's updates and translates them: `message:updated`
302
+ * does not tell read from played —both are a status change— and whoever watches a message
303
+ * wants to know which of the two happened, not that «something changed».
304
+ *
305
+ * @param handler - Recibe cada cambio / Receives every change
306
+ * @returns Función para dejar de supervisar / Function to stop watching
307
+ */
308
+ watch(handler) {
309
+ const { wa } = this._init;
310
+ let seen = this._raw.status;
311
+ const off_updated = wa.on('message:updated', (msg) => {
312
+ if (msg.id === this._raw.id) {
313
+ const status = msg._raw.status;
314
+ // Sólo los avances cuentan: un mismo estado repetido no es una noticia, y sin
315
+ // este corte cada acuse reenviado por WhatsApp se contaría como una lectura más.
316
+ // Only advances count: a repeated status is not news, and without this cut every
317
+ // acknowledgement WhatsApp resends would count as another read.
318
+ if (status > seen) {
319
+ seen = status;
320
+ if (status >= proto.WebMessageInfo.Status.PLAYED)
321
+ handler({ name: 'played', payload: msg });
322
+ else if (status >= proto.WebMessageInfo.Status.READ)
323
+ handler({ name: 'read', payload: msg });
324
+ }
325
+ }
326
+ });
327
+ const off_deleted = wa.on('message:deleted', (msg) => {
328
+ if (msg.id === this._raw.id)
329
+ handler({ name: 'deleted', payload: msg });
330
+ });
331
+ return () => {
332
+ off_updated();
333
+ off_deleted();
334
+ };
335
+ }
291
336
  /** Marca el mensaje como leído. / Marks the message as read. */
292
337
  async seen() {
293
338
  const { cid, id, author } = this._raw;
@@ -467,6 +512,23 @@ export class Audio extends Media {
467
512
  get duration() { return this._media?.seconds ?? 0; }
468
513
  /** Forma de onda 0-100 lista para pintar. / Paint-ready 0-100 waveform. */
469
514
  get waveform() { return Array.from(to_buffer(this._media?.waveform) ?? []); }
515
+ /** true si ya fue reproducido: el micrófono azul que ve quien lo mandó. / true when already played: the blue mic its sender sees. */
516
+ get played() { return this._raw.status >= proto.WebMessageInfo.Status.PLAYED; }
517
+ /**
518
+ * Acusa el audio como reproducido. Es un aviso aparte del de leído —abrir el chat no
519
+ * reproduce nada—, y por eso viaja como recibo propio: quien lo mandó ve el micrófono
520
+ * azul sólo después de esto.
521
+ * Acknowledges the audio as played. It is separate from the read receipt —opening the chat
522
+ * plays nothing— and so travels as its own receipt: whoever sent it sees the blue mic only
523
+ * after this.
524
+ *
525
+ * @returns true cuando el acuse salió / true once the receipt left
526
+ */
527
+ async play() {
528
+ const { cid, id, author } = this._raw;
529
+ await this._init.socket.sendReceipt(cid, cid.endsWith('@g.us') ? author : undefined, [id], 'played');
530
+ return true;
531
+ }
470
532
  }
471
533
  /** Mensaje de sticker. / Sticker message. */
472
534
  export class Sticker extends Media {
@@ -598,6 +660,25 @@ export class VCard extends Message {
598
660
  export class Event extends Message {
599
661
  /** @internal Bloque del evento en el raw. / Raw event block. */
600
662
  get _event() { return this._raw.raw.message?.eventMessage; }
663
+ /** Asistentes confirmados, acompañantes incluidos. / Confirmed attendees, companions included. */
664
+ get going() {
665
+ return (this._raw.responses ?? []).filter((entry) => entry.response === 'going').reduce((sum, entry) => sum + 1 + entry.guests, 0);
666
+ }
667
+ /**
668
+ * Respuestas de asistencia al evento, con el nombre resuelto de cada contacto y en orden
669
+ * de llegada — la última es la más reciente.
670
+ * Attendance responses, with each contact's resolved name, in arrival order — the last one
671
+ * is the most recent.
672
+ */
673
+ async attendees() {
674
+ const rows = [];
675
+ for (const entry of this._raw.responses ?? []) {
676
+ const who = await this._init.wa.Contact.get(entry.author).catch(() => null);
677
+ const contact = (who?.phone ?? entry.author.split('@')[0].split(':')[0]);
678
+ rows.push({ name: who?.name ?? contact, contact, response: entry.response, guests: entry.guests });
679
+ }
680
+ return rows;
681
+ }
601
682
  /** Nombre del evento. / Event name. */
602
683
  get name() { return this._event?.name ?? ''; }
603
684
  /** Inicio en ISO UTC. / Start as ISO UTC. */
@@ -3,6 +3,33 @@ import { Account, contact } from '../../lib/contact/index.js';
3
3
  import Message, { message } from '../../lib/message/index.js';
4
4
  import { Feed } from '../../lib/status/index.js';
5
5
  import { type Engine } from '../../lib/store/index.js';
6
+ /**
7
+ * Lo que hace alguien al otro lado, en los términos en los que se mira: WhatsApp distingue
8
+ * «disponible» de «escribiendo» y de «grabando», y esa distinción es justo la que interesa
9
+ * vigilar. `paused` no se propaga: es dejar de escribir, no un estado en sí.
10
+ * What someone is doing on the other end, in the terms one watches it: WhatsApp tells «available»
11
+ * from «typing» and from «recording», and that distinction is exactly what is worth watching.
12
+ * `paused` is not propagated: it is stopping typing, not a state of its own.
13
+ */
14
+ export type Presence = 'online' | 'offline' | 'typing' | 'recording';
15
+ /**
16
+ * Lo que se supervisa con `watch()`, en un solo sobre: `name` dice qué pasó y `payload` trae la
17
+ * instancia a la que le pasó, ya resuelta. Quien escucha no tiene que volver a buscar de quién
18
+ * se trata ni filtrar lo que no le toca.
19
+ * What `watch()` supervises, in a single envelope: `name` says what happened and `payload` brings
20
+ * the already-resolved instance it happened to. Whoever listens does not have to look up who it
21
+ * was nor filter out what does not concern them.
22
+ */
23
+ export interface WatchEvent<N extends string, P> {
24
+ name: N;
25
+ payload: P;
26
+ }
27
+ /** Cada acción de alguien al otro lado, incluido dejar de escribir o de grabar. / Every action from the other end, including stopping typing or recording. */
28
+ export type ContactWatch = 'online' | 'offline' | 'typing' | 'recording' | 'stopped-typing' | 'stopped-recording';
29
+ /** Lo que le puede pasar a un mensaje ya enviado. / What can happen to an already-sent message. */
30
+ export type MessageWatch = 'read' | 'played' | 'deleted';
31
+ /** Un chat mezcla lo que hace la persona con lo que llega a la conversación. / A chat mixes what the person does with what arrives in the conversation. */
32
+ export type ChatWatch = ContactWatch | 'message';
6
33
  type ChatInstance = InstanceType<ReturnType<typeof chat>>;
7
34
  type ContactInstance = InstanceType<ReturnType<typeof contact>>;
8
35
  interface Options {
@@ -63,8 +90,14 @@ export interface Farewell {
63
90
  interface EventMap {
64
91
  connected: [WhatsApp];
65
92
  disconnected: [WhatsApp, Farewell];
93
+ /** Fallo que no tumba la conexión pero que quien la abrió necesita saber. / A failure that does not drop the connection but whoever opened it needs to know. */
94
+ error: [Error & {
95
+ code?: string;
96
+ }, WhatsApp];
66
97
  'contact:created': [ContactInstance, ChatInstance, WhatsApp];
67
98
  'contact:updated': [ContactInstance, ChatInstance, WhatsApp];
99
+ /** Alguien entró, salió, escribe o graba. Sólo llega de quien se esté vigilando con `Contact.watch()`. / Someone came in, left, is typing or recording. Only arrives for whoever is being watched with `Contact.watch()`. */
100
+ 'contact:presence': [ContactInstance, ContactWatch, WhatsApp];
68
101
  'chat:created': [ChatInstance, WhatsApp];
69
102
  'chat:deleted': [ChatInstance, WhatsApp];
70
103
  'chat:pinned': [ChatInstance, WhatsApp];
@@ -76,7 +109,7 @@ interface EventMap {
76
109
  'message:created': [Message, ChatInstance, WhatsApp];
77
110
  'message:updated': [Message, ChatInstance, WhatsApp];
78
111
  'message:deleted': [Message, ChatInstance, WhatsApp];
79
- 'message:reacted': [Message, ChatInstance, string, WhatsApp];
112
+ 'message:reacted': [Message, ChatInstance, string, ContactInstance, WhatsApp];
80
113
  'message:starred': [Message, ChatInstance, WhatsApp];
81
114
  'message:unstarred': [Message, ChatInstance, WhatsApp];
82
115
  'message:forwarded': [Message, ChatInstance, WhatsApp];
@@ -1,4 +1,4 @@
1
- import { Browsers, decryptPollVote, DisconnectReason, downloadMediaMessage, fetchLatestBaileysVersion, getContentType, initAuthCreds, jidNormalizedUser, makeWASocket, proto, updateMessageWithPollUpdate, } from 'baileys';
1
+ import { Browsers, decryptEventResponse, decryptPollVote, DisconnectReason, downloadMediaMessage, fetchLatestBaileysVersion, getContentType, initAuthCreds, jidNormalizedUser, makeWASocket, proto, updateMessageWithPollUpdate, } from 'baileys';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import pino from 'pino';
4
4
  import * as QRCode from 'qrcode';
@@ -34,7 +34,18 @@ const queued = (locks, path, work) => {
34
34
  locks.set(path, next.catch(() => { }));
35
35
  return next;
36
36
  };
37
+ /** Traduce el vocabulario de baileys al que se vigila; lo que no está aquí no se propaga. / Translates baileys' vocabulary into the watched one; whatever is missing is not propagated. */
38
+ const PRESENCE = { available: 'online', unavailable: 'offline', composing: 'typing', recording: 'recording', paused: 'paused' };
37
39
  const readable = (value) => (value && !/^\+?[\d\s·•∙⋅]+$/.test(value) ? value : null);
40
+ /**
41
+ * Refrescos de QR que dura un PIN antes de darlo por caducado. WhatsApp no avisa de la
42
+ * expiración, así que se mide por los ciclos de refresco (~20 s cada uno): tres son el margen
43
+ * observado en el que un código sigue siendo aceptado.
44
+ * QR refreshes a PIN lasts before it is considered expired. WhatsApp gives no expiry notice, so
45
+ * it is measured in refresh cycles (~20s each): three is the observed window in which a code is
46
+ * still accepted.
47
+ */
48
+ const OTP_CYCLES = 3;
38
49
  export default class WhatsApp {
39
50
  #event = new EventEmitter();
40
51
  #options;
@@ -77,9 +88,13 @@ export default class WhatsApp {
77
88
  let retries = 0;
78
89
  let intentional = false;
79
90
  let silent = false;
91
+ let paired = false;
92
+ let cycles = 0;
80
93
  let alive = null;
81
94
  let timer = null;
82
95
  let chain = Promise.resolve();
96
+ /** Qué estaba haciendo cada quien, para poder leer su `paused`. / What each one was doing, so their `paused` can be read. */
97
+ const doing = new Map();
83
98
  const locks = new Map();
84
99
  return new Promise((resolve, reject) => {
85
100
  const start = async () => {
@@ -258,9 +273,33 @@ export default class WhatsApp {
258
273
  socket.ev.on('creds.update', () => queued(locks, '/session/creds', () => engine.set('/session/creds', serialize(creds))));
259
274
  socket.ev.on('connection.update', async ({ connection, lastDisconnect, qr }) => {
260
275
  if (qr && !creds.registered) {
261
- await callback(digits && (method ?? 'otp') === 'otp'
262
- ? await socket.requestPairingCode(digits)
263
- : await QRCode.toBuffer(qr, { type: 'png', margin: 2 }));
276
+ if (digits && (method ?? 'otp') === 'otp') {
277
+ // El QR se refresca cada ~20 s, pero el PIN vive más: pedir uno en
278
+ // cada refresco invalida el anterior y el que la persona está
279
+ // tecleando deja de servir a media escritura —el síntoma es un PIN
280
+ // correcto que «no lo toma»—. Por eso se cuenta el ciclo y sólo se
281
+ // renueva al caducar de verdad, gastando un reintento del presupuesto.
282
+ // The QR refreshes every ~20s, but the PIN lives longer: asking for
283
+ // one on each refresh invalidates the previous, and the one being
284
+ // typed stops working mid-typing —the symptom is a correct PIN that
285
+ // «is not accepted»—. So the cycle is counted and it is only renewed
286
+ // once it truly expires, spending one retry from the budget.
287
+ cycles = paired ? cycles + 1 : 0;
288
+ if (!paired || cycles >= OTP_CYCLES) {
289
+ if (paired && budget !== null && retries >= budget) {
290
+ this.emit('error', Object.assign(new Error('El código de vinculación expiró'), { code: 'ERR_OTP_EXPIRED' }), this);
291
+ }
292
+ else {
293
+ retries += paired ? 1 : 0;
294
+ cycles = 0;
295
+ paired = true;
296
+ await callback(await socket.requestPairingCode(digits));
297
+ }
298
+ }
299
+ }
300
+ else {
301
+ await callback(await QRCode.toBuffer(qr, { type: 'png', margin: 2 }));
302
+ }
264
303
  }
265
304
  if (connection === 'open') {
266
305
  connected = true;
@@ -371,6 +410,31 @@ export default class WhatsApp {
371
410
  }
372
411
  }).catch(() => { });
373
412
  });
413
+ socket.ev.on('presence.update', ({ id, presences }) => {
414
+ chain = chain.then(async () => {
415
+ const cid = await canonical(id);
416
+ for (const [participant, data] of Object.entries(presences)) {
417
+ const state = PRESENCE[data.lastKnownPresence];
418
+ if (state) {
419
+ const who = await canonical(participant || cid);
420
+ // `paused` es «dejó de hacer lo que hacía», y sólo el estado
421
+ // anterior dice qué era: sin recordarlo no se puede distinguir
422
+ // dejar de escribir de dejar de grabar.
423
+ // `paused` means «stopped doing what they were doing», and only
424
+ // the previous state says which: without remembering it there is
425
+ // no telling stopped-typing from stopped-recording.
426
+ const last = doing.get(who);
427
+ const name = state === 'paused' ? (last === 'recording' ? 'stopped-recording' : 'stopped-typing') : state;
428
+ if (state === 'typing' || state === 'recording')
429
+ doing.set(who, state);
430
+ else
431
+ doing.delete(who);
432
+ const card = deserialize(await engine.get(`/contact/${who}`));
433
+ this.emit('contact:presence', new this.Contact(card ?? { id: who, lid: null, name: null, notify: null, verified_name: null, img_url: null, status: null }), name, this);
434
+ }
435
+ }
436
+ }).catch(() => { });
437
+ });
374
438
  socket.ev.on('lid-mapping.update', ({ lid, pn }) => {
375
439
  chain = chain.then(async () => {
376
440
  await remember(lid, pn);
@@ -476,7 +540,15 @@ export default class WhatsApp {
476
540
  ];
477
541
  await engine.set(found.path, serialize(found.doc), found.doc.created_at);
478
542
  const instance = new Message(init, found.doc);
479
- this.emit('message:reacted', instance, await instance.chat(), emoji, this);
543
+ // Quién reaccionó viaja en el evento: sin él, distinguir la
544
+ // reacción del usuario del eco de una propia obligaba a cada
545
+ // consumidor a llevar su propio registro de ecos con timeouts.
546
+ // Who reacted travels in the event: without it, telling the
547
+ // user's reaction from the echo of an own one forced every
548
+ // consumer to keep its own echo ledger with timeouts.
549
+ const who = await canonical(author);
550
+ const card = deserialize(await engine.get(`/contact/${who}`));
551
+ this.emit('message:reacted', instance, await instance.chat(), emoji, new this.Contact(card ?? { id: who, lid: null, name: null, notify: null, verified_name: null, img_url: null, status: null }), this);
480
552
  }
481
553
  continue;
482
554
  }
@@ -493,7 +565,18 @@ export default class WhatsApp {
493
565
  continue;
494
566
  }
495
567
  const type = { conversation: 'text', extendedTextMessage: 'text', imageMessage: 'image', videoMessage: 'video', audioMessage: 'audio' }[kind ?? ''];
496
- const author = msg.key.participant ?? '';
568
+ // El autor de un estado puede llegar por `participant` o por su
569
+ // alterno según venga identificado por teléfono o por LID, y se
570
+ // guarda canónico como todo lo demás: con el LID crudo el estado
571
+ // queda a nombre de un número larguísimo que no case con ningún
572
+ // contacto, y para quien mira es un estado que no llegó.
573
+ // A status author can arrive via `participant` or its alternate
574
+ // depending on whether it is identified by phone or by LID, and
575
+ // is stored canonically like everything else: with the raw LID
576
+ // the status ends up under a long meaningless number matching no
577
+ // contact, and to whoever looks it is a status that never came.
578
+ const claimed = msg.key.participant ?? msg.key.participantAlt ?? '';
579
+ const author = claimed ? await canonical(claimed) : '';
497
580
  if (!type || !author) {
498
581
  continue;
499
582
  }
@@ -521,6 +604,45 @@ export default class WhatsApp {
521
604
  this.emit('feed:created', new Feed(init, doc), this);
522
605
  continue;
523
606
  }
607
+ if (kind === 'encEventResponseMessage') {
608
+ const enc = msg.message?.encEventResponseMessage;
609
+ const key = enc?.eventCreationMessageKey;
610
+ const found = key?.id && key.remoteJid ? await locate(key.remoteJid, key.id) : null;
611
+ const raw_secret = found?.doc.raw.message?.messageContextInfo?.messageSecret;
612
+ const secret = typeof raw_secret === 'string' ? Buffer.from(raw_secret, 'base64') : raw_secret;
613
+ if (found && secret && enc?.encPayload && enc.encIv) {
614
+ const mine = [socket.user?.lid, socket.user?.id];
615
+ const theirs = (from) => [from.remoteJid, from.participant, from.remoteJidAlt];
616
+ const responders = (msg.key.fromMe ? mine : theirs(msg.key)).filter((id) => Boolean(id));
617
+ const creators = (found.doc.raw.key?.fromMe ? mine : theirs(found.doc.raw.key ?? {})).filter((id) => Boolean(id));
618
+ for (const pair of responders.flatMap((who) => creators.map((creator) => [who, creator]))) {
619
+ try {
620
+ const parsed = decryptEventResponse({ encPayload: enc.encPayload, encIv: enc.encIv }, {
621
+ eventCreatorJid: jidNormalizedUser(pair[1]),
622
+ eventMsgId: found.doc.id,
623
+ eventEncKey: secret,
624
+ responderJid: jidNormalizedUser(pair[0]),
625
+ });
626
+ const response = { 1: 'going', 2: 'not_going', 3: 'maybe' }[parsed.response];
627
+ if (response) {
628
+ const author = await canonical(jidNormalizedUser((msg.key.fromMe ? socket.user?.id : msg.key.participant ?? cid) ?? cid));
629
+ found.doc.responses = [
630
+ ...(found.doc.responses ?? []).filter((entry) => entry.author !== author),
631
+ { author, response, guests: Number(parsed.extraGuestCount ?? 0), at: (Number(msg.messageTimestamp) || Math.floor(Date.now() / 1_000)) * 1_000 },
632
+ ];
633
+ await engine.set(found.path, serialize(found.doc), found.doc.created_at);
634
+ const instance = new Message(init, found.doc);
635
+ this.emit('message:updated', instance, await instance.chat(), this);
636
+ }
637
+ break;
638
+ }
639
+ catch {
640
+ /* identidad equivocada / wrong identity */
641
+ }
642
+ }
643
+ }
644
+ continue;
645
+ }
524
646
  if (kind === 'pollUpdateMessage') {
525
647
  const key = msg.message?.pollUpdateMessage?.pollCreationMessageKey;
526
648
  const vote = msg.message?.pollUpdateMessage?.vote;
package/package.json CHANGED
@@ -72,7 +72,7 @@
72
72
  "release": "npm publish --access public"
73
73
  },
74
74
  "types": "./build/esm/index.d.ts",
75
- "version": "7.4.2",
75
+ "version": "8.2.0",
76
76
  "engines": {
77
77
  "node": ">=20"
78
78
  },