whalibmob 5.5.25 → 5.5.26
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/lib/Client.js +278 -2
- package/lib/messages/MessageSender.js +41 -1
- package/lib/messages/TcTokenStore.js +116 -0
- package/package.json +1 -1
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
|
|
173
|
-
const skFile
|
|
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
|
-
|
|
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 };
|