@arcaelas/whatsapp 6.2.0 → 7.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +19 -15
  2. package/build/cjs/index.d.ts +9 -5
  3. package/build/cjs/index.js +8 -7
  4. package/build/cjs/lib/bot/decorator.d.ts +2 -9
  5. package/build/cjs/lib/bot/decorator.js +0 -1
  6. package/build/cjs/lib/bot/decorators.d.ts +48 -4
  7. package/build/cjs/lib/bot/decorators.js +2 -2
  8. package/build/cjs/lib/chat/index.d.ts +132 -152
  9. package/build/cjs/lib/chat/index.js +138 -219
  10. package/build/cjs/lib/contact/index.d.ts +114 -80
  11. package/build/cjs/lib/contact/index.js +187 -94
  12. package/build/cjs/lib/message/index.d.ts +400 -321
  13. package/build/cjs/lib/message/index.js +425 -913
  14. package/build/cjs/lib/status/index.d.ts +22 -33
  15. package/build/cjs/lib/status/index.js +52 -93
  16. package/build/cjs/lib/store/index.d.ts +16 -0
  17. package/build/cjs/lib/store/index.js +29 -3
  18. package/build/cjs/lib/whatsapp/index.d.ts +44 -212
  19. package/build/cjs/lib/whatsapp/index.js +483 -1099
  20. package/build/esm/index.d.ts +9 -5
  21. package/build/esm/index.js +6 -4
  22. package/build/esm/lib/bot/decorator.d.ts +2 -9
  23. package/build/esm/lib/bot/decorator.js +1 -1
  24. package/build/esm/lib/bot/decorators.d.ts +48 -4
  25. package/build/esm/lib/bot/decorators.js +2 -2
  26. package/build/esm/lib/chat/index.d.ts +132 -152
  27. package/build/esm/lib/chat/index.js +139 -219
  28. package/build/esm/lib/contact/index.d.ts +114 -80
  29. package/build/esm/lib/contact/index.js +186 -94
  30. package/build/esm/lib/message/index.d.ts +400 -321
  31. package/build/esm/lib/message/index.js +422 -913
  32. package/build/esm/lib/status/index.d.ts +22 -33
  33. package/build/esm/lib/status/index.js +49 -93
  34. package/build/esm/lib/store/index.d.ts +16 -0
  35. package/build/esm/lib/store/index.js +25 -0
  36. package/build/esm/lib/whatsapp/index.d.ts +44 -212
  37. package/build/esm/lib/whatsapp/index.js +486 -1101
  38. package/package.json +1 -1
  39. package/build/cjs/lib/internal.d.ts +0 -40
  40. package/build/cjs/lib/internal.js +0 -38
  41. package/build/esm/lib/internal.d.ts +0 -40
  42. package/build/esm/lib/internal.js +0 -34
@@ -1,245 +1,54 @@
1
- /**
2
- * @file whatsapp/index.ts
3
- * @description Orquestador principal del cliente WhatsApp v3.
4
- * Main orchestrator of the WhatsApp v3 client.
5
- */
6
1
  import { Browsers, decryptPollVote, DisconnectReason, downloadMediaMessage, fetchLatestBaileysVersion, getContentType, initAuthCreds, jidNormalizedUser, makeWASocket, proto, updateMessageWithPollUpdate, } from 'baileys';
7
2
  import { EventEmitter } from 'node:events';
8
3
  import pino from 'pino';
9
4
  import * as QRCode from 'qrcode';
10
5
  import { chat } from '../../lib/chat/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';
6
+ import { Account, contact } from '../../lib/contact/index.js';
7
+ import Message, { message } from '../../lib/message/index.js';
14
8
  import { Feed, TTL_MS as FEED_TTL_MS } from '../../lib/status/index.js';
15
- import { deserialize, serialize } from '../../lib/store/index.js';
16
- /** Rechazo del servidor: estado terminal, el único que puede retroceder el avance. / Server rejection: terminal state, the only one allowed to move the state backwards. */
17
- const ERROR = 0;
18
- /** Estados legibles del mensaje que el receipt puede avanzar. / Readable message states a receipt can advance to. */
19
- const READ = 4;
20
- const PLAYED = 5;
21
- /**
22
- * Deduce el MIME de un binario por su firma, acotado a lo que WhatsApp acepta en un estado.
23
- * Infers a binary's MIME from its signature, limited to what WhatsApp accepts in a status.
24
- *
25
- * @param data - Binario a inspeccionar / Binary to inspect
26
- * @returns MIME reconocido, o null / Recognized MIME, or null
27
- */
28
- function sniff_media(data) {
29
- if (data.subarray(0, 3).toString('hex') === 'ffd8ff')
30
- return 'image/jpeg';
31
- if (data.subarray(0, 8).toString('hex') === '89504e470d0a1a0a')
32
- return 'image/png';
33
- if (data.subarray(0, 4).toString() === 'RIFF' && data.subarray(8, 12).toString() === 'WEBP')
34
- return 'image/webp';
35
- if (data.subarray(4, 8).toString() === 'ftyp')
36
- return 'video/mp4';
37
- return null;
38
- }
39
- /**
40
- * Cliente principal de WhatsApp. No inicia la conexión al instanciar.
41
- * Main WhatsApp client. Does not connect on instantiation.
42
- *
43
- * @example
44
- * const wa = new WhatsApp({ engine: new FileSystemEngine(__dirname), phone: 5491112345678 });
45
- * wa.on('message:created', (msg) => console.log(msg.caption));
46
- * await wa.connect((code) => console.log(code));
47
- */
48
- export class WhatsApp {
49
- /** @internal Emisor de los eventos del cliente. / Client event emitter. */
9
+ import { deserialize, jid_of, serialize } from '../../lib/store/index.js';
10
+ export default class WhatsApp {
50
11
  #event = new EventEmitter();
51
- /** @internal Estado compartido con las entidades (socket, resolución de JIDs). / State shared with the entities. */
52
- #internals;
53
- #phone;
54
- #method;
55
- #autoclean;
56
- #reconnect;
57
- #sync;
58
- #intentional_close = false;
59
- #silent_close = false;
60
- #has_connected = false;
61
- #retry_timer = null;
62
- #retry_count = 0;
63
- /**
64
- * @internal
65
- * Cadena que serializa los handlers de eventos de baileys: dos eventos sobre el mismo
66
- * documento ya no se intercalan (lost updates por read-modify-write concurrente).
67
- * Chain serializing baileys event handlers: two events over the same document no longer
68
- * interleave (lost updates from concurrent read-modify-write).
69
- */
70
- #chain = Promise.resolve();
12
+ #options;
13
+ #close = null;
71
14
  constructor(options) {
72
15
  this.engine = options.engine;
73
- this.#phone = options.phone !== undefined ? String(options.phone).replace(/\D+/g, '') : undefined;
74
- this.#method = options.method;
75
- this.#autoclean = options.autoclean ?? true;
76
- this.#sync = options.sync ?? true;
77
- this.#reconnect =
78
- options.reconnect === false ? { max: 0, interval_ms: 60_000 }
79
- : options.reconnect === undefined || options.reconnect === true ? { max: null, interval_ms: 60_000 }
80
- : typeof options.reconnect === 'number' ? { max: options.reconnect, interval_ms: 60_000 }
81
- : { max: options.reconnect.max ?? null, interval_ms: (options.reconnect.interval ?? 60) * 1_000 };
82
- this.#internals = { socket: null, resolve_jid: (uid) => this.#resolve_jid(uid) };
83
- bind(this, this.#internals);
84
- this.Contact = contact(this);
85
- this.Chat = chat(this);
86
- this.Message = {
87
- get: (cid, mid) => Message.get(this, cid, mid),
88
- list: (cid, offset, limit) => Message.list(this, cid, offset, limit),
89
- text: (cid, ...rest) => Message.text(this, cid, ...rest),
90
- image: (cid, ...rest) => Message.image(this, cid, ...rest),
91
- video: (cid, ...rest) => Message.video(this, cid, ...rest),
92
- audio: (cid, ...rest) => Message.audio(this, cid, ...rest),
93
- location: (cid, ...rest) => Message.location(this, cid, ...rest),
94
- poll: (cid, ...rest) => Message.poll(this, cid, ...rest),
95
- document: (cid, ...rest) => Message.document(this, cid, ...rest),
96
- vcard: (cid, ...rest) => Message.vcard(this, cid, ...rest),
97
- event: (cid, ...rest) => Message.event(this, cid, ...rest),
98
- react: (cid, mid, emoji) => Message.react(this, cid, mid, emoji),
99
- star: (cid, mid, value) => Message.star(this, cid, mid, value),
100
- seen: (cid, mid) => Message.seen(this, cid, mid),
101
- edit: (cid, mid, caption) => Message.edit(this, cid, mid, caption),
102
- forward: (cid, mid, target) => Message.forward(this, cid, mid, target),
103
- delete: (cid, mid, all) => Message.delete(this, cid, mid, all),
104
- reactions: (cid, mid) => Message.reactions(this, cid, mid),
105
- Text, Image, Video, Audio, Sticker, Document, Location, Poll, VCard, Event,
106
- };
107
- }
108
- /**
109
- * Contacto de la cuenta autenticada, o null mientras no hay sesión abierta.
110
- * Authenticated account's contact, or null while there is no open session.
111
- */
112
- get contact() {
113
- const user = this.#internals.socket?.user;
114
- if (user) {
115
- const jid = jidNormalizedUser(user.id);
116
- return new this.Contact({ id: jid, phone_number: jid, lid: user.lid ?? null, name: user.name ?? null });
117
- }
118
- return null;
119
- }
120
- /**
121
- * @internal
122
- * Persiste un binario: crudo cuando el driver lo soporta, JSON con base64 si no.
123
- * Persists a binary: raw when the driver supports it, base64 JSON otherwise.
124
- */
125
- async #write_content(path, data) {
126
- if (this.engine.set_buffer) {
127
- await this.engine.set_buffer(path, data);
128
- }
129
- else {
130
- await this.engine.set(path, serialize({ data: data.toString('base64') }));
131
- }
16
+ this.#options = options;
132
17
  }
133
- /** @internal Encola una tarea en la cadena serial de handlers. / Queues a task on the serial handler chain. */
134
- #enqueue(task) {
135
- this.#chain = this.#chain.then(task).catch(() => { });
136
- }
137
- /**
138
- * Emite un evento del cliente. Lo usan las entidades de la librería para propagar los
139
- * cambios que provocan; el consumidor puede emitir los suyos para pruebas.
140
- * Emits a client event. Library entities use it to propagate the changes they cause;
141
- * consumers may emit their own for testing.
142
- *
143
- * @param event - Nombre del evento / Event name
144
- * @param args - Argumentos del evento / Event arguments
145
- * @returns true si había listeners / true when listeners were present
146
- */
147
18
  emit(event, ...args) {
148
19
  return this.#event.emit(event, ...args);
149
20
  }
150
- /**
151
- * Registra un listener de evento. Retorna función para desuscribirse.
152
- * Registers an event listener. Returns an unsubscribe function.
153
- */
154
21
  on(event, handler) {
155
22
  this.#event.on(event, handler);
156
23
  return () => { this.#event.off(event, handler); };
157
24
  }
158
- /**
159
- * Quita un listener previamente registrado.
160
- * Removes a previously registered listener.
161
- */
162
- off(event, handler) {
163
- this.#event.off(event, handler);
164
- return this;
165
- }
166
- /**
167
- * Registra un listener one-shot. Retorna función para desuscribirse antes de que dispare.
168
- * Registers a one-shot listener. Returns an unsubscribe function.
169
- */
170
25
  once(event, handler) {
171
26
  this.#event.once(event, handler);
172
27
  return () => { this.#event.off(event, handler); };
173
28
  }
174
- /**
175
- * @internal
176
- * Normaliza cualquier identificador (JID, LID, número, etc.) a JID canónico. Las
177
- * entidades lo alcanzan por el canal interno, no por la instancia.
178
- * Normalizes any identifier (JID, LID, number…) into a canonical JID. Entities reach it
179
- * through the internal channel, not through the instance.
180
- */
181
- async #resolve_jid(uid) {
182
- let result = null;
183
- if (uid.endsWith('@g.us') || uid.endsWith('@s.whatsapp.net')) {
184
- result = uid;
185
- }
186
- else if (uid.endsWith('@lid')) {
187
- // Los receipts direccionan por dispositivo (`…:9@lid`); el índice se guarda sin él.
188
- // Receipts address per device (`…:9@lid`); the index is stored without it.
189
- const lid = jidNormalizedUser(uid);
190
- const direct = deserialize(await this.engine.get(`/lid/${lid}`));
191
- if (direct) {
192
- result = direct.includes('@') ? direct : `${direct}@s.whatsapp.net`;
193
- }
194
- else {
195
- const reverse = deserialize(await this.engine.get(`/lid/${lid.split('@')[0]}_reverse`));
196
- if (reverse != null) {
197
- result = `${reverse}@s.whatsapp.net`;
198
- }
199
- else {
200
- // El store local puede no tener el mapping (sesión sin upsert del contacto);
201
- // baileys lo conoce vía su lidMapping. Sin esto, un chat referenciado por @lid
202
- // (p.ej. el pollCreationMessageKey de un voto entrante) no resuelve al PN donde
203
- // realmente está guardado, y el mensaje/poll no se encuentra.
204
- const pn = await this.#internals.socket?.signalRepository?.lidMapping?.getPNForLID(lid).catch(() => null);
205
- if (pn) {
206
- // getPNForLID puede traer sufijo de dispositivo (`:0`); se normaliza para
207
- // que el JID coincida con el que usa el store (sin device).
208
- result = jidNormalizedUser(pn.includes('@') ? pn : `${pn}@s.whatsapp.net`);
209
- }
210
- }
211
- }
212
- }
213
- else {
214
- const cleaned = uid.replace(/\D/g, '');
215
- if (cleaned) {
216
- result = `${cleaned}@s.whatsapp.net`;
217
- }
218
- }
219
- return result;
29
+ off(event, handler) {
30
+ this.#event.off(event, handler);
31
+ return this;
220
32
  }
221
- /**
222
- * Inicia la conexión. El callback recibe el PIN (string) si se configuró `phone`, o el QR (Buffer PNG) si no.
223
- * Resuelve cuando la sesión sincroniza; reintenta automáticamente en cierres no-loggedOut.
224
- *
225
- * Starts the connection. Callback receives the PIN (string) when `phone` is configured, or the QR (PNG Buffer) otherwise.
226
- * Resolves once the session is synced; retries on non-loggedOut disconnects.
227
- */
228
33
  async connect(callback) {
229
- if (this.#internals.socket) {
230
- await this.disconnect({ silent: true });
231
- }
34
+ const { engine } = this;
35
+ const { phone, method, autoclean = true, sync = true, reconnect = true } = this.#options;
36
+ const digits = phone !== undefined ? String(phone).replace(/\D+/g, '') : '';
37
+ const budget = reconnect === false ? 0 : reconnect === true ? null : typeof reconnect === 'number' ? reconnect : reconnect.max ?? null;
38
+ const wait = typeof reconnect === 'object' ? (reconnect.interval ?? 60) * 1_000 : 60_000;
39
+ await this.#close?.(true);
232
40
  const { version } = await fetchLatestBaileysVersion();
233
- this.#intentional_close = false;
234
- this.#silent_close = false;
235
- this.#has_connected = false;
41
+ let connected = false;
42
+ let retries = 0;
43
+ let intentional = false;
44
+ let silent = false;
45
+ let alive = null;
46
+ let timer = null;
47
+ let chain = Promise.resolve();
236
48
  return new Promise((resolve, reject) => {
237
49
  const start = async () => {
238
- // Re-lee creds en cada start() para que limpiezas del engine tomen efecto
239
- // en reintentos (permite al consumer forzar nueva sesión borrando /session/creds).
240
- const stored = await this.engine.get('/session/creds');
241
- const creds = deserialize(stored) ?? initAuthCreds();
242
- this.#internals.socket = makeWASocket({
50
+ const creds = deserialize(await engine.get('/session/creds')) ?? initAuthCreds();
51
+ const socket = makeWASocket({
243
52
  version,
244
53
  auth: {
245
54
  creds,
@@ -247,949 +56,525 @@ export class WhatsApp {
247
56
  get: async (type, ids) => {
248
57
  const data = {};
249
58
  await Promise.all(ids.map(async (id) => {
250
- const value = deserialize(await this.engine.get(`/session/${type}/${id}`));
59
+ const value = deserialize(await engine.get(`/session/${type}/${id}`));
251
60
  if (value) {
252
- data[id] =
253
- type === 'app-state-sync-key'
254
- ? proto.Message.AppStateSyncKeyData.create(value)
255
- : value;
61
+ data[id] = type === 'app-state-sync-key'
62
+ ? proto.Message.AppStateSyncKeyData.create(value)
63
+ : value;
256
64
  }
257
65
  }));
258
66
  return data;
259
67
  },
260
68
  set: async (data) => {
261
69
  await Promise.all(Object.entries(data).flatMap(([category, entries]) => Object.entries(entries).map(([id, value]) => value != null
262
- ? this.engine.set(`/session/${category}/${id}`, serialize(value))
263
- : this.engine.unset(`/session/${category}/${id}`))));
70
+ ? engine.set(`/session/${category}/${id}`, serialize(value))
71
+ : engine.unset(`/session/${category}/${id}`))));
264
72
  },
265
73
  },
266
74
  },
267
75
  browser: Browsers.windows('Chrome'),
268
76
  logger: pino({ level: 'silent' }),
269
- syncFullHistory: this.#sync,
270
- // Los syncs no-FULL cargan las LID mappings y los tctokens (trusted-contact
271
- // tokens) que rc13 exige para enviar: apagarlos todos con `() => this.#sync`
272
- // deja la sesión sin tctoken y el server rechaza los mensajes con
273
- // "error 463: account restricted or missing tctoken". Por eso se procesan
274
- // siempre los no-FULL; `sync` solo decide si además se trae el historial FULL.
275
- // Non-FULL syncs carry the LID mappings and tctokens rc13 requires to send;
276
- // disabling them all made the server reject messages with error 463. We always
277
- // process non-FULL; `sync` only gates whether FULL history is pulled too.
278
- shouldSyncHistoryMessage: ({ syncType }) => this.#sync || syncType !== proto.HistorySync.HistorySyncType.FULL,
77
+ syncFullHistory: sync,
78
+ shouldSyncHistoryMessage: ({ syncType }) => sync || syncType !== proto.HistorySync.HistorySyncType.FULL,
79
+ // Cuando el receptor no puede descifrar pide un retry; el cache interno de
80
+ // baileys indexa por JID pero el retry llega por LID y no lo encuentra: sin
81
+ // este fallback el mensaje muere en un solo check.
82
+ // When the receiver cannot decrypt it asks for a retry; the internal baileys
83
+ // cache indexes by JID but the retry arrives by LID and misses: without this
84
+ // fallback the message dies at a single check.
85
+ getMessage: async (key) => {
86
+ const found = key.remoteJid && key.id ? await locate(key.remoteJid, key.id) : null;
87
+ return found?.doc.raw.message ?? undefined;
88
+ },
279
89
  markOnlineOnConnect: false,
280
90
  });
281
- const socket = this.#internals.socket;
282
- socket.ev.on('creds.update', () => this.engine.set('/session/creds', serialize(creds)));
283
- socket.ev.on('connection.update', async (update) => {
284
- const { connection, lastDisconnect, qr } = update;
285
- if (qr && !creds.registered) {
286
- // Baileys refresca QR periódicamente (~20s). Emitimos nuevo pair
287
- // code / QR en cada refresh para que el usuario pueda renovar si
288
- // el anterior expiró.
289
- // El PIN necesita número: sin `phone` la vinculación es siempre QR, y
290
- // con `phone` manda `method` (OTP por defecto).
291
- // A PIN requires a number: without `phone` linking is always QR, and with
292
- // `phone` the `method` option decides (OTP by default).
293
- if (this.#phone && (this.#method ?? 'otp') === 'otp') {
294
- await callback(await socket.requestPairingCode(this.#phone));
295
- }
296
- else {
297
- await callback(await QRCode.toBuffer(qr, { type: 'png', margin: 2 }));
91
+ alive = socket;
92
+ const init = { wa: this, engine, socket };
93
+ this.Contact = contact(init);
94
+ this.Chat = chat(init);
95
+ this.Message = message(init);
96
+ this.account = async () => {
97
+ const user = socket.user;
98
+ if (!user)
99
+ return null;
100
+ const id = jidNormalizedUser(user.id);
101
+ const card = deserialize(await engine.get(`/contact/${id}`));
102
+ return new Account(init, {
103
+ id,
104
+ phone_number: id,
105
+ lid: user.lid ?? card?.lid ?? null,
106
+ name: user.name ?? card?.name ?? null,
107
+ notify: card?.notify ?? null,
108
+ verified_name: card?.verified_name ?? null,
109
+ img_url: (await socket.profilePictureUrl(id, 'image').catch(() => null)) ?? card?.img_url ?? null,
110
+ status: card?.status ?? null,
111
+ });
112
+ };
113
+ const locate = async (cid, mid) => {
114
+ const lid = cid.endsWith('@lid') ? jidNormalizedUser(cid) : '';
115
+ const resolved = await jid_of(engine, cid, socket);
116
+ for (const candidate of new Set([resolved, cid, lid].filter(Boolean))) {
117
+ const path = `/chat/${candidate}/message/${mid}`;
118
+ const doc = deserialize(await engine.get(path));
119
+ if (doc) {
120
+ return { path, doc };
298
121
  }
299
122
  }
123
+ return null;
124
+ };
125
+ socket.ev.on('creds.update', () => engine.set('/session/creds', serialize(creds)));
126
+ socket.ev.on('connection.update', async ({ connection, lastDisconnect, qr }) => {
127
+ if (qr && !creds.registered) {
128
+ await callback(digits && (method ?? 'otp') === 'otp'
129
+ ? await socket.requestPairingCode(digits)
130
+ : await QRCode.toBuffer(qr, { type: 'png', margin: 2 }));
131
+ }
300
132
  if (connection === 'open') {
301
- this.#has_connected = true;
302
- this.#retry_count = 0;
133
+ connected = true;
134
+ retries = 0;
303
135
  this.emit('connected', this);
304
136
  resolve();
305
137
  }
306
138
  else if (connection === 'close') {
307
- this.#internals.socket = null;
308
- const status_code = lastDisconnect?.error?.output?.statusCode;
309
- // `restartRequired` (515) es una reconexión exigida por el protocolo
310
- // tras el sync inicial — no es un disconnect "real".
311
- const is_transient = status_code === DisconnectReason.restartRequired;
312
- // Limpieza del engine ANTES de emitir `disconnected` para que los
313
- // listeners vean el estado final (engine vaciado o creds borradas).
314
- if (status_code === DisconnectReason.loggedOut) {
315
- if (this.#autoclean) {
316
- await this.engine.clear();
317
- }
318
- else {
319
- await this.engine.unset('/session/creds');
320
- }
139
+ const code = lastDisconnect?.error?.output?.statusCode;
140
+ const transient = code === DisconnectReason.restartRequired;
141
+ if (code === DisconnectReason.loggedOut) {
142
+ await (autoclean ? engine.clear() : engine.unset('/session/creds'));
321
143
  }
322
- // `disconnect({ silent: true })` calla el evento de este cierre concreto.
323
- // `disconnect({ silent: true })` mutes this specific close's event.
324
- if (this.#has_connected && !is_transient && !this.#silent_close) {
144
+ if (connected && !transient && !silent) {
325
145
  this.emit('disconnected', this);
326
146
  }
327
- if (!this.#intentional_close) {
328
- if (status_code === DisconnectReason.loggedOut) {
329
- reject(new Error('Logged out'));
330
- }
331
- else {
332
- const max = this.#reconnect.max;
333
- // Transient closes (restartRequired) son parte del protocolo,
334
- // no cuentan contra el límite de reintentos por fallo.
335
- const exhausted = !is_transient && max !== null && this.#retry_count >= max;
336
- if (exhausted) {
337
- reject(new Error(`Reconnect attempts exhausted (${max})`));
338
- }
339
- else {
340
- if (!is_transient) {
341
- this.#retry_count++;
147
+ if (intentional) {
148
+ /* cierre pedido por disconnect(): sin reintentos / close requested by disconnect(): no retries */
149
+ }
150
+ else if (code === DisconnectReason.loggedOut) {
151
+ reject(new Error('Logged out'));
152
+ }
153
+ else if (!transient && budget !== null && retries >= budget) {
154
+ reject(new Error(`Reconnect attempts exhausted (${budget})`));
155
+ }
156
+ else {
157
+ retries += transient ? 0 : 1;
158
+ timer = setTimeout(() => {
159
+ timer = null;
160
+ start().catch(reject);
161
+ }, transient ? 0 : wait);
162
+ }
163
+ }
164
+ });
165
+ socket.ev.on('messaging-history.set', ({ chats, contacts, messages }) => {
166
+ socket.ev.emit('contacts.upsert', contacts);
167
+ socket.ev.emit('chats.upsert', chats);
168
+ socket.ev.emit('messages.upsert', { messages, type: 'append' });
169
+ });
170
+ socket.ev.on('contacts.upsert', (rows) => {
171
+ chain = chain.then(async () => {
172
+ for (const row of rows) {
173
+ if (row.id) {
174
+ const current = deserialize(await engine.get(`/contact/${row.id}`));
175
+ const doc = {
176
+ id: row.id,
177
+ lid: row.lid ?? current?.lid ?? null,
178
+ name: row.name ?? current?.name ?? null,
179
+ notify: row.notify ?? current?.notify ?? null,
180
+ verified_name: row.verifiedName ?? current?.verified_name ?? null,
181
+ img_url: (typeof row.imgUrl === 'string' ? row.imgUrl : null) ?? current?.img_url ?? null,
182
+ status: row.status ?? current?.status ?? null,
183
+ };
184
+ if (!current || JSON.stringify(current) !== JSON.stringify(doc)) {
185
+ await engine.set(`/contact/${row.id}`, serialize(doc));
186
+ if (doc.lid) {
187
+ await engine.set(`/lid/${doc.lid}`, serialize(doc.id));
342
188
  }
343
- const delay = is_transient ? 0 : this.#reconnect.interval_ms;
344
- this.#retry_timer = setTimeout(() => {
345
- this.#retry_timer = null;
346
- start().catch(reject);
347
- }, delay);
189
+ const person = new this.Contact(doc);
190
+ const owner = deserialize(await engine.get(`/chat/${doc.id}`));
191
+ this.emit(current ? 'contact:updated' : 'contact:created', person, new this.Chat(owner ?? { id: doc.id, name: person.name }), this);
348
192
  }
349
193
  }
350
194
  }
351
- }
195
+ }).catch(() => { });
352
196
  });
353
- this.#attach_business_handlers(socket);
354
- };
355
- start().catch(reject);
356
- });
357
- }
358
- /**
359
- * Actualiza el perfil de la cuenta en WhatsApp: nombre público, bio y/o foto. Sólo se
360
- * envía lo que llega en el parche; `photo: null` elimina la foto actual.
361
- * Updates the account profile on WhatsApp: public name, bio and/or picture. Only the
362
- * given fields are sent; `photo: null` removes the current picture.
363
- *
364
- * @param patch - Campos a actualizar / Fields to update
365
- * @returns true si todo lo pedido se envió / true when everything requested was sent
366
- * @throws ERR_PROFILE_PICTURE_LIB si falta `sharp` o `jimp` para procesar la foto / when `sharp` or `jimp` is missing to process the picture
367
- */
368
- async profile(patch) {
369
- const socket = this.#internals.socket;
370
- let ok = false;
371
- if (socket) {
372
- const self = jidNormalizedUser(socket.user?.id ?? '');
373
- if (patch.name !== undefined) {
374
- await socket.updateProfileName(patch.name);
375
- }
376
- if (patch.content !== undefined) {
377
- await socket.updateProfileStatus(patch.content);
378
- }
379
- if (patch.photo === null) {
380
- await socket.removeProfilePicture(self);
381
- }
382
- else if (patch.photo !== undefined) {
383
- // baileys redimensiona la foto con `sharp` o `jimp`; ninguna es dependencia
384
- // nuestra, así que su ausencia se traduce a un error accionable.
385
- // baileys resizes the picture with `sharp` or `jimp`; neither is a dependency
386
- // of ours, so their absence is translated into an actionable error.
387
- await socket
388
- .updateProfilePicture(self, typeof patch.photo === 'string' ? { url: patch.photo } : patch.photo)
389
- .catch((error) => {
390
- if (/image processing library/i.test(error.message)) {
391
- throw new Error('ERR_PROFILE_PICTURE_LIB');
392
- }
393
- throw error;
197
+ socket.ev.on('contacts.update', (rows) => {
198
+ chain = chain.then(async () => {
199
+ for (const row of rows) {
200
+ const current = row.id ? deserialize(await engine.get(`/contact/${row.id}`)) : null;
201
+ const patch = {
202
+ ...(row.notify && { notify: row.notify }),
203
+ ...(row.name && { name: row.name }),
204
+ ...(row.verifiedName && { verified_name: row.verifiedName }),
205
+ ...(typeof row.imgUrl === 'string' && { img_url: row.imgUrl }),
206
+ ...(row.status && { status: row.status }),
207
+ ...(row.lid && { lid: row.lid }),
208
+ };
209
+ if (current && row.id && Object.keys(patch).length > 0) {
210
+ const doc = { ...current, ...patch };
211
+ await engine.set(`/contact/${row.id}`, serialize(doc));
212
+ if (patch.lid) {
213
+ await engine.set(`/lid/${patch.lid}`, serialize(row.id));
214
+ }
215
+ const person = new this.Contact(doc);
216
+ const owner = deserialize(await engine.get(`/chat/${row.id}`));
217
+ this.emit('contact:updated', person, new this.Chat(owner ?? { id: row.id, name: person.name }), this);
218
+ }
219
+ }
220
+ }).catch(() => { });
394
221
  });
395
- }
396
- ok = true;
397
- }
398
- return ok;
399
- }
400
- /**
401
- * Publica un estado (status broadcast). Con sólo `caption` publica texto; con `content`
402
- * publica imagen o video (el tipo se deduce del binario) usando `caption` como pie.
403
- * `contacts` es la audiencia: WhatsApp no reparte el estado a quien no esté en la lista.
404
- * Publishes a status broadcast. With only `caption` it posts text; with `content` it
405
- * posts an image or video (type inferred from the binary) using `caption` as its footer.
406
- * `contacts` is the audience: WhatsApp does not deliver the status to anyone outside it.
407
- *
408
- * @param post - Contenido, pie y audiencia / Content, caption and audience
409
- * @returns Publicación creada, o null si no hay sesión / Created post, or null without a session
410
- * @throws ERR_FEED_EMPTY sin `content` ni `caption` / when neither `content` nor `caption` is given
411
- * @throws ERR_FEED_MEDIA si el binario no es imagen ni video / when the binary is neither image nor video
412
- */
413
- async feed(post) {
414
- const socket = this.#internals.socket;
415
- let result = null;
416
- if (socket) {
417
- const audience = (await Promise.all(post.contacts.map((uid) => this.#resolve_jid(String(uid))))).filter((jid) => jid !== null);
418
- const mime = post.content ? sniff_media(post.content) : null;
419
- if (post.content && !mime) {
420
- throw new Error('ERR_FEED_MEDIA');
421
- }
422
- if (!post.content && !post.caption) {
423
- throw new Error('ERR_FEED_EMPTY');
424
- }
425
- const kind = mime?.startsWith('video/') ? 'video' : mime ? 'image' : 'text';
426
- const sent = await socket.sendMessage('status@broadcast', (post.content
427
- ? { [kind]: post.content, caption: post.caption }
428
- : { text: post.caption }), { statusJidList: audience });
429
- if (sent?.key?.id) {
430
- const created_at = (Number(sent.messageTimestamp) || Math.floor(Date.now() / 1_000)) * 1_000;
431
- const doc = {
432
- id: sent.key.id,
433
- author_jid: jidNormalizedUser(socket.user?.id ?? ''),
434
- type: kind,
435
- caption: post.caption ?? '',
436
- mime: mime ?? 'text/plain',
437
- created_at,
438
- expires_at: created_at + FEED_TTL_MS,
439
- viewed: true,
440
- raw: sent,
441
- };
442
- await this.engine.set(`/status/${doc.id}`, serialize(doc), created_at);
443
- if (post.content) {
444
- await this.#write_content(`/status/${doc.id}/content`, post.content);
445
- }
446
- result = new Feed(this, doc);
447
- this.emit('feed:created', result, this);
448
- }
449
- }
450
- return result;
451
- }
452
- /**
453
- * Cierra la conexión. Con `destroy: true` vacía el engine completo.
454
- * Closes the connection. With `destroy: true` clears the engine entirely.
455
- */
456
- async disconnect(options = {}) {
457
- this.#intentional_close = true;
458
- this.#silent_close = options.silent === true;
459
- // Cancela cualquier retry programado por un close anterior, para no resucitar
460
- // el socket después de una desconexión manual.
461
- if (this.#retry_timer) {
462
- clearTimeout(this.#retry_timer);
463
- this.#retry_timer = null;
464
- }
465
- if (this.#internals.socket) {
466
- try {
467
- // Pasa un error Boom-like con statusCode=connectionClosed (428) para que
468
- // `lastDisconnect.error.output.statusCode` quede explícito en el close
469
- // en lugar de `undefined`.
470
- const intentional = Object.assign(new Error('intentional close'), {
471
- output: { statusCode: DisconnectReason.connectionClosed },
222
+ socket.ev.on('lid-mapping.update', ({ lid, pn }) => {
223
+ chain = chain.then(async () => {
224
+ await engine.set(`/lid/${lid}`, serialize(pn));
225
+ await engine.set(`/lid/${pn}`, serialize(lid));
226
+ }).catch(() => { });
472
227
  });
473
- await this.#internals.socket.end(intentional);
474
- }
475
- catch {
476
- /* socket may already be closed */
477
- }
478
- this.#internals.socket = null;
479
- }
480
- if (options.destroy) {
481
- await this.engine.clear();
482
- }
483
- }
484
- /**
485
- * Conecta los handlers de eventos de baileys: contactos, chats y mensajes.
486
- * Wires baileys event handlers: contacts, chats and messages.
487
- *
488
- * @internal
489
- */
490
- #attach_business_handlers(socket) {
491
- socket.ev.on('messaging-history.set', ({ chats, contacts, messages }) => {
492
- this.#enqueue(async () => {
493
- await this.#handle_contacts_upsert(contacts);
494
- await this.#handle_chats_upsert(chats);
495
- await this.#handle_messages_upsert(messages);
496
- });
497
- });
498
- socket.ev.on('contacts.upsert', (contacts) => {
499
- this.#enqueue(() => this.#handle_contacts_upsert(contacts));
500
- });
501
- socket.ev.on('contacts.update', (contacts) => {
502
- this.#enqueue(() => this.#handle_contacts_update(contacts));
503
- });
504
- socket.ev.on('lid-mapping.update', ({ lid, pn }) => {
505
- this.#enqueue(() => this.#handle_lid_mapping(lid, pn));
506
- });
507
- socket.ev.on('chats.upsert', (chats) => {
508
- this.#enqueue(() => this.#handle_chats_upsert(chats));
509
- });
510
- socket.ev.on('chats.update', (chats) => {
511
- this.#enqueue(() => this.#handle_chats_update(chats));
512
- });
513
- socket.ev.on('chats.delete', (ids) => {
514
- this.#enqueue(() => this.#handle_chats_delete(ids));
515
- });
516
- socket.ev.on('messages.upsert', ({ messages }) => {
517
- this.#enqueue(() => this.#handle_messages_upsert(messages));
518
- });
519
- socket.ev.on('messages.update', (updates) => {
520
- this.#enqueue(() => this.#handle_messages_update(updates));
521
- });
522
- socket.ev.on('message-receipt.update', (updates) => {
523
- this.#enqueue(() => this.#handle_message_receipt(updates));
524
- });
525
- // Las reacciones llegan duplicadas por `messages.reaction` Y `messages.upsert`
526
- // (como `reactionMessage`). Se usa solo `messages.upsert` para evitar el doble disparo.
527
- // socket.ev.on('messages.reaction', (reactions) => {
528
- // void this.#handle_messages_reaction(reactions);
529
- // });
530
- }
531
- /**
532
- * Persiste un contacto y su índice LID; emite `contact:created` solo si es nuevo.
533
- * Persists a contact and its LID index; emits `contact:created` only when new.
534
- *
535
- * @param raw - Documento del contacto a persistir / Contact document to persist
536
- * @internal
537
- */
538
- async #persist_contact(raw) {
539
- const current = deserialize(await this.engine.get(`/contact/${raw.id}`));
540
- // Los upserts del re-sync llegan con los campos vacíos: sin fusionar borran el nombre
541
- // que ya se conocía y el chat pasa a mostrar el número pelado.
542
- // Re-sync upserts arrive with empty fields: without merging they wipe the name already
543
- // known and the chat falls back to showing the bare number.
544
- const doc = current
545
- ? {
546
- id: raw.id,
547
- lid: raw.lid ?? current.lid,
548
- name: raw.name ?? current.name,
549
- notify: raw.notify ?? current.notify,
550
- verified_name: raw.verified_name ?? current.verified_name,
551
- img_url: raw.img_url ?? current.img_url,
552
- status: raw.status ?? current.status,
553
- }
554
- : raw;
555
- await this.engine.set(`/contact/${raw.id}`, serialize(doc));
556
- if (doc.lid) {
557
- await this.engine.set(`/lid/${doc.lid}`, serialize(doc.id));
558
- }
559
- // Una ficha que existía vacía y ahora tiene nombre es un cambio que el consumidor
560
- // necesita: sin avisar, quien memorice el contacto sigue mostrando el número.
561
- // A card that existed empty and now has a name is a change the consumer needs: without
562
- // notifying, whoever memoized the contact keeps showing the bare number.
563
- const changed = current && ['lid', 'name', 'notify', 'verified_name', 'img_url', 'status'].some((key) => current[key] !== doc[key]);
564
- if (!current || changed) {
565
- const person = new this.Contact(doc);
566
- const cached_chat = deserialize(await this.engine.get(`/chat/${doc.id}`));
567
- const chat = new this.Chat(cached_chat ?? { id: doc.id, name: person.name });
568
- this.emit(current ? 'contact:updated' : 'contact:created', person, chat, this);
569
- }
570
- }
571
- /** @internal */
572
- async #handle_contacts_upsert(contacts) {
573
- for (const c of contacts) {
574
- if (c.id) {
575
- await this.#persist_contact({
576
- id: c.id,
577
- lid: c.lid ?? null,
578
- name: c.name ?? null,
579
- notify: c.notify ?? null,
580
- verified_name: c.verifiedName ?? null,
581
- img_url: c.imgUrl ?? null,
582
- status: c.status ?? null,
583
- });
584
- }
585
- }
586
- }
587
- /** @internal */
588
- async #handle_contacts_update(contacts) {
589
- for (const c of contacts) {
590
- if (c.id) {
591
- const current = deserialize(await this.engine.get(`/contact/${c.id}`));
592
- if (current) {
593
- const patch = {
594
- ...(c.notify && { notify: c.notify }),
595
- ...(c.name && { name: c.name }),
596
- ...(c.verifiedName && { verified_name: c.verifiedName }),
597
- ...(c.imgUrl && { img_url: c.imgUrl }),
598
- ...(c.status && { status: c.status }),
599
- ...(c.lid && { lid: c.lid }),
600
- };
601
- if (Object.keys(patch).length > 0) {
602
- const merged = { ...current, ...patch };
603
- await this.engine.set(`/contact/${c.id}`, serialize(merged));
604
- if (patch.lid) {
605
- await this.engine.set(`/lid/${patch.lid}`, serialize(c.id));
606
- }
607
- const person = new this.Contact(merged);
608
- const cached_chat = deserialize(await this.engine.get(`/chat/${c.id}`));
609
- this.emit('contact:updated', person, new this.Chat(cached_chat ?? { id: c.id, name: person.name }), this);
610
- }
611
- }
612
- }
613
- }
614
- }
615
- /** @internal */
616
- async #handle_lid_mapping(lid, pn) {
617
- await this.engine.set(`/lid/${lid}`, serialize(pn));
618
- await this.engine.set(`/lid/${pn}`, serialize(lid));
619
- }
620
- /**
621
- * Actividad del chat según su último mensaje persistido. Es el respaldo para los
622
- * documentos que se guardaron antes de que el chat llevara su propia marca.
623
- * Chat activity from its last persisted message. It is the fallback for documents stored
624
- * before the chat carried its own stamp.
625
- *
626
- * @param cid - Identificador del chat / Chat identifier
627
- * @returns Epoch ms del último mensaje, o 0 si el chat no tiene ninguno / Last message epoch ms, or 0 when the chat has none
628
- * @internal
629
- */
630
- async #activity(cid) {
631
- const [raw] = await this.engine.list(`/chat/${cid}/message`, 0, 1);
632
- return deserialize(raw ?? null)?.created_at ?? 0;
633
- }
634
- /** @internal */
635
- async #handle_chats_upsert(chats) {
636
- for (const ch of chats) {
637
- if (ch.id) {
638
- const current = deserialize(await this.engine.get(`/chat/${ch.id}`));
639
- const raw = current ?? {
640
- id: ch.id,
641
- name: ch.name ?? null,
642
- archived: ch.archived ?? null,
643
- pinned: ch.pinned ?? null,
644
- mute_end_time: ch.muteEndTime != null ? Number(ch.muteEndTime) : null,
645
- unread_count: ch.unreadCount ?? null,
646
- };
647
- if (ch.name) {
648
- raw.name = ch.name;
649
- }
650
- // El sync trae la última actividad del chat; sin ella la lista quedaría ordenada
651
- // por el momento en que se escribió cada documento.
652
- // The sync carries the chat's last activity; without it the list would be ordered
653
- // by the moment each document happened to be written.
654
- const stamp = ch.conversationTimestamp != null ? Number(ch.conversationTimestamp) * 1_000 : null;
655
- raw.activity = Math.max(stamp ?? 0, raw.activity ?? 0, await this.#activity(ch.id)) || null;
656
- await this.engine.set(`/chat/${ch.id}`, serialize(raw), raw.activity ?? 0);
657
- if (current === null) {
658
- this.emit('chat:created', new this.Chat(raw), this);
659
- }
660
- }
661
- }
662
- }
663
- /** @internal */
664
- async #handle_chats_update(chats) {
665
- for (const ch of chats) {
666
- if (ch.id && ch.id !== 'status@broadcast') {
667
- const current = deserialize(await this.engine.get(`/chat/${ch.id}`)) ?? {
668
- id: ch.id,
669
- name: ch.name ?? null,
670
- };
671
- const patch = {};
672
- const pinned_changed = 'pinned' in ch;
673
- const archived_changed = ch.archived !== undefined;
674
- const mute_changed = 'muteEndTime' in ch;
675
- if (ch.name) {
676
- patch.name = ch.name;
677
- }
678
- if (pinned_changed) {
679
- patch.pinned = ch.pinned ?? null;
680
- }
681
- if (archived_changed) {
682
- patch.archived = ch.archived ?? false;
683
- }
684
- if (mute_changed) {
685
- patch.mute_end_time = ch.muteEndTime != null ? Number(ch.muteEndTime) : null;
686
- }
687
- if (ch.unreadCount != null) {
688
- patch.unread_count = ch.unreadCount;
689
- }
690
- if (Object.keys(patch).length > 0) {
691
- const merged = { ...current, ...patch };
692
- // Fijar, archivar o silenciar no es actividad: el chat conserva su posición.
693
- // Pinning, archiving or muting is not activity: the chat keeps its position.
694
- await this.engine.set(`/chat/${ch.id}`, serialize(merged), merged.activity ?? undefined);
695
- if (pinned_changed) {
696
- this.emit(ch.pinned != null ? 'chat:pinned' : 'chat:unpinned', new this.Chat(merged), this);
697
- }
698
- if (archived_changed) {
699
- this.emit(ch.archived ? 'chat:archived' : 'chat:unarchived', new this.Chat(merged), this);
700
- }
701
- if (mute_changed) {
702
- const is_muted = patch.mute_end_time != null && patch.mute_end_time > Date.now();
703
- this.emit(is_muted ? 'chat:muted' : 'chat:unmuted', new this.Chat(merged), this);
704
- }
705
- }
706
- }
707
- }
708
- }
709
- /** @internal */
710
- async #handle_chats_delete(ids) {
711
- for (const cid of ids) {
712
- const raw = deserialize(await this.engine.get(`/chat/${cid}`)) ?? { id: cid };
713
- await this.engine.unset(`/chat/${cid}`);
714
- this.emit('chat:deleted', new this.Chat(raw), this);
715
- }
716
- }
717
- /** @internal */
718
- async #handle_message_receipt(updates) {
719
- for (const { key, receipt } of updates) {
720
- // Receipt sobre status@broadcast → marca el feed como visto y emite feed:updated.
721
- // Receipt on status@broadcast → marks feed viewed and emits feed:updated.
722
- if (key.remoteJid === 'status@broadcast' && key.id) {
723
- const feed_raw = deserialize(await this.engine.get(`/status/${key.id}`));
724
- if (feed_raw && !feed_raw.viewed) {
725
- feed_raw.viewed = true;
726
- await this.engine.set(`/status/${key.id}`, serialize(feed_raw));
727
- this.emit('feed:updated', new Feed(this, feed_raw), this);
728
- }
729
- continue;
730
- }
731
- if (key.remoteJid && key.id && (receipt.readTimestamp != null || receipt.playedTimestamp != null)) {
732
- const found = await this.#locate(key.remoteJid, key.id);
733
- if (found) {
734
- const { path, doc } = found;
735
- const next = receipt.playedTimestamp != null ? PLAYED : READ;
736
- if (doc.status < next) {
737
- doc.status = next;
738
- doc.raw.status = next;
739
- await this.engine.set(path, serialize(doc), doc.created_at);
740
- }
741
- const msg_instance = message(this, doc);
742
- this.emit('message:seen', msg_instance, await msg_instance.chat(), this);
743
- }
744
- }
745
- }
746
- }
747
- /** @internal */
748
- async #handle_messages_upsert(messages) {
749
- for (const msg of messages) {
750
- if (msg.key?.remoteJid && msg.key.id) {
751
- const cid = msg.key.remoteJidAlt ?? msg.key.remoteJid;
752
- const mid = msg.key.id;
753
- const content_type = getContentType(msg.message ?? {});
754
- if (content_type === 'reactionMessage') {
755
- // Canal único para reacciones: se procesa aquí y se ignora `messages.reaction`.
756
- // Single channel for reactions: handled here; `messages.reaction` is disabled.
757
- const reaction = msg.message?.reactionMessage;
758
- if (reaction?.key?.id && reaction.key.remoteJid) {
759
- const target_cid = (await this.#resolve_jid(reaction.key.remoteJid)) ?? reaction.key.remoteJid;
760
- await this.#handle_messages_reaction([{
761
- key: {
762
- remoteJid: target_cid,
763
- id: reaction.key.id,
764
- participant: msg.key.fromMe ? (this.#internals.socket?.user?.id ?? null) : (msg.key.participant ?? cid),
765
- },
766
- reaction: { text: reaction.text ?? '' },
767
- }]);
768
- }
769
- continue;
770
- }
771
- // Status broadcast — flujo dedicado. Nunca emite `message:*`.
772
- // Status broadcast — dedicated flow. Never emits `message:*`.
773
- if (msg.key.remoteJid === 'status@broadcast') {
774
- if (content_type === 'protocolMessage') {
775
- const protocol = msg.message?.protocolMessage;
776
- if (protocol?.type === proto.Message.ProtocolMessage.Type.REVOKE &&
777
- protocol.key?.id) {
778
- const feed_raw = deserialize(await this.engine.get(`/status/${protocol.key.id}`));
779
- if (feed_raw) {
780
- await this.engine.unset(`/status/${protocol.key.id}`);
781
- this.emit('feed:deleted', new Feed(this, feed_raw), this);
228
+ socket.ev.on('chats.upsert', (rows) => {
229
+ chain = chain.then(async () => {
230
+ for (const row of rows) {
231
+ if (row.id) {
232
+ const current = deserialize(await engine.get(`/chat/${row.id}`));
233
+ const doc = current ?? {
234
+ id: row.id,
235
+ name: row.name ?? null,
236
+ archived: row.archived ?? null,
237
+ pinned: row.pinned ?? null,
238
+ mute_end_time: row.muteEndTime != null ? Number(row.muteEndTime) : null,
239
+ unread_count: row.unreadCount ?? null,
240
+ };
241
+ if (row.name) {
242
+ doc.name = row.name;
243
+ }
244
+ const [newest] = await engine.list(`/chat/${row.id}/message`, 0, 1);
245
+ doc.activity = Math.max(row.conversationTimestamp != null ? Number(row.conversationTimestamp) * 1_000 : 0, doc.activity ?? 0, deserialize(newest ?? null)?.created_at ?? 0) || null;
246
+ await engine.set(`/chat/${row.id}`, serialize(doc), doc.activity ?? 0);
247
+ if (!current) {
248
+ this.emit('chat:created', new this.Chat(doc), this);
249
+ }
782
250
  }
783
251
  }
784
- continue;
785
- }
786
- const FEED_TYPE_MAP = {
787
- conversation: 'text',
788
- extendedTextMessage: 'text',
789
- imageMessage: 'image',
790
- videoMessage: 'video',
791
- audioMessage: 'audio',
792
- };
793
- const feed_type = FEED_TYPE_MAP[content_type ?? ''];
794
- const author = msg.key.participant ?? '';
795
- if (!feed_type || !author) {
796
- continue;
797
- }
798
- const msg_content = msg.message?.[content_type];
799
- let caption = '';
800
- let mime = 'text/plain';
801
- if (typeof msg_content === 'string') {
802
- caption = msg_content;
803
- }
804
- else if (msg_content && typeof msg_content === 'object') {
805
- caption =
806
- msg_content.caption ??
807
- msg_content.text ??
808
- '';
809
- if (feed_type !== 'text') {
810
- mime = msg_content.mimetype ?? 'application/octet-stream';
811
- }
812
- }
813
- const created_at = (Number(msg.messageTimestamp) || Math.floor(Date.now() / 1000)) * 1000;
814
- const feed_raw = {
815
- id: mid,
816
- author_jid: author,
817
- type: feed_type,
818
- caption,
819
- mime,
820
- created_at,
821
- expires_at: created_at + FEED_TTL_MS,
822
- viewed: false,
823
- raw: msg,
824
- };
825
- let content_buf = Buffer.alloc(0);
826
- if (feed_type === 'text') {
827
- content_buf = Buffer.from(caption, 'utf-8');
828
- }
829
- else if (this.#internals.socket) {
830
- try {
831
- const buf = await downloadMediaMessage(msg, 'buffer', {});
832
- if (Buffer.isBuffer(buf)) {
833
- content_buf = buf;
252
+ }).catch(() => { });
253
+ });
254
+ socket.ev.on('chats.update', (rows) => {
255
+ chain = chain.then(async () => {
256
+ for (const row of rows) {
257
+ if (row.id && row.id !== 'status@broadcast') {
258
+ const current = deserialize(await engine.get(`/chat/${row.id}`)) ?? { id: row.id, name: row.name ?? null };
259
+ const patch = {};
260
+ const events = [];
261
+ if (row.name) {
262
+ patch.name = row.name;
263
+ }
264
+ if (row.unreadCount != null) {
265
+ patch.unread_count = row.unreadCount;
266
+ }
267
+ if ('pinned' in row) {
268
+ patch.pinned = row.pinned ?? null;
269
+ events.push(row.pinned != null ? 'chat:pinned' : 'chat:unpinned');
270
+ }
271
+ if (row.archived !== undefined) {
272
+ patch.archived = row.archived ?? false;
273
+ events.push(row.archived ? 'chat:archived' : 'chat:unarchived');
274
+ }
275
+ if ('muteEndTime' in row) {
276
+ patch.mute_end_time = row.muteEndTime != null ? Number(row.muteEndTime) : null;
277
+ events.push(patch.mute_end_time != null && patch.mute_end_time > Date.now() ? 'chat:muted' : 'chat:unmuted');
278
+ }
279
+ if (Object.keys(patch).length > 0) {
280
+ const doc = { ...current, ...patch };
281
+ await engine.set(`/chat/${row.id}`, serialize(doc), doc.activity ?? undefined);
282
+ for (const event of events) {
283
+ this.emit(event, new this.Chat(doc), this);
284
+ }
285
+ }
834
286
  }
835
287
  }
836
- catch {
837
- /* media download may fail */
288
+ }).catch(() => { });
289
+ });
290
+ socket.ev.on('chats.delete', (ids) => {
291
+ chain = chain.then(async () => {
292
+ for (const cid of ids) {
293
+ const doc = deserialize(await engine.get(`/chat/${cid}`)) ?? { id: cid };
294
+ await engine.unset(`/chat/${cid}`);
295
+ this.emit('chat:deleted', new this.Chat(doc), this);
838
296
  }
839
- }
840
- await this.engine.set(`/status/${mid}`, serialize(feed_raw));
841
- if (content_buf.length > 0) {
842
- await this.#write_content(`/status/${mid}/content`, content_buf);
843
- }
844
- this.emit('feed:created', new Feed(this, feed_raw), this);
845
- continue;
846
- }
847
- if (content_type === 'pollUpdateMessage') {
848
- const update = msg.message?.pollUpdateMessage;
849
- const creation_key = update?.pollCreationMessageKey;
850
- if (creation_key?.id &&
851
- creation_key.remoteJid &&
852
- update?.vote?.encPayload &&
853
- update.vote.encIv) {
854
- const resolved_cid = (await this.#resolve_jid(creation_key.remoteJid)) ?? creation_key.remoteJid;
855
- const target_mid = creation_key.id;
856
- const poll_doc = deserialize(await this.engine.get(`/chat/${resolved_cid}/message/${target_mid}`));
857
- const secret_raw = poll_doc?.raw.message?.messageContextInfo?.messageSecret;
858
- const message_secret = typeof secret_raw === 'string' ? Buffer.from(secret_raw, 'base64') : secret_raw;
859
- if (poll_doc && message_secret) {
860
- try {
861
- const poll_key = poll_doc.raw.key ?? {};
862
- // La identidad propia del HMAC depende del addressing del chat
863
- // (LID en @lid, PN en @s.whatsapp.net), así que para las posiciones
864
- // fromMe se intenta descifrar con ambas: AES-GCM autentica, la
865
- // clave equivocada lanza y se prueba la siguiente.
866
- // Own HMAC identity depends on chat addressing (LID on @lid, PN on
867
- // @s.whatsapp.net), so fromMe positions try both candidates:
868
- // AES-GCM authenticates, a wrong key throws and the next is tried.
869
- const self_id = this.#internals.socket?.user?.id ?? '';
870
- const self_lid = this.#internals.socket?.user?.lid ?? '';
871
- const selves = [...new Set([self_lid, self_id].filter(Boolean))];
872
- // Candidatos foráneos: todas las formas de identidad del key (LID,
873
- // participant, alt, remoteJid); se prueban todas porque el addressing
874
- // del stanza varía (LID vs PN) según la migración del contacto.
875
- const foreign_of = (k) => [...new Set([k.remoteJid, k.participant, k.remoteJidAlt, k.remoteJid].filter((x) => Boolean(x)))];
876
- const voters = msg.key.fromMe ? selves : foreign_of(msg.key);
877
- const creators = poll_key.fromMe ? selves : foreign_of(poll_key);
878
- let decrypted = null;
879
- for (const voter of voters) {
880
- for (const creator of creators) {
297
+ }).catch(() => { });
298
+ });
299
+ socket.ev.on('messages.upsert', ({ messages }) => {
300
+ chain = chain.then(async () => {
301
+ for (const msg of messages) {
302
+ const cid = msg.key?.remoteJidAlt ?? msg.key?.remoteJid;
303
+ const mid = msg.key?.id;
304
+ if (!cid || !mid) {
305
+ continue;
306
+ }
307
+ const kind = getContentType(msg.message ?? {});
308
+ if (kind === 'reactionMessage') {
309
+ const target = msg.message?.reactionMessage;
310
+ const found = target?.key?.id && target.key.remoteJid ? await locate(target.key.remoteJid, target.key.id) : null;
311
+ if (found && target) {
312
+ const author = jidNormalizedUser((msg.key.fromMe ? socket.user?.id : msg.key.participant ?? cid) ?? cid);
313
+ const emoji = target.text ?? '';
314
+ found.doc.reactions = [
315
+ ...(found.doc.reactions ?? []).filter((entry) => entry.author !== author),
316
+ ...(emoji ? [{ author, emoji, at: Date.now() }] : []),
317
+ ];
318
+ await engine.set(found.path, serialize(found.doc), found.doc.created_at);
319
+ const instance = new Message(init, found.doc);
320
+ this.emit('message:reacted', instance, await instance.chat(), emoji, this);
321
+ }
322
+ continue;
323
+ }
324
+ if (msg.key.remoteJid === 'status@broadcast') {
325
+ const revoked = kind === 'protocolMessage' && msg.message?.protocolMessage?.type === proto.Message.ProtocolMessage.Type.REVOKE
326
+ ? msg.message.protocolMessage.key?.id
327
+ : null;
328
+ if (revoked) {
329
+ const gone = deserialize(await engine.get(`/status/${revoked}`));
330
+ if (gone) {
331
+ await engine.unset(`/status/${revoked}`);
332
+ this.emit('feed:deleted', new Feed(init, gone), this);
333
+ }
334
+ continue;
335
+ }
336
+ const type = { conversation: 'text', extendedTextMessage: 'text', imageMessage: 'image', videoMessage: 'video', audioMessage: 'audio' }[kind ?? ''];
337
+ const author = msg.key.participant ?? '';
338
+ if (!type || !author) {
339
+ continue;
340
+ }
341
+ const body = msg.message?.[kind];
342
+ const caption = typeof body === 'string' ? body : (body?.caption ?? body?.text ?? '');
343
+ const created_at = (Number(msg.messageTimestamp) || Math.floor(Date.now() / 1_000)) * 1_000;
344
+ const doc = {
345
+ id: mid,
346
+ author_jid: author,
347
+ type,
348
+ caption,
349
+ mime: type === 'text' ? 'text/plain' : ((typeof body === 'object' && body?.mimetype) || 'application/octet-stream'),
350
+ created_at,
351
+ expires_at: created_at + FEED_TTL_MS,
352
+ viewed: false,
353
+ raw: msg,
354
+ };
355
+ const binary = type === 'text'
356
+ ? Buffer.from(caption, 'utf-8')
357
+ : await downloadMediaMessage(msg, 'buffer', {}).catch(() => Buffer.alloc(0));
358
+ await engine.set(`/status/${mid}`, serialize(doc));
359
+ if (binary.length > 0) {
360
+ await (engine.set_buffer?.(`/status/${mid}/content`, binary) ?? engine.set(`/status/${mid}/content`, serialize({ data: binary.toString('base64') })));
361
+ }
362
+ this.emit('feed:created', new Feed(init, doc), this);
363
+ continue;
364
+ }
365
+ if (kind === 'pollUpdateMessage') {
366
+ const key = msg.message?.pollUpdateMessage?.pollCreationMessageKey;
367
+ const vote = msg.message?.pollUpdateMessage?.vote;
368
+ const found = key?.id && key.remoteJid ? await locate(key.remoteJid, key.id) : null;
369
+ const raw_secret = found?.doc.raw.message?.messageContextInfo?.messageSecret;
370
+ const secret = typeof raw_secret === 'string' ? Buffer.from(raw_secret, 'base64') : raw_secret;
371
+ if (found && secret && vote?.encPayload && vote.encIv) {
372
+ const mine = [socket.user?.lid, socket.user?.id];
373
+ const theirs = (from) => [from.remoteJid, from.participant, from.remoteJidAlt];
374
+ const voters = (msg.key.fromMe ? mine : theirs(msg.key)).filter((id) => Boolean(id));
375
+ const creators = (found.doc.raw.key?.fromMe ? mine : theirs(found.doc.raw.key ?? {})).filter((id) => Boolean(id));
376
+ for (const pair of voters.flatMap((who) => creators.map((creator) => [who, creator]))) {
881
377
  try {
882
- decrypted = decryptPollVote({ encPayload: update.vote.encPayload, encIv: update.vote.encIv }, {
883
- pollCreatorJid: jidNormalizedUser(creator),
884
- pollMsgId: target_mid,
885
- pollEncKey: message_secret,
886
- voterJid: jidNormalizedUser(voter),
378
+ updateMessageWithPollUpdate(found.doc.raw, {
379
+ pollUpdateMessageKey: msg.key,
380
+ vote: decryptPollVote({ encPayload: vote.encPayload, encIv: vote.encIv }, {
381
+ pollCreatorJid: jidNormalizedUser(pair[1]),
382
+ pollMsgId: found.doc.id,
383
+ pollEncKey: secret,
384
+ voterJid: jidNormalizedUser(pair[0]),
385
+ }),
386
+ senderTimestampMs: Number(msg.messageTimestamp) || Date.now(),
887
387
  });
388
+ await engine.set(found.path, serialize(found.doc), found.doc.created_at);
389
+ const instance = new Message(init, found.doc);
390
+ this.emit('message:updated', instance, await instance.chat(), this);
888
391
  break;
889
392
  }
890
393
  catch {
891
- /* identidad equivocada: probar la siguiente */
394
+ /* identidad equivocada / wrong identity */
892
395
  }
893
396
  }
894
- if (decrypted) {
895
- break;
896
- }
897
397
  }
898
- if (decrypted) {
899
- updateMessageWithPollUpdate(poll_doc.raw, {
900
- pollUpdateMessageKey: msg.key,
901
- vote: decrypted,
902
- senderTimestampMs: Number(msg.messageTimestamp) || Date.now(),
903
- });
904
- await this.engine.set(`/chat/${resolved_cid}/message/${target_mid}`, serialize(poll_doc), poll_doc.created_at);
905
- const msg_instance = message(this, poll_doc);
906
- this.emit('message:updated', msg_instance, await msg_instance.chat(), this);
398
+ continue;
399
+ }
400
+ if (kind === 'protocolMessage') {
401
+ const protocol = msg.message?.protocolMessage;
402
+ const found = protocol?.key?.id ? await locate(protocol.key.remoteJid ?? cid, protocol.key.id) : null;
403
+ if (found && protocol?.type === proto.Message.ProtocolMessage.Type.MESSAGE_EDIT && protocol.editedMessage) {
404
+ found.doc.raw.message = protocol.editedMessage;
405
+ found.doc.edited = true;
406
+ found.doc.caption = new Message(init, found.doc.raw).caption;
407
+ await engine.set(found.path, serialize(found.doc), found.doc.created_at);
408
+ const instance = new Message(init, found.doc);
409
+ this.emit('message:updated', instance, await instance.chat(), this);
410
+ }
411
+ else if (found && protocol?.type === proto.Message.ProtocolMessage.Type.REVOKE) {
412
+ await engine.unset(found.path);
413
+ const instance = new Message(init, found.doc);
414
+ this.emit('message:deleted', instance, await instance.chat(), this);
907
415
  }
416
+ continue;
908
417
  }
909
- catch {
910
- /* decrypt may fail */
418
+ const doc = new Message(init, msg)._raw;
419
+ const stored = deserialize(await engine.get(`/chat/${cid}/message/${mid}`));
420
+ if (stored) {
421
+ doc.multiple = typeof stored.multiple === 'boolean' ? stored.multiple : doc.multiple;
422
+ doc.reactions = stored.reactions ?? doc.reactions;
423
+ const advanced = doc.status > stored.status;
424
+ doc.status = Math.max(stored.status, doc.status);
425
+ if (!advanced && stored.caption === doc.caption && stored.edited === doc.edited && stored.starred === doc.starred) {
426
+ continue;
427
+ }
911
428
  }
912
- }
913
- }
914
- continue;
915
- }
916
- if (content_type === 'protocolMessage') {
917
- const protocol = msg.message?.protocolMessage;
918
- if (protocol?.key?.id) {
919
- const target_mid = protocol.key.id;
920
- const target_cid = protocol.key.remoteJid ?? cid;
921
- const doc = deserialize(await this.engine.get(`/chat/${target_cid}/message/${target_mid}`));
922
- if (protocol.type === proto.Message.ProtocolMessage.Type.MESSAGE_EDIT &&
923
- protocol.editedMessage &&
924
- doc) {
925
- doc.raw.message = protocol.editedMessage;
926
- doc.edited = true;
927
- doc.caption = message(this, doc.raw).caption;
928
- await this.engine.set(`/chat/${target_cid}/message/${target_mid}`, serialize(doc), doc.created_at);
929
- const msg_instance = message(this, doc);
930
- this.emit('message:updated', msg_instance, await msg_instance.chat(), this);
931
- }
932
- else if (protocol.type === proto.Message.ProtocolMessage.Type.REVOKE) {
933
- await this.engine.unset(`/chat/${target_cid}/message/${target_mid}`);
934
- if (doc) {
935
- const msg_instance = message(this, doc);
936
- this.emit('message:deleted', msg_instance, await msg_instance.chat(), this);
429
+ if (!stored && !doc.me) {
430
+ const known = deserialize(await engine.get(`/contact/${doc.author}`));
431
+ if (doc.author && !(known?.name ?? known?.notify ?? known?.verified_name)) {
432
+ socket.ev.emit('contacts.upsert', [{
433
+ id: doc.author,
434
+ lid: msg.key.remoteJid?.endsWith('@lid') ? msg.key.remoteJid : undefined,
435
+ notify: msg.pushName ?? undefined,
436
+ verifiedName: msg.verifiedBizName ?? undefined,
437
+ }]);
438
+ }
439
+ if (!(await engine.get(`/chat/${cid}`))) {
440
+ const owner = { id: cid, name: cid.endsWith('@g.us') ? null : msg.pushName ?? null, activity: doc.created_at };
441
+ await engine.set(`/chat/${cid}`, serialize(owner), doc.created_at);
442
+ this.emit('chat:created', new this.Chat(owner), this);
443
+ }
444
+ }
445
+ await engine.set(`/chat/${cid}/message/${mid}`, serialize(doc), doc.created_at);
446
+ const owner = deserialize(await engine.get(`/chat/${cid}`));
447
+ if (owner && doc.created_at > (owner.activity ?? 0)) {
448
+ owner.activity = doc.created_at;
449
+ await engine.set(`/chat/${cid}`, serialize(owner), doc.created_at);
450
+ }
451
+ if (!stored) {
452
+ const place = msg.message?.locationMessage ?? msg.message?.liveLocationMessage;
453
+ const poll = msg.message?.pollCreationMessage ?? msg.message?.pollCreationMessageV2 ?? msg.message?.pollCreationMessageV3;
454
+ const cards = msg.message?.contactsArrayMessage?.contacts ?? (msg.message?.contactMessage ? [msg.message.contactMessage] : []);
455
+ const body = doc.type === 'text' ? doc.caption
456
+ : doc.type === 'location' ? JSON.stringify({ lat: place?.degreesLatitude, lng: place?.degreesLongitude })
457
+ : doc.type === 'poll' ? JSON.stringify({ content: poll?.name ?? '', options: poll?.options?.map((option) => ({ content: option.optionName })) ?? [] })
458
+ : doc.type === 'vcard' ? cards.map((card) => card.vcard ?? '').join('\n')
459
+ : doc.type === 'event' ? JSON.stringify(msg.message?.eventMessage ?? {})
460
+ : null;
461
+ const binary = body !== null
462
+ ? Buffer.from(body, 'utf-8')
463
+ : ['image', 'video', 'audio', 'document'].includes(doc.type)
464
+ ? await downloadMediaMessage(msg, 'buffer', {}).catch(() => Buffer.alloc(0))
465
+ : Buffer.alloc(0);
466
+ if (binary.length > 0) {
467
+ await (engine.set_buffer?.(`/chat/${cid}/message/${mid}/content`, binary) ?? engine.set(`/chat/${cid}/message/${mid}/content`, serialize({ data: binary.toString('base64') })));
468
+ }
469
+ }
470
+ const instance = new Message(init, doc);
471
+ const owner_chat = await instance.chat();
472
+ this.emit('message:created', instance, owner_chat, this);
473
+ if (doc.forwarded) {
474
+ this.emit('message:forwarded', instance, owner_chat, this);
937
475
  }
938
476
  }
939
- }
940
- continue;
941
- }
942
- const doc = message(this, msg)._raw;
943
- // Cada reconexión re-entrega el historial completo: reescribir documentos
944
- // idénticos contamina la cronología, re-descarga la media y spamea eventos,
945
- // así que un doc ya persistido sin cambios visibles se salta entero.
946
- // Every reconnect re-delivers the full history: rewriting identical documents
947
- // pollutes chronology, re-downloads media and spams events, so an already
948
- // persisted doc without visible changes is skipped entirely.
949
- const existing_doc = deserialize(await this.engine.get(`/chat/${cid}/message/${mid}`));
950
- if (existing_doc) {
951
- if (typeof existing_doc.multiple === 'boolean') {
952
- doc.multiple = existing_doc.multiple;
953
- }
954
- if (existing_doc.reactions) {
955
- doc.reactions = existing_doc.reactions;
956
- }
957
- // El historial reporta el estado que tenía al sincronizarse: si acá se reescribe
958
- // por otro cambio (edición, destacado), el estado ya conocido se conserva.
959
- // History reports the state it had when synced: if the doc gets rewritten here
960
- // for another change (edit, star), the state already known is kept.
961
- if (existing_doc.status > doc.status) {
962
- doc.status = existing_doc.status;
963
- }
964
- if (existing_doc.status >= doc.status &&
965
- existing_doc.caption === doc.caption &&
966
- existing_doc.edited === doc.edited &&
967
- existing_doc.starred === doc.starred) {
968
- continue;
969
- }
970
- }
971
- // Autocreación de contacto/chat desde pushName cuando baileys no emite upsert previo
972
- if (!existing_doc && !doc.me) {
973
- const push_name = msg.pushName ?? null;
974
- const is_group = cid.endsWith('@g.us');
975
- // El contacto se completa cuando no existe y también cuando existe sin ningún
976
- // nombre: el mensaje trae el pushName y el nombre del negocio verificado, que
977
- // es lo único que queda si un re-sync anterior dejó la ficha en blanco.
978
- // The contact is filled in when missing and also when it exists with no name at
979
- // all: the message carries the pushName and the verified business name, the only
980
- // thing left when an earlier re-sync blanked the card.
981
- if (doc.author) {
982
- const known = deserialize(await this.engine.get(`/contact/${doc.author}`));
983
- if (!known || !(known.name ?? known.notify ?? known.verified_name)) {
984
- await this.#persist_contact({
985
- id: doc.author,
986
- lid: msg.key.remoteJid?.endsWith('@lid') ? msg.key.remoteJid : null,
987
- name: null,
988
- notify: push_name,
989
- verified_name: msg.verifiedBizName ?? null,
990
- img_url: null,
991
- status: null,
992
- });
477
+ }).catch(() => { });
478
+ });
479
+ socket.ev.on('messages.update', (updates) => {
480
+ chain = chain.then(async () => {
481
+ for (const { key, update } of updates) {
482
+ const found = key.remoteJid && key.id && key.remoteJid !== 'status@broadcast'
483
+ ? await locate(key.remoteJid, key.id)
484
+ : null;
485
+ if (found) {
486
+ const { path, doc } = found;
487
+ const patch = update;
488
+ const raw = doc.raw ?? { key };
489
+ if (patch.message) {
490
+ const edited = patch.message.editedMessage?.message;
491
+ raw.message = edited ?? { ...raw.message, ...patch.message };
492
+ doc.edited = doc.edited || Boolean(edited);
493
+ doc.caption = new Message(init, raw).caption;
494
+ doc.raw = raw;
495
+ await engine.set(path, serialize(doc), doc.created_at);
496
+ const instance = new Message(init, doc);
497
+ this.emit('message:updated', instance, await instance.chat(), this);
498
+ }
499
+ else if (patch.starred !== undefined) {
500
+ doc.starred = patch.starred === true;
501
+ raw.starred = doc.starred;
502
+ doc.raw = raw;
503
+ await engine.set(path, serialize(doc), doc.created_at);
504
+ const instance = new Message(init, doc);
505
+ this.emit(doc.starred ? 'message:starred' : 'message:unstarred', instance, await instance.chat(), this);
506
+ }
507
+ else if (patch.status !== undefined && (patch.status > doc.status || patch.status === proto.WebMessageInfo.Status.ERROR)) {
508
+ raw.status = patch.status;
509
+ doc.status = patch.status;
510
+ raw.messageStubParameters = patch.messageStubParameters ?? raw.messageStubParameters;
511
+ doc.raw = raw;
512
+ await engine.set(path, serialize(doc), doc.created_at);
513
+ const instance = new Message(init, doc);
514
+ this.emit('message:updated', instance, await instance.chat(), this);
515
+ }
516
+ }
993
517
  }
994
- }
995
- if (!(await this.engine.get(`/chat/${cid}`))) {
996
- const chat_raw = {
997
- id: cid,
998
- name: is_group ? null : push_name,
999
- activity: doc.created_at,
1000
- };
1001
- await this.engine.set(`/chat/${cid}`, serialize(chat_raw), doc.created_at);
1002
- this.emit('chat:created', new this.Chat(chat_raw), this);
1003
- }
1004
- }
1005
- await this.engine.set(`/chat/${cid}/message/${mid}`, serialize(doc), doc.created_at);
1006
- // El mensaje más nuevo define la posición del chat en la lista; un mensaje viejo
1007
- // que llega en un re-sync no la altera.
1008
- // The newest message defines the chat's position in the list; an old message
1009
- // arriving in a re-sync does not move it.
1010
- const chat_doc = deserialize(await this.engine.get(`/chat/${cid}`));
1011
- if (chat_doc && doc.created_at > (chat_doc.activity ?? 0)) {
1012
- chat_doc.activity = doc.created_at;
1013
- await this.engine.set(`/chat/${cid}`, serialize(chat_doc), doc.created_at);
1014
- }
1015
- // El binario solo se materializa en la primera entrega; en re-syncs ya vive en el engine.
1016
- // The binary is only materialized on first delivery; on re-syncs it already lives in the engine.
1017
- let content_buf = Buffer.alloc(0);
1018
- if (existing_doc) {
1019
- /* ya materializado / already materialized */
1020
- }
1021
- else if (doc.type === 'text') {
1022
- content_buf = Buffer.from(doc.caption, 'utf-8');
1023
- }
1024
- else if (doc.type === 'location') {
1025
- const loc = msg.message?.locationMessage ?? msg.message?.liveLocationMessage;
1026
- content_buf = Buffer.from(JSON.stringify({ lat: loc?.degreesLatitude, lng: loc?.degreesLongitude }), 'utf-8');
1027
- }
1028
- else if (doc.type === 'poll') {
1029
- const poll = msg.message?.pollCreationMessage ??
1030
- msg.message?.pollCreationMessageV2 ??
1031
- msg.message?.pollCreationMessageV3;
1032
- content_buf = Buffer.from(JSON.stringify({
1033
- content: poll?.name ?? '',
1034
- options: poll?.options?.map((o) => ({ content: o.optionName })) ?? [],
1035
- }), 'utf-8');
1036
- }
1037
- else if (doc.type === 'vcard') {
1038
- const cards = msg.message?.contactsArrayMessage?.contacts ?? (msg.message?.contactMessage ? [msg.message.contactMessage] : []);
1039
- content_buf = Buffer.from(cards.map((c) => c.vcard ?? '').join('\n'), 'utf-8');
1040
- }
1041
- else if (doc.type === 'event') {
1042
- content_buf = Buffer.from(JSON.stringify(msg.message?.eventMessage ?? {}), 'utf-8');
1043
- }
1044
- else if (this.#internals.socket && ['image', 'video', 'audio', 'document'].includes(doc.type)) {
1045
- try {
1046
- const buffer = await downloadMediaMessage(msg, 'buffer', {});
1047
- if (Buffer.isBuffer(buffer)) {
1048
- content_buf = buffer;
518
+ }).catch(() => { });
519
+ });
520
+ socket.ev.on('message-receipt.update', (updates) => {
521
+ chain = chain.then(async () => {
522
+ for (const { key, receipt } of updates) {
523
+ if (key.remoteJid === 'status@broadcast' && key.id) {
524
+ const doc = deserialize(await engine.get(`/status/${key.id}`));
525
+ if (doc && !doc.viewed) {
526
+ doc.viewed = true;
527
+ await engine.set(`/status/${key.id}`, serialize(doc));
528
+ this.emit('feed:updated', new Feed(init, doc), this);
529
+ }
530
+ continue;
531
+ }
532
+ const played = receipt.playedTimestamp != null;
533
+ const found = (played || receipt.readTimestamp != null) && key.remoteJid && key.id
534
+ ? await locate(key.remoteJid, key.id)
535
+ : null;
536
+ if (found) {
537
+ const next = played ? proto.WebMessageInfo.Status.PLAYED : proto.WebMessageInfo.Status.READ;
538
+ if (found.doc.status < next) {
539
+ found.doc.status = next;
540
+ found.doc.raw.status = next;
541
+ await engine.set(found.path, serialize(found.doc), found.doc.created_at);
542
+ }
543
+ const instance = new Message(init, found.doc);
544
+ this.emit('message:seen', instance, await instance.chat(), this);
545
+ }
1049
546
  }
1050
- }
1051
- catch {
1052
- /* media download may fail */
1053
- }
547
+ }).catch(() => { });
548
+ });
549
+ };
550
+ this.#close = async (quiet) => {
551
+ intentional = true;
552
+ silent = quiet;
553
+ if (timer) {
554
+ clearTimeout(timer);
555
+ timer = null;
1054
556
  }
1055
- if (content_buf.length > 0) {
1056
- await this.#write_content(`/chat/${cid}/message/${mid}/content`, content_buf);
557
+ try {
558
+ alive?.end(Object.assign(new Error('intentional close'), { output: { statusCode: DisconnectReason.connectionClosed } }));
1057
559
  }
1058
- const instance = message(this, doc);
1059
- const chat_instance = await instance.chat();
1060
- this.emit('message:created', instance, chat_instance, this);
1061
- if (doc.forwarded) {
1062
- this.emit('message:forwarded', instance, chat_instance, this);
560
+ catch {
561
+ /* el socket ya estaba cerrado / socket already closed */
1063
562
  }
1064
- }
1065
- }
563
+ alive = null;
564
+ };
565
+ start().catch(reject);
566
+ });
1066
567
  }
1067
568
  /**
1068
- * Ubica el documento de un mensaje partiendo del chat crudo del key. Los updates y
1069
- * receipts llegan direccionados por LID —con o sin dispositivo— mientras el documento
1070
- * vive bajo el JID con el que se guardó, así que se prueban ambas formas.
1071
- * Locates a message document from the raw chat in the key. Updates and receipts arrive
1072
- * LID-addressed —with or without device— while the document lives under the JID it was
1073
- * stored with, so both forms are tried.
569
+ * Cierra la sesión: cancela el reintento pendiente y termina el socket.
570
+ * Closes the session: cancels the pending retry and ends the socket.
1074
571
  *
1075
- * @param cid - Chat tal como viene en el key / Chat as it comes in the key
1076
- * @param mid - Identificador del mensaje / Message identifier
1077
- * @returns Ruta y documento, o null si no existe / Path and document, or null when missing
1078
- * @internal
572
+ * @param options - `silent` calla el evento `disconnected`; `destroy` vacía el engine / `silent` mutes the `disconnected` event; `destroy` clears the engine
1079
573
  */
1080
- async #locate(cid, mid) {
1081
- const tried = new Set();
1082
- for (const candidate of [await this.#resolve_jid(cid), cid, jidNormalizedUser(cid)]) {
1083
- if (candidate && !tried.has(candidate)) {
1084
- tried.add(candidate);
1085
- const path = `/chat/${candidate}/message/${mid}`;
1086
- const doc = deserialize(await this.engine.get(path));
1087
- if (doc) {
1088
- return { path, doc };
1089
- }
1090
- }
1091
- }
1092
- return null;
1093
- }
1094
- /** @internal */
1095
- async #handle_messages_update(updates) {
1096
- for (const { key, update: upd } of updates) {
1097
- if (key.remoteJid && key.id) {
1098
- // Updates sobre `status@broadcast` se descartan: el feed sólo se
1099
- // muta vía reacciones (`messages.reaction`), `Feed.view()` o REVOKE.
1100
- // Updates on `status@broadcast` are discarded: feed mutates only via
1101
- // reactions, `Feed.view()` or REVOKE.
1102
- if (key.remoteJid === 'status@broadcast') {
1103
- continue;
1104
- }
1105
- const found = await this.#locate(key.remoteJid, key.id);
1106
- if (found) {
1107
- const { path, doc } = found;
1108
- const raw = doc.raw ?? { key };
1109
- const upd_any = upd;
1110
- const edited_message = upd_any.message?.editedMessage?.message;
1111
- const content_update = upd_any.message;
1112
- const status = upd_any.status;
1113
- const starred_changed = upd_any.starred !== undefined;
1114
- if (edited_message) {
1115
- raw.message = edited_message;
1116
- doc.raw = raw;
1117
- doc.edited = true;
1118
- doc.caption = message(this, raw).caption;
1119
- await this.engine.set(path, serialize(doc), doc.created_at);
1120
- const msg_instance = message(this, doc);
1121
- this.emit('message:updated', msg_instance, await msg_instance.chat(), this);
1122
- }
1123
- else if (content_update) {
1124
- // Actualización de contenido (ej: live location). Mergea sobre el raw existente.
1125
- raw.message = { ...raw.message, ...content_update };
1126
- doc.raw = raw;
1127
- doc.caption = message(this, raw).caption;
1128
- await this.engine.set(path, serialize(doc), doc.created_at);
1129
- const msg_instance = message(this, doc);
1130
- this.emit('message:updated', msg_instance, await msg_instance.chat(), this);
1131
- }
1132
- else if (starred_changed) {
1133
- doc.starred = upd_any.starred === true;
1134
- raw.starred = doc.starred;
1135
- doc.raw = raw;
1136
- await this.engine.set(path, serialize(doc), doc.created_at);
1137
- const msg_instance = message(this, doc);
1138
- this.emit(doc.starred ? 'message:starred' : 'message:unstarred', msg_instance, await msg_instance.chat(), this);
1139
- // WhatsApp reemite los acks desordenados al reconectar (un `sent` después
1140
- // de un `delivered`), así que el estado solo avanza; el rechazo (`error`)
1141
- // es terminal y sí puede pisar lo que hubiera.
1142
- // WhatsApp re-emits acks out of order on reconnect (a `sent` after a
1143
- // `delivered`), so the state only moves forward; a rejection (`error`) is
1144
- // terminal and may override whatever was there.
1145
- }
1146
- else if (status !== undefined && (status > doc.status || status === ERROR)) {
1147
- raw.status = status;
1148
- doc.status = status;
1149
- // El rechazo del servidor viaja como stub del update; sin persistirlo el
1150
- // mensaje queda en error sin decir por qué.
1151
- // The server rejection travels as an update stub; without persisting it the
1152
- // message stays in error without saying why.
1153
- if (upd_any.messageStubParameters) {
1154
- raw.messageStubParameters = upd_any.messageStubParameters;
1155
- }
1156
- doc.raw = raw;
1157
- await this.engine.set(path, serialize(doc), doc.created_at);
1158
- const msg_instance = message(this, doc);
1159
- this.emit('message:updated', msg_instance, await msg_instance.chat(), this);
1160
- }
1161
- }
1162
- }
1163
- }
1164
- }
1165
- /** @internal */
1166
- async #handle_messages_reaction(reactions) {
1167
- for (const { key, reaction } of reactions) {
1168
- if (key.remoteJid && key.id) {
1169
- // Reacciones sobre status@broadcast → feed:updated (no message:reacted).
1170
- // Reactions on status@broadcast → feed:updated (not message:reacted).
1171
- if (key.remoteJid === 'status@broadcast') {
1172
- const feed_raw = deserialize(await this.engine.get(`/status/${key.id}`));
1173
- if (feed_raw) {
1174
- this.emit('feed:updated', new Feed(this, feed_raw), this);
1175
- }
1176
- continue;
1177
- }
1178
- const found = await this.#locate(key.remoteJid, key.id);
1179
- if (found) {
1180
- const { path, doc } = found;
1181
- const reactor = jidNormalizedUser(key.participant ?? key.remoteJid);
1182
- const emoji = reaction.text ?? '';
1183
- doc.reactions = [
1184
- ...(doc.reactions ?? []).filter((r) => r.author !== reactor),
1185
- ...(emoji ? [{ author: reactor, emoji, at: Date.now() }] : []),
1186
- ];
1187
- await this.engine.set(path, serialize(doc), doc.created_at);
1188
- const msg_instance = message(this, doc);
1189
- this.emit('message:reacted', msg_instance, await msg_instance.chat(), reaction.text ?? '', this);
1190
- }
1191
- }
574
+ async disconnect(options = {}) {
575
+ await this.#close?.(options.silent === true);
576
+ if (options.destroy) {
577
+ await this.engine.clear();
1192
578
  }
1193
579
  }
1194
580
  }
1195
- export default WhatsApp;