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 +53 -29
- package/cli.js +18 -16
- package/index.js +4 -0
- package/lib/Client.js +183 -25
- package/lib/argo/ArgoDecoder.js +240 -0
- package/package.json +1 -1
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
|
-
> [!
|
|
1453
|
-
> **
|
|
1454
|
-
>
|
|
1455
|
-
>
|
|
1456
|
-
>
|
|
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
|
|
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
|
|
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
|
-
>
|
|
1463
|
-
>
|
|
1464
|
-
>
|
|
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
|
|
1467
|
-
> restriction starting and lifting, and those announcements
|
|
1468
|
-
>
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
1523
|
+
if (e.mexDecoded !== undefined) {
|
|
1520
1524
|
out('');
|
|
1521
|
-
out(' The
|
|
1522
|
-
out('
|
|
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
|
|
1525
|
-
out('
|
|
1526
|
-
|
|
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
|
|
1530
|
-
out('
|
|
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 =
|
|
4293
|
-
|
|
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:
|
|
4297
|
-
to:
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
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:
|
|
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 + '=' +
|
|
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]:
|
|
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
|
|
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
|
-
//
|
|
5003
|
-
//
|
|
5004
|
-
//
|
|
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 + '"
|
|
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
|
|
5086
|
-
if (!
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
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
|
+
};
|