@arcaelas/whatsapp 7.1.0 → 7.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -47,6 +47,15 @@ function deserialize(raw) {
47
47
  return null;
48
48
  }
49
49
  }
50
+ /**
51
+ * Un mapeo que lleva a otro LID no traduce nada, y al ser truthy corta la cadena de respaldos
52
+ * de `jid_of` justo antes de llegar al mapping de baileys —el único que sí conoce el teléfono—.
53
+ * Descartarlo es lo que permite que la cadena siga buscando.
54
+ * A mapping leading to another LID translates nothing, and being truthy it cuts `jid_of`'s
55
+ * fallback chain right before reaching the baileys mapping —the only one that does know the
56
+ * phone—. Discarding it is what lets the chain keep looking.
57
+ */
58
+ const phone_like = (value) => (value != null && !String(value).endsWith('@lid') ? String(value) : null);
50
59
  /**
51
60
  * JID canónico de un teléfono, JID o LID: los grupos y JIDs pasan tal cual, el LID se mapea
52
61
  * contra los índices `/lid` del engine (o contra baileys cuando hay socket) y el resto se
@@ -65,9 +74,9 @@ const jid_of = async (engine, uid, socket) => {
65
74
  return uid;
66
75
  const lid = uid.endsWith('@lid') ? (0, baileys_1.jidNormalizedUser)(uid) : '';
67
76
  const mapped = lid
68
- ? deserialize(await engine.get(`/lid/${lid}`))
69
- ?? deserialize(await engine.get(`/lid/${lid.split('@')[0]}_reverse`))
70
- ?? await socket?.signalRepository?.lidMapping?.getPNForLID(lid).catch(() => null)
77
+ ? phone_like(deserialize(await engine.get(`/lid/${lid}`)))
78
+ ?? phone_like(deserialize(await engine.get(`/lid/${lid.split('@')[0]}_reverse`)))
79
+ ?? phone_like(await socket?.signalRepository?.lidMapping?.getPNForLID(lid).catch(() => null))
71
80
  : uid.replace(/\D/g, '');
72
81
  return mapped ? (String(mapped).includes('@') ? (0, baileys_1.jidNormalizedUser)(String(mapped)) : `${mapped}@s.whatsapp.net`) : null;
73
82
  };
@@ -21,6 +21,15 @@ interface Options {
21
21
  };
22
22
  /** Descargar el historial de mensajes al vincular; contactos, credenciales, LID mappings y tctokens se sincronizan siempre. / Download the message history on link; contacts, credentials, LID mappings and tctokens always sync. */
23
23
  sync?: boolean;
24
+ /**
25
+ * Nombre con el que esta sesión aparece en «Dispositivos vinculados» del teléfono. Cuando
26
+ * una cuenta tiene varias sesiones, es lo ÚNICO que permite distinguirlas para cerrar la
27
+ * correcta: sin él todas se ven iguales y no hay forma de saber cuál sobra.
28
+ * Name this session shows under the phone's «Linked devices». When an account holds several
29
+ * sessions it is the ONLY thing telling them apart to close the right one: without it they
30
+ * all look alike and there is no way to know which one is spare.
31
+ */
32
+ device?: string;
24
33
  }
25
34
  interface EventMap {
26
35
  connected: [WhatsApp];
@@ -63,10 +72,28 @@ export default class WhatsApp {
63
72
  off<E extends keyof EventMap>(event: E, handler: (...args: EventMap[E]) => void): this;
64
73
  connect(callback: (auth: string | Buffer) => void | Promise<void>): Promise<void>;
65
74
  /**
66
- * Cierra la sesión: cancela el reintento pendiente y termina el socket.
67
- * Closes the session: cancels the pending retry and ends the socket.
75
+ * Cierra la sesión de verdad: desvincula el dispositivo del teléfono y termina el socket.
76
+ * La promesa no resuelve hasta que todo eso ocurrió.
77
+ *
78
+ * Los dos flags modulan efectos secundarios, nunca si la sesión muere: `silent` sólo calla
79
+ * el evento `disconnected` local, y `destroy` decide si el engine se vacía o conserva
80
+ * chats, mensajes y contactos para estudiarlos después. Las credenciales se borran en los
81
+ * dos casos: el dispositivo ya no existe, así que reconectar con ellas sólo devolvería un
82
+ * `loggedOut`.
83
+ *
84
+ * Closes the session for real: unlinks the device from the phone and ends the socket. The
85
+ * promise does not settle until all of that happened.
86
+ *
87
+ * Both flags modulate side effects, never whether the session dies: `silent` only mutes the
88
+ * local `disconnected` event, and `destroy` decides whether the engine is wiped or keeps
89
+ * chats, messages and contacts for later study. Credentials go in both cases: the device no
90
+ * longer exists, so reconnecting with them would only return a `loggedOut`.
91
+ *
92
+ * @param options - `silent` calla el evento; `destroy` vacía el engine entero / `silent` mutes the event; `destroy` wipes the whole engine
68
93
  *
69
- * @param options - `silent` calla el evento `disconnected`; `destroy` vacía el engine / `silent` mutes the `disconnected` event; `destroy` clears the engine
94
+ * @example
95
+ * await wa.disconnect(); // desvincula y conserva el historial
96
+ * await wa.disconnect({ destroy: true }); // desvincula y no queda nada
70
97
  */
71
98
  disconnect(options?: {
72
99
  silent?: boolean;
@@ -45,10 +45,26 @@ const contact_1 = require("../../lib/contact");
45
45
  const message_1 = __importStar(require("../../lib/message"));
46
46
  const status_1 = require("../../lib/status");
47
47
  const store_1 = require("../../lib/store");
48
+ /**
49
+ * WhatsApp devuelve el nombre de la propia cuenta enmascarado —«+58∙∙∙∙∙∙∙∙40»— cuando el perfil
50
+ * no viajó completo. Eso no es un nombre: aceptarlo tapa al verdadero, que sí está guardado en
51
+ * la ficha del contacto propio.
52
+ * WhatsApp returns the own account name masked —«+58∙∙∙∙∙∙∙∙40»— when the profile did not travel
53
+ * whole. That is not a name: taking it hides the real one, which is stored on the own contact
54
+ * card.
55
+ */
56
+ const readable = (value) => (value && !/^\+?[\d\s·•∙⋅]+$/.test(value) ? value : null);
48
57
  class WhatsApp {
49
58
  #event = new node_events_1.EventEmitter();
50
59
  #options;
51
60
  #close = null;
61
+ /**
62
+ * Cierre completo: desvincula del teléfono y termina el socket. Es distinto de `#close`,
63
+ * que sólo cuelga —lo que hace falta al reconectar, donde desvincular sería absurdo—.
64
+ * Full close: unlinks from the phone and ends the socket. Distinct from `#close`, which
65
+ * merely hangs up —what reconnecting needs, where unlinking would be absurd—.
66
+ */
67
+ #unlink = null;
52
68
  constructor(options) {
53
69
  this.engine = options.engine;
54
70
  this.#options = options;
@@ -70,7 +86,7 @@ class WhatsApp {
70
86
  }
71
87
  async connect(callback) {
72
88
  const { engine } = this;
73
- const { phone, method, autoclean = true, sync = true, reconnect = true } = this.#options;
89
+ const { phone, method, autoclean = true, sync = true, reconnect = true, device } = this.#options;
74
90
  const digits = phone !== undefined ? String(phone).replace(/\D+/g, '') : '';
75
91
  const budget = reconnect === false ? 0 : reconnect === true ? null : typeof reconnect === 'number' ? reconnect : reconnect.max ?? null;
76
92
  const wait = typeof reconnect === 'object' ? (reconnect.interval ?? 60) * 1_000 : 60_000;
@@ -110,7 +126,7 @@ class WhatsApp {
110
126
  },
111
127
  },
112
128
  },
113
- browser: baileys_1.Browsers.windows('Chrome'),
129
+ browser: baileys_1.Browsers.appropriate(device ?? 'Orchestrator'),
114
130
  logger: (0, pino_1.default)({ level: 'silent' }),
115
131
  syncFullHistory: sync,
116
132
  shouldSyncHistoryMessage: ({ syncType }) => sync || syncType !== baileys_1.proto.HistorySync.HistorySyncType.FULL,
@@ -136,16 +152,29 @@ class WhatsApp {
136
152
  if (!user)
137
153
  return null;
138
154
  const id = (0, baileys_1.jidNormalizedUser)(user.id);
155
+ // La cuenta propia también se guarda por LID cuando el teléfono se anuncia
156
+ // así, y entonces la ficha del JID viene vacía: se leen las dos y gana la
157
+ // que tenga el dato.
158
+ // The own account is stored by LID too when the phone announces itself that
159
+ // way, and then the JID card comes back empty: both are read and whichever
160
+ // holds the data wins.
139
161
  const card = (0, store_1.deserialize)(await engine.get(`/contact/${id}`));
162
+ const alias = user.lid ? (0, store_1.deserialize)(await engine.get(`/contact/${(0, baileys_1.jidNormalizedUser)(user.lid)}`)) : null;
140
163
  return new contact_1.Account(init, {
141
164
  id,
142
165
  phone_number: id,
143
- lid: user.lid ?? card?.lid ?? null,
144
- name: user.name ?? card?.name ?? null,
145
- notify: card?.notify ?? null,
146
- verified_name: card?.verified_name ?? null,
147
- img_url: (await socket.profilePictureUrl(id, 'image').catch(() => null)) ?? card?.img_url ?? null,
148
- status: card?.status ?? null,
166
+ lid: user.lid ?? card?.lid ?? alias?.lid ?? null,
167
+ // `verified_name` es el nombre de una cuenta de empresa y `notify` el
168
+ // que la propia línea difunde en sus mensajes: cualquiera de los dos es
169
+ // el nombre real de la cuenta cuando el perfil no viajó en el login.
170
+ // `verified_name` is a business account's name and `notify` the one the
171
+ // line itself broadcasts in its messages: either is the account's real
172
+ // name when the profile did not travel in the login.
173
+ name: readable(user.name) ?? readable(card?.name) ?? readable(alias?.name) ?? card?.verified_name ?? alias?.verified_name ?? card?.notify ?? alias?.notify ?? null,
174
+ notify: card?.notify ?? alias?.notify ?? null,
175
+ verified_name: card?.verified_name ?? alias?.verified_name ?? null,
176
+ img_url: (await socket.profilePictureUrl(id, 'image').catch(() => null)) ?? card?.img_url ?? alias?.img_url ?? null,
177
+ status: card?.status ?? alias?.status ?? null,
149
178
  });
150
179
  };
151
180
  const locate = async (cid, mid) => {
@@ -160,6 +189,89 @@ class WhatsApp {
160
189
  }
161
190
  return null;
162
191
  };
192
+ /**
193
+ * Identidad con la que se guarda a alguien. El mismo contacto llega unas veces
194
+ * por teléfono y otras por LID, y tratar ambos como distintos le abre dos fichas
195
+ * y dos chats. El teléfono manda; el LID sólo se conserva cuando aún no hay
196
+ * forma de traducirlo.
197
+ * The identity someone is stored under. The same contact arrives sometimes by
198
+ * phone and sometimes by LID, and treating both as distinct opens two cards and
199
+ * two chats for them. The phone wins; the LID is only kept while there is still
200
+ * no way to translate it.
201
+ */
202
+ const canonical = async (uid) => (uid.endsWith('@lid') ? await (0, store_1.jid_of)(engine, uid, socket).catch(() => null) : null) ?? uid;
203
+ /** Índice LID↔teléfono, sólo cuando traduce de verdad. / LID↔phone index, only when it actually translates. */
204
+ const remember = async (lid, jid) => {
205
+ if (lid && !jid.endsWith('@lid')) {
206
+ await engine.set(`/lid/${lid}`, (0, store_1.serialize)(jid));
207
+ await engine.set(`/lid/${jid}`, (0, store_1.serialize)(lid));
208
+ }
209
+ };
210
+ /**
211
+ * Vuelca sobre el teléfono lo que se había guardado bajo el LID —ficha, chat y
212
+ * mensajes— y borra el duplicado. Los campos ya presentes en el destino ganan:
213
+ * son los que la cuenta viene usando.
214
+ * Pours whatever was stored under the LID —card, chat and messages— onto the
215
+ * phone and drops the duplicate. Fields already present on the target win: those
216
+ * are the ones the account has been using.
217
+ */
218
+ const absorb = async (lid, pn) => {
219
+ const [from, to] = [(0, baileys_1.jidNormalizedUser)(lid), (0, baileys_1.jidNormalizedUser)(pn)];
220
+ if (from !== to) {
221
+ const stale = (0, store_1.deserialize)(await engine.get(`/contact/${from}`));
222
+ if (stale) {
223
+ const target = (0, store_1.deserialize)(await engine.get(`/contact/${to}`));
224
+ await engine.set(`/contact/${to}`, (0, store_1.serialize)({ ...stale, ...target, id: to, lid: from }));
225
+ await engine.unset(`/contact/${from}`);
226
+ }
227
+ const orphan = (0, store_1.deserialize)(await engine.get(`/chat/${from}`));
228
+ if (orphan) {
229
+ const target = (0, store_1.deserialize)(await engine.get(`/chat/${to}`));
230
+ for (const raw of await engine.list(`/chat/${from}/message`, 0, 10_000)) {
231
+ const msg = (0, store_1.deserialize)(raw);
232
+ if (msg) {
233
+ await engine.set(`/chat/${to}/message/${msg.id}`, (0, store_1.serialize)({ ...msg, cid: to }), msg.created_at);
234
+ await engine.unset(`/chat/${from}/message/${msg.id}`);
235
+ }
236
+ }
237
+ const doc = { ...orphan, ...target, id: to, activity: Math.max(orphan.activity ?? 0, target?.activity ?? 0) || null };
238
+ await engine.set(`/chat/${to}`, (0, store_1.serialize)(doc), doc.activity ?? 0);
239
+ await engine.unset(`/chat/${from}`);
240
+ // Para quien escucha, el duplicado desaparece y el bueno aparece: es
241
+ // literalmente lo que pasó, y deja la lista sin la fila fantasma.
242
+ // To a listener the duplicate goes away and the good one shows up:
243
+ // that is literally what happened, and it leaves the list without
244
+ // the ghost row.
245
+ this.emit('chat:deleted', new this.Chat(orphan), this);
246
+ if (!target) {
247
+ this.emit('chat:created', new this.Chat(doc), this);
248
+ }
249
+ }
250
+ }
251
+ };
252
+ /**
253
+ * Pasa por todo lo guardado bajo un LID y lo une a su teléfono. Cubre lo que se
254
+ * escribió antes de que el mapeo existiera —o antes de que la librería supiera
255
+ * unirlo—, que es lo que deja la lista con el mismo contacto dos veces: una con
256
+ * su nombre y otra como un número largo sin sentido.
257
+ * Walks everything stored under a LID and joins it to its phone. It covers what
258
+ * was written before the mapping existed —or before the library knew how to join
259
+ * it—, which is what leaves the same contact twice in the list: once with a name
260
+ * and once as a long meaningless number.
261
+ */
262
+ const reconcile = async () => {
263
+ for (const path of ['/contact', '/chat']) {
264
+ for (const raw of await engine.list(path, 0, 10_000)) {
265
+ const id = (0, store_1.deserialize)(raw)?.id;
266
+ if (id?.endsWith('@lid')) {
267
+ const jid = await (0, store_1.jid_of)(engine, id, socket).catch(() => null);
268
+ if (jid) {
269
+ await absorb(id, jid);
270
+ }
271
+ }
272
+ }
273
+ }
274
+ };
163
275
  socket.ev.on('creds.update', () => engine.set('/session/creds', (0, store_1.serialize)(creds)));
164
276
  socket.ev.on('connection.update', async ({ connection, lastDisconnect, qr }) => {
165
277
  if (qr && !creds.registered) {
@@ -170,6 +282,13 @@ class WhatsApp {
170
282
  if (connection === 'open') {
171
283
  connected = true;
172
284
  retries = 0;
285
+ // Los mapeos que faltaban ya viajaron en el handshake: recién ahora se
286
+ // puede unir lo que quedó partido en sesiones anteriores, cuando esos
287
+ // LID todavía eran intraducibles.
288
+ // The missing mappings already travelled in the handshake: only now can
289
+ // whatever stayed split in earlier sessions be joined, back when those
290
+ // LIDs were still untranslatable.
291
+ chain = chain.then(reconcile).catch(() => { });
173
292
  this.emit('connected', this);
174
293
  resolve();
175
294
  }
@@ -209,10 +328,11 @@ class WhatsApp {
209
328
  chain = chain.then(async () => {
210
329
  for (const row of rows) {
211
330
  if (row.id) {
212
- const current = (0, store_1.deserialize)(await engine.get(`/contact/${row.id}`));
331
+ const id = await canonical(row.id);
332
+ const current = (0, store_1.deserialize)(await engine.get(`/contact/${id}`));
213
333
  const doc = {
214
- id: row.id,
215
- lid: row.lid ?? current?.lid ?? null,
334
+ id,
335
+ lid: row.lid ?? (row.id.endsWith('@lid') ? row.id : null) ?? current?.lid ?? null,
216
336
  name: row.name ?? current?.name ?? null,
217
337
  notify: row.notify ?? current?.notify ?? null,
218
338
  verified_name: row.verifiedName ?? current?.verified_name ?? null,
@@ -220,13 +340,11 @@ class WhatsApp {
220
340
  status: row.status ?? current?.status ?? null,
221
341
  };
222
342
  if (!current || JSON.stringify(current) !== JSON.stringify(doc)) {
223
- await engine.set(`/contact/${row.id}`, (0, store_1.serialize)(doc));
224
- if (doc.lid) {
225
- await engine.set(`/lid/${doc.lid}`, (0, store_1.serialize)(doc.id));
226
- }
343
+ await engine.set(`/contact/${id}`, (0, store_1.serialize)(doc));
344
+ await remember(doc.lid, id);
227
345
  const person = new this.Contact(doc);
228
- const owner = (0, store_1.deserialize)(await engine.get(`/chat/${doc.id}`));
229
- this.emit(current ? 'contact:updated' : 'contact:created', person, new this.Chat(owner ?? { id: doc.id, name: person.name }), this);
346
+ const owner = (0, store_1.deserialize)(await engine.get(`/chat/${id}`));
347
+ this.emit(current ? 'contact:updated' : 'contact:created', person, new this.Chat(owner ?? { id, name: person.name }), this);
230
348
  }
231
349
  }
232
350
  }
@@ -235,38 +353,44 @@ class WhatsApp {
235
353
  socket.ev.on('contacts.update', (rows) => {
236
354
  chain = chain.then(async () => {
237
355
  for (const row of rows) {
238
- const current = row.id ? (0, store_1.deserialize)(await engine.get(`/contact/${row.id}`)) : null;
356
+ const id = row.id ? await canonical(row.id) : '';
357
+ const current = id ? (0, store_1.deserialize)(await engine.get(`/contact/${id}`)) : null;
239
358
  const patch = {
240
359
  ...(row.notify && { notify: row.notify }),
241
360
  ...(row.name && { name: row.name }),
242
361
  ...(row.verifiedName && { verified_name: row.verifiedName }),
243
362
  ...(typeof row.imgUrl === 'string' && { img_url: row.imgUrl }),
244
363
  ...(row.status && { status: row.status }),
245
- ...(row.lid && { lid: row.lid }),
364
+ ...((row.lid ?? (row.id?.endsWith('@lid') ? row.id : null)) && { lid: row.lid ?? row.id }),
246
365
  };
247
- if (current && row.id && Object.keys(patch).length > 0) {
366
+ if (current && Object.keys(patch).length > 0) {
248
367
  const doc = { ...current, ...patch };
249
- await engine.set(`/contact/${row.id}`, (0, store_1.serialize)(doc));
250
- if (patch.lid) {
251
- await engine.set(`/lid/${patch.lid}`, (0, store_1.serialize)(row.id));
252
- }
368
+ await engine.set(`/contact/${id}`, (0, store_1.serialize)(doc));
369
+ await remember(patch.lid, id);
253
370
  const person = new this.Contact(doc);
254
- const owner = (0, store_1.deserialize)(await engine.get(`/chat/${row.id}`));
255
- this.emit('contact:updated', person, new this.Chat(owner ?? { id: row.id, name: person.name }), this);
371
+ const owner = (0, store_1.deserialize)(await engine.get(`/chat/${id}`));
372
+ this.emit('contact:updated', person, new this.Chat(owner ?? { id, name: person.name }), this);
256
373
  }
257
374
  }
258
375
  }).catch(() => { });
259
376
  });
260
377
  socket.ev.on('lid-mapping.update', ({ lid, pn }) => {
261
378
  chain = chain.then(async () => {
262
- await engine.set(`/lid/${lid}`, (0, store_1.serialize)(pn));
263
- await engine.set(`/lid/${pn}`, (0, store_1.serialize)(lid));
379
+ await remember(lid, pn);
380
+ // El mapeo recién llega: lo que se guardó bajo el LID mientras era
381
+ // irresoluble se une ahora a su teléfono, o el contacto queda partido
382
+ // en dos fichas y dos chats que nunca se vuelven a encontrar.
383
+ // The mapping just arrived: whatever was stored under the LID while it
384
+ // was unresolvable now joins its phone, or the contact stays split into
385
+ // two cards and two chats that never meet again.
386
+ await absorb(lid, pn);
264
387
  }).catch(() => { });
265
388
  });
266
389
  socket.ev.on('chats.upsert', (rows) => {
267
390
  chain = chain.then(async () => {
268
- for (const row of rows) {
269
- if (row.id) {
391
+ for (const raw of rows) {
392
+ if (raw.id) {
393
+ const row = { ...raw, id: await canonical(raw.id) };
270
394
  const current = (0, store_1.deserialize)(await engine.get(`/chat/${row.id}`));
271
395
  const doc = current ?? {
272
396
  id: row.id,
@@ -476,18 +600,33 @@ class WhatsApp {
476
600
  continue;
477
601
  }
478
602
  }
603
+ if (!stored && doc.me && msg.pushName && socket.user) {
604
+ // Un mensaje propio lleva el nombre con el que la cuenta se
605
+ // anuncia al mundo. Cuando el perfil no viajó en el login —una
606
+ // reconexión, por ejemplo— esta es la única vía para conocerlo,
607
+ // y sin ella la línea se ve a sí misma como un número.
608
+ // An own message carries the name the account announces itself
609
+ // with. When the profile did not travel in the login —a
610
+ // reconnection, say— this is the only way to learn it, and
611
+ // without it the line sees itself as a number.
612
+ const own = (0, baileys_1.jidNormalizedUser)(socket.user.id);
613
+ const known = (0, store_1.deserialize)(await engine.get(`/contact/${own}`));
614
+ if (!(known?.name ?? known?.notify ?? known?.verified_name)) {
615
+ socket.ev.emit('contacts.upsert', [{ id: own, lid: socket.user.lid, notify: readable(msg.pushName) ?? undefined }]);
616
+ }
617
+ }
479
618
  if (!stored && !doc.me) {
480
619
  const known = (0, store_1.deserialize)(await engine.get(`/contact/${doc.author}`));
481
620
  if (doc.author && !(known?.name ?? known?.notify ?? known?.verified_name)) {
482
621
  socket.ev.emit('contacts.upsert', [{
483
622
  id: doc.author,
484
623
  lid: msg.key.remoteJid?.endsWith('@lid') ? msg.key.remoteJid : undefined,
485
- notify: msg.pushName ?? undefined,
624
+ notify: readable(msg.pushName) ?? undefined,
486
625
  verifiedName: msg.verifiedBizName ?? undefined,
487
626
  }]);
488
627
  }
489
628
  if (!(await engine.get(`/chat/${cid}`))) {
490
- const owner = { id: cid, name: cid.endsWith('@g.us') ? null : msg.pushName ?? null, activity: doc.created_at };
629
+ const owner = { id: cid, name: cid.endsWith('@g.us') ? null : readable(msg.pushName), activity: doc.created_at };
491
630
  await engine.set(`/chat/${cid}`, (0, store_1.serialize)(owner), doc.created_at);
492
631
  this.emit('chat:created', new this.Chat(owner), this);
493
632
  }
@@ -597,6 +736,34 @@ class WhatsApp {
597
736
  }).catch(() => { });
598
737
  });
599
738
  };
739
+ this.#unlink = async (quiet) => {
740
+ intentional = true;
741
+ silent = quiet;
742
+ if (timer) {
743
+ clearTimeout(timer);
744
+ timer = null;
745
+ }
746
+ try {
747
+ // `logout` avisa al teléfono y termina el socket; la promesa no resuelve
748
+ // hasta que el teléfono acusa, que es cuando el dispositivo ya no existe.
749
+ // `logout` notifies the phone and ends the socket; the promise does not
750
+ // settle until the phone acknowledges, which is when the device is gone.
751
+ await alive?.logout();
752
+ }
753
+ catch {
754
+ // Sin red o con el socket ya muerto no hay a quién avisar: se cierra de
755
+ // este lado para no dejar el proceso colgado de una sesión que no existe.
756
+ // With no network or an already dead socket there is nobody to notify: it
757
+ // closes on this side so the process is not left hanging on a dead session.
758
+ try {
759
+ alive?.end(Object.assign(new Error('intentional close'), { output: { statusCode: baileys_1.DisconnectReason.connectionClosed } }));
760
+ }
761
+ catch {
762
+ /* el socket ya estaba cerrado / socket already closed */
763
+ }
764
+ }
765
+ alive = null;
766
+ };
600
767
  this.#close = async (quiet) => {
601
768
  intentional = true;
602
769
  silent = quiet;
@@ -616,16 +783,34 @@ class WhatsApp {
616
783
  });
617
784
  }
618
785
  /**
619
- * Cierra la sesión: cancela el reintento pendiente y termina el socket.
620
- * Closes the session: cancels the pending retry and ends the socket.
786
+ * Cierra la sesión de verdad: desvincula el dispositivo del teléfono y termina el socket.
787
+ * La promesa no resuelve hasta que todo eso ocurrió.
788
+ *
789
+ * Los dos flags modulan efectos secundarios, nunca si la sesión muere: `silent` sólo calla
790
+ * el evento `disconnected` local, y `destroy` decide si el engine se vacía o conserva
791
+ * chats, mensajes y contactos para estudiarlos después. Las credenciales se borran en los
792
+ * dos casos: el dispositivo ya no existe, así que reconectar con ellas sólo devolvería un
793
+ * `loggedOut`.
794
+ *
795
+ * Closes the session for real: unlinks the device from the phone and ends the socket. The
796
+ * promise does not settle until all of that happened.
797
+ *
798
+ * Both flags modulate side effects, never whether the session dies: `silent` only mutes the
799
+ * local `disconnected` event, and `destroy` decides whether the engine is wiped or keeps
800
+ * chats, messages and contacts for later study. Credentials go in both cases: the device no
801
+ * longer exists, so reconnecting with them would only return a `loggedOut`.
802
+ *
803
+ * @param options - `silent` calla el evento; `destroy` vacía el engine entero / `silent` mutes the event; `destroy` wipes the whole engine
621
804
  *
622
- * @param options - `silent` calla el evento `disconnected`; `destroy` vacía el engine / `silent` mutes the `disconnected` event; `destroy` clears the engine
805
+ * @example
806
+ * await wa.disconnect(); // desvincula y conserva el historial
807
+ * await wa.disconnect({ destroy: true }); // desvincula y no queda nada
623
808
  */
624
809
  async disconnect(options = {}) {
625
- await this.#close?.(options.silent === true);
626
- if (options.destroy) {
627
- await this.engine.clear();
628
- }
810
+ await this.#unlink?.(options.silent === true);
811
+ this.#unlink = null;
812
+ this.#close = null;
813
+ await (options.destroy ? this.engine.clear() : this.engine.unset('/session'));
629
814
  }
630
815
  }
631
816
  exports.default = WhatsApp;
@@ -38,6 +38,15 @@ export function deserialize(raw) {
38
38
  return null;
39
39
  }
40
40
  }
41
+ /**
42
+ * Un mapeo que lleva a otro LID no traduce nada, y al ser truthy corta la cadena de respaldos
43
+ * de `jid_of` justo antes de llegar al mapping de baileys —el único que sí conoce el teléfono—.
44
+ * Descartarlo es lo que permite que la cadena siga buscando.
45
+ * A mapping leading to another LID translates nothing, and being truthy it cuts `jid_of`'s
46
+ * fallback chain right before reaching the baileys mapping —the only one that does know the
47
+ * phone—. Discarding it is what lets the chain keep looking.
48
+ */
49
+ const phone_like = (value) => (value != null && !String(value).endsWith('@lid') ? String(value) : null);
41
50
  /**
42
51
  * JID canónico de un teléfono, JID o LID: los grupos y JIDs pasan tal cual, el LID se mapea
43
52
  * contra los índices `/lid` del engine (o contra baileys cuando hay socket) y el resto se
@@ -56,9 +65,9 @@ export const jid_of = async (engine, uid, socket) => {
56
65
  return uid;
57
66
  const lid = uid.endsWith('@lid') ? jidNormalizedUser(uid) : '';
58
67
  const mapped = lid
59
- ? deserialize(await engine.get(`/lid/${lid}`))
60
- ?? deserialize(await engine.get(`/lid/${lid.split('@')[0]}_reverse`))
61
- ?? await socket?.signalRepository?.lidMapping?.getPNForLID(lid).catch(() => null)
68
+ ? phone_like(deserialize(await engine.get(`/lid/${lid}`)))
69
+ ?? phone_like(deserialize(await engine.get(`/lid/${lid.split('@')[0]}_reverse`)))
70
+ ?? phone_like(await socket?.signalRepository?.lidMapping?.getPNForLID(lid).catch(() => null))
62
71
  : uid.replace(/\D/g, '');
63
72
  return mapped ? (String(mapped).includes('@') ? jidNormalizedUser(String(mapped)) : `${mapped}@s.whatsapp.net`) : null;
64
73
  };
@@ -21,6 +21,15 @@ interface Options {
21
21
  };
22
22
  /** Descargar el historial de mensajes al vincular; contactos, credenciales, LID mappings y tctokens se sincronizan siempre. / Download the message history on link; contacts, credentials, LID mappings and tctokens always sync. */
23
23
  sync?: boolean;
24
+ /**
25
+ * Nombre con el que esta sesión aparece en «Dispositivos vinculados» del teléfono. Cuando
26
+ * una cuenta tiene varias sesiones, es lo ÚNICO que permite distinguirlas para cerrar la
27
+ * correcta: sin él todas se ven iguales y no hay forma de saber cuál sobra.
28
+ * Name this session shows under the phone's «Linked devices». When an account holds several
29
+ * sessions it is the ONLY thing telling them apart to close the right one: without it they
30
+ * all look alike and there is no way to know which one is spare.
31
+ */
32
+ device?: string;
24
33
  }
25
34
  interface EventMap {
26
35
  connected: [WhatsApp];
@@ -63,10 +72,28 @@ export default class WhatsApp {
63
72
  off<E extends keyof EventMap>(event: E, handler: (...args: EventMap[E]) => void): this;
64
73
  connect(callback: (auth: string | Buffer) => void | Promise<void>): Promise<void>;
65
74
  /**
66
- * Cierra la sesión: cancela el reintento pendiente y termina el socket.
67
- * Closes the session: cancels the pending retry and ends the socket.
75
+ * Cierra la sesión de verdad: desvincula el dispositivo del teléfono y termina el socket.
76
+ * La promesa no resuelve hasta que todo eso ocurrió.
77
+ *
78
+ * Los dos flags modulan efectos secundarios, nunca si la sesión muere: `silent` sólo calla
79
+ * el evento `disconnected` local, y `destroy` decide si el engine se vacía o conserva
80
+ * chats, mensajes y contactos para estudiarlos después. Las credenciales se borran en los
81
+ * dos casos: el dispositivo ya no existe, así que reconectar con ellas sólo devolvería un
82
+ * `loggedOut`.
83
+ *
84
+ * Closes the session for real: unlinks the device from the phone and ends the socket. The
85
+ * promise does not settle until all of that happened.
86
+ *
87
+ * Both flags modulate side effects, never whether the session dies: `silent` only mutes the
88
+ * local `disconnected` event, and `destroy` decides whether the engine is wiped or keeps
89
+ * chats, messages and contacts for later study. Credentials go in both cases: the device no
90
+ * longer exists, so reconnecting with them would only return a `loggedOut`.
91
+ *
92
+ * @param options - `silent` calla el evento; `destroy` vacía el engine entero / `silent` mutes the event; `destroy` wipes the whole engine
68
93
  *
69
- * @param options - `silent` calla el evento `disconnected`; `destroy` vacía el engine / `silent` mutes the `disconnected` event; `destroy` clears the engine
94
+ * @example
95
+ * await wa.disconnect(); // desvincula y conserva el historial
96
+ * await wa.disconnect({ destroy: true }); // desvincula y no queda nada
70
97
  */
71
98
  disconnect(options?: {
72
99
  silent?: boolean;
@@ -7,10 +7,26 @@ import { Account, contact } from '../../lib/contact/index.js';
7
7
  import Message, { message } from '../../lib/message/index.js';
8
8
  import { Feed, TTL_MS as FEED_TTL_MS } from '../../lib/status/index.js';
9
9
  import { deserialize, jid_of, serialize } from '../../lib/store/index.js';
10
+ /**
11
+ * WhatsApp devuelve el nombre de la propia cuenta enmascarado —«+58∙∙∙∙∙∙∙∙40»— cuando el perfil
12
+ * no viajó completo. Eso no es un nombre: aceptarlo tapa al verdadero, que sí está guardado en
13
+ * la ficha del contacto propio.
14
+ * WhatsApp returns the own account name masked —«+58∙∙∙∙∙∙∙∙40»— when the profile did not travel
15
+ * whole. That is not a name: taking it hides the real one, which is stored on the own contact
16
+ * card.
17
+ */
18
+ const readable = (value) => (value && !/^\+?[\d\s·•∙⋅]+$/.test(value) ? value : null);
10
19
  export default class WhatsApp {
11
20
  #event = new EventEmitter();
12
21
  #options;
13
22
  #close = null;
23
+ /**
24
+ * Cierre completo: desvincula del teléfono y termina el socket. Es distinto de `#close`,
25
+ * que sólo cuelga —lo que hace falta al reconectar, donde desvincular sería absurdo—.
26
+ * Full close: unlinks from the phone and ends the socket. Distinct from `#close`, which
27
+ * merely hangs up —what reconnecting needs, where unlinking would be absurd—.
28
+ */
29
+ #unlink = null;
14
30
  constructor(options) {
15
31
  this.engine = options.engine;
16
32
  this.#options = options;
@@ -32,7 +48,7 @@ export default class WhatsApp {
32
48
  }
33
49
  async connect(callback) {
34
50
  const { engine } = this;
35
- const { phone, method, autoclean = true, sync = true, reconnect = true } = this.#options;
51
+ const { phone, method, autoclean = true, sync = true, reconnect = true, device } = this.#options;
36
52
  const digits = phone !== undefined ? String(phone).replace(/\D+/g, '') : '';
37
53
  const budget = reconnect === false ? 0 : reconnect === true ? null : typeof reconnect === 'number' ? reconnect : reconnect.max ?? null;
38
54
  const wait = typeof reconnect === 'object' ? (reconnect.interval ?? 60) * 1_000 : 60_000;
@@ -72,7 +88,7 @@ export default class WhatsApp {
72
88
  },
73
89
  },
74
90
  },
75
- browser: Browsers.windows('Chrome'),
91
+ browser: Browsers.appropriate(device ?? 'Orchestrator'),
76
92
  logger: pino({ level: 'silent' }),
77
93
  syncFullHistory: sync,
78
94
  shouldSyncHistoryMessage: ({ syncType }) => sync || syncType !== proto.HistorySync.HistorySyncType.FULL,
@@ -98,16 +114,29 @@ export default class WhatsApp {
98
114
  if (!user)
99
115
  return null;
100
116
  const id = jidNormalizedUser(user.id);
117
+ // La cuenta propia también se guarda por LID cuando el teléfono se anuncia
118
+ // así, y entonces la ficha del JID viene vacía: se leen las dos y gana la
119
+ // que tenga el dato.
120
+ // The own account is stored by LID too when the phone announces itself that
121
+ // way, and then the JID card comes back empty: both are read and whichever
122
+ // holds the data wins.
101
123
  const card = deserialize(await engine.get(`/contact/${id}`));
124
+ const alias = user.lid ? deserialize(await engine.get(`/contact/${jidNormalizedUser(user.lid)}`)) : null;
102
125
  return new Account(init, {
103
126
  id,
104
127
  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,
128
+ lid: user.lid ?? card?.lid ?? alias?.lid ?? null,
129
+ // `verified_name` es el nombre de una cuenta de empresa y `notify` el
130
+ // que la propia línea difunde en sus mensajes: cualquiera de los dos es
131
+ // el nombre real de la cuenta cuando el perfil no viajó en el login.
132
+ // `verified_name` is a business account's name and `notify` the one the
133
+ // line itself broadcasts in its messages: either is the account's real
134
+ // name when the profile did not travel in the login.
135
+ name: readable(user.name) ?? readable(card?.name) ?? readable(alias?.name) ?? card?.verified_name ?? alias?.verified_name ?? card?.notify ?? alias?.notify ?? null,
136
+ notify: card?.notify ?? alias?.notify ?? null,
137
+ verified_name: card?.verified_name ?? alias?.verified_name ?? null,
138
+ img_url: (await socket.profilePictureUrl(id, 'image').catch(() => null)) ?? card?.img_url ?? alias?.img_url ?? null,
139
+ status: card?.status ?? alias?.status ?? null,
111
140
  });
112
141
  };
113
142
  const locate = async (cid, mid) => {
@@ -122,6 +151,89 @@ export default class WhatsApp {
122
151
  }
123
152
  return null;
124
153
  };
154
+ /**
155
+ * Identidad con la que se guarda a alguien. El mismo contacto llega unas veces
156
+ * por teléfono y otras por LID, y tratar ambos como distintos le abre dos fichas
157
+ * y dos chats. El teléfono manda; el LID sólo se conserva cuando aún no hay
158
+ * forma de traducirlo.
159
+ * The identity someone is stored under. The same contact arrives sometimes by
160
+ * phone and sometimes by LID, and treating both as distinct opens two cards and
161
+ * two chats for them. The phone wins; the LID is only kept while there is still
162
+ * no way to translate it.
163
+ */
164
+ const canonical = async (uid) => (uid.endsWith('@lid') ? await jid_of(engine, uid, socket).catch(() => null) : null) ?? uid;
165
+ /** Índice LID↔teléfono, sólo cuando traduce de verdad. / LID↔phone index, only when it actually translates. */
166
+ const remember = async (lid, jid) => {
167
+ if (lid && !jid.endsWith('@lid')) {
168
+ await engine.set(`/lid/${lid}`, serialize(jid));
169
+ await engine.set(`/lid/${jid}`, serialize(lid));
170
+ }
171
+ };
172
+ /**
173
+ * Vuelca sobre el teléfono lo que se había guardado bajo el LID —ficha, chat y
174
+ * mensajes— y borra el duplicado. Los campos ya presentes en el destino ganan:
175
+ * son los que la cuenta viene usando.
176
+ * Pours whatever was stored under the LID —card, chat and messages— onto the
177
+ * phone and drops the duplicate. Fields already present on the target win: those
178
+ * are the ones the account has been using.
179
+ */
180
+ const absorb = async (lid, pn) => {
181
+ const [from, to] = [jidNormalizedUser(lid), jidNormalizedUser(pn)];
182
+ if (from !== to) {
183
+ const stale = deserialize(await engine.get(`/contact/${from}`));
184
+ if (stale) {
185
+ const target = deserialize(await engine.get(`/contact/${to}`));
186
+ await engine.set(`/contact/${to}`, serialize({ ...stale, ...target, id: to, lid: from }));
187
+ await engine.unset(`/contact/${from}`);
188
+ }
189
+ const orphan = deserialize(await engine.get(`/chat/${from}`));
190
+ if (orphan) {
191
+ const target = deserialize(await engine.get(`/chat/${to}`));
192
+ for (const raw of await engine.list(`/chat/${from}/message`, 0, 10_000)) {
193
+ const msg = deserialize(raw);
194
+ if (msg) {
195
+ await engine.set(`/chat/${to}/message/${msg.id}`, serialize({ ...msg, cid: to }), msg.created_at);
196
+ await engine.unset(`/chat/${from}/message/${msg.id}`);
197
+ }
198
+ }
199
+ const doc = { ...orphan, ...target, id: to, activity: Math.max(orphan.activity ?? 0, target?.activity ?? 0) || null };
200
+ await engine.set(`/chat/${to}`, serialize(doc), doc.activity ?? 0);
201
+ await engine.unset(`/chat/${from}`);
202
+ // Para quien escucha, el duplicado desaparece y el bueno aparece: es
203
+ // literalmente lo que pasó, y deja la lista sin la fila fantasma.
204
+ // To a listener the duplicate goes away and the good one shows up:
205
+ // that is literally what happened, and it leaves the list without
206
+ // the ghost row.
207
+ this.emit('chat:deleted', new this.Chat(orphan), this);
208
+ if (!target) {
209
+ this.emit('chat:created', new this.Chat(doc), this);
210
+ }
211
+ }
212
+ }
213
+ };
214
+ /**
215
+ * Pasa por todo lo guardado bajo un LID y lo une a su teléfono. Cubre lo que se
216
+ * escribió antes de que el mapeo existiera —o antes de que la librería supiera
217
+ * unirlo—, que es lo que deja la lista con el mismo contacto dos veces: una con
218
+ * su nombre y otra como un número largo sin sentido.
219
+ * Walks everything stored under a LID and joins it to its phone. It covers what
220
+ * was written before the mapping existed —or before the library knew how to join
221
+ * it—, which is what leaves the same contact twice in the list: once with a name
222
+ * and once as a long meaningless number.
223
+ */
224
+ const reconcile = async () => {
225
+ for (const path of ['/contact', '/chat']) {
226
+ for (const raw of await engine.list(path, 0, 10_000)) {
227
+ const id = deserialize(raw)?.id;
228
+ if (id?.endsWith('@lid')) {
229
+ const jid = await jid_of(engine, id, socket).catch(() => null);
230
+ if (jid) {
231
+ await absorb(id, jid);
232
+ }
233
+ }
234
+ }
235
+ }
236
+ };
125
237
  socket.ev.on('creds.update', () => engine.set('/session/creds', serialize(creds)));
126
238
  socket.ev.on('connection.update', async ({ connection, lastDisconnect, qr }) => {
127
239
  if (qr && !creds.registered) {
@@ -132,6 +244,13 @@ export default class WhatsApp {
132
244
  if (connection === 'open') {
133
245
  connected = true;
134
246
  retries = 0;
247
+ // Los mapeos que faltaban ya viajaron en el handshake: recién ahora se
248
+ // puede unir lo que quedó partido en sesiones anteriores, cuando esos
249
+ // LID todavía eran intraducibles.
250
+ // The missing mappings already travelled in the handshake: only now can
251
+ // whatever stayed split in earlier sessions be joined, back when those
252
+ // LIDs were still untranslatable.
253
+ chain = chain.then(reconcile).catch(() => { });
135
254
  this.emit('connected', this);
136
255
  resolve();
137
256
  }
@@ -171,10 +290,11 @@ export default class WhatsApp {
171
290
  chain = chain.then(async () => {
172
291
  for (const row of rows) {
173
292
  if (row.id) {
174
- const current = deserialize(await engine.get(`/contact/${row.id}`));
293
+ const id = await canonical(row.id);
294
+ const current = deserialize(await engine.get(`/contact/${id}`));
175
295
  const doc = {
176
- id: row.id,
177
- lid: row.lid ?? current?.lid ?? null,
296
+ id,
297
+ lid: row.lid ?? (row.id.endsWith('@lid') ? row.id : null) ?? current?.lid ?? null,
178
298
  name: row.name ?? current?.name ?? null,
179
299
  notify: row.notify ?? current?.notify ?? null,
180
300
  verified_name: row.verifiedName ?? current?.verified_name ?? null,
@@ -182,13 +302,11 @@ export default class WhatsApp {
182
302
  status: row.status ?? current?.status ?? null,
183
303
  };
184
304
  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));
188
- }
305
+ await engine.set(`/contact/${id}`, serialize(doc));
306
+ await remember(doc.lid, id);
189
307
  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);
308
+ const owner = deserialize(await engine.get(`/chat/${id}`));
309
+ this.emit(current ? 'contact:updated' : 'contact:created', person, new this.Chat(owner ?? { id, name: person.name }), this);
192
310
  }
193
311
  }
194
312
  }
@@ -197,38 +315,44 @@ export default class WhatsApp {
197
315
  socket.ev.on('contacts.update', (rows) => {
198
316
  chain = chain.then(async () => {
199
317
  for (const row of rows) {
200
- const current = row.id ? deserialize(await engine.get(`/contact/${row.id}`)) : null;
318
+ const id = row.id ? await canonical(row.id) : '';
319
+ const current = id ? deserialize(await engine.get(`/contact/${id}`)) : null;
201
320
  const patch = {
202
321
  ...(row.notify && { notify: row.notify }),
203
322
  ...(row.name && { name: row.name }),
204
323
  ...(row.verifiedName && { verified_name: row.verifiedName }),
205
324
  ...(typeof row.imgUrl === 'string' && { img_url: row.imgUrl }),
206
325
  ...(row.status && { status: row.status }),
207
- ...(row.lid && { lid: row.lid }),
326
+ ...((row.lid ?? (row.id?.endsWith('@lid') ? row.id : null)) && { lid: row.lid ?? row.id }),
208
327
  };
209
- if (current && row.id && Object.keys(patch).length > 0) {
328
+ if (current && Object.keys(patch).length > 0) {
210
329
  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
- }
330
+ await engine.set(`/contact/${id}`, serialize(doc));
331
+ await remember(patch.lid, id);
215
332
  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);
333
+ const owner = deserialize(await engine.get(`/chat/${id}`));
334
+ this.emit('contact:updated', person, new this.Chat(owner ?? { id, name: person.name }), this);
218
335
  }
219
336
  }
220
337
  }).catch(() => { });
221
338
  });
222
339
  socket.ev.on('lid-mapping.update', ({ lid, pn }) => {
223
340
  chain = chain.then(async () => {
224
- await engine.set(`/lid/${lid}`, serialize(pn));
225
- await engine.set(`/lid/${pn}`, serialize(lid));
341
+ await remember(lid, pn);
342
+ // El mapeo recién llega: lo que se guardó bajo el LID mientras era
343
+ // irresoluble se une ahora a su teléfono, o el contacto queda partido
344
+ // en dos fichas y dos chats que nunca se vuelven a encontrar.
345
+ // The mapping just arrived: whatever was stored under the LID while it
346
+ // was unresolvable now joins its phone, or the contact stays split into
347
+ // two cards and two chats that never meet again.
348
+ await absorb(lid, pn);
226
349
  }).catch(() => { });
227
350
  });
228
351
  socket.ev.on('chats.upsert', (rows) => {
229
352
  chain = chain.then(async () => {
230
- for (const row of rows) {
231
- if (row.id) {
353
+ for (const raw of rows) {
354
+ if (raw.id) {
355
+ const row = { ...raw, id: await canonical(raw.id) };
232
356
  const current = deserialize(await engine.get(`/chat/${row.id}`));
233
357
  const doc = current ?? {
234
358
  id: row.id,
@@ -438,18 +562,33 @@ export default class WhatsApp {
438
562
  continue;
439
563
  }
440
564
  }
565
+ if (!stored && doc.me && msg.pushName && socket.user) {
566
+ // Un mensaje propio lleva el nombre con el que la cuenta se
567
+ // anuncia al mundo. Cuando el perfil no viajó en el login —una
568
+ // reconexión, por ejemplo— esta es la única vía para conocerlo,
569
+ // y sin ella la línea se ve a sí misma como un número.
570
+ // An own message carries the name the account announces itself
571
+ // with. When the profile did not travel in the login —a
572
+ // reconnection, say— this is the only way to learn it, and
573
+ // without it the line sees itself as a number.
574
+ const own = jidNormalizedUser(socket.user.id);
575
+ const known = deserialize(await engine.get(`/contact/${own}`));
576
+ if (!(known?.name ?? known?.notify ?? known?.verified_name)) {
577
+ socket.ev.emit('contacts.upsert', [{ id: own, lid: socket.user.lid, notify: readable(msg.pushName) ?? undefined }]);
578
+ }
579
+ }
441
580
  if (!stored && !doc.me) {
442
581
  const known = deserialize(await engine.get(`/contact/${doc.author}`));
443
582
  if (doc.author && !(known?.name ?? known?.notify ?? known?.verified_name)) {
444
583
  socket.ev.emit('contacts.upsert', [{
445
584
  id: doc.author,
446
585
  lid: msg.key.remoteJid?.endsWith('@lid') ? msg.key.remoteJid : undefined,
447
- notify: msg.pushName ?? undefined,
586
+ notify: readable(msg.pushName) ?? undefined,
448
587
  verifiedName: msg.verifiedBizName ?? undefined,
449
588
  }]);
450
589
  }
451
590
  if (!(await engine.get(`/chat/${cid}`))) {
452
- const owner = { id: cid, name: cid.endsWith('@g.us') ? null : msg.pushName ?? null, activity: doc.created_at };
591
+ const owner = { id: cid, name: cid.endsWith('@g.us') ? null : readable(msg.pushName), activity: doc.created_at };
453
592
  await engine.set(`/chat/${cid}`, serialize(owner), doc.created_at);
454
593
  this.emit('chat:created', new this.Chat(owner), this);
455
594
  }
@@ -559,6 +698,34 @@ export default class WhatsApp {
559
698
  }).catch(() => { });
560
699
  });
561
700
  };
701
+ this.#unlink = async (quiet) => {
702
+ intentional = true;
703
+ silent = quiet;
704
+ if (timer) {
705
+ clearTimeout(timer);
706
+ timer = null;
707
+ }
708
+ try {
709
+ // `logout` avisa al teléfono y termina el socket; la promesa no resuelve
710
+ // hasta que el teléfono acusa, que es cuando el dispositivo ya no existe.
711
+ // `logout` notifies the phone and ends the socket; the promise does not
712
+ // settle until the phone acknowledges, which is when the device is gone.
713
+ await alive?.logout();
714
+ }
715
+ catch {
716
+ // Sin red o con el socket ya muerto no hay a quién avisar: se cierra de
717
+ // este lado para no dejar el proceso colgado de una sesión que no existe.
718
+ // With no network or an already dead socket there is nobody to notify: it
719
+ // closes on this side so the process is not left hanging on a dead session.
720
+ try {
721
+ alive?.end(Object.assign(new Error('intentional close'), { output: { statusCode: DisconnectReason.connectionClosed } }));
722
+ }
723
+ catch {
724
+ /* el socket ya estaba cerrado / socket already closed */
725
+ }
726
+ }
727
+ alive = null;
728
+ };
562
729
  this.#close = async (quiet) => {
563
730
  intentional = true;
564
731
  silent = quiet;
@@ -578,15 +745,33 @@ export default class WhatsApp {
578
745
  });
579
746
  }
580
747
  /**
581
- * Cierra la sesión: cancela el reintento pendiente y termina el socket.
582
- * Closes the session: cancels the pending retry and ends the socket.
748
+ * Cierra la sesión de verdad: desvincula el dispositivo del teléfono y termina el socket.
749
+ * La promesa no resuelve hasta que todo eso ocurrió.
750
+ *
751
+ * Los dos flags modulan efectos secundarios, nunca si la sesión muere: `silent` sólo calla
752
+ * el evento `disconnected` local, y `destroy` decide si el engine se vacía o conserva
753
+ * chats, mensajes y contactos para estudiarlos después. Las credenciales se borran en los
754
+ * dos casos: el dispositivo ya no existe, así que reconectar con ellas sólo devolvería un
755
+ * `loggedOut`.
756
+ *
757
+ * Closes the session for real: unlinks the device from the phone and ends the socket. The
758
+ * promise does not settle until all of that happened.
759
+ *
760
+ * Both flags modulate side effects, never whether the session dies: `silent` only mutes the
761
+ * local `disconnected` event, and `destroy` decides whether the engine is wiped or keeps
762
+ * chats, messages and contacts for later study. Credentials go in both cases: the device no
763
+ * longer exists, so reconnecting with them would only return a `loggedOut`.
764
+ *
765
+ * @param options - `silent` calla el evento; `destroy` vacía el engine entero / `silent` mutes the event; `destroy` wipes the whole engine
583
766
  *
584
- * @param options - `silent` calla el evento `disconnected`; `destroy` vacía el engine / `silent` mutes the `disconnected` event; `destroy` clears the engine
767
+ * @example
768
+ * await wa.disconnect(); // desvincula y conserva el historial
769
+ * await wa.disconnect({ destroy: true }); // desvincula y no queda nada
585
770
  */
586
771
  async disconnect(options = {}) {
587
- await this.#close?.(options.silent === true);
588
- if (options.destroy) {
589
- await this.engine.clear();
590
- }
772
+ await this.#unlink?.(options.silent === true);
773
+ this.#unlink = null;
774
+ this.#close = null;
775
+ await (options.destroy ? this.engine.clear() : this.engine.unset('/session'));
591
776
  }
592
777
  }
package/package.json CHANGED
@@ -72,7 +72,7 @@
72
72
  "release": "npm publish --access public"
73
73
  },
74
74
  "types": "./build/esm/index.d.ts",
75
- "version": "7.1.0",
75
+ "version": "7.2.0",
76
76
  "engines": {
77
77
  "node": ">=20"
78
78
  },