whalibmob 5.14.19 → 5.16.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/index.js +9 -0
- package/lib/Client.js +314 -1
- package/lib/HistorySyncHandler.js +58 -10
- package/lib/OfflineNodeProcessor.js +100 -0
- 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/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
|
|
|
@@ -381,6 +393,19 @@ class WhalibmobClient extends EventEmitter {
|
|
|
381
393
|
// One mutex per client — two accounts in the same process are unrelated
|
|
382
394
|
// and must not wait on each other.
|
|
383
395
|
this._preKeyMutex = new Mutex();
|
|
396
|
+
// How far this machine's clock is from the server's, in milliseconds.
|
|
397
|
+
//
|
|
398
|
+
// The server stamps `t` on the stanzas that matter — <success> and
|
|
399
|
+
// pair-success — and the unified_session id is derived from a clock that is
|
|
400
|
+
// supposed to be the server's, not ours. Computing it from Date.now() alone
|
|
401
|
+
// makes the id drift by however wrong the local clock is, which on a phone
|
|
402
|
+
// or a container without NTP can be minutes. Zero until the server says
|
|
403
|
+
// otherwise, which is the same as not correcting at all.
|
|
404
|
+
this._serverTimeOffsetMs = 0;
|
|
405
|
+
// Message ids we have asked the phone to resend, and have not seen since.
|
|
406
|
+
// An entry is the request itself: while it is there, no second request for
|
|
407
|
+
// that id goes out. Bounded and time-limited — see _placeholderResendCache.
|
|
408
|
+
this.__placeholderResend = new Map();
|
|
384
409
|
// Incoming work runs one item at a time, in arrival order.
|
|
385
410
|
//
|
|
386
411
|
// Stanzas arrive faster than they are handled, and the handling is
|
|
@@ -926,6 +951,11 @@ class WhalibmobClient extends EventEmitter {
|
|
|
926
951
|
async _handleCompanionFinish(node) {
|
|
927
952
|
const { buildCompanionFinishBundle } = require('./PairingCode');
|
|
928
953
|
|
|
954
|
+
// Same as the reference client does on pair-success: this stanza carries
|
|
955
|
+
// the server's clock, and the unified_session sent a moment later is
|
|
956
|
+
// derived from it.
|
|
957
|
+
this._updateServerTimeOffset(node);
|
|
958
|
+
|
|
929
959
|
const reg = findChild(node, 'link_code_companion_reg');
|
|
930
960
|
if (!reg) return;
|
|
931
961
|
|
|
@@ -1084,11 +1114,27 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1084
1114
|
// pairing and after each login. It carries no state we need; sending it keeps
|
|
1085
1115
|
// our traffic shaped like the client we are claiming to be. Failures are
|
|
1086
1116
|
// ignored, as they are in the reference.
|
|
1117
|
+
// Take the server's clock from whatever stanza carries it.
|
|
1118
|
+
//
|
|
1119
|
+
// Mirrors the reference client: read `t` off <success> and off pair-success,
|
|
1120
|
+
// and keep the difference from ours. Anything unparseable or non-positive is
|
|
1121
|
+
// ignored rather than allowed to poison the offset.
|
|
1122
|
+
_updateServerTimeOffset(node) {
|
|
1123
|
+
const t = node && node.attrs && node.attrs.t;
|
|
1124
|
+
if (!t) return;
|
|
1125
|
+
const parsed = Number(t);
|
|
1126
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return;
|
|
1127
|
+
this._serverTimeOffsetMs = parsed * 1000 - Date.now();
|
|
1128
|
+
_whaDbg('[DBG] SERVER_TIME_OFFSET ' + this._serverTimeOffsetMs + 'ms');
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1087
1131
|
_sendUnifiedSession() {
|
|
1088
1132
|
if (this._mode !== 'web' || !this._socket) return;
|
|
1089
1133
|
try {
|
|
1090
1134
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
1091
|
-
|
|
1135
|
+
// Server clock, not ours — see _serverTimeOffsetMs.
|
|
1136
|
+
const now = Date.now() + this._serverTimeOffsetMs;
|
|
1137
|
+
const id = ((now + 3 * 24 * 60 * 60 * 1000) % WEEK_MS).toString();
|
|
1092
1138
|
this._socket.sendNode(new BinaryNode('ib', {}, [
|
|
1093
1139
|
new BinaryNode('unified_session', { id }, null)
|
|
1094
1140
|
]));
|
|
@@ -1305,6 +1351,9 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1305
1351
|
// ── Feature 1: Parse <success> node ──────────────────────────────────────
|
|
1306
1352
|
// Extract ADVSignedDeviceIdentity, platform, and other account info that
|
|
1307
1353
|
// the server provides on each successful authentication.
|
|
1354
|
+
// The server's clock, before anything derived from it is computed.
|
|
1355
|
+
this._updateServerTimeOffset(successNode);
|
|
1356
|
+
|
|
1308
1357
|
this._parseSuccessNode(successNode);
|
|
1309
1358
|
|
|
1310
1359
|
// ── Feature 3 (part A): Send <active> IQ ─────────────────────────────────
|
|
@@ -2044,6 +2093,27 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2044
2093
|
|
|
2045
2094
|
// ─── Node dispatch ────────────────────────────────────────────────────────
|
|
2046
2095
|
|
|
2096
|
+
// The queue the reconnect backlog drains through. Built on first use so a
|
|
2097
|
+
// session that never goes offline never pays for it.
|
|
2098
|
+
get _offlineQueue() {
|
|
2099
|
+
if (!this.__offlineQueue) {
|
|
2100
|
+
const { makeOfflineNodeProcessor } = require('./OfflineNodeProcessor');
|
|
2101
|
+
this.__offlineQueue = makeOfflineNodeProcessor(new Map([
|
|
2102
|
+
['message', (n) => this._handleMessage(n)],
|
|
2103
|
+
['receipt', (n) => this._handleReceipt(n)],
|
|
2104
|
+
['notification', (n) => this._handleNotification(n)],
|
|
2105
|
+
['call', (n) => this._handleCall(n)]
|
|
2106
|
+
]), {
|
|
2107
|
+
isOpen: () => !!(this._socket && this._connected),
|
|
2108
|
+
onError: (err, ctx) => {
|
|
2109
|
+
_whaDbg('[DBG] OFFLINE_QUEUE_ERR ' + ctx + ': ' + (err && err.message));
|
|
2110
|
+
this.emit('node_error', { tag: ctx, err });
|
|
2111
|
+
}
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
return this.__offlineQueue;
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2047
2117
|
_onNode(node) {
|
|
2048
2118
|
if (!node || !node.description) return;
|
|
2049
2119
|
const tag = node.description;
|
|
@@ -2061,6 +2131,16 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2061
2131
|
// behind it in the same offline batch was lost. Contain it: log the stanza
|
|
2062
2132
|
// that did it, tell the caller, carry on reading.
|
|
2063
2133
|
try {
|
|
2134
|
+
// Backlog from the reconnect, not live traffic. The server stamps these
|
|
2135
|
+
// `offline`, and handling a burst of them the way live stanzas are
|
|
2136
|
+
// handled starts every one of them at once — see OfflineNodeProcessor.
|
|
2137
|
+
if (node.attrs && node.attrs.offline &&
|
|
2138
|
+
(tag === 'message' || tag === 'receipt' ||
|
|
2139
|
+
tag === 'notification' || tag === 'call')) {
|
|
2140
|
+
this._offlineQueue.enqueue(tag, node);
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2064
2144
|
if (tag === 'iq') this._handleIq(node);
|
|
2065
2145
|
else if (tag === 'message') this._handleMessage(node);
|
|
2066
2146
|
else if (tag === 'receipt') this._handleReceipt(node);
|
|
@@ -2517,6 +2597,9 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2517
2597
|
return;
|
|
2518
2598
|
}
|
|
2519
2599
|
|
|
2600
|
+
// If we had asked the phone to resend this one, it has answered.
|
|
2601
|
+
this._resolvePlaceholderResend(id);
|
|
2602
|
+
|
|
2520
2603
|
this.emit('message', { id, from, participant, ts, decoded, node });
|
|
2521
2604
|
this._sendReadReceipt(id, fromRaw, partRaw);
|
|
2522
2605
|
|
|
@@ -3053,6 +3136,29 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3053
3136
|
|
|
3054
3137
|
if (!this._socket || !this._connected) return;
|
|
3055
3138
|
|
|
3139
|
+
// Ask the phone as well, for the first couple of rounds.
|
|
3140
|
+
//
|
|
3141
|
+
// The retry receipt asks the *sender* to encrypt it again, which only helps
|
|
3142
|
+
// when their session is the thing that went wrong. When ours is — a spent
|
|
3143
|
+
// pre-key, a ratchet that moved on — no amount of asking them helps, and
|
|
3144
|
+
// the plaintext is sitting on our own phone the whole time. The reference
|
|
3145
|
+
// client asks both, and gives up on the phone after two rounds.
|
|
3146
|
+
//
|
|
3147
|
+
// Deliberately not awaited: a retry receipt must go out now, and this is a
|
|
3148
|
+
// slower second route to the same message.
|
|
3149
|
+
if (count <= 2) {
|
|
3150
|
+
const attrs = (origNode && origNode.attrs) || {};
|
|
3151
|
+
const from = attrs.from ? String(attrs.from) : null;
|
|
3152
|
+
if (from) {
|
|
3153
|
+
this.requestPlaceholderResend({
|
|
3154
|
+
remoteJid: from,
|
|
3155
|
+
fromMe: false,
|
|
3156
|
+
id: msgId
|
|
3157
|
+
}).catch(err =>
|
|
3158
|
+
_whaDbg('[DBG] PLACEHOLDER request failed: ' + (err && err.message)));
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3056
3162
|
// Pick a prekey that is not already advertised by another unresolved retry.
|
|
3057
3163
|
// A prekey is deleted the moment a pkmsg using it is decrypted, so handing
|
|
3058
3164
|
// the same one to two senders means the second arrives after the key is
|
|
@@ -3839,6 +3945,213 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3839
3945
|
this._ensureServerPreKeys('server asked');
|
|
3840
3946
|
}
|
|
3841
3947
|
|
|
3948
|
+
// Outstanding resend requests, with an hour's memory and a ceiling.
|
|
3949
|
+
//
|
|
3950
|
+
// A plain Map would hold every id the session ever failed to decrypt. The TTL
|
|
3951
|
+
// is what the reference client uses; the ceiling is what keeps a burst of
|
|
3952
|
+
// undecryptable stanzas from growing it without bound, dropping the oldest
|
|
3953
|
+
// entries first — the ones whose request has already timed out anyway.
|
|
3954
|
+
get _placeholderResendCache() {
|
|
3955
|
+
const map = this.__placeholderResend;
|
|
3956
|
+
const now = Date.now();
|
|
3957
|
+
return {
|
|
3958
|
+
has: (id) => {
|
|
3959
|
+
const e = map.get(id);
|
|
3960
|
+
if (!e) return false;
|
|
3961
|
+
if (Date.now() - e.at > PLACEHOLDER_TTL_MS) { map.delete(id); return false; }
|
|
3962
|
+
return true;
|
|
3963
|
+
},
|
|
3964
|
+
get: (id) => {
|
|
3965
|
+
const e = map.get(id);
|
|
3966
|
+
if (!e) return undefined;
|
|
3967
|
+
if (Date.now() - e.at > PLACEHOLDER_TTL_MS) { map.delete(id); return undefined; }
|
|
3968
|
+
return e.value;
|
|
3969
|
+
},
|
|
3970
|
+
set: (id, value) => {
|
|
3971
|
+
map.set(id, { value, at: now });
|
|
3972
|
+
// Map keeps insertion order, so the first key is the oldest.
|
|
3973
|
+
while (map.size > PLACEHOLDER_MAX) map.delete(map.keys().next().value);
|
|
3974
|
+
},
|
|
3975
|
+
delete: (id) => map.delete(id),
|
|
3976
|
+
get size() { return map.size; }
|
|
3977
|
+
};
|
|
3978
|
+
}
|
|
3979
|
+
|
|
3980
|
+
// ─── Peer data operations, and asking the phone to resend ─────────────────
|
|
3981
|
+
//
|
|
3982
|
+
// A peer-data-operation request is how a companion asks its own phone for
|
|
3983
|
+
// something. It goes out as a ProtocolMessage addressed to this account, with
|
|
3984
|
+
// the stanza marked category="peer" so the server routes it to the other
|
|
3985
|
+
// devices instead of delivering it as a chat message to yourself.
|
|
3986
|
+
//
|
|
3987
|
+
// @param {object} pdo see encodePeerDataOperationRequestMessage
|
|
3988
|
+
// @returns {Promise<string>} the id of the message that carried the request
|
|
3989
|
+
async sendPeerDataOperationMessage(pdo) {
|
|
3990
|
+
if (!this._sender || !this._connected) throw new Error('Not connected');
|
|
3991
|
+
const me = this._store && this._store.me && this._store.me.id;
|
|
3992
|
+
if (!me) throw new Error('Not authenticated');
|
|
3993
|
+
|
|
3994
|
+
const {
|
|
3995
|
+
encodeMessage, encodeProtocolMessage,
|
|
3996
|
+
encodePeerDataOperationRequestMessage,
|
|
3997
|
+
PROTOCOL_TYPE_PEER_DATA_OPERATION_REQUEST
|
|
3998
|
+
} = require('./proto/MessageProto');
|
|
3999
|
+
|
|
4000
|
+
const payload = encodeProtocolMessage({
|
|
4001
|
+
type: PROTOCOL_TYPE_PEER_DATA_OPERATION_REQUEST,
|
|
4002
|
+
peerDataOperationRequestMessage: encodePeerDataOperationRequestMessage(pdo)
|
|
4003
|
+
});
|
|
4004
|
+
const msgBuf = encodeMessage('protocol', payload);
|
|
4005
|
+
const msgId = this._genMsgId();
|
|
4006
|
+
|
|
4007
|
+
await this._sender._sendMessage(String(me), msgId, msgBuf, 'text', {
|
|
4008
|
+
_protocol: true, // not a reach-out; keeps it off the tcToken gate
|
|
4009
|
+
category: 'peer',
|
|
4010
|
+
pushPriority: 'high_force',
|
|
4011
|
+
additionalNodes: [new BinaryNode('meta', { appdata: 'default' }, null)]
|
|
4012
|
+
});
|
|
4013
|
+
return msgId;
|
|
4014
|
+
}
|
|
4015
|
+
|
|
4016
|
+
/**
|
|
4017
|
+
* Ask the phone to send a message again.
|
|
4018
|
+
*
|
|
4019
|
+
* A message that cannot be decrypted — a session that moved on, a key that
|
|
4020
|
+
* was already spent — is gone as far as this device is concerned. The phone
|
|
4021
|
+
* still has the plaintext, so the fix is to ask it, not to retry the
|
|
4022
|
+
* ciphertext.
|
|
4023
|
+
*
|
|
4024
|
+
* The cache is what stops that turning into a storm. One request per message
|
|
4025
|
+
* id, and the entry is what says a request is outstanding:
|
|
4026
|
+
*
|
|
4027
|
+
* • already asked → say nothing, return
|
|
4028
|
+
* • it arrives within 2s → the entry is gone, answer 'RESOLVED'
|
|
4029
|
+
* • no answer after 8s → drop the entry so a later attempt may ask
|
|
4030
|
+
*
|
|
4031
|
+
* @param {{remoteJid: string, fromMe: boolean, id: string}} messageKey
|
|
4032
|
+
* @param {object} [msgData] metadata to keep for whatever handles the reply
|
|
4033
|
+
* @returns {Promise<string|undefined|'RESOLVED'>}
|
|
4034
|
+
*/
|
|
4035
|
+
async requestPlaceholderResend(messageKey, msgData) {
|
|
4036
|
+
if (!messageKey || !messageKey.id) return;
|
|
4037
|
+
const me = this._store && this._store.me && this._store.me.id;
|
|
4038
|
+
if (!me) throw new Error('Not authenticated');
|
|
4039
|
+
|
|
4040
|
+
const cache = this._placeholderResendCache;
|
|
4041
|
+
if (cache.has(messageKey.id)) {
|
|
4042
|
+
_whaDbg('[DBG] PLACEHOLDER already requested for ' + messageKey.id);
|
|
4043
|
+
return;
|
|
4044
|
+
}
|
|
4045
|
+
cache.set(messageKey.id, msgData || true);
|
|
4046
|
+
|
|
4047
|
+
// Give it a moment. Most of these resolve on their own — the message that
|
|
4048
|
+
// failed to decrypt is very often followed by the sender's own retry.
|
|
4049
|
+
await new Promise(r => setTimeout(r, PLACEHOLDER_SETTLE_MS));
|
|
4050
|
+
if (!cache.has(messageKey.id)) {
|
|
4051
|
+
_whaDbg('[DBG] PLACEHOLDER ' + messageKey.id + ' arrived before asking');
|
|
4052
|
+
return 'RESOLVED';
|
|
4053
|
+
}
|
|
4054
|
+
|
|
4055
|
+
// The phone may simply be off. Let the entry go so this is not the last
|
|
4056
|
+
// word on the subject.
|
|
4057
|
+
setTimeout(() => {
|
|
4058
|
+
if (cache.has(messageKey.id)) {
|
|
4059
|
+
_whaDbg('[DBG] PLACEHOLDER no answer for ' + messageKey.id + ' — phone likely offline');
|
|
4060
|
+
cache.delete(messageKey.id);
|
|
4061
|
+
}
|
|
4062
|
+
}, PLACEHOLDER_TIMEOUT_MS).unref?.();
|
|
4063
|
+
|
|
4064
|
+
const { PeerDataOperationRequestType } = require('./proto/MessageProto');
|
|
4065
|
+
_whaDbg('[DBG] PLACEHOLDER requesting resend of ' + messageKey.id);
|
|
4066
|
+
return this.sendPeerDataOperationMessage({
|
|
4067
|
+
peerDataOperationRequestType: PeerDataOperationRequestType.PLACEHOLDER_MESSAGE_RESEND,
|
|
4068
|
+
placeholderMessageResendRequest: [{ messageKey }]
|
|
4069
|
+
});
|
|
4070
|
+
}
|
|
4071
|
+
|
|
4072
|
+
/**
|
|
4073
|
+
* Ask the phone for older messages in a chat.
|
|
4074
|
+
*
|
|
4075
|
+
* The same channel, a different operation — this is what an on-demand
|
|
4076
|
+
* history sync is built on.
|
|
4077
|
+
*/
|
|
4078
|
+
async fetchMessageHistory(count, oldestMsgKey, oldestMsgTimestampMs) {
|
|
4079
|
+
const { PeerDataOperationRequestType } = require('./proto/MessageProto');
|
|
4080
|
+
return this.sendPeerDataOperationMessage({
|
|
4081
|
+
peerDataOperationRequestType: PeerDataOperationRequestType.HISTORY_SYNC_ON_DEMAND,
|
|
4082
|
+
historySyncOnDemandRequest: {
|
|
4083
|
+
chatJid: oldestMsgKey.remoteJid,
|
|
4084
|
+
oldestMsgId: oldestMsgKey.id,
|
|
4085
|
+
oldestMsgFromMe: !!oldestMsgKey.fromMe,
|
|
4086
|
+
onDemandMsgCount: count,
|
|
4087
|
+
oldestMsgTimestampMs: oldestMsgTimestampMs
|
|
4088
|
+
}
|
|
4089
|
+
});
|
|
4090
|
+
}
|
|
4091
|
+
|
|
4092
|
+
/** Note that a message we asked about has turned up, so nothing is owed. */
|
|
4093
|
+
_resolvePlaceholderResend(msgId) {
|
|
4094
|
+
if (msgId && this._placeholderResendCache.has(msgId)) {
|
|
4095
|
+
this._placeholderResendCache.delete(msgId);
|
|
4096
|
+
_whaDbg('[DBG] PLACEHOLDER resolved ' + msgId);
|
|
4097
|
+
}
|
|
4098
|
+
}
|
|
4099
|
+
|
|
4100
|
+
// ─── WAM — the stats channel WhatsApp Web reports on ──────────────────────
|
|
4101
|
+
//
|
|
4102
|
+
// A WAM buffer is a batch of telemetry events encoded in WhatsApp's own
|
|
4103
|
+
// binary format and posted to `w:stats`. The web client sends these
|
|
4104
|
+
// constantly; a companion session that never does is one more thing that
|
|
4105
|
+
// distinguishes it from a browser.
|
|
4106
|
+
//
|
|
4107
|
+
// Nothing here fires on its own, and that matches the reference client's
|
|
4108
|
+
// library exactly — it builds the same three pieces, exposes the same
|
|
4109
|
+
// `wamBuffer` and the same `sendWAMBuffer`, and never calls the latter
|
|
4110
|
+
// itself. What to report, and when, is the application's decision.
|
|
4111
|
+
//
|
|
4112
|
+
// const { WAM } = require('whalibmob')
|
|
4113
|
+
//
|
|
4114
|
+
// client.wamBuffer.sequence = 1
|
|
4115
|
+
// client.wamBuffer.events = [{
|
|
4116
|
+
// WamDroppedEvent: {
|
|
4117
|
+
// props: { droppedEventCode: 3, droppedEventCount: 1, isFromWamsys: true },
|
|
4118
|
+
// globals: {}
|
|
4119
|
+
// }
|
|
4120
|
+
// }]
|
|
4121
|
+
// await client.sendWAMBuffer(WAM.encodeWAM(client.wamBuffer))
|
|
4122
|
+
//
|
|
4123
|
+
// The event and field names come from WAM.WEB_EVENTS and WAM.WEB_GLOBALS,
|
|
4124
|
+
// which are the reference client's tables verbatim.
|
|
4125
|
+
|
|
4126
|
+
/** The buffer an application fills in before encoding. */
|
|
4127
|
+
get wamBuffer() {
|
|
4128
|
+
if (!this._wamBuffer) {
|
|
4129
|
+
const { BinaryInfo } = require('./WAM');
|
|
4130
|
+
this._wamBuffer = new BinaryInfo();
|
|
4131
|
+
}
|
|
4132
|
+
return this._wamBuffer;
|
|
4133
|
+
}
|
|
4134
|
+
|
|
4135
|
+
/**
|
|
4136
|
+
* Post an encoded WAM buffer to the server.
|
|
4137
|
+
*
|
|
4138
|
+
* @param {Buffer} wamBuffer output of WAM.encodeWAM()
|
|
4139
|
+
* @returns {Promise<object|null>} the server's IQ result, or null on timeout
|
|
4140
|
+
*/
|
|
4141
|
+
async sendWAMBuffer(wamBuffer) {
|
|
4142
|
+
if (!this._socket || !this._connected) throw new Error('Not connected');
|
|
4143
|
+
if (!Buffer.isBuffer(wamBuffer)) {
|
|
4144
|
+
throw new Error('sendWAMBuffer expects a Buffer from WAM.encodeWAM()');
|
|
4145
|
+
}
|
|
4146
|
+
return this._sendIq(new BinaryNode('iq', {
|
|
4147
|
+
id: this._genMsgId(),
|
|
4148
|
+
to: 's.whatsapp.net',
|
|
4149
|
+
xmlns: 'w:stats'
|
|
4150
|
+
}, [
|
|
4151
|
+
new BinaryNode('add', { t: String(Math.round(Date.now() / 1000)) }, wamBuffer)
|
|
4152
|
+
]));
|
|
4153
|
+
}
|
|
4154
|
+
|
|
3842
4155
|
// ─── IQ helper (send + await response) ────────────────────────────────────
|
|
3843
4156
|
|
|
3844
4157
|
_sendIq(node) {
|
|
@@ -52,9 +52,17 @@ const HistorySyncType = {
|
|
|
52
52
|
RECENT: 3,
|
|
53
53
|
PUSH_NAME: 4,
|
|
54
54
|
NON_BLOCKING_DATA: 5,
|
|
55
|
-
ON_DEMAND: 6
|
|
55
|
+
ON_DEMAND: 6,
|
|
56
|
+
// The server says these two outright rather than sending a payload, so a
|
|
57
|
+
// chunk of either kind has nothing to download and is not an error.
|
|
58
|
+
NO_HISTORY: 7,
|
|
59
|
+
MESSAGE_ACCESS_STATUS: 8
|
|
56
60
|
};
|
|
57
61
|
|
|
62
|
+
// Sync types that never carry a blob. Asking for one is the server telling us
|
|
63
|
+
// there is nothing to fetch, not a chunk we failed to read.
|
|
64
|
+
const PAYLOADLESS_SYNC_TYPES = new Set([7, 8]);
|
|
65
|
+
|
|
58
66
|
const HistorySyncTypeName = Object.fromEntries(
|
|
59
67
|
Object.entries(HistorySyncType).map(([k, v]) => [v, k])
|
|
60
68
|
);
|
|
@@ -151,7 +159,24 @@ function decodeHistorySyncNotification(buf) {
|
|
|
151
159
|
chunkOrder: typeof f[7] === 'number' ? f[7] : 0,
|
|
152
160
|
originalMessageId: _str(f[8]),
|
|
153
161
|
progress: typeof f[9] === 'number' ? f[9] : 0,
|
|
154
|
-
|
|
162
|
+
oldestMsgInChunkTimestampSec: _uint64(f[10]),
|
|
163
|
+
// Field 11, not 10.
|
|
164
|
+
//
|
|
165
|
+
// Ten is oldestMsgInChunkTimestampSec — a varint — so reading the inline
|
|
166
|
+
// payload from it never produced a Buffer, and the check that guards the
|
|
167
|
+
// inline path (`payload && payload.length > 0`) was therefore never true.
|
|
168
|
+
// Every chunk fell through to the download branch instead.
|
|
169
|
+
//
|
|
170
|
+
// That is fatal for exactly the chunk that matters. WhatsApp sends the
|
|
171
|
+
// initial bootstrap — the one carrying the conversations, and with them
|
|
172
|
+
// every contact's trusted-contact token — with its payload inline and no
|
|
173
|
+
// directPath at all. The download branch has nothing to download, throws
|
|
174
|
+
// 'missing directPath or mediaKey', and the whole history is lost. What
|
|
175
|
+
// survived was the chunks that do have a directPath, which are the ones
|
|
176
|
+
// with no conversations in them.
|
|
177
|
+
initialHistBootstrapInlinePayload: Buffer.isBuffer(f[11]) ? f[11] : null,
|
|
178
|
+
peerDataRequestSessionId: _str(f[12]),
|
|
179
|
+
encHandle: _str(f[14])
|
|
155
180
|
};
|
|
156
181
|
} catch (e) {
|
|
157
182
|
return null;
|
|
@@ -359,8 +384,12 @@ function decodeHistorySync(buf) {
|
|
|
359
384
|
try {
|
|
360
385
|
const f = _decodeFields(buf);
|
|
361
386
|
|
|
362
|
-
|
|
363
|
-
|
|
387
|
+
// Five is chunkOrder and six is progress — this read five as the progress
|
|
388
|
+
// and never looked at six, so every chunk reported its ordinal as a
|
|
389
|
+
// percentage.
|
|
390
|
+
const syncType = typeof f[1] === 'number' ? f[1] : 0;
|
|
391
|
+
const chunkOrder = typeof f[5] === 'number' ? f[5] : 0;
|
|
392
|
+
const progress = typeof f[6] === 'number' ? f[6] : 0;
|
|
364
393
|
|
|
365
394
|
const conversations = [];
|
|
366
395
|
if (f[2]) {
|
|
@@ -373,9 +402,12 @@ function decodeHistorySync(buf) {
|
|
|
373
402
|
}
|
|
374
403
|
}
|
|
375
404
|
|
|
405
|
+
// Pushnames are field 7. Four is not a field of HistorySync at all, so this
|
|
406
|
+
// read undefined every time and a PUSH_NAME sync — whose entire content is
|
|
407
|
+
// this list — always arrived empty.
|
|
376
408
|
const pushnames = [];
|
|
377
|
-
if (f[
|
|
378
|
-
const pnBufs = Array.isArray(f[
|
|
409
|
+
if (f[7]) {
|
|
410
|
+
const pnBufs = Array.isArray(f[7]) ? f[7] : [f[7]];
|
|
379
411
|
for (const pb of pnBufs) {
|
|
380
412
|
if (Buffer.isBuffer(pb)) {
|
|
381
413
|
const p = decodePushName(pb);
|
|
@@ -384,9 +416,13 @@ function decodeHistorySync(buf) {
|
|
|
384
416
|
}
|
|
385
417
|
}
|
|
386
418
|
|
|
419
|
+
// phoneNumberToLidMappings is field 15. Ten is threadDsTimeframeOffset, a
|
|
420
|
+
// uint32, so the loop below was handed a number, found it was not a Buffer
|
|
421
|
+
// and skipped it — the mappings history carries were never read, and every
|
|
422
|
+
// LID had to be learnt again from live traffic.
|
|
387
423
|
const lidPnMappings = [];
|
|
388
|
-
if (f[
|
|
389
|
-
const mapBufs = Array.isArray(f[
|
|
424
|
+
if (f[15]) {
|
|
425
|
+
const mapBufs = Array.isArray(f[15]) ? f[15] : [f[15]];
|
|
390
426
|
for (const mb of mapBufs) {
|
|
391
427
|
if (Buffer.isBuffer(mb)) {
|
|
392
428
|
const m = decodeLidPnMapping(mb);
|
|
@@ -395,7 +431,7 @@ function decodeHistorySync(buf) {
|
|
|
395
431
|
}
|
|
396
432
|
}
|
|
397
433
|
|
|
398
|
-
return { syncType, progress, conversations, pushnames, lidPnMappings };
|
|
434
|
+
return { syncType, progress, chunkOrder, conversations, pushnames, lidPnMappings };
|
|
399
435
|
} catch (e) {
|
|
400
436
|
return null;
|
|
401
437
|
}
|
|
@@ -679,9 +715,21 @@ async function processHistorySyncNotification(notification, sessionDir, phone) {
|
|
|
679
715
|
notification.initialHistBootstrapInlinePayload.length > 0) {
|
|
680
716
|
// Inline payload — no network call needed (zlib-compressed HistorySync proto)
|
|
681
717
|
decompressed = await inflateBuffer(notification.initialHistBootstrapInlinePayload);
|
|
718
|
+
} else if (PAYLOADLESS_SYNC_TYPES.has(notification.syncType)) {
|
|
719
|
+
// NO_HISTORY and MESSAGE_ACCESS_STATUS are statements, not chunks. There is
|
|
720
|
+
// nothing to fetch and nothing went wrong, so answer with an empty result
|
|
721
|
+
// rather than raising an error the caller can only log.
|
|
722
|
+
return {
|
|
723
|
+
syncType: notification.syncType,
|
|
724
|
+
syncTypeName: typeName,
|
|
725
|
+
progress: notification.progress || 0,
|
|
726
|
+
chunkOrder: notification.chunkOrder || 0,
|
|
727
|
+
chats: [], contacts: [], pushNames: [], lidPnMappings: [], merged: null
|
|
728
|
+
};
|
|
682
729
|
} else {
|
|
683
730
|
if (!notification.directPath || !notification.mediaKey) {
|
|
684
|
-
throw new Error('HistorySyncNotification missing directPath or mediaKey'
|
|
731
|
+
throw new Error('HistorySyncNotification missing directPath or mediaKey' +
|
|
732
|
+
' (syncType=' + typeName + ')');
|
|
685
733
|
}
|
|
686
734
|
|
|
687
735
|
const cdnUrl = 'https://' + CDN_HOST + notification.directPath;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The backlog the server flushes on reconnect, processed at a pace that leaves
|
|
4
|
+
// the process usable.
|
|
5
|
+
//
|
|
6
|
+
// Everything that arrived while this session was offline is delivered in one
|
|
7
|
+
// burst the moment it reconnects — messages, receipts, notifications, calls,
|
|
8
|
+
// stamped `offline` so a client can tell them from live traffic. On an account
|
|
9
|
+
// that has been away for a day that burst is hundreds of stanzas.
|
|
10
|
+
//
|
|
11
|
+
// Handled the way live traffic is handled, they all start at once: hundreds of
|
|
12
|
+
// decryptions, disk writes and event emissions racing each other with nothing
|
|
13
|
+
// between them, and the event loop never gets a turn. Nothing else in the
|
|
14
|
+
// process runs until the last one finishes — no timers, no keepalive, no socket
|
|
15
|
+
// reads. And one handler that throws takes the rest of the burst with it.
|
|
16
|
+
//
|
|
17
|
+
// So the backlog gets a queue of its own:
|
|
18
|
+
//
|
|
19
|
+
// • one stanza at a time, in the order the server sent them
|
|
20
|
+
// • the event loop gets a turn after every batch, so timers and the socket
|
|
21
|
+
// keep running while the backlog drains
|
|
22
|
+
// • a handler that throws is reported and the queue carries on — a single bad
|
|
23
|
+
// stanza costs one stanza, not the rest of the burst
|
|
24
|
+
// • a socket that closes mid-drain stops the queue rather than working
|
|
25
|
+
// through a backlog nobody can answer
|
|
26
|
+
//
|
|
27
|
+
// Live traffic does not come through here. It has no `offline` attribute and
|
|
28
|
+
// goes straight to its handler, exactly as before.
|
|
29
|
+
|
|
30
|
+
// How many stanzas to handle before giving the event loop a turn. The reference
|
|
31
|
+
// client uses ten; more starves the loop, fewer spends the whole drain in
|
|
32
|
+
// setImmediate round trips.
|
|
33
|
+
const DEFAULT_BATCH_SIZE = 10;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {Map<string, (node) => Promise<void>|void>} handlers stanza tag → handler
|
|
37
|
+
* @param {object} deps
|
|
38
|
+
* @param {() => boolean} deps.isOpen whether the socket is still up
|
|
39
|
+
* @param {(err, ctx) => void} deps.onError called for anything a handler throws
|
|
40
|
+
* @param {() => Promise<void>} [deps.yield] how to give the loop a turn
|
|
41
|
+
* @param {number} [batchSize]
|
|
42
|
+
*/
|
|
43
|
+
function makeOfflineNodeProcessor(handlers, deps, batchSize) {
|
|
44
|
+
const size = batchSize > 0 ? batchSize : DEFAULT_BATCH_SIZE;
|
|
45
|
+
const pending = [];
|
|
46
|
+
let draining = false;
|
|
47
|
+
|
|
48
|
+
const yieldToLoop = (deps && deps.yield) ||
|
|
49
|
+
(() => new Promise(resolve => setImmediate(resolve)));
|
|
50
|
+
|
|
51
|
+
async function drain() {
|
|
52
|
+
let inBatch = 0;
|
|
53
|
+
while (pending.length && deps.isOpen()) {
|
|
54
|
+
const { tag, node } = pending.shift();
|
|
55
|
+
const handler = handlers.get(tag);
|
|
56
|
+
|
|
57
|
+
if (!handler) {
|
|
58
|
+
deps.onError(new Error('unknown offline stanza: ' + tag), 'offline ' + tag);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
await handler(node);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
// Reported, not rethrown: the rest of the backlog is still worth having.
|
|
66
|
+
deps.onError(err, 'offline ' + tag);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (++inBatch >= size) {
|
|
70
|
+
inBatch = 0;
|
|
71
|
+
await yieldToLoop();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
draining = false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
/** Put one stanza on the queue, and start draining if nothing else is. */
|
|
79
|
+
enqueue(tag, node) {
|
|
80
|
+
pending.push({ tag, node });
|
|
81
|
+
if (draining) return;
|
|
82
|
+
draining = true;
|
|
83
|
+
drain().catch(err => {
|
|
84
|
+
draining = false;
|
|
85
|
+
deps.onError(err, 'draining offline backlog');
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
/** How many stanzas are still waiting. */
|
|
90
|
+
get size() { return pending.length; },
|
|
91
|
+
|
|
92
|
+
/** Whether the queue is working through a backlog right now. */
|
|
93
|
+
get draining() { return draining; },
|
|
94
|
+
|
|
95
|
+
/** Throw the backlog away — used when the session is torn down. */
|
|
96
|
+
clear() { pending.length = 0; }
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { makeOfflineNodeProcessor, DEFAULT_BATCH_SIZE };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// What an outgoing WAM buffer is assembled from.
|
|
4
|
+
//
|
|
5
|
+
// Identical to Baileys' BinaryInfo: a protocol version, a sequence number that
|
|
6
|
+
// the server uses to order buffers, the events queued for the next flush, and
|
|
7
|
+
// the scratch list the encoder fills with byte chunks.
|
|
8
|
+
|
|
9
|
+
class BinaryInfo {
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.protocolVersion = 5;
|
|
12
|
+
this.sequence = 0;
|
|
13
|
+
this.events = [];
|
|
14
|
+
this.buffer = [];
|
|
15
|
+
Object.assign(this, options);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { BinaryInfo };
|