whalibmob 5.10.3 → 5.10.6

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 CHANGED
@@ -1449,24 +1449,32 @@ client.on('account_restriction', (r) => {
1449
1449
  > client-side shortens it; the only thing that helps is not sending more
1450
1450
  > unanswered first messages while it is on.
1451
1451
 
1452
- > [!WARNING]
1453
- > **Some servers answer this query in Argo, not JSON.** Argo is Meta's binary
1454
- > GraphQL encoding, and this library cannot read it. When that happens
1455
- > `fetchReachoutTimelock()` throws with `err.mexFormat === 'argo'` and the raw
1456
- > bytes on `err.mexPayload`.
1452
+ > [!NOTE]
1453
+ > **This transport answers in one of two encodings**, and the reply says which:
1454
+ > JSON, or Argo — Meta's compact binary encoding for GraphQL. Both are read.
1455
+ > WhatsApp sends Argo with its self-describing flag set, so it carries its own
1456
+ > field names and decodes without the GraphQL schema the query was written
1457
+ > against.
1457
1458
  >
1458
- > Do not add a `format` attribute to the query in the hope of getting JSON — it
1459
+ > Do not add a `format` attribute to the query in the hope of steering this — it
1459
1460
  > was tried against a live server, which responded by dropping the stanza
1460
- > entirely. An answer in a format you cannot read still beats no answer.
1461
+ > entirely and answering nothing.
1462
+ >
1463
+ > Some servers answer tersely — a bare `false` or `true` rather than the object
1464
+ > with the expiry in it. `false` is taken as "no restriction", because it is a
1465
+ > definite answer rather than a missing one. `true` sets `active` with
1466
+ > `expiryUnknown: true`, since it says there is a restriction but not when it
1467
+ > ends; the countdown then comes from the announcement.
1461
1468
  >
1462
- > It never guesses. Reporting "not restricted" from an answer nobody decoded
1463
- > would have you send more messages and make the restriction longer, so an
1464
- > unreadable reply is an error rather than an all-clear.
1469
+ > An answer that is neither — `null`, or anything that says nothing either way —
1470
+ > throws with the decoded value on `err.mexDecoded`, rather than being reported
1471
+ > as "not restricted". An all-clear invented from an answer that never gave one
1472
+ > would have you send more messages and lengthen the very restriction you were
1473
+ > asking about.
1465
1474
  >
1466
- > The `account_restriction` event is unaffected: the server *announces* a
1467
- > restriction starting and lifting, and those announcements arrive as readable
1468
- > JSON on a different route. Keep a listener attached and you will still be
1469
- > told, with the countdown, without ever calling the query.
1475
+ > The `account_restriction` event does not depend on any of this. The server
1476
+ > *announces* a restriction starting and lifting, and those announcements carry
1477
+ > the countdown. Keep a listener attached and you are told either way.
1470
1478
 
1471
1479
  ### What Is Automatic vs What You Need to Do
1472
1480
 
@@ -2142,7 +2150,11 @@ Both methods accept a **Buffer** (use `fs.readFileSync` to load a file).
2142
2150
  const fs = require('fs')
2143
2151
 
2144
2152
  // change your own profile picture
2145
- await client.changeProfilePicture(fs.readFileSync('./avatar.jpg'))
2153
+ // returns the new picture id, or 'remove' when it was taken down
2154
+ const picId = await client.changeProfilePicture(fs.readFileSync('./avatar.jpg'))
2155
+
2156
+ // pass null to remove it
2157
+ await client.changeProfilePicture(null)
2146
2158
 
2147
2159
  // change a group's picture (you must be admin)
2148
2160
  // returns the new picture id, or 'remove' when the picture was taken down
@@ -2197,6 +2209,32 @@ await client.changePrivacySetting('groups_add', 'contacts')
2197
2209
  await client.changePrivacySetting('call_add', 'known')
2198
2210
  ```
2199
2211
 
2212
+ **Each setting takes its own values**, and they are not interchangeable:
2213
+
2214
+ | setting | accepts |
2215
+ |---|---|
2216
+ | `last_seen`, `profile_picture`, `status`, `groups_add` | `all` · `contacts` · `contact_blacklist` · `none` |
2217
+ | `read_receipts` | `all` · `none` |
2218
+ | `online` | `all` · `match_last_seen` |
2219
+ | `call_add` | `all` · `known` |
2220
+ | `messages` | `all` · `contacts` |
2221
+ | `defense` | `on_standard` · `off` |
2222
+ | `stickers` | `contacts` · `contact_allowlist` · `none` |
2223
+
2224
+ Friendlier words are translated: `on`, `off`, `everyone`, `nobody`,
2225
+ `my_contacts`, `contacts_except`, `contact_whitelist`. `on` becomes `all` — or
2226
+ `on_standard` for `defense`, which spells its on-state differently.
2227
+
2228
+ ```js
2229
+ await client.changePrivacySetting('read_receipts', 'on') // sent as 'all'
2230
+ await client.changePrivacySetting('read_receipts', 'off') // sent as 'none'
2231
+ ```
2232
+
2233
+ A value the setting does not take throws before anything is sent. This matters
2234
+ more than it looks: the server does not refuse an unknown value, it drops the
2235
+ stanza and never answers, so the mistake would otherwise surface as a timeout
2236
+ that looks like a network problem.
2237
+
2200
2238
  ### Read Privacy Settings
2201
2239
 
2202
2240
  ```js
@@ -3284,20 +3322,6 @@ wa> /restriction --demo
3284
3322
  `--demo 90` uses ninety seconds instead of the default five hours, which is
3285
3323
  short enough to watch it reach zero and stop on its own.
3286
3324
 
3287
- If your server encodes this answer in Argo, the command says so rather than
3288
- guessing — and points you at the announcements, which stay readable:
3289
-
3290
- ```sh
3291
- wa> /restriction
3292
- checking account restriction...
3293
- error: mex query 23983697327930364: the server answered in "argo" rather than JSON, which this library cannot read (10 bytes: 0402300c000002010303)
3294
-
3295
- The query itself worked — your server encodes this answer in a binary
3296
- format this library cannot read yet, so the countdown cannot be polled.
3297
-
3298
- You will still be told about a restriction: the server announces one when
3299
- it starts and when it lifts, and those announcements do arrive readable.
3300
- ```
3301
3325
 
3302
3326
  An account that is fine says so and returns immediately:
3303
3327
 
package/cli.js CHANGED
@@ -447,7 +447,7 @@ const HELP = `
447
447
  Profile
448
448
  /name <text> change display name
449
449
  /about <text> change own bio / about text
450
- /photo <file> change own profile picture (JPEG)
450
+ /photo <file>|remove change or remove own profile picture (JPEG)
451
451
  /privacy [<type> <value>] show or change privacy settings
452
452
  types: last_seen profile_picture status
453
453
  online read_receipts groups_add
@@ -1387,6 +1387,9 @@ async function handleLine(line) {
1387
1387
  out(' types: last_seen profile_picture status online read_receipts');
1388
1388
  out(' groups_add call_add messages defense stickers');
1389
1389
  out(' values: all contacts contact_blacklist contact_allowlist none');
1390
+ out(' not every setting takes every value — read_receipts is');
1391
+ out(' all/none, online is all/match_last_seen. on and off are');
1392
+ out(' accepted and translated.');
1390
1393
  out(' match_last_seen known on_standard off');
1391
1394
  break;
1392
1395
  }
@@ -1398,11 +1401,12 @@ async function handleLine(line) {
1398
1401
  case '/photo': {
1399
1402
  requireConn();
1400
1403
  const file = p[1];
1401
- if (!file) { fail('usage: /photo <file>'); break; }
1402
- if (!fs.existsSync(file)) { fail('file not found: ' + file); break; }
1403
- const buf = fs.readFileSync(file);
1404
- await _client.changeProfilePicture(buf);
1405
- out('profile picture updated');
1404
+ if (!file) { fail('usage: /photo <file> (or /photo remove)'); break; }
1405
+ if (file !== 'remove' && !fs.existsSync(file)) { fail('file not found: ' + file); break; }
1406
+ const buf = file === 'remove' ? null : fs.readFileSync(file);
1407
+ const picId = await _client.changeProfilePicture(buf);
1408
+ out(picId === 'remove' ? 'profile picture removed'
1409
+ : 'profile picture updated' + (picId ? ' id=' + picId : ''));
1406
1410
  break;
1407
1411
  }
1408
1412
 
@@ -1516,19 +1520,17 @@ async function handleLine(line) {
1516
1520
  st = await _client.fetchReachoutTimelock();
1517
1521
  } catch (e) {
1518
1522
  fail(e.message);
1519
- if (e.mexFormat) {
1523
+ if (e.mexDecoded !== undefined) {
1520
1524
  out('');
1521
- out(' The query itself worked — your server encodes this answer in a binary');
1522
- out(' format this library cannot read yet, so the countdown cannot be polled.');
1525
+ out(' The reply was read, but it says nothing about a restriction either');
1526
+ out(' way — so it is not being reported as an all-clear.');
1523
1527
  out('');
1524
- out(' You will still be told about a restriction: the server announces one when');
1525
- out(' it starts and when it lifts, and those announcements do arrive readable.');
1526
- out(' Leave the session connected and watch for:');
1527
- out(' ACCOUNT RESTRICTED — <reason>, HH:MM:SS remaining');
1528
+ out(' You will still be told if one starts: the server announces that, and');
1529
+ out(' the announcement carries the countdown.');
1530
+ } else if (e.mexFormat) {
1528
1531
  out('');
1529
- out(' The payload above is worth keeping. Run this again while the account IS');
1530
- out(' restricted: the two payloads differ only where the answer differs, which');
1531
- out(' is what makes this format readable without its schema.');
1532
+ out(' The query worked, but the reply is in an encoding that could not be');
1533
+ out(' decoded. Paste the bytes above when reporting this.');
1532
1534
  } else {
1533
1535
  out('');
1534
1536
  out(' If the reply is sketched above, paste that line when reporting this —');
package/index.js CHANGED
@@ -14,6 +14,7 @@ const { MessageSender, makeJid, generateMessageId } = require('./lib/messages/Me
14
14
  const { GroupParticipantResult, GroupJoinRequest } = require('./lib/GroupParticipant');
15
15
  const { AppStateStore, COLLECTIONS: APP_STATE_COLLECTIONS } = require('./lib/appstate/AppStateStore');
16
16
  const ReachoutTimelock = require('./lib/ReachoutTimelock');
17
+ const { decodeArgo, tryDecodeArgo } = require('./lib/argo/ArgoDecoder');
17
18
  const AppStateSync = require('./lib/appstate/AppStateSync');
18
19
  const { PATCH_INTEGRITY: LT_HASH } = require('./lib/appstate/LTHash');
19
20
  const {
@@ -113,6 +114,9 @@ module.exports = {
113
114
  LT_HASH,
114
115
  // Account restriction
115
116
  ReachoutTimelock,
117
+ // Argo (the binary encoding the GraphQL transport may answer in)
118
+ decodeArgo,
119
+ tryDecodeArgo,
116
120
  // Auth utilities
117
121
  makeCacheableSignalKeyStore,
118
122
  addTransactionCapability,
package/lib/Client.js CHANGED
@@ -14,6 +14,7 @@ const { createNewStore, saveStore, loadStore, toSixParts, fromSixParts } = requi
14
14
  const { BinaryNode } = require('./BinaryNode');
15
15
  const { AppStateStore, COLLECTIONS } = require('./appstate/AppStateStore');
16
16
  const ReachoutTimelock = require('./ReachoutTimelock');
17
+ const { decodeArgo } = require('./argo/ArgoDecoder');
17
18
  const AppStateSync = require('./appstate/AppStateSync');
18
19
  const SyncdProto = require('./appstate/SyncdProto');
19
20
  const AppMutations = require('./appstate/Mutations');
@@ -73,6 +74,46 @@ const PRIVACY_NAME_ALIASES = {
73
74
  stickers: 'stickers'
74
75
  };
75
76
 
77
+ // What each setting will actually accept. The server does not argue with a
78
+ // value it does not know — it drops the stanza and never answers, so a typo
79
+ // looks exactly like a network problem. Checking here turns that into a
80
+ // sentence naming the choices.
81
+ const PRIVACY_ALLOWED_VALUES = {
82
+ last: ['all', 'contacts', 'contact_blacklist', 'none'],
83
+ profile: ['all', 'contacts', 'contact_blacklist', 'none'],
84
+ status: ['all', 'contacts', 'contact_blacklist', 'none'],
85
+ groupadd: ['all', 'contacts', 'contact_blacklist', 'none'],
86
+ readreceipts: ['all', 'none'],
87
+ online: ['all', 'match_last_seen'],
88
+ calladd: ['all', 'known'],
89
+ messages: ['all', 'contacts'],
90
+ defense: ['on_standard', 'off'],
91
+ stickers: ['contacts', 'contact_allowlist', 'none']
92
+ };
93
+
94
+ // The words people reach for, mapped to the ones the protocol uses. "on" and
95
+ // "off" are the obvious things to type for read receipts and mean nothing on
96
+ // the wire, which is exactly the mistake worth absorbing.
97
+ const PRIVACY_VALUE_ALIASES = {
98
+ everyone: 'all',
99
+ my_contacts: 'contacts',
100
+ contacts_except: 'contact_blacklist',
101
+ contact_whitelist: 'contact_allowlist',
102
+ nobody: 'none',
103
+ off: 'none',
104
+ on: 'all',
105
+ same_as_last_seen: 'match_last_seen',
106
+ last_seen: 'match_last_seen',
107
+ standard: 'on_standard'
108
+ };
109
+
110
+ // Where a word means something different for one setting than for the rest.
111
+ // "on" is "all" everywhere except defense, whose on-state has its own name.
112
+ const PRIVACY_VALUE_ALIASES_BY_NAME = {
113
+ defense: { on: 'on_standard' },
114
+ online: { on: 'all', off: 'match_last_seen' }
115
+ };
116
+
76
117
  // Wire name → the key it is reported under in the settings object.
77
118
  const PRIVACY_WIRE_TO_KEY = {
78
119
  last: 'lastSeen',
@@ -4287,19 +4328,45 @@ class WhalibmobClient extends EventEmitter {
4287
4328
 
4288
4329
  // Change profile picture
4289
4330
  // buf: Buffer with JPEG image data
4331
+ // Change your own profile picture.
4332
+ //
4333
+ // buf: a Buffer of JPEG bytes, or null to remove the current picture.
4334
+ // Returns the new picture id, or 'remove' when it was taken down.
4335
+ //
4336
+ // The picture is not addressed by sending the IQ *to* yourself: it goes to
4337
+ // the server with the account named in `target`, the same shape a group
4338
+ // picture uses. Addressed the other way the server files it nowhere and never
4339
+ // answers, which looked from the outside like the picture had been set — the
4340
+ // stanza went out, nothing came back, and nothing checked.
4290
4341
  async changeProfilePicture(buf) {
4291
4342
  if (!this._store) throw new Error('Not connected');
4292
- const selfJid = `${this._store.phoneNumber}@s.whatsapp.net`;
4293
- const id = this._genMsgId();
4343
+ const selfJid = this._ownDeviceJid
4344
+ ? toNonAdJid(this._ownDeviceJid())
4345
+ : `${this._store.phoneNumber}@s.whatsapp.net`;
4346
+
4294
4347
  const node = new BinaryNode('iq', {
4295
- id,
4296
- xmlns: 'w:profile:picture',
4297
- to: selfJid,
4298
- type: 'set'
4299
- }, [
4300
- new BinaryNode('picture', { type: 'image' }, buf)
4301
- ]);
4302
- return this._sendIq(node);
4348
+ id: this._genMsgId(),
4349
+ xmlns: 'w:profile:picture',
4350
+ to: 's.whatsapp.net',
4351
+ target: jidStrToObj(selfJid),
4352
+ type: 'set'
4353
+ }, buf ? [new BinaryNode('picture', { type: 'image' }, buf)] : null);
4354
+
4355
+ const resp = await this._sendIq(node);
4356
+ if (!resp) throw new Error('changeProfilePicture: no reply from server (IQ timed out)');
4357
+ if (resp.attrs && resp.attrs.type === 'error') {
4358
+ const errNode = findChild(resp, 'error');
4359
+ const code = errNode && errNode.attrs && errNode.attrs.code;
4360
+ throw new Error('changeProfilePicture: ' +
4361
+ (String(code) === '406' ? 'the image was rejected (must be a JPEG)'
4362
+ : String(code) === '401' ? 'not authorised to change this picture'
4363
+ : 'server rejected the request' + (code ? ' (' + code + ')' : '')));
4364
+ }
4365
+
4366
+ if (!buf) return 'remove';
4367
+ const picNode = findChild(resp, 'picture');
4368
+ return (picNode && picNode.attrs && picNode.attrs.id)
4369
+ ? String(picNode.attrs.id) : null;
4303
4370
  }
4304
4371
 
4305
4372
  // ─── Privacy settings ─────────────────────────────────────────────────────
@@ -4372,6 +4439,8 @@ class WhalibmobClient extends EventEmitter {
4372
4439
  Object.keys(PRIVACY_NAME_ALIASES).join(', '));
4373
4440
  }
4374
4441
 
4442
+ const wireValue = this._privacyWireValue(name, value);
4443
+
4375
4444
  // WhatsApp Web does not put <user> children on this IQ; the exclusion list
4376
4445
  // is maintained separately. Kept for callers that pass it,
4377
4446
  // and only emitted when they do.
@@ -4389,7 +4458,7 @@ class WhalibmobClient extends EventEmitter {
4389
4458
  type: 'set'
4390
4459
  }, [
4391
4460
  new BinaryNode('privacy', {}, [
4392
- new BinaryNode('category', { name, value: String(value) },
4461
+ new BinaryNode('category', { name, value: wireValue },
4393
4462
  children.length > 0 ? children : null)
4394
4463
  ])
4395
4464
  ]);
@@ -4400,7 +4469,7 @@ class WhalibmobClient extends EventEmitter {
4400
4469
  const errNode = findChild(resp, 'error');
4401
4470
  const code = errNode && errNode.attrs && errNode.attrs.code;
4402
4471
  const text = errNode && errNode.attrs && errNode.attrs.text;
4403
- throw new Error('changePrivacySetting: server rejected ' + name + '=' + value +
4472
+ throw new Error('changePrivacySetting: server rejected ' + name + '=' + wireValue +
4404
4473
  (code ? ' (' + code + (text ? ' ' + text : '') + ')' : ''));
4405
4474
  }
4406
4475
 
@@ -4408,10 +4477,31 @@ class WhalibmobClient extends EventEmitter {
4408
4477
  // rather than making the caller re-query.
4409
4478
  const key = PRIVACY_WIRE_TO_KEY[name];
4410
4479
  this._privacySettings = Object.assign({}, this._privacySettings || {},
4411
- { [key]: String(value) });
4480
+ { [key]: wireValue });
4412
4481
  return this._privacySettings;
4413
4482
  }
4414
4483
 
4484
+ // Resolve what the caller typed to what this particular setting accepts.
4485
+ //
4486
+ // The allowed set differs per setting — read receipts are all-or-none, online
4487
+ // is all-or-match-last-seen — so "contacts" is right for one and meaningless
4488
+ // for another. Sending a meaningless one gets no reply at all.
4489
+ _privacyWireValue(name, value) {
4490
+ const allowed = PRIVACY_ALLOWED_VALUES[name];
4491
+ const raw = String(value == null ? '' : value).trim().toLowerCase();
4492
+ const perName = PRIVACY_VALUE_ALIASES_BY_NAME[name] || {};
4493
+ const mapped = allowed && allowed.includes(raw)
4494
+ ? raw
4495
+ : (perName[raw] || PRIVACY_VALUE_ALIASES[raw] || raw);
4496
+
4497
+ if (allowed && !allowed.includes(mapped)) {
4498
+ throw new Error('changePrivacySetting: "' + value + '" is not a value ' +
4499
+ name + ' accepts — use one of ' + allowed.join(', ') +
4500
+ '. The server ignores anything else without answering.');
4501
+ }
4502
+ return mapped;
4503
+ }
4504
+
4415
4505
  // ──────────────────────────────────────────────────────────────────────────
4416
4506
  // MESSAGING — editMessage, deleteMessage, sendStatus, forwardMessage
4417
4507
  // ──────────────────────────────────────────────────────────────────────────
@@ -4900,8 +4990,26 @@ class WhalibmobClient extends EventEmitter {
4900
4990
  async fetchReachoutTimelock() {
4901
4991
  const payload = await this._mexQuery(MEX_QUERY_REACHOUT_TIMELOCK, {},
4902
4992
  'xwa2_fetch_account_reachout_timelock');
4993
+
4994
+ // Some servers answer this one tersely: a bare boolean rather than the
4995
+ // object with the expiry in it. `false` is a definite "no restriction" and
4996
+ // is taken at face value; `true` says there is one but not when it ends, so
4997
+ // it is marked as an expiry we do not know and the announcement supplies
4998
+ // the countdown.
4999
+ if (typeof payload === 'boolean') {
5000
+ return this._setReachoutTimelock(
5001
+ ReachoutTimelock.parseTimelockPayload({ is_active: payload }), 'query');
5002
+ }
5003
+
5004
+ if (payload == null || typeof payload !== 'object') {
5005
+ const err = new Error('the server answered ' + JSON.stringify(payload) +
5006
+ ', which says nothing about a restriction either way');
5007
+ err.mexDecoded = payload;
5008
+ throw err;
5009
+ }
5010
+
4903
5011
  return this._setReachoutTimelock(
4904
- ReachoutTimelock.parseTimelockPayload(payload || {}), 'query');
5012
+ ReachoutTimelock.parseTimelockPayload(payload), 'query');
4905
5013
  }
4906
5014
 
4907
5015
  /**
@@ -4999,15 +5107,50 @@ class WhalibmobClient extends EventEmitter {
4999
5107
  return dataPath ? (json.data && json.data[dataPath]) : json.data;
5000
5108
  }
5001
5109
 
5002
- // The query itself worked — this is purely about how the answer was
5003
- // encoded, and saying so is the difference between a fixable report and a
5004
- // shrug.
5110
+ // Not JSON, so the other encoding this transport uses: Argo, which is what
5111
+ // newer servers answer with. It carries its own field names, so it decodes
5112
+ // without the GraphQL schema the query was written against.
5005
5113
  const resultNode = findChildDeep(resp, 'result');
5006
5114
  const format = resultNode && resultNode.attrs && resultNode.attrs.format;
5115
+ const raw = resultNode ? getNodeContent(resultNode) : null;
5116
+
5117
+ // Only when the server said so. Guessing that unlabelled bytes are Argo
5118
+ // turns "this reply is not what I expected" into a misleading complaint
5119
+ // about a format that was never claimed.
5120
+ if (raw && raw.length && format && String(format).toLowerCase() === 'argo') {
5121
+ let decoded;
5122
+ try {
5123
+ decoded = decodeArgo(raw);
5124
+ } catch (e) {
5125
+ const err = new Error('mex query ' + queryId + ': the reply is ' +
5126
+ (format ? '"' + format + '"' : 'binary') + ' and could not be decoded — ' +
5127
+ e.message + ' (' + raw.length + ' bytes: ' + raw.toString('hex') + ')');
5128
+ err.mexFormat = format ? String(format) : null;
5129
+ err.mexPayload = raw;
5130
+ throw err;
5131
+ }
5132
+ _whaDbg('[DBG] MEX_ARGO decoded ' + JSON.stringify(decoded));
5133
+
5134
+ if (decoded && typeof decoded === 'object') {
5135
+ if (decoded.errors && decoded.errors.length) {
5136
+ throw new Error('mex query ' + queryId + ': ' +
5137
+ decoded.errors.map(e => (e && e.message) || 'unknown error').join(', '));
5138
+ }
5139
+ if (decoded.data !== undefined) {
5140
+ return dataPath ? (decoded.data && decoded.data[dataPath]) : decoded.data;
5141
+ }
5142
+ return dataPath ? decoded[dataPath] : decoded;
5143
+ }
5144
+
5145
+ // Not a GraphQL envelope but still an answer — a bare boolean, most
5146
+ // often. Handed back as-is for the caller to interpret, since only the
5147
+ // caller knows what its own query means.
5148
+ return decoded;
5149
+ }
5150
+
5007
5151
  if (format && String(format).toLowerCase() !== 'json') {
5008
- const raw = getNodeContent(resultNode);
5009
5152
  const err = new Error('mex query ' + queryId + ': the server answered in "' +
5010
- format + '" rather than JSON, which this library cannot read' +
5153
+ format + '", which this library cannot read' +
5011
5154
  (raw ? ' (' + raw.length + ' bytes: ' + raw.toString('hex') + ')' : ''));
5012
5155
  err.mexFormat = String(format);
5013
5156
  err.mexPayload = raw || null;
@@ -5080,13 +5223,27 @@ class WhalibmobClient extends EventEmitter {
5080
5223
  }
5081
5224
 
5082
5225
  // Internal: parse newsletter response from w:mex IQ result
5226
+ //
5227
+ // Every newsletter call travels the same transport as the restriction query,
5228
+ // so a server that answers that one in Argo answers these in Argo too. This
5229
+ // used to swallow that and return null, which reads as "no such newsletter"
5230
+ // rather than "this reply is in an encoding nothing here can read" — the
5231
+ // difference between a shrug and something a caller can act on.
5083
5232
  _parseNewsletterResponse(resp) {
5084
5233
  try {
5085
- const resultNode = findChild(resp, 'result');
5086
- if (!resultNode) return null;
5087
- const buf = getNodeContent(resultNode);
5088
- if (!buf) return null;
5089
- const json = JSON.parse(buf.toString('utf8'));
5234
+ const json = this._mexJsonFrom(resp);
5235
+ if (!json) {
5236
+ const resultNode = findChildDeep(resp, 'result');
5237
+ const format = resultNode && resultNode.attrs && resultNode.attrs.format;
5238
+ if (format && String(format).toLowerCase() !== 'json') {
5239
+ _whaDbg('[DBG] NEWSLETTER reply format=' + format + ' — cannot decode');
5240
+ const err = new Error('the server answered in "' + format +
5241
+ '" rather than JSON, which this library cannot read');
5242
+ err.mexFormat = String(format);
5243
+ throw err;
5244
+ }
5245
+ return null;
5246
+ }
5090
5247
  const nl = json && (json.newsletter || (json.data && json.data.xwa2_newsletter));
5091
5248
  if (!nl) return null;
5092
5249
  const meta = nl.metadata || nl;
@@ -5097,7 +5254,8 @@ class WhalibmobClient extends EventEmitter {
5097
5254
  subscriberCount: nl.subscribers_count || meta.subscriber_count || 0,
5098
5255
  creationTime: nl.creation_time || meta.creation_time || 0
5099
5256
  };
5100
- } catch (_) {
5257
+ } catch (err) {
5258
+ if (err && err.mexFormat) throw err;
5101
5259
  return null;
5102
5260
  }
5103
5261
  }
@@ -0,0 +1,240 @@
1
+ 'use strict';
2
+
3
+ // Argo — the binary encoding WhatsApp's GraphQL transport answers in.
4
+ //
5
+ // A w:mex reply can come back as JSON or as Argo; the <result> node says which.
6
+ // Argo is normally decoded against the schema of the query that asked, which a
7
+ // client outside Meta does not have. But a message can set a SelfDescribing
8
+ // flag, and then it carries its own field names and types — which is what
9
+ // WhatsApp sends, and what makes this readable at all.
10
+ //
11
+ // Only the self-describing form is implemented. A schema-typed message is
12
+ // refused rather than guessed at.
13
+ //
14
+ // Layout:
15
+ // [header bitset][block][block]…[core]
16
+ // where each block is a length label followed by that many bytes, and the core
17
+ // is the last one. Values in the core are Labels — zigzag ULEB128 integers —
18
+ // that either carry a small value outright or point into a block.
19
+
20
+ // ─── labels ──────────────────────────────────────────────────────────────────
21
+ //
22
+ // A label is one signed integer doing several jobs at once. Non-negative is a
23
+ // length or a boolean; the three values below are markers; anything lower is a
24
+ // backreference to a value already seen.
25
+ const LABEL_NULL = -1;
26
+ const LABEL_ABSENT = -2;
27
+ const LABEL_ERROR = -3;
28
+ // -4 is the first backreference and means "the first value in this block".
29
+ const BACKREF_BASE = -4;
30
+
31
+ // Self-describing type markers, as label values.
32
+ const MARKER = {
33
+ NULL: -1,
34
+ FALSE: 0,
35
+ TRUE: 1,
36
+ OBJECT: 2,
37
+ LIST: 3,
38
+ STRING: 4,
39
+ BYTES: 5,
40
+ INT: 6,
41
+ FLOAT: 7
42
+ };
43
+
44
+ // Header flag bit positions.
45
+ const FLAG_INLINE_EVERYTHING = 0;
46
+ const FLAG_SELF_DESCRIBING = 1;
47
+
48
+ class Reader {
49
+ constructor(buf) {
50
+ this.buf = buf;
51
+ this.pos = 0;
52
+ }
53
+ get done() { return this.pos >= this.buf.length; }
54
+ byte() {
55
+ if (this.pos >= this.buf.length) throw new Error('argo: ran off the end of the message');
56
+ return this.buf[this.pos++];
57
+ }
58
+ peek() { return this.buf[this.pos]; }
59
+ take(n) {
60
+ if (n < 0 || this.pos + n > this.buf.length) {
61
+ throw new Error('argo: asked for ' + n + ' bytes with ' +
62
+ (this.buf.length - this.pos) + ' left');
63
+ }
64
+ const out = this.buf.slice(this.pos, this.pos + n);
65
+ this.pos += n;
66
+ return out;
67
+ }
68
+ // ULEB128, then zigzag back to signed.
69
+ label() {
70
+ let value = 0n, shift = 0n;
71
+ for (;;) {
72
+ const b = this.byte();
73
+ value |= BigInt(b & 0x7f) << shift;
74
+ if (!(b & 0x80)) break;
75
+ shift += 7n;
76
+ if (shift > 63n) throw new Error('argo: label is too long to be a number');
77
+ }
78
+ const signed = (value >> 1n) ^ -(value & 1n);
79
+ return Number(signed);
80
+ }
81
+ }
82
+
83
+ function readHeader(reader) {
84
+ let bits = 0, shift = 0, more = true;
85
+ while (more) {
86
+ const b = reader.byte();
87
+ bits |= (b >> 1) << shift;
88
+ shift += 7;
89
+ more = (b & 1) === 1;
90
+ }
91
+ return {
92
+ bits,
93
+ selfDescribing: !!(bits >> FLAG_SELF_DESCRIBING & 1),
94
+ inlineEverything: !!(bits >> FLAG_INLINE_EVERYTHING & 1)
95
+ };
96
+ }
97
+
98
+ // A block holds the bulky values — strings, byte arrays, numbers — with the
99
+ // core referring to them in order. Blocks are handed out in the order their
100
+ // types are first needed, which is why they are taken from a queue rather than
101
+ // looked up by name.
102
+ class BlockQueue {
103
+ constructor(blocks, core, inlineEverything) {
104
+ this.blocks = blocks;
105
+ this.core = core;
106
+ this.inline = inlineEverything;
107
+ this.next = 0;
108
+ this.byKey = new Map();
109
+ }
110
+ for(key) {
111
+ if (this.inline) return { reader: this.core, seen: [] };
112
+ if (!this.byKey.has(key)) {
113
+ const buf = this.blocks[this.next++];
114
+ if (buf === undefined) throw new Error('argo: message is missing a block for ' + key);
115
+ this.byKey.set(key, { reader: new Reader(buf), seen: [] });
116
+ }
117
+ return this.byKey.get(key);
118
+ }
119
+ }
120
+
121
+ function decodeArgo(bytes) {
122
+ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
123
+ const reader = new Reader(buf);
124
+ const header = readHeader(reader);
125
+
126
+ if (!header.selfDescribing) {
127
+ const err = new Error('argo: this message is encoded against a GraphQL schema ' +
128
+ 'rather than describing itself, so it cannot be read without that schema');
129
+ err.needsSchema = true;
130
+ throw err;
131
+ }
132
+
133
+ const blocks = [];
134
+ if (header.inlineEverything) {
135
+ blocks.push(reader.take(buf.length - reader.pos));
136
+ } else {
137
+ while (!reader.done) {
138
+ const len = reader.label();
139
+ if (len < 0) throw new Error('argo: block length cannot be ' + len);
140
+ blocks.push(reader.take(len));
141
+ }
142
+ }
143
+ if (!blocks.length) throw new Error('argo: message has no core');
144
+
145
+ // The core is the last block, and every other block feeds it.
146
+ const core = new Reader(blocks[blocks.length - 1]);
147
+ const queue = new BlockQueue(blocks.slice(0, -1), core, header.inlineEverything);
148
+
149
+ return readValue(core, queue);
150
+ }
151
+
152
+ // One length-prefixed value out of a block, with backreferences resolved.
153
+ //
154
+ // The same string written twice is written once and pointed at the second time,
155
+ // which is most of why this format is small. A backreference that points at
156
+ // nothing means the message and our reading of it have diverged.
157
+ function readBlockValue(core, queue, key, convert) {
158
+ const block = queue.for(key);
159
+ const label = core.label();
160
+ if (label <= BACKREF_BASE) {
161
+ const idx = -label + BACKREF_BASE;
162
+ if (idx < 0 || idx >= block.seen.length) {
163
+ throw new Error('argo: backreference ' + label + ' points outside ' + key);
164
+ }
165
+ return block.seen[idx];
166
+ }
167
+ if (label < 0) throw new Error('argo: ' + key + ' cannot have label ' + label);
168
+ const value = convert(block.reader.take(label));
169
+ block.seen.push(value);
170
+ return value;
171
+ }
172
+
173
+ function readValue(core, queue) {
174
+ const marker = core.label();
175
+
176
+ switch (marker) {
177
+ case MARKER.NULL: return null;
178
+ case MARKER.FALSE: return false;
179
+ case MARKER.TRUE: return true;
180
+
181
+ case MARKER.OBJECT: {
182
+ const count = core.label();
183
+ if (count < 0) throw new Error('argo: object cannot have ' + count + ' fields');
184
+ const out = {};
185
+ for (let i = 0; i < count; i++) {
186
+ const name = readBlockValue(core, queue, 'String', b => b.toString('utf8'));
187
+ out[name] = readValue(core, queue);
188
+ }
189
+ return out;
190
+ }
191
+
192
+ case MARKER.LIST: {
193
+ const count = core.label();
194
+ if (count < 0) throw new Error('argo: list cannot have ' + count + ' items');
195
+ const out = [];
196
+ for (let i = 0; i < count; i++) out.push(readValue(core, queue));
197
+ return out;
198
+ }
199
+
200
+ case MARKER.STRING: return readBlockValue(core, queue, 'String', b => b.toString('utf8'));
201
+ case MARKER.BYTES: return readBlockValue(core, queue, 'Bytes', b => Buffer.from(b));
202
+
203
+ // An integer lives entirely in its block — the core spends nothing on it.
204
+ case MARKER.INT: {
205
+ const block = queue.for('Varint');
206
+ return block.reader.label();
207
+ }
208
+
209
+ case MARKER.FLOAT: {
210
+ const block = queue.for('Float');
211
+ return block.reader.take(8).readDoubleLE(0);
212
+ }
213
+
214
+ default:
215
+ if (marker === LABEL_ABSENT) return undefined;
216
+ if (marker === LABEL_ERROR) return readValue(core, queue);
217
+ throw new Error('argo: unknown type marker ' + marker);
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Decode an Argo message, or return null when it is not one this can read.
223
+ *
224
+ * Never throws: a caller reaching for this already has a reply it could not
225
+ * parse, and a second failure on top of that is not news. `decodeArgo` is there
226
+ * for when the reason matters.
227
+ */
228
+ function tryDecodeArgo(bytes) {
229
+ try { return decodeArgo(bytes); }
230
+ catch (_) { return null; }
231
+ }
232
+
233
+ module.exports = {
234
+ decodeArgo,
235
+ tryDecodeArgo,
236
+ MARKER,
237
+ LABEL_NULL,
238
+ LABEL_ABSENT,
239
+ LABEL_ERROR
240
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.10.3",
3
+ "version": "5.10.6",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API no web",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",