whalibmob 5.9.2 → 5.10.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/README.md +148 -0
- package/cli.js +119 -0
- package/index.js +3 -0
- package/lib/Client.js +260 -0
- package/lib/ReachoutTimelock.js +133 -0
- package/lib/messages/MessageSender.js +7 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -98,6 +98,7 @@ npm install -g whalibmob
|
|
|
98
98
|
- [Archive / Unarchive](#archive--unarchive)
|
|
99
99
|
- [Star / Unstar a Message](#star--unstar-a-message-cli)
|
|
100
100
|
- [Sync App State (CLI)](#cli-app-state)
|
|
101
|
+
- [Account Restriction Countdown](#cli-restriction)
|
|
101
102
|
- [Disappearing Messages](#cli-disappearing-messages)
|
|
102
103
|
- [Default Disappearing Timer](#default-disappearing-timer)
|
|
103
104
|
- [Block / Unblock](#block--unblock)
|
|
@@ -169,6 +170,7 @@ npm install -g whalibmob
|
|
|
169
170
|
- [Persistent Files Written to Disk](#persistent-files-written-to-disk)
|
|
170
171
|
- [Reading the History Store](#reading-the-history-store)
|
|
171
172
|
- [tcToken — Error 463 Defense](#tctoken--error-463-defense)
|
|
173
|
+
- [When 463 Means the Account Is Restricted](#account-restriction)
|
|
172
174
|
- [What Is Automatic vs What You Need to Do](#what-is-automatic-vs-what-you-need-to-do)
|
|
173
175
|
- [Receiving Media](#receiving-media)
|
|
174
176
|
- [Sending Messages](#sending-messages)
|
|
@@ -1113,6 +1115,8 @@ connect()
|
|
|
1113
1115
|
| `app_state_mutation` | `{ collection, index, action, removed }` | An app-state change this library does not model |
|
|
1114
1116
|
| `app_state_key_missing` | `{ collection, keyId }` | App state cannot be read until your phone shares this key |
|
|
1115
1117
|
| `app_state_keys` | `{ keys }` | Your phone shared app-state sync keys; a sync starts automatically |
|
|
1118
|
+
| `account_restriction` | `{ active, remaining, remainingMs, endsAtDate, enforcementType, reason, source }` | The account was restricted or the restriction was lifted — see [When 463 Means the Account Is Restricted](#account-restriction) |
|
|
1119
|
+
| `mex_notification` | `{ opName, data }` | A server push over `w:mex` this library does not model |
|
|
1116
1120
|
|
|
1117
1121
|
`remote: true` on a chat event means the change was made on your phone or another
|
|
1118
1122
|
linked device rather than by this session. Your own calls carry `synced` instead,
|
|
@@ -1390,6 +1394,76 @@ whalibmob implements the full lifecycle to prevent this:
|
|
|
1390
1394
|
|
|
1391
1395
|
Token storage uses the **LID JID** of the contact (e.g. `112345678901234@lid`) as the key — never the phone-number JID — matching WhatsApp's internal convention.
|
|
1392
1396
|
|
|
1397
|
+
<a id="account-restriction"></a>
|
|
1398
|
+
|
|
1399
|
+
### When 463 Means the Account Is Restricted
|
|
1400
|
+
|
|
1401
|
+
A `463` has two causes that look identical on the wire, and telling them apart matters because only one of them is worth retrying.
|
|
1402
|
+
|
|
1403
|
+
- **No privacy token for that one contact.** The library issues one and re-sends. This is the common case and it resolves itself.
|
|
1404
|
+
- **The account is restricted.** WhatsApp has decided you are starting too many chats with people who never reply, and refuses *all* new conversations until the restriction expires. Existing chats keep working, which is why the account otherwise looks healthy. No retry helps — and sending more counts as further reach-outs, which makes it longer.
|
|
1405
|
+
|
|
1406
|
+
The expiry is never pushed to a client that has not asked. `fetchReachoutTimelock()` is the only way to learn it:
|
|
1407
|
+
|
|
1408
|
+
```js
|
|
1409
|
+
const r = await client.fetchReachoutTimelock()
|
|
1410
|
+
|
|
1411
|
+
if (r.active) {
|
|
1412
|
+
console.log('restricted:', r.reason)
|
|
1413
|
+
console.log('ends at: ', r.endsAtDate.toISOString())
|
|
1414
|
+
console.log('remaining: ', r.remaining) // '04:59:20'
|
|
1415
|
+
} else {
|
|
1416
|
+
console.log('not restricted')
|
|
1417
|
+
}
|
|
1418
|
+
```
|
|
1419
|
+
|
|
1420
|
+
```js
|
|
1421
|
+
{
|
|
1422
|
+
active: true,
|
|
1423
|
+
remaining: '04:59:20', // HH:MM:SS, hours not wrapped at 24
|
|
1424
|
+
remainingMs: 17960000,
|
|
1425
|
+
endsAt: 1800018000000,
|
|
1426
|
+
endsAtDate: Date,
|
|
1427
|
+
enforcementType: 'BIZ_QUALITY',
|
|
1428
|
+
reason: 'too many people you messaged blocked or reported you',
|
|
1429
|
+
expiryUnknown: false, // true when the server withheld the end time
|
|
1430
|
+
checkedAt: 1800000040000
|
|
1431
|
+
}
|
|
1432
|
+
```
|
|
1433
|
+
|
|
1434
|
+
`getReachoutTimelock()` returns the same shape from what was last learned, without asking again — the countdown is recomputed on every call, so it is what a once-a-second display reads.
|
|
1435
|
+
|
|
1436
|
+
**You rarely need to call either.** A send refused with 463 triggers a check on its own (rate-limited to once a minute), and the server pushes an update both when a restriction starts and when it is lifted:
|
|
1437
|
+
|
|
1438
|
+
```js
|
|
1439
|
+
client.on('account_restriction', (r) => {
|
|
1440
|
+
if (r.active) console.log('restricted for', r.remaining, '—', r.reason)
|
|
1441
|
+
else console.log('restriction lifted')
|
|
1442
|
+
})
|
|
1443
|
+
```
|
|
1444
|
+
|
|
1445
|
+
`r.source` is `'notification'` when the server announced it and `'query'` when we asked.
|
|
1446
|
+
|
|
1447
|
+
> [!NOTE]
|
|
1448
|
+
> A first restriction is usually around five hours. Repeats get longer. Nothing
|
|
1449
|
+
> client-side shortens it; the only thing that helps is not sending more
|
|
1450
|
+
> unanswered first messages while it is on.
|
|
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
|
+
> It never guesses. Reporting "not restricted" from an answer nobody decoded
|
|
1459
|
+
> would have you send more messages and make the restriction longer, so an
|
|
1460
|
+
> unreadable reply is an error rather than an all-clear.
|
|
1461
|
+
>
|
|
1462
|
+
> The `account_restriction` event is unaffected: the server *announces* a
|
|
1463
|
+
> restriction starting and lifting, and those announcements arrive as readable
|
|
1464
|
+
> JSON on a different route. Keep a listener attached and you will still be
|
|
1465
|
+
> told, with the countdown, without ever calling the query.
|
|
1466
|
+
|
|
1393
1467
|
### What Is Automatic vs What You Need to Do
|
|
1394
1468
|
|
|
1395
1469
|
**Everything in the "Automatic" column requires zero code from you.**
|
|
@@ -1412,6 +1486,8 @@ Token storage uses the **LID JID** of the contact (e.g. `112345678901234@lid`) a
|
|
|
1412
1486
|
| Handle incoming `privacy_token` notifications | ✅ | |
|
|
1413
1487
|
| Re-issue tcToken after peer identity change | ✅ | |
|
|
1414
1488
|
| Recover from error 463 with automatic retry | ✅ | |
|
|
1489
|
+
| Check for an account restriction after a 463 | ✅ | Rate-limited to once a minute |
|
|
1490
|
+
| Track restriction start / lift pushed by the server | ✅ | Raised as `account_restriction` |
|
|
1415
1491
|
| Populate in-memory LID↔PN maps | ✅ | |
|
|
1416
1492
|
| Listen to `history_sync` event | 🔵 Optional | Only if your app needs to react to history data |
|
|
1417
1493
|
| Read `<phone>.history.json` | 🔵 Optional | Only if your app needs chat/contact data at rest |
|
|
@@ -3150,6 +3226,75 @@ Changes that arrive on their own are printed as they land:
|
|
|
3150
3226
|
If your phone has not yet shared a sync key with this session, the command says
|
|
3151
3227
|
so — leave WhatsApp open on the phone for a moment and try again.
|
|
3152
3228
|
|
|
3229
|
+
<a id="cli-restriction"></a>
|
|
3230
|
+
|
|
3231
|
+
#### Account Restriction Countdown
|
|
3232
|
+
|
|
3233
|
+
When WhatsApp restricts an account, new chats are refused with error 463 until
|
|
3234
|
+
it expires — usually about five hours the first time. `/restriction` asks the
|
|
3235
|
+
server how long is left and then counts it down, once a second, in place:
|
|
3236
|
+
|
|
3237
|
+
```sh
|
|
3238
|
+
wa> /restriction
|
|
3239
|
+
checking account restriction...
|
|
3240
|
+
──────────────────────────────────────────────────
|
|
3241
|
+
status RESTRICTED
|
|
3242
|
+
reason too many people you messaged blocked or reported you
|
|
3243
|
+
type BIZ_QUALITY
|
|
3244
|
+
ends at 2026-07-28 19:41:12 UTC
|
|
3245
|
+
remaining 04:59:22
|
|
3246
|
+
──────────────────────────────────────────────────
|
|
3247
|
+
New chats with people you have never messaged are refused with
|
|
3248
|
+
error 463 until this expires. Existing conversations keep working,
|
|
3249
|
+
and sending more only makes the restriction longer.
|
|
3250
|
+
|
|
3251
|
+
press any key to stop watching
|
|
3252
|
+
restricted — 04:59:20 remaining
|
|
3253
|
+
```
|
|
3254
|
+
|
|
3255
|
+
The last line rewrites itself every second — `04:59:20`, `04:59:19`, … — and
|
|
3256
|
+
stops on its own the moment the restriction lifts. Any key ends the watch; the
|
|
3257
|
+
restriction is unaffected either way. `/limit` is the same command.
|
|
3258
|
+
|
|
3259
|
+
For the numbers without the countdown:
|
|
3260
|
+
|
|
3261
|
+
```sh
|
|
3262
|
+
wa> /restriction --once
|
|
3263
|
+
```
|
|
3264
|
+
|
|
3265
|
+
If your server encodes this answer in Argo, the command says so rather than
|
|
3266
|
+
guessing — and points you at the announcements, which stay readable:
|
|
3267
|
+
|
|
3268
|
+
```sh
|
|
3269
|
+
wa> /restriction
|
|
3270
|
+
checking account restriction...
|
|
3271
|
+
error: mex query 23983697327930364: the server answered in "argo" rather than JSON, which this library cannot read (10 bytes: 0402300c000002010303)
|
|
3272
|
+
|
|
3273
|
+
The query itself worked — your server encodes this answer in a binary
|
|
3274
|
+
format this library cannot read yet, so the countdown cannot be polled.
|
|
3275
|
+
|
|
3276
|
+
You will still be told about a restriction: the server announces one when
|
|
3277
|
+
it starts and when it lifts, and those announcements do arrive readable.
|
|
3278
|
+
```
|
|
3279
|
+
|
|
3280
|
+
An account that is fine says so and returns immediately:
|
|
3281
|
+
|
|
3282
|
+
```sh
|
|
3283
|
+
wa> /restriction
|
|
3284
|
+
checking account restriction...
|
|
3285
|
+
──────────────────────────────────────────────────
|
|
3286
|
+
status not restricted — you can start new chats
|
|
3287
|
+
──────────────────────────────────────────────────
|
|
3288
|
+
```
|
|
3289
|
+
|
|
3290
|
+
You do not have to run it to find out. A refused send checks by itself, and the
|
|
3291
|
+
shell prints the change as it happens:
|
|
3292
|
+
|
|
3293
|
+
```sh
|
|
3294
|
+
ACCOUNT RESTRICTED — too many people you messaged blocked or reported you, 04:59:58 remaining
|
|
3295
|
+
run /restriction to watch the countdown
|
|
3296
|
+
```
|
|
3297
|
+
|
|
3153
3298
|
#### CLI Disappearing Messages
|
|
3154
3299
|
|
|
3155
3300
|
| Duration | Seconds |
|
|
@@ -3647,6 +3792,9 @@ wa> /quit
|
|
|
3647
3792
|
| `/unstar <jid> <msgId> [me]` | Unstar a message |
|
|
3648
3793
|
| `/appstate [collection...]` | Pull pins/archives/mutes/stars from your phone |
|
|
3649
3794
|
| `/appstate --snapshot` | Re-read all app state from scratch |
|
|
3795
|
+
| `/restriction` | Account restriction status with a live countdown |
|
|
3796
|
+
| `/restriction --once` | Restriction status without the countdown |
|
|
3797
|
+
| `/limit` | Alias for `/restriction` |
|
|
3650
3798
|
| `/ephemeral <jid> <seconds>` | Set disappearing messages timer for a chat |
|
|
3651
3799
|
| `/ephemeral-default <seconds>` | Set global default ephemeral timer for new chats |
|
|
3652
3800
|
| `/block <jid>` | Block a contact |
|
package/cli.js
CHANGED
|
@@ -303,6 +303,55 @@ function participantLine(p) {
|
|
|
303
303
|
// A chat setting either reached app state — and so every other device — or it
|
|
304
304
|
// did not, and stayed here. Saying which costs one word and saves the user
|
|
305
305
|
// wondering why their phone did not follow.
|
|
306
|
+
// A live HH:MM:SS countdown, redrawn in place once a second.
|
|
307
|
+
//
|
|
308
|
+
// The restriction's end is a fixed moment, so every tick recomputes from it
|
|
309
|
+
// rather than decrementing a counter — a tick that arrives late, or a laptop
|
|
310
|
+
// that slept, then shows the truth instead of drifting further behind.
|
|
311
|
+
//
|
|
312
|
+
// Returns a promise that settles when the time runs out or the user stops it.
|
|
313
|
+
function countdown(client, initial) {
|
|
314
|
+
return new Promise((resolve) => {
|
|
315
|
+
const tty = process.stdout.isTTY;
|
|
316
|
+
let done = false;
|
|
317
|
+
|
|
318
|
+
const finish = (why) => {
|
|
319
|
+
if (done) return;
|
|
320
|
+
done = true;
|
|
321
|
+
clearInterval(timer);
|
|
322
|
+
process.stdin.removeListener('data', onKey);
|
|
323
|
+
if (tty) process.stdout.write('\n');
|
|
324
|
+
resolve(why);
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const draw = () => {
|
|
328
|
+
const s = client.getReachoutTimelock();
|
|
329
|
+
if (!s.active) {
|
|
330
|
+
if (tty) process.stdout.write('\r\x1b[2K');
|
|
331
|
+
out(' the restriction has been lifted — you can start new chats again');
|
|
332
|
+
return finish('lifted');
|
|
333
|
+
}
|
|
334
|
+
const line = ' restricted — ' + s.remaining + ' remaining' +
|
|
335
|
+
(s.expiryUnknown ? ' (server did not give an end time; re-checking)' : '');
|
|
336
|
+
if (tty) process.stdout.write('\r\x1b[2K' + line);
|
|
337
|
+
else out(line);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// Any keypress stops the countdown; the restriction is unaffected either
|
|
341
|
+
// way, so there is nothing to confirm.
|
|
342
|
+
const onKey = () => finish('stopped');
|
|
343
|
+
if (tty && process.stdin.isTTY) {
|
|
344
|
+
process.stdin.resume();
|
|
345
|
+
process.stdin.once('data', onKey);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (tty) out(' press any key to stop watching');
|
|
349
|
+
void initial;
|
|
350
|
+
draw();
|
|
351
|
+
const timer = setInterval(draw, 1000);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
306
355
|
function chatResult(label, synced) {
|
|
307
356
|
return label + (synced ? '' : ' (this session only — no app state key)');
|
|
308
357
|
}
|
|
@@ -427,6 +476,8 @@ const HELP = `
|
|
|
427
476
|
/unstar <jid> <msgId> [me] unstar a message
|
|
428
477
|
/appstate [collection...] pull pins/archives/mutes/stars from your phone
|
|
429
478
|
/appstate --snapshot re-read all of it from scratch
|
|
479
|
+
/restriction account restriction + live countdown
|
|
480
|
+
/restriction --once just the numbers, no countdown
|
|
430
481
|
/ephemeral <jid> <seconds> set disappearing timer for chat
|
|
431
482
|
/ephemeral-default <seconds> set default timer for ALL new chats
|
|
432
483
|
/block <jid> block contact
|
|
@@ -600,6 +651,20 @@ function attachEvents(client) {
|
|
|
600
651
|
_rl && (_rl.resume(), _rl.prompt(true));
|
|
601
652
|
});
|
|
602
653
|
|
|
654
|
+
// The server announces a restriction starting and being lifted, so a session
|
|
655
|
+
// that is just sitting there still finds out.
|
|
656
|
+
client.on('account_restriction', (r) => {
|
|
657
|
+
if (r.source !== 'notification') return;
|
|
658
|
+
_rl && _rl.pause();
|
|
659
|
+
if (r.active) {
|
|
660
|
+
out(' ACCOUNT RESTRICTED — ' + r.reason + ', ' + r.remaining + ' remaining');
|
|
661
|
+
out(' run /restriction to watch the countdown');
|
|
662
|
+
} else {
|
|
663
|
+
out(' account restriction lifted — you can start new chats again');
|
|
664
|
+
}
|
|
665
|
+
_rl && (_rl.resume(), _rl.prompt(true));
|
|
666
|
+
});
|
|
667
|
+
|
|
603
668
|
client.on('app_state_keys', (e) => {
|
|
604
669
|
_rl && _rl.pause();
|
|
605
670
|
out(' app state: received ' + e.keys.length + ' sync key(s) from your phone —' +
|
|
@@ -1412,6 +1477,60 @@ async function handleLine(line) {
|
|
|
1412
1477
|
out('marked read');
|
|
1413
1478
|
break;
|
|
1414
1479
|
|
|
1480
|
+
case '/restriction':
|
|
1481
|
+
case '/limit': {
|
|
1482
|
+
requireConn();
|
|
1483
|
+
const watch = !p.includes('--once');
|
|
1484
|
+
out('checking account restriction...');
|
|
1485
|
+
let st;
|
|
1486
|
+
try {
|
|
1487
|
+
st = await _client.fetchReachoutTimelock();
|
|
1488
|
+
} catch (e) {
|
|
1489
|
+
fail(e.message);
|
|
1490
|
+
if (e.mexFormat) {
|
|
1491
|
+
out('');
|
|
1492
|
+
out(' The query itself worked — your server encodes this answer in a binary');
|
|
1493
|
+
out(' format this library cannot read yet, so the countdown cannot be polled.');
|
|
1494
|
+
out('');
|
|
1495
|
+
out(' You will still be told about a restriction: the server announces one when');
|
|
1496
|
+
out(' it starts and when it lifts, and those announcements do arrive readable.');
|
|
1497
|
+
out(' Leave the session connected and watch for:');
|
|
1498
|
+
out(' ACCOUNT RESTRICTED — <reason>, HH:MM:SS remaining');
|
|
1499
|
+
out('');
|
|
1500
|
+
out(' The payload above is worth keeping. Run this again while the account IS');
|
|
1501
|
+
out(' restricted: the two payloads differ only where the answer differs, which');
|
|
1502
|
+
out(' is what makes this format readable without its schema.');
|
|
1503
|
+
} else {
|
|
1504
|
+
out('');
|
|
1505
|
+
out(' If the reply is sketched above, paste that line when reporting this —');
|
|
1506
|
+
out(' it is the server\'s actual answer. Run with --debug for the raw stanza.');
|
|
1507
|
+
}
|
|
1508
|
+
break;
|
|
1509
|
+
}
|
|
1510
|
+
hr();
|
|
1511
|
+
if (!st.active) {
|
|
1512
|
+
kv('status', 'not restricted — you can start new chats');
|
|
1513
|
+
hr();
|
|
1514
|
+
break;
|
|
1515
|
+
}
|
|
1516
|
+
kv('status', 'RESTRICTED');
|
|
1517
|
+
kv('reason', st.reason);
|
|
1518
|
+
kv('type', st.enforcementType);
|
|
1519
|
+
kv('ends at', st.endsAtDate ? st.endsAtDate.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC') : 'unknown');
|
|
1520
|
+
kv('remaining', st.remaining);
|
|
1521
|
+
hr();
|
|
1522
|
+
out(' New chats with people you have never messaged are refused with');
|
|
1523
|
+
out(' error 463 until this expires. Existing conversations keep working,');
|
|
1524
|
+
out(' and sending more only makes the restriction longer.');
|
|
1525
|
+
out('');
|
|
1526
|
+
if (watch) {
|
|
1527
|
+
_rl && _rl.pause();
|
|
1528
|
+
await countdown(_client, st);
|
|
1529
|
+
_rl && _rl.resume();
|
|
1530
|
+
}
|
|
1531
|
+
break;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1415
1534
|
case '/appstate': {
|
|
1416
1535
|
requireConn();
|
|
1417
1536
|
const snapshot = p.includes('--snapshot');
|
package/index.js
CHANGED
|
@@ -13,6 +13,7 @@ const { encryptMedia, decryptMedia, uploadMedia, downloadMedia } = require('./li
|
|
|
13
13
|
const { MessageSender, makeJid, generateMessageId } = require('./lib/messages/MessageSender');
|
|
14
14
|
const { GroupParticipantResult, GroupJoinRequest } = require('./lib/GroupParticipant');
|
|
15
15
|
const { AppStateStore, COLLECTIONS: APP_STATE_COLLECTIONS } = require('./lib/appstate/AppStateStore');
|
|
16
|
+
const ReachoutTimelock = require('./lib/ReachoutTimelock');
|
|
16
17
|
const AppStateSync = require('./lib/appstate/AppStateSync');
|
|
17
18
|
const { PATCH_INTEGRITY: LT_HASH } = require('./lib/appstate/LTHash');
|
|
18
19
|
const {
|
|
@@ -110,6 +111,8 @@ module.exports = {
|
|
|
110
111
|
AppStateSync,
|
|
111
112
|
APP_STATE_COLLECTIONS,
|
|
112
113
|
LT_HASH,
|
|
114
|
+
// Account restriction
|
|
115
|
+
ReachoutTimelock,
|
|
113
116
|
// Auth utilities
|
|
114
117
|
makeCacheableSignalKeyStore,
|
|
115
118
|
addTransactionCapability,
|
package/lib/Client.js
CHANGED
|
@@ -13,6 +13,7 @@ const { getDeviceConfig } = require('./DeviceConfig');
|
|
|
13
13
|
const { createNewStore, saveStore, loadStore, toSixParts, fromSixParts } = require('./Store');
|
|
14
14
|
const { BinaryNode } = require('./BinaryNode');
|
|
15
15
|
const { AppStateStore, COLLECTIONS } = require('./appstate/AppStateStore');
|
|
16
|
+
const ReachoutTimelock = require('./ReachoutTimelock');
|
|
16
17
|
const AppStateSync = require('./appstate/AppStateSync');
|
|
17
18
|
const SyncdProto = require('./appstate/SyncdProto');
|
|
18
19
|
const AppMutations = require('./appstate/Mutations');
|
|
@@ -50,6 +51,12 @@ const CALL_VIDEO_PREFIX = 'https://call.whatsapp.com/video/';
|
|
|
50
51
|
// The one prefix group invite codes are wrapped in.
|
|
51
52
|
const INVITE_LINK_PREFIX = 'https://chat.whatsapp.com/';
|
|
52
53
|
|
|
54
|
+
// The w:mex query that reports whether this account is restricted and until
|
|
55
|
+
// when. The id is the server's handle for that stored GraphQL document; there
|
|
56
|
+
// is no way to derive it, and a wrong one is answered with an error rather than
|
|
57
|
+
// with a different query.
|
|
58
|
+
const MEX_QUERY_REACHOUT_TIMELOCK = '23983697327930364';
|
|
59
|
+
|
|
53
60
|
// The names privacy settings actually go by on the wire, and the friendly
|
|
54
61
|
// aliases this library has always accepted for them. The wire names are the
|
|
55
62
|
// right-hand column.
|
|
@@ -170,6 +177,39 @@ function findChildDeep(node, desc) {
|
|
|
170
177
|
return null;
|
|
171
178
|
}
|
|
172
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
|
+
|
|
173
213
|
function getNodeContent(node) {
|
|
174
214
|
if (!node) return null;
|
|
175
215
|
if (Buffer.isBuffer(node.content)) return node.content;
|
|
@@ -246,6 +286,9 @@ class WhalibmobClient extends EventEmitter {
|
|
|
246
286
|
this._tcTokenStore = null; // TcTokenStore — loaded in init()
|
|
247
287
|
this._inFlightTcTokenIssuance = new Set(); // dedupe concurrent proactive issuePrivacyTokens per JID
|
|
248
288
|
this._inFlight463Recoveries = new Set(); // dedupe concurrent 463-triggered token issuances per JID (separate from proactive)
|
|
289
|
+
// Last known account restriction, or null until one is fetched or pushed.
|
|
290
|
+
this._reachoutTimelock = null;
|
|
291
|
+
this._reachoutTimelockInFlight = null;
|
|
249
292
|
// Blue-tick every message as it arrives. Set to false to make read receipts
|
|
250
293
|
// explicit, sent only by a markRead() call.
|
|
251
294
|
this.autoRead = opts.autoRead !== false;
|
|
@@ -2596,6 +2639,10 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2596
2639
|
this._processGroupUpdate(node);
|
|
2597
2640
|
}
|
|
2598
2641
|
|
|
2642
|
+
if (type === 'mex') {
|
|
2643
|
+
this._handleMexNotification(node);
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2599
2646
|
if (type === 'privacy_token') {
|
|
2600
2647
|
// Incoming trusted-contact token from a conversation partner — store it
|
|
2601
2648
|
// so we can attach it on next DM send (prevents error 463).
|
|
@@ -4833,6 +4880,219 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4833
4880
|
return this._sendIq(node);
|
|
4834
4881
|
}
|
|
4835
4882
|
|
|
4883
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
4884
|
+
// ACCOUNT RESTRICTION (reachout timelock)
|
|
4885
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
4886
|
+
|
|
4887
|
+
/**
|
|
4888
|
+
* Ask the server whether this account is currently restricted, and until when.
|
|
4889
|
+
*
|
|
4890
|
+
* A restriction is what error 463 means when it will not go away: WhatsApp
|
|
4891
|
+
* has decided the account is starting too many chats with people who never
|
|
4892
|
+
* reply, and refuses new ones until it expires. Existing chats keep working,
|
|
4893
|
+
* which is why the account otherwise looks fine.
|
|
4894
|
+
*
|
|
4895
|
+
* The expiry is not pushed to the client — this is the only way to learn it.
|
|
4896
|
+
*
|
|
4897
|
+
* @returns {Promise<object>} { active, remaining, remainingMs, endsAtDate,
|
|
4898
|
+
* enforcementType, reason, expiryUnknown }
|
|
4899
|
+
*/
|
|
4900
|
+
async fetchReachoutTimelock() {
|
|
4901
|
+
const payload = await this._mexQuery(MEX_QUERY_REACHOUT_TIMELOCK, {},
|
|
4902
|
+
'xwa2_fetch_account_reachout_timelock');
|
|
4903
|
+
return this._setReachoutTimelock(
|
|
4904
|
+
ReachoutTimelock.parseTimelockPayload(payload || {}), 'query');
|
|
4905
|
+
}
|
|
4906
|
+
|
|
4907
|
+
/**
|
|
4908
|
+
* What we last learned about the restriction, without asking again.
|
|
4909
|
+
*
|
|
4910
|
+
* The countdown is computed fresh on every call, so this is what a ticking
|
|
4911
|
+
* display reads once a second. Returns an inactive state when nothing has
|
|
4912
|
+
* been fetched yet — call fetchReachoutTimelock() for that.
|
|
4913
|
+
*/
|
|
4914
|
+
getReachoutTimelock() {
|
|
4915
|
+
return ReachoutTimelock.describe(this._reachoutTimelock);
|
|
4916
|
+
}
|
|
4917
|
+
|
|
4918
|
+
/**
|
|
4919
|
+
* Refresh the restriction after a send was refused, at most once a minute.
|
|
4920
|
+
*
|
|
4921
|
+
* A 463 has two causes that look identical on the wire: a contact we simply
|
|
4922
|
+
* hold no privacy token for, which the resend recovers from, and an account
|
|
4923
|
+
* restriction, which no resend will fix. Asking the server tells the two
|
|
4924
|
+
* apart, and it is the only way to learn when the restriction ends.
|
|
4925
|
+
*
|
|
4926
|
+
* Rate-limited because a burst of refused sends would otherwise fire one
|
|
4927
|
+
* query each, and a restricted account is exactly where a burst happens.
|
|
4928
|
+
*/
|
|
4929
|
+
_refreshReachoutTimelockAfter463() {
|
|
4930
|
+
if (!this._connected) return;
|
|
4931
|
+
const last = this._reachoutTimelock && this._reachoutTimelock.checkedAt;
|
|
4932
|
+
if (last && Date.now() - last < 60000) return;
|
|
4933
|
+
if (this._reachoutTimelockInFlight) return;
|
|
4934
|
+
|
|
4935
|
+
this._reachoutTimelockInFlight = this.fetchReachoutTimelock()
|
|
4936
|
+
.then(state => {
|
|
4937
|
+
if (state.active) {
|
|
4938
|
+
_whaDbg('[DBG] ACCOUNT_RESTRICTED for ' + state.remaining +
|
|
4939
|
+
' (' + state.enforcementType + ')');
|
|
4940
|
+
}
|
|
4941
|
+
})
|
|
4942
|
+
.catch(err => _whaDbg('[DBG] REACHOUT_TIMELOCK fetch failed: ' + (err && err.message)))
|
|
4943
|
+
.then(() => { this._reachoutTimelockInFlight = null; });
|
|
4944
|
+
}
|
|
4945
|
+
|
|
4946
|
+
_setReachoutTimelock(state, source) {
|
|
4947
|
+
this._reachoutTimelock = Object.assign({}, state, { checkedAt: Date.now() });
|
|
4948
|
+
const described = ReachoutTimelock.describe(this._reachoutTimelock);
|
|
4949
|
+
this.emit('account_restriction', Object.assign({ source }, described));
|
|
4950
|
+
return described;
|
|
4951
|
+
}
|
|
4952
|
+
|
|
4953
|
+
/**
|
|
4954
|
+
* One w:mex GraphQL call.
|
|
4955
|
+
*
|
|
4956
|
+
* The reply is JSON inside <result>. GraphQL reports failures in the body
|
|
4957
|
+
* with a 200-shaped envelope, so an errors array has to be checked for
|
|
4958
|
+
* separately — otherwise a refused query reads as an empty result and the
|
|
4959
|
+
* caller concludes the account is fine.
|
|
4960
|
+
*/
|
|
4961
|
+
async _mexQuery(queryId, variables, dataPath) {
|
|
4962
|
+
const body = Buffer.from(JSON.stringify({ variables: variables || {} }), 'utf8');
|
|
4963
|
+
|
|
4964
|
+
const send = (askForJson) => {
|
|
4965
|
+
const attrs = { query_id: String(queryId) };
|
|
4966
|
+
// Newer servers answer this transport in Argo, Meta's binary GraphQL
|
|
4967
|
+
// encoding, which nothing here can read. The reply labels its own format,
|
|
4968
|
+
// so the format is evidently negotiable — ask for the one we can parse.
|
|
4969
|
+
// A server that does not know the attribute ignores it, and one that
|
|
4970
|
+
// refuses the stanza over it is handled below.
|
|
4971
|
+
if (askForJson) attrs.format = 'json';
|
|
4972
|
+
return this._sendIq(new BinaryNode('iq', {
|
|
4973
|
+
id: this._genMsgId(),
|
|
4974
|
+
to: 's.whatsapp.net',
|
|
4975
|
+
type: 'get',
|
|
4976
|
+
xmlns: 'w:mex'
|
|
4977
|
+
}, [new BinaryNode('query', attrs, body)]));
|
|
4978
|
+
};
|
|
4979
|
+
|
|
4980
|
+
const hinted = this._mexJsonHintWorks !== false;
|
|
4981
|
+
let resp = await send(hinted);
|
|
4982
|
+
|
|
4983
|
+
// The hint is the only thing that changed, so a refusal means the server
|
|
4984
|
+
// does not accept it. Drop it for this session and ask again plainly.
|
|
4985
|
+
if (hinted && resp && resp.attrs && resp.attrs.type === 'error') {
|
|
4986
|
+
this._mexJsonHintWorks = false;
|
|
4987
|
+
_whaDbg('[DBG] MEX format=json hint refused — retrying without it');
|
|
4988
|
+
resp = await send(false);
|
|
4989
|
+
}
|
|
4990
|
+
|
|
4991
|
+
if (!resp) throw new Error('mex query ' + queryId + ': no reply from server (IQ timed out)');
|
|
4992
|
+
|
|
4993
|
+
// An <error> child is the refusal whether or not the iq is typed as one.
|
|
4994
|
+
const errNode = (resp.attrs && resp.attrs.type === 'error')
|
|
4995
|
+
? findChildDeep(resp, 'error') : findChild(resp, 'error');
|
|
4996
|
+
if (errNode) {
|
|
4997
|
+
const a = errNode.attrs || {};
|
|
4998
|
+
const code = a.code ? String(a.code) : null;
|
|
4999
|
+
const text = a.text ? String(a.text) : null;
|
|
5000
|
+
throw new Error('mex query ' + queryId + ': server rejected the request' +
|
|
5001
|
+
(code ? ' (' + code + (text ? ' ' + text : '') + ')' : ''));
|
|
5002
|
+
}
|
|
5003
|
+
|
|
5004
|
+
// The JSON normally sits in <result>, but not every build wraps it the same
|
|
5005
|
+
// way, so take it from wherever in the reply it turns up rather than from
|
|
5006
|
+
// one fixed path.
|
|
5007
|
+
const json = this._mexJsonFrom(resp);
|
|
5008
|
+
if (json) {
|
|
5009
|
+
if (json.errors && json.errors.length) {
|
|
5010
|
+
throw new Error('mex query ' + queryId + ': ' +
|
|
5011
|
+
json.errors.map(e => (e && e.message) || 'unknown error').join(', '));
|
|
5012
|
+
}
|
|
5013
|
+
return dataPath ? (json.data && json.data[dataPath]) : json.data;
|
|
5014
|
+
}
|
|
5015
|
+
|
|
5016
|
+
// The query itself worked — this is purely about how the answer was
|
|
5017
|
+
// encoded, and saying so is the difference between a fixable report and a
|
|
5018
|
+
// shrug.
|
|
5019
|
+
const resultNode = findChildDeep(resp, 'result');
|
|
5020
|
+
const format = resultNode && resultNode.attrs && resultNode.attrs.format;
|
|
5021
|
+
if (format && String(format).toLowerCase() !== 'json') {
|
|
5022
|
+
const raw = getNodeContent(resultNode);
|
|
5023
|
+
const err = new Error('mex query ' + queryId + ': the server answered in "' +
|
|
5024
|
+
format + '" rather than JSON, which this library cannot read' +
|
|
5025
|
+
(raw ? ' (' + raw.length + ' bytes: ' + raw.toString('hex') + ')' : ''));
|
|
5026
|
+
err.mexFormat = String(format);
|
|
5027
|
+
err.mexPayload = raw || null;
|
|
5028
|
+
_whaDbg('[DBG] MEX_REPLY_FORMAT ' + format + ' — cannot decode');
|
|
5029
|
+
throw err;
|
|
5030
|
+
}
|
|
5031
|
+
|
|
5032
|
+
_whaDbg('[DBG] MEX_REPLY_UNPARSED ' + JSON.stringify(describeNodeBriefly(resp)));
|
|
5033
|
+
throw new Error('mex query ' + queryId + ': the reply carried no JSON — ' +
|
|
5034
|
+
describeNodeBriefly(resp));
|
|
5035
|
+
}
|
|
5036
|
+
|
|
5037
|
+
// Walk the reply for the first node whose content parses as a JSON object.
|
|
5038
|
+
//
|
|
5039
|
+
// Content arrives as bytes for a long payload and as a plain string when the
|
|
5040
|
+
// encoder had a token for it, so both are tried.
|
|
5041
|
+
_mexJsonFrom(node) {
|
|
5042
|
+
if (!node) return null;
|
|
5043
|
+
const raw = getNodeContent(node);
|
|
5044
|
+
if (raw && raw.length) {
|
|
5045
|
+
const text = raw.toString('utf8').trim();
|
|
5046
|
+
if (text.startsWith('{') || text.startsWith('[')) {
|
|
5047
|
+
try {
|
|
5048
|
+
const parsed = JSON.parse(text);
|
|
5049
|
+
if (parsed && typeof parsed === 'object') return parsed;
|
|
5050
|
+
} catch (_) { /* not this node */ }
|
|
5051
|
+
}
|
|
5052
|
+
}
|
|
5053
|
+
if (Array.isArray(node.content)) {
|
|
5054
|
+
for (const child of node.content) {
|
|
5055
|
+
const found = this._mexJsonFrom(child);
|
|
5056
|
+
if (found) return found;
|
|
5057
|
+
}
|
|
5058
|
+
}
|
|
5059
|
+
return null;
|
|
5060
|
+
}
|
|
5061
|
+
|
|
5062
|
+
/**
|
|
5063
|
+
* <notification type="mex"> — a GraphQL push rather than a reply.
|
|
5064
|
+
*
|
|
5065
|
+
* The restriction is the one that matters here: the server announces both
|
|
5066
|
+
* when it starts and when it is lifted, so a client that listens does not
|
|
5067
|
+
* have to poll to know it can send again.
|
|
5068
|
+
*/
|
|
5069
|
+
_handleMexNotification(node) {
|
|
5070
|
+
const update = findChild(node, 'update');
|
|
5071
|
+
if (!update) return;
|
|
5072
|
+
const opName = (update.attrs && update.attrs.op_name) || '';
|
|
5073
|
+
const buf = getNodeContent(update);
|
|
5074
|
+
if (!buf) return;
|
|
5075
|
+
|
|
5076
|
+
let json;
|
|
5077
|
+
try { json = JSON.parse(buf.toString('utf8')); }
|
|
5078
|
+
catch (_) { _whaDbg('[DBG] MEX_NOTIF ' + opName + ' — body was not JSON'); return; }
|
|
5079
|
+
if (json.errors && json.errors.length) return;
|
|
5080
|
+
const data = json.data;
|
|
5081
|
+
if (!data) return;
|
|
5082
|
+
|
|
5083
|
+
if (opName === 'NotificationUserReachoutTimelockUpdate') {
|
|
5084
|
+
const payload = data.xwa2_notify_account_reachout_timelock;
|
|
5085
|
+
if (!payload) return;
|
|
5086
|
+
const state = ReachoutTimelock.parseTimelockPayload(payload);
|
|
5087
|
+
_whaDbg('[DBG] MEX_NOTIF restriction ' + (state.active ? 'set' : 'lifted'));
|
|
5088
|
+
this._setReachoutTimelock(state, 'notification');
|
|
5089
|
+
return;
|
|
5090
|
+
}
|
|
5091
|
+
|
|
5092
|
+
// Anything else the server pushes this way, passed on rather than dropped.
|
|
5093
|
+
this.emit('mex_notification', { opName, data });
|
|
5094
|
+
}
|
|
5095
|
+
|
|
4836
5096
|
// Internal: parse newsletter response from w:mex IQ result
|
|
4837
5097
|
_parseNewsletterResponse(resp) {
|
|
4838
5098
|
try {
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The "reachout timelock" — the temporary restriction WhatsApp puts on an
|
|
4
|
+
// account that has started too many chats with people who have never replied.
|
|
5
|
+
//
|
|
6
|
+
// While it is on, messages to a contact you have no history with are refused
|
|
7
|
+
// with error 463 and nothing you do client-side gets them through. The
|
|
8
|
+
// restriction is not permanent: the server holds an expiry, and once it passes
|
|
9
|
+
// the account goes back to normal on its own. Five hours is the usual first
|
|
10
|
+
// one; repeat offences get longer.
|
|
11
|
+
//
|
|
12
|
+
// The expiry is not on any stanza the client is sent by default — it has to be
|
|
13
|
+
// asked for, over the GraphQL-style w:mex transport.
|
|
14
|
+
|
|
15
|
+
// The reason a restriction was applied. DEFAULT is the one that means "no
|
|
16
|
+
// restriction", which is also what the server sends when it is lifting one.
|
|
17
|
+
const ENFORCEMENT_TYPES = new Set([
|
|
18
|
+
'DEFAULT',
|
|
19
|
+
'BIZ_QUALITY',
|
|
20
|
+
'WEB_COMPANION_ONLY',
|
|
21
|
+
'BIZ_COMMERCE_VIOLATION_ALCOHOL',
|
|
22
|
+
'BIZ_COMMERCE_VIOLATION_ADULT',
|
|
23
|
+
'BIZ_COMMERCE_VIOLATION_ANIMALS',
|
|
24
|
+
'BIZ_COMMERCE_VIOLATION_BODY_PARTS_FLUIDS',
|
|
25
|
+
'BIZ_COMMERCE_VIOLATION_DATING',
|
|
26
|
+
'BIZ_COMMERCE_VIOLATION_DIGITAL_SERVICES_PRODUCTS',
|
|
27
|
+
'BIZ_COMMERCE_VIOLATION_DRUGS',
|
|
28
|
+
'BIZ_COMMERCE_VIOLATION_DRUGS_ONLY_OTC',
|
|
29
|
+
'BIZ_COMMERCE_VIOLATION_GAMBLING',
|
|
30
|
+
'BIZ_COMMERCE_VIOLATION_HEALTHCARE',
|
|
31
|
+
'BIZ_COMMERCE_VIOLATION_REAL_FAKE_CURRENCY',
|
|
32
|
+
'BIZ_COMMERCE_VIOLATION_SUPPLEMENTS',
|
|
33
|
+
'BIZ_COMMERCE_VIOLATION_TOBACCO',
|
|
34
|
+
'BIZ_COMMERCE_VIOLATION_VIOLENT_CONTENT',
|
|
35
|
+
'BIZ_COMMERCE_VIOLATION_WEAPONS'
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
// Plain-language reasons for the ones a normal account actually meets.
|
|
39
|
+
const ENFORCEMENT_REASONS = {
|
|
40
|
+
DEFAULT: 'no restriction',
|
|
41
|
+
BIZ_QUALITY: 'too many people you messaged blocked or reported you',
|
|
42
|
+
WEB_COMPANION_ONLY: 'restricted to the linked-device app'
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// When the server says a restriction is on but does not say when it ends, it
|
|
46
|
+
// means "ask again shortly" rather than "forever". A minute is what WhatsApp
|
|
47
|
+
// Web assumes in the same spot.
|
|
48
|
+
const UNKNOWN_EXPIRY_MS = 60 * 1000;
|
|
49
|
+
|
|
50
|
+
function normalizeEnforcementType(value) {
|
|
51
|
+
return (typeof value === 'string' && ENFORCEMENT_TYPES.has(value)) ? value : 'DEFAULT';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Turn what the server sent into the shape the rest of the library uses.
|
|
56
|
+
*
|
|
57
|
+
* `time_enforcement_ends` is unix *seconds* as a string, and "0" is the
|
|
58
|
+
* server's way of saying there is no expiry to report — treating it as a date
|
|
59
|
+
* would put the restriction at 1970 and make it look long expired.
|
|
60
|
+
*/
|
|
61
|
+
function parseTimelockPayload(payload, opts) {
|
|
62
|
+
const p = payload || {};
|
|
63
|
+
const active = !!p.is_active;
|
|
64
|
+
|
|
65
|
+
if (!active) {
|
|
66
|
+
return { active: false, endsAt: null, enforcementType: 'DEFAULT', reason: ENFORCEMENT_REASONS.DEFAULT };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const raw = p.time_enforcement_ends;
|
|
70
|
+
const ts = (raw != null && String(raw) !== '0') ? parseInt(String(raw), 10) : NaN;
|
|
71
|
+
const endsAt = Number.isFinite(ts) && ts > 0
|
|
72
|
+
? ts * 1000
|
|
73
|
+
: ((opts && opts.now ? opts.now : Date.now()) + UNKNOWN_EXPIRY_MS);
|
|
74
|
+
|
|
75
|
+
const enforcementType = normalizeEnforcementType(p.enforcement_type);
|
|
76
|
+
return {
|
|
77
|
+
active: true,
|
|
78
|
+
endsAt,
|
|
79
|
+
enforcementType,
|
|
80
|
+
reason: ENFORCEMENT_REASONS[enforcementType] || 'account restricted',
|
|
81
|
+
// The server did not tell us when it ends, so this is a guess and the
|
|
82
|
+
// caller should ask again rather than trust the countdown.
|
|
83
|
+
expiryUnknown: !(Number.isFinite(ts) && ts > 0)
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Milliseconds left, floored at zero. */
|
|
88
|
+
function remainingMs(state, now) {
|
|
89
|
+
if (!state || !state.active || !state.endsAt) return 0;
|
|
90
|
+
return Math.max(0, state.endsAt - (now == null ? Date.now() : now));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* HH:MM:SS, which is how long a restriction is worth showing to a person.
|
|
95
|
+
* Hours are not wrapped at 24 — a two-day restriction reads "48:00:00" rather
|
|
96
|
+
* than looking like a fresh one.
|
|
97
|
+
*/
|
|
98
|
+
function formatRemaining(ms) {
|
|
99
|
+
const total = Math.max(0, Math.ceil(ms / 1000));
|
|
100
|
+
const h = Math.floor(total / 3600);
|
|
101
|
+
const m = Math.floor((total % 3600) / 60);
|
|
102
|
+
const s = total % 60;
|
|
103
|
+
const pad = n => String(n).padStart(2, '0');
|
|
104
|
+
return pad(h) + ':' + pad(m) + ':' + pad(s);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A state object with the derived fields filled in, for handing to a caller. */
|
|
108
|
+
function describe(state, now) {
|
|
109
|
+
const base = state || { active: false, endsAt: null, enforcementType: 'DEFAULT' };
|
|
110
|
+
const left = remainingMs(base, now);
|
|
111
|
+
return {
|
|
112
|
+
active: !!base.active && left > 0,
|
|
113
|
+
endsAt: base.endsAt || null,
|
|
114
|
+
endsAtDate: base.endsAt ? new Date(base.endsAt) : null,
|
|
115
|
+
remainingMs: left,
|
|
116
|
+
remaining: formatRemaining(left),
|
|
117
|
+
enforcementType: base.enforcementType || 'DEFAULT',
|
|
118
|
+
reason: base.reason || ENFORCEMENT_REASONS.DEFAULT,
|
|
119
|
+
expiryUnknown: !!base.expiryUnknown,
|
|
120
|
+
checkedAt: base.checkedAt || null
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
parseTimelockPayload,
|
|
126
|
+
remainingMs,
|
|
127
|
+
formatRemaining,
|
|
128
|
+
describe,
|
|
129
|
+
normalizeEnforcementType,
|
|
130
|
+
ENFORCEMENT_TYPES,
|
|
131
|
+
ENFORCEMENT_REASONS,
|
|
132
|
+
UNKNOWN_EXPIRY_MS
|
|
133
|
+
};
|
|
@@ -1513,6 +1513,13 @@ class MessageSender {
|
|
|
1513
1513
|
// token attached. Uses a separate _inFlight463Recoveries Set so
|
|
1514
1514
|
// concurrent 463 recoveries and proactive issuances don't clash.
|
|
1515
1515
|
if (error === '463' && !_isRetry) {
|
|
1516
|
+
// A 463 is either a missing privacy token for this one contact,
|
|
1517
|
+
// which the resend below fixes, or an account-wide restriction,
|
|
1518
|
+
// which it cannot. Ask the server which it is — that answer also
|
|
1519
|
+
// carries when a restriction ends, which nothing else reports.
|
|
1520
|
+
if (typeof client._refreshReachoutTimelockAfter463 === 'function') {
|
|
1521
|
+
client._refreshReachoutTimelockAfter463();
|
|
1522
|
+
}
|
|
1516
1523
|
const ackFrom = ackAttrs.from ? String(ackAttrs.from) : '';
|
|
1517
1524
|
const tcJid = client._resolveTcTokenJid(
|
|
1518
1525
|
ackFrom || String(node.attrs && node.attrs.to || '')
|
package/package.json
CHANGED