@itsliaaa/baileys 0.1.33 → 0.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.
@@ -5,17 +5,19 @@ 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 { WAMessageStatus, WAMessageStubType } from '../Types/index.js';
8
- import { aesDecryptCTR, aesEncryptGCM, cleanMessage, Curve, decodeMediaRetryNode, decodeMessageNode, decryptMessageNode, delay, derivePairingCodeKey, encodeBigEndian, encodeSignedDeviceIdentity, extractAddressingContext, getCallStatusFromNode, getHistoryMsg, getNextPreKeys, getStatusFromReceiptType, handleIdentityChange, hkdf, MISSING_KEYS_ERROR_TEXT, NACK_REASONS, NO_MESSAGE_FOUND_ERROR_TEXT, toNumber, unixTimestampSeconds, xmppPreKey, xmppSignedPreKey } from '../Utils/index.js';
8
+ import { aesDecryptCTR, aesEncryptGCM, cleanMessage, Curve, decodeMediaRetryNode, decodeMessageNode, decryptMessageNode, delay, derivePairingCodeKey, encodeBigEndian, encodeSignedDeviceIdentity, extractAddressingContext, getCallStatusFromNode, getHistoryMsg, getNextPreKeys, getStatusFromReceiptType, handleIdentityChange, hkdf, MISSING_KEYS_ERROR_TEXT, SERVER_ERROR_CODES, NACK_REASONS, NO_MESSAGE_FOUND_ERROR_TEXT, toNumber, unixTimestampSeconds, 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';
12
+ import { buildMergedTcTokenIndexWrite, isTcTokenExpired, readTcTokenIndex, resolveIssuanceJid, resolveTcTokenJid, storeTcTokensFromIqResult, TC_TOKEN_INDEX_KEY } from '../Utils/tc-token-utils.js';
12
13
  import { areJidsSameUser, binaryNodeToString, getAllBinaryNodeChildren, getBinaryNodeChild, getBinaryNodeChildBuffer, getBinaryNodeChildren, getBinaryNodeChildString, isJidGroup, isJidNewsletter, isJidStatusBroadcast, isLidUser, isPnUser, jidDecode, jidNormalizedUser, S_WHATSAPP_NET } from '../WABinary/index.js';
13
14
  import { extractGroupMetadata } from './groups.js';
14
15
  import { makeMessagesSocket } from './messages-send.js';
15
16
  export const makeMessagesRecvSocket = (config) => {
16
17
  const { logger, retryRequestDelayMs, maxMsgRetryCount, getMessage, shouldIgnoreJid, enableAutoSessionRecreation } = config;
17
18
  const sock = makeMessagesSocket(config);
18
- const { ev, authState, ws, messageMutex, notificationMutex, receiptMutex, signalRepository, query, upsertMessage, resyncAppState, onUnexpectedError, assertSessions, sendNode, relayMessage, sendReceipt, uploadPreKeys, sendPeerDataOperationMessage, generateMessageTag, messageRetryManager, registerSocketEndHandler } = sock;
19
+ const { ev, authState, ws, messageMutex, notificationMutex, receiptMutex, signalRepository, query, upsertMessage, resyncAppState, onUnexpectedError, assertSessions, sendNode, relayMessage, sendReceipt, uploadPreKeys, sendPeerDataOperationMessage, generateMessageTag, messageRetryManager, issuePrivacyTokens, registerSocketEndHandler } = sock;
20
+ const getLIDForPN = signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping);
19
21
  /** this mutex ensures that each retryRequest will wait for the previous one to finish */
20
22
  const retryMutex = makeMutex();
21
23
  const devicesMutex = makeMutex();
@@ -473,6 +475,40 @@ export const makeMessagesRecvSocket = (config) => {
473
475
  logger.info({ msgAttrs: node.attrs, retryCount }, 'sent retry receipt');
474
476
  }, authState?.creds?.me?.id || 'sendRetryRequest');
475
477
  };
478
+ /**
479
+ * Fire-and-forget tctoken re-issuance after a peer's device identity changed.
480
+ * Mirrors WAWebSendTcTokenWhenDeviceIdentityChange — runs in parallel with
481
+ * the session refresh (not after it).
482
+ */
483
+ const reissueTcTokenAfterIdentityChange = (from) => {
484
+ void (async () => {
485
+ const normalizedJid = jidNormalizedUser(from);
486
+ const tcJid = await resolveTcTokenJid(normalizedJid, getLIDForPN);
487
+ const tcTokenData = await authState.keys.get('tctoken', [tcJid]);
488
+ const senderTs = tcTokenData?.[tcJid]?.senderTimestamp;
489
+ if (senderTs === null || senderTs === undefined || isTcTokenExpired(senderTs)) {
490
+ return;
491
+ }
492
+ logger.debug({ jid: normalizedJid, senderTimestamp: senderTs }, 'identity changed, re-issuing tctoken');
493
+ const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping);
494
+ const issueJid = await resolveIssuanceJid(
495
+ normalizedJid,
496
+ sock.serverProps.lidTrustedTokenIssueToLid,
497
+ getLIDForPN,
498
+ getPNForLID
499
+ );
500
+ const result = await issuePrivacyTokens([issueJid], senderTs);
501
+ await storeTcTokensFromIqResult({
502
+ result,
503
+ fallbackJid: tcJid,
504
+ keys: authState.keys,
505
+ getLIDForPN,
506
+ onNewJidStored: trackTcTokenJid
507
+ });
508
+ })().catch(err => {
509
+ logger.debug({ jid: from, err: err?.message }, 'failed to re-issue tctoken after identity change');
510
+ });
511
+ };
476
512
  const handleEncryptNotification = async (node) => {
477
513
  const from = node.attrs.from;
478
514
  if (from === S_WHATSAPP_NET) {
@@ -491,7 +527,8 @@ export const makeMessagesRecvSocket = (config) => {
491
527
  validateSession: signalRepository.validateSession,
492
528
  assertSessions,
493
529
  debounceCache: identityAssertDebounce,
494
- logger
530
+ logger,
531
+ onBeforeSessionRefresh: reissueTcTokenAfterIdentityChange
495
532
  });
496
533
  if (result.action === 'no_identity_node') {
497
534
  logger.info({ node }, 'unknown encrypt notification');
@@ -502,6 +539,7 @@ export const makeMessagesRecvSocket = (config) => {
502
539
  // TODO: Support PN/LID (Here is only LID now)
503
540
  const actingParticipantLid = fullNode.attrs.participant;
504
541
  const actingParticipantPn = fullNode.attrs.participant_pn;
542
+ const actingParticipantUsername = fullNode.attrs.participant_username;
505
543
  const affectedParticipantLid = getBinaryNodeChild(child, 'participant')?.attrs?.jid || actingParticipantLid;
506
544
  const affectedParticipantPn = getBinaryNodeChild(child, 'participant')?.attrs?.phone_number || actingParticipantPn;
507
545
  switch (child?.tag) {
@@ -521,7 +559,8 @@ export const makeMessagesRecvSocket = (config) => {
521
559
  {
522
560
  ...metadata,
523
561
  author: actingParticipantLid,
524
- authorPn: actingParticipantPn
562
+ authorPn: actingParticipantPn,
563
+ authorUsername: actingParticipantUsername
525
564
  }
526
565
  ]);
527
566
  break;
@@ -552,6 +591,7 @@ export const makeMessagesRecvSocket = (config) => {
552
591
  id: attrs.jid,
553
592
  phoneNumber: isLidUser(attrs.jid) && isPnUser(attrs.phone_number) ? attrs.phone_number : undefined,
554
593
  lid: isPnUser(attrs.jid) && isLidUser(attrs.lid) ? attrs.lid : undefined,
594
+ username: attrs.participant_username || attrs.username || undefined,
555
595
  admin: (attrs.type || null)
556
596
  };
557
597
  });
@@ -834,27 +874,70 @@ export const makeMessagesRecvSocket = (config) => {
834
874
  return result;
835
875
  }
836
876
  };
877
+ /**
878
+ * In-memory cache of storage JIDs with stored tctokens, seeded from the persisted index.
879
+ * Used to coalesce writes during a session; pruning always re-reads the persisted index
880
+ * to cover writes made by other layers (e.g. history sync).
881
+ */
882
+ const tcTokenKnownJids = new Set();
883
+ const tcTokenIndexLoaded = (async () => {
884
+ try {
885
+ const jids = await readTcTokenIndex(authState.keys);
886
+ for (const jid of jids) {
887
+ tcTokenKnownJids.add(jid);
888
+ }
889
+ logger.debug({ count: tcTokenKnownJids.size }, 'loaded tctoken index');
890
+ }
891
+ catch (err) {
892
+ logger.warn({ err: err?.message }, 'failed to load tctoken index');
893
+ }
894
+ })();
895
+ let tcTokenIndexTimer;
896
+ async function flushTcTokenIndex() {
897
+ if (tcTokenIndexTimer) {
898
+ clearTimeout(tcTokenIndexTimer);
899
+ tcTokenIndexTimer = undefined;
900
+ }
901
+ // Merge with whatever is already persisted so we don't clobber writes from other
902
+ // paths (history sync, concurrent sessions on the same store).
903
+ const write = await buildMergedTcTokenIndexWrite(authState.keys, tcTokenKnownJids);
904
+ return authState.keys.set({ tctoken: write });
905
+ };
906
+ function scheduleTcTokenIndexSave() {
907
+ if (tcTokenIndexTimer) {
908
+ clearTimeout(tcTokenIndexTimer);
909
+ }
910
+ tcTokenIndexTimer = setTimeout(() => {
911
+ tcTokenIndexTimer = undefined;
912
+ flushTcTokenIndex().catch(err => {
913
+ logger.warn({ err: err?.message }, 'failed to save tctoken index');
914
+ });
915
+ }, 5000);
916
+ };
917
+ function trackTcTokenJid(jid) {
918
+ if (jid && jid !== TC_TOKEN_INDEX_KEY && !tcTokenKnownJids.has(jid)) {
919
+ tcTokenKnownJids.add(jid);
920
+ scheduleTcTokenIndexSave();
921
+ }
922
+ }
837
923
  const handlePrivacyTokenNotification = async (node) => {
838
924
  const tokensNode = getBinaryNodeChild(node, 'tokens');
925
+ if (!tokensNode) return;
839
926
  const from = jidNormalizedUser(node.attrs.from);
840
- if (!tokensNode)
841
- return;
842
- const tokenNodes = getBinaryNodeChildren(tokensNode, 'token');
843
- for (const tokenNode of tokenNodes) {
844
- const { attrs, content } = tokenNode;
845
- const type = attrs.type;
846
- const timestamp = attrs.t;
847
- if (type === 'trusted_contact' && content instanceof Buffer) {
848
- logger.debug({
849
- from,
850
- timestamp,
851
- tcToken: content
852
- }, 'received trusted contact token');
853
- await authState.keys.set({
854
- tctoken: { [from]: { token: content, timestamp } }
855
- });
856
- }
857
- }
927
+ // WA Web uses: senderLid ?? toLid(from) for the storage key
928
+ // The sender_lid attribute provides the LID directly when available
929
+ const senderLid = node.attrs.sender_lid && isLidUser(jidNormalizedUser(node.attrs.sender_lid))
930
+ ? jidNormalizedUser(node.attrs.sender_lid)
931
+ : undefined;
932
+ const fallbackJid = senderLid ?? (await resolveTcTokenJid(from, getLIDForPN));
933
+ logger.debug({ from, storageJid: fallbackJid }, 'processing privacy token notification');
934
+ await storeTcTokensFromIqResult({
935
+ result: node,
936
+ fallbackJid,
937
+ keys: authState.keys,
938
+ getLIDForPN,
939
+ onNewJidStored: trackTcTokenJid
940
+ });
858
941
  };
859
942
  async function decipherLinkPublicKey(data) {
860
943
  const buffer = toRequiredBuffer(data);
@@ -973,11 +1056,6 @@ export const makeMessagesRecvSocket = (config) => {
973
1056
  fromMe,
974
1057
  participant: attrs.participant
975
1058
  };
976
- if (shouldIgnoreJid(remoteJid) && remoteJid !== S_WHATSAPP_NET) {
977
- logger.debug({ remoteJid }, 'ignoring receipt from jid');
978
- await sendMessageAck(node);
979
- return;
980
- }
981
1059
  const ids = [attrs.id];
982
1060
  if (Array.isArray(content)) {
983
1061
  const items = getBinaryNodeChildren(content[0], 'item');
@@ -1042,11 +1120,6 @@ export const makeMessagesRecvSocket = (config) => {
1042
1120
  };
1043
1121
  const handleNotification = async (node) => {
1044
1122
  const remoteJid = node.attrs.from;
1045
- if (shouldIgnoreJid(remoteJid) && remoteJid !== S_WHATSAPP_NET) {
1046
- logger.debug({ remoteJid, id: node.attrs.id }, 'ignored notification');
1047
- await sendMessageAck(node);
1048
- return;
1049
- }
1050
1123
  try {
1051
1124
  await Promise.all([
1052
1125
  notificationMutex.mutex(async () => {
@@ -1059,6 +1132,7 @@ export const makeMessagesRecvSocket = (config) => {
1059
1132
  fromMe,
1060
1133
  participant: node.attrs.participant,
1061
1134
  participantAlt,
1135
+ username: attrs.participant_username || attrs.username || undefined,
1062
1136
  addressingMode,
1063
1137
  id: node.attrs.id,
1064
1138
  ...(msg.key || {})
@@ -1076,11 +1150,6 @@ export const makeMessagesRecvSocket = (config) => {
1076
1150
  }
1077
1151
  };
1078
1152
  const handleMessage = async (node) => {
1079
- if (shouldIgnoreJid(node.attrs.from) && node.attrs.from !== S_WHATSAPP_NET) {
1080
- logger.debug({ key: node.attrs.key }, 'ignored message');
1081
- await sendMessageAck(node, NACK_REASONS.UnhandledError);
1082
- return;
1083
- }
1084
1153
  const encNode = getBinaryNodeChild(node, 'enc');
1085
1154
  // TODO: temporary fix for crashes and issues resulting of failed msmsg decryption
1086
1155
  if (encNode?.attrs.type === 'msmsg') {
@@ -1298,6 +1367,13 @@ export const makeMessagesRecvSocket = (config) => {
1298
1367
  offline: !!attrs.offline,
1299
1368
  status
1300
1369
  };
1370
+ if (status === 'relaylatency') {
1371
+ const latencyValue = infoChild.attrs.latency || infoChild.attrs['latency_ms'] || infoChild.attrs['latency-ms'];
1372
+ const latencyMs = latencyValue ? Number(latencyValue) : undefined;
1373
+ if (Number.isFinite(latencyMs)) {
1374
+ call.latencyMs = latencyMs;
1375
+ }
1376
+ }
1301
1377
  if (status === 'offer') {
1302
1378
  call.isVideo = !!getBinaryNodeChild(infoChild, 'video');
1303
1379
  call.isGroup = infoChild.attrs.type === 'group' || !!infoChild.attrs['group-jid'];
@@ -1342,7 +1418,19 @@ export const makeMessagesRecvSocket = (config) => {
1342
1418
  // error in acknowledgement,
1343
1419
  // device could not display the message
1344
1420
  if (attrs.error) {
1345
- logger.warn({ attrs }, 'received error in ack');
1421
+ if (attrs.error === SERVER_ERROR_CODES.MissingTcToken) {
1422
+ // 463 = account restricted + no tctoken for this contact.
1423
+ // WA Web prevents this client-side (disables compose bar).
1424
+ // No retry — retrying worsens the restriction by counting
1425
+ // as another "reach out" to an unknown contact.
1426
+ logger.warn({ msgId: attrs.id, from: attrs.from }, 'error 463: account restricted or missing tctoken for contact');
1427
+ }
1428
+ else if (attrs.error === SERVER_ERROR_CODES.SmaxInvalid) {
1429
+ logger.warn({ msgId: attrs.id, from: attrs.from }, 'smax-invalid (479): stanza rejected by server — likely stale device session or malformed addressing');
1430
+ }
1431
+ else {
1432
+ logger.warn({ attrs }, 'received error in ack');
1433
+ }
1346
1434
  ev.emit('messages.update', [
1347
1435
  {
1348
1436
  key,
@@ -1352,19 +1440,6 @@ export const makeMessagesRecvSocket = (config) => {
1352
1440
  }
1353
1441
  }
1354
1442
  ]);
1355
- // resend the message with device_fanout=false, use at your own risk
1356
- // if (attrs.error === '475') {
1357
- // const msg = await getMessage(key)
1358
- // if (msg) {
1359
- // await relayMessage(key.remoteJid!, msg, {
1360
- // messageId: key.id!,
1361
- // useUserDevicesCache: false,
1362
- // additionalAttributes: {
1363
- // device_fanout: 'false'
1364
- // }
1365
- // })
1366
- // }
1367
- // }
1368
1443
  }
1369
1444
  };
1370
1445
  /// processes a node with the given function
@@ -1388,6 +1463,22 @@ export const makeMessagesRecvSocket = (config) => {
1388
1463
  yieldToEventLoop: () => new Promise(resolve => setImmediate(resolve))
1389
1464
  });
1390
1465
  const processNode = async (type, node, identifier, exec) => {
1466
+ // Fast path: ack and drop ignored JIDs before entering the buffer/queue
1467
+ const from = node.attrs.from;
1468
+ let ignoreJid = from;
1469
+ if (type === 'receipt' && from) {
1470
+ const attrs = node.attrs;
1471
+ const isLid = attrs.from.includes('lid');
1472
+ const isNodeFromMe = areJidsSameUser(
1473
+ attrs.participant || attrs.from,
1474
+ isLid ? authState.creds.me?.lid : authState.creds.me?.id
1475
+ );
1476
+ ignoreJid = !isNodeFromMe || isJidGroup(attrs.from) ? attrs.from : attrs.recipient;
1477
+ }
1478
+ if (ignoreJid && ignoreJid !== S_WHATSAPP_NET && shouldIgnoreJid(ignoreJid)) {
1479
+ await sendMessageAck(node, type === 'message' ? NACK_REASONS.UnhandledError : undefined);
1480
+ return;
1481
+ }
1391
1482
  const isOffline = !!node.attrs.offline;
1392
1483
  if (isOffline) {
1393
1484
  offlineNodeProcessor.enqueue(type, node);
@@ -1443,12 +1534,98 @@ export const makeMessagesRecvSocket = (config) => {
1443
1534
  await upsertMessage(protoMsg, call.offline ? 'append' : 'notify');
1444
1535
  }
1445
1536
  });
1446
- ev.on('connection.update', ({ isOnline }) => {
1537
+ /** timestamp of last tctoken prune run — throttles to once per 24h */
1538
+ let lastTcTokenPruneTs = 0;
1539
+ ev.on('connection.update', ({ isOnline, connection }) => {
1447
1540
  if (typeof isOnline !== 'undefined') {
1448
1541
  sendActiveReceipts = isOnline;
1449
1542
  logger.trace(`sendActiveReceipts set to "${sendActiveReceipts}"`);
1450
1543
  }
1451
- });
1544
+ // Flush pending tctoken index save on disconnect to avoid writing after close
1545
+ if (connection === 'close' && tcTokenIndexTimer) {
1546
+ clearTimeout(tcTokenIndexTimer);
1547
+ tcTokenIndexTimer = undefined;
1548
+ // Best-effort flush — may fail if store is already closed
1549
+ try {
1550
+ void Promise.resolve(flushTcTokenIndex()).catch(() => {});
1551
+ }
1552
+ catch {
1553
+ /* ignore sync errors */
1554
+ }
1555
+ }
1556
+ // Prune expired tctokens when coming online, at most once per 24 hours
1557
+ // Matches WA Web's CLEAN_TC_TOKENS task
1558
+ // Note: don't gate on tcTokenKnownJids.size — the index may still be loading
1559
+ if (isOnline) {
1560
+ const now = Date.now();
1561
+ const DAY_MS = 24 * 60 * 60 * 1000;
1562
+ if (now - lastTcTokenPruneTs >= DAY_MS) {
1563
+ lastTcTokenPruneTs = now;
1564
+ void pruneExpiredTcTokens();
1565
+ }
1566
+ }
1567
+ })
1568
+ async function pruneExpiredTcTokens() {
1569
+ try {
1570
+ await tcTokenIndexLoaded;
1571
+ // Union with the persisted index picks up JIDs added by other layers
1572
+ // (history sync) without needing inter-module wiring.
1573
+ const persisted = await readTcTokenIndex(authState.keys);
1574
+ const allJids = new Set(tcTokenKnownJids);
1575
+ for (const jid of persisted) {
1576
+ allJids.add(jid);
1577
+ }
1578
+ if (!allJids.size) return;
1579
+ const jids = [...allJids];
1580
+ const allTokens = await authState.keys.get('tctoken', jids);
1581
+ const writes = {};
1582
+ const survivors = new Set();
1583
+ let mutated = 0;
1584
+ for (const jid of jids) {
1585
+ const entry = allTokens[jid];
1586
+ if (!entry) {
1587
+ // Tracked but nothing in store — drop from index.
1588
+ mutated++;
1589
+ continue;
1590
+ }
1591
+ const hasPeerToken = !!entry.token?.length;
1592
+ const peerTokenExpired = hasPeerToken && isTcTokenExpired(entry.timestamp);
1593
+ const hasSenderTs = entry.senderTimestamp !== undefined;
1594
+ const senderTsExpired = hasSenderTs && isTcTokenExpired(entry.senderTimestamp);
1595
+ const keepPeerToken = hasPeerToken && !peerTokenExpired;
1596
+ const keepSenderTs = hasSenderTs && !senderTsExpired;
1597
+ if (!keepPeerToken && !keepSenderTs) {
1598
+ writes[jid] = null;
1599
+ mutated++;
1600
+ }
1601
+ else if (peerTokenExpired && keepSenderTs) {
1602
+ writes[jid] = { token: Buffer.alloc(0), senderTimestamp: entry.senderTimestamp };
1603
+ survivors.add(jid);
1604
+ mutated++;
1605
+ }
1606
+ else {
1607
+ survivors.add(jid);
1608
+ }
1609
+ }
1610
+ if (mutated === 0) return;
1611
+ await authState.keys.set({
1612
+ tctoken: {
1613
+ ...writes,
1614
+ [TC_TOKEN_INDEX_KEY]: {
1615
+ token: Buffer.from(JSON.stringify([...survivors]))
1616
+ }
1617
+ }
1618
+ });
1619
+ tcTokenKnownJids.clear();
1620
+ for (const jid of survivors) {
1621
+ tcTokenKnownJids.add(jid);
1622
+ }
1623
+ logger.debug({ mutated, remaining: survivors.size }, 'pruned expired tctokens');
1624
+ }
1625
+ catch (err) {
1626
+ logger.warn({ err: err?.message }, 'failed to prune expired tctokens');
1627
+ }
1628
+ };
1452
1629
  registerSocketEndHandler(() => {
1453
1630
  if (!config.msgRetryCounterCache && msgRetryCache.close) {
1454
1631
  msgRetryCache.close();
@@ -8,13 +8,21 @@ import { AssociationType } from '../Types/index.js';
8
8
  import { getUrlInfo } from '../Utils/link-preview.js';
9
9
  import { makeKeyedMutex } from '../Utils/make-mutex.js';
10
10
  import { getMessageReportingToken, shouldIncludeReportingToken } from '../Utils/reporting-utils.js';
11
- import { areJidsSameUser, getBinaryNodeChild, getBinaryNodeChildren, getBizBinaryNode, isHostedLidUser, isHostedPnUser, isJidGroup, isJidNewsletter, isLidUser, isPnUser, jidDecode, jidEncode, jidNormalizedUser, S_WHATSAPP_NET } from '../WABinary/index.js';
11
+ import { buildMergedTcTokenIndexWrite, isTcTokenExpired, resolveIssuanceJid, resolveTcTokenJid, shouldSendNewTcToken, storeTcTokensFromIqResult } from '../Utils/tc-token-utils.js';
12
+ import { areJidsSameUser, getBinaryNodeChild, getBinaryNodeChildren, getBizBinaryNode, isHostedLidUser, isHostedPnUser, isJidBot, isJidGroup, isJidMetaAI, isJidNewsletter, isLidUser, isPnUser, jidDecode, jidEncode, jidNormalizedUser, PSA_WID, S_WHATSAPP_NET } from '../WABinary/index.js';
12
13
  import { USyncQuery, USyncUser } from '../WAUSync/index.js';
13
14
  import { makeNewsletterSocket } from './newsletter.js';
14
15
  export const makeMessagesSocket = (config) => {
15
16
  const { logger, linkPreviewImageThumbnailWidth, generateHighQualityLinkPreview, options: httpRequestOptions, patchMessageBeforeSending, cachedGroupMetadata, enableRecentMessageCache, maxMsgRetryCount } = config;
16
17
  const sock = makeNewsletterSocket(config);
17
18
  const { ev, authState, messageMutex, signalRepository, upsertMessage, query, fetchPrivacySettings, sendNode, groupMetadata, groupToggleEphemeral, registerSocketEndHandler } = sock;
19
+ const getLIDForPN = signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping);
20
+ /**
21
+ * Set of tctoken storage JIDs with a fire-and-forget `issuePrivacyTokens` IQ in flight.
22
+ * Prevents duplicate IQs from rapid back-to-back sends before `senderTimestamp` persists.
23
+ * Entries are always removed in `.finally()`, so the set is bounded by concurrency.
24
+ */
25
+ const inFlightTcTokenIssuance = new Set();
18
26
  const userDevicesCache = config.userDevicesCache ??=
19
27
  new NodeCache({
20
28
  stdTTL: DEFAULT_CACHE_TTLS.USER_DEVICES, // 5 minutes
@@ -801,9 +809,30 @@ export const makeMessagesSocket = (config) => {
801
809
  logger.warn({ jid, trace: error?.stack }, 'failed to attach reporting token');
802
810
  }
803
811
  }
804
- const contactTcTokenData = !isGroup && !isRetryResend && !isStatus ? await authState.keys.get('tctoken', [destinationJid]) : {};
805
- const tcTokenBuffer = contactTcTokenData[destinationJid]?.token;
806
- if (tcTokenBuffer) {
812
+ // WA Web never attaches tctoken to peer (AppStateSync) messages server rejects with 479
813
+ const isPeerMessage = additionalAttributes?.['category'] === 'peer';
814
+ const is1on1Send = !isGroup && !isRetryResend && !isStatus && !isNewsletter && !isPeerMessage;
815
+ // Resolve destination to LID for tctoken storage — matches Signal session key pattern
816
+ const tcTokenJid = is1on1Send ? await resolveTcTokenJid(destinationJid, getLIDForPN) : destinationJid;
817
+ const contactTcTokenData = is1on1Send ? await authState.keys.get('tctoken', [tcTokenJid]) : {};
818
+ const existingTokenEntry = contactTcTokenData[tcTokenJid];
819
+ let tcTokenBuffer = existingTokenEntry?.token;
820
+ // Treat expired tokens the same as missing — clear from cache
821
+ if (tcTokenBuffer?.length && isTcTokenExpired(existingTokenEntry?.timestamp)) {
822
+ logger.debug({ jid: destinationJid, timestamp: existingTokenEntry?.timestamp }, 'tctoken expired, clearing');
823
+ tcTokenBuffer = undefined;
824
+ // Preserve senderTimestamp so the fire-and-forget issuance dedupe survives cleanup.
825
+ const cleared = existingTokenEntry?.senderTimestamp !== undefined
826
+ ? { token: Buffer.alloc(0), senderTimestamp: existingTokenEntry.senderTimestamp }
827
+ : null;
828
+ try {
829
+ await authState.keys.set({ tctoken: { [tcTokenJid]: cleared } });
830
+ }
831
+ catch (err) {
832
+ logger.debug({ jid: destinationJid, err: err?.message }, 'failed to persist tctoken expiry cleanup');
833
+ }
834
+ }
835
+ if (tcTokenBuffer?.length && sock.serverProps.privacyTokenOn1to1) {
807
836
  ;
808
837
  stanza.content.push({
809
838
  tag: 'tctoken',
@@ -825,6 +854,48 @@ export const makeMessagesSocket = (config) => {
825
854
  }
826
855
  logger.debug({ msgId }, `sending message to ${participants.length} devices`);
827
856
  await sendNode(stanza);
857
+ // Fire-and-forget: issue our token to the contact AFTER message send.
858
+ // WA Web skips protocol messages and PSA/bot contacts (TcTokenChatAction: isRegularUser)
859
+ const isProtocolMsg = !!normalizeMessageContent(message)?.protocolMessage;
860
+ const isBotOrPSA = destinationJid === PSA_WID || isJidBot(destinationJid) || isJidMetaAI(destinationJid);
861
+ if (is1on1Send &&
862
+ !isProtocolMsg &&
863
+ !isBotOrPSA &&
864
+ shouldSendNewTcToken(existingTokenEntry?.senderTimestamp) &&
865
+ !inFlightTcTokenIssuance.has(tcTokenJid)) {
866
+ inFlightTcTokenIssuance.add(tcTokenJid);
867
+ const issueTimestamp = unixTimestampSeconds();
868
+ const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping);
869
+ resolveIssuanceJid(destinationJid, sock.serverProps.lidTrustedTokenIssueToLid, getLIDForPN, getPNForLID)
870
+ .then(issueJid => issuePrivacyTokens([issueJid], issueTimestamp))
871
+ .then(async result => {
872
+ await storeTcTokensFromIqResult({
873
+ result,
874
+ fallbackJid: tcTokenJid,
875
+ keys: authState.keys,
876
+ getLIDForPN
877
+ });
878
+ const currentData = await authState.keys.get('tctoken', [tcTokenJid]);
879
+ const currentEntry = currentData[tcTokenJid];
880
+ const indexWrite = await buildMergedTcTokenIndexWrite(authState.keys, [tcTokenJid]);
881
+ await authState.keys.set({
882
+ tctoken: {
883
+ [tcTokenJid]: {
884
+ token: Buffer.alloc(0),
885
+ ...currentEntry,
886
+ senderTimestamp: issueTimestamp
887
+ },
888
+ ...indexWrite
889
+ }
890
+ });
891
+ })
892
+ .catch(err => {
893
+ logger.debug({ jid: destinationJid, err: err?.message }, 'fire-and-forget tctoken issuance failed');
894
+ })
895
+ .finally(() => {
896
+ inFlightTcTokenIssuance.delete(tcTokenJid);
897
+ });
898
+ }
828
899
  // Add message to retry cache if enabled
829
900
  if (messageRetryManager && !participant) {
830
901
  messageRetryManager.addRecentMessage(destinationJid, msgId, message);
@@ -915,8 +986,8 @@ export const makeMessagesSocket = (config) => {
915
986
  }
916
987
  return ''
917
988
  };
918
- const getPrivacyTokens = async (jids) => {
919
- const t = unixTimestampSeconds().toString();
989
+ const issuePrivacyTokens = async (jids, timestamp) => {
990
+ const t = (timestamp ?? unixTimestampSeconds()).toString();
920
991
  const result = await query({
921
992
  tag: 'iq',
922
993
  attrs: {
@@ -957,7 +1028,7 @@ export const makeMessagesSocket = (config) => {
957
1028
  });
958
1029
  return {
959
1030
  ...sock,
960
- getPrivacyTokens,
1031
+ issuePrivacyTokens,
961
1032
  assertSessions,
962
1033
  relayMessage,
963
1034
  sendReceipt,
@@ -38,7 +38,7 @@ const to64BitNetworkOrder = (e) => {
38
38
  buff.writeUint32BE(e, 4);
39
39
  return buff;
40
40
  };
41
- const makeLtHashGenerator = ({ indexValueMap, hash }) => {
41
+ export const makeLtHashGenerator = ({ indexValueMap, hash }) => {
42
42
  indexValueMap = { ...indexValueMap };
43
43
  const addBuffs = [];
44
44
  const subBuffs = [];
@@ -48,7 +48,10 @@ const makeLtHashGenerator = ({ indexValueMap, hash }) => {
48
48
  const prevOp = indexValueMap[indexMacBase64];
49
49
  if (operation === proto.SyncdMutation.SyncdOperation.REMOVE) {
50
50
  if (!prevOp) {
51
- throw new Boom('tried remove, but no previous op', { data: { indexMac, valueMac } });
51
+ // WA Web does not throw here it logs a warning and skips the subtract.
52
+ // The missing REMOVE will cause an LTHash mismatch, which is handled
53
+ // by the MAC validation layer (snapshot recovery or retry).
54
+ return;
52
55
  }
53
56
  // remove from index value mac, since this mutation is erased
54
57
  delete indexValueMap[indexMacBase64];
@@ -83,7 +86,7 @@ export const newLTHashState = () => ({ version: 0, hash: Buffer.alloc(128), inde
83
86
  export const encodeSyncdPatch = async ({ type, index, syncAction, apiVersion, operation }, myAppStateKeyId, state, getAppStateSyncKey) => {
84
87
  const key = !!myAppStateKeyId ? await getAppStateSyncKey(myAppStateKeyId) : undefined;
85
88
  if (!key) {
86
- throw new Boom(`myAppStateKey ("${myAppStateKeyId}") not present`, { statusCode: 404 });
89
+ throw new Boom(`myAppStateKey ("${myAppStateKeyId}") not present`, { data: { isMissingKey: true } });
87
90
  }
88
91
  const encKeyId = Buffer.from(myAppStateKeyId, 'base64');
89
92
  state = { ...state, indexValueMap: { ...state.indexValueMap } };
@@ -176,7 +179,7 @@ export const decodeSyncdMutations = async (msgMutations, initialState, getAppSta
176
179
  if (!keyEnc) {
177
180
  throw new Boom(`failed to find key "${base64Key}" to decode mutation`, {
178
181
  statusCode: 404,
179
- data: { msgMutations }
182
+ data: { isMissingKey: true, msgMutations }
180
183
  });
181
184
  }
182
185
  const keys = mutationKeys(keyEnc.keyData);
@@ -184,12 +187,35 @@ export const decodeSyncdMutations = async (msgMutations, initialState, getAppSta
184
187
  return keys;
185
188
  }
186
189
  };
190
+ export const ensureLTHashStateVersion = (state) => {
191
+ if (typeof state.version !== 'number' || isNaN(state.version)) {
192
+ state.version = 0;
193
+ }
194
+ return state;
195
+ };
196
+ export const MAX_SYNC_ATTEMPTS = 2;
197
+ /**
198
+ * Check if an error is a missing app state sync key.
199
+ * WA Web treats these as "Blocked" (waits for key arrival), not fatal.
200
+ * In Baileys we retry with a snapshot which may use a different key.
201
+ */
202
+ export const isMissingKeyError = (error) => {
203
+ return error?.data?.isMissingKey === true;
204
+ };
205
+ /**
206
+ * Determines if an app state sync error is unrecoverable.
207
+ * TypeError indicates a WASM crash; otherwise we give up after MAX_SYNC_ATTEMPTS.
208
+ * Missing keys are NOT checked here — they are handled separately as "Blocked".
209
+ */
210
+ export const isAppStateSyncIrrecoverable = (error, attempts) => {
211
+ return attempts >= MAX_SYNC_ATTEMPTS || error?.name === 'TypeError';
212
+ };
187
213
  export const decodeSyncdPatch = async (msg, name, initialState, getAppStateSyncKey, onMutation, validateMacs) => {
188
214
  if (validateMacs) {
189
215
  const base64Key = Buffer.from(msg.keyId.id).toString('base64');
190
216
  const mainKeyObj = await getAppStateSyncKey(base64Key);
191
217
  if (!mainKeyObj) {
192
- throw new Boom(`failed to find key "${base64Key}" to decode patch`, { statusCode: 404, data: { msg } });
218
+ throw new Boom(`failed to find key "${base64Key}" to decode patch`, { data: { isMissingKey: true, msg } });
193
219
  }
194
220
  const mainKey = mutationKeys(mainKeyObj.keyData);
195
221
  const mutationmacs = msg.mutations.map(mutation => mutation.record.value.blob.slice(-32));
@@ -267,7 +293,7 @@ export const decodeSyncdSnapshot = async (name, snapshot, getAppStateSyncKey, mi
267
293
  const base64Key = Buffer.from(snapshot.keyId.id).toString('base64');
268
294
  const keyEnc = await getAppStateSyncKey(base64Key);
269
295
  if (!keyEnc) {
270
- throw new Boom(`failed to find key "${base64Key}" to decode mutation`);
296
+ throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { data: { isMissingKey: true } });
271
297
  }
272
298
  const result = mutationKeys(keyEnc.keyData);
273
299
  const computedSnapshotMac = generateSnapshotMac(newState.hash, newState.version, name, result.snapshotMacKey);
@@ -309,7 +335,7 @@ export const decodePatches = async (name, syncds, initial, getAppStateSyncKey, o
309
335
  const base64Key = Buffer.from(keyId.id).toString('base64');
310
336
  const keyEnc = await getAppStateSyncKey(base64Key);
311
337
  if (!keyEnc) {
312
- throw new Boom(`failed to find key "${base64Key}" to decode mutation`);
338
+ throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { data: { isMissingKey: true } });
313
339
  }
314
340
  const result = mutationKeys(keyEnc.keyData);
315
341
  const computedSnapshotMac = generateSnapshotMac(newState.hash, newState.version, name, result.snapshotMacKey);
@@ -779,6 +805,7 @@ export const processSyncAction = (syncAction, ev, me, initialSyncOpts, logger) =
779
805
  action.lidContactAction.firstName ||
780
806
  action.lidContactAction.username ||
781
807
  undefined,
808
+ username: action.lidContactAction.username || undefined,
782
809
  lid: id,
783
810
  phoneNumber: undefined
784
811
  }