@arcaelas/whatsapp 4.5.0 → 5.1.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 (95) hide show
  1. package/build/cjs/index.d.ts +6 -4
  2. package/build/cjs/index.js +22 -2
  3. package/build/cjs/index.js.map +1 -1
  4. package/build/cjs/lib/bot/decorator.d.ts +2 -0
  5. package/build/cjs/lib/bot/decorator.js +6 -0
  6. package/build/cjs/lib/bot/decorator.js.map +1 -1
  7. package/build/cjs/lib/chat/index.d.ts +340 -204
  8. package/build/cjs/lib/chat/index.js +225 -213
  9. package/build/cjs/lib/chat/index.js.map +1 -1
  10. package/build/cjs/lib/contact/index.d.ts +162 -179
  11. package/build/cjs/lib/contact/index.js +96 -190
  12. package/build/cjs/lib/contact/index.js.map +1 -1
  13. package/build/cjs/lib/internal.d.ts +40 -0
  14. package/build/cjs/lib/internal.js +39 -0
  15. package/build/cjs/lib/internal.js.map +1 -0
  16. package/build/cjs/lib/message/index.d.ts +462 -289
  17. package/build/cjs/lib/message/index.js +825 -741
  18. package/build/cjs/lib/message/index.js.map +1 -1
  19. package/build/cjs/lib/status/index.d.ts +60 -70
  20. package/build/cjs/lib/status/index.js +106 -111
  21. package/build/cjs/lib/status/index.js.map +1 -1
  22. package/build/cjs/lib/store/engine/index.d.ts +21 -7
  23. package/build/cjs/lib/store/engine/index.js +6 -3
  24. package/build/cjs/lib/store/engine/index.js.map +1 -1
  25. package/build/cjs/lib/store/engine/lib/file_system/index.d.ts +38 -8
  26. package/build/cjs/lib/store/engine/lib/file_system/index.js +92 -67
  27. package/build/cjs/lib/store/engine/lib/file_system/index.js.map +1 -1
  28. package/build/cjs/lib/store/engine/lib/index.d.ts +112 -0
  29. package/build/cjs/lib/store/engine/lib/index.js +213 -0
  30. package/build/cjs/lib/store/engine/lib/index.js.map +1 -0
  31. package/build/cjs/lib/store/engine/lib/redis/index.d.ts +28 -13
  32. package/build/cjs/lib/store/engine/lib/redis/index.js +56 -42
  33. package/build/cjs/lib/store/engine/lib/redis/index.js.map +1 -1
  34. package/build/cjs/lib/store/engine/lib/s3/index.d.ts +44 -1
  35. package/build/cjs/lib/store/engine/lib/s3/index.js +112 -64
  36. package/build/cjs/lib/store/engine/lib/s3/index.js.map +1 -1
  37. package/build/cjs/lib/store/engine/lib/sqlite/index.d.ts +98 -0
  38. package/build/cjs/lib/store/engine/lib/sqlite/index.js +114 -0
  39. package/build/cjs/lib/store/engine/lib/sqlite/index.js.map +1 -0
  40. package/build/cjs/lib/store/index.d.ts +7 -3
  41. package/build/cjs/lib/store/index.js +14 -4
  42. package/build/cjs/lib/store/index.js.map +1 -1
  43. package/build/cjs/lib/whatsapp/index.d.ts +106 -68
  44. package/build/cjs/lib/whatsapp/index.js +380 -170
  45. package/build/cjs/lib/whatsapp/index.js.map +1 -1
  46. package/build/cjs/test.js +5 -4
  47. package/build/cjs/test.js.map +1 -1
  48. package/build/esm/index.d.ts +6 -4
  49. package/build/esm/index.js +5 -2
  50. package/build/esm/index.js.map +1 -1
  51. package/build/esm/lib/bot/decorator.d.ts +2 -0
  52. package/build/esm/lib/bot/decorator.js +6 -0
  53. package/build/esm/lib/bot/decorator.js.map +1 -1
  54. package/build/esm/lib/chat/index.d.ts +340 -204
  55. package/build/esm/lib/chat/index.js +225 -213
  56. package/build/esm/lib/chat/index.js.map +1 -1
  57. package/build/esm/lib/contact/index.d.ts +162 -179
  58. package/build/esm/lib/contact/index.js +96 -189
  59. package/build/esm/lib/contact/index.js.map +1 -1
  60. package/build/esm/lib/internal.d.ts +40 -0
  61. package/build/esm/lib/internal.js +35 -0
  62. package/build/esm/lib/internal.js.map +1 -0
  63. package/build/esm/lib/message/index.d.ts +462 -289
  64. package/build/esm/lib/message/index.js +820 -736
  65. package/build/esm/lib/message/index.js.map +1 -1
  66. package/build/esm/lib/status/index.d.ts +60 -70
  67. package/build/esm/lib/status/index.js +105 -110
  68. package/build/esm/lib/status/index.js.map +1 -1
  69. package/build/esm/lib/store/engine/index.d.ts +21 -7
  70. package/build/esm/lib/store/engine/index.js +4 -2
  71. package/build/esm/lib/store/engine/index.js.map +1 -1
  72. package/build/esm/lib/store/engine/lib/file_system/index.d.ts +38 -8
  73. package/build/esm/lib/store/engine/lib/file_system/index.js +92 -67
  74. package/build/esm/lib/store/engine/lib/file_system/index.js.map +1 -1
  75. package/build/esm/lib/store/engine/lib/index.d.ts +112 -0
  76. package/build/esm/lib/store/engine/lib/index.js +206 -0
  77. package/build/esm/lib/store/engine/lib/index.js.map +1 -0
  78. package/build/esm/lib/store/engine/lib/redis/index.d.ts +28 -13
  79. package/build/esm/lib/store/engine/lib/redis/index.js +54 -40
  80. package/build/esm/lib/store/engine/lib/redis/index.js.map +1 -1
  81. package/build/esm/lib/store/engine/lib/s3/index.d.ts +44 -1
  82. package/build/esm/lib/store/engine/lib/s3/index.js +111 -63
  83. package/build/esm/lib/store/engine/lib/s3/index.js.map +1 -1
  84. package/build/esm/lib/store/engine/lib/sqlite/index.d.ts +98 -0
  85. package/build/esm/lib/store/engine/lib/sqlite/index.js +110 -0
  86. package/build/esm/lib/store/engine/lib/sqlite/index.js.map +1 -0
  87. package/build/esm/lib/store/index.d.ts +7 -3
  88. package/build/esm/lib/store/index.js +13 -4
  89. package/build/esm/lib/store/index.js.map +1 -1
  90. package/build/esm/lib/whatsapp/index.d.ts +106 -68
  91. package/build/esm/lib/whatsapp/index.js +382 -172
  92. package/build/esm/lib/whatsapp/index.js.map +1 -1
  93. package/build/esm/test.js +5 -4
  94. package/build/esm/test.js.map +1 -1
  95. package/package.json +2 -2
@@ -8,10 +8,32 @@ import { EventEmitter } from 'node:events';
8
8
  import pino from 'pino';
9
9
  import * as QRCode from 'qrcode';
10
10
  import { chat } from '../../lib/chat/index.js';
11
- import { contact, contact_name } from '../../lib/contact/index.js';
12
- import { message } from '../../lib/message/index.js';
13
- import Feed, { TTL_MS as FEED_TTL_MS } from '../../lib/status/index.js';
11
+ import { contact } from '../../lib/contact/index.js';
12
+ import { bind } from '../../lib/internal.js';
13
+ import { Audio, Document, Event, Image, Location, message, Message, Poll, Sticker, Text, VCard, Video, } from '../../lib/message/index.js';
14
+ import { Feed, TTL_MS as FEED_TTL_MS } from '../../lib/status/index.js';
14
15
  import { deserialize, serialize } from '../../lib/store/index.js';
16
+ /** Estados legibles del mensaje que el receipt puede avanzar. / Readable message states a receipt can advance to. */
17
+ const READ = 4;
18
+ const PLAYED = 5;
19
+ /**
20
+ * Deduce el MIME de un binario por su firma, acotado a lo que WhatsApp acepta en un estado.
21
+ * Infers a binary's MIME from its signature, limited to what WhatsApp accepts in a status.
22
+ *
23
+ * @param data - Binario a inspeccionar / Binary to inspect
24
+ * @returns MIME reconocido, o null / Recognized MIME, or null
25
+ */
26
+ function sniff_media(data) {
27
+ if (data.subarray(0, 3).toString('hex') === 'ffd8ff')
28
+ return 'image/jpeg';
29
+ if (data.subarray(0, 8).toString('hex') === '89504e470d0a1a0a')
30
+ return 'image/png';
31
+ if (data.subarray(0, 4).toString() === 'RIFF' && data.subarray(8, 12).toString() === 'WEBP')
32
+ return 'image/webp';
33
+ if (data.subarray(4, 8).toString() === 'ftyp')
34
+ return 'video/mp4';
35
+ return null;
36
+ }
15
37
  /**
16
38
  * Cliente principal de WhatsApp. No inicia la conexión al instanciar.
17
39
  * Main WhatsApp client. Does not connect on instantiation.
@@ -22,55 +44,108 @@ import { deserialize, serialize } from '../../lib/store/index.js';
22
44
  * await wa.connect((code) => console.log(code));
23
45
  */
24
46
  export class WhatsApp {
47
+ /** @internal Emisor de los eventos del cliente. / Client event emitter. */
48
+ #event = new EventEmitter();
49
+ /** @internal Estado compartido con las entidades (socket, resolución de JIDs). / State shared with the entities. */
50
+ #internals;
51
+ #phone;
52
+ #method;
53
+ #autoclean;
54
+ #reconnect;
55
+ #sync;
56
+ #intentional_close = false;
57
+ #silent_close = false;
58
+ #has_connected = false;
59
+ #retry_timer = null;
60
+ #retry_count = 0;
61
+ /**
62
+ * @internal
63
+ * Cadena que serializa los handlers de eventos de baileys: dos eventos sobre el mismo
64
+ * documento ya no se intercalan (lost updates por read-modify-write concurrente).
65
+ * Chain serializing baileys event handlers: two events over the same document no longer
66
+ * interleave (lost updates from concurrent read-modify-write).
67
+ */
68
+ #chain = Promise.resolve();
25
69
  constructor(options) {
26
- /** @internal */
27
- this._socket = null;
28
- this._intentional_close = false;
29
- this._has_connected = false;
30
- this._retry_timer = null;
31
- this._retry_count = 0;
32
70
  this.engine = options.engine;
33
- this._phone =
34
- options.phone !== undefined ? String(options.phone).replace(/\D+/g, '') : undefined;
35
- this._method = options.method;
36
- this._autoclean = options.autoclean ?? true;
37
- this._sync = options.sync ?? true;
38
- this._reconnect = this._parse_reconnect(options.reconnect);
39
- this._event = new EventEmitter();
71
+ this.#phone = options.phone !== undefined ? String(options.phone).replace(/\D+/g, '') : undefined;
72
+ this.#method = options.method;
73
+ this.#autoclean = options.autoclean ?? true;
74
+ this.#sync = options.sync ?? true;
75
+ this.#reconnect =
76
+ options.reconnect === false ? { max: 0, interval_ms: 60_000 }
77
+ : options.reconnect === undefined || options.reconnect === true ? { max: null, interval_ms: 60_000 }
78
+ : typeof options.reconnect === 'number' ? { max: options.reconnect, interval_ms: 60_000 }
79
+ : { max: options.reconnect.max ?? null, interval_ms: (options.reconnect.interval ?? 60) * 1_000 };
80
+ this.#internals = { socket: null, resolve_jid: (uid) => this.#resolve_jid(uid) };
81
+ bind(this, this.#internals);
40
82
  this.Contact = contact(this);
41
83
  this.Chat = chat(this);
42
- this.Message = message(this);
84
+ this.Message = {
85
+ get: (cid, mid) => Message.get(this, cid, mid),
86
+ list: (cid, offset, limit) => Message.list(this, cid, offset, limit),
87
+ text: (cid, ...rest) => Message.text(this, cid, ...rest),
88
+ image: (cid, ...rest) => Message.image(this, cid, ...rest),
89
+ video: (cid, ...rest) => Message.video(this, cid, ...rest),
90
+ audio: (cid, ...rest) => Message.audio(this, cid, ...rest),
91
+ location: (cid, ...rest) => Message.location(this, cid, ...rest),
92
+ poll: (cid, ...rest) => Message.poll(this, cid, ...rest),
93
+ document: (cid, ...rest) => Message.document(this, cid, ...rest),
94
+ vcard: (cid, ...rest) => Message.vcard(this, cid, ...rest),
95
+ event: (cid, ...rest) => Message.event(this, cid, ...rest),
96
+ react: (cid, mid, emoji) => Message.react(this, cid, mid, emoji),
97
+ star: (cid, mid, value) => Message.star(this, cid, mid, value),
98
+ seen: (cid, mid) => Message.seen(this, cid, mid),
99
+ edit: (cid, mid, caption) => Message.edit(this, cid, mid, caption),
100
+ forward: (cid, mid, target) => Message.forward(this, cid, mid, target),
101
+ delete: (cid, mid, all) => Message.delete(this, cid, mid, all),
102
+ reactions: (cid, mid) => Message.reactions(this, cid, mid),
103
+ Text, Image, Video, Audio, Sticker, Document, Location, Poll, VCard, Event,
104
+ };
43
105
  }
44
- /** @internal */
45
- _parse_reconnect(option) {
46
- if (option === false) {
47
- return { max: 0, interval_ms: 60_000 };
48
- }
49
- if (option === undefined || option === true) {
50
- return { max: null, interval_ms: 60_000 };
51
- }
52
- if (typeof option === 'number') {
53
- return { max: option, interval_ms: 60_000 };
106
+ /**
107
+ * Contacto de la cuenta autenticada, o null mientras no hay sesión abierta.
108
+ * Authenticated account's contact, or null while there is no open session.
109
+ */
110
+ get contact() {
111
+ const user = this.#internals.socket?.user;
112
+ if (user) {
113
+ const jid = jidNormalizedUser(user.id);
114
+ return new this.Contact({ id: jid, phone_number: jid, lid: user.lid ?? null, name: user.name ?? null });
54
115
  }
55
- return {
56
- max: option.max ?? null,
57
- interval_ms: (option.interval ?? 60) * 1000,
58
- };
116
+ return null;
117
+ }
118
+ /** @internal Encola una tarea en la cadena serial de handlers. / Queues a task on the serial handler chain. */
119
+ #enqueue(task) {
120
+ this.#chain = this.#chain.then(task).catch(() => { });
121
+ }
122
+ /**
123
+ * Emite un evento del cliente. Lo usan las entidades de la librería para propagar los
124
+ * cambios que provocan; el consumidor puede emitir los suyos para pruebas.
125
+ * Emits a client event. Library entities use it to propagate the changes they cause;
126
+ * consumers may emit their own for testing.
127
+ *
128
+ * @param event - Nombre del evento / Event name
129
+ * @param args - Argumentos del evento / Event arguments
130
+ * @returns true si había listeners / true when listeners were present
131
+ */
132
+ emit(event, ...args) {
133
+ return this.#event.emit(event, ...args);
59
134
  }
60
135
  /**
61
136
  * Registra un listener de evento. Retorna función para desuscribirse.
62
137
  * Registers an event listener. Returns an unsubscribe function.
63
138
  */
64
139
  on(event, handler) {
65
- this._event.on(event, handler);
66
- return () => { this._event.off(event, handler); };
140
+ this.#event.on(event, handler);
141
+ return () => { this.#event.off(event, handler); };
67
142
  }
68
143
  /**
69
144
  * Quita un listener previamente registrado.
70
145
  * Removes a previously registered listener.
71
146
  */
72
147
  off(event, handler) {
73
- this._event.off(event, handler);
148
+ this.#event.off(event, handler);
74
149
  return this;
75
150
  }
76
151
  /**
@@ -78,15 +153,17 @@ export class WhatsApp {
78
153
  * Registers a one-shot listener. Returns an unsubscribe function.
79
154
  */
80
155
  once(event, handler) {
81
- this._event.once(event, handler);
82
- return () => { this._event.off(event, handler); };
156
+ this.#event.once(event, handler);
157
+ return () => { this.#event.off(event, handler); };
83
158
  }
84
159
  /**
85
160
  * @internal
86
- * Normaliza cualquier identificador (JID, LID, número, etc.) a JID canónico.
87
- * Uso interno de la librería no forma parte de la API pública.
161
+ * Normaliza cualquier identificador (JID, LID, número, etc.) a JID canónico. Las
162
+ * entidades lo alcanzan por el canal interno, no por la instancia.
163
+ * Normalizes any identifier (JID, LID, number…) into a canonical JID. Entities reach it
164
+ * through the internal channel, not through the instance.
88
165
  */
89
- async _resolve_jid(uid) {
166
+ async #resolve_jid(uid) {
90
167
  let result = null;
91
168
  if (uid.endsWith('@g.us') || uid.endsWith('@s.whatsapp.net')) {
92
169
  result = uid;
@@ -106,7 +183,7 @@ export class WhatsApp {
106
183
  // baileys lo conoce vía su lidMapping. Sin esto, un chat referenciado por @lid
107
184
  // (p.ej. el pollCreationMessageKey de un voto entrante) no resuelve al PN donde
108
185
  // realmente está guardado, y el mensaje/poll no se encuentra.
109
- const pn = await this._socket?.signalRepository?.lidMapping?.getPNForLID(uid).catch(() => null);
186
+ const pn = await this.#internals.socket?.signalRepository?.lidMapping?.getPNForLID(uid).catch(() => null);
110
187
  if (pn) {
111
188
  // getPNForLID puede traer sufijo de dispositivo (`:0`); se normaliza para
112
189
  // que el JID coincida con el que usa el store (sin device).
@@ -131,16 +208,20 @@ export class WhatsApp {
131
208
  * Resolves once the session is synced; retries on non-loggedOut disconnects.
132
209
  */
133
210
  async connect(callback) {
211
+ if (this.#internals.socket) {
212
+ await this.disconnect({ silent: true });
213
+ }
134
214
  const { version } = await fetchLatestBaileysVersion();
135
- this._intentional_close = false;
136
- this._has_connected = false;
215
+ this.#intentional_close = false;
216
+ this.#silent_close = false;
217
+ this.#has_connected = false;
137
218
  return new Promise((resolve, reject) => {
138
219
  const start = async () => {
139
220
  // Re-lee creds en cada start() para que limpiezas del engine tomen efecto
140
221
  // en reintentos (permite al consumer forzar nueva sesión borrando /session/creds).
141
222
  const stored = await this.engine.get('/session/creds');
142
223
  const creds = deserialize(stored) ?? initAuthCreds();
143
- this._socket = makeWASocket({
224
+ this.#internals.socket = makeWASocket({
144
225
  version,
145
226
  auth: {
146
227
  creds,
@@ -167,19 +248,19 @@ export class WhatsApp {
167
248
  },
168
249
  browser: Browsers.windows('Chrome'),
169
250
  logger: pino({ level: 'silent' }),
170
- syncFullHistory: this._sync,
251
+ syncFullHistory: this.#sync,
171
252
  // Los syncs no-FULL cargan las LID mappings y los tctokens (trusted-contact
172
- // tokens) que rc13 exige para enviar: apagarlos todos con `() => this._sync`
253
+ // tokens) que rc13 exige para enviar: apagarlos todos con `() => this.#sync`
173
254
  // deja la sesión sin tctoken y el server rechaza los mensajes con
174
255
  // "error 463: account restricted or missing tctoken". Por eso se procesan
175
256
  // siempre los no-FULL; `sync` solo decide si además se trae el historial FULL.
176
257
  // Non-FULL syncs carry the LID mappings and tctokens rc13 requires to send;
177
258
  // disabling them all made the server reject messages with error 463. We always
178
259
  // process non-FULL; `sync` only gates whether FULL history is pulled too.
179
- shouldSyncHistoryMessage: ({ syncType }) => this._sync || syncType !== proto.HistorySync.HistorySyncType.FULL,
260
+ shouldSyncHistoryMessage: ({ syncType }) => this.#sync || syncType !== proto.HistorySync.HistorySyncType.FULL,
180
261
  markOnlineOnConnect: false,
181
262
  });
182
- const socket = this._socket;
263
+ const socket = this.#internals.socket;
183
264
  socket.ev.on('creds.update', () => this.engine.set('/session/creds', serialize(creds)));
184
265
  socket.ev.on('connection.update', async (update) => {
185
266
  const { connection, lastDisconnect, qr } = update;
@@ -187,24 +268,25 @@ export class WhatsApp {
187
268
  // Baileys refresca QR periódicamente (~20s). Emitimos nuevo pair
188
269
  // code / QR en cada refresh para que el usuario pueda renovar si
189
270
  // el anterior expiró.
190
- // `method` fuerza el canal; sin él, phone OTP y su ausencia ⇒ QR.
191
- // `otp` exige phone: si se pidió sin phone, se cae a QR.
192
- const auth = this._method ?? (this._phone ? 'otp' : 'qr');
193
- if (auth === 'otp' && this._phone) {
194
- await callback(await socket.requestPairingCode(this._phone));
271
+ // El PIN necesita número: sin `phone` la vinculación es siempre QR, y
272
+ // con `phone` manda `method` (OTP por defecto).
273
+ // A PIN requires a number: without `phone` linking is always QR, and with
274
+ // `phone` the `method` option decides (OTP by default).
275
+ if (this.#phone && (this.#method ?? 'otp') === 'otp') {
276
+ await callback(await socket.requestPairingCode(this.#phone));
195
277
  }
196
278
  else {
197
279
  await callback(await QRCode.toBuffer(qr, { type: 'png', margin: 2 }));
198
280
  }
199
281
  }
200
282
  if (connection === 'open') {
201
- this._has_connected = true;
202
- this._retry_count = 0;
203
- this._event.emit('connected', this);
283
+ this.#has_connected = true;
284
+ this.#retry_count = 0;
285
+ this.emit('connected', this);
204
286
  resolve();
205
287
  }
206
288
  else if (connection === 'close') {
207
- this._socket = null;
289
+ this.#internals.socket = null;
208
290
  const status_code = lastDisconnect?.error?.output?.statusCode;
209
291
  // `restartRequired` (515) es una reconexión exigida por el protocolo
210
292
  // tras el sync inicial — no es un disconnect "real".
@@ -212,35 +294,37 @@ export class WhatsApp {
212
294
  // Limpieza del engine ANTES de emitir `disconnected` para que los
213
295
  // listeners vean el estado final (engine vaciado o creds borradas).
214
296
  if (status_code === DisconnectReason.loggedOut) {
215
- if (this._autoclean) {
297
+ if (this.#autoclean) {
216
298
  await this.engine.clear();
217
299
  }
218
300
  else {
219
301
  await this.engine.unset('/session/creds');
220
302
  }
221
303
  }
222
- if (this._has_connected && !is_transient) {
223
- this._event.emit('disconnected', this);
304
+ // `disconnect({ silent: true })` calla el evento de este cierre concreto.
305
+ // `disconnect({ silent: true })` mutes this specific close's event.
306
+ if (this.#has_connected && !is_transient && !this.#silent_close) {
307
+ this.emit('disconnected', this);
224
308
  }
225
- if (!this._intentional_close) {
309
+ if (!this.#intentional_close) {
226
310
  if (status_code === DisconnectReason.loggedOut) {
227
311
  reject(new Error('Logged out'));
228
312
  }
229
313
  else {
230
- const max = this._reconnect.max;
314
+ const max = this.#reconnect.max;
231
315
  // Transient closes (restartRequired) son parte del protocolo,
232
316
  // no cuentan contra el límite de reintentos por fallo.
233
- const exhausted = !is_transient && max !== null && this._retry_count >= max;
317
+ const exhausted = !is_transient && max !== null && this.#retry_count >= max;
234
318
  if (exhausted) {
235
319
  reject(new Error(`Reconnect attempts exhausted (${max})`));
236
320
  }
237
321
  else {
238
322
  if (!is_transient) {
239
- this._retry_count++;
323
+ this.#retry_count++;
240
324
  }
241
- const delay = is_transient ? 0 : this._reconnect.interval_ms;
242
- this._retry_timer = setTimeout(() => {
243
- this._retry_timer = null;
325
+ const delay = is_transient ? 0 : this.#reconnect.interval_ms;
326
+ this.#retry_timer = setTimeout(() => {
327
+ this.#retry_timer = null;
244
328
  start().catch(reject);
245
329
  }, delay);
246
330
  }
@@ -248,24 +332,119 @@ export class WhatsApp {
248
332
  }
249
333
  }
250
334
  });
251
- this._attach_business_handlers(socket);
335
+ this.#attach_business_handlers(socket);
252
336
  };
253
337
  start().catch(reject);
254
338
  });
255
339
  }
340
+ /**
341
+ * Actualiza el perfil de la cuenta en WhatsApp: nombre público, bio y/o foto. Sólo se
342
+ * envía lo que llega en el parche; `photo: null` elimina la foto actual.
343
+ * Updates the account profile on WhatsApp: public name, bio and/or picture. Only the
344
+ * given fields are sent; `photo: null` removes the current picture.
345
+ *
346
+ * @param patch - Campos a actualizar / Fields to update
347
+ * @returns true si todo lo pedido se envió / true when everything requested was sent
348
+ * @throws ERR_PROFILE_PICTURE_LIB si falta `sharp` o `jimp` para procesar la foto / when `sharp` or `jimp` is missing to process the picture
349
+ */
350
+ async profile(patch) {
351
+ const socket = this.#internals.socket;
352
+ let ok = false;
353
+ if (socket) {
354
+ const self = jidNormalizedUser(socket.user?.id ?? '');
355
+ if (patch.name !== undefined) {
356
+ await socket.updateProfileName(patch.name);
357
+ }
358
+ if (patch.content !== undefined) {
359
+ await socket.updateProfileStatus(patch.content);
360
+ }
361
+ if (patch.photo === null) {
362
+ await socket.removeProfilePicture(self);
363
+ }
364
+ else if (patch.photo !== undefined) {
365
+ // baileys redimensiona la foto con `sharp` o `jimp`; ninguna es dependencia
366
+ // nuestra, así que su ausencia se traduce a un error accionable.
367
+ // baileys resizes the picture with `sharp` or `jimp`; neither is a dependency
368
+ // of ours, so their absence is translated into an actionable error.
369
+ await socket
370
+ .updateProfilePicture(self, typeof patch.photo === 'string' ? { url: patch.photo } : patch.photo)
371
+ .catch((error) => {
372
+ if (/image processing library/i.test(error.message)) {
373
+ throw new Error('ERR_PROFILE_PICTURE_LIB');
374
+ }
375
+ throw error;
376
+ });
377
+ }
378
+ ok = true;
379
+ }
380
+ return ok;
381
+ }
382
+ /**
383
+ * Publica un estado (status broadcast). Con sólo `caption` publica texto; con `content`
384
+ * publica imagen o video (el tipo se deduce del binario) usando `caption` como pie.
385
+ * `contacts` es la audiencia: WhatsApp no reparte el estado a quien no esté en la lista.
386
+ * Publishes a status broadcast. With only `caption` it posts text; with `content` it
387
+ * posts an image or video (type inferred from the binary) using `caption` as its footer.
388
+ * `contacts` is the audience: WhatsApp does not deliver the status to anyone outside it.
389
+ *
390
+ * @param post - Contenido, pie y audiencia / Content, caption and audience
391
+ * @returns Publicación creada, o null si no hay sesión / Created post, or null without a session
392
+ * @throws ERR_FEED_EMPTY sin `content` ni `caption` / when neither `content` nor `caption` is given
393
+ * @throws ERR_FEED_MEDIA si el binario no es imagen ni video / when the binary is neither image nor video
394
+ */
395
+ async feed(post) {
396
+ const socket = this.#internals.socket;
397
+ let result = null;
398
+ if (socket) {
399
+ const audience = (await Promise.all(post.contacts.map((uid) => this.#resolve_jid(String(uid))))).filter((jid) => jid !== null);
400
+ const mime = post.content ? sniff_media(post.content) : null;
401
+ if (post.content && !mime) {
402
+ throw new Error('ERR_FEED_MEDIA');
403
+ }
404
+ if (!post.content && !post.caption) {
405
+ throw new Error('ERR_FEED_EMPTY');
406
+ }
407
+ const kind = mime?.startsWith('video/') ? 'video' : mime ? 'image' : 'text';
408
+ const sent = await socket.sendMessage('status@broadcast', (post.content
409
+ ? { [kind]: post.content, caption: post.caption }
410
+ : { text: post.caption }), { statusJidList: audience });
411
+ if (sent?.key?.id) {
412
+ const created_at = (Number(sent.messageTimestamp) || Math.floor(Date.now() / 1_000)) * 1_000;
413
+ const doc = {
414
+ id: sent.key.id,
415
+ author_jid: jidNormalizedUser(socket.user?.id ?? ''),
416
+ type: kind,
417
+ caption: post.caption ?? '',
418
+ mime: mime ?? 'text/plain',
419
+ created_at,
420
+ expires_at: created_at + FEED_TTL_MS,
421
+ viewed: true,
422
+ raw: sent,
423
+ };
424
+ await this.engine.set(`/status/${doc.id}`, serialize(doc), created_at);
425
+ if (post.content) {
426
+ await this.engine.set(`/status/${doc.id}/content`, serialize({ data: post.content.toString('base64') }));
427
+ }
428
+ result = new Feed(this, doc);
429
+ this.emit('feed:created', result, this);
430
+ }
431
+ }
432
+ return result;
433
+ }
256
434
  /**
257
435
  * Cierra la conexión. Con `destroy: true` vacía el engine completo.
258
436
  * Closes the connection. With `destroy: true` clears the engine entirely.
259
437
  */
260
438
  async disconnect(options = {}) {
261
- this._intentional_close = true;
439
+ this.#intentional_close = true;
440
+ this.#silent_close = options.silent === true;
262
441
  // Cancela cualquier retry programado por un close anterior, para no resucitar
263
442
  // el socket después de una desconexión manual.
264
- if (this._retry_timer) {
265
- clearTimeout(this._retry_timer);
266
- this._retry_timer = null;
443
+ if (this.#retry_timer) {
444
+ clearTimeout(this.#retry_timer);
445
+ this.#retry_timer = null;
267
446
  }
268
- if (this._socket) {
447
+ if (this.#internals.socket) {
269
448
  try {
270
449
  // Pasa un error Boom-like con statusCode=connectionClosed (428) para que
271
450
  // `lastDisconnect.error.output.statusCode` quede explícito en el close
@@ -273,17 +452,16 @@ export class WhatsApp {
273
452
  const intentional = Object.assign(new Error('intentional close'), {
274
453
  output: { statusCode: DisconnectReason.connectionClosed },
275
454
  });
276
- await this._socket.end(intentional);
455
+ await this.#internals.socket.end(intentional);
277
456
  }
278
457
  catch {
279
458
  /* socket may already be closed */
280
459
  }
281
- this._socket = null;
460
+ this.#internals.socket = null;
282
461
  }
283
462
  if (options.destroy) {
284
463
  await this.engine.clear();
285
464
  }
286
- void options.silent;
287
465
  }
288
466
  /**
289
467
  * Conecta los handlers de eventos de baileys: contactos, chats y mensajes.
@@ -291,43 +469,45 @@ export class WhatsApp {
291
469
  *
292
470
  * @internal
293
471
  */
294
- _attach_business_handlers(socket) {
295
- socket.ev.on('messaging-history.set', async ({ chats, contacts, messages }) => {
296
- await this._handle_contacts_upsert(contacts);
297
- await this._handle_chats_upsert(chats);
298
- await this._handle_messages_upsert(messages);
472
+ #attach_business_handlers(socket) {
473
+ socket.ev.on('messaging-history.set', ({ chats, contacts, messages }) => {
474
+ this.#enqueue(async () => {
475
+ await this.#handle_contacts_upsert(contacts);
476
+ await this.#handle_chats_upsert(chats);
477
+ await this.#handle_messages_upsert(messages);
478
+ });
299
479
  });
300
480
  socket.ev.on('contacts.upsert', (contacts) => {
301
- void this._handle_contacts_upsert(contacts);
481
+ this.#enqueue(() => this.#handle_contacts_upsert(contacts));
302
482
  });
303
483
  socket.ev.on('contacts.update', (contacts) => {
304
- void this._handle_contacts_update(contacts);
484
+ this.#enqueue(() => this.#handle_contacts_update(contacts));
305
485
  });
306
486
  socket.ev.on('lid-mapping.update', ({ lid, pn }) => {
307
- void this._handle_lid_mapping(lid, pn);
487
+ this.#enqueue(() => this.#handle_lid_mapping(lid, pn));
308
488
  });
309
489
  socket.ev.on('chats.upsert', (chats) => {
310
- void this._handle_chats_upsert(chats);
490
+ this.#enqueue(() => this.#handle_chats_upsert(chats));
311
491
  });
312
492
  socket.ev.on('chats.update', (chats) => {
313
- void this._handle_chats_update(chats);
493
+ this.#enqueue(() => this.#handle_chats_update(chats));
314
494
  });
315
495
  socket.ev.on('chats.delete', (ids) => {
316
- void this._handle_chats_delete(ids);
496
+ this.#enqueue(() => this.#handle_chats_delete(ids));
317
497
  });
318
498
  socket.ev.on('messages.upsert', ({ messages }) => {
319
- void this._handle_messages_upsert(messages);
499
+ this.#enqueue(() => this.#handle_messages_upsert(messages));
320
500
  });
321
501
  socket.ev.on('messages.update', (updates) => {
322
- void this._handle_messages_update(updates);
502
+ this.#enqueue(() => this.#handle_messages_update(updates));
323
503
  });
324
504
  socket.ev.on('message-receipt.update', (updates) => {
325
- void this._handle_message_receipt(updates);
505
+ this.#enqueue(() => this.#handle_message_receipt(updates));
326
506
  });
327
507
  // Las reacciones llegan duplicadas por `messages.reaction` Y `messages.upsert`
328
508
  // (como `reactionMessage`). Se usa solo `messages.upsert` para evitar el doble disparo.
329
509
  // socket.ev.on('messages.reaction', (reactions) => {
330
- // void this._handle_messages_reaction(reactions);
510
+ // void this.#handle_messages_reaction(reactions);
331
511
  // });
332
512
  }
333
513
  /**
@@ -337,23 +517,23 @@ export class WhatsApp {
337
517
  * @param raw - Documento del contacto a persistir / Contact document to persist
338
518
  * @internal
339
519
  */
340
- async _persist_contact(raw) {
520
+ async #persist_contact(raw) {
341
521
  const existing = await this.engine.get(`/contact/${raw.id}`);
342
522
  await this.engine.set(`/contact/${raw.id}`, serialize(raw));
343
523
  if (raw.lid) {
344
524
  await this.engine.set(`/lid/${raw.lid}`, serialize(raw.id));
345
525
  }
346
526
  if (existing === null) {
527
+ const person = new this.Contact(raw);
347
528
  const cached_chat = deserialize(await this.engine.get(`/chat/${raw.id}`));
348
- const chat_instance = new this.Chat(cached_chat ?? { id: raw.id, name: contact_name(raw) });
349
- this._event.emit('contact:created', new this.Contact(raw, chat_instance), chat_instance, this);
529
+ this.emit('contact:created', person, new this.Chat(cached_chat ?? { id: raw.id, name: person.name }), this);
350
530
  }
351
531
  }
352
532
  /** @internal */
353
- async _handle_contacts_upsert(contacts) {
533
+ async #handle_contacts_upsert(contacts) {
354
534
  for (const c of contacts) {
355
535
  if (c.id) {
356
- await this._persist_contact({
536
+ await this.#persist_contact({
357
537
  id: c.id,
358
538
  lid: c.lid ?? null,
359
539
  name: c.name ?? null,
@@ -366,7 +546,7 @@ export class WhatsApp {
366
546
  }
367
547
  }
368
548
  /** @internal */
369
- async _handle_contacts_update(contacts) {
549
+ async #handle_contacts_update(contacts) {
370
550
  for (const c of contacts) {
371
551
  if (c.id) {
372
552
  const current = deserialize(await this.engine.get(`/contact/${c.id}`));
@@ -384,21 +564,21 @@ export class WhatsApp {
384
564
  if (patch.lid) {
385
565
  await this.engine.set(`/lid/${patch.lid}`, serialize(c.id));
386
566
  }
567
+ const person = new this.Contact(merged);
387
568
  const cached_chat = deserialize(await this.engine.get(`/chat/${c.id}`));
388
- const chat_instance = new this.Chat(cached_chat ?? { id: c.id, name: contact_name(merged) });
389
- this._event.emit('contact:updated', new this.Contact(merged, chat_instance), chat_instance, this);
569
+ this.emit('contact:updated', person, new this.Chat(cached_chat ?? { id: c.id, name: person.name }), this);
390
570
  }
391
571
  }
392
572
  }
393
573
  }
394
574
  }
395
575
  /** @internal */
396
- async _handle_lid_mapping(lid, pn) {
576
+ async #handle_lid_mapping(lid, pn) {
397
577
  await this.engine.set(`/lid/${lid}`, serialize(pn));
398
578
  await this.engine.set(`/lid/${pn}`, serialize(lid));
399
579
  }
400
580
  /** @internal */
401
- async _handle_chats_upsert(chats) {
581
+ async #handle_chats_upsert(chats) {
402
582
  for (const ch of chats) {
403
583
  if (ch.id) {
404
584
  const current = deserialize(await this.engine.get(`/chat/${ch.id}`));
@@ -408,21 +588,19 @@ export class WhatsApp {
408
588
  archived: ch.archived ?? null,
409
589
  pinned: ch.pinned ?? null,
410
590
  mute_end_time: ch.muteEndTime != null ? Number(ch.muteEndTime) : null,
411
- unread_count: ch.unreadCount ?? null,
412
- read_only: ch.readOnly ?? null,
413
591
  };
414
592
  if (ch.name) {
415
593
  raw.name = ch.name;
416
594
  }
417
595
  await this.engine.set(`/chat/${ch.id}`, serialize(raw));
418
596
  if (current === null) {
419
- this._event.emit('chat:created', new this.Chat(raw), this);
597
+ this.emit('chat:created', new this.Chat(raw), this);
420
598
  }
421
599
  }
422
600
  }
423
601
  }
424
602
  /** @internal */
425
- async _handle_chats_update(chats) {
603
+ async #handle_chats_update(chats) {
426
604
  for (const ch of chats) {
427
605
  if (ch.id && ch.id !== 'status@broadcast') {
428
606
  const current = deserialize(await this.engine.get(`/chat/${ch.id}`)) ?? {
@@ -445,36 +623,33 @@ export class WhatsApp {
445
623
  if (mute_changed) {
446
624
  patch.mute_end_time = ch.muteEndTime != null ? Number(ch.muteEndTime) : null;
447
625
  }
448
- if (ch.unreadCount != null) {
449
- patch.unread_count = ch.unreadCount;
450
- }
451
626
  if (Object.keys(patch).length > 0) {
452
627
  const merged = { ...current, ...patch };
453
628
  await this.engine.set(`/chat/${ch.id}`, serialize(merged));
454
629
  if (pinned_changed) {
455
- this._event.emit(ch.pinned != null ? 'chat:pinned' : 'chat:unpinned', new this.Chat(merged), this);
630
+ this.emit(ch.pinned != null ? 'chat:pinned' : 'chat:unpinned', new this.Chat(merged), this);
456
631
  }
457
632
  if (archived_changed) {
458
- this._event.emit(ch.archived ? 'chat:archived' : 'chat:unarchived', new this.Chat(merged), this);
633
+ this.emit(ch.archived ? 'chat:archived' : 'chat:unarchived', new this.Chat(merged), this);
459
634
  }
460
635
  if (mute_changed) {
461
636
  const is_muted = patch.mute_end_time != null && patch.mute_end_time > Date.now();
462
- this._event.emit(is_muted ? 'chat:muted' : 'chat:unmuted', new this.Chat(merged), this);
637
+ this.emit(is_muted ? 'chat:muted' : 'chat:unmuted', new this.Chat(merged), this);
463
638
  }
464
639
  }
465
640
  }
466
641
  }
467
642
  }
468
643
  /** @internal */
469
- async _handle_chats_delete(ids) {
644
+ async #handle_chats_delete(ids) {
470
645
  for (const cid of ids) {
471
646
  const raw = deserialize(await this.engine.get(`/chat/${cid}`)) ?? { id: cid };
472
647
  await this.engine.unset(`/chat/${cid}`);
473
- this._event.emit('chat:deleted', new this.Chat(raw), this);
648
+ this.emit('chat:deleted', new this.Chat(raw), this);
474
649
  }
475
650
  }
476
651
  /** @internal */
477
- async _handle_message_receipt(updates) {
652
+ async #handle_message_receipt(updates) {
478
653
  for (const { key, receipt } of updates) {
479
654
  // Receipt sobre status@broadcast → marca el feed como visto y emite feed:updated.
480
655
  // Receipt on status@broadcast → marks feed viewed and emits feed:updated.
@@ -483,21 +658,27 @@ export class WhatsApp {
483
658
  if (feed_raw && !feed_raw.viewed) {
484
659
  feed_raw.viewed = true;
485
660
  await this.engine.set(`/status/${key.id}`, serialize(feed_raw));
486
- this._event.emit('feed:updated', new Feed({ wa: this, doc: feed_raw }), this);
661
+ this.emit('feed:updated', new Feed(this, feed_raw), this);
487
662
  }
488
663
  continue;
489
664
  }
490
665
  if (key.remoteJid && key.id && (receipt.readTimestamp != null || receipt.playedTimestamp != null)) {
491
666
  const doc = deserialize(await this.engine.get(`/chat/${key.remoteJid}/message/${key.id}`));
492
667
  if (doc) {
493
- const msg_instance = await this.Message.build_instance(doc);
494
- this._event.emit('message:seen', msg_instance, await msg_instance.chat(), this);
668
+ const next = receipt.playedTimestamp != null ? PLAYED : READ;
669
+ if (doc.status < next) {
670
+ doc.status = next;
671
+ doc.raw.status = next;
672
+ await this.engine.set(`/chat/${key.remoteJid}/message/${key.id}`, serialize(doc), doc.created_at);
673
+ }
674
+ const msg_instance = message(this, doc);
675
+ this.emit('message:seen', msg_instance, await msg_instance.chat(), this);
495
676
  }
496
677
  }
497
678
  }
498
679
  }
499
680
  /** @internal */
500
- async _handle_messages_upsert(messages) {
681
+ async #handle_messages_upsert(messages) {
501
682
  for (const msg of messages) {
502
683
  if (msg.key?.remoteJid && msg.key.id) {
503
684
  const cid = msg.key.remoteJidAlt ?? msg.key.remoteJid;
@@ -508,9 +689,13 @@ export class WhatsApp {
508
689
  // Single channel for reactions: handled here; `messages.reaction` is disabled.
509
690
  const reaction = msg.message?.reactionMessage;
510
691
  if (reaction?.key?.id && reaction.key.remoteJid) {
511
- const target_cid = (await this._resolve_jid(reaction.key.remoteJid)) ?? reaction.key.remoteJid;
512
- await this._handle_messages_reaction([{
513
- key: { remoteJid: target_cid, id: reaction.key.id },
692
+ const target_cid = (await this.#resolve_jid(reaction.key.remoteJid)) ?? reaction.key.remoteJid;
693
+ await this.#handle_messages_reaction([{
694
+ key: {
695
+ remoteJid: target_cid,
696
+ id: reaction.key.id,
697
+ participant: msg.key.fromMe ? (this.#internals.socket?.user?.id ?? null) : (msg.key.participant ?? cid),
698
+ },
514
699
  reaction: { text: reaction.text ?? '' },
515
700
  }]);
516
701
  }
@@ -526,7 +711,7 @@ export class WhatsApp {
526
711
  const feed_raw = deserialize(await this.engine.get(`/status/${protocol.key.id}`));
527
712
  if (feed_raw) {
528
713
  await this.engine.unset(`/status/${protocol.key.id}`);
529
- this._event.emit('feed:deleted', new Feed({ wa: this, doc: feed_raw }), this);
714
+ this.emit('feed:deleted', new Feed(this, feed_raw), this);
530
715
  }
531
716
  }
532
717
  continue;
@@ -574,7 +759,7 @@ export class WhatsApp {
574
759
  if (feed_type === 'text') {
575
760
  content_buf = Buffer.from(caption, 'utf-8');
576
761
  }
577
- else if (this._socket) {
762
+ else if (this.#internals.socket) {
578
763
  try {
579
764
  const buf = await downloadMediaMessage(msg, 'buffer', {});
580
765
  if (Buffer.isBuffer(buf)) {
@@ -589,7 +774,7 @@ export class WhatsApp {
589
774
  if (content_buf.length > 0) {
590
775
  await this.engine.set(`/status/${mid}/content`, serialize({ data: content_buf.toString('base64') }));
591
776
  }
592
- this._event.emit('feed:created', new Feed({ wa: this, doc: feed_raw }), this);
777
+ this.emit('feed:created', new Feed(this, feed_raw), this);
593
778
  continue;
594
779
  }
595
780
  if (content_type === 'pollUpdateMessage') {
@@ -599,7 +784,7 @@ export class WhatsApp {
599
784
  creation_key.remoteJid &&
600
785
  update?.vote?.encPayload &&
601
786
  update.vote.encIv) {
602
- const resolved_cid = (await this._resolve_jid(creation_key.remoteJid)) ?? creation_key.remoteJid;
787
+ const resolved_cid = (await this.#resolve_jid(creation_key.remoteJid)) ?? creation_key.remoteJid;
603
788
  const target_mid = creation_key.id;
604
789
  const poll_doc = deserialize(await this.engine.get(`/chat/${resolved_cid}/message/${target_mid}`));
605
790
  const secret_raw = poll_doc?.raw.message?.messageContextInfo?.messageSecret;
@@ -614,8 +799,8 @@ export class WhatsApp {
614
799
  // Own HMAC identity depends on chat addressing (LID on @lid, PN on
615
800
  // @s.whatsapp.net), so fromMe positions try both candidates:
616
801
  // AES-GCM authenticates, a wrong key throws and the next is tried.
617
- const self_id = this._socket?.user?.id ?? '';
618
- const self_lid = this._socket?.user?.lid ?? '';
802
+ const self_id = this.#internals.socket?.user?.id ?? '';
803
+ const self_lid = this.#internals.socket?.user?.lid ?? '';
619
804
  const selves = [...new Set([self_lid, self_id].filter(Boolean))];
620
805
  // Candidatos foráneos: todas las formas de identidad del key (LID,
621
806
  // participant, alt, remoteJid); se prueban todas porque el addressing
@@ -649,9 +834,9 @@ export class WhatsApp {
649
834
  vote: decrypted,
650
835
  senderTimestampMs: Number(msg.messageTimestamp) || Date.now(),
651
836
  });
652
- await this.engine.set(`/chat/${resolved_cid}/message/${target_mid}`, serialize(poll_doc));
653
- const msg_instance = await this.Message.build_instance(poll_doc);
654
- this._event.emit('message:updated', msg_instance, await msg_instance.chat(), this);
837
+ await this.engine.set(`/chat/${resolved_cid}/message/${target_mid}`, serialize(poll_doc), poll_doc.created_at);
838
+ const msg_instance = message(this, poll_doc);
839
+ this.emit('message:updated', msg_instance, await msg_instance.chat(), this);
655
840
  }
656
841
  }
657
842
  catch {
@@ -672,28 +857,49 @@ export class WhatsApp {
672
857
  doc) {
673
858
  doc.raw.message = protocol.editedMessage;
674
859
  doc.edited = true;
675
- doc.caption = this.Message.build_message_doc(doc.raw, this._socket?.user?.id, true).caption;
676
- await this.engine.set(`/chat/${target_cid}/message/${target_mid}`, serialize(doc));
677
- const msg_instance = await this.Message.build_instance(doc);
678
- this._event.emit('message:updated', msg_instance, await msg_instance.chat(), this);
860
+ doc.caption = message(this, doc.raw).caption;
861
+ await this.engine.set(`/chat/${target_cid}/message/${target_mid}`, serialize(doc), doc.created_at);
862
+ const msg_instance = message(this, doc);
863
+ this.emit('message:updated', msg_instance, await msg_instance.chat(), this);
679
864
  }
680
865
  else if (protocol.type === proto.Message.ProtocolMessage.Type.REVOKE) {
681
866
  await this.engine.unset(`/chat/${target_cid}/message/${target_mid}`);
682
867
  if (doc) {
683
- const msg_instance = await this.Message.build_instance(doc);
684
- this._event.emit('message:deleted', msg_instance, await msg_instance.chat(), this);
868
+ const msg_instance = message(this, doc);
869
+ this.emit('message:deleted', msg_instance, await msg_instance.chat(), this);
685
870
  }
686
871
  }
687
872
  }
688
873
  continue;
689
874
  }
690
- const doc = this.Message.build_message_doc(msg, this._socket?.user?.id);
875
+ const doc = message(this, msg)._raw;
876
+ // Cada reconexión re-entrega el historial completo: reescribir documentos
877
+ // idénticos contamina la cronología, re-descarga la media y spamea eventos,
878
+ // así que un doc ya persistido sin cambios visibles se salta entero.
879
+ // Every reconnect re-delivers the full history: rewriting identical documents
880
+ // pollutes chronology, re-downloads media and spams events, so an already
881
+ // persisted doc without visible changes is skipped entirely.
882
+ const existing_doc = deserialize(await this.engine.get(`/chat/${cid}/message/${mid}`));
883
+ if (existing_doc) {
884
+ if (typeof existing_doc.multiple === 'boolean') {
885
+ doc.multiple = existing_doc.multiple;
886
+ }
887
+ if (existing_doc.reactions) {
888
+ doc.reactions = existing_doc.reactions;
889
+ }
890
+ if (existing_doc.status >= doc.status &&
891
+ existing_doc.caption === doc.caption &&
892
+ existing_doc.edited === doc.edited &&
893
+ existing_doc.starred === doc.starred) {
894
+ continue;
895
+ }
896
+ }
691
897
  // Autocreación de contacto/chat desde pushName cuando baileys no emite upsert previo
692
- if (!doc.me) {
898
+ if (!existing_doc && !doc.me) {
693
899
  const push_name = msg.pushName ?? null;
694
900
  const is_group = cid.endsWith('@g.us');
695
901
  if (doc.author && !(await this.engine.get(`/contact/${doc.author}`))) {
696
- await this._persist_contact({
902
+ await this.#persist_contact({
697
903
  id: doc.author,
698
904
  lid: msg.key.remoteJid?.endsWith('@lid') ? msg.key.remoteJid : null,
699
905
  name: null,
@@ -709,20 +915,17 @@ export class WhatsApp {
709
915
  name: is_group ? null : push_name,
710
916
  };
711
917
  await this.engine.set(`/chat/${cid}`, serialize(chat_raw));
712
- this._event.emit('chat:created', new this.Chat(chat_raw), this);
918
+ this.emit('chat:created', new this.Chat(chat_raw), this);
713
919
  }
714
920
  }
715
- // Preservar flags explícitos persistidos por el send (ej: `multiple` en polls
716
- // auto-emitidos, donde el echo de WhatsApp pierde el discriminador del proto).
717
- // Preserve send-time flags (e.g. `multiple` on own polls, since WhatsApp drops
718
- // the proto discriminator on echo).
719
- const existing_doc = deserialize(await this.engine.get(`/chat/${cid}/message/${mid}`));
720
- if (existing_doc && typeof existing_doc.multiple === 'boolean') {
721
- doc.multiple = existing_doc.multiple;
722
- }
723
- await this.engine.set(`/chat/${cid}/message/${mid}`, serialize(doc));
921
+ await this.engine.set(`/chat/${cid}/message/${mid}`, serialize(doc), doc.created_at);
922
+ // El binario solo se materializa en la primera entrega; en re-syncs ya vive en el engine.
923
+ // The binary is only materialized on first delivery; on re-syncs it already lives in the engine.
724
924
  let content_buf = Buffer.alloc(0);
725
- if (doc.type === 'text') {
925
+ if (existing_doc) {
926
+ /* ya materializado / already materialized */
927
+ }
928
+ else if (doc.type === 'text') {
726
929
  content_buf = Buffer.from(doc.caption, 'utf-8');
727
930
  }
728
931
  else if (doc.type === 'location') {
@@ -745,7 +948,7 @@ export class WhatsApp {
745
948
  else if (doc.type === 'event') {
746
949
  content_buf = Buffer.from(JSON.stringify(msg.message?.eventMessage ?? {}), 'utf-8');
747
950
  }
748
- else if (this._socket && ['image', 'video', 'audio', 'document'].includes(doc.type)) {
951
+ else if (this.#internals.socket && ['image', 'video', 'audio', 'document'].includes(doc.type)) {
749
952
  try {
750
953
  const buffer = await downloadMediaMessage(msg, 'buffer', {});
751
954
  if (Buffer.isBuffer(buffer)) {
@@ -759,17 +962,17 @@ export class WhatsApp {
759
962
  if (content_buf.length > 0) {
760
963
  await this.engine.set(`/chat/${cid}/message/${mid}/content`, serialize({ data: content_buf.toString('base64') }));
761
964
  }
762
- const instance = await this.Message.build_instance(doc);
965
+ const instance = message(this, doc);
763
966
  const chat_instance = await instance.chat();
764
- this._event.emit('message:created', instance, chat_instance, this);
967
+ this.emit('message:created', instance, chat_instance, this);
765
968
  if (doc.forwarded) {
766
- this._event.emit('message:forwarded', instance, chat_instance, this);
969
+ this.emit('message:forwarded', instance, chat_instance, this);
767
970
  }
768
971
  }
769
972
  }
770
973
  }
771
974
  /** @internal */
772
- async _handle_messages_update(updates) {
975
+ async #handle_messages_update(updates) {
773
976
  for (const { key, update: upd } of updates) {
774
977
  if (key.remoteJid && key.id) {
775
978
  // Updates sobre `status@broadcast` se descartan: el feed sólo se
@@ -791,42 +994,42 @@ export class WhatsApp {
791
994
  raw.message = edited_message;
792
995
  doc.raw = raw;
793
996
  doc.edited = true;
794
- doc.caption = this.Message.build_message_doc(raw, this._socket?.user?.id, true).caption;
795
- await this.engine.set(`/chat/${key.remoteJid}/message/${key.id}`, serialize(doc));
796
- const msg_instance = await this.Message.build_instance(doc);
797
- this._event.emit('message:updated', msg_instance, await msg_instance.chat(), this);
997
+ doc.caption = message(this, raw).caption;
998
+ await this.engine.set(`/chat/${key.remoteJid}/message/${key.id}`, serialize(doc), doc.created_at);
999
+ const msg_instance = message(this, doc);
1000
+ this.emit('message:updated', msg_instance, await msg_instance.chat(), this);
798
1001
  }
799
1002
  else if (content_update) {
800
1003
  // Actualización de contenido (ej: live location). Mergea sobre el raw existente.
801
1004
  raw.message = { ...raw.message, ...content_update };
802
1005
  doc.raw = raw;
803
- doc.caption = this.Message.build_message_doc(raw, this._socket?.user?.id, doc.edited).caption;
804
- await this.engine.set(`/chat/${key.remoteJid}/message/${key.id}`, serialize(doc));
805
- const msg_instance = await this.Message.build_instance(doc);
806
- this._event.emit('message:updated', msg_instance, await msg_instance.chat(), this);
1006
+ doc.caption = message(this, raw).caption;
1007
+ await this.engine.set(`/chat/${key.remoteJid}/message/${key.id}`, serialize(doc), doc.created_at);
1008
+ const msg_instance = message(this, doc);
1009
+ this.emit('message:updated', msg_instance, await msg_instance.chat(), this);
807
1010
  }
808
1011
  else if (starred_changed) {
809
1012
  doc.starred = upd_any.starred === true;
810
1013
  raw.starred = doc.starred;
811
1014
  doc.raw = raw;
812
- await this.engine.set(`/chat/${key.remoteJid}/message/${key.id}`, serialize(doc));
813
- const msg_instance = await this.Message.build_instance(doc);
814
- this._event.emit(doc.starred ? 'message:starred' : 'message:unstarred', msg_instance, await msg_instance.chat(), this);
1015
+ await this.engine.set(`/chat/${key.remoteJid}/message/${key.id}`, serialize(doc), doc.created_at);
1016
+ const msg_instance = message(this, doc);
1017
+ this.emit(doc.starred ? 'message:starred' : 'message:unstarred', msg_instance, await msg_instance.chat(), this);
815
1018
  }
816
1019
  else if (status !== undefined) {
817
1020
  raw.status = status;
818
1021
  doc.status = status;
819
1022
  doc.raw = raw;
820
- await this.engine.set(`/chat/${key.remoteJid}/message/${key.id}`, serialize(doc));
821
- const msg_instance = await this.Message.build_instance(doc);
822
- this._event.emit('message:updated', msg_instance, await msg_instance.chat(), this);
1023
+ await this.engine.set(`/chat/${key.remoteJid}/message/${key.id}`, serialize(doc), doc.created_at);
1024
+ const msg_instance = message(this, doc);
1025
+ this.emit('message:updated', msg_instance, await msg_instance.chat(), this);
823
1026
  }
824
1027
  }
825
1028
  }
826
1029
  }
827
1030
  }
828
1031
  /** @internal */
829
- async _handle_messages_reaction(reactions) {
1032
+ async #handle_messages_reaction(reactions) {
830
1033
  for (const { key, reaction } of reactions) {
831
1034
  if (key.remoteJid && key.id) {
832
1035
  // Reacciones sobre status@broadcast → feed:updated (no message:reacted).
@@ -834,14 +1037,21 @@ export class WhatsApp {
834
1037
  if (key.remoteJid === 'status@broadcast') {
835
1038
  const feed_raw = deserialize(await this.engine.get(`/status/${key.id}`));
836
1039
  if (feed_raw) {
837
- this._event.emit('feed:updated', new Feed({ wa: this, doc: feed_raw }), this);
1040
+ this.emit('feed:updated', new Feed(this, feed_raw), this);
838
1041
  }
839
1042
  continue;
840
1043
  }
841
1044
  const doc = deserialize(await this.engine.get(`/chat/${key.remoteJid}/message/${key.id}`));
842
1045
  if (doc) {
843
- const msg_instance = await this.Message.build_instance(doc);
844
- this._event.emit('message:reacted', msg_instance, await msg_instance.chat(), reaction.text ?? '', this);
1046
+ const reactor = jidNormalizedUser(key.participant ?? key.remoteJid);
1047
+ const emoji = reaction.text ?? '';
1048
+ doc.reactions = [
1049
+ ...(doc.reactions ?? []).filter((r) => r.author !== reactor),
1050
+ ...(emoji ? [{ author: reactor, emoji, at: Date.now() }] : []),
1051
+ ];
1052
+ await this.engine.set(`/chat/${key.remoteJid}/message/${key.id}`, serialize(doc), doc.created_at);
1053
+ const msg_instance = message(this, doc);
1054
+ this.emit('message:reacted', msg_instance, await msg_instance.chat(), reaction.text ?? '', this);
845
1055
  }
846
1056
  }
847
1057
  }