whalibmob 5.25.0 → 5.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts CHANGED
@@ -665,7 +665,7 @@ export interface WhalibmobEvents {
665
665
  privacy_settings: (p: { changes: any; settings: PrivacySettings }) => void;
666
666
 
667
667
  app_state_sync: (r: AppStateSyncResult) => void;
668
- app_state_mutation: (m: { collection: string; index: any; action: any; removed: boolean }) => void;
668
+ app_state_mutation: (m: { collection: string; index: any; action: any; removed: boolean; version?: number }) => void;
669
669
  app_state_key_missing: (m: { collection: string; keyId: string }) => void;
670
670
  app_state_keys: (m: { keys: any[] }) => void;
671
671
 
@@ -821,6 +821,15 @@ export declare class WhalibmobClient extends EventEmitter {
821
821
  syncAppState(names?: string[] | null, opts?: { snapshot?: boolean }): Promise<AppStateSyncResult>;
822
822
  /** Whether an app-state key is available — `false` on an SMS session with no companion. */
823
823
  canSyncAppState(): boolean;
824
+ /**
825
+ * Ask the primary device for app-state keys it never shared.
826
+ *
827
+ * Sent automatically when a sync runs into a key it does not have, so calling
828
+ * it is rarely necessary. Ids may be base64 or hex. The same id is not asked
829
+ * for twice within 24 hours; resolves to `null` when everything passed was
830
+ * held down, or when there is no connection to send it over.
831
+ */
832
+ requestAppStateKeys(keyIds: string[]): Promise<string | null>;
824
833
 
825
834
  // ─── Account restriction ─────────────────────────────────────────────────
826
835
  fetchReachoutTimelock(): Promise<ReachoutTimelockState>;
package/lib/Client.js CHANGED
@@ -79,6 +79,17 @@ const MEX_QUERY_REACHOUT_TIMELOCK = '23983697327930364';
79
79
  // server takes a longer one, answers <iq type="result"/> and keeps the old text.
80
80
  const ABOUT_MAX_LENGTH = 139;
81
81
 
82
+ // How many times a single syncAppState call will go back for more. A busy
83
+ // account really does need several rounds, so this is high enough not to cut a
84
+ // genuine sync short; it exists only so a server that never stops saying
85
+ // has_more_patches cannot keep us here indefinitely.
86
+ const MAX_APP_STATE_ROUNDS = 30;
87
+
88
+ // How long to wait before asking the primary device for the same app-state key
89
+ // again. A key it has not shared is usually one it will not share; asking on
90
+ // every reconnect would be a message to your own phone every few seconds.
91
+ const APP_STATE_KEY_REQUEST_INTERVAL_MS = 24 * 60 * 60 * 1000;
92
+
82
93
  // How long the first message to a contact waits for its trusted-contact token.
83
94
  // One round-trip to the server, no more: past this the message goes out without
84
95
  // a token rather than sitting in the queue, and the issuance finishes in the
@@ -1999,11 +2010,13 @@ class WhalibmobClient extends EventEmitter {
1999
2010
  const nodes = wanted.map(name => {
2000
2011
  const st = this._appState.get(name);
2001
2012
  const wantSnapshot = !!opts.snapshot || !st.version;
2002
- return new BinaryNode('collection', {
2003
- name,
2004
- version: String(wantSnapshot ? 0 : st.version),
2005
- return_snapshot: wantSnapshot ? 'true' : 'false'
2006
- }, null);
2013
+ // Asking for a snapshot means asking for the collection from nothing, so
2014
+ // there is no version to ask from. The attribute is left off entirely
2015
+ // rather than sent as 0 — that is what the official client puts on the
2016
+ // wire, and a version on a snapshot request is a contradiction.
2017
+ const attrs = { name, return_snapshot: wantSnapshot ? 'true' : 'false' };
2018
+ if (!wantSnapshot) attrs.version = String(st.version);
2019
+ return new BinaryNode('collection', attrs, null);
2007
2020
  });
2008
2021
 
2009
2022
  const resp = await this._sendIq(new BinaryNode('iq', {
@@ -2043,6 +2056,79 @@ class WhalibmobClient extends EventEmitter {
2043
2056
  return out;
2044
2057
  }
2045
2058
 
2059
+ /**
2060
+ * Ask the primary device for app-state keys it never shared.
2061
+ *
2062
+ * App-state mutations are encrypted under keys the phone hands out in an
2063
+ * encrypted message of its own. Miss that message — linked while the phone
2064
+ * was off, a session that had to be rebuilt — and every patch signed with
2065
+ * that key is unreadable, and stays unreadable: the key is not re-offered,
2066
+ * and nothing later in the collection can be decoded past it. The request
2067
+ * goes out as a peer message, the same channel a placeholder resend uses, so
2068
+ * the server routes it to your own devices rather than delivering it as a
2069
+ * chat message to yourself.
2070
+ *
2071
+ * Asking twice for the same key inside a day achieves nothing, so each id is
2072
+ * remembered and held down for that long. This is what whatsmeow does with
2073
+ * `requestAppStateKeys`, and for the same reason.
2074
+ *
2075
+ * @param {string[]} keyIds base64 (or hex) key ids, as the decoder reports
2076
+ * @returns {Promise<string|null>} the id of the message that carried it
2077
+ */
2078
+ async requestAppStateKeys(keyIds) {
2079
+ if (!Array.isArray(keyIds) || !keyIds.length) return null;
2080
+ if (!this._sender || !this._connected) return null;
2081
+ const me = this._store && this._store.me && this._store.me.id;
2082
+ if (!me) return null;
2083
+
2084
+ this._appStateKeyRequests = this._appStateKeyRequests || new Map();
2085
+ const now = Date.now();
2086
+ const want = [];
2087
+
2088
+ for (const id of keyIds) {
2089
+ if (!id) continue;
2090
+ // The decoder reports base64; a caller with the stored spelling has hex.
2091
+ // Both name the same bytes, so normalise before deduplicating.
2092
+ let raw;
2093
+ try {
2094
+ raw = /^[0-9a-fA-F]+$/.test(id) && id.length % 2 === 0
2095
+ ? Buffer.from(id, 'hex')
2096
+ : Buffer.from(id, 'base64');
2097
+ } catch (_) { continue; }
2098
+ if (!raw || !raw.length) continue;
2099
+
2100
+ const key = raw.toString('base64');
2101
+ const last = this._appStateKeyRequests.get(key);
2102
+ if (last && now - last < APP_STATE_KEY_REQUEST_INTERVAL_MS) continue;
2103
+ this._appStateKeyRequests.set(key, now);
2104
+ want.push(raw);
2105
+ }
2106
+ if (!want.length) return null;
2107
+
2108
+ const {
2109
+ encodeMessage, encodeProtocolMessage, encodeAppStateSyncKeyRequest,
2110
+ PROTOCOL_TYPE_APP_STATE_SYNC_KEY_REQUEST
2111
+ } = require('./proto/MessageProto');
2112
+
2113
+ const payload = encodeProtocolMessage({
2114
+ type: PROTOCOL_TYPE_APP_STATE_SYNC_KEY_REQUEST,
2115
+ appStateSyncKeyRequest: encodeAppStateSyncKeyRequest(want)
2116
+ });
2117
+ const msgBuf = encodeMessage('protocol', payload);
2118
+ const msgId = this._genMsgId();
2119
+
2120
+ _whaDbg('[DBG] APP_STATE_KEY_REQUEST ' +
2121
+ want.map(b => b.toString('hex')).join(', '));
2122
+
2123
+ await this._sender._sendMessage(String(me), msgId, msgBuf, 'text', {
2124
+ _protocol: true, // not a reach-out; keeps it off the tcToken gate
2125
+ category: 'peer',
2126
+ pushPriority: 'high_force',
2127
+ additionalNodes: [new BinaryNode('meta', { appdata: 'default' }, null)]
2128
+ });
2129
+ return msgId;
2130
+ }
2131
+
2046
2132
  async _applyAppStateResponse(resp, opts) {
2047
2133
  const syncNode = findChild(resp, 'sync');
2048
2134
  const cols = (syncNode && Array.isArray(syncNode.content))
@@ -2089,11 +2175,13 @@ class WhalibmobClient extends EventEmitter {
2089
2175
  }
2090
2176
  } catch (err) {
2091
2177
  if (err && err.isMissingKey) {
2092
- // Nothing can be read until the primary shares that key. Leave the
2178
+ // Nothing in this collection can be read until the primary shares
2179
+ // that key, and it will not offer one unasked — so ask, and leave the
2093
2180
  // collection where it is so the next sync picks up from here.
2094
2181
  _whaDbg('[DBG] APP_STATE ' + name + ' waiting for key ' + err.keyId);
2095
2182
  summary[name] = { waitingForKey: err.keyId };
2096
2183
  this.emit('app_state_key_missing', { collection: name, keyId: err.keyId });
2184
+ this.requestAppStateKeys([err.keyId]).catch(() => {});
2097
2185
  continue;
2098
2186
  }
2099
2187
  throw err;
@@ -2123,10 +2211,23 @@ class WhalibmobClient extends EventEmitter {
2123
2211
  if (col.attrs && col.attrs.has_more_patches === 'true') more.push(name);
2124
2212
  }
2125
2213
 
2126
- if (more.length && !opts._retried) {
2127
- const again = await this.syncAppState(more, Object.assign({}, opts, { _retried: true }));
2128
- applied += again.applied;
2129
- Object.assign(summary, again.collections);
2214
+ // The server hands a long collection over in pieces and says so with
2215
+ // has_more_patches; a hash that stopped agreeing puts the collection back
2216
+ // here too, once, as a snapshot. Either way there is more to fetch, and
2217
+ // stopping after one more round would leave the collection half-read
2218
+ // without anything saying so. Keep going until the server stops asking —
2219
+ // with a ceiling, because a server that always says "more" would otherwise
2220
+ // spin here forever.
2221
+ if (more.length) {
2222
+ const rounds = (opts._round || 0) + 1;
2223
+ if (rounds > MAX_APP_STATE_ROUNDS) {
2224
+ _whaDbg('[DBG] APP_STATE giving up after ' + rounds + ' rounds: ' + more.join(', '));
2225
+ } else {
2226
+ const again = await this.syncAppState(more,
2227
+ Object.assign({}, opts, { _round: rounds, snapshot: false }));
2228
+ applied += again.applied;
2229
+ Object.assign(summary, again.collections);
2230
+ }
2130
2231
  }
2131
2232
 
2132
2233
  this.emit('app_state_sync', { collections: summary, applied });
@@ -2254,7 +2355,7 @@ class WhalibmobClient extends EventEmitter {
2254
2355
  // Something the server tracks that this library does not model yet.
2255
2356
  // Reported rather than dropped, so it is visible that it happened.
2256
2357
  this.emit('app_state_mutation', {
2257
- collection, index: m.index, action: a, removed
2358
+ collection, index: m.index, action: a, removed, version: m.version
2258
2359
  });
2259
2360
  break;
2260
2361
  }
@@ -5360,9 +5461,9 @@ class WhalibmobClient extends EventEmitter {
5360
5461
  // Web-mode history lands in its own files. A number can be registered over
5361
5462
  // SMS and separately linked as a companion, and only the companion ever
5362
5463
  // receives history — merging the two would be merging different accounts'
5363
- // views of the same number.
5364
- const phone = (this._store && this._store.phoneNumber) +
5365
- (this._mode === 'web' ? '.web' : '');
5464
+ // views of the same number. Same prefix the readers use.
5465
+ const phone = this._sessionFilePrefix();
5466
+ if (!phone) return;
5366
5467
  const sessionDir = this._sessionDir;
5367
5468
 
5368
5469
  _whaDbg('[DBG] HIST_SYNC start syncType=' + notification.syncType +
@@ -5510,6 +5611,19 @@ class WhalibmobClient extends EventEmitter {
5510
5611
  _whaDbg('[DBG] APP_STATE_KEY_SHARE_SAVE_ERR ' + e.message);
5511
5612
  }
5512
5613
 
5614
+ // A key that has arrived is no longer one we are holding a request down
5615
+ // for; drop the hold so that if it is ever missing again the ask goes out
5616
+ // at once rather than a day later.
5617
+ if (this._appStateKeyRequests) {
5618
+ for (const k of keyShare.keys) {
5619
+ if (!k || !k.keyId) continue;
5620
+ try {
5621
+ this._appStateKeyRequests.delete(
5622
+ Buffer.from(k.keyId, 'hex').toString('base64'));
5623
+ } catch (_) {}
5624
+ }
5625
+ }
5626
+
5513
5627
  this.emit('app_state_keys', { keys: keyShare.keys });
5514
5628
 
5515
5629
  // The key is what app state was waiting on. Anything the server has been
@@ -5566,15 +5680,25 @@ class WhalibmobClient extends EventEmitter {
5566
5680
  }
5567
5681
 
5568
5682
  /**
5569
- * getHistoryStore — load the persisted history store for this phone.
5570
- * Returns the parsed {phone}.history.json content or null if not yet synced.
5683
+ * The name every file of this session is built from.
5571
5684
  *
5572
- * @returns {object|null}
5685
+ * A number can be registered over SMS and separately linked as a companion,
5686
+ * and the two are different accounts' views of it, so the companion's files
5687
+ * carry a `.web` in the middle: 40712345678.web.history.json. Everything that
5688
+ * reads or writes one has to agree on that — a writer that adds the `.web`
5689
+ * and a reader that does not will not error, it will simply never find the
5690
+ * file it just wrote.
5573
5691
  */
5574
- getHistoryStore() {
5692
+ _sessionFilePrefix() {
5575
5693
  if (!this._store || !this._store.phoneNumber) return null;
5694
+ return this._store.phoneNumber + (this._mode === 'web' ? '.web' : '');
5695
+ }
5696
+
5697
+ _readSessionJson(suffix) {
5698
+ const prefix = this._sessionFilePrefix();
5699
+ if (!prefix) return null;
5576
5700
  const fs = require('fs');
5577
- const file = require('path').join(this._sessionDir, this._store.phoneNumber + '.history.json');
5701
+ const file = require('path').join(this._sessionDir, prefix + suffix);
5578
5702
  try {
5579
5703
  if (!fs.existsSync(file)) return null;
5580
5704
  return JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -5582,19 +5706,25 @@ class WhalibmobClient extends EventEmitter {
5582
5706
  }
5583
5707
 
5584
5708
  /**
5585
- * getMessagesStore — load the persisted messages for this phone.
5586
- * Returns the parsed {phone}.messages.json content or null if not yet synced.
5709
+ * getHistoryStore — load the persisted history store for this session.
5710
+ * Returns the parsed {phone}.history.json content — {phone}.web.history.json
5711
+ * on a companion — or null if not yet synced.
5712
+ *
5713
+ * @returns {object|null}
5714
+ */
5715
+ getHistoryStore() {
5716
+ return this._readSessionJson('.history.json');
5717
+ }
5718
+
5719
+ /**
5720
+ * getMessagesStore — load the persisted messages for this session.
5721
+ * Returns the parsed {phone}.messages.json content — {phone}.web.messages.json
5722
+ * on a companion — or null if not yet synced.
5587
5723
  *
5588
5724
  * @returns {object|null}
5589
5725
  */
5590
5726
  getMessagesStore() {
5591
- if (!this._store || !this._store.phoneNumber) return null;
5592
- const fs = require('fs');
5593
- const file = require('path').join(this._sessionDir, this._store.phoneNumber + '.messages.json');
5594
- try {
5595
- if (!fs.existsSync(file)) return null;
5596
- return JSON.parse(fs.readFileSync(file, 'utf8'));
5597
- } catch (_) { return null; }
5727
+ return this._readSessionJson('.messages.json');
5598
5728
  }
5599
5729
 
5600
5730
  // ─── Public API ───────────────────────────────────────────────────────────
@@ -95,8 +95,8 @@ class MissingAppStateKeyError extends Error {
95
95
  * missing key is different — nothing after it can be read either, and the fix
96
96
  * is to wait for the key rather than to carry on — so that one is thrown.
97
97
  */
98
- function decodeMutations(mutations, state, getKey, onMutation, validateMacs) {
99
- const gen = makeGenerator(state);
98
+ function decodeMutations(mutations, state, getKey, onMutation, validateMacs, opts) {
99
+ const gen = makeGenerator(state, opts);
100
100
  const cache = new Map();
101
101
  let skipped = 0;
102
102
 
@@ -145,7 +145,17 @@ function decodeMutations(mutations, state, getKey, onMutation, validateMacs) {
145
145
  try { index = JSON.parse(data.index.toString('utf8')); }
146
146
  catch (_) { skipped++; continue; }
147
147
 
148
- onMutation({ index, action: data.value, operation: m.operation });
148
+ onMutation({
149
+ index,
150
+ action: data.value,
151
+ operation: m.operation,
152
+ // The api version the mutation was written under. The server sends it, so
153
+ // report it: a caller that has to tell an old write from a new one has
154
+ // nothing else to go on.
155
+ version: data.version,
156
+ indexMac: m.indexBlob,
157
+ valueMac: theirMac
158
+ });
149
159
  gen.mix({
150
160
  indexMac: m.indexBlob,
151
161
  valueMac: theirMac,
@@ -168,7 +178,8 @@ function decodeSnapshot(name, snapshot, getKey, getMutation, validateMacs) {
168
178
  const state = newState();
169
179
  state.version = snapshot.version;
170
180
 
171
- const res = decodeMutations(snapshot.records, state, getKey, getMutation, validateMacs);
181
+ const res = decodeMutations(snapshot.records, state, getKey, getMutation,
182
+ validateMacs, { snapshot: true });
172
183
  state.hash = res.hash;
173
184
  state.indexValueMap = res.indexValueMap;
174
185
 
@@ -79,7 +79,15 @@ const PATCH_INTEGRITY = new LTHash(PATCH_SALT);
79
79
  // indexValueMap is index MAC (base64) → { valueMac }. It is the reason a
80
80
  // mutation can be replaced at all: without knowing the value an index used to
81
81
  // carry, there is nothing to subtract back out.
82
- function makeGenerator(state) {
82
+ //
83
+ // `snapshot` turns the subtraction off. A snapshot is the whole collection
84
+ // stated from nothing, so the server computed its hash by adding every record
85
+ // and subtracting none — including two records that happen to share an index.
86
+ // Displacing the first would leave us one value short of the hash the snapshot
87
+ // is signed against, and the collection would be thrown away for a mismatch
88
+ // that was ours.
89
+ function makeGenerator(state, opts) {
90
+ const snapshot = !!(opts && opts.snapshot);
83
91
  const indexValueMap = Object.assign({}, state.indexValueMap || {});
84
92
  const hash = Buffer.isBuffer(state.hash) ? state.hash : Buffer.alloc(HASH_BYTES);
85
93
  const addBufs = [];
@@ -88,7 +96,7 @@ function makeGenerator(state) {
88
96
  return {
89
97
  mix({ indexMac, valueMac, remove }) {
90
98
  const key = Buffer.from(indexMac).toString('base64');
91
- const prev = indexValueMap[key];
99
+ const prev = snapshot ? null : indexValueMap[key];
92
100
  if (remove) {
93
101
  // A remove for something we never had is the server telling us about a
94
102
  // mutation we missed. There is nothing to subtract; the hash will not
@@ -364,18 +364,25 @@ function encodeDeviceSentMessage(destinationJid, messageBuf, phash) {
364
364
  }
365
365
 
366
366
  // ─── ProtocolMessage (field 12 of Message) ───────────────────────────────────
367
- // ProtocolMessage { key=1, type=2, ephemeralExpiration=4, ... }
368
- // Type enum: REVOKE=0, EPHEMERAL_SETTING=3
367
+ // ProtocolMessage { key=1, type=2, ephemeralExpiration=4,
368
+ // appStateSyncKeyShare=6, appStateSyncKeyRequest=8, ... }
369
+ // Type enum: REVOKE=0, EPHEMERAL_SETTING=3, APP_STATE_SYNC_KEY_REQUEST=7
369
370
  const PROTOCOL_MSG_REVOKE = 0;
370
371
  const PROTOCOL_MSG_EPHEMERAL = 3;
371
372
 
373
+ /** ProtocolMessage.Type.APP_STATE_SYNC_KEY_REQUEST */
374
+ const PROTOCOL_TYPE_APP_STATE_SYNC_KEY_REQUEST = 7;
375
+
372
376
  function encodeProtocolMessage(opts) {
373
377
  // opts: { type, key: { remoteJid, fromMe, id }, ephemeralExpiration,
374
- // peerDataOperationRequestMessage }
378
+ // appStateSyncKeyRequest, peerDataOperationRequestMessage }
375
379
  const parts = [];
376
380
  if (opts.key) parts.push(field(1, WIRE_LEN, encodeMessageKey(opts.key)));
377
381
  if (opts.type !== undefined) parts.push(field(2, WIRE_VARINT, varint(opts.type)));
378
382
  if (opts.ephemeralExpiration !== undefined) parts.push(field(4, WIRE_VARINT, varint(opts.ephemeralExpiration)));
383
+ if (opts.appStateSyncKeyRequest) {
384
+ parts.push(field(8, WIRE_LEN, opts.appStateSyncKeyRequest));
385
+ }
379
386
  // Field 16, and the Type enum value that goes with it is also 16 — a
380
387
  // coincidence in the schema, not a mistake here.
381
388
  if (opts.peerDataOperationRequestMessage) {
@@ -384,6 +391,21 @@ function encodeProtocolMessage(opts) {
384
391
  return Buffer.concat(parts);
385
392
  }
386
393
 
394
+ // ─── AppStateSyncKeyRequest ──────────────────────────────────────────────────
395
+ //
396
+ // Asking the primary device for an app-state key it never shared. Mutations are
397
+ // encrypted under a key the phone hands out over an encrypted message; if that
398
+ // message was missed, every patch signed with that key is unreadable and stays
399
+ // unreadable — nothing later in the collection can be decoded either. The only
400
+ // way out is to ask, which is what this carries.
401
+ //
402
+ // AppStateSyncKeyRequest { keyIds = 1 (repeated) }
403
+ // AppStateSyncKeyId { keyId = 1 }
404
+ function encodeAppStateSyncKeyRequest(keyIds) {
405
+ return Buffer.concat((keyIds || []).map(
406
+ id => field(1, WIRE_LEN, field(1, WIRE_LEN, bytes(id)))));
407
+ }
408
+
387
409
  // ─── PeerDataOperationRequestMessage ─────────────────────────────────────────
388
410
  //
389
411
  // The channel a companion asks its own phone for something over. It travels as
@@ -980,6 +1002,8 @@ module.exports = {
980
1002
  encodePeerDataOperationRequestMessage,
981
1003
  PeerDataOperationRequestType,
982
1004
  PROTOCOL_TYPE_PEER_DATA_OPERATION_REQUEST,
1005
+ PROTOCOL_TYPE_APP_STATE_SYNC_KEY_REQUEST,
1006
+ encodeAppStateSyncKeyRequest,
983
1007
  encodeExtendedText,
984
1008
  encodeContextInfo,
985
1009
  encodeSenderKeyDistributionMessage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.25.0",
3
+ "version": "5.26.1",
4
4
  "description": "Node.js library for WhatsApp — register a number over SMS, or link as a companion by QR. Signal E2E encryption, media, groups, channels.",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",