whalibmob 5.10.0 → 5.10.3
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 -0
- package/cli.js +54 -1
- package/lib/Client.js +104 -18
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1449,6 +1449,25 @@ 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`.
|
|
1457
|
+
>
|
|
1458
|
+
> Do not add a `format` attribute to the query in the hope of getting JSON — it
|
|
1459
|
+
> 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
|
+
>
|
|
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.
|
|
1465
|
+
>
|
|
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.
|
|
1470
|
+
|
|
1452
1471
|
### What Is Automatic vs What You Need to Do
|
|
1453
1472
|
|
|
1454
1473
|
**Everything in the "Automatic" column requires zero code from you.**
|
|
@@ -3247,6 +3266,39 @@ For the numbers without the countdown:
|
|
|
3247
3266
|
wa> /restriction --once
|
|
3248
3267
|
```
|
|
3249
3268
|
|
|
3269
|
+
To check the display without waiting to be restricted, `--demo` counts down a
|
|
3270
|
+
made-up restriction. Nothing is sent, nothing is asked of the server, and
|
|
3271
|
+
nothing is left behind when it ends:
|
|
3272
|
+
|
|
3273
|
+
```sh
|
|
3274
|
+
wa> /restriction --demo
|
|
3275
|
+
──────────────────────────────────────────────────
|
|
3276
|
+
status RESTRICTED (demo — not real)
|
|
3277
|
+
reason too many people you messaged blocked or reported you
|
|
3278
|
+
remaining 05:00:00
|
|
3279
|
+
──────────────────────────────────────────────────
|
|
3280
|
+
press any key to stop watching
|
|
3281
|
+
restricted — 04:59:57 remaining
|
|
3282
|
+
```
|
|
3283
|
+
|
|
3284
|
+
`--demo 90` uses ninety seconds instead of the default five hours, which is
|
|
3285
|
+
short enough to watch it reach zero and stop on its own.
|
|
3286
|
+
|
|
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
|
+
|
|
3250
3302
|
An account that is fine says so and returns immediately:
|
|
3251
3303
|
|
|
3252
3304
|
```sh
|
|
@@ -3764,6 +3816,7 @@ wa> /quit
|
|
|
3764
3816
|
| `/appstate --snapshot` | Re-read all app state from scratch |
|
|
3765
3817
|
| `/restriction` | Account restriction status with a live countdown |
|
|
3766
3818
|
| `/restriction --once` | Restriction status without the countdown |
|
|
3819
|
+
| `/restriction --demo [seconds]` | Fake countdown, to check the display |
|
|
3767
3820
|
| `/limit` | Alias for `/restriction` |
|
|
3768
3821
|
| `/ephemeral <jid> <seconds>` | Set disappearing messages timer for a chat |
|
|
3769
3822
|
| `/ephemeral-default <seconds>` | Set global default ephemeral timer for new chats |
|
package/cli.js
CHANGED
|
@@ -478,6 +478,7 @@ const HELP = `
|
|
|
478
478
|
/appstate --snapshot re-read all of it from scratch
|
|
479
479
|
/restriction account restriction + live countdown
|
|
480
480
|
/restriction --once just the numbers, no countdown
|
|
481
|
+
/restriction --demo [seconds] fake countdown to check the display
|
|
481
482
|
/ephemeral <jid> <seconds> set disappearing timer for chat
|
|
482
483
|
/ephemeral-default <seconds> set default timer for ALL new chats
|
|
483
484
|
/block <jid> block contact
|
|
@@ -1479,10 +1480,62 @@ async function handleLine(line) {
|
|
|
1479
1480
|
|
|
1480
1481
|
case '/restriction':
|
|
1481
1482
|
case '/limit': {
|
|
1483
|
+
// --demo feeds a made-up restriction into this session and counts it
|
|
1484
|
+
// down, so the display can be checked without waiting to be restricted.
|
|
1485
|
+
// Nothing is sent and nothing is asked of the server.
|
|
1486
|
+
const demoAt = p.indexOf('--demo');
|
|
1487
|
+
if (demoAt !== -1) {
|
|
1488
|
+
const secs = parseInt(p[demoAt + 1], 10) || 5 * 3600;
|
|
1489
|
+
const { parseTimelockPayload } = require('./lib/ReachoutTimelock');
|
|
1490
|
+
if (!_client) { fail('/restriction --demo needs a client; use /connect first'); break; }
|
|
1491
|
+
const fake = parseTimelockPayload({
|
|
1492
|
+
is_active: true,
|
|
1493
|
+
time_enforcement_ends: String(Math.floor(Date.now() / 1000) + secs),
|
|
1494
|
+
enforcement_type: 'BIZ_QUALITY'
|
|
1495
|
+
});
|
|
1496
|
+
const shown = _client._setReachoutTimelock(fake, 'demo');
|
|
1497
|
+
hr();
|
|
1498
|
+
kv('status', 'RESTRICTED (demo — not real)');
|
|
1499
|
+
kv('reason', shown.reason);
|
|
1500
|
+
kv('remaining', shown.remaining);
|
|
1501
|
+
hr();
|
|
1502
|
+
_rl && _rl.pause();
|
|
1503
|
+
await countdown(_client, shown);
|
|
1504
|
+
_rl && _rl.resume();
|
|
1505
|
+
// Leave nothing behind that a later check could mistake for real.
|
|
1506
|
+
_client._reachoutTimelock = null;
|
|
1507
|
+
out(' demo over — nothing was recorded');
|
|
1508
|
+
break;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1482
1511
|
requireConn();
|
|
1483
1512
|
const watch = !p.includes('--once');
|
|
1484
1513
|
out('checking account restriction...');
|
|
1485
|
-
|
|
1514
|
+
let st;
|
|
1515
|
+
try {
|
|
1516
|
+
st = await _client.fetchReachoutTimelock();
|
|
1517
|
+
} catch (e) {
|
|
1518
|
+
fail(e.message);
|
|
1519
|
+
if (e.mexFormat) {
|
|
1520
|
+
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.');
|
|
1523
|
+
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('');
|
|
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
|
+
} else {
|
|
1533
|
+
out('');
|
|
1534
|
+
out(' If the reply is sketched above, paste that line when reporting this —');
|
|
1535
|
+
out(' it is the server\'s actual answer. Run with --debug for the raw stanza.');
|
|
1536
|
+
}
|
|
1537
|
+
break;
|
|
1538
|
+
}
|
|
1486
1539
|
hr();
|
|
1487
1540
|
if (!st.active) {
|
|
1488
1541
|
kv('status', 'not restricted — you can start new chats');
|
package/lib/Client.js
CHANGED
|
@@ -177,6 +177,39 @@ function findChildDeep(node, desc) {
|
|
|
177
177
|
return null;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
// A one-line sketch of a stanza, for an error message that has to be useful to
|
|
181
|
+
// somebody who cannot see the wire. Shows the tag tree and a short preview of
|
|
182
|
+
// any content, so an unexpected reply can be reported rather than guessed at.
|
|
183
|
+
function describeNodeBriefly(node, depth) {
|
|
184
|
+
if (!node) return '(nothing)';
|
|
185
|
+
depth = depth || 0;
|
|
186
|
+
const attrs = Object.entries(node.attrs || {})
|
|
187
|
+
.map(([k, v]) => k + '=' + String(v))
|
|
188
|
+
.join(' ');
|
|
189
|
+
let out = '<' + node.description + (attrs ? ' ' + attrs : '');
|
|
190
|
+
|
|
191
|
+
if (Array.isArray(node.content)) {
|
|
192
|
+
if (depth >= 3) return out + ' …>';
|
|
193
|
+
const kids = node.content
|
|
194
|
+
.filter(c => c && c.description)
|
|
195
|
+
.map(c => describeNodeBriefly(c, depth + 1));
|
|
196
|
+
return out + '>' + kids.join('');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const buf = getNodeContent(node);
|
|
200
|
+
if (buf && buf.length) {
|
|
201
|
+
const text = buf.toString('utf8');
|
|
202
|
+
// Printable content is far more use than hex; fall back to hex when it is
|
|
203
|
+
// not text at all.
|
|
204
|
+
const printable = /^[\x09\x0a\x0d\x20-\x7e]*$/.test(text);
|
|
205
|
+
const shown = printable
|
|
206
|
+
? JSON.stringify(text.length > 120 ? text.slice(0, 120) + '…' : text)
|
|
207
|
+
: '0x' + buf.slice(0, 40).toString('hex') + (buf.length > 40 ? '…' : '');
|
|
208
|
+
return out + '>' + buf.length + 'B ' + shown;
|
|
209
|
+
}
|
|
210
|
+
return out + '/>';
|
|
211
|
+
}
|
|
212
|
+
|
|
180
213
|
function getNodeContent(node) {
|
|
181
214
|
if (!node) return null;
|
|
182
215
|
if (Buffer.isBuffer(node.content)) return node.content;
|
|
@@ -4926,37 +4959,90 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4926
4959
|
* caller concludes the account is fine.
|
|
4927
4960
|
*/
|
|
4928
4961
|
async _mexQuery(queryId, variables, dataPath) {
|
|
4962
|
+
const body = Buffer.from(JSON.stringify({ variables: variables || {} }), 'utf8');
|
|
4963
|
+
|
|
4964
|
+
// The <query> node carries the query id and nothing else.
|
|
4965
|
+
//
|
|
4966
|
+
// Asking for a format here was tried and is wrong: a `format="json"`
|
|
4967
|
+
// attribute makes the server drop the stanza on the floor — no reply at
|
|
4968
|
+
// all, where without it there is at least an answer. It does not ignore
|
|
4969
|
+
// what it does not recognise, so nothing speculative belongs on this node.
|
|
4929
4970
|
const resp = await this._sendIq(new BinaryNode('iq', {
|
|
4930
4971
|
id: this._genMsgId(),
|
|
4931
4972
|
to: 's.whatsapp.net',
|
|
4932
4973
|
type: 'get',
|
|
4933
4974
|
xmlns: 'w:mex'
|
|
4934
|
-
}, [
|
|
4935
|
-
new BinaryNode('query', { query_id: String(queryId) },
|
|
4936
|
-
Buffer.from(JSON.stringify({ variables: variables || {} }), 'utf8'))
|
|
4937
|
-
]));
|
|
4975
|
+
}, [new BinaryNode('query', { query_id: String(queryId) }, body)]));
|
|
4938
4976
|
|
|
4939
4977
|
if (!resp) throw new Error('mex query ' + queryId + ': no reply from server (IQ timed out)');
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4978
|
+
|
|
4979
|
+
// An <error> child is the refusal whether or not the iq is typed as one.
|
|
4980
|
+
const errNode = (resp.attrs && resp.attrs.type === 'error')
|
|
4981
|
+
? findChildDeep(resp, 'error') : findChild(resp, 'error');
|
|
4982
|
+
if (errNode) {
|
|
4983
|
+
const a = errNode.attrs || {};
|
|
4984
|
+
const code = a.code ? String(a.code) : null;
|
|
4985
|
+
const text = a.text ? String(a.text) : null;
|
|
4943
4986
|
throw new Error('mex query ' + queryId + ': server rejected the request' +
|
|
4944
|
-
(code ? ' (' + code + ')' : ''));
|
|
4987
|
+
(code ? ' (' + code + (text ? ' ' + text : '') + ')' : ''));
|
|
4945
4988
|
}
|
|
4946
4989
|
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
|
|
4990
|
+
// The JSON normally sits in <result>, but not every build wraps it the same
|
|
4991
|
+
// way, so take it from wherever in the reply it turns up rather than from
|
|
4992
|
+
// one fixed path.
|
|
4993
|
+
const json = this._mexJsonFrom(resp);
|
|
4994
|
+
if (json) {
|
|
4995
|
+
if (json.errors && json.errors.length) {
|
|
4996
|
+
throw new Error('mex query ' + queryId + ': ' +
|
|
4997
|
+
json.errors.map(e => (e && e.message) || 'unknown error').join(', '));
|
|
4998
|
+
}
|
|
4999
|
+
return dataPath ? (json.data && json.data[dataPath]) : json.data;
|
|
5000
|
+
}
|
|
4950
5001
|
|
|
4951
|
-
|
|
4952
|
-
|
|
4953
|
-
|
|
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.
|
|
5005
|
+
const resultNode = findChildDeep(resp, 'result');
|
|
5006
|
+
const format = resultNode && resultNode.attrs && resultNode.attrs.format;
|
|
5007
|
+
if (format && String(format).toLowerCase() !== 'json') {
|
|
5008
|
+
const raw = getNodeContent(resultNode);
|
|
5009
|
+
const err = new Error('mex query ' + queryId + ': the server answered in "' +
|
|
5010
|
+
format + '" rather than JSON, which this library cannot read' +
|
|
5011
|
+
(raw ? ' (' + raw.length + ' bytes: ' + raw.toString('hex') + ')' : ''));
|
|
5012
|
+
err.mexFormat = String(format);
|
|
5013
|
+
err.mexPayload = raw || null;
|
|
5014
|
+
_whaDbg('[DBG] MEX_REPLY_FORMAT ' + format + ' — cannot decode');
|
|
5015
|
+
throw err;
|
|
5016
|
+
}
|
|
4954
5017
|
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
5018
|
+
_whaDbg('[DBG] MEX_REPLY_UNPARSED ' + JSON.stringify(describeNodeBriefly(resp)));
|
|
5019
|
+
throw new Error('mex query ' + queryId + ': the reply carried no JSON — ' +
|
|
5020
|
+
describeNodeBriefly(resp));
|
|
5021
|
+
}
|
|
5022
|
+
|
|
5023
|
+
// Walk the reply for the first node whose content parses as a JSON object.
|
|
5024
|
+
//
|
|
5025
|
+
// Content arrives as bytes for a long payload and as a plain string when the
|
|
5026
|
+
// encoder had a token for it, so both are tried.
|
|
5027
|
+
_mexJsonFrom(node) {
|
|
5028
|
+
if (!node) return null;
|
|
5029
|
+
const raw = getNodeContent(node);
|
|
5030
|
+
if (raw && raw.length) {
|
|
5031
|
+
const text = raw.toString('utf8').trim();
|
|
5032
|
+
if (text.startsWith('{') || text.startsWith('[')) {
|
|
5033
|
+
try {
|
|
5034
|
+
const parsed = JSON.parse(text);
|
|
5035
|
+
if (parsed && typeof parsed === 'object') return parsed;
|
|
5036
|
+
} catch (_) { /* not this node */ }
|
|
5037
|
+
}
|
|
5038
|
+
}
|
|
5039
|
+
if (Array.isArray(node.content)) {
|
|
5040
|
+
for (const child of node.content) {
|
|
5041
|
+
const found = this._mexJsonFrom(child);
|
|
5042
|
+
if (found) return found;
|
|
5043
|
+
}
|
|
4958
5044
|
}
|
|
4959
|
-
return
|
|
5045
|
+
return null;
|
|
4960
5046
|
}
|
|
4961
5047
|
|
|
4962
5048
|
/**
|
package/package.json
CHANGED