whalibmob 5.19.0 → 5.19.1
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/README.md +4 -2
- package/lib/Client.js +106 -0
- package/lib/messages/MessageSender.js +32 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2007,10 +2007,11 @@ Every option is optional; `sessionDir` is the only one most senders ever set.
|
|
|
2007
2007
|
| `sessionDir` | `~/.waSession` | The authentication folder. Each number gets its own subfolder inside it — see [Saving & Restoring Sessions](#saving--restoring-sessions). |
|
|
2008
2008
|
| `autoFixNumber` | `true` | Re-file the session automatically when the server reports the account under a different number. Set `false` to be told instead of fixed — see [The Number WhatsApp Files Your Account Under](#the-number-whatsapp-files-your-account-under). |
|
|
2009
2009
|
| `autoRead` | `true` | Send read receipts for incoming messages. `false` leaves them unread. |
|
|
2010
|
-
| `refreshVersion` | `true` |
|
|
2010
|
+
| `refreshVersion` | `true` | Check the platform's store for the current build on every connect and reconnect, and write it into the session file. Both iOS and Android. Set `false` to keep announcing whatever the session registered with — see [Keeping the Announced Version Current](#keeping-the-announced-version-current). |
|
|
2011
2011
|
| `pino` | off | Debug logging. `true` turns it on at `debug` level; an object is passed to `pino` as-is. |
|
|
2012
2012
|
| `sentCacheSize` | `2000` | How many sent messages keep their plaintext so a retry receipt naming them can be answered. |
|
|
2013
2013
|
| `maxRetryResends` | `5` | How many times one message may be re-sent in answer to retry receipts before the client gives up. |
|
|
2014
|
+
| `tcTokenPresendTimeoutMs` | `5000` | How long the first message to a new contact waits for its trusted-contact token before going out without one — see [tcToken — Error 463 Defense](#tctoken--error-463-defense). `0` sends immediately and lets the token arrive for the next message. |
|
|
2014
2015
|
|
|
2015
2016
|
```js
|
|
2016
2017
|
const client = new WhalibmobClient({
|
|
@@ -3278,8 +3279,9 @@ whalibmob implements the full lifecycle to prevent this:
|
|
|
3278
3279
|
| Step | What the library does automatically |
|
|
3279
3280
|
|---|---|
|
|
3280
3281
|
| **History seed** | On every history sync chunk, `tcToken` bytes are extracted from each conversation in the protobuf and loaded into `TcTokenStore` in memory. The first send after reconnect already has a valid token ready — no 463 risk on cold start. |
|
|
3282
|
+
| **First contact** | The very first DM to a contact has no token on file yet. Before the stanza is built, the library issues one and waits for it, so that message carries a `<tctoken>` like every later one instead of counting as an anonymous reach-out. The wait is bounded by `tcTokenPresendTimeoutMs` (default 5 s) — past it the message goes out regardless and the token lands in time for the next one. |
|
|
3281
3283
|
| **Attach on send** | Before dispatching any DM, `MessageSender` looks up the token for the recipient JID, checks it has not expired (28-day rolling window), and pushes a `<tctoken>` child node into the message stanza. |
|
|
3282
|
-
| **
|
|
3284
|
+
| **Renewal** | After a successful DM send, the library fires a `<iq type='set' xmlns='privacy'>` requesting a fresh token for that JID from the server — once per 7-day bucket, deduplicated in-flight. This one is fire-and-forget: the token being attached right now is still valid, so nothing waits for it. |
|
|
3283
3285
|
| **Incoming notification** | When a contact starts a new conversation, WhatsApp pushes a `<notification type='privacy_token'>`. The library catches it in `_handlePrivacyTokenNotification` and stores the token immediately. |
|
|
3284
3286
|
| **Identity change re-issue** | When decrypting a `pkmsg` (new Signal session from peer), the library calls `_reissueTcTokenAfterIdentityChange` to re-issue the token for the new session. |
|
|
3285
3287
|
| **Error 463 recovery** | If a send fails with error 463, the library issues a fresh token, waits for the server response, and automatically retries the same message with the new token attached. |
|
package/lib/Client.js
CHANGED
|
@@ -79,6 +79,12 @@ const MEX_QUERY_REACHOUT_TIMELOCK = '23983697327930364';
|
|
|
79
79
|
// server takes a longer one, answers <iq type="result"/> and keeps the old text.
|
|
80
80
|
const ABOUT_MAX_LENGTH = 139;
|
|
81
81
|
|
|
82
|
+
// How long the first message to a contact waits for its trusted-contact token.
|
|
83
|
+
// One round-trip to the server, no more: past this the message goes out without
|
|
84
|
+
// a token rather than sitting in the queue, and the issuance finishes in the
|
|
85
|
+
// background for the next send.
|
|
86
|
+
const TC_TOKEN_PRESEND_TIMEOUT_MS = 5000;
|
|
87
|
+
|
|
82
88
|
// The names privacy settings actually go by on the wire, and the friendly
|
|
83
89
|
// aliases this library has always accepted for them. The wire names are the
|
|
84
90
|
// right-hand column.
|
|
@@ -464,6 +470,15 @@ class WhalibmobClient extends EventEmitter {
|
|
|
464
470
|
this._tcTokenStore = null; // TcTokenStore — loaded in init()
|
|
465
471
|
this._inFlightTcTokenIssuance = new Set(); // dedupe concurrent proactive issuePrivacyTokens per JID
|
|
466
472
|
this._inFlight463Recoveries = new Set(); // dedupe concurrent 463-triggered token issuances per JID (separate from proactive)
|
|
473
|
+
// jid → in-flight pre-send issuance promise. A Set would only say that one
|
|
474
|
+
// is running; concurrent first sends to the same contact have to be able to
|
|
475
|
+
// wait for the same answer, so the promise itself is what gets shared.
|
|
476
|
+
this._tcTokenIssuanceWaiters = new Map();
|
|
477
|
+
// How long a first send will wait for its token before going out without
|
|
478
|
+
// one. _sendIq gives up after 15s, which is far too long to hold a message.
|
|
479
|
+
this._tcTokenPresendTimeoutMs =
|
|
480
|
+
Number(opts.tcTokenPresendTimeoutMs) >= 0
|
|
481
|
+
? Number(opts.tcTokenPresendTimeoutMs) : TC_TOKEN_PRESEND_TIMEOUT_MS;
|
|
467
482
|
// Last known account restriction, or null until one is fetched or pushed.
|
|
468
483
|
this._reachoutTimelock = null;
|
|
469
484
|
this._reachoutTimelockInFlight = null;
|
|
@@ -4765,6 +4780,97 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4765
4780
|
return this._sendIq(node);
|
|
4766
4781
|
}
|
|
4767
4782
|
|
|
4783
|
+
/**
|
|
4784
|
+
* Put a trusted-contact token on file *before* the first message to a contact
|
|
4785
|
+
* is built, so that message carries a `<tctoken>` like every later one.
|
|
4786
|
+
*
|
|
4787
|
+
* The token never came from the contact. `_issuePrivacyTokens` is an IQ to
|
|
4788
|
+
* s.whatsapp.net and the server answers with the bytes, so nothing here waits
|
|
4789
|
+
* on the other side replying, having us in their address book, or a
|
|
4790
|
+
* conversation existing at all.
|
|
4791
|
+
*
|
|
4792
|
+
* What was missing was only the ordering. Issuance ran after
|
|
4793
|
+
* `_dispatchAndAck` and fire-and-forget, so the first message to any new
|
|
4794
|
+
* contact went out with no token and the server counted it as an anonymous
|
|
4795
|
+
* reach-out. At bulk that is one such event per number, which is exactly the
|
|
4796
|
+
* fuel for error 463. The official app issues on opening the chat — before
|
|
4797
|
+
* anything is typed — so `issue → send` is the faithful order, not `send →
|
|
4798
|
+
* issue`.
|
|
4799
|
+
*
|
|
4800
|
+
* Three cases return without waiting, and each matters:
|
|
4801
|
+
*
|
|
4802
|
+
* - a live token is already on file. Renewal is due eventually, but the
|
|
4803
|
+
* post-send path does that without holding a message up.
|
|
4804
|
+
* - `shouldIssue` is false. We already asked inside this 7-day bucket and
|
|
4805
|
+
* the server gave us nothing; asking again now would put a round-trip in
|
|
4806
|
+
* front of every send for nothing.
|
|
4807
|
+
* - the JID is the PSA account or a bot, which cannot timelock us.
|
|
4808
|
+
*
|
|
4809
|
+
* @param {string} tcJid storage key, already through _resolveTcTokenJid
|
|
4810
|
+
* @param {string} routingToJid the JID the message is addressed to
|
|
4811
|
+
* @returns {Promise<boolean>} whether a usable token is on file now
|
|
4812
|
+
*/
|
|
4813
|
+
async ensureTcTokenBeforeSend(tcJid, routingToJid) {
|
|
4814
|
+
const store = this._tcTokenStore;
|
|
4815
|
+
if (!store) return false;
|
|
4816
|
+
|
|
4817
|
+
const { tcTokenExpired, isRegularTcTokenUser } = require('./messages/TcTokenStore');
|
|
4818
|
+
if (!isRegularTcTokenUser(tcJid)) return false;
|
|
4819
|
+
|
|
4820
|
+
// Never ourselves. A note-to-self is not a reach-out, and holding it up for
|
|
4821
|
+
// a token nobody will check is the one stall this change could introduce.
|
|
4822
|
+
const bare = String(tcJid).split('@')[0].split(':')[0];
|
|
4823
|
+
const ownPhone = this._store && this._store.phoneNumber
|
|
4824
|
+
? String(this._store.phoneNumber) : null;
|
|
4825
|
+
const ownLidUsr = this._myLid ? String(this._myLid).split('@')[0].split(':')[0] : null;
|
|
4826
|
+
if ((ownPhone && bare === ownPhone) || (ownLidUsr && bare === ownLidUsr)) return false;
|
|
4827
|
+
|
|
4828
|
+
const live = entry =>
|
|
4829
|
+
!!(entry && entry.token && entry.token.length && !tcTokenExpired(entry.timestamp));
|
|
4830
|
+
|
|
4831
|
+
if (live(store.get(tcJid))) return true;
|
|
4832
|
+
if (!store.shouldIssue(tcJid)) return false;
|
|
4833
|
+
|
|
4834
|
+
// A second send to the same contact must wait on the first one's answer
|
|
4835
|
+
// rather than issue a competing IQ for the same JID.
|
|
4836
|
+
let pending = this._tcTokenIssuanceWaiters.get(tcJid);
|
|
4837
|
+
if (!pending) {
|
|
4838
|
+
const issueTs = Math.floor(Date.now() / 1000);
|
|
4839
|
+
const issueJid = this._resolveTcTokenIssuanceJid(routingToJid);
|
|
4840
|
+
_whaDbg('[DBG] TCTOKEN_PRESEND issuing for ' + tcJid + ' (as ' + issueJid + ')');
|
|
4841
|
+
pending = this._issuePrivacyTokens(issueJid, issueTs)
|
|
4842
|
+
.then(result => { this._storeTcTokenFromIqResult(result, tcJid, issueTs); })
|
|
4843
|
+
.catch(err => {
|
|
4844
|
+
_whaDbg('[DBG] TCTOKEN_PRESEND_ERR jid=' + tcJid + ' err=' + (err && err.message));
|
|
4845
|
+
})
|
|
4846
|
+
.finally(() => { this._tcTokenIssuanceWaiters.delete(tcJid); });
|
|
4847
|
+
this._tcTokenIssuanceWaiters.set(tcJid, pending);
|
|
4848
|
+
}
|
|
4849
|
+
|
|
4850
|
+
// Bounded wait. A send is never failed or delayed indefinitely over a token:
|
|
4851
|
+
// past the deadline it goes out without one, the issuance carries on in the
|
|
4852
|
+
// background, and the next message to this contact picks the token up.
|
|
4853
|
+
const limit = this._tcTokenPresendTimeoutMs;
|
|
4854
|
+
if (limit <= 0) return false;
|
|
4855
|
+
|
|
4856
|
+
let timer = null;
|
|
4857
|
+
const deadline = new Promise(resolve => {
|
|
4858
|
+
timer = setTimeout(() => resolve(false), limit);
|
|
4859
|
+
});
|
|
4860
|
+
const finished = await Promise.race([pending.then(() => true), deadline]);
|
|
4861
|
+
if (timer) clearTimeout(timer);
|
|
4862
|
+
|
|
4863
|
+
if (!finished) {
|
|
4864
|
+
_whaDbg('[DBG] TCTOKEN_PRESEND timed out after ' + limit + 'ms jid=' + tcJid +
|
|
4865
|
+
' — sending without a token');
|
|
4866
|
+
return false;
|
|
4867
|
+
}
|
|
4868
|
+
|
|
4869
|
+
const ok = live(store.get(tcJid));
|
|
4870
|
+
_whaDbg('[DBG] TCTOKEN_PRESEND ' + (ok ? 'ready' : 'no bytes returned') + ' jid=' + tcJid);
|
|
4871
|
+
return ok;
|
|
4872
|
+
}
|
|
4873
|
+
|
|
4768
4874
|
/**
|
|
4769
4875
|
* Parse the IQ result from `_issuePrivacyTokens` and persist the token.
|
|
4770
4876
|
*
|
|
@@ -1097,6 +1097,28 @@ class MessageSender {
|
|
|
1097
1097
|
// filed when it arrived. Looking it up by the phone JID — which is what
|
|
1098
1098
|
// this did — never found one, so nothing was ever attached.
|
|
1099
1099
|
const tcJid = this._client._resolveTcTokenJid(routingToJid);
|
|
1100
|
+
|
|
1101
|
+
// A protocol message — a revoke, an ephemeral-timer change — is bookkeeping
|
|
1102
|
+
// rather than a reach-out, so it never triggers an issuance. An existing
|
|
1103
|
+
// token still rides along on it, which is what the apps do.
|
|
1104
|
+
const isProtocolMsg = !!(options && options._protocol);
|
|
1105
|
+
|
|
1106
|
+
// First message to this contact: fetch the token now, before the stanza is
|
|
1107
|
+
// built, so it goes out on this message instead of on the next one.
|
|
1108
|
+
//
|
|
1109
|
+
// The device enumeration above has already run, so the LID is known by the
|
|
1110
|
+
// time we get here and the issuance and the storage agree on the contact.
|
|
1111
|
+
// Doing this any earlier would issue against the phone JID while the token
|
|
1112
|
+
// got filed under the LID, which is the split that used to leave live
|
|
1113
|
+
// tokens unused.
|
|
1114
|
+
//
|
|
1115
|
+
// Returns without waiting when a live token is already on file, so this
|
|
1116
|
+
// costs one round-trip per contact rather than one per message. It never
|
|
1117
|
+
// throws and never fails the send.
|
|
1118
|
+
if (tcStore && !isProtocolMsg) {
|
|
1119
|
+
await this._client.ensureTcTokenBeforeSend(tcJid, routingToJid);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1100
1122
|
if (tcStore) {
|
|
1101
1123
|
const tcEntry = tcStore.get(tcJid);
|
|
1102
1124
|
if (tcEntry && tcEntry.token && tcEntry.token.length) {
|
|
@@ -1153,11 +1175,16 @@ class MessageSender {
|
|
|
1153
1175
|
}
|
|
1154
1176
|
}
|
|
1155
1177
|
|
|
1156
|
-
// ─── Fire-and-forget:
|
|
1157
|
-
//
|
|
1158
|
-
//
|
|
1159
|
-
//
|
|
1160
|
-
//
|
|
1178
|
+
// ─── Fire-and-forget: renew the privacy token ────────────────────────────
|
|
1179
|
+
// The first message to a contact now gets its token before the stanza is
|
|
1180
|
+
// built, up above, so what is left here is renewal: a contact whose token
|
|
1181
|
+
// is still live but whose 7-day bucket has rolled over. That case must not
|
|
1182
|
+
// hold a message up — the token being attached right now is perfectly
|
|
1183
|
+
// valid — so it stays fire-and-forget.
|
|
1184
|
+
//
|
|
1185
|
+
// The gate below is what keeps the two from firing twice for one contact.
|
|
1186
|
+
// A successful pre-send issuance saves senderTimestamp, which puts
|
|
1187
|
+
// shouldIssue in the current bucket and makes this a no-op.
|
|
1161
1188
|
//
|
|
1162
1189
|
// Two targets never get one. WhatsApp's own announcement account
|
|
1163
1190
|
// (0@s.whatsapp.net) and the bot numbers, including Meta AI, are not
|
|
@@ -1166,7 +1193,6 @@ class MessageSender {
|
|
|
1166
1193
|
// too — they are bookkeeping, not a reach-out. Attaching an existing token
|
|
1167
1194
|
// to them is still correct
|
|
1168
1195
|
// and still happens above — this only governs asking for a new one.
|
|
1169
|
-
const isProtocolMsg = !!(options && options._protocol);
|
|
1170
1196
|
if (tcStore && isRegularTcTokenUser(tcJid) && !isProtocolMsg &&
|
|
1171
1197
|
tcStore.shouldIssue(tcJid) &&
|
|
1172
1198
|
!this._client._inFlightTcTokenIssuance.has(tcJid)) {
|