whalibmob 5.14.19 → 5.17.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.
- package/README.md +57 -0
- package/index.js +9 -0
- package/lib/Client.js +386 -1
- package/lib/HistorySyncHandler.js +58 -10
- package/lib/OfflineNodeProcessor.js +100 -0
- package/lib/Registration.js +1 -1
- package/lib/WAM/BinaryInfo.js +19 -0
- package/lib/WAM/constants.js +22868 -0
- package/lib/WAM/encode.js +161 -0
- package/lib/WAM/index.js +18 -0
- package/lib/messages/MessageSender.js +11 -0
- package/lib/proto/MessageProto.js +72 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
CONTACT ME ON TELEGRAM IF YOU WANT TO WORK WITH ME AND IF YOU HAVE PROBLEM WITH WHALIBMOB : @brtyu545
|
|
11
11
|
|
|
12
|
+
**Need test numbers to try whalibmob with?** Message me on Telegram at **@brtyu545** — I can provide phone numbers for receiving SMS verification codes, so you can register and test whalibmob without using your own number.
|
|
13
|
+
|
|
12
14
|
If you want News about whalibmob enter this whalibmob channel: https://t.me/+sHN4MDCyB7U5OWY0
|
|
13
15
|
|
|
14
16
|
|
|
@@ -2005,6 +2007,7 @@ Every option is optional; `sessionDir` is the only one most senders ever set.
|
|
|
2005
2007
|
| `sessionDir` | `~/.waSession` | The authentication folder. Each number gets its own subfolder inside it — see [Saving & Restoring Sessions](#saving--restoring-sessions). |
|
|
2006
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). |
|
|
2007
2009
|
| `autoRead` | `true` | Send read receipts for incoming messages. `false` leaves them unread. |
|
|
2010
|
+
| `refreshVersion` | `true` | **iOS sessions only.** Check the App Store for the current build on every `init()` and write it into the session file. Set `false` to keep announcing whatever the session registered with — see [Keeping the Announced Version Current](#keeping-the-announced-version-current). Android is unaffected either way. |
|
|
2008
2011
|
| `pino` | off | Debug logging. `true` turns it on at `debug` level; an object is passed to `pino` as-is. |
|
|
2009
2012
|
| `sentCacheSize` | `2000` | How many sent messages keep their plaintext so a retry receipt naming them can be answered. |
|
|
2010
2013
|
| `maxRetryResends` | `5` | How many times one message may be re-sent in answer to retry receipts before the client gives up. |
|
|
@@ -2571,6 +2574,60 @@ of a pile by prefix.
|
|
|
2571
2574
|
> is moved unless you ask — `wa migrate-sessions` does that, one number or all
|
|
2572
2575
|
> of them, and re-running it is safe.
|
|
2573
2576
|
|
|
2577
|
+
### Keeping the Announced Version Current
|
|
2578
|
+
|
|
2579
|
+
Every connection announces the WhatsApp build it claims to be. The server checks
|
|
2580
|
+
it, and when it stops recognising the number it refuses the handshake with
|
|
2581
|
+
`405` — a failure that says nothing about the version and has nothing to do with
|
|
2582
|
+
the account.
|
|
2583
|
+
|
|
2584
|
+
A session records the version it registered with. Left alone it announces that
|
|
2585
|
+
same number forever, so a number registered in spring is still claiming a spring
|
|
2586
|
+
build in autumn, and one day the connect simply stops working.
|
|
2587
|
+
|
|
2588
|
+
**iOS sessions now refresh themselves.** On every `init()` the client asks the
|
|
2589
|
+
App Store what the current build is and writes it into `<phone>.json` before the
|
|
2590
|
+
handshake. Nothing to run, nothing to remember:
|
|
2591
|
+
|
|
2592
|
+
```js
|
|
2593
|
+
client.on('version_update', ({ from, to }) => {
|
|
2594
|
+
console.log('announcing', to, 'instead of', from)
|
|
2595
|
+
})
|
|
2596
|
+
|
|
2597
|
+
await client.init('40756469325')
|
|
2598
|
+
```
|
|
2599
|
+
|
|
2600
|
+
Three rules keep this from being the thing that breaks a working session:
|
|
2601
|
+
|
|
2602
|
+
- **It never goes backwards.** The App Store lookup answers with a pinned
|
|
2603
|
+
fallback rather than failing when it cannot reach the network, and that
|
|
2604
|
+
fallback can easily be older than what the session holds. Moving a session to
|
|
2605
|
+
an older version is the one outcome that makes a `405` *more* likely.
|
|
2606
|
+
- **`WA_VERSION` still wins.** A version pinned on the way out of a `405` is a
|
|
2607
|
+
decision, and is never quietly replaced.
|
|
2608
|
+
- **A failed lookup changes nothing.** The session keeps the version it has and
|
|
2609
|
+
the connect carries on.
|
|
2610
|
+
|
|
2611
|
+
Set `{ refreshVersion: false }` on the client to turn it off.
|
|
2612
|
+
|
|
2613
|
+
> [!NOTE]
|
|
2614
|
+
> **Android is deliberately left alone.** Its version comes out of the APK the
|
|
2615
|
+
> token material was read from, and the two have to agree — the registration
|
|
2616
|
+
> token is computed from that build. Refreshing the announced version behind the
|
|
2617
|
+
> caller's back would put it out of step with the token. Android keeps the
|
|
2618
|
+
> explicit flow: `wa apk-material --download`, then `wa refresh-version`.
|
|
2619
|
+
|
|
2620
|
+
The manual tool still works on both platforms, and is the only way to move an
|
|
2621
|
+
Android session:
|
|
2622
|
+
|
|
2623
|
+
```bash
|
|
2624
|
+
wa refresh-version 40756469325 # current build for the platform
|
|
2625
|
+
wa refresh-version 40756469325 --version 2.25.1.2 # or one you name
|
|
2626
|
+
```
|
|
2627
|
+
|
|
2628
|
+
Companion sessions have never had this problem: `connectWeb()` reads the live
|
|
2629
|
+
web revision on every connect already.
|
|
2630
|
+
|
|
2574
2631
|
### One-time Pre-keys
|
|
2575
2632
|
|
|
2576
2633
|
A pre-key is a one-shot Diffie-Hellman key the account leaves with the server so
|
package/index.js
CHANGED
|
@@ -7,6 +7,7 @@ const SessionPaths = require('./lib/SessionPaths');
|
|
|
7
7
|
const { createNewStore, saveStore, loadStore, toSixParts, fromSixParts, storeToJson, storeFromJson } = require('./lib/Store');
|
|
8
8
|
const { checkIfRegistered, requestSmsCode, verifyCode } = require('./lib/Registration');
|
|
9
9
|
const { SignalProtocol } = require('./lib/signal/SignalProtocol');
|
|
10
|
+
const WAM = require('./lib/WAM');
|
|
10
11
|
const { SignalStore } = require('./lib/signal/SignalStore');
|
|
11
12
|
const { SenderKeyStore, SenderKeyCrypto } = require('./lib/signal/SenderKey');
|
|
12
13
|
const { DeviceManager } = require('./lib/DeviceManager');
|
|
@@ -103,6 +104,14 @@ module.exports = {
|
|
|
103
104
|
encodeCompanionRegisterPayload,
|
|
104
105
|
encodeCompanionLoginPayload,
|
|
105
106
|
fetchWaWebVersion,
|
|
107
|
+
// WAM — the web client's stats channel: the event/global tables, the binary
|
|
108
|
+
// encoder and the BinaryInfo holder, all as the reference client defines
|
|
109
|
+
// them. See client.wamBuffer / client.sendWAMBuffer().
|
|
110
|
+
WAM,
|
|
111
|
+
encodeWAM: WAM.encodeWAM,
|
|
112
|
+
BinaryInfo: WAM.BinaryInfo,
|
|
113
|
+
WEB_EVENTS: WAM.WEB_EVENTS,
|
|
114
|
+
WEB_GLOBALS: WAM.WEB_GLOBALS,
|
|
106
115
|
// Signal / encryption internals
|
|
107
116
|
SignalProtocol,
|
|
108
117
|
SignalStore,
|
package/lib/Client.js
CHANGED
|
@@ -175,6 +175,18 @@ const RECONNECT_BACKOFF = [1000, 2000, 4000, 8000, 15000, 30000];
|
|
|
175
175
|
|
|
176
176
|
// How many attempts an upload gets before it is left for the next check, and
|
|
177
177
|
// how long it waits between them.
|
|
178
|
+
// How long a placeholder resend waits before it is actually asked for, and how
|
|
179
|
+
// long it waits for an answer before the request is forgotten. The first is
|
|
180
|
+
// there because most of these resolve on their own; the second because a phone
|
|
181
|
+
// that is switched off will never answer and the entry must not block a later
|
|
182
|
+
// attempt forever.
|
|
183
|
+
const PLACEHOLDER_SETTLE_MS = 2000;
|
|
184
|
+
const PLACEHOLDER_TIMEOUT_MS = 8000;
|
|
185
|
+
// An hour, matching the reference client, and a ceiling so a flood of
|
|
186
|
+
// undecryptable stanzas cannot grow the map without bound.
|
|
187
|
+
const PLACEHOLDER_TTL_MS = 60 * 60 * 1000;
|
|
188
|
+
const PLACEHOLDER_MAX = 500;
|
|
189
|
+
|
|
178
190
|
const PRE_KEY_UPLOAD_TRIES = 4;
|
|
179
191
|
const PRE_KEY_UPLOAD_BACKOFF = (attempt) => Math.min(1000 * Math.pow(2, attempt), 10000);
|
|
180
192
|
|
|
@@ -367,6 +379,13 @@ class WhalibmobClient extends EventEmitter {
|
|
|
367
379
|
// automatically on the first refused login. Set { autoFixNumber: false } to
|
|
368
380
|
// be told about it instead.
|
|
369
381
|
this._autoFixNumber = opts.autoFixNumber !== false;
|
|
382
|
+
// An iOS session checks the App Store for the current build on every
|
|
383
|
+
// connect and writes it back — see _refreshIosVersion(). Set
|
|
384
|
+
// { refreshVersion: false } to keep announcing whatever the session
|
|
385
|
+
// registered with. Android is not affected either way: its version comes
|
|
386
|
+
// out of the APK the token material was read from, and that stays a
|
|
387
|
+
// deliberate step.
|
|
388
|
+
this._refreshVersion = opts.refreshVersion !== false;
|
|
370
389
|
this._numberFixAttempted = false;
|
|
371
390
|
this._pingTimer = null;
|
|
372
391
|
this._keepTimer = null;
|
|
@@ -381,6 +400,19 @@ class WhalibmobClient extends EventEmitter {
|
|
|
381
400
|
// One mutex per client — two accounts in the same process are unrelated
|
|
382
401
|
// and must not wait on each other.
|
|
383
402
|
this._preKeyMutex = new Mutex();
|
|
403
|
+
// How far this machine's clock is from the server's, in milliseconds.
|
|
404
|
+
//
|
|
405
|
+
// The server stamps `t` on the stanzas that matter — <success> and
|
|
406
|
+
// pair-success — and the unified_session id is derived from a clock that is
|
|
407
|
+
// supposed to be the server's, not ours. Computing it from Date.now() alone
|
|
408
|
+
// makes the id drift by however wrong the local clock is, which on a phone
|
|
409
|
+
// or a container without NTP can be minutes. Zero until the server says
|
|
410
|
+
// otherwise, which is the same as not correcting at all.
|
|
411
|
+
this._serverTimeOffsetMs = 0;
|
|
412
|
+
// Message ids we have asked the phone to resend, and have not seen since.
|
|
413
|
+
// An entry is the request itself: while it is there, no second request for
|
|
414
|
+
// that id goes out. Bounded and time-limited — see _placeholderResendCache.
|
|
415
|
+
this.__placeholderResend = new Map();
|
|
384
416
|
// Incoming work runs one item at a time, in arrival order.
|
|
385
417
|
//
|
|
386
418
|
// Stanzas arrive faster than they are handled, and the handling is
|
|
@@ -472,6 +504,67 @@ class WhalibmobClient extends EventEmitter {
|
|
|
472
504
|
return this.init(phoneNumber);
|
|
473
505
|
}
|
|
474
506
|
|
|
507
|
+
/**
|
|
508
|
+
* Bring an iOS session's announced version up to date, on every connect.
|
|
509
|
+
*
|
|
510
|
+
* A session records the version it registered with and announces that
|
|
511
|
+
* forever after. Months later the server stops accepting it and the connect
|
|
512
|
+
* is refused with a 405 that says nothing about why — and the only remaining
|
|
513
|
+
* move, before this, was to run `wa refresh-version` by hand or register the
|
|
514
|
+
* number again. The companion path never had that problem because
|
|
515
|
+
* connectWeb() reads the live revision every time; this is the same idea for
|
|
516
|
+
* the primary one.
|
|
517
|
+
*
|
|
518
|
+
* iOS only, and deliberately so. Android's version comes out of the APK the
|
|
519
|
+
* token material was read from — the two have to agree, because the token is
|
|
520
|
+
* computed from that build — so refreshing it behind the caller's back would
|
|
521
|
+
* put the announced version and the token out of step. Android keeps its
|
|
522
|
+
* Play Store flow exactly as it was.
|
|
523
|
+
*
|
|
524
|
+
* Three rules keep this from being the thing that breaks a working session:
|
|
525
|
+
*
|
|
526
|
+
* • It never goes backwards. fetchIosVersion() answers with the pinned
|
|
527
|
+
* fallback rather than failing when the lookup does not work, and that
|
|
528
|
+
* fallback can easily be older than what a session already holds. Moving
|
|
529
|
+
* a session to an older version is the one outcome that makes a 405 more
|
|
530
|
+
* likely rather than less.
|
|
531
|
+
* • WA_VERSION still wins. Someone who has pinned a version on the way out
|
|
532
|
+
* of a 405 must not have it quietly replaced.
|
|
533
|
+
* • It never throws, and it never blocks for long. A lookup that fails
|
|
534
|
+
* leaves the session exactly as it was and the connect carries on.
|
|
535
|
+
*/
|
|
536
|
+
async _refreshIosVersion() {
|
|
537
|
+
if (!this._refreshVersion || this._mode === 'web' || !this._store) return;
|
|
538
|
+
if (process.env.WA_VERSION) {
|
|
539
|
+
_whaDbg('[DBG] VERSION refresh skipped — WA_VERSION is set');
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const device = this._store.device || getDeviceConfig();
|
|
544
|
+
if (device.os === 'android') return; // the APK flow owns this
|
|
545
|
+
|
|
546
|
+
try {
|
|
547
|
+
const { compareVersions } = require('./Registration');
|
|
548
|
+
const before = this._store.version;
|
|
549
|
+
const live = await fetchIosVersion(!!device.business);
|
|
550
|
+
if (!live) return;
|
|
551
|
+
|
|
552
|
+
if (before && compareVersions(live, before) <= 0) {
|
|
553
|
+
_whaDbg('[DBG] VERSION already current (' + before + ', App Store says ' + live + ')');
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
this._store.version = live;
|
|
558
|
+
this._persistStore();
|
|
559
|
+
_whaDbg('[DBG] VERSION refreshed ' + (before || 'none') + ' → ' + live +
|
|
560
|
+
' (App Store listing)');
|
|
561
|
+
this.emit('version_update', { from: before || null, to: live, source: 'App Store listing' });
|
|
562
|
+
} catch (err) {
|
|
563
|
+
// A session that cannot ask keeps the version it has.
|
|
564
|
+
_whaDbg('[DBG] VERSION refresh failed: ' + (err && err.message));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
475
568
|
async init(phoneNumber) {
|
|
476
569
|
phoneNumber = String(phoneNumber).replace(/\D/g, '');
|
|
477
570
|
this._phoneNumber = phoneNumber;
|
|
@@ -501,6 +594,10 @@ class WhalibmobClient extends EventEmitter {
|
|
|
501
594
|
);
|
|
502
595
|
}
|
|
503
596
|
|
|
597
|
+
// Before the handshake, which reads the version straight out of the store.
|
|
598
|
+
// iOS only; Android keeps its APK flow. See _refreshIosVersion().
|
|
599
|
+
await this._refreshIosVersion();
|
|
600
|
+
|
|
504
601
|
const signalFile = path.join(this._sessionDir, `${phoneNumber}.signal.json`);
|
|
505
602
|
const skFile = path.join(this._sessionDir, `${phoneNumber}.sk.json`);
|
|
506
603
|
const tcTokenFile = path.join(this._sessionDir, `${phoneNumber}.tctoken.json`);
|
|
@@ -926,6 +1023,11 @@ class WhalibmobClient extends EventEmitter {
|
|
|
926
1023
|
async _handleCompanionFinish(node) {
|
|
927
1024
|
const { buildCompanionFinishBundle } = require('./PairingCode');
|
|
928
1025
|
|
|
1026
|
+
// Same as the reference client does on pair-success: this stanza carries
|
|
1027
|
+
// the server's clock, and the unified_session sent a moment later is
|
|
1028
|
+
// derived from it.
|
|
1029
|
+
this._updateServerTimeOffset(node);
|
|
1030
|
+
|
|
929
1031
|
const reg = findChild(node, 'link_code_companion_reg');
|
|
930
1032
|
if (!reg) return;
|
|
931
1033
|
|
|
@@ -1084,11 +1186,27 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1084
1186
|
// pairing and after each login. It carries no state we need; sending it keeps
|
|
1085
1187
|
// our traffic shaped like the client we are claiming to be. Failures are
|
|
1086
1188
|
// ignored, as they are in the reference.
|
|
1189
|
+
// Take the server's clock from whatever stanza carries it.
|
|
1190
|
+
//
|
|
1191
|
+
// Mirrors the reference client: read `t` off <success> and off pair-success,
|
|
1192
|
+
// and keep the difference from ours. Anything unparseable or non-positive is
|
|
1193
|
+
// ignored rather than allowed to poison the offset.
|
|
1194
|
+
_updateServerTimeOffset(node) {
|
|
1195
|
+
const t = node && node.attrs && node.attrs.t;
|
|
1196
|
+
if (!t) return;
|
|
1197
|
+
const parsed = Number(t);
|
|
1198
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return;
|
|
1199
|
+
this._serverTimeOffsetMs = parsed * 1000 - Date.now();
|
|
1200
|
+
_whaDbg('[DBG] SERVER_TIME_OFFSET ' + this._serverTimeOffsetMs + 'ms');
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1087
1203
|
_sendUnifiedSession() {
|
|
1088
1204
|
if (this._mode !== 'web' || !this._socket) return;
|
|
1089
1205
|
try {
|
|
1090
1206
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
1091
|
-
|
|
1207
|
+
// Server clock, not ours — see _serverTimeOffsetMs.
|
|
1208
|
+
const now = Date.now() + this._serverTimeOffsetMs;
|
|
1209
|
+
const id = ((now + 3 * 24 * 60 * 60 * 1000) % WEEK_MS).toString();
|
|
1092
1210
|
this._socket.sendNode(new BinaryNode('ib', {}, [
|
|
1093
1211
|
new BinaryNode('unified_session', { id }, null)
|
|
1094
1212
|
]));
|
|
@@ -1305,6 +1423,9 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1305
1423
|
// ── Feature 1: Parse <success> node ──────────────────────────────────────
|
|
1306
1424
|
// Extract ADVSignedDeviceIdentity, platform, and other account info that
|
|
1307
1425
|
// the server provides on each successful authentication.
|
|
1426
|
+
// The server's clock, before anything derived from it is computed.
|
|
1427
|
+
this._updateServerTimeOffset(successNode);
|
|
1428
|
+
|
|
1308
1429
|
this._parseSuccessNode(successNode);
|
|
1309
1430
|
|
|
1310
1431
|
// ── Feature 3 (part A): Send <active> IQ ─────────────────────────────────
|
|
@@ -2044,6 +2165,27 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2044
2165
|
|
|
2045
2166
|
// ─── Node dispatch ────────────────────────────────────────────────────────
|
|
2046
2167
|
|
|
2168
|
+
// The queue the reconnect backlog drains through. Built on first use so a
|
|
2169
|
+
// session that never goes offline never pays for it.
|
|
2170
|
+
get _offlineQueue() {
|
|
2171
|
+
if (!this.__offlineQueue) {
|
|
2172
|
+
const { makeOfflineNodeProcessor } = require('./OfflineNodeProcessor');
|
|
2173
|
+
this.__offlineQueue = makeOfflineNodeProcessor(new Map([
|
|
2174
|
+
['message', (n) => this._handleMessage(n)],
|
|
2175
|
+
['receipt', (n) => this._handleReceipt(n)],
|
|
2176
|
+
['notification', (n) => this._handleNotification(n)],
|
|
2177
|
+
['call', (n) => this._handleCall(n)]
|
|
2178
|
+
]), {
|
|
2179
|
+
isOpen: () => !!(this._socket && this._connected),
|
|
2180
|
+
onError: (err, ctx) => {
|
|
2181
|
+
_whaDbg('[DBG] OFFLINE_QUEUE_ERR ' + ctx + ': ' + (err && err.message));
|
|
2182
|
+
this.emit('node_error', { tag: ctx, err });
|
|
2183
|
+
}
|
|
2184
|
+
});
|
|
2185
|
+
}
|
|
2186
|
+
return this.__offlineQueue;
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2047
2189
|
_onNode(node) {
|
|
2048
2190
|
if (!node || !node.description) return;
|
|
2049
2191
|
const tag = node.description;
|
|
@@ -2061,6 +2203,16 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2061
2203
|
// behind it in the same offline batch was lost. Contain it: log the stanza
|
|
2062
2204
|
// that did it, tell the caller, carry on reading.
|
|
2063
2205
|
try {
|
|
2206
|
+
// Backlog from the reconnect, not live traffic. The server stamps these
|
|
2207
|
+
// `offline`, and handling a burst of them the way live stanzas are
|
|
2208
|
+
// handled starts every one of them at once — see OfflineNodeProcessor.
|
|
2209
|
+
if (node.attrs && node.attrs.offline &&
|
|
2210
|
+
(tag === 'message' || tag === 'receipt' ||
|
|
2211
|
+
tag === 'notification' || tag === 'call')) {
|
|
2212
|
+
this._offlineQueue.enqueue(tag, node);
|
|
2213
|
+
return;
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2064
2216
|
if (tag === 'iq') this._handleIq(node);
|
|
2065
2217
|
else if (tag === 'message') this._handleMessage(node);
|
|
2066
2218
|
else if (tag === 'receipt') this._handleReceipt(node);
|
|
@@ -2517,6 +2669,9 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2517
2669
|
return;
|
|
2518
2670
|
}
|
|
2519
2671
|
|
|
2672
|
+
// If we had asked the phone to resend this one, it has answered.
|
|
2673
|
+
this._resolvePlaceholderResend(id);
|
|
2674
|
+
|
|
2520
2675
|
this.emit('message', { id, from, participant, ts, decoded, node });
|
|
2521
2676
|
this._sendReadReceipt(id, fromRaw, partRaw);
|
|
2522
2677
|
|
|
@@ -3053,6 +3208,29 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3053
3208
|
|
|
3054
3209
|
if (!this._socket || !this._connected) return;
|
|
3055
3210
|
|
|
3211
|
+
// Ask the phone as well, for the first couple of rounds.
|
|
3212
|
+
//
|
|
3213
|
+
// The retry receipt asks the *sender* to encrypt it again, which only helps
|
|
3214
|
+
// when their session is the thing that went wrong. When ours is — a spent
|
|
3215
|
+
// pre-key, a ratchet that moved on — no amount of asking them helps, and
|
|
3216
|
+
// the plaintext is sitting on our own phone the whole time. The reference
|
|
3217
|
+
// client asks both, and gives up on the phone after two rounds.
|
|
3218
|
+
//
|
|
3219
|
+
// Deliberately not awaited: a retry receipt must go out now, and this is a
|
|
3220
|
+
// slower second route to the same message.
|
|
3221
|
+
if (count <= 2) {
|
|
3222
|
+
const attrs = (origNode && origNode.attrs) || {};
|
|
3223
|
+
const from = attrs.from ? String(attrs.from) : null;
|
|
3224
|
+
if (from) {
|
|
3225
|
+
this.requestPlaceholderResend({
|
|
3226
|
+
remoteJid: from,
|
|
3227
|
+
fromMe: false,
|
|
3228
|
+
id: msgId
|
|
3229
|
+
}).catch(err =>
|
|
3230
|
+
_whaDbg('[DBG] PLACEHOLDER request failed: ' + (err && err.message)));
|
|
3231
|
+
}
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3056
3234
|
// Pick a prekey that is not already advertised by another unresolved retry.
|
|
3057
3235
|
// A prekey is deleted the moment a pkmsg using it is decrypted, so handing
|
|
3058
3236
|
// the same one to two senders means the second arrives after the key is
|
|
@@ -3839,6 +4017,213 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3839
4017
|
this._ensureServerPreKeys('server asked');
|
|
3840
4018
|
}
|
|
3841
4019
|
|
|
4020
|
+
// Outstanding resend requests, with an hour's memory and a ceiling.
|
|
4021
|
+
//
|
|
4022
|
+
// A plain Map would hold every id the session ever failed to decrypt. The TTL
|
|
4023
|
+
// is what the reference client uses; the ceiling is what keeps a burst of
|
|
4024
|
+
// undecryptable stanzas from growing it without bound, dropping the oldest
|
|
4025
|
+
// entries first — the ones whose request has already timed out anyway.
|
|
4026
|
+
get _placeholderResendCache() {
|
|
4027
|
+
const map = this.__placeholderResend;
|
|
4028
|
+
const now = Date.now();
|
|
4029
|
+
return {
|
|
4030
|
+
has: (id) => {
|
|
4031
|
+
const e = map.get(id);
|
|
4032
|
+
if (!e) return false;
|
|
4033
|
+
if (Date.now() - e.at > PLACEHOLDER_TTL_MS) { map.delete(id); return false; }
|
|
4034
|
+
return true;
|
|
4035
|
+
},
|
|
4036
|
+
get: (id) => {
|
|
4037
|
+
const e = map.get(id);
|
|
4038
|
+
if (!e) return undefined;
|
|
4039
|
+
if (Date.now() - e.at > PLACEHOLDER_TTL_MS) { map.delete(id); return undefined; }
|
|
4040
|
+
return e.value;
|
|
4041
|
+
},
|
|
4042
|
+
set: (id, value) => {
|
|
4043
|
+
map.set(id, { value, at: now });
|
|
4044
|
+
// Map keeps insertion order, so the first key is the oldest.
|
|
4045
|
+
while (map.size > PLACEHOLDER_MAX) map.delete(map.keys().next().value);
|
|
4046
|
+
},
|
|
4047
|
+
delete: (id) => map.delete(id),
|
|
4048
|
+
get size() { return map.size; }
|
|
4049
|
+
};
|
|
4050
|
+
}
|
|
4051
|
+
|
|
4052
|
+
// ─── Peer data operations, and asking the phone to resend ─────────────────
|
|
4053
|
+
//
|
|
4054
|
+
// A peer-data-operation request is how a companion asks its own phone for
|
|
4055
|
+
// something. It goes out as a ProtocolMessage addressed to this account, with
|
|
4056
|
+
// the stanza marked category="peer" so the server routes it to the other
|
|
4057
|
+
// devices instead of delivering it as a chat message to yourself.
|
|
4058
|
+
//
|
|
4059
|
+
// @param {object} pdo see encodePeerDataOperationRequestMessage
|
|
4060
|
+
// @returns {Promise<string>} the id of the message that carried the request
|
|
4061
|
+
async sendPeerDataOperationMessage(pdo) {
|
|
4062
|
+
if (!this._sender || !this._connected) throw new Error('Not connected');
|
|
4063
|
+
const me = this._store && this._store.me && this._store.me.id;
|
|
4064
|
+
if (!me) throw new Error('Not authenticated');
|
|
4065
|
+
|
|
4066
|
+
const {
|
|
4067
|
+
encodeMessage, encodeProtocolMessage,
|
|
4068
|
+
encodePeerDataOperationRequestMessage,
|
|
4069
|
+
PROTOCOL_TYPE_PEER_DATA_OPERATION_REQUEST
|
|
4070
|
+
} = require('./proto/MessageProto');
|
|
4071
|
+
|
|
4072
|
+
const payload = encodeProtocolMessage({
|
|
4073
|
+
type: PROTOCOL_TYPE_PEER_DATA_OPERATION_REQUEST,
|
|
4074
|
+
peerDataOperationRequestMessage: encodePeerDataOperationRequestMessage(pdo)
|
|
4075
|
+
});
|
|
4076
|
+
const msgBuf = encodeMessage('protocol', payload);
|
|
4077
|
+
const msgId = this._genMsgId();
|
|
4078
|
+
|
|
4079
|
+
await this._sender._sendMessage(String(me), msgId, msgBuf, 'text', {
|
|
4080
|
+
_protocol: true, // not a reach-out; keeps it off the tcToken gate
|
|
4081
|
+
category: 'peer',
|
|
4082
|
+
pushPriority: 'high_force',
|
|
4083
|
+
additionalNodes: [new BinaryNode('meta', { appdata: 'default' }, null)]
|
|
4084
|
+
});
|
|
4085
|
+
return msgId;
|
|
4086
|
+
}
|
|
4087
|
+
|
|
4088
|
+
/**
|
|
4089
|
+
* Ask the phone to send a message again.
|
|
4090
|
+
*
|
|
4091
|
+
* A message that cannot be decrypted — a session that moved on, a key that
|
|
4092
|
+
* was already spent — is gone as far as this device is concerned. The phone
|
|
4093
|
+
* still has the plaintext, so the fix is to ask it, not to retry the
|
|
4094
|
+
* ciphertext.
|
|
4095
|
+
*
|
|
4096
|
+
* The cache is what stops that turning into a storm. One request per message
|
|
4097
|
+
* id, and the entry is what says a request is outstanding:
|
|
4098
|
+
*
|
|
4099
|
+
* • already asked → say nothing, return
|
|
4100
|
+
* • it arrives within 2s → the entry is gone, answer 'RESOLVED'
|
|
4101
|
+
* • no answer after 8s → drop the entry so a later attempt may ask
|
|
4102
|
+
*
|
|
4103
|
+
* @param {{remoteJid: string, fromMe: boolean, id: string}} messageKey
|
|
4104
|
+
* @param {object} [msgData] metadata to keep for whatever handles the reply
|
|
4105
|
+
* @returns {Promise<string|undefined|'RESOLVED'>}
|
|
4106
|
+
*/
|
|
4107
|
+
async requestPlaceholderResend(messageKey, msgData) {
|
|
4108
|
+
if (!messageKey || !messageKey.id) return;
|
|
4109
|
+
const me = this._store && this._store.me && this._store.me.id;
|
|
4110
|
+
if (!me) throw new Error('Not authenticated');
|
|
4111
|
+
|
|
4112
|
+
const cache = this._placeholderResendCache;
|
|
4113
|
+
if (cache.has(messageKey.id)) {
|
|
4114
|
+
_whaDbg('[DBG] PLACEHOLDER already requested for ' + messageKey.id);
|
|
4115
|
+
return;
|
|
4116
|
+
}
|
|
4117
|
+
cache.set(messageKey.id, msgData || true);
|
|
4118
|
+
|
|
4119
|
+
// Give it a moment. Most of these resolve on their own — the message that
|
|
4120
|
+
// failed to decrypt is very often followed by the sender's own retry.
|
|
4121
|
+
await new Promise(r => setTimeout(r, PLACEHOLDER_SETTLE_MS));
|
|
4122
|
+
if (!cache.has(messageKey.id)) {
|
|
4123
|
+
_whaDbg('[DBG] PLACEHOLDER ' + messageKey.id + ' arrived before asking');
|
|
4124
|
+
return 'RESOLVED';
|
|
4125
|
+
}
|
|
4126
|
+
|
|
4127
|
+
// The phone may simply be off. Let the entry go so this is not the last
|
|
4128
|
+
// word on the subject.
|
|
4129
|
+
setTimeout(() => {
|
|
4130
|
+
if (cache.has(messageKey.id)) {
|
|
4131
|
+
_whaDbg('[DBG] PLACEHOLDER no answer for ' + messageKey.id + ' — phone likely offline');
|
|
4132
|
+
cache.delete(messageKey.id);
|
|
4133
|
+
}
|
|
4134
|
+
}, PLACEHOLDER_TIMEOUT_MS).unref?.();
|
|
4135
|
+
|
|
4136
|
+
const { PeerDataOperationRequestType } = require('./proto/MessageProto');
|
|
4137
|
+
_whaDbg('[DBG] PLACEHOLDER requesting resend of ' + messageKey.id);
|
|
4138
|
+
return this.sendPeerDataOperationMessage({
|
|
4139
|
+
peerDataOperationRequestType: PeerDataOperationRequestType.PLACEHOLDER_MESSAGE_RESEND,
|
|
4140
|
+
placeholderMessageResendRequest: [{ messageKey }]
|
|
4141
|
+
});
|
|
4142
|
+
}
|
|
4143
|
+
|
|
4144
|
+
/**
|
|
4145
|
+
* Ask the phone for older messages in a chat.
|
|
4146
|
+
*
|
|
4147
|
+
* The same channel, a different operation — this is what an on-demand
|
|
4148
|
+
* history sync is built on.
|
|
4149
|
+
*/
|
|
4150
|
+
async fetchMessageHistory(count, oldestMsgKey, oldestMsgTimestampMs) {
|
|
4151
|
+
const { PeerDataOperationRequestType } = require('./proto/MessageProto');
|
|
4152
|
+
return this.sendPeerDataOperationMessage({
|
|
4153
|
+
peerDataOperationRequestType: PeerDataOperationRequestType.HISTORY_SYNC_ON_DEMAND,
|
|
4154
|
+
historySyncOnDemandRequest: {
|
|
4155
|
+
chatJid: oldestMsgKey.remoteJid,
|
|
4156
|
+
oldestMsgId: oldestMsgKey.id,
|
|
4157
|
+
oldestMsgFromMe: !!oldestMsgKey.fromMe,
|
|
4158
|
+
onDemandMsgCount: count,
|
|
4159
|
+
oldestMsgTimestampMs: oldestMsgTimestampMs
|
|
4160
|
+
}
|
|
4161
|
+
});
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
/** Note that a message we asked about has turned up, so nothing is owed. */
|
|
4165
|
+
_resolvePlaceholderResend(msgId) {
|
|
4166
|
+
if (msgId && this._placeholderResendCache.has(msgId)) {
|
|
4167
|
+
this._placeholderResendCache.delete(msgId);
|
|
4168
|
+
_whaDbg('[DBG] PLACEHOLDER resolved ' + msgId);
|
|
4169
|
+
}
|
|
4170
|
+
}
|
|
4171
|
+
|
|
4172
|
+
// ─── WAM — the stats channel WhatsApp Web reports on ──────────────────────
|
|
4173
|
+
//
|
|
4174
|
+
// A WAM buffer is a batch of telemetry events encoded in WhatsApp's own
|
|
4175
|
+
// binary format and posted to `w:stats`. The web client sends these
|
|
4176
|
+
// constantly; a companion session that never does is one more thing that
|
|
4177
|
+
// distinguishes it from a browser.
|
|
4178
|
+
//
|
|
4179
|
+
// Nothing here fires on its own, and that matches the reference client's
|
|
4180
|
+
// library exactly — it builds the same three pieces, exposes the same
|
|
4181
|
+
// `wamBuffer` and the same `sendWAMBuffer`, and never calls the latter
|
|
4182
|
+
// itself. What to report, and when, is the application's decision.
|
|
4183
|
+
//
|
|
4184
|
+
// const { WAM } = require('whalibmob')
|
|
4185
|
+
//
|
|
4186
|
+
// client.wamBuffer.sequence = 1
|
|
4187
|
+
// client.wamBuffer.events = [{
|
|
4188
|
+
// WamDroppedEvent: {
|
|
4189
|
+
// props: { droppedEventCode: 3, droppedEventCount: 1, isFromWamsys: true },
|
|
4190
|
+
// globals: {}
|
|
4191
|
+
// }
|
|
4192
|
+
// }]
|
|
4193
|
+
// await client.sendWAMBuffer(WAM.encodeWAM(client.wamBuffer))
|
|
4194
|
+
//
|
|
4195
|
+
// The event and field names come from WAM.WEB_EVENTS and WAM.WEB_GLOBALS,
|
|
4196
|
+
// which are the reference client's tables verbatim.
|
|
4197
|
+
|
|
4198
|
+
/** The buffer an application fills in before encoding. */
|
|
4199
|
+
get wamBuffer() {
|
|
4200
|
+
if (!this._wamBuffer) {
|
|
4201
|
+
const { BinaryInfo } = require('./WAM');
|
|
4202
|
+
this._wamBuffer = new BinaryInfo();
|
|
4203
|
+
}
|
|
4204
|
+
return this._wamBuffer;
|
|
4205
|
+
}
|
|
4206
|
+
|
|
4207
|
+
/**
|
|
4208
|
+
* Post an encoded WAM buffer to the server.
|
|
4209
|
+
*
|
|
4210
|
+
* @param {Buffer} wamBuffer output of WAM.encodeWAM()
|
|
4211
|
+
* @returns {Promise<object|null>} the server's IQ result, or null on timeout
|
|
4212
|
+
*/
|
|
4213
|
+
async sendWAMBuffer(wamBuffer) {
|
|
4214
|
+
if (!this._socket || !this._connected) throw new Error('Not connected');
|
|
4215
|
+
if (!Buffer.isBuffer(wamBuffer)) {
|
|
4216
|
+
throw new Error('sendWAMBuffer expects a Buffer from WAM.encodeWAM()');
|
|
4217
|
+
}
|
|
4218
|
+
return this._sendIq(new BinaryNode('iq', {
|
|
4219
|
+
id: this._genMsgId(),
|
|
4220
|
+
to: 's.whatsapp.net',
|
|
4221
|
+
xmlns: 'w:stats'
|
|
4222
|
+
}, [
|
|
4223
|
+
new BinaryNode('add', { t: String(Math.round(Date.now() / 1000)) }, wamBuffer)
|
|
4224
|
+
]));
|
|
4225
|
+
}
|
|
4226
|
+
|
|
3842
4227
|
// ─── IQ helper (send + await response) ────────────────────────────────────
|
|
3843
4228
|
|
|
3844
4229
|
_sendIq(node) {
|