@arcaelas/whatsapp 4.4.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 (97) 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 +463 -278
  17. package/build/cjs/lib/message/index.js +830 -710
  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 +117 -63
  44. package/build/cjs/lib/whatsapp/index.js +404 -184
  45. package/build/cjs/lib/whatsapp/index.js.map +1 -1
  46. package/build/cjs/test.d.ts +1 -0
  47. package/build/cjs/test.js +72 -0
  48. package/build/cjs/test.js.map +1 -0
  49. package/build/esm/index.d.ts +6 -4
  50. package/build/esm/index.js +5 -2
  51. package/build/esm/index.js.map +1 -1
  52. package/build/esm/lib/bot/decorator.d.ts +2 -0
  53. package/build/esm/lib/bot/decorator.js +6 -0
  54. package/build/esm/lib/bot/decorator.js.map +1 -1
  55. package/build/esm/lib/chat/index.d.ts +340 -204
  56. package/build/esm/lib/chat/index.js +225 -213
  57. package/build/esm/lib/chat/index.js.map +1 -1
  58. package/build/esm/lib/contact/index.d.ts +162 -179
  59. package/build/esm/lib/contact/index.js +96 -189
  60. package/build/esm/lib/contact/index.js.map +1 -1
  61. package/build/esm/lib/internal.d.ts +40 -0
  62. package/build/esm/lib/internal.js +35 -0
  63. package/build/esm/lib/internal.js.map +1 -0
  64. package/build/esm/lib/message/index.d.ts +463 -278
  65. package/build/esm/lib/message/index.js +825 -705
  66. package/build/esm/lib/message/index.js.map +1 -1
  67. package/build/esm/lib/status/index.d.ts +60 -70
  68. package/build/esm/lib/status/index.js +105 -110
  69. package/build/esm/lib/status/index.js.map +1 -1
  70. package/build/esm/lib/store/engine/index.d.ts +21 -7
  71. package/build/esm/lib/store/engine/index.js +4 -2
  72. package/build/esm/lib/store/engine/index.js.map +1 -1
  73. package/build/esm/lib/store/engine/lib/file_system/index.d.ts +38 -8
  74. package/build/esm/lib/store/engine/lib/file_system/index.js +92 -67
  75. package/build/esm/lib/store/engine/lib/file_system/index.js.map +1 -1
  76. package/build/esm/lib/store/engine/lib/index.d.ts +112 -0
  77. package/build/esm/lib/store/engine/lib/index.js +206 -0
  78. package/build/esm/lib/store/engine/lib/index.js.map +1 -0
  79. package/build/esm/lib/store/engine/lib/redis/index.d.ts +28 -13
  80. package/build/esm/lib/store/engine/lib/redis/index.js +54 -40
  81. package/build/esm/lib/store/engine/lib/redis/index.js.map +1 -1
  82. package/build/esm/lib/store/engine/lib/s3/index.d.ts +44 -1
  83. package/build/esm/lib/store/engine/lib/s3/index.js +111 -63
  84. package/build/esm/lib/store/engine/lib/s3/index.js.map +1 -1
  85. package/build/esm/lib/store/engine/lib/sqlite/index.d.ts +98 -0
  86. package/build/esm/lib/store/engine/lib/sqlite/index.js +110 -0
  87. package/build/esm/lib/store/engine/lib/sqlite/index.js.map +1 -0
  88. package/build/esm/lib/store/index.d.ts +7 -3
  89. package/build/esm/lib/store/index.js +13 -4
  90. package/build/esm/lib/store/index.js.map +1 -1
  91. package/build/esm/lib/whatsapp/index.d.ts +117 -63
  92. package/build/esm/lib/whatsapp/index.js +406 -186
  93. package/build/esm/lib/whatsapp/index.js.map +1 -1
  94. package/build/esm/test.d.ts +1 -0
  95. package/build/esm/test.js +67 -0
  96. package/build/esm/test.js.map +1 -0
  97. 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,54 +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._autoclean = options.autoclean ?? true;
36
- this._sync = options.sync ?? false;
37
- this._reconnect = this._parse_reconnect(options.reconnect);
38
- 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);
39
82
  this.Contact = contact(this);
40
83
  this.Chat = chat(this);
41
- 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
+ };
42
105
  }
43
- /** @internal */
44
- _parse_reconnect(option) {
45
- if (option === false) {
46
- return { max: 0, interval_ms: 60_000 };
47
- }
48
- if (option === undefined || option === true) {
49
- return { max: null, interval_ms: 60_000 };
50
- }
51
- if (typeof option === 'number') {
52
- 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 });
53
115
  }
54
- return {
55
- max: option.max ?? null,
56
- interval_ms: (option.interval ?? 60) * 1000,
57
- };
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);
58
134
  }
59
135
  /**
60
136
  * Registra un listener de evento. Retorna función para desuscribirse.
61
137
  * Registers an event listener. Returns an unsubscribe function.
62
138
  */
63
139
  on(event, handler) {
64
- this._event.on(event, handler);
65
- return () => { this._event.off(event, handler); };
140
+ this.#event.on(event, handler);
141
+ return () => { this.#event.off(event, handler); };
66
142
  }
67
143
  /**
68
144
  * Quita un listener previamente registrado.
69
145
  * Removes a previously registered listener.
70
146
  */
71
147
  off(event, handler) {
72
- this._event.off(event, handler);
148
+ this.#event.off(event, handler);
73
149
  return this;
74
150
  }
75
151
  /**
@@ -77,15 +153,17 @@ export class WhatsApp {
77
153
  * Registers a one-shot listener. Returns an unsubscribe function.
78
154
  */
79
155
  once(event, handler) {
80
- this._event.once(event, handler);
81
- return () => { this._event.off(event, handler); };
156
+ this.#event.once(event, handler);
157
+ return () => { this.#event.off(event, handler); };
82
158
  }
83
159
  /**
84
160
  * @internal
85
- * Normaliza cualquier identificador (JID, LID, número, etc.) a JID canónico.
86
- * 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.
87
165
  */
88
- async _resolve_jid(uid) {
166
+ async #resolve_jid(uid) {
89
167
  let result = null;
90
168
  if (uid.endsWith('@g.us') || uid.endsWith('@s.whatsapp.net')) {
91
169
  result = uid;
@@ -100,6 +178,18 @@ export class WhatsApp {
100
178
  if (reverse != null) {
101
179
  result = `${reverse}@s.whatsapp.net`;
102
180
  }
181
+ else {
182
+ // El store local puede no tener el mapping (sesión sin upsert del contacto);
183
+ // baileys lo conoce vía su lidMapping. Sin esto, un chat referenciado por @lid
184
+ // (p.ej. el pollCreationMessageKey de un voto entrante) no resuelve al PN donde
185
+ // realmente está guardado, y el mensaje/poll no se encuentra.
186
+ const pn = await this.#internals.socket?.signalRepository?.lidMapping?.getPNForLID(uid).catch(() => null);
187
+ if (pn) {
188
+ // getPNForLID puede traer sufijo de dispositivo (`:0`); se normaliza para
189
+ // que el JID coincida con el que usa el store (sin device).
190
+ result = jidNormalizedUser(pn.includes('@') ? pn : `${pn}@s.whatsapp.net`);
191
+ }
192
+ }
103
193
  }
104
194
  }
105
195
  else {
@@ -118,16 +208,20 @@ export class WhatsApp {
118
208
  * Resolves once the session is synced; retries on non-loggedOut disconnects.
119
209
  */
120
210
  async connect(callback) {
211
+ if (this.#internals.socket) {
212
+ await this.disconnect({ silent: true });
213
+ }
121
214
  const { version } = await fetchLatestBaileysVersion();
122
- this._intentional_close = false;
123
- this._has_connected = false;
215
+ this.#intentional_close = false;
216
+ this.#silent_close = false;
217
+ this.#has_connected = false;
124
218
  return new Promise((resolve, reject) => {
125
219
  const start = async () => {
126
220
  // Re-lee creds en cada start() para que limpiezas del engine tomen efecto
127
221
  // en reintentos (permite al consumer forzar nueva sesión borrando /session/creds).
128
222
  const stored = await this.engine.get('/session/creds');
129
223
  const creds = deserialize(stored) ?? initAuthCreds();
130
- this._socket = makeWASocket({
224
+ this.#internals.socket = makeWASocket({
131
225
  version,
132
226
  auth: {
133
227
  creds,
@@ -154,17 +248,19 @@ export class WhatsApp {
154
248
  },
155
249
  browser: Browsers.windows('Chrome'),
156
250
  logger: pino({ level: 'silent' }),
157
- syncFullHistory: this._sync,
158
- // rc13 cambió el default de `shouldSyncHistoryMessage` a
159
- // `({ syncType }) => syncType !== FULL` (independiente de `syncFullHistory`).
160
- // Replicamos el comportamiento previo (rc.9), donde el sync de historial
161
- // dependía únicamente de `sync`.
162
- // rc13 changed the `shouldSyncHistoryMessage` default; we keep the rc.9
163
- // behavior where history sync depended solely on `sync`.
164
- shouldSyncHistoryMessage: () => this._sync,
251
+ syncFullHistory: this.#sync,
252
+ // Los syncs no-FULL cargan las LID mappings y los tctokens (trusted-contact
253
+ // tokens) que rc13 exige para enviar: apagarlos todos con `() => this.#sync`
254
+ // deja la sesión sin tctoken y el server rechaza los mensajes con
255
+ // "error 463: account restricted or missing tctoken". Por eso se procesan
256
+ // siempre los no-FULL; `sync` solo decide si además se trae el historial FULL.
257
+ // Non-FULL syncs carry the LID mappings and tctokens rc13 requires to send;
258
+ // disabling them all made the server reject messages with error 463. We always
259
+ // process non-FULL; `sync` only gates whether FULL history is pulled too.
260
+ shouldSyncHistoryMessage: ({ syncType }) => this.#sync || syncType !== proto.HistorySync.HistorySyncType.FULL,
165
261
  markOnlineOnConnect: false,
166
262
  });
167
- const socket = this._socket;
263
+ const socket = this.#internals.socket;
168
264
  socket.ev.on('creds.update', () => this.engine.set('/session/creds', serialize(creds)));
169
265
  socket.ev.on('connection.update', async (update) => {
170
266
  const { connection, lastDisconnect, qr } = update;
@@ -172,21 +268,25 @@ export class WhatsApp {
172
268
  // Baileys refresca QR periódicamente (~20s). Emitimos nuevo pair
173
269
  // code / QR en cada refresh para que el usuario pueda renovar si
174
270
  // el anterior expiró.
175
- if (this._phone) {
176
- 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));
177
277
  }
178
278
  else {
179
279
  await callback(await QRCode.toBuffer(qr, { type: 'png', margin: 2 }));
180
280
  }
181
281
  }
182
282
  if (connection === 'open') {
183
- this._has_connected = true;
184
- this._retry_count = 0;
185
- this._event.emit('connected', this);
283
+ this.#has_connected = true;
284
+ this.#retry_count = 0;
285
+ this.emit('connected', this);
186
286
  resolve();
187
287
  }
188
288
  else if (connection === 'close') {
189
- this._socket = null;
289
+ this.#internals.socket = null;
190
290
  const status_code = lastDisconnect?.error?.output?.statusCode;
191
291
  // `restartRequired` (515) es una reconexión exigida por el protocolo
192
292
  // tras el sync inicial — no es un disconnect "real".
@@ -194,35 +294,37 @@ export class WhatsApp {
194
294
  // Limpieza del engine ANTES de emitir `disconnected` para que los
195
295
  // listeners vean el estado final (engine vaciado o creds borradas).
196
296
  if (status_code === DisconnectReason.loggedOut) {
197
- if (this._autoclean) {
297
+ if (this.#autoclean) {
198
298
  await this.engine.clear();
199
299
  }
200
300
  else {
201
301
  await this.engine.unset('/session/creds');
202
302
  }
203
303
  }
204
- if (this._has_connected && !is_transient) {
205
- 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);
206
308
  }
207
- if (!this._intentional_close) {
309
+ if (!this.#intentional_close) {
208
310
  if (status_code === DisconnectReason.loggedOut) {
209
311
  reject(new Error('Logged out'));
210
312
  }
211
313
  else {
212
- const max = this._reconnect.max;
314
+ const max = this.#reconnect.max;
213
315
  // Transient closes (restartRequired) son parte del protocolo,
214
316
  // no cuentan contra el límite de reintentos por fallo.
215
- const exhausted = !is_transient && max !== null && this._retry_count >= max;
317
+ const exhausted = !is_transient && max !== null && this.#retry_count >= max;
216
318
  if (exhausted) {
217
319
  reject(new Error(`Reconnect attempts exhausted (${max})`));
218
320
  }
219
321
  else {
220
322
  if (!is_transient) {
221
- this._retry_count++;
323
+ this.#retry_count++;
222
324
  }
223
- const delay = is_transient ? 0 : this._reconnect.interval_ms;
224
- this._retry_timer = setTimeout(() => {
225
- this._retry_timer = null;
325
+ const delay = is_transient ? 0 : this.#reconnect.interval_ms;
326
+ this.#retry_timer = setTimeout(() => {
327
+ this.#retry_timer = null;
226
328
  start().catch(reject);
227
329
  }, delay);
228
330
  }
@@ -230,24 +332,119 @@ export class WhatsApp {
230
332
  }
231
333
  }
232
334
  });
233
- this._attach_business_handlers(socket);
335
+ this.#attach_business_handlers(socket);
234
336
  };
235
337
  start().catch(reject);
236
338
  });
237
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
+ }
238
434
  /**
239
435
  * Cierra la conexión. Con `destroy: true` vacía el engine completo.
240
436
  * Closes the connection. With `destroy: true` clears the engine entirely.
241
437
  */
242
438
  async disconnect(options = {}) {
243
- this._intentional_close = true;
439
+ this.#intentional_close = true;
440
+ this.#silent_close = options.silent === true;
244
441
  // Cancela cualquier retry programado por un close anterior, para no resucitar
245
442
  // el socket después de una desconexión manual.
246
- if (this._retry_timer) {
247
- clearTimeout(this._retry_timer);
248
- this._retry_timer = null;
443
+ if (this.#retry_timer) {
444
+ clearTimeout(this.#retry_timer);
445
+ this.#retry_timer = null;
249
446
  }
250
- if (this._socket) {
447
+ if (this.#internals.socket) {
251
448
  try {
252
449
  // Pasa un error Boom-like con statusCode=connectionClosed (428) para que
253
450
  // `lastDisconnect.error.output.statusCode` quede explícito en el close
@@ -255,17 +452,16 @@ export class WhatsApp {
255
452
  const intentional = Object.assign(new Error('intentional close'), {
256
453
  output: { statusCode: DisconnectReason.connectionClosed },
257
454
  });
258
- await this._socket.end(intentional);
455
+ await this.#internals.socket.end(intentional);
259
456
  }
260
457
  catch {
261
458
  /* socket may already be closed */
262
459
  }
263
- this._socket = null;
460
+ this.#internals.socket = null;
264
461
  }
265
462
  if (options.destroy) {
266
463
  await this.engine.clear();
267
464
  }
268
- void options.silent;
269
465
  }
270
466
  /**
271
467
  * Conecta los handlers de eventos de baileys: contactos, chats y mensajes.
@@ -273,43 +469,45 @@ export class WhatsApp {
273
469
  *
274
470
  * @internal
275
471
  */
276
- _attach_business_handlers(socket) {
277
- socket.ev.on('messaging-history.set', async ({ chats, contacts, messages }) => {
278
- await this._handle_contacts_upsert(contacts);
279
- await this._handle_chats_upsert(chats);
280
- 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
+ });
281
479
  });
282
480
  socket.ev.on('contacts.upsert', (contacts) => {
283
- void this._handle_contacts_upsert(contacts);
481
+ this.#enqueue(() => this.#handle_contacts_upsert(contacts));
284
482
  });
285
483
  socket.ev.on('contacts.update', (contacts) => {
286
- void this._handle_contacts_update(contacts);
484
+ this.#enqueue(() => this.#handle_contacts_update(contacts));
287
485
  });
288
486
  socket.ev.on('lid-mapping.update', ({ lid, pn }) => {
289
- void this._handle_lid_mapping(lid, pn);
487
+ this.#enqueue(() => this.#handle_lid_mapping(lid, pn));
290
488
  });
291
489
  socket.ev.on('chats.upsert', (chats) => {
292
- void this._handle_chats_upsert(chats);
490
+ this.#enqueue(() => this.#handle_chats_upsert(chats));
293
491
  });
294
492
  socket.ev.on('chats.update', (chats) => {
295
- void this._handle_chats_update(chats);
493
+ this.#enqueue(() => this.#handle_chats_update(chats));
296
494
  });
297
495
  socket.ev.on('chats.delete', (ids) => {
298
- void this._handle_chats_delete(ids);
496
+ this.#enqueue(() => this.#handle_chats_delete(ids));
299
497
  });
300
498
  socket.ev.on('messages.upsert', ({ messages }) => {
301
- void this._handle_messages_upsert(messages);
499
+ this.#enqueue(() => this.#handle_messages_upsert(messages));
302
500
  });
303
501
  socket.ev.on('messages.update', (updates) => {
304
- void this._handle_messages_update(updates);
502
+ this.#enqueue(() => this.#handle_messages_update(updates));
305
503
  });
306
504
  socket.ev.on('message-receipt.update', (updates) => {
307
- void this._handle_message_receipt(updates);
505
+ this.#enqueue(() => this.#handle_message_receipt(updates));
308
506
  });
309
507
  // Las reacciones llegan duplicadas por `messages.reaction` Y `messages.upsert`
310
508
  // (como `reactionMessage`). Se usa solo `messages.upsert` para evitar el doble disparo.
311
509
  // socket.ev.on('messages.reaction', (reactions) => {
312
- // void this._handle_messages_reaction(reactions);
510
+ // void this.#handle_messages_reaction(reactions);
313
511
  // });
314
512
  }
315
513
  /**
@@ -319,23 +517,23 @@ export class WhatsApp {
319
517
  * @param raw - Documento del contacto a persistir / Contact document to persist
320
518
  * @internal
321
519
  */
322
- async _persist_contact(raw) {
520
+ async #persist_contact(raw) {
323
521
  const existing = await this.engine.get(`/contact/${raw.id}`);
324
522
  await this.engine.set(`/contact/${raw.id}`, serialize(raw));
325
523
  if (raw.lid) {
326
524
  await this.engine.set(`/lid/${raw.lid}`, serialize(raw.id));
327
525
  }
328
526
  if (existing === null) {
527
+ const person = new this.Contact(raw);
329
528
  const cached_chat = deserialize(await this.engine.get(`/chat/${raw.id}`));
330
- const chat_instance = new this.Chat(cached_chat ?? { id: raw.id, name: contact_name(raw) });
331
- 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);
332
530
  }
333
531
  }
334
532
  /** @internal */
335
- async _handle_contacts_upsert(contacts) {
533
+ async #handle_contacts_upsert(contacts) {
336
534
  for (const c of contacts) {
337
535
  if (c.id) {
338
- await this._persist_contact({
536
+ await this.#persist_contact({
339
537
  id: c.id,
340
538
  lid: c.lid ?? null,
341
539
  name: c.name ?? null,
@@ -348,7 +546,7 @@ export class WhatsApp {
348
546
  }
349
547
  }
350
548
  /** @internal */
351
- async _handle_contacts_update(contacts) {
549
+ async #handle_contacts_update(contacts) {
352
550
  for (const c of contacts) {
353
551
  if (c.id) {
354
552
  const current = deserialize(await this.engine.get(`/contact/${c.id}`));
@@ -366,21 +564,21 @@ export class WhatsApp {
366
564
  if (patch.lid) {
367
565
  await this.engine.set(`/lid/${patch.lid}`, serialize(c.id));
368
566
  }
567
+ const person = new this.Contact(merged);
369
568
  const cached_chat = deserialize(await this.engine.get(`/chat/${c.id}`));
370
- const chat_instance = new this.Chat(cached_chat ?? { id: c.id, name: contact_name(merged) });
371
- 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);
372
570
  }
373
571
  }
374
572
  }
375
573
  }
376
574
  }
377
575
  /** @internal */
378
- async _handle_lid_mapping(lid, pn) {
576
+ async #handle_lid_mapping(lid, pn) {
379
577
  await this.engine.set(`/lid/${lid}`, serialize(pn));
380
578
  await this.engine.set(`/lid/${pn}`, serialize(lid));
381
579
  }
382
580
  /** @internal */
383
- async _handle_chats_upsert(chats) {
581
+ async #handle_chats_upsert(chats) {
384
582
  for (const ch of chats) {
385
583
  if (ch.id) {
386
584
  const current = deserialize(await this.engine.get(`/chat/${ch.id}`));
@@ -390,21 +588,19 @@ export class WhatsApp {
390
588
  archived: ch.archived ?? null,
391
589
  pinned: ch.pinned ?? null,
392
590
  mute_end_time: ch.muteEndTime != null ? Number(ch.muteEndTime) : null,
393
- unread_count: ch.unreadCount ?? null,
394
- read_only: ch.readOnly ?? null,
395
591
  };
396
592
  if (ch.name) {
397
593
  raw.name = ch.name;
398
594
  }
399
595
  await this.engine.set(`/chat/${ch.id}`, serialize(raw));
400
596
  if (current === null) {
401
- this._event.emit('chat:created', new this.Chat(raw), this);
597
+ this.emit('chat:created', new this.Chat(raw), this);
402
598
  }
403
599
  }
404
600
  }
405
601
  }
406
602
  /** @internal */
407
- async _handle_chats_update(chats) {
603
+ async #handle_chats_update(chats) {
408
604
  for (const ch of chats) {
409
605
  if (ch.id && ch.id !== 'status@broadcast') {
410
606
  const current = deserialize(await this.engine.get(`/chat/${ch.id}`)) ?? {
@@ -427,36 +623,33 @@ export class WhatsApp {
427
623
  if (mute_changed) {
428
624
  patch.mute_end_time = ch.muteEndTime != null ? Number(ch.muteEndTime) : null;
429
625
  }
430
- if (ch.unreadCount != null) {
431
- patch.unread_count = ch.unreadCount;
432
- }
433
626
  if (Object.keys(patch).length > 0) {
434
627
  const merged = { ...current, ...patch };
435
628
  await this.engine.set(`/chat/${ch.id}`, serialize(merged));
436
629
  if (pinned_changed) {
437
- 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);
438
631
  }
439
632
  if (archived_changed) {
440
- 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);
441
634
  }
442
635
  if (mute_changed) {
443
636
  const is_muted = patch.mute_end_time != null && patch.mute_end_time > Date.now();
444
- 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);
445
638
  }
446
639
  }
447
640
  }
448
641
  }
449
642
  }
450
643
  /** @internal */
451
- async _handle_chats_delete(ids) {
644
+ async #handle_chats_delete(ids) {
452
645
  for (const cid of ids) {
453
646
  const raw = deserialize(await this.engine.get(`/chat/${cid}`)) ?? { id: cid };
454
647
  await this.engine.unset(`/chat/${cid}`);
455
- this._event.emit('chat:deleted', new this.Chat(raw), this);
648
+ this.emit('chat:deleted', new this.Chat(raw), this);
456
649
  }
457
650
  }
458
651
  /** @internal */
459
- async _handle_message_receipt(updates) {
652
+ async #handle_message_receipt(updates) {
460
653
  for (const { key, receipt } of updates) {
461
654
  // Receipt sobre status@broadcast → marca el feed como visto y emite feed:updated.
462
655
  // Receipt on status@broadcast → marks feed viewed and emits feed:updated.
@@ -465,21 +658,27 @@ export class WhatsApp {
465
658
  if (feed_raw && !feed_raw.viewed) {
466
659
  feed_raw.viewed = true;
467
660
  await this.engine.set(`/status/${key.id}`, serialize(feed_raw));
468
- this._event.emit('feed:updated', new Feed({ wa: this, doc: feed_raw }), this);
661
+ this.emit('feed:updated', new Feed(this, feed_raw), this);
469
662
  }
470
663
  continue;
471
664
  }
472
665
  if (key.remoteJid && key.id && (receipt.readTimestamp != null || receipt.playedTimestamp != null)) {
473
666
  const doc = deserialize(await this.engine.get(`/chat/${key.remoteJid}/message/${key.id}`));
474
667
  if (doc) {
475
- const msg_instance = await this.Message.build_instance(doc);
476
- 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);
477
676
  }
478
677
  }
479
678
  }
480
679
  }
481
680
  /** @internal */
482
- async _handle_messages_upsert(messages) {
681
+ async #handle_messages_upsert(messages) {
483
682
  for (const msg of messages) {
484
683
  if (msg.key?.remoteJid && msg.key.id) {
485
684
  const cid = msg.key.remoteJidAlt ?? msg.key.remoteJid;
@@ -490,9 +689,13 @@ export class WhatsApp {
490
689
  // Single channel for reactions: handled here; `messages.reaction` is disabled.
491
690
  const reaction = msg.message?.reactionMessage;
492
691
  if (reaction?.key?.id && reaction.key.remoteJid) {
493
- const target_cid = (await this._resolve_jid(reaction.key.remoteJid)) ?? reaction.key.remoteJid;
494
- await this._handle_messages_reaction([{
495
- 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
+ },
496
699
  reaction: { text: reaction.text ?? '' },
497
700
  }]);
498
701
  }
@@ -508,7 +711,7 @@ export class WhatsApp {
508
711
  const feed_raw = deserialize(await this.engine.get(`/status/${protocol.key.id}`));
509
712
  if (feed_raw) {
510
713
  await this.engine.unset(`/status/${protocol.key.id}`);
511
- this._event.emit('feed:deleted', new Feed({ wa: this, doc: feed_raw }), this);
714
+ this.emit('feed:deleted', new Feed(this, feed_raw), this);
512
715
  }
513
716
  }
514
717
  continue;
@@ -556,7 +759,7 @@ export class WhatsApp {
556
759
  if (feed_type === 'text') {
557
760
  content_buf = Buffer.from(caption, 'utf-8');
558
761
  }
559
- else if (this._socket) {
762
+ else if (this.#internals.socket) {
560
763
  try {
561
764
  const buf = await downloadMediaMessage(msg, 'buffer', {});
562
765
  if (Buffer.isBuffer(buf)) {
@@ -571,7 +774,7 @@ export class WhatsApp {
571
774
  if (content_buf.length > 0) {
572
775
  await this.engine.set(`/status/${mid}/content`, serialize({ data: content_buf.toString('base64') }));
573
776
  }
574
- this._event.emit('feed:created', new Feed({ wa: this, doc: feed_raw }), this);
777
+ this.emit('feed:created', new Feed(this, feed_raw), this);
575
778
  continue;
576
779
  }
577
780
  if (content_type === 'pollUpdateMessage') {
@@ -581,7 +784,7 @@ export class WhatsApp {
581
784
  creation_key.remoteJid &&
582
785
  update?.vote?.encPayload &&
583
786
  update.vote.encIv) {
584
- 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;
585
788
  const target_mid = creation_key.id;
586
789
  const poll_doc = deserialize(await this.engine.get(`/chat/${resolved_cid}/message/${target_mid}`));
587
790
  const secret_raw = poll_doc?.raw.message?.messageContextInfo?.messageSecret;
@@ -596,23 +799,15 @@ export class WhatsApp {
596
799
  // Own HMAC identity depends on chat addressing (LID on @lid, PN on
597
800
  // @s.whatsapp.net), so fromMe positions try both candidates:
598
801
  // AES-GCM authenticates, a wrong key throws and the next is tried.
599
- const self_id = this._socket?.user?.id ?? '';
600
- 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 ?? '';
601
804
  const selves = [...new Set([self_lid, self_id].filter(Boolean))];
602
- const foreign_voter = msg.key.remoteJid?.endsWith('@lid')
603
- ? msg.key.remoteJid
604
- : msg.key.participant ||
605
- msg.key.remoteJidAlt ||
606
- msg.key.remoteJid ||
607
- '';
608
- const foreign_creator = poll_key.remoteJid?.endsWith('@lid')
609
- ? poll_key.remoteJid
610
- : poll_key.participant ||
611
- poll_key.remoteJidAlt ||
612
- poll_key.remoteJid ||
613
- '';
614
- const voters = msg.key.fromMe ? selves : [foreign_voter];
615
- const creators = poll_key.fromMe ? selves : [foreign_creator];
805
+ // Candidatos foráneos: todas las formas de identidad del key (LID,
806
+ // participant, alt, remoteJid); se prueban todas porque el addressing
807
+ // del stanza varía (LID vs PN) según la migración del contacto.
808
+ const foreign_of = (k) => [...new Set([k.remoteJid, k.participant, k.remoteJidAlt, k.remoteJid].filter((x) => Boolean(x)))];
809
+ const voters = msg.key.fromMe ? selves : foreign_of(msg.key);
810
+ const creators = poll_key.fromMe ? selves : foreign_of(poll_key);
616
811
  let decrypted = null;
617
812
  for (const voter of voters) {
618
813
  for (const creator of creators) {
@@ -639,9 +834,9 @@ export class WhatsApp {
639
834
  vote: decrypted,
640
835
  senderTimestampMs: Number(msg.messageTimestamp) || Date.now(),
641
836
  });
642
- await this.engine.set(`/chat/${resolved_cid}/message/${target_mid}`, serialize(poll_doc));
643
- const msg_instance = await this.Message.build_instance(poll_doc);
644
- 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);
645
840
  }
646
841
  }
647
842
  catch {
@@ -662,28 +857,49 @@ export class WhatsApp {
662
857
  doc) {
663
858
  doc.raw.message = protocol.editedMessage;
664
859
  doc.edited = true;
665
- doc.caption = this.Message.build_message_doc(doc.raw, this._socket?.user?.id, true).caption;
666
- await this.engine.set(`/chat/${target_cid}/message/${target_mid}`, serialize(doc));
667
- const msg_instance = await this.Message.build_instance(doc);
668
- 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);
669
864
  }
670
865
  else if (protocol.type === proto.Message.ProtocolMessage.Type.REVOKE) {
671
866
  await this.engine.unset(`/chat/${target_cid}/message/${target_mid}`);
672
867
  if (doc) {
673
- const msg_instance = await this.Message.build_instance(doc);
674
- 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);
675
870
  }
676
871
  }
677
872
  }
678
873
  continue;
679
874
  }
680
- 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
+ }
681
897
  // Autocreación de contacto/chat desde pushName cuando baileys no emite upsert previo
682
- if (!doc.me) {
898
+ if (!existing_doc && !doc.me) {
683
899
  const push_name = msg.pushName ?? null;
684
900
  const is_group = cid.endsWith('@g.us');
685
901
  if (doc.author && !(await this.engine.get(`/contact/${doc.author}`))) {
686
- await this._persist_contact({
902
+ await this.#persist_contact({
687
903
  id: doc.author,
688
904
  lid: msg.key.remoteJid?.endsWith('@lid') ? msg.key.remoteJid : null,
689
905
  name: null,
@@ -699,20 +915,17 @@ export class WhatsApp {
699
915
  name: is_group ? null : push_name,
700
916
  };
701
917
  await this.engine.set(`/chat/${cid}`, serialize(chat_raw));
702
- this._event.emit('chat:created', new this.Chat(chat_raw), this);
918
+ this.emit('chat:created', new this.Chat(chat_raw), this);
703
919
  }
704
920
  }
705
- // Preservar flags explícitos persistidos por el send (ej: `multiple` en polls
706
- // auto-emitidos, donde el echo de WhatsApp pierde el discriminador del proto).
707
- // Preserve send-time flags (e.g. `multiple` on own polls, since WhatsApp drops
708
- // the proto discriminator on echo).
709
- const existing_doc = deserialize(await this.engine.get(`/chat/${cid}/message/${mid}`));
710
- if (existing_doc && typeof existing_doc.multiple === 'boolean') {
711
- doc.multiple = existing_doc.multiple;
712
- }
713
- 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.
714
924
  let content_buf = Buffer.alloc(0);
715
- if (doc.type === 'text') {
925
+ if (existing_doc) {
926
+ /* ya materializado / already materialized */
927
+ }
928
+ else if (doc.type === 'text') {
716
929
  content_buf = Buffer.from(doc.caption, 'utf-8');
717
930
  }
718
931
  else if (doc.type === 'location') {
@@ -735,7 +948,7 @@ export class WhatsApp {
735
948
  else if (doc.type === 'event') {
736
949
  content_buf = Buffer.from(JSON.stringify(msg.message?.eventMessage ?? {}), 'utf-8');
737
950
  }
738
- else if (this._socket && ['image', 'video', 'audio', 'document'].includes(doc.type)) {
951
+ else if (this.#internals.socket && ['image', 'video', 'audio', 'document'].includes(doc.type)) {
739
952
  try {
740
953
  const buffer = await downloadMediaMessage(msg, 'buffer', {});
741
954
  if (Buffer.isBuffer(buffer)) {
@@ -749,17 +962,17 @@ export class WhatsApp {
749
962
  if (content_buf.length > 0) {
750
963
  await this.engine.set(`/chat/${cid}/message/${mid}/content`, serialize({ data: content_buf.toString('base64') }));
751
964
  }
752
- const instance = await this.Message.build_instance(doc);
965
+ const instance = message(this, doc);
753
966
  const chat_instance = await instance.chat();
754
- this._event.emit('message:created', instance, chat_instance, this);
967
+ this.emit('message:created', instance, chat_instance, this);
755
968
  if (doc.forwarded) {
756
- this._event.emit('message:forwarded', instance, chat_instance, this);
969
+ this.emit('message:forwarded', instance, chat_instance, this);
757
970
  }
758
971
  }
759
972
  }
760
973
  }
761
974
  /** @internal */
762
- async _handle_messages_update(updates) {
975
+ async #handle_messages_update(updates) {
763
976
  for (const { key, update: upd } of updates) {
764
977
  if (key.remoteJid && key.id) {
765
978
  // Updates sobre `status@broadcast` se descartan: el feed sólo se
@@ -781,42 +994,42 @@ export class WhatsApp {
781
994
  raw.message = edited_message;
782
995
  doc.raw = raw;
783
996
  doc.edited = true;
784
- doc.caption = this.Message.build_message_doc(raw, this._socket?.user?.id, true).caption;
785
- await this.engine.set(`/chat/${key.remoteJid}/message/${key.id}`, serialize(doc));
786
- const msg_instance = await this.Message.build_instance(doc);
787
- 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);
788
1001
  }
789
1002
  else if (content_update) {
790
1003
  // Actualización de contenido (ej: live location). Mergea sobre el raw existente.
791
1004
  raw.message = { ...raw.message, ...content_update };
792
1005
  doc.raw = raw;
793
- doc.caption = this.Message.build_message_doc(raw, this._socket?.user?.id, doc.edited).caption;
794
- await this.engine.set(`/chat/${key.remoteJid}/message/${key.id}`, serialize(doc));
795
- const msg_instance = await this.Message.build_instance(doc);
796
- 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);
797
1010
  }
798
1011
  else if (starred_changed) {
799
1012
  doc.starred = upd_any.starred === true;
800
1013
  raw.starred = doc.starred;
801
1014
  doc.raw = raw;
802
- await this.engine.set(`/chat/${key.remoteJid}/message/${key.id}`, serialize(doc));
803
- const msg_instance = await this.Message.build_instance(doc);
804
- 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);
805
1018
  }
806
1019
  else if (status !== undefined) {
807
1020
  raw.status = status;
808
1021
  doc.status = status;
809
1022
  doc.raw = raw;
810
- await this.engine.set(`/chat/${key.remoteJid}/message/${key.id}`, serialize(doc));
811
- const msg_instance = await this.Message.build_instance(doc);
812
- 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);
813
1026
  }
814
1027
  }
815
1028
  }
816
1029
  }
817
1030
  }
818
1031
  /** @internal */
819
- async _handle_messages_reaction(reactions) {
1032
+ async #handle_messages_reaction(reactions) {
820
1033
  for (const { key, reaction } of reactions) {
821
1034
  if (key.remoteJid && key.id) {
822
1035
  // Reacciones sobre status@broadcast → feed:updated (no message:reacted).
@@ -824,14 +1037,21 @@ export class WhatsApp {
824
1037
  if (key.remoteJid === 'status@broadcast') {
825
1038
  const feed_raw = deserialize(await this.engine.get(`/status/${key.id}`));
826
1039
  if (feed_raw) {
827
- this._event.emit('feed:updated', new Feed({ wa: this, doc: feed_raw }), this);
1040
+ this.emit('feed:updated', new Feed(this, feed_raw), this);
828
1041
  }
829
1042
  continue;
830
1043
  }
831
1044
  const doc = deserialize(await this.engine.get(`/chat/${key.remoteJid}/message/${key.id}`));
832
1045
  if (doc) {
833
- const msg_instance = await this.Message.build_instance(doc);
834
- 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);
835
1055
  }
836
1056
  }
837
1057
  }