@violetix/baileys 0.4.0 → 0.5.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.
@@ -2,7 +2,7 @@ import { proto } from '../../WAProto/index.js';
2
2
  import { makeLibSignalRepository } from '../Signal/libsignal.js';
3
3
  import { Browsers } from '../Utils/browser-utils.js';
4
4
  import logger from '../Utils/logger.js';
5
- const version = [2, 3000, 1048107131];
5
+ const version = [2, 3000, 1053032355];
6
6
  export const UNAUTHORIZED_CODES = [401, 403, 419];
7
7
  export const BIZ_BOT_SUPPORT_PAYLOAD = '{"version":1,"is_ai_message":true,"should_upload_client_logs":false,"should_show_system_message":false,"ticket_id":"7004947587700716","citation_items":[],"ticket_locale":"us"}';
8
8
  export const DEFAULT_ORIGIN = 'https://web.whatsapp.com';
@@ -68,7 +68,9 @@ export const DEFAULT_CONNECTION_CONFIG = {
68
68
  fireInitQueries: true,
69
69
  auth: undefined,
70
70
  markOnlineOnConnect: true,
71
- syncFullHistory: true,
71
+ // FULL history is dropped by shouldSyncHistoryMessage below anyway; asking for it only adds
72
+ // reconnect load (upstream default is false too)
73
+ syncFullHistory: false,
72
74
  patchMessageBeforeSending: msg => msg,
73
75
  shouldSyncHistoryMessage: ({ syncType }) => {
74
76
  return syncType !== proto.HistorySync.HistorySyncType.FULL;
@@ -1077,7 +1077,9 @@ export const makeChatsSocket = (config) => {
1077
1077
  if (attrs.timestamp) {
1078
1078
  let { lastAccountSyncTimestamp } = authState.creds;
1079
1079
  if (lastAccountSyncTimestamp) {
1080
- await cleanDirtyBits('account_sync', lastAccountSyncTimestamp);
1080
+ // async ws handler: a rejection here would be unhandled and kill the process
1081
+ await cleanDirtyBits('account_sync', lastAccountSyncTimestamp)
1082
+ .catch(error => logger.warn({ error }, 'failed to clean account_sync dirty bits'));
1081
1083
  }
1082
1084
  lastAccountSyncTimestamp = +attrs.timestamp;
1083
1085
  ev.emit('creds.update', { lastAccountSyncTimestamp });
@@ -23,7 +23,7 @@ export const makeGroupsSocket = (config) => {
23
23
  }
24
24
  const promise = groupQuery(jid, 'get', [{ tag: 'query', attrs: { request: 'interactive' } }])
25
25
  .then(result => extractGroupMetadata(result))
26
- .catch(() => null)
26
+ // no swallow: a null here made group sends go to zero recipients silently
27
27
  .finally(() => inflightGroupMetadata.delete(jid));
28
28
  inflightGroupMetadata.set(jid, promise);
29
29
  return promise;
@@ -69,8 +69,13 @@ export const makeGroupsSocket = (config) => {
69
69
  if (attrs.type !== 'groups') {
70
70
  return;
71
71
  }
72
- await groupFetchAllParticipating();
73
- await sock.cleanDirtyBits('groups');
72
+ try {
73
+ await groupFetchAllParticipating();
74
+ await sock.cleanDirtyBits('groups');
75
+ }
76
+ catch (error) {
77
+ config.logger?.warn({ error }, 'failed to handle dirty groups');
78
+ }
74
79
  });
75
80
  return {
76
81
  ...sock,
@@ -5,7 +5,7 @@ import Long from 'long';
5
5
  import { proto } from '../../WAProto/index.js';
6
6
  import { DEFAULT_CACHE_TTLS, KEY_BUNDLE_TYPE, MIN_PREKEY_COUNT, PLACEHOLDER_MAX_AGE_SECONDS, STATUS_EXPIRY_SECONDS } from '../Defaults/index.js';
7
7
  import { ReachoutTimelockEnforcementType, WAMessageStatus, WAMessageStubType } from '../Types/index.js';
8
- import { ACCOUNT_RESTRICTED_TEXT, aesDecryptCTR, aesEncryptGCM, cleanMessage, Curve, decodeMediaRetryNode, decodeMessageNode, decryptMessageNode, setBotMessageSecret, delay, derivePairingCodeKey, encodeBigEndian, encodeSignedDeviceIdentity, extractAddressingContext, extractE2ESessionFromRetryReceipt, getCallStatusFromNode, getHistoryMsg, getNextPreKeys, getStatusFromReceiptType, handleIdentityChange, hkdf, MISSING_KEYS_ERROR_TEXT, NACK_REASONS, NO_MESSAGE_FOUND_ERROR_TEXT, SERVER_ERROR_CODES, toNumber, unixTimestampSeconds, xmppPreKey, xmppSignedPreKey } from '../Utils/index.js';
8
+ import { ACCOUNT_RESTRICTED_TEXT, aesDecryptCTR, aesEncryptGCM, cleanMessage, Curve, decodeMediaRetryNode, decodeMessageNode, decryptMessageNode, setBotMessageSecret, delay, derivePairingCodeKey, encodeBigEndian, encodeSignedDeviceIdentity, extractAddressingContext, extractE2ESessionFromRetryReceipt, getCallStatusFromNode, getHistoryMsg, getNextPreKeys, getStatusFromReceiptType, handleIdentityChange, hkdf, MISSING_KEYS_ERROR_TEXT, NACK_REASONS, NO_MESSAGE_FOUND_ERROR_TEXT, SERVER_ERROR_CODES, toNumber, unixTimestampSeconds, unpadRandomMax16, xmppPreKey, xmppSignedPreKey } from '../Utils/index.js';
9
9
  import { makeMutex } from '../Utils/make-mutex.js';
10
10
  import { makeOfflineNodeProcessor } from '../Utils/offline-node-processor.js';
11
11
  import { buildAckStanza } from '../Utils/stanza-ack.js';
@@ -394,7 +394,7 @@ export const makeMessagesRecvSocket = (config) => {
394
394
  }
395
395
  };
396
396
  const sendMessageAck = async (node, errorCode) => {
397
- const stanza = buildAckStanza(node, errorCode, authState.creds.me.id);
397
+ const stanza = buildAckStanza(node, errorCode, authState.creds.me?.id);
398
398
  logger.debug({ recv: { tag: node.tag, attrs: node.attrs }, sent: stanza.attrs }, 'sent ack');
399
399
  await sendNode(stanza);
400
400
  };
@@ -657,7 +657,7 @@ export const makeMessagesRecvSocket = (config) => {
657
657
  };
658
658
  break;
659
659
  case 'modify':
660
- const oldNumber = getBinaryNodeChildren(child, 'participant').map(p => p.attrs.jid);
660
+ const oldNumber = getBinaryNodeChildren(child, 'participant').map(p => JSON.stringify({ id: p.attrs.jid, phoneNumber: p.attrs.phone_number, lid: p.attrs.lid }));
661
661
  msg.messageStubParameters = oldNumber || [];
662
662
  msg.messageStubType = WAMessageStubType.GROUP_PARTICIPANT_CHANGE_NUMBER;
663
663
  break;
@@ -1455,8 +1455,10 @@ export const makeMessagesSocket = (config) => {
1455
1455
  const isQuizMsg = 'poll' in content && !!content.poll.pollType;
1456
1456
  const isAiMsg = 'ai' in content && !!content.ai;
1457
1457
  const isNeedBizAttrs = 'secureMetaServiceLabel' in content && !!content.secureMetaServiceLabel;
1458
- const additionalAttributes = options.additionalAttributes || {};
1459
- const additionalNodes = options.additionalNodes || [];
1458
+ // copies: these get .edit / meta / bot added below, which must not leak into the
1459
+ // caller's reused options object (stacked nodes, stray edit turning sends into deletes)
1460
+ const additionalAttributes = { ...options.additionalAttributes };
1461
+ const additionalNodes = [...(options.additionalNodes || [])];
1460
1462
  // required for delete
1461
1463
  if (isDeleteMsg || isKeepMsg) {
1462
1464
  // if the chat is a group, and I am not the author, then delete the message as an admin
@@ -1512,7 +1514,6 @@ export const makeMessagesSocket = (config) => {
1512
1514
  },
1513
1515
  content: undefined
1514
1516
  });
1515
- delete content.ai;
1516
1517
  }
1517
1518
  const relayOpts = {
1518
1519
  messageId: fullMsg.key.id,
@@ -1523,12 +1524,11 @@ export const makeMessagesSocket = (config) => {
1523
1524
  additionalAttributes,
1524
1525
  additionalNodes
1525
1526
  };
1526
- if (options.exclusive) {
1527
- await relayExclusive(jid, fullMsg.message, options, relayOpts);
1528
- }
1529
- else {
1530
- await relayMessage(jid, fullMsg.message, relayOpts);
1531
- }
1527
+ // children (poll option images, album items) must honour `exclusive` too
1528
+ const relay = (message, opts) => options.exclusive
1529
+ ? relayExclusive(jid, message, options, opts)
1530
+ : relayMessage(jid, message, opts);
1531
+ await relay(fullMsg.message, relayOpts);
1532
1532
  if (config.emitOwnEvents) {
1533
1533
  setImmediate(() => emitOwnUpsert(fullMsg));
1534
1534
  }
@@ -1548,7 +1548,7 @@ export const makeMessagesSocket = (config) => {
1548
1548
  userJid,
1549
1549
  messageId: generateMessageIDV2(userJid)
1550
1550
  });
1551
- await relayMessage(jid, optionMsg.message, {
1551
+ await relay(optionMsg.message, {
1552
1552
  messageId: optionMsg.key.id,
1553
1553
  useCachedGroupMetadata: options.useCachedGroupMetadata,
1554
1554
  additionalAttributes,
@@ -1581,7 +1581,7 @@ export const makeMessagesSocket = (config) => {
1581
1581
  parentMessageKey: fullMsg.key,
1582
1582
  associationType: AssociationType.MEDIA_ALBUM
1583
1583
  };
1584
- await relayMessage(jid, albumMsg.message, {
1584
+ await relay(albumMsg.message, {
1585
1585
  messageId: albumMsg.key.id,
1586
1586
  useCachedGroupMetadata: options.useCachedGroupMetadata,
1587
1587
  addBizAttributes: isNeedBizAttrs,
@@ -136,12 +136,13 @@ export const makeSocket = (config) => {
136
136
  node.attrs.id = generateMessageTag();
137
137
  }
138
138
  const msgId = node.attrs.id;
139
+ pendingQueries++;
139
140
  const result = await promiseTimeout(timeoutMs, async (resolve, reject) => {
140
141
  const result = waitForMessage(msgId, timeoutMs).catch(reject);
141
142
  sendNode(node)
142
143
  .then(async () => resolve(await result))
143
144
  .catch(reject);
144
- });
145
+ }).finally(() => { pendingQueries--; });
145
146
  if (result && 'tag' in result) {
146
147
  assertNodeErrorFree(result);
147
148
  }
@@ -376,6 +377,8 @@ export const makeSocket = (config) => {
376
377
  const keys = addTransactionCapability(authState.keys, logger, transactionOpts);
377
378
  const signalRepository = makeSignalRepository({ creds, keys }, logger, pnFromLIDUSync);
378
379
  let lastDateRecv;
380
+ /** queries awaiting a reply; keep-alive skips its ping while these are flowing */
381
+ let pendingQueries = 0;
379
382
  let epoch = 1;
380
383
  let keepAliveReq;
381
384
  let qrTimer;
@@ -711,48 +714,89 @@ export const makeSocket = (config) => {
711
714
  ws.off('error', onClose);
712
715
  });
713
716
  };
717
+ /**
718
+ * Keep-alive, modelled on zapo-js (src/transport/keepalive/WaKeepAlive.ts):
719
+ * - self-rescheduling setTimeout with +-10% jitter (min 250ms), so a fleet of sockets in one
720
+ * process drifts apart instead of pinging in the same tick
721
+ * - at most one ping in flight; a slow pong never stacks more pings on a busy loop
722
+ * - no ping while other queries are pending and data arrived recently: the link is
723
+ * demonstrably alive and the ping would only add queue pressure
724
+ * - "lost" is still decided by inbound silence (interval + grace), and skipped when the
725
+ * timer itself fired late (blocked event loop: pongs may be unread in the socket buffer)
726
+ */
714
727
  const startKeepAliveRequest = () => {
728
+ let timer;
729
+ let stopped = false;
730
+ let pingInFlight = false;
731
+ let lastCheckAt;
732
+ let expectedDelayMs = 0;
733
+ const nextDelay = () => {
734
+ const jitter = Math.max(250, Math.floor(keepAliveIntervalMs * 0.1));
735
+ return Math.max(1000, keepAliveIntervalMs + Math.floor((Math.random() * 2 - 1) * jitter));
736
+ };
737
+ const schedule = (delayMs) => {
738
+ if (stopped) {
739
+ return;
740
+ }
741
+ expectedDelayMs = delayMs;
742
+ timer = safeSetTimeout(() => {
743
+ timer = undefined;
744
+ cb();
745
+ schedule(nextDelay());
746
+ }, delayMs);
747
+ };
715
748
  const cb = () => {
716
749
  if (!lastDateRecv) {
717
750
  lastDateRecv = new Date();
718
751
  }
719
- const diff = Date.now() - lastDateRecv.getTime();
720
- if (diff > keepAliveIntervalMs + keepAliveGracePeriodMs) {
721
- void end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }));
752
+ const now = Date.now();
753
+ const sinceRecvMs = now - lastDateRecv.getTime();
754
+ const lagMs = lastCheckAt ? now - lastCheckAt - expectedDelayMs : 0;
755
+ lastCheckAt = now;
756
+ const loopStalled = lagMs > keepAliveGracePeriodMs;
757
+ if (loopStalled) {
758
+ logger.warn({ lagMs, sinceLastRecvMs: sinceRecvMs }, 'event loop stalled; deferring keep-alive timeout');
722
759
  }
723
- else if (ws.isOpen) {
724
- query({
725
- tag: 'iq',
726
- attrs: {
727
- id: generateMessageTag(),
728
- to: S_WHATSAPP_NET,
729
- type: 'get',
730
- xmlns: 'w:p'
731
- },
732
- content: [{ tag: 'ping', attrs: {} }]
733
- }).catch(err => {
734
- logger.error({ trace: err.stack }, 'error in sending keep alive');
735
- });
760
+ if (sinceRecvMs > keepAliveIntervalMs + keepAliveGracePeriodMs && !loopStalled) {
761
+ void end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }));
762
+ return;
736
763
  }
737
- else {
764
+ if (!ws.isOpen) {
738
765
  logger.warn('keep alive called when WS not open');
766
+ return;
767
+ }
768
+ if (pingInFlight) {
769
+ logger.trace('keep alive skipped: ping already in flight');
770
+ return;
771
+ }
772
+ if (pendingQueries > 0 && sinceRecvMs < keepAliveGracePeriodMs) {
773
+ logger.trace({ pendingQueries, sinceRecvMs }, 'keep alive skipped: queries pending with recent inbound');
774
+ return;
739
775
  }
776
+ pingInFlight = true;
777
+ query({
778
+ tag: 'iq',
779
+ attrs: {
780
+ id: generateMessageTag(),
781
+ to: S_WHATSAPP_NET,
782
+ type: 'get',
783
+ xmlns: 'w:p'
784
+ },
785
+ content: [{ tag: 'ping', attrs: {} }]
786
+ }, keepAliveIntervalMs + keepAliveGracePeriodMs)
787
+ .catch(err => {
788
+ // the verdict stays with inbound silence above; a failed ping alone is not "lost"
789
+ logger.warn({ trace: err.stack }, 'error in sending keep alive');
790
+ })
791
+ .finally(() => { pingInFlight = false; });
740
792
  };
741
- // Per socket, so keepAliveIntervalMs from the config is actually the ping interval: the
742
- // shared process-wide timer ran at a hardcoded 30s while the staleness check below still
743
- // used the configured value, so any interval other than 30s produced false connectionLost.
744
- // The random start offset staggers the fleet - on the shared timer every socket in the
745
- // process pinged in the same tick, which is a self-inflicted burst at 100+ sessions.
746
- let pinger;
747
- const starter = safeSetTimeout(() => {
748
- cb();
749
- pinger = safeSetInterval(cb, keepAliveIntervalMs);
750
- }, Math.floor(Math.random() * (keepAliveIntervalMs / 2)));
793
+ // random first tick staggers sockets that connect together (reconnect storms)
794
+ schedule(Math.floor(Math.random() * (keepAliveIntervalMs / 2)));
751
795
  keepAliveReq = {
752
796
  cb,
753
797
  cancel: () => {
754
- clearSafeTimer(starter);
755
- clearSafeTimer(pinger);
798
+ stopped = true;
799
+ clearSafeTimer(timer);
756
800
  }
757
801
  };
758
802
  };
@@ -9,7 +9,7 @@ import { writeFileSync, readFileSync, existsSync } from 'fs';
9
9
  import { WAProto } from '../Types/index.js';
10
10
  import { LabelAssociationType } from '../Types/LabelAssociation.js';
11
11
  import { DEFAULT_CONNECTION_CONFIG } from '../Defaults/index.js';
12
- import { md5, toNumber, updateMessageWithReceipt, updateMessageWithReaction } from '../Utils/index.js';
12
+ import { BufferJSON, md5, toNumber, updateMessageWithReceipt, updateMessageWithReaction } from '../Utils/index.js';
13
13
  import { jidDecode, jidNormalizedUser } from '../WABinary/index.js';
14
14
  import { makeOrderedDictionary } from './make-ordered-dictionary.js';
15
15
  import { ObjectRepository } from './object-repository.js';
@@ -69,6 +69,9 @@ export const makeInMemoryStore = (config = {}) => {
69
69
  }
70
70
  }
71
71
  };
72
+ // one key rule for every handler; upsert used alt+normalized while update/receipt/reaction/delete
73
+ // used raw or non-alt keys, so LID chats never found their stored messages
74
+ const keyJid = (key) => jidNormalizedUser(key.remoteJidAlt || key.remoteJid);
72
75
  const assertMessageList = (jid) => {
73
76
  if (!messages[jid]) {
74
77
  messages[jid] = makeMessagesDictionary();
@@ -116,8 +119,7 @@ export const makeInMemoryStore = (config = {}) => {
116
119
  }
117
120
  logger.debug({ deletedContacts: isLatest ? oldContacts.size : 0, newContacts }, 'synced contacts');
118
121
  for (const msg of newMessages) {
119
- const jid = msg.key.remoteJidAlt || msg.key.remoteJid;
120
- const list = assertMessageList(jid);
122
+ const list = assertMessageList(keyJid(msg.key));
121
123
  list.upsert(msg, 'prepend');
122
124
  }
123
125
  enforceLimits(messages, groupMetadata, chats, maxChats, maxMessagesPerChat);
@@ -149,9 +151,13 @@ export const makeInMemoryStore = (config = {}) => {
149
151
  }
150
152
  }
151
153
  else {
152
- return logger.debug({ update }, 'got update for non-existant contact');
154
+ logger.debug({ update }, 'got update for non-existant contact');
155
+ continue;
153
156
  }
154
- Object.assign(contacts[contact.id], contact);
157
+ // was Object.assign(contact, contact): update fields (notify, name...) never landed.
158
+ // id may be a WA_ADD_NOTIF hash and imgUrl a sentinel, so neither is copied.
159
+ const { id: _id, imgUrl: _imgUrl, ...fields } = update;
160
+ Object.assign(contact, fields);
155
161
  }
156
162
  });
157
163
  on('chats.upsert', newChats => {
@@ -211,7 +217,7 @@ export const makeInMemoryStore = (config = {}) => {
211
217
  case 'append':
212
218
  case 'notify':
213
219
  for (const msg of newMessages) {
214
- const jid = jidNormalizedUser(msg.key.remoteJidAlt || msg.key.remoteJid);
220
+ const jid = keyJid(msg.key);
215
221
  const list = assertMessageList(jid);
216
222
  list.upsert(msg, 'append');
217
223
  enforceLimits(messages, groupMetadata, chats, maxChats, maxMessagesPerChat);
@@ -231,7 +237,12 @@ export const makeInMemoryStore = (config = {}) => {
231
237
  on('messages.update', updates => {
232
238
  var _a;
233
239
  for (const { update, key } of updates) {
234
- const list = assertMessageList(jidNormalizedUser(key.remoteJid));
240
+ // no create: an update for an unknown chat must not add an empty list that evicts real ones
241
+ const list = messages[keyJid(key)];
242
+ if (!list) {
243
+ logger.debug({ update }, 'got update for non-existent message');
244
+ continue;
245
+ }
235
246
  if (update === null || update === void 0 ? void 0 : update.status) {
236
247
  const listStatus = (_a = list.get(key.id)) === null || _a === void 0 ? void 0 : _a.status;
237
248
  if (listStatus && (update === null || update === void 0 ? void 0 : update.status) <= listStatus) {
@@ -252,8 +263,7 @@ export const makeInMemoryStore = (config = {}) => {
252
263
  list === null || list === void 0 ? void 0 : list.clear();
253
264
  }
254
265
  else {
255
- const jid = item.keys[0].remoteJidAlt || item.keys[0].remoteJid;
256
- const list = messages[jid];
266
+ const list = messages[keyJid(item.keys[0])];
257
267
  if (list) {
258
268
  const idSet = new Set(item.keys.map(k => k.id));
259
269
  list.filter(m => !idSet.has(m.key.id));
@@ -308,8 +318,7 @@ export const makeInMemoryStore = (config = {}) => {
308
318
  });
309
319
  on('message-receipt.update', updates => {
310
320
  for (const { key, receipt } of updates) {
311
- const obj = messages[key.remoteJidAlt || key.remoteJid];
312
- const msg = obj === null || obj === void 0 ? void 0 : obj.get(key.id);
321
+ const msg = messages[keyJid(key)]?.get(key.id);
313
322
  if (msg) {
314
323
  updateMessageWithReceipt(msg, receipt);
315
324
  }
@@ -317,8 +326,7 @@ export const makeInMemoryStore = (config = {}) => {
317
326
  });
318
327
  on('messages.reaction', (reactions) => {
319
328
  for (const { key, reaction } of reactions) {
320
- const obj = messages[key.remoteJidAlt || key.remoteJid];
321
- const msg = obj === null || obj === void 0 ? void 0 : obj.get(key.id);
329
+ const msg = messages[keyJid(key)]?.get(key.id);
322
330
  if (msg) {
323
331
  updateMessageWithReaction(msg, reaction);
324
332
  }
@@ -363,28 +371,28 @@ export const makeInMemoryStore = (config = {}) => {
363
371
  bind,
364
372
  /** loads messages from the store, if not found -- uses the legacy connection */
365
373
  loadMessages: async (jid, count, cursor) => {
366
- const list = assertMessageList(jid);
374
+ const list = messages[jid]; // read-only: don't create phantom lists
367
375
  const mode = !cursor || 'before' in cursor ? 'before' : 'after';
368
376
  const cursorKey = !!cursor ? ('before' in cursor ? cursor.before : cursor.after) : undefined;
369
- const cursorValue = cursorKey ? list.get(cursorKey.id) : undefined;
370
- let messages;
377
+ const cursorValue = cursorKey ? list?.get(cursorKey.id) : undefined;
378
+ let result;
371
379
  if (list && mode === 'before' && (!cursorKey || cursorValue)) {
372
380
  if (cursorValue) {
373
381
  const msgIdx = list.array.findIndex(m => m.key.id === (cursorKey === null || cursorKey === void 0 ? void 0 : cursorKey.id));
374
- messages = list.array.slice(0, msgIdx);
382
+ result = list.array.slice(0, msgIdx);
375
383
  }
376
384
  else {
377
- messages = list.array;
385
+ result = list.array;
378
386
  }
379
- const diff = count - messages.length;
387
+ const diff = count - result.length;
380
388
  if (diff < 0) {
381
- messages = messages.slice(-count); // get the last X messages
389
+ result = result.slice(-count); // get the last X messages
382
390
  }
383
391
  }
384
392
  else {
385
- messages = [];
393
+ result = [];
386
394
  }
387
- return messages;
395
+ return result;
388
396
  },
389
397
  /**
390
398
  * Get all available labels for profile
@@ -447,13 +455,13 @@ export const makeInMemoryStore = (config = {}) => {
447
455
  toJSON,
448
456
  fromJSON,
449
457
  writeToFile: (path) => {
450
- writeFileSync(path, JSON.stringify(toJSON()));
458
+ writeFileSync(path, JSON.stringify(toJSON(), BufferJSON.replacer));
451
459
  },
452
460
  readFromFile: (path) => {
453
461
  if (existsSync(path)) {
454
462
  logger.debug({ path }, 'reading from file');
455
463
  const jsonStr = readFileSync(path, { encoding: 'utf-8' });
456
- const json = JSON.parse(jsonStr);
464
+ const json = JSON.parse(jsonStr, BufferJSON.reviver);
457
465
  fromJSON(json);
458
466
  }
459
467
  }
@@ -344,7 +344,6 @@ export const decodePatches = async (name, syncds, initial, getAppStateSyncKey, o
344
344
  syncd.mutations?.push(...ref.mutations);
345
345
  }
346
346
  const patchVersion = toNumber(version.version);
347
- newState.version = patchVersion;
348
347
  const shouldMutate = typeof minimumVersionNumber === 'undefined' || patchVersion > minimumVersionNumber;
349
348
  let decodeResult;
350
349
  try {
@@ -358,9 +357,12 @@ export const decodePatches = async (name, syncds, initial, getAppStateSyncKey, o
358
357
  catch (err) {
359
358
  if (isMissingKeyError(err))
360
359
  throw err;
361
- logger?.warn({ name, version: patchVersion, error: err.message }, 'failed to decode patch, skipping');
362
- continue;
360
+ // stop here: advancing the version past an undecoded patch loses its mutations for good;
361
+ // the next sync resumes from the last good version
362
+ logger?.warn({ name, version: patchVersion, error: err.message }, 'failed to decode patch, stopping');
363
+ break;
363
364
  }
365
+ newState.version = patchVersion;
364
366
  newState.hash = decodeResult.hash;
365
367
  newState.indexValueMap = decodeResult.indexValueMap;
366
368
  if (validateMacs) {
@@ -400,7 +400,8 @@ eventData, logger) {
400
400
  data.contactUpserts[contact.id] = upsert;
401
401
  }
402
402
  if (data.contactUpdates[contact.id]) {
403
- upsert = Object.assign(data.contactUpdates[contact.id], trimUndefined(contact));
403
+ // merge into the stored upsert itself; the old code merged into a temp and lost the update
404
+ Object.assign(upsert, trimUndefined(data.contactUpdates[contact.id]), trimUndefined(contact));
404
405
  delete data.contactUpdates[contact.id];
405
406
  }
406
407
  }
@@ -525,10 +526,8 @@ eventData, logger) {
525
526
  const groupUpdates = eventData;
526
527
  for (const update of groupUpdates) {
527
528
  const id = update.id;
528
- const groupUpdate = data.groupUpdates[id] || {};
529
- if (!data.groupUpdates[id]) {
530
- data.groupUpdates[id] = Object.assign(groupUpdate, update);
531
- }
529
+ // merge every update in the window, not just the first
530
+ data.groupUpdates[id] = Object.assign(data.groupUpdates[id] || {}, update);
532
531
  }
533
532
  break;
534
533
  default:
@@ -263,17 +263,32 @@ export const getStatusFromReceiptType = (type) => {
263
263
  }
264
264
  return status;
265
265
  };
266
- const CODE_MAP = {
267
- conflict: DisconnectReason.connectionReplaced
268
- };
269
266
  /**
270
- * Stream errors generally provide a reason, map that to a baileys DisconnectReason
271
- * @param reason the string reason given, eg. "conflict"
267
+ * Stream errors generally provide a reason, map that to a baileys DisconnectReason.
268
+ * Mirrors zapo-js (src/transport/stream/parse.ts): only conflict means the session is gone;
269
+ * ack / xml-not-well-formed / unknown reasons are transient and must stay reconnectable.
270
+ * The old fallback was badSession (500), which made bots wipe healthy sessions.
272
271
  */
273
272
  export const getErrorCodeFromStreamError = (node) => {
274
273
  const [reasonNode] = getAllBinaryNodeChildren(node);
275
274
  let reason = reasonNode?.tag || 'unknown';
276
- const statusCode = +(node.attrs.code || CODE_MAP[reason] || DisconnectReason.badSession);
275
+ let statusCode;
276
+ if (reason === 'conflict') {
277
+ // type="replaced": another client took over; any other type (device_removed): unlinked
278
+ statusCode = reasonNode.attrs?.type === 'replaced' || !reasonNode.attrs?.type
279
+ ? DisconnectReason.connectionReplaced
280
+ : DisconnectReason.loggedOut;
281
+ }
282
+ else if (node.attrs.code) {
283
+ statusCode = +node.attrs.code;
284
+ // 516 = server-forced logout
285
+ if (statusCode === 516) {
286
+ statusCode = DisconnectReason.loggedOut;
287
+ }
288
+ }
289
+ else {
290
+ statusCode = DisconnectReason.connectionClosed;
291
+ }
277
292
  if (statusCode === DisconnectReason.restartRequired) {
278
293
  reason = 'restart required';
279
294
  }
@@ -101,7 +101,8 @@ export const buildIgnoreContext = (node, meId, meLid) => {
101
101
  const fromMe = !!author && ((!!meId && areJidsSameUser(author, meId)) || (!!meLid && areJidsSameUser(author, meLid)));
102
102
  return {
103
103
  kind: node.tag,
104
- remoteJid: stripDevice(attrs.from),
104
+ // a 1:1 message sent from another of our devices arrives from us, with the chat in `recipient`
105
+ remoteJid: stripDevice(fromMe && attrs.recipient ? attrs.recipient : attrs.from),
105
106
  fromMe,
106
107
  id: attrs.id,
107
108
  participant: stripDevice(attrs.participant || attrs.participant_pn || attrs.participant_lid)
@@ -121,7 +122,8 @@ export const matchesIgnoreKey = (entry, ctx, node) => {
121
122
  return false;
122
123
  }
123
124
  const attrs = node.attrs || {};
124
- if (entry.remoteJid && !entry.remoteJid.some(wanted => REMOTE_JID_ATTRS.some(attr => sameJid(attrs[attr], wanted)))) {
125
+ const remoteAttrs = ctx.fromMe && attrs.recipient ? ['recipient', ...REMOTE_JID_ATTRS] : REMOTE_JID_ATTRS;
126
+ if (entry.remoteJid && !entry.remoteJid.some(wanted => remoteAttrs.some(attr => sameJid(attrs[attr], wanted)))) {
125
127
  return false;
126
128
  }
127
129
  if (entry.participant && !PARTICIPANT_ATTRS.some(attr => sameJid(attrs[attr], entry.participant))) {
@@ -57,6 +57,8 @@ export interface MediaEncryptionOptions {
57
57
  };
58
58
  saveOriginalFileIfRequired?: boolean;
59
59
  opts?: MediaFetchOptions;
60
+ /** Reuse an existing 32-byte media key instead of generating one (e.g. sticker-pack thumbnails). */
61
+ mediaKey?: Uint8Array;
60
62
  }
61
63
  export interface MediaDownloadOptions {
62
64
  startByte?: number;
@@ -481,10 +481,11 @@ class SidecarAccumulator {
481
481
  this.windowOffset = SIDECAR_IV_SIZE;
482
482
  }
483
483
  }
484
- export const encryptedStream = async (media, mediaType, { logger, saveOriginalFileIfRequired, opts } = {}) => {
484
+ export const encryptedStream = async (media, mediaType, { logger, saveOriginalFileIfRequired, opts, mediaKey: givenMediaKey } = {}) => {
485
485
  const { stream, type } = await getStream(media, opts);
486
486
  logger?.debug('fetched media stream');
487
- const mediaKey = Crypto.randomBytes(32);
487
+ // sticker-pack thumbnails must reuse the pack's key, the message carries only that one
488
+ const mediaKey = givenMediaKey ?? Crypto.randomBytes(32);
488
489
  const { cipherKey, iv, macKey } = await getMediaKeys(mediaKey, mediaType);
489
490
  const encFilePath = join(getTmpFilesDirectory(), mediaType + generateMessageIDV2() + '-enc');
490
491
  const encFileWriteStream = createWriteStream(encFilePath);
@@ -105,7 +105,8 @@ export const prepareWAMessageMedia = async (message, options) => {
105
105
  if (mediaBuff) {
106
106
  logger?.debug({ cacheableKey }, 'got media cache hit');
107
107
  const obj = proto.Message.decode(mediaBuff);
108
- const key = `${mediaType}Message`;
108
+ // ptv entries are cached after videoMessage was renamed to ptvMessage
109
+ const key = obj.ptvMessage ? 'ptvMessage' : `${mediaType}Message`;
109
110
  Object.assign(obj[key], { ...uploadData, media: undefined });
110
111
  return obj;
111
112
  }
@@ -114,7 +114,10 @@ export const isRealMessage = (message) => {
114
114
  hasSomeContent &&
115
115
  !normalizedContent?.protocolMessage &&
116
116
  !normalizedContent?.reactionMessage &&
117
- !normalizedContent?.pollUpdateMessage);
117
+ !normalizedContent?.pollUpdateMessage &&
118
+ // edit envelopes and event responses are not new chat messages (unread/timestamp bump)
119
+ !normalizedContent?.secretEncryptedMessage &&
120
+ !normalizedContent?.encEventResponseMessage);
118
121
  };
119
122
  export const shouldIncrementChatUnread = (message) => !message.key.fromMe && !message.messageStubType;
120
123
  /**
@@ -236,7 +236,7 @@ export const prepareRichResponseMessage = (content) => {
236
236
  submessages.push({
237
237
  messageType: RichSubMessageType.LATEX,
238
238
  latexMetadata: {
239
- text,
239
+ text: contentText,
240
240
  expressions: latex
241
241
  }
242
242
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@violetix/baileys",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Enhanced Baileys v7 with fixes for newsletter media uploads, plus support for interactive messages, albums, and additional message types.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",