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 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
- const id = ((Date.now() + 3 * 24 * 60 * 60 * 1000) % WEEK_MS).toString();
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
- initialHistBootstrapInlinePayload: f[10] || null
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
- const syncType = typeof f[1] === 'number' ? f[1] : 0;
363
- const progress = typeof f[5] === 'number' ? f[5] : 0;
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[4]) {
378
- const pnBufs = Array.isArray(f[4]) ? f[4] : [f[4]];
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[10]) {
389
- const mapBufs = Array.isArray(f[10]) ? f[10] : [f[10]];
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 };