@ryuu-reinzz/baileys 3.5.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +30 -25
  2. package/WAProto/fix-imports.js +22 -18
  3. package/WAProto/index.js +22 -18
  4. package/lib/Defaults/index.js +10 -9
  5. package/lib/Signal/libsignal.js +46 -19
  6. package/lib/Signal/lid-mapping.js +6 -0
  7. package/lib/Socket/chats.js +241 -39
  8. package/lib/Socket/groups.js +20 -0
  9. package/lib/Socket/messages-recv.js +736 -314
  10. package/lib/Socket/messages-send.js +279 -129
  11. package/lib/Socket/newsletter.js +2 -2
  12. package/lib/Socket/socket.js +56 -25
  13. package/lib/Types/{Newsletter.js → Mex.js} +9 -3
  14. package/lib/Types/State.js +43 -0
  15. package/lib/Types/index.js +1 -1
  16. package/lib/Utils/auth-utils.js +12 -0
  17. package/lib/Utils/chat-utils.js +80 -20
  18. package/lib/Utils/companion-reg-client-utils.js +35 -0
  19. package/lib/Utils/decode-wa-message.js +34 -0
  20. package/lib/Utils/event-buffer.js +49 -1
  21. package/lib/Utils/generics.js +12 -3
  22. package/lib/Utils/history.js +12 -9
  23. package/lib/Utils/identity-change-handler.js +1 -0
  24. package/lib/Utils/index.js +3 -1
  25. package/lib/Utils/link-preview.js +2 -2
  26. package/lib/Utils/message-retry-manager.js +40 -0
  27. package/lib/Utils/messages-media.js +21 -7
  28. package/lib/Utils/messages.js +28 -5
  29. package/lib/Utils/offline-node-processor.js +40 -0
  30. package/lib/Utils/process-message.js +103 -1
  31. package/lib/Utils/signal.js +42 -0
  32. package/lib/Utils/stanza-ack.js +38 -0
  33. package/lib/Utils/sync-action-utils.js +1 -0
  34. package/lib/Utils/tc-token-utils.js +149 -4
  35. package/lib/Utils/validate-connection.js +3 -0
  36. package/lib/WAUSync/Protocols/USyncContactProtocol.js +26 -3
  37. package/lib/WAUSync/Protocols/USyncUsernameProtocol.js +25 -0
  38. package/lib/WAUSync/Protocols/index.js +1 -0
  39. package/lib/WAUSync/USyncQuery.js +6 -2
  40. package/lib/WAUSync/USyncUser.js +8 -0
  41. package/package.json +39 -12
@@ -169,7 +169,27 @@ export const makeEventBuffer = (logger) => {
169
169
  },
170
170
  on: (...args) => ev.on(...args),
171
171
  off: (...args) => ev.off(...args),
172
- removeAllListeners: (...args) => ev.removeAllListeners(...args)
172
+ removeAllListeners: (...args) => ev.removeAllListeners(...args),
173
+ destroy() {
174
+ // Clear buffer timeout
175
+ if (bufferTimeout) {
176
+ clearTimeout(bufferTimeout);
177
+ bufferTimeout = null;
178
+ }
179
+ if (flushPendingTimeout) {
180
+ clearTimeout(flushPendingTimeout);
181
+ flushPendingTimeout = null;
182
+ }
183
+ // Clear history cache
184
+ historyCache.clear();
185
+ // Reset buffer data
186
+ data = makeBufferData();
187
+ isBuffering = false;
188
+ bufferCount = 0;
189
+ // Remove all listeners
190
+ ev.removeAllListeners();
191
+ logger.debug('Event buffer destroyed');
192
+ }
173
193
  };
174
194
  };
175
195
  const makeBufferData = () => {
@@ -235,7 +255,33 @@ eventData, logger) {
235
255
  }
236
256
  data.historySets.empty = false;
237
257
  data.historySets.syncType = eventData.syncType;
258
+ if (eventData.pastParticipants?.length) {
259
+ const merged = new Map();
260
+ const sigOf = (p) => `${p.userJid || ''}:${p.leaveTs || ''}:${p.leaveReason || ''}`;
261
+ const ingest = (entry) => {
262
+ const key = entry.groupJid ?? JSON.stringify(entry);
263
+ const existing = merged.get(key);
264
+ if (!existing) {
265
+ merged.set(key, { ...entry, pastParticipants: [...(entry.pastParticipants || [])] });
266
+ return;
267
+ }
268
+ const seen = new Set((existing.pastParticipants || []).map(sigOf));
269
+ for (const p of entry.pastParticipants || []) {
270
+ const sig = sigOf(p);
271
+ if (!seen.has(sig)) {
272
+ existing.pastParticipants.push(p);
273
+ seen.add(sig);
274
+ }
275
+ }
276
+ };
277
+ for (const entry of data.historySets.pastParticipants || [])
278
+ ingest(entry);
279
+ for (const entry of eventData.pastParticipants)
280
+ ingest(entry);
281
+ data.historySets.pastParticipants = [...merged.values()];
282
+ }
238
283
  data.historySets.progress = eventData.progress;
284
+ data.historySets.chunkOrder = eventData.chunkOrder;
239
285
  data.historySets.peerDataRequestSessionId = eventData.peerDataRequestSessionId;
240
286
  data.historySets.isLatest = eventData.isLatest || data.historySets.isLatest;
241
287
  break;
@@ -500,9 +546,11 @@ function consolidateEvents(data) {
500
546
  chats: Object.values(data.historySets.chats),
501
547
  messages: Object.values(data.historySets.messages),
502
548
  contacts: Object.values(data.historySets.contacts),
549
+ pastParticipants: data.historySets.pastParticipants,
503
550
  syncType: data.historySets.syncType,
504
551
  progress: data.historySets.progress,
505
552
  isLatest: data.historySets.isLatest,
553
+ chunkOrder: data.historySets.chunkOrder,
506
554
  peerDataRequestSessionId: data.historySets.peerDataRequestSessionId
507
555
  };
508
556
  }
@@ -1,7 +1,7 @@
1
1
  import { Boom } from '@hapi/boom';
2
2
  import { createHash, randomBytes } from 'crypto';
3
3
  import { proto } from '../../WAProto/index.js';
4
- const baileysVersion = [2, 3000, 1033105955];
4
+ const baileysVersion = [2, 3000, 1035194821];
5
5
  import { DisconnectReason } from '../Types/index.js';
6
6
  import { getAllBinaryNodeChildren, jidDecode } from '../WABinary/index.js';
7
7
  import { sha256 } from './crypto.js';
@@ -144,10 +144,10 @@ export const generateMessageIDV2 = (userId) => {
144
144
  const random = randomBytes(16);
145
145
  random.copy(data, 28);
146
146
  const hash = createHash('sha256').update(data).digest();
147
- return '3EB0' + hash.toString('hex').toUpperCase().substring(0, 14) + "RYUU";
147
+ return '3EB0' + hash.toString('hex').toUpperCase().substring(0, 18);
148
148
  };
149
149
  // generate a random ID to attach to a message
150
- export const generateMessageID = () => '3EB0' + randomBytes(14).toString('hex').toUpperCase() + "RYUU";
150
+ export const generateMessageID = () => '3EB0' + randomBytes(18).toString('hex').toUpperCase();
151
151
  export function bindWaitForEvent(ev, event) {
152
152
  return async (check, timeoutMs) => {
153
153
  let listener;
@@ -314,6 +314,15 @@ export const getCallStatusFromNode = ({ tag, attrs }) => {
314
314
  status = 'terminate';
315
315
  }
316
316
  break;
317
+ case 'preaccept':
318
+ status = 'preaccept';
319
+ break;
320
+ case 'transport':
321
+ status = 'transport';
322
+ break;
323
+ case 'relaylatency':
324
+ status = 'relaylatency';
325
+ break;
317
326
  case 'reject':
318
327
  status = 'reject';
319
328
  break;
@@ -1,5 +1,6 @@
1
+ import { pipeline } from 'stream/promises';
1
2
  import { promisify } from 'util';
2
- import { inflate } from 'zlib';
3
+ import { createInflate, inflate } from 'zlib';
3
4
  import { proto } from '../../WAProto/index.js';
4
5
  import { WAMessageStubType } from '../Types/index.js';
5
6
  import { isHostedLidUser, isHostedPnUser, isLidUser, isPnUser } from '../WABinary/index.js';
@@ -24,13 +25,13 @@ const extractPnFromMessages = (messages) => {
24
25
  };
25
26
  export const downloadHistory = async (msg, options) => {
26
27
  const stream = await downloadContentFromMessage(msg, 'md-msg-hist', { options });
27
- const bufferArray = [];
28
- for await (const chunk of stream) {
29
- bufferArray.push(chunk);
30
- }
31
- let buffer = Buffer.concat(bufferArray);
32
- // decompress buffer
33
- buffer = await inflatePromise(buffer);
28
+ // Pipe decrypted stream directly through zlib inflate
29
+ // This avoids allocating an intermediate buffer for the compressed data
30
+ const inflater = createInflate();
31
+ const chunks = [];
32
+ inflater.on('data', (chunk) => chunks.push(chunk));
33
+ await pipeline(stream, inflater);
34
+ const buffer = Buffer.concat(chunks);
34
35
  const syncData = proto.HistorySync.decode(buffer);
35
36
  return syncData;
36
37
  };
@@ -55,6 +56,7 @@ export const processHistoryMessage = (item, logger) => {
55
56
  contacts.push({
56
57
  id: chat.id,
57
58
  name: chat.displayName || chat.name || chat.username || undefined,
59
+ username: chat.username || undefined,
58
60
  lid: chat.lidJid || chat.accountLid || undefined,
59
61
  phoneNumber: chat.pnJid || undefined
60
62
  });
@@ -95,7 +97,7 @@ export const processHistoryMessage = (item, logger) => {
95
97
  });
96
98
  }
97
99
  }
98
- chats.push({ ...chat });
100
+ chats.push(chat);
99
101
  }
100
102
  break;
101
103
  case proto.HistorySync.HistorySyncType.PUSH_NAME:
@@ -109,6 +111,7 @@ export const processHistoryMessage = (item, logger) => {
109
111
  contacts,
110
112
  messages,
111
113
  lidPnMappings,
114
+ pastParticipants: item.pastParticipants,
112
115
  syncType: item.syncType,
113
116
  progress: item.progress
114
117
  };
@@ -37,6 +37,7 @@ export async function handleIdentityChange(node, ctx) {
37
37
  ctx.logger.debug({ jid: from }, 'skipping session refresh during offline processing');
38
38
  return { action: 'skipped_offline' };
39
39
  }
40
+ ctx.onBeforeSessionRefresh?.(from);
40
41
  try {
41
42
  await ctx.assertSessions([from], true);
42
43
  return { action: 'session_refreshed' };
@@ -11,11 +11,13 @@ export * from './chat-utils.js';
11
11
  export * from './lt-hash.js';
12
12
  export * from './auth-utils.js';
13
13
  export * from './use-multi-file-auth-state.js';
14
+ export * from './use-sqlite-auth-state.js';
14
15
  export * from './link-preview.js';
15
16
  export * from './event-buffer.js';
16
17
  export * from './process-message.js';
17
18
  export * from './message-retry-manager.js';
18
19
  export * from './browser-utils.js';
20
+ export * from './companion-reg-client-utils.js';
19
21
  export * from './identity-change-handler.js';
20
- export * from './use-sqlite-auth-state.js';
22
+ export * from './stanza-ack.js';
21
23
  //# sourceMappingURL=index.js.map
@@ -19,7 +19,7 @@ export const getUrlInfo = async (text, opts = {
19
19
  }) => {
20
20
  try {
21
21
  // retries
22
- const retries = 0;
22
+ let retries = 0;
23
23
  const maxRetry = 5;
24
24
  const { getLinkPreview } = await import('link-preview-js');
25
25
  let previewLink = text;
@@ -38,7 +38,7 @@ export const getUrlInfo = async (text, opts = {
38
38
  if (forwardedURLObj.hostname === urlObj.hostname ||
39
39
  forwardedURLObj.hostname === 'www.' + urlObj.hostname ||
40
40
  'www.' + forwardedURLObj.hostname === urlObj.hostname) {
41
- retries + 1;
41
+ retries += 1;
42
42
  return true;
43
43
  }
44
44
  else {
@@ -52,6 +52,11 @@ export class MessageRetryManager {
52
52
  ttlAutopurge: true,
53
53
  updateAgeOnGet: true
54
54
  }); // 15 minutes TTL
55
+ this.baseKeys = new LRUCache({
56
+ max: 1024,
57
+ ttl: 15 * 60 * 1000,
58
+ ttlAutopurge: true
59
+ });
55
60
  this.pendingPhoneRequests = {};
56
61
  this.maxMsgRetryCount = 5;
57
62
  this.statistics = {
@@ -210,6 +215,41 @@ export class MessageRetryManager {
210
215
  this.logger.debug(`Cancelled pending phone request for message ${messageId}`);
211
216
  }
212
217
  }
218
+ clear() {
219
+ this.recentMessagesMap.clear();
220
+ this.messageKeyIndex.clear();
221
+ this.sessionRecreateHistory.clear();
222
+ this.retryCounters.clear();
223
+ this.baseKeys.clear();
224
+ for (const messageId of Object.keys(this.pendingPhoneRequests)) {
225
+ this.cancelPendingPhoneRequest(messageId);
226
+ }
227
+ this.statistics = {
228
+ totalRetries: 0,
229
+ successfulRetries: 0,
230
+ failedRetries: 0,
231
+ mediaRetries: 0,
232
+ sessionRecreations: 0,
233
+ phoneRequests: 0
234
+ };
235
+ }
236
+ saveBaseKey(addr, msgId, baseKey) {
237
+ this.baseKeys.set(`${addr}:${msgId}`, baseKey);
238
+ }
239
+ hasSameBaseKey(addr, msgId, baseKey) {
240
+ const stored = this.baseKeys.get(`${addr}:${msgId}`);
241
+ if (!stored || stored.length !== baseKey.length) {
242
+ return false;
243
+ }
244
+ for (let i = 0; i < stored.length; i++) {
245
+ if (stored[i] !== baseKey[i])
246
+ return false;
247
+ }
248
+ return true;
249
+ }
250
+ deleteBaseKey(addr, msgId) {
251
+ this.baseKeys.delete(`${addr}:${msgId}`);
252
+ }
213
253
  keyToString(key) {
214
254
  return `${key.to}${MESSAGE_KEY_SEPARATOR}${key.id}`;
215
255
  }
@@ -217,7 +217,7 @@ export async function getAudioWaveform(buffer, logger) {
217
217
  const blockStart = blockSize * i; // the location of the first sample in the block
218
218
  let sum = 0;
219
219
  for (let j = 0; j < blockSize; j++) {
220
- sum = sum + Math.abs(rawData[blockStart + j] ?? 0); // find the sum of all the samples in the block
220
+ sum = sum + Math.abs(rawData[blockStart + j]); // find the sum of all the samples in the block
221
221
  }
222
222
  filteredData.push(sum / blockSize); // divide the sum by the block size to get the average
223
223
  }
@@ -397,15 +397,27 @@ export const encryptedStream = async (media, mediaType, { logger, saveOriginalFi
397
397
  throw error;
398
398
  }
399
399
  };
400
- const DEF_HOST = 'mmg.whatsapp.net';
400
+ export const DEF_MEDIA_HOST = 'mmg.whatsapp.net';
401
401
  const AES_CHUNK_SIZE = 16;
402
402
  const toSmallestChunkSize = (num) => {
403
403
  return Math.floor(num / AES_CHUNK_SIZE) * AES_CHUNK_SIZE;
404
404
  };
405
- export const getUrlFromDirectPath = (directPath) => `https://${DEF_HOST}${directPath}`;
405
+ export const getUrlFromDirectPath = (directPath, host = DEF_MEDIA_HOST) => `https://${host}${directPath}`;
406
+ const extractHost = (url) => {
407
+ if (!url)
408
+ return undefined;
409
+ try {
410
+ return new URL(url).host;
411
+ }
412
+ catch {
413
+ return undefined;
414
+ }
415
+ };
406
416
  export const downloadContentFromMessage = async ({ mediaKey, directPath, url }, type, opts = {}) => {
407
- const isValidMediaUrl = url?.startsWith('https://mmg.whatsapp.net/');
408
- const downloadUrl = isValidMediaUrl ? url : getUrlFromDirectPath(directPath);
417
+ // Fallback host: explicit opt > host parsed from `url` > DEF_MEDIA_HOST.
418
+ // Lets us honor a non-default host carried by the proto without forcing callers to thread it through.
419
+ const fallbackHost = opts.host ?? extractHost(url);
420
+ const downloadUrl = directPath ? getUrlFromDirectPath(directPath, fallbackHost) : url;
409
421
  if (!downloadUrl) {
410
422
  throw new Boom('No valid media URL or directPath present in message', { statusCode: 400 });
411
423
  }
@@ -465,7 +477,7 @@ export const downloadEncryptedContent = async (downloadUrl, { cipherKey, iv }, {
465
477
  };
466
478
  const output = new Transform({
467
479
  transform(chunk, _, callback) {
468
- let data = Buffer.concat([remainingBytes, chunk]);
480
+ let data = remainingBytes.length ? Buffer.concat([remainingBytes, chunk]) : chunk;
469
481
  const decryptLength = toSmallestChunkSize(data.length);
470
482
  remainingBytes = data.slice(decryptLength);
471
483
  data = data.slice(0, decryptLength);
@@ -584,8 +596,10 @@ const uploadWithFetch = async ({ url, filePath, headers, timeoutMs, agent }) =>
584
596
  // Convert Node.js Readable to Web ReadableStream
585
597
  const nodeStream = createReadStream(filePath);
586
598
  const webStream = Readable.toWeb(nodeStream);
599
+ // Native fetch only accepts Undici-style dispatchers, not generic https Agents.
600
+ const dispatcher = typeof agent?.dispatch === 'function' ? agent : undefined;
587
601
  const response = await fetch(url, {
588
- dispatcher: agent,
602
+ ...(dispatcher ? { dispatcher } : {}),
589
603
  method: 'POST',
590
604
  body: webStream,
591
605
  headers,
@@ -133,7 +133,7 @@ export const prepareWAMessageMedia = async (message, options) => {
133
133
  }
134
134
  const requiresDurationComputation = mediaType === 'audio' && typeof uploadData.seconds === 'undefined';
135
135
  const requiresThumbnailComputation = (mediaType === 'image' || mediaType === 'video') && typeof uploadData['jpegThumbnail'] === 'undefined';
136
- const requiresWaveformProcessing = mediaType === 'audio' && uploadData.ptt === true;
136
+ const requiresWaveformProcessing = mediaType === 'audio' && uploadData.ptt === true && typeof uploadData.waveform === 'undefined';
137
137
  const requiresAudioBackground = options.backgroundColor && mediaType === 'audio' && uploadData.ptt === true;
138
138
  const requiresOriginalForSomeProcessing = requiresDurationComputation || requiresThumbnailComputation;
139
139
  const { mediaKey, encFilePath, originalFilePath, fileEncSha256, fileSha256, fileLength } = await encryptedStream(uploadData.media, options.mediaTypeOverride || mediaType, {
@@ -460,6 +460,12 @@ export const generateWAMessageContent = async (message, options) => {
460
460
  }
461
461
  }
462
462
  }
463
+ else if (hasNonNullishProperty(message, 'album')) {
464
+ m.albumMessage = {
465
+ expectedImageCount: message.album.expectedImageCount,
466
+ expectedVideoCount: message.album.expectedVideoCount
467
+ };
468
+ }
463
469
  else if (hasNonNullishProperty(message, 'sharePhoneNumber')) {
464
470
  m.protocolMessage = {
465
471
  type: proto.Message.ProtocolMessage.Type.SHARE_PHONE_NUMBER
@@ -485,15 +491,23 @@ export const generateWAMessageContent = async (message, options) => {
485
491
  if (hasOptionalProperty(message, 'viewOnce') && !!message.viewOnce) {
486
492
  m = { viewOnceMessage: { message: m } };
487
493
  }
488
- if (hasOptionalProperty(message, 'mentions') && message.mentions?.length) {
494
+ if ((hasOptionalProperty(message, 'mentions') && message.mentions?.length) ||
495
+ (hasOptionalProperty(message, 'mentionAll') && message.mentionAll)) {
489
496
  const messageType = Object.keys(m)[0];
490
497
  const key = m[messageType];
491
- if ('contextInfo' in key && !!key.contextInfo) {
492
- key.contextInfo.mentionedJid = message.mentions;
498
+ if (key && 'contextInfo' in key) {
499
+ key.contextInfo = key.contextInfo || {};
500
+ if (message.mentions?.length) {
501
+ key.contextInfo.mentionedJid = message.mentions;
502
+ }
503
+ if (message.mentionAll) {
504
+ key.contextInfo.nonJidMentions = 1;
505
+ }
493
506
  }
494
507
  else if (key) {
495
508
  key.contextInfo = {
496
- mentionedJid: message.mentions
509
+ mentionedJid: message.mentions,
510
+ nonJidMentions: message.mentionAll ? 1 : 0
497
511
  };
498
512
  }
499
513
  }
@@ -517,6 +531,15 @@ export const generateWAMessageContent = async (message, options) => {
517
531
  key.contextInfo = message.contextInfo;
518
532
  }
519
533
  }
534
+ if (hasOptionalProperty(message, 'albumParentKey') && !!message.albumParentKey) {
535
+ m.messageContextInfo = {
536
+ ...m.messageContextInfo,
537
+ messageAssociation: {
538
+ associationType: WAProto.MessageAssociation.AssociationType.MEDIA_ALBUM,
539
+ parentMessageKey: message.albumParentKey
540
+ }
541
+ };
542
+ }
520
543
  if (shouldIncludeReportingToken(m)) {
521
544
  m.messageContextInfo = m.messageContextInfo || {};
522
545
  if (!m.messageContextInfo.messageSecret) {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Creates a processor for offline stanza nodes that:
3
+ * - Queues nodes for sequential processing
4
+ * - Yields to the event loop periodically to avoid blocking
5
+ * - Catches handler errors to prevent the processing loop from crashing
6
+ */
7
+ export function makeOfflineNodeProcessor(nodeProcessorMap, deps, batchSize = 10) {
8
+ const nodes = [];
9
+ let isProcessing = false;
10
+ const enqueue = (type, node) => {
11
+ nodes.push({ type, node });
12
+ if (isProcessing) {
13
+ return;
14
+ }
15
+ isProcessing = true;
16
+ const promise = async () => {
17
+ let processedInBatch = 0;
18
+ while (nodes.length && deps.isWsOpen()) {
19
+ const { type, node } = nodes.shift();
20
+ const nodeProcessor = nodeProcessorMap.get(type);
21
+ if (!nodeProcessor) {
22
+ deps.onUnexpectedError(new Error(`unknown offline node type: ${type}`), 'processing offline node');
23
+ continue;
24
+ }
25
+ await nodeProcessor(node).catch(err => deps.onUnexpectedError(err, `processing offline ${type}`));
26
+ processedInBatch++;
27
+ // Yield to event loop after processing a batch
28
+ // This prevents blocking the event loop for too long when there are many offline nodes
29
+ if (processedInBatch >= batchSize) {
30
+ processedInBatch = 0;
31
+ await deps.yieldToEventLoop();
32
+ }
33
+ }
34
+ isProcessing = false;
35
+ };
36
+ promise().catch(error => deps.onUnexpectedError(error, 'processing offline nodes'));
37
+ };
38
+ return { enqueue };
39
+ }
40
+ //# sourceMappingURL=offline-node-processor.js.map
@@ -1,3 +1,4 @@
1
+ import { Boom } from '@hapi/boom';
1
2
  import { proto } from '../../WAProto/index.js';
2
3
  import { WAMessageStubType } from '../Types/index.js';
3
4
  import { getContentType, normalizeMessageContent } from '../Utils/messages.js';
@@ -5,6 +6,7 @@ import { areJidsSameUser, isHostedLidUser, isHostedPnUser, isJidBroadcast, isJid
5
6
  import { aesDecryptGCM, hmacSign } from './crypto.js';
6
7
  import { getKeyAuthor, toNumber } from './generics.js';
7
8
  import { downloadAndProcessHistorySyncNotification } from './history.js';
9
+ import { buildMergedTcTokenIndexWrite, resolveTcTokenJid } from './tc-token-utils.js';
8
10
  const REAL_MSG_STUB_TYPES = new Set([
9
11
  WAMessageStubType.CALL_MISSED_GROUP_VIDEO,
10
12
  WAMessageStubType.CALL_MISSED_GROUP_VOICE,
@@ -12,6 +14,53 @@ const REAL_MSG_STUB_TYPES = new Set([
12
14
  WAMessageStubType.CALL_MISSED_VOICE
13
15
  ]);
14
16
  const REAL_MSG_REQ_ME_STUB_TYPES = new Set([WAMessageStubType.GROUP_PARTICIPANT_ADD]);
17
+ async function storeTcTokensFromHistorySync(chats, signalRepository, keyStore, logger) {
18
+ const getLIDForPN = signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping);
19
+ const candidates = [];
20
+ for (const chat of chats) {
21
+ const ts = chat.tcTokenTimestamp ? toNumber(chat.tcTokenTimestamp) : 0;
22
+ if (chat.tcToken?.length && ts > 0) {
23
+ const jid = jidNormalizedUser(chat.id);
24
+ const storageJid = await resolveTcTokenJid(jid, getLIDForPN);
25
+ candidates.push({
26
+ storageJid,
27
+ token: Buffer.from(chat.tcToken),
28
+ ts,
29
+ senderTs: chat.tcTokenSenderTimestamp ? toNumber(chat.tcTokenSenderTimestamp) : undefined
30
+ });
31
+ }
32
+ }
33
+ if (!candidates.length) {
34
+ return;
35
+ }
36
+ const jids = candidates.map(c => c.storageJid);
37
+ const existing = await keyStore.get('tctoken', jids);
38
+ const entries = {};
39
+ for (const c of candidates) {
40
+ const existingEntry = existing[c.storageJid];
41
+ const existingTs = existingEntry?.timestamp ? Number(existingEntry.timestamp) : 0;
42
+ if (existingTs > 0 && existingTs >= c.ts) {
43
+ continue;
44
+ }
45
+ entries[c.storageJid] = {
46
+ ...existingEntry,
47
+ token: c.token,
48
+ timestamp: String(c.ts),
49
+ ...(c.senderTs !== undefined ? { senderTimestamp: c.senderTs } : {})
50
+ };
51
+ }
52
+ if (Object.keys(entries).length) {
53
+ logger?.debug({ count: Object.keys(entries).length }, 'storing tctokens from history sync');
54
+ try {
55
+ // Include updated __index so cross-session pruning picks these JIDs up.
56
+ const indexWrite = await buildMergedTcTokenIndexWrite(keyStore, Object.keys(entries));
57
+ await keyStore.set({ tctoken: { ...entries, ...indexWrite } });
58
+ }
59
+ catch (err) {
60
+ logger?.warn({ err }, 'failed to store tctokens from history sync');
61
+ }
62
+ }
63
+ }
15
64
  /** Cleans a received message to further processing */
16
65
  export const cleanMessage = (message, meId, meLid) => {
17
66
  // ensure remoteJid and participant doesn't have device or agent in it
@@ -73,7 +122,17 @@ export const shouldIncrementChatUnread = (message) => !message.key.fromMe && !me
73
122
  * Typically -- that'll be the remoteJid, but for broadcasts, it'll be the participant
74
123
  */
75
124
  export const getChatId = ({ remoteJid, participant, fromMe }) => {
125
+ if (!remoteJid) {
126
+ throw new Boom('Cannot derive chat id: message key is missing remoteJid', {
127
+ data: { remoteJid, participant, fromMe }
128
+ });
129
+ }
76
130
  if (isJidBroadcast(remoteJid) && !isJidStatusBroadcast(remoteJid) && !fromMe) {
131
+ if (!participant) {
132
+ throw new Boom('Cannot derive chat id: broadcast message key is missing participant', {
133
+ data: { remoteJid, fromMe }
134
+ });
135
+ }
77
136
  return participant;
78
137
  }
79
138
  return remoteJid;
@@ -146,6 +205,39 @@ const processMessage = async (message, { shouldProcessHistoryMsg, placeholderRes
146
205
  }
147
206
  const protocolMsg = content?.protocolMessage;
148
207
  if (protocolMsg) {
208
+ // Mirror whatsmeow's `handleProtocolMessage` guard, but applied only to
209
+ // the protocol message types that originate from our own device — an
210
+ // attacker could otherwise spoof any of these to manipulate local state.
211
+ //
212
+ // Self-only types (drop if `!fromMe`):
213
+ // - HISTORY_SYNC_NOTIFICATION (our phone driving history sync)
214
+ // - APP_STATE_SYNC_KEY_SHARE (key share between our devices)
215
+ // - LID_MIGRATION_MAPPING_SYNC (server-initiated via our phone)
216
+ // - PEER_DATA_OPERATION_REQUEST_RESPONSE_MESSAGE (response from our phone to our PDO request)
217
+ //
218
+ // Cross-user types (must NOT be dropped — legitimately arrive from others):
219
+ // - REVOKE
220
+ // - MESSAGE_EDIT
221
+ // - EPHEMERAL_SETTING
222
+ // - GROUP_MEMBER_LABEL_CHANGE
223
+ //
224
+ // See https://github.com/tulir/whatsmeow/blob/8d3700152a/message.go#L842-L845
225
+ // for the reference architecture — whatsmeow's `handleProtocolMessage`
226
+ // only contains self-only types because edits are unwrapped from
227
+ // `EditedMessage` BEFORE this dispatch and revokes aren't routed here.
228
+ const SELF_ONLY_TYPES = new Set([
229
+ proto.Message.ProtocolMessage.Type.HISTORY_SYNC_NOTIFICATION,
230
+ proto.Message.ProtocolMessage.Type.APP_STATE_SYNC_KEY_SHARE,
231
+ proto.Message.ProtocolMessage.Type.LID_MIGRATION_MAPPING_SYNC,
232
+ proto.Message.ProtocolMessage.Type.PEER_DATA_OPERATION_REQUEST_RESPONSE_MESSAGE
233
+ ]);
234
+ if (protocolMsg.type !== null &&
235
+ protocolMsg.type !== undefined &&
236
+ SELF_ONLY_TYPES.has(protocolMsg.type) &&
237
+ !message.key.fromMe) {
238
+ logger?.warn({ msgId: message.key.id, type: protocolMsg.type, from: message.key.participant || message.key.remoteJid }, 'dropping spoofed self-only protocolMessage from non-self origin');
239
+ return;
240
+ }
149
241
  switch (protocolMsg.type) {
150
242
  case proto.Message.ProtocolMessage.Type.HISTORY_SYNC_NOTIFICATION:
151
243
  const histNotification = protocolMsg.historySyncNotification;
@@ -174,9 +266,11 @@ const processMessage = async (message, { shouldProcessHistoryMsg, placeholderRes
174
266
  .storeLIDPNMappings(data.lidPnMappings)
175
267
  .catch(err => logger?.warn({ err }, 'failed to store LID-PN mappings from history sync'));
176
268
  }
269
+ await storeTcTokensFromHistorySync(data.chats, signalRepository, keyStore, logger);
177
270
  ev.emit('messaging-history.set', {
178
271
  ...data,
179
272
  isLatest: histNotification.syncType !== proto.HistorySync.HistorySyncType.ON_DEMAND ? isLatest : undefined,
273
+ chunkOrder: histNotification.chunkOrder,
180
274
  peerDataRequestSessionId: histNotification.peerDataRequestSessionId
181
275
  });
182
276
  }
@@ -383,12 +477,19 @@ const processMessage = async (message, { shouldProcessHistoryMsg, placeholderRes
383
477
  id: jid,
384
478
  author: message.key.participant,
385
479
  authorPn: message.key.participantAlt,
480
+ authorUsername: message.key.participantUsername,
386
481
  participants,
387
482
  action
388
483
  });
389
484
  const emitGroupUpdate = (update) => {
390
485
  ev.emit('groups.update', [
391
- { id: jid, ...update, author: message.key.participant ?? undefined, authorPn: message.key.participantAlt }
486
+ {
487
+ id: jid,
488
+ ...update,
489
+ author: message.key.participant ?? undefined,
490
+ authorPn: message.key.participantAlt,
491
+ authorUsername: message.key.participantUsername
492
+ }
392
493
  ]);
393
494
  };
394
495
  const emitGroupRequestJoin = (participant, action, method) => {
@@ -396,6 +497,7 @@ const processMessage = async (message, { shouldProcessHistoryMsg, placeholderRes
396
497
  id: jid,
397
498
  author: message.key.participant,
398
499
  authorPn: message.key.participantAlt,
500
+ authorUsername: message.key.participantUsername,
399
501
  participant: participant.lid,
400
502
  participantPn: participant.pn,
401
503
  action,
@@ -55,6 +55,48 @@ export const xmppPreKey = (pair, id) => ({
55
55
  { tag: 'value', attrs: {}, content: pair.public }
56
56
  ]
57
57
  });
58
+ const isValidUInt = (n) => typeof n === 'number' && Number.isInteger(n);
59
+ export const extractE2ESessionFromRetryReceipt = (receipt) => {
60
+ const keysNode = getBinaryNodeChild(receipt, 'keys');
61
+ if (!keysNode)
62
+ return null;
63
+ const typeBuf = getBinaryNodeChildBuffer(keysNode, 'type');
64
+ if (!typeBuf || typeBuf.length !== 1 || typeBuf[0] !== KEY_BUNDLE_TYPE[0])
65
+ return null;
66
+ const identity = getBinaryNodeChildBuffer(keysNode, 'identity');
67
+ const skey = getBinaryNodeChild(keysNode, 'skey');
68
+ if (!identity || identity.length !== 32 || !skey)
69
+ return null;
70
+ const registrationId = getBinaryNodeChildUInt(receipt, 'registration', 4);
71
+ if (!isValidUInt(registrationId))
72
+ return null;
73
+ const signedPubKey = getBinaryNodeChildBuffer(skey, 'value');
74
+ const signedSig = getBinaryNodeChildBuffer(skey, 'signature');
75
+ const signedKeyId = getBinaryNodeChildUInt(skey, 'id', 3);
76
+ if (!signedPubKey || signedPubKey.length !== 32 || !signedSig || !isValidUInt(signedKeyId)) {
77
+ return null;
78
+ }
79
+ const preKeyNode = getBinaryNodeChild(keysNode, 'key');
80
+ let preKey;
81
+ if (preKeyNode) {
82
+ const preKeyPub = getBinaryNodeChildBuffer(preKeyNode, 'value');
83
+ const preKeyId = getBinaryNodeChildUInt(preKeyNode, 'id', 3);
84
+ if (!preKeyPub || preKeyPub.length !== 32 || !isValidUInt(preKeyId)) {
85
+ return null;
86
+ }
87
+ preKey = { keyId: preKeyId, publicKey: generateSignalPubKey(preKeyPub) };
88
+ }
89
+ return {
90
+ registrationId,
91
+ identityKey: generateSignalPubKey(identity),
92
+ signedPreKey: {
93
+ keyId: signedKeyId,
94
+ publicKey: generateSignalPubKey(signedPubKey),
95
+ signature: signedSig
96
+ },
97
+ preKey
98
+ };
99
+ };
58
100
  export const parseAndInjectE2ESessions = async (node, repository) => {
59
101
  const extractKey = (key) => key
60
102
  ? {