whalibmob 5.5.25 → 5.5.27

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.
package/cli.js CHANGED
@@ -196,9 +196,10 @@ const HELP = `
196
196
  /biz <phone|jid> query business profile
197
197
 
198
198
  Registration
199
- /reg check <phone> check if number has WhatsApp
200
- /reg code <phone> [sms|voice|wa_old] request verification code
201
- /reg confirm <phone> <code> complete registration
199
+ /reg check <phone> check if number has WhatsApp
200
+ /reg code <phone> [sms|voice|wa_old] request verification code
201
+ /reg code <phone> email <address> request code via email
202
+ /reg confirm <phone> <code> complete registration
202
203
 
203
204
  Connection
204
205
  /connect <phone> connect to WhatsApp
@@ -1153,8 +1154,14 @@ async function handleLine(line) {
1153
1154
  }
1154
1155
  else if (sub === 'code') {
1155
1156
  const ph = normalizePhone(p[2]);
1156
- const method = p[3] || 'sms';
1157
- if (!ph) { fail('usage: /reg code <phone> [sms|voice|wa_old]'); break; }
1157
+ const method = (p[3] || 'sms').toLowerCase();
1158
+ // email method: /reg code <phone> email <address>
1159
+ const emailAddr = method === 'email' ? (p[4] || '') : '';
1160
+ if (!ph) { fail('usage: /reg code <phone> [sms|voice|wa_old|email <address>]'); break; }
1161
+ if (method === 'email' && !emailAddr) {
1162
+ fail('email method requires an address — usage: /reg code <phone> email <address>');
1163
+ break;
1164
+ }
1158
1165
  if (!fs.existsSync(_sessDir)) fs.mkdirSync(_sessDir, { recursive: true });
1159
1166
  const sessFile = path.join(_sessDir, `${ph}.json`);
1160
1167
  let store = loadStore(sessFile);
@@ -1173,8 +1180,10 @@ async function handleLine(line) {
1173
1180
  out(' new keys saved — proceed with code below');
1174
1181
  }
1175
1182
  }
1176
- out('requesting ' + method + ' code for +' + ph + '...');
1177
- const r = await requestSmsCode(store, method);
1183
+ const methodLabel = method === 'email' ? ('email ' + emailAddr) : method;
1184
+ out('requesting ' + methodLabel + ' code for +' + ph + '...');
1185
+ const codeOpts = method === 'email' ? { email: emailAddr } : {};
1186
+ const r = await requestSmsCode(store, method, codeOpts);
1178
1187
  store.codePending = true;
1179
1188
  saveStore(store, sessFile);
1180
1189
  out(' status ' + (r && r.status));
@@ -1427,7 +1436,8 @@ usage:
1427
1436
 
1428
1437
  options:
1429
1438
  --session <dir> session directory (default: ~/.waSession)
1430
- --method sms | voice | wa_old (default: sms)
1439
+ --method sms | voice | wa_old | email (default: sms)
1440
+ --email <address> email address (required when --method email)
1431
1441
 
1432
1442
  after connecting, type /help for all available commands.
1433
1443
  `.trim();
@@ -1475,9 +1485,14 @@ async function main() {
1475
1485
  }
1476
1486
 
1477
1487
  if (flags['request-code'] !== undefined) {
1478
- const ph = phone || normalizePhone(pos[0] || '');
1479
- const method = flags.method || 'sms';
1488
+ const ph = phone || normalizePhone(pos[0] || '');
1489
+ const method = (flags.method || 'sms').toLowerCase();
1490
+ const emailAddr = flags.email || '';
1480
1491
  if (!ph) { fail('phone number required'); process.exit(1); }
1492
+ if (method === 'email' && !emailAddr) {
1493
+ fail('--method email requires --email <address>');
1494
+ process.exit(1);
1495
+ }
1481
1496
  if (!fs.existsSync(_sessDir)) fs.mkdirSync(_sessDir, { recursive: true });
1482
1497
  const sessFile = path.join(_sessDir, `${ph}.json`);
1483
1498
  let store = loadStore(sessFile);
@@ -1500,9 +1515,11 @@ async function main() {
1500
1515
  }
1501
1516
  // If store.codePending === true, keys were already accepted by WhatsApp in a
1502
1517
  // prior /code request — reuse the exact same store without any /exist call.
1503
- out('requesting ' + method + ' code for +' + ph + '...');
1518
+ const methodLabel = method === 'email' ? ('email ' + emailAddr) : method;
1519
+ out('requesting ' + methodLabel + ' code for +' + ph + '...');
1504
1520
  try {
1505
- const r = await requestSmsCode(store, method);
1521
+ const codeOpts = method === 'email' ? { email: emailAddr } : {};
1522
+ const r = await requestSmsCode(store, method, codeOpts);
1506
1523
  store.codePending = true;
1507
1524
  saveStore(store, sessFile);
1508
1525
  out(' status ' + (r && r.status));
package/lib/Client.js CHANGED
@@ -133,6 +133,8 @@ class WhalibmobClient extends EventEmitter {
133
133
  this._retryPreKeyIdx = 0; // rotating index for assigning unique prekeys to retries
134
134
  this._appStateVersions = {}; // collectionName → version (int)
135
135
  this._sentMsgCache = new Map(); // msgId → {plaintext, toJid, msgId, mediaType, options, recipientPhone}
136
+ this._tcTokenStore = null; // TcTokenStore — loaded in init()
137
+ this._inFlightTcTokenIssuance = new Set(); // dedupe concurrent issuePrivacyTokens calls per JID
136
138
  }
137
139
 
138
140
  get store() { return this._store; }
@@ -169,8 +171,12 @@ class WhalibmobClient extends EventEmitter {
169
171
  throw new Error(`No session for ${phoneNumber}. Run 'wa registration -R <code>' first.`);
170
172
  }
171
173
 
172
- const signalFile = path.join(this._sessionDir, `${phoneNumber}.signal.json`);
173
- const skFile = path.join(this._sessionDir, `${phoneNumber}.sk.json`);
174
+ const signalFile = path.join(this._sessionDir, `${phoneNumber}.signal.json`);
175
+ const skFile = path.join(this._sessionDir, `${phoneNumber}.sk.json`);
176
+ const tcTokenFile = path.join(this._sessionDir, `${phoneNumber}.tctoken.json`);
177
+
178
+ const { TcTokenStore } = require('./messages/TcTokenStore');
179
+ this._tcTokenStore = new TcTokenStore(tcTokenFile);
174
180
 
175
181
  this._signal = SignalProtocol.fromStore(this._store, signalFile, skFile);
176
182
  this._devMgr = new DeviceManager(this);
@@ -663,6 +669,13 @@ class WhalibmobClient extends EventEmitter {
663
669
 
664
670
  this.emit('message', { id, from, participant, ts, decoded, node });
665
671
  this._sendReadReceipt(id, fromRaw, partRaw);
672
+
673
+ // ── pkmsg = peer opened a new Signal session (identity/device change) ──
674
+ // Re-issue our tcToken to them so the fresh session carries a valid token.
675
+ // Mirrors Baileys' reissueTcTokenAfterIdentityChange (messages-recv.js).
676
+ if (encType === 'pkmsg' && this._tcTokenStore) {
677
+ this._reissueTcTokenAfterIdentityChange(sigJid);
678
+ }
666
679
  })
667
680
  .catch(err => {
668
681
  process.stderr.write('[DBG] DM_ERR id=' + id + ' err=' + (err && err.message) + '\n');
@@ -810,6 +823,7 @@ class WhalibmobClient extends EventEmitter {
810
823
  const attrs = node.attrs || {};
811
824
  const id = attrs.id || '';
812
825
  const cls = attrs.class || '';
826
+ const error = attrs.error ? String(attrs.error) : '';
813
827
 
814
828
  if (cls === 'message') {
815
829
  const handler = this._pendingAcks.get(id);
@@ -817,6 +831,27 @@ class WhalibmobClient extends EventEmitter {
817
831
  this._pendingAcks.delete(id);
818
832
  handler(attrs);
819
833
  }
834
+
835
+ // ─── Error 463: reachout timelock / missing tcToken ─────────────────
836
+ // The server returns error 463 when a DM is sent without a tctoken node
837
+ // and the account has hit the "reaching out" rate limit.
838
+ // Recovery: immediately issue a privacy token to the sender and store it
839
+ // so future messages carry the <tctoken> node automatically.
840
+ if (error === '463') {
841
+ const from = attrs.from ? String(attrs.from) : '';
842
+ process.stderr.write('[DBG] ACK_463 from=' + from + ' id=' + id + '\n');
843
+ if (from && this._tcTokenStore && !this._inFlightTcTokenIssuance.has(from)) {
844
+ this._inFlightTcTokenIssuance.add(from);
845
+ const issueTs = Math.floor(Date.now() / 1000);
846
+ const { normalizeJidForTcToken } = require('./messages/TcTokenStore');
847
+ const tcJid = normalizeJidForTcToken(from);
848
+ this._issuePrivacyTokens(from, issueTs)
849
+ .then(result => this._storeTcTokenFromIqResult(result, tcJid, issueTs))
850
+ .catch(() => {})
851
+ .finally(() => this._inFlightTcTokenIssuance.delete(from));
852
+ }
853
+ }
854
+ // ────────────────────────────────────────────────────────────────────
820
855
  }
821
856
  }
822
857
 
@@ -957,6 +992,12 @@ class WhalibmobClient extends EventEmitter {
957
992
  this._processGroupUpdate(node);
958
993
  }
959
994
 
995
+ if (type === 'privacy_token') {
996
+ // Incoming trusted-contact token from a conversation partner — store it
997
+ // so we can attach it on next DM send (prevents error 463).
998
+ this._handlePrivacyTokenNotification(node);
999
+ }
1000
+
960
1001
  // Ack the notification
961
1002
  if (this._socket && this._connected) {
962
1003
  this._socket.sendNode(new BinaryNode('ack', {
@@ -1355,6 +1396,241 @@ class WhalibmobClient extends EventEmitter {
1355
1396
  return crypto.randomBytes(8).toString('hex').toUpperCase();
1356
1397
  }
1357
1398
 
1399
+ // ─── tcToken / privacy token helpers ──────────────────────────────────────
1400
+
1401
+ /**
1402
+ * Send a `<iq type='set' xmlns='privacy'>` to issue a trusted-contact token
1403
+ * for `jid`. Returns the raw result node (or null on timeout).
1404
+ *
1405
+ * Mirrors Baileys' `issuePrivacyTokens` in messages-send.js.
1406
+ *
1407
+ * @param {string} jid - Bare or full JID of the conversation partner
1408
+ * @param {number} timestamp - Unix seconds to use as the token timestamp
1409
+ */
1410
+ _issuePrivacyTokens(jid, timestamp) {
1411
+ const { normalizeJidForTcToken } = require('./messages/TcTokenStore');
1412
+ const normalizedJid = normalizeJidForTcToken(jid);
1413
+ const id = this._genMsgId();
1414
+ const node = new BinaryNode('iq', {
1415
+ id,
1416
+ to: 's.whatsapp.net',
1417
+ type: 'set',
1418
+ xmlns: 'privacy'
1419
+ }, [
1420
+ new BinaryNode('tokens', {}, [
1421
+ new BinaryNode('token', {
1422
+ jid: normalizedJid,
1423
+ t: String(timestamp),
1424
+ type: 'trusted_contact'
1425
+ })
1426
+ ])
1427
+ ]);
1428
+ process.stderr.write('[DBG] ISSUE_PRIVACY_TOKENS jid=' + normalizedJid + ' t=' + timestamp + ' iq_id=' + id + '\n');
1429
+ return this._sendIq(node);
1430
+ }
1431
+
1432
+ /**
1433
+ * Parse the IQ result from `_issuePrivacyTokens` and persist the token.
1434
+ *
1435
+ * The server echoes back:
1436
+ * <iq type='result' ...>
1437
+ * <tokens>
1438
+ * <token type='trusted_contact' jid='X' t='Y'>...bytes...</token>
1439
+ * </tokens>
1440
+ * </iq>
1441
+ *
1442
+ * @param {object|null} result - IQ result node (null on timeout)
1443
+ * @param {string} fallbackJid - Normalized JID to use if not found in result
1444
+ * @param {number} issueTs - Timestamp supplied to the IQ
1445
+ */
1446
+ _storeTcTokenFromIqResult(result, fallbackJid, issueTs, opts) {
1447
+ const saveSenderTs = !opts || opts.saveSenderTs !== false; // default true
1448
+ if (!this._tcTokenStore) return;
1449
+
1450
+ // ── Debug: dump result structure so we can trace server response ─────────
1451
+ if (!result) {
1452
+ process.stderr.write('[DBG] STORE_TCTOKEN result=NULL (IQ timeout or not matched) fallback=' + fallbackJid + '\n');
1453
+ } else {
1454
+ const contentLen = Array.isArray(result.content) ? result.content.length
1455
+ : Buffer.isBuffer(result.content) ? result.content.length
1456
+ : 0;
1457
+ process.stderr.write('[DBG] STORE_TCTOKEN result type=' + (result.attrs && result.attrs.type) +
1458
+ ' id=' + (result.attrs && result.attrs.id) +
1459
+ ' contentLen=' + contentLen + '\n');
1460
+ if (Array.isArray(result.content)) {
1461
+ result.content.forEach((c, i) => {
1462
+ if (c && c.description) {
1463
+ const childContent = Array.isArray(c.content) ? c.content.length + ' children'
1464
+ : Buffer.isBuffer(c.content) ? c.content.length + 'B'
1465
+ : String(c.content);
1466
+ process.stderr.write('[DBG] STORE_TCTOKEN child[' + i + '] tag=' + c.description +
1467
+ ' attrs=' + JSON.stringify(c.attrs || {}) +
1468
+ ' content=' + childContent + '\n');
1469
+ }
1470
+ });
1471
+ }
1472
+ }
1473
+ // ─────────────────────────────────────────────────────────────────────────
1474
+
1475
+ let tokenStoredCount = 0;
1476
+ if (result) {
1477
+ try {
1478
+ // Find <tokens> child — may be direct child or nested
1479
+ const content = Array.isArray(result.content) ? result.content : [];
1480
+ const tokensNode = content.find(n => n && n.description === 'tokens');
1481
+ const entries = tokensNode && Array.isArray(tokensNode.content) ? tokensNode.content : [];
1482
+ for (const tokenNode of entries) {
1483
+ if (!tokenNode || tokenNode.description !== 'token') continue;
1484
+ const a = tokenNode.attrs || {};
1485
+ if (a.type !== 'trusted_contact') continue;
1486
+ // bytes: server returns raw binary in node content
1487
+ const bytes = Buffer.isBuffer(tokenNode.content) ? tokenNode.content
1488
+ : ArrayBuffer.isView(tokenNode.content)
1489
+ ? Buffer.from(tokenNode.content)
1490
+ : (typeof tokenNode.content === 'string' && tokenNode.content.length > 0)
1491
+ ? Buffer.from(tokenNode.content, 'base64')
1492
+ : null;
1493
+ // Baileys tc-token-utils.js line 137-138:
1494
+ // "In notifications tokenNode.attrs.jid is your own device JID, not the sender's"
1495
+ // → always prefer fallbackJid; only fall back to a.jid when fallbackJid is absent
1496
+ const jid = fallbackJid || String(a.jid);
1497
+ const t = a.t ? parseInt(String(a.t), 10) : issueTs;
1498
+ if (bytes && bytes.length) {
1499
+ this._tcTokenStore.setToken(jid, bytes, t);
1500
+ tokenStoredCount++;
1501
+ process.stderr.write('[DBG] TCTOKEN_STORED jid=' + jid + ' t=' + t + ' len=' + bytes.length + '\n');
1502
+ } else {
1503
+ process.stderr.write('[DBG] STORE_TCTOKEN token node has no bytes jid=' + jid + '\n');
1504
+ }
1505
+ }
1506
+ } catch (e) {
1507
+ process.stderr.write('[DBG] STORE_TCTOKEN parse error: ' + e.message + '\n');
1508
+ }
1509
+ }
1510
+
1511
+ // ── CRITICAL: Baileys ALWAYS persists senderTimestamp after issuing, ─────
1512
+ // ── regardless of whether server returned token bytes. ─────
1513
+ // ── Without this, shouldSendNewTcToken() returns true every send and ─────
1514
+ // ── we flood the server with privacy IQs. ─────
1515
+ // ── For incoming privacy_token notifications saveSenderTs=false so ─────
1516
+ // ── we don't overwrite our own send-dedupe timestamp with the peer's ─────
1517
+ if (saveSenderTs && issueTs != null) {
1518
+ try {
1519
+ this._tcTokenStore.setSenderTimestamp(fallbackJid, issueTs);
1520
+ process.stderr.write('[DBG] SENDER_TS_SAVED jid=' + fallbackJid + ' ts=' + issueTs +
1521
+ ' tokenBytes=' + tokenStoredCount + '\n');
1522
+ } catch (_) {}
1523
+ }
1524
+ }
1525
+
1526
+ /**
1527
+ * Handle a server-pushed `<notification type='privacy_token'>`.
1528
+ *
1529
+ * Wire format (mirrors Baileys' handlePrivacyTokenNotification):
1530
+ * <notification type='privacy_token' from='...' [sender_lid='...@lid']>
1531
+ * <tokens>
1532
+ * <token type='trusted_contact' jid='...' t='...'>…bytes…</token>
1533
+ * </tokens>
1534
+ * </notification>
1535
+ *
1536
+ * Key rules (from Baileys messages-recv.js lines 996-1015):
1537
+ * 1. Must find <tokens> wrapper inside notification; abort if absent.
1538
+ * 2. storage key = sender_lid (if present and @lid) else PN→LID lookup else from-JID.
1539
+ * 3. senderTimestamp is NOT updated here — only the peer-token bytes + timestamp.
1540
+ *
1541
+ * @param {object} node - The full `<notification>` BinaryNode
1542
+ */
1543
+ _handlePrivacyTokenNotification(node) {
1544
+ if (!this._tcTokenStore) return;
1545
+ const { normalizeJidForTcToken } = require('./messages/TcTokenStore');
1546
+
1547
+ const attrs = node.attrs || {};
1548
+ process.stderr.write('[DBG] PRIVACY_TOKEN_NOTIF_ENTER from=' + String(attrs.from || '') +
1549
+ ' sender_lid=' + String(attrs.sender_lid || '') + '\n');
1550
+
1551
+ // ── 1. Require <tokens> wrapper (matches Baileys' getBinaryNodeChild check) ──
1552
+ const content = Array.isArray(node.content) ? node.content : [];
1553
+ const tokensNode = content.find(n => n && n.description === 'tokens');
1554
+ if (!tokensNode) {
1555
+ process.stderr.write('[DBG] PRIVACY_TOKEN_NOTIF no <tokens> wrapper — abort\n');
1556
+ return;
1557
+ }
1558
+
1559
+ // ── 2. Resolve storage JID ─────────────────────────────────────────────────
1560
+ // Priority (mirrors Baileys lines 1001-1006):
1561
+ // 1. sender_lid attr if it is a @lid JID
1562
+ // 2. from user is a known LID user (LID comes as @s.whatsapp.net in notification)
1563
+ // 3. from is a PN → look up LID in _pnToLid
1564
+ // 4. bare from JID as fallback
1565
+ const rawFrom = String(attrs.from || '');
1566
+ const rawSenderLid = attrs.sender_lid ? String(attrs.sender_lid) : null;
1567
+ const fromUser = rawFrom.split('@')[0].split(':')[0];
1568
+
1569
+ let storageJid;
1570
+ if (rawSenderLid && rawSenderLid.endsWith('@lid')) {
1571
+ // Explicit @lid sender_lid attr
1572
+ storageJid = normalizeJidForTcToken(rawSenderLid);
1573
+ } else if (this._lidToPn && this._lidToPn.has(fromUser)) {
1574
+ // from = LID_USER@s.whatsapp.net — WA sends LID notifications this way.
1575
+ // The user part is already the LID user; convert domain to @lid.
1576
+ storageJid = fromUser + '@lid';
1577
+ } else if (this._pnToLid && this._pnToLid.has(fromUser)) {
1578
+ // from is a PN → convert to LID for storage (Baileys always stores under LID)
1579
+ storageJid = this._pnToLid.get(fromUser) + '@lid';
1580
+ } else {
1581
+ storageJid = normalizeJidForTcToken(rawFrom);
1582
+ }
1583
+
1584
+ process.stderr.write('[DBG] PRIVACY_TOKEN_NOTIF storageJid=' + storageJid + '\n');
1585
+
1586
+ // ── 3. Parse <token> children and store bytes (no senderTimestamp update) ──
1587
+ // Reuse _storeTcTokenFromIqResult with the full notification node so that its
1588
+ // <tokens><token> traversal is identical to the IQ-result code path.
1589
+ // Pass { saveSenderTs: false } so we don't overwrite our own senderTimestamp.
1590
+ this._storeTcTokenFromIqResult(node, storageJid, null, { saveSenderTs: false });
1591
+ }
1592
+
1593
+ /**
1594
+ * Fire-and-forget tcToken re-issuance after a peer's device identity changed.
1595
+ * Called when we successfully decrypt a pkmsg — the peer opened a fresh Signal
1596
+ * session (reset or new device), so we re-issue a privacy token so they can
1597
+ * reach us. Only fires if we've previously issued a token (senderTimestamp
1598
+ * present and not expired), mirroring Baileys' reissueTcTokenAfterIdentityChange.
1599
+ *
1600
+ * @param {string} from - Signal-session JID (PN or LID JID of the sender)
1601
+ */
1602
+ _reissueTcTokenAfterIdentityChange(from) {
1603
+ if (!this._tcTokenStore || !from) return;
1604
+ const { normalizeJidForTcToken, isTcTokenExpired } = require('./messages/TcTokenStore');
1605
+
1606
+ void (async () => {
1607
+ try {
1608
+ // Resolve storage JID: PN→LID if available, otherwise use from as-is
1609
+ const fromNorm = normalizeJidForTcToken(from);
1610
+ const fromUser = fromNorm.split('@')[0];
1611
+ const lidUser = this._pnToLid && this._pnToLid.get(fromUser);
1612
+ const tcJid = lidUser ? (lidUser + '@lid') : fromNorm;
1613
+
1614
+ const entry = this._tcTokenStore.get(tcJid);
1615
+ const senderTs = entry && entry.senderTimestamp;
1616
+ if (!senderTs || isTcTokenExpired(senderTs)) {
1617
+ // No previous issuance — nothing to re-issue
1618
+ return;
1619
+ }
1620
+
1621
+ process.stderr.write('[DBG] REISSUE_TC_TOKEN_AFTER_IDENTITY_CHANGE jid=' + tcJid +
1622
+ ' prevSenderTs=' + senderTs + '\n');
1623
+
1624
+ const issueTs = Math.floor(Date.now() / 1000);
1625
+ const result = await this._issuePrivacyTokens(tcJid, issueTs);
1626
+ this._storeTcTokenFromIqResult(result, tcJid, issueTs);
1627
+ } catch (e) {
1628
+ process.stderr.write('[DBG] REISSUE_TC_TOKEN_ERR from=' + from +
1629
+ ' err=' + (e && e.message) + '\n');
1630
+ }
1631
+ })();
1632
+ }
1633
+
1358
1634
  // ─── Public API ───────────────────────────────────────────────────────────
1359
1635
 
1360
1636
  setPresence(available) {
@@ -19,6 +19,7 @@ const {
19
19
  PROTOCOL_MSG_REVOKE, PROTOCOL_MSG_EPHEMERAL
20
20
  } = require('../proto/MessageProto');
21
21
  const { uploadMedia } = require('../MediaService');
22
+ const { tcTokenExpired, shouldSendNewTcToken, normalizeJidForTcToken } = require('./TcTokenStore');
22
23
 
23
24
  // ─── Helpers ──────────────────────────────────────────────────────────────────
24
25
 
@@ -640,6 +641,27 @@ class MessageSender {
640
641
  msgContent = [...devIdNodes, participantsNode];
641
642
  }
642
643
 
644
+ // ─── tcToken (error 463 / reachout timelock defense) ─────────────────────
645
+ // WhatsApp counts every DM without a <tctoken> node as a "reaching out"
646
+ // event. Once enough accumulate the server returns error 463 (timelock).
647
+ // We attach the stored trusted-contact token when one exists and is valid.
648
+ const tcStore = this._client._tcTokenStore;
649
+ const tcJid = normalizeJidForTcToken(routingToJid);
650
+ if (tcStore) {
651
+ const tcEntry = tcStore.get(tcJid);
652
+ if (tcEntry && tcEntry.token && tcEntry.token.length) {
653
+ if (!tcTokenExpired(tcEntry.timestamp)) {
654
+ msgContent.push(new BinaryNode('tctoken', {}, tcEntry.token));
655
+ process.stderr.write('[DBG] TCTOKEN_ATTACH jid=' + tcJid + '\n');
656
+ } else {
657
+ // Expired token — clear it, keep senderTimestamp for dedupe
658
+ tcStore.clearToken(tcJid);
659
+ process.stderr.write('[DBG] TCTOKEN_EXPIRED jid=' + tcJid + ' — cleared\n');
660
+ }
661
+ }
662
+ }
663
+ // ─────────────────────────────────────────────────────────────────────────
664
+
643
665
  const msgNode = new BinaryNode('message', stanzaAttrs, msgContent);
644
666
 
645
667
  // Debug: log outgoing stanza details
@@ -667,7 +689,25 @@ class MessageSender {
667
689
  }
668
690
  }
669
691
 
670
- return this._dispatchAndAck(msgNode, msgId);
692
+ const dispatchResult = await this._dispatchAndAck(msgNode, msgId);
693
+
694
+ // ─── Fire-and-forget: issue privacy token to contact ─────────────────────
695
+ // After each DM send we ask WhatsApp to issue us a trusted-contact token
696
+ // for this JID (one issuance per 7-day bucket is enough). The token is
697
+ // stored and attached on future sends so the server does not count them
698
+ // as anonymous "reaching out" events (which would trigger error 463).
699
+ if (tcStore && tcStore.shouldIssue(tcJid) &&
700
+ !this._client._inFlightTcTokenIssuance.has(tcJid)) {
701
+ this._client._inFlightTcTokenIssuance.add(tcJid);
702
+ const issueTs = Math.floor(Date.now() / 1000);
703
+ this._client._issuePrivacyTokens(routingToJid, issueTs)
704
+ .then(result => this._client._storeTcTokenFromIqResult(result, tcJid, issueTs))
705
+ .catch(() => {})
706
+ .finally(() => this._client._inFlightTcTokenIssuance.delete(tcJid));
707
+ }
708
+ // ─────────────────────────────────────────────────────────────────────────
709
+
710
+ return dispatchResult;
671
711
  }
672
712
 
673
713
  // ─── Group SenderKey fanout ────────────────────────────────────────────────
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // 7-day buckets, 4 buckets = ~28-day rolling window (matches Baileys tc-token-utils)
7
+ const TC_BUCKET_DURATION = 604800;
8
+ const TC_NUM_BUCKETS = 4;
9
+
10
+ function tcTokenExpired(timestamp) {
11
+ if (timestamp == null) return true;
12
+ const ts = typeof timestamp === 'string' ? parseInt(timestamp, 10) : timestamp;
13
+ if (!ts || isNaN(ts)) return true;
14
+ const now = Math.floor(Date.now() / 1000);
15
+ const curBucket = Math.floor(now / TC_BUCKET_DURATION);
16
+ const cutoffBucket = curBucket - (TC_NUM_BUCKETS - 1);
17
+ return ts < cutoffBucket * TC_BUCKET_DURATION;
18
+ }
19
+
20
+ function shouldSendNewTcToken(senderTimestamp) {
21
+ if (senderTimestamp == null) return true;
22
+ const now = Math.floor(Date.now() / 1000);
23
+ const curBucket = Math.floor(now / TC_BUCKET_DURATION);
24
+ const senderBucket = Math.floor(senderTimestamp / TC_BUCKET_DURATION);
25
+ return curBucket > senderBucket;
26
+ }
27
+
28
+ // Strip device part and normalize to bare JID
29
+ function normalizeJidForTcToken(jid) {
30
+ if (!jid) return String(jid || '');
31
+ const str = String(jid);
32
+ const atIdx = str.indexOf('@');
33
+ if (atIdx < 0) return str;
34
+ const server = str.slice(atIdx + 1);
35
+ let user = str.slice(0, atIdx);
36
+ const colon = user.indexOf(':');
37
+ if (colon >= 0) user = user.slice(0, colon);
38
+ return user + '@' + server;
39
+ }
40
+
41
+ class TcTokenStore {
42
+ constructor(filePath) {
43
+ this._path = filePath;
44
+ // jid → { token: Buffer|null, timestamp: number|null, senderTimestamp: number|null }
45
+ this._map = new Map();
46
+ this._load();
47
+ }
48
+
49
+ _load() {
50
+ try {
51
+ if (!fs.existsSync(this._path)) return;
52
+ const raw = JSON.parse(fs.readFileSync(this._path, 'utf8'));
53
+ for (const [jid, entry] of Object.entries(raw)) {
54
+ this._map.set(jid, {
55
+ token: entry.token ? Buffer.from(entry.token, 'base64') : null,
56
+ timestamp: entry.timestamp != null ? entry.timestamp : null,
57
+ senderTimestamp: entry.senderTimestamp != null ? entry.senderTimestamp : null
58
+ });
59
+ }
60
+ } catch (_) {}
61
+ }
62
+
63
+ _save() {
64
+ try {
65
+ const dir = path.dirname(this._path);
66
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
67
+ const obj = {};
68
+ for (const [jid, entry] of this._map) {
69
+ obj[jid] = {
70
+ token: (entry.token && entry.token.length) ? entry.token.toString('base64') : null,
71
+ timestamp: entry.timestamp != null ? entry.timestamp : null,
72
+ senderTimestamp: entry.senderTimestamp != null ? entry.senderTimestamp : null
73
+ };
74
+ }
75
+ fs.writeFileSync(this._path, JSON.stringify(obj, null, 2), 'utf8');
76
+ } catch (_) {}
77
+ }
78
+
79
+ get(jid) {
80
+ return this._map.get(normalizeJidForTcToken(jid)) || null;
81
+ }
82
+
83
+ setToken(jid, tokenBuf, timestamp) {
84
+ const key = normalizeJidForTcToken(jid);
85
+ const existing = this._map.get(key) || {};
86
+ this._map.set(key, { ...existing, token: tokenBuf, timestamp });
87
+ this._save();
88
+ }
89
+
90
+ clearToken(jid) {
91
+ const key = normalizeJidForTcToken(jid);
92
+ const existing = this._map.get(key) || {};
93
+ this._map.set(key, { ...existing, token: null });
94
+ this._save();
95
+ }
96
+
97
+ setSenderTimestamp(jid, ts) {
98
+ const key = normalizeJidForTcToken(jid);
99
+ const existing = this._map.get(key) || {};
100
+ this._map.set(key, { ...existing, senderTimestamp: ts });
101
+ this._save();
102
+ }
103
+
104
+ isExpired(jid) {
105
+ const entry = this.get(jid);
106
+ if (!entry || !entry.token || !entry.token.length) return true;
107
+ return tcTokenExpired(entry.timestamp);
108
+ }
109
+
110
+ shouldIssue(jid) {
111
+ const entry = this.get(jid);
112
+ return shouldSendNewTcToken(entry && entry.senderTimestamp);
113
+ }
114
+ }
115
+
116
+ module.exports = { TcTokenStore, tcTokenExpired, shouldSendNewTcToken, normalizeJidForTcToken };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.5.25",
3
+ "version": "5.5.27",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API no web",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",