@arcaelas/whatsapp 7.4.0 → 7.4.2

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.
@@ -7,6 +7,7 @@ import WhatsApp from './lib/whatsapp';
7
7
  export { WhatsApp };
8
8
  export default WhatsApp;
9
9
  export type IWhatsApp = ConstructorParameters<typeof WhatsApp>[0];
10
+ export type { Farewell } from './lib/whatsapp';
10
11
  export type DisconnectOptions = NonNullable<Parameters<WhatsApp['disconnect']>[0]>;
11
12
  export type ReconnectOption = NonNullable<IWhatsApp['reconnect']>;
12
13
  export { FileSystemEngine, RedisEngine, S3Engine, SQLiteEngine, serialize, deserialize } from './lib/store';
@@ -215,7 +215,22 @@ function chat(init) {
215
215
  */
216
216
  async seen() {
217
217
  const last = await tail(this._raw.id);
218
- await init.socket.chatModify({ markRead: true, lastMessages: last.messages }, this._raw.id);
218
+ // Marcar leído se acusa con recibos, no con una mutación del estado de la app.
219
+ // `chatModify({ markRead })` emite un app patch, y cuando el estado local va por
220
+ // detrás del servidor —lo normal en un dispositivo recién vinculado, que arranca en
221
+ // v0— WhatsApp responde al patch expulsando el dispositivo: `conflict
222
+ // (device_removed)`. Abrir un chat basta para provocarlo, así que la línea se caía
223
+ // sola al primer chat que se mirara. Los recibos no tocan el estado de la app.
224
+ // Marking read is acknowledged with receipts, not with an app-state mutation.
225
+ // `chatModify({ markRead })` emits an app patch, and when the local state trails the
226
+ // server —the norm on a freshly linked device, which starts at v0— WhatsApp answers
227
+ // that patch by dropping the device: `conflict (device_removed)`. Opening a chat was
228
+ // enough to trigger it, so the line died on the first chat anyone looked at.
229
+ // Receipts leave the app state alone.
230
+ const keys = last.messages.map(({ key }) => ({ remoteJid: key.remoteJid, id: key.id, participant: undefined }));
231
+ if (keys.length) {
232
+ await init.socket.readMessages(keys);
233
+ }
219
234
  this._raw.unread_count = 0;
220
235
  await init.engine.set(`/chat/${this._raw.id}`, (0, store_1.serialize)(this._raw), this._raw.activity ?? last.at);
221
236
  return true;
@@ -32,10 +32,37 @@ interface Options {
32
32
  * more than one should name them.
33
33
  */
34
34
  device?: string;
35
+ /**
36
+ * Nivel del log interno de baileys; `silent` por defecto. En silencio un cierre remoto no
37
+ * deja rastro de por qué ocurrió: `Farewell` da el código, pero el intercambio que llevó
38
+ * hasta él —el nodo que WhatsApp rechazó— sólo aparece subiendo esto a `debug` o `trace`.
39
+ * Level of baileys' internal log; `silent` by default. Kept silent, a remote close leaves no
40
+ * trace of why it happened: `Farewell` gives the code, but the exchange leading to it —the
41
+ * node WhatsApp rejected— only shows up by raising this to `debug` or `trace`.
42
+ */
43
+ debug?: 'silent' | 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
44
+ }
45
+ /**
46
+ * Por qué se cerró la sesión. Sin esto un cierre remoto es indistinguible de otro: el evento
47
+ * sólo decía «se cerró», el logger va en silencio y `autoclean` borra la evidencia antes de que
48
+ * nadie pueda mirarla, así que no queda con qué diagnosticar por qué WhatsApp echó la línea.
49
+ * Why the session closed. Without it one remote close is indistinguishable from another: the
50
+ * event only said «it closed», the logger runs silent and `autoclean` wipes the evidence before
51
+ * anyone can look at it, leaving nothing to diagnose why WhatsApp dropped the line.
52
+ */
53
+ export interface Farewell {
54
+ /** Código de baileys, si vino. / Baileys status code, if any. */
55
+ code: number | null;
56
+ /** Nombre del motivo (`loggedOut`, `connectionReplaced`, `badSession`…) o `unknown`. / Reason name or `unknown`. */
57
+ reason: string;
58
+ /** El teléfono desvinculó la sesión: no se reintenta y las credenciales ya no sirven. / The phone unlinked it: no retry, credentials are dead. */
59
+ expired: boolean;
60
+ /** Mensaje del error subyacente, si lo hubo. / Underlying error message, if any. */
61
+ detail: string | null;
35
62
  }
36
63
  interface EventMap {
37
64
  connected: [WhatsApp];
38
- disconnected: [WhatsApp];
65
+ disconnected: [WhatsApp, Farewell];
39
66
  'contact:created': [ContactInstance, ChatInstance, WhatsApp];
40
67
  'contact:updated': [ContactInstance, ChatInstance, WhatsApp];
41
68
  'chat:created': [ChatInstance, WhatsApp];
@@ -53,6 +53,25 @@ const store_1 = require("../../lib/store");
53
53
  * whole. That is not a name: taking it hides the real one, which is stored on the own contact
54
54
  * card.
55
55
  */
56
+ /**
57
+ * Cola por ruta para las escrituras de sesión. Baileys emite `creds.update` varias veces
58
+ * seguidas y lanza `keys.set` en paralelo; sin serializar, dos escrituras sobre el mismo
59
+ * archivo pueden resolverse en orden inverso y dejar el estado viejo encima del nuevo. Un
60
+ * archivo íntegro pero atrasado no se nota —el `tmp+rename` del engine lo deja bien formado—,
61
+ * y es peor que uno corrupto: WhatsApp lo rechaza en el handshake y cierra la sesión.
62
+ * Su propia referencia (`useMultiFileAuthState`) toma un mutex por archivo por esto mismo.
63
+ * Per-path queue for session writes. Baileys emits `creds.update` several times in a row and
64
+ * fires `keys.set` in parallel; without serializing, two writes to the same file can settle in
65
+ * reverse order and leave the old state on top of the new one. A whole but stale file goes
66
+ * unnoticed —the engine's `tmp+rename` leaves it well formed— and is worse than a corrupt one:
67
+ * WhatsApp rejects it at the handshake and closes the session. Their own reference
68
+ * (`useMultiFileAuthState`) takes a mutex per file for this very reason.
69
+ */
70
+ const queued = (locks, path, work) => {
71
+ const next = (locks.get(path) ?? Promise.resolve()).then(work, work);
72
+ locks.set(path, next.catch(() => { }));
73
+ return next;
74
+ };
56
75
  const readable = (value) => (value && !/^\+?[\d\s·•∙⋅]+$/.test(value) ? value : null);
57
76
  class WhatsApp {
58
77
  #event = new node_events_1.EventEmitter();
@@ -86,7 +105,7 @@ class WhatsApp {
86
105
  }
87
106
  async connect(callback) {
88
107
  const { engine } = this;
89
- const { phone, method, autoclean = true, sync = true, reconnect = true, device } = this.#options;
108
+ const { phone, method, autoclean = true, sync = true, reconnect = true, device, debug } = this.#options;
90
109
  const digits = phone !== undefined ? String(phone).replace(/\D+/g, '') : '';
91
110
  const budget = reconnect === false ? 0 : reconnect === true ? null : typeof reconnect === 'number' ? reconnect : reconnect.max ?? null;
92
111
  const wait = typeof reconnect === 'object' ? (reconnect.interval ?? 60) * 1_000 : 60_000;
@@ -99,6 +118,7 @@ class WhatsApp {
99
118
  let alive = null;
100
119
  let timer = null;
101
120
  let chain = Promise.resolve();
121
+ const locks = new Map();
102
122
  return new Promise((resolve, reject) => {
103
123
  const start = async () => {
104
124
  const creds = (0, store_1.deserialize)(await engine.get('/session/creds')) ?? (0, baileys_1.initAuthCreds)();
@@ -120,14 +140,15 @@ class WhatsApp {
120
140
  return data;
121
141
  },
122
142
  set: async (data) => {
123
- await Promise.all(Object.entries(data).flatMap(([category, entries]) => Object.entries(entries).map(([id, value]) => value != null
124
- ? engine.set(`/session/${category}/${id}`, (0, store_1.serialize)(value))
125
- : engine.unset(`/session/${category}/${id}`))));
143
+ await Promise.all(Object.entries(data).flatMap(([category, entries]) => Object.entries(entries).map(([id, value]) => {
144
+ const path = `/session/${category}/${id}`;
145
+ return queued(locks, path, () => (value != null ? engine.set(path, (0, store_1.serialize)(value)) : engine.unset(path)));
146
+ })));
126
147
  },
127
148
  },
128
149
  },
129
150
  browser: baileys_1.Browsers.windows(device ?? 'Chrome'),
130
- logger: (0, pino_1.default)({ level: 'silent' }),
151
+ logger: (0, pino_1.default)({ level: debug ?? 'silent' }),
131
152
  syncFullHistory: sync,
132
153
  shouldSyncHistoryMessage: ({ syncType }) => sync || syncType !== baileys_1.proto.HistorySync.HistorySyncType.FULL,
133
154
  // Cuando el receptor no puede descifrar pide un retry; el cache interno de
@@ -272,7 +293,7 @@ class WhatsApp {
272
293
  }
273
294
  }
274
295
  };
275
- socket.ev.on('creds.update', () => engine.set('/session/creds', (0, store_1.serialize)(creds)));
296
+ socket.ev.on('creds.update', () => queued(locks, '/session/creds', () => engine.set('/session/creds', (0, store_1.serialize)(creds))));
276
297
  socket.ev.on('connection.update', async ({ connection, lastDisconnect, qr }) => {
277
298
  if (qr && !creds.registered) {
278
299
  await callback(digits && (method ?? 'otp') === 'otp'
@@ -295,11 +316,25 @@ class WhatsApp {
295
316
  else if (connection === 'close') {
296
317
  const code = lastDisconnect?.error?.output?.statusCode;
297
318
  const transient = code === baileys_1.DisconnectReason.restartRequired;
319
+ const farewell = {
320
+ code: code ?? null,
321
+ reason: Object.entries(baileys_1.DisconnectReason).find(([, value]) => value === code)?.[0] ?? 'unknown',
322
+ expired: code === baileys_1.DisconnectReason.loggedOut,
323
+ detail: lastDisconnect?.error instanceof Error ? lastDisconnect.error.message : null,
324
+ };
298
325
  if (code === baileys_1.DisconnectReason.loggedOut) {
326
+ // Las escrituras en vuelo tienen que aterrizar antes de borrar, o una
327
+ // de ellas resucita las credenciales justo después del clear y la
328
+ // siguiente conexión arranca con restos de una sesión ya muerta.
329
+ // In-flight writes must land before wiping, or one of them resurrects
330
+ // the credentials right after the clear and the next connection starts
331
+ // on the leftovers of an already dead session.
332
+ await Promise.allSettled([...locks.values()]);
333
+ locks.clear();
299
334
  await (autoclean ? engine.clear() : engine.unset('/session/creds'));
300
335
  }
301
336
  if (connected && !transient && !silent) {
302
- this.emit('disconnected', this);
337
+ this.emit('disconnected', this, farewell);
303
338
  }
304
339
  if (intentional) {
305
340
  /* cierre pedido por disconnect(): sin reintentos / close requested by disconnect(): no retries */
@@ -7,6 +7,7 @@ import WhatsApp from './lib/whatsapp/index.js';
7
7
  export { WhatsApp };
8
8
  export default WhatsApp;
9
9
  export type IWhatsApp = ConstructorParameters<typeof WhatsApp>[0];
10
+ export type { Farewell } from './lib/whatsapp/index.js';
10
11
  export type DisconnectOptions = NonNullable<Parameters<WhatsApp['disconnect']>[0]>;
11
12
  export type ReconnectOption = NonNullable<IWhatsApp['reconnect']>;
12
13
  export { FileSystemEngine, RedisEngine, S3Engine, SQLiteEngine, serialize, deserialize } from './lib/store/index.js';
@@ -211,7 +211,22 @@ export function chat(init) {
211
211
  */
212
212
  async seen() {
213
213
  const last = await tail(this._raw.id);
214
- await init.socket.chatModify({ markRead: true, lastMessages: last.messages }, this._raw.id);
214
+ // Marcar leído se acusa con recibos, no con una mutación del estado de la app.
215
+ // `chatModify({ markRead })` emite un app patch, y cuando el estado local va por
216
+ // detrás del servidor —lo normal en un dispositivo recién vinculado, que arranca en
217
+ // v0— WhatsApp responde al patch expulsando el dispositivo: `conflict
218
+ // (device_removed)`. Abrir un chat basta para provocarlo, así que la línea se caía
219
+ // sola al primer chat que se mirara. Los recibos no tocan el estado de la app.
220
+ // Marking read is acknowledged with receipts, not with an app-state mutation.
221
+ // `chatModify({ markRead })` emits an app patch, and when the local state trails the
222
+ // server —the norm on a freshly linked device, which starts at v0— WhatsApp answers
223
+ // that patch by dropping the device: `conflict (device_removed)`. Opening a chat was
224
+ // enough to trigger it, so the line died on the first chat anyone looked at.
225
+ // Receipts leave the app state alone.
226
+ const keys = last.messages.map(({ key }) => ({ remoteJid: key.remoteJid, id: key.id, participant: undefined }));
227
+ if (keys.length) {
228
+ await init.socket.readMessages(keys);
229
+ }
215
230
  this._raw.unread_count = 0;
216
231
  await init.engine.set(`/chat/${this._raw.id}`, serialize(this._raw), this._raw.activity ?? last.at);
217
232
  return true;
@@ -32,10 +32,37 @@ interface Options {
32
32
  * more than one should name them.
33
33
  */
34
34
  device?: string;
35
+ /**
36
+ * Nivel del log interno de baileys; `silent` por defecto. En silencio un cierre remoto no
37
+ * deja rastro de por qué ocurrió: `Farewell` da el código, pero el intercambio que llevó
38
+ * hasta él —el nodo que WhatsApp rechazó— sólo aparece subiendo esto a `debug` o `trace`.
39
+ * Level of baileys' internal log; `silent` by default. Kept silent, a remote close leaves no
40
+ * trace of why it happened: `Farewell` gives the code, but the exchange leading to it —the
41
+ * node WhatsApp rejected— only shows up by raising this to `debug` or `trace`.
42
+ */
43
+ debug?: 'silent' | 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
44
+ }
45
+ /**
46
+ * Por qué se cerró la sesión. Sin esto un cierre remoto es indistinguible de otro: el evento
47
+ * sólo decía «se cerró», el logger va en silencio y `autoclean` borra la evidencia antes de que
48
+ * nadie pueda mirarla, así que no queda con qué diagnosticar por qué WhatsApp echó la línea.
49
+ * Why the session closed. Without it one remote close is indistinguishable from another: the
50
+ * event only said «it closed», the logger runs silent and `autoclean` wipes the evidence before
51
+ * anyone can look at it, leaving nothing to diagnose why WhatsApp dropped the line.
52
+ */
53
+ export interface Farewell {
54
+ /** Código de baileys, si vino. / Baileys status code, if any. */
55
+ code: number | null;
56
+ /** Nombre del motivo (`loggedOut`, `connectionReplaced`, `badSession`…) o `unknown`. / Reason name or `unknown`. */
57
+ reason: string;
58
+ /** El teléfono desvinculó la sesión: no se reintenta y las credenciales ya no sirven. / The phone unlinked it: no retry, credentials are dead. */
59
+ expired: boolean;
60
+ /** Mensaje del error subyacente, si lo hubo. / Underlying error message, if any. */
61
+ detail: string | null;
35
62
  }
36
63
  interface EventMap {
37
64
  connected: [WhatsApp];
38
- disconnected: [WhatsApp];
65
+ disconnected: [WhatsApp, Farewell];
39
66
  'contact:created': [ContactInstance, ChatInstance, WhatsApp];
40
67
  'contact:updated': [ContactInstance, ChatInstance, WhatsApp];
41
68
  'chat:created': [ChatInstance, WhatsApp];
@@ -15,6 +15,25 @@ import { deserialize, jid_of, serialize } from '../../lib/store/index.js';
15
15
  * whole. That is not a name: taking it hides the real one, which is stored on the own contact
16
16
  * card.
17
17
  */
18
+ /**
19
+ * Cola por ruta para las escrituras de sesión. Baileys emite `creds.update` varias veces
20
+ * seguidas y lanza `keys.set` en paralelo; sin serializar, dos escrituras sobre el mismo
21
+ * archivo pueden resolverse en orden inverso y dejar el estado viejo encima del nuevo. Un
22
+ * archivo íntegro pero atrasado no se nota —el `tmp+rename` del engine lo deja bien formado—,
23
+ * y es peor que uno corrupto: WhatsApp lo rechaza en el handshake y cierra la sesión.
24
+ * Su propia referencia (`useMultiFileAuthState`) toma un mutex por archivo por esto mismo.
25
+ * Per-path queue for session writes. Baileys emits `creds.update` several times in a row and
26
+ * fires `keys.set` in parallel; without serializing, two writes to the same file can settle in
27
+ * reverse order and leave the old state on top of the new one. A whole but stale file goes
28
+ * unnoticed —the engine's `tmp+rename` leaves it well formed— and is worse than a corrupt one:
29
+ * WhatsApp rejects it at the handshake and closes the session. Their own reference
30
+ * (`useMultiFileAuthState`) takes a mutex per file for this very reason.
31
+ */
32
+ const queued = (locks, path, work) => {
33
+ const next = (locks.get(path) ?? Promise.resolve()).then(work, work);
34
+ locks.set(path, next.catch(() => { }));
35
+ return next;
36
+ };
18
37
  const readable = (value) => (value && !/^\+?[\d\s·•∙⋅]+$/.test(value) ? value : null);
19
38
  export default class WhatsApp {
20
39
  #event = new EventEmitter();
@@ -48,7 +67,7 @@ export default class WhatsApp {
48
67
  }
49
68
  async connect(callback) {
50
69
  const { engine } = this;
51
- const { phone, method, autoclean = true, sync = true, reconnect = true, device } = this.#options;
70
+ const { phone, method, autoclean = true, sync = true, reconnect = true, device, debug } = this.#options;
52
71
  const digits = phone !== undefined ? String(phone).replace(/\D+/g, '') : '';
53
72
  const budget = reconnect === false ? 0 : reconnect === true ? null : typeof reconnect === 'number' ? reconnect : reconnect.max ?? null;
54
73
  const wait = typeof reconnect === 'object' ? (reconnect.interval ?? 60) * 1_000 : 60_000;
@@ -61,6 +80,7 @@ export default class WhatsApp {
61
80
  let alive = null;
62
81
  let timer = null;
63
82
  let chain = Promise.resolve();
83
+ const locks = new Map();
64
84
  return new Promise((resolve, reject) => {
65
85
  const start = async () => {
66
86
  const creds = deserialize(await engine.get('/session/creds')) ?? initAuthCreds();
@@ -82,14 +102,15 @@ export default class WhatsApp {
82
102
  return data;
83
103
  },
84
104
  set: async (data) => {
85
- await Promise.all(Object.entries(data).flatMap(([category, entries]) => Object.entries(entries).map(([id, value]) => value != null
86
- ? engine.set(`/session/${category}/${id}`, serialize(value))
87
- : engine.unset(`/session/${category}/${id}`))));
105
+ await Promise.all(Object.entries(data).flatMap(([category, entries]) => Object.entries(entries).map(([id, value]) => {
106
+ const path = `/session/${category}/${id}`;
107
+ return queued(locks, path, () => (value != null ? engine.set(path, serialize(value)) : engine.unset(path)));
108
+ })));
88
109
  },
89
110
  },
90
111
  },
91
112
  browser: Browsers.windows(device ?? 'Chrome'),
92
- logger: pino({ level: 'silent' }),
113
+ logger: pino({ level: debug ?? 'silent' }),
93
114
  syncFullHistory: sync,
94
115
  shouldSyncHistoryMessage: ({ syncType }) => sync || syncType !== proto.HistorySync.HistorySyncType.FULL,
95
116
  // Cuando el receptor no puede descifrar pide un retry; el cache interno de
@@ -234,7 +255,7 @@ export default class WhatsApp {
234
255
  }
235
256
  }
236
257
  };
237
- socket.ev.on('creds.update', () => engine.set('/session/creds', serialize(creds)));
258
+ socket.ev.on('creds.update', () => queued(locks, '/session/creds', () => engine.set('/session/creds', serialize(creds))));
238
259
  socket.ev.on('connection.update', async ({ connection, lastDisconnect, qr }) => {
239
260
  if (qr && !creds.registered) {
240
261
  await callback(digits && (method ?? 'otp') === 'otp'
@@ -257,11 +278,25 @@ export default class WhatsApp {
257
278
  else if (connection === 'close') {
258
279
  const code = lastDisconnect?.error?.output?.statusCode;
259
280
  const transient = code === DisconnectReason.restartRequired;
281
+ const farewell = {
282
+ code: code ?? null,
283
+ reason: Object.entries(DisconnectReason).find(([, value]) => value === code)?.[0] ?? 'unknown',
284
+ expired: code === DisconnectReason.loggedOut,
285
+ detail: lastDisconnect?.error instanceof Error ? lastDisconnect.error.message : null,
286
+ };
260
287
  if (code === DisconnectReason.loggedOut) {
288
+ // Las escrituras en vuelo tienen que aterrizar antes de borrar, o una
289
+ // de ellas resucita las credenciales justo después del clear y la
290
+ // siguiente conexión arranca con restos de una sesión ya muerta.
291
+ // In-flight writes must land before wiping, or one of them resurrects
292
+ // the credentials right after the clear and the next connection starts
293
+ // on the leftovers of an already dead session.
294
+ await Promise.allSettled([...locks.values()]);
295
+ locks.clear();
261
296
  await (autoclean ? engine.clear() : engine.unset('/session/creds'));
262
297
  }
263
298
  if (connected && !transient && !silent) {
264
- this.emit('disconnected', this);
299
+ this.emit('disconnected', this, farewell);
265
300
  }
266
301
  if (intentional) {
267
302
  /* cierre pedido por disconnect(): sin reintentos / close requested by disconnect(): no retries */
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.0",
75
+ "version": "7.4.2",
76
76
  "engines": {
77
77
  "node": ">=20"
78
78
  },