whalibmob 5.16.0 → 5.18.0
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 +57 -0
- package/lib/Client.js +133 -0
- package/lib/DeviceManager.js +76 -1
- package/lib/Registration.js +1 -1
- package/lib/messages/MessageSender.js +41 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
CONTACT ME ON TELEGRAM IF YOU WANT TO WORK WITH ME AND IF YOU HAVE PROBLEM WITH WHALIBMOB : @brtyu545
|
|
11
11
|
|
|
12
|
+
**Need test numbers to try whalibmob with?** Message me on Telegram at **@brtyu545** — I can provide phone numbers for receiving SMS verification codes, so you can register and test whalibmob without using your own number.
|
|
13
|
+
|
|
12
14
|
If you want News about whalibmob enter this whalibmob channel: https://t.me/+sHN4MDCyB7U5OWY0
|
|
13
15
|
|
|
14
16
|
|
|
@@ -2005,6 +2007,7 @@ Every option is optional; `sessionDir` is the only one most senders ever set.
|
|
|
2005
2007
|
| `sessionDir` | `~/.waSession` | The authentication folder. Each number gets its own subfolder inside it — see [Saving & Restoring Sessions](#saving--restoring-sessions). |
|
|
2006
2008
|
| `autoFixNumber` | `true` | Re-file the session automatically when the server reports the account under a different number. Set `false` to be told instead of fixed — see [The Number WhatsApp Files Your Account Under](#the-number-whatsapp-files-your-account-under). |
|
|
2007
2009
|
| `autoRead` | `true` | Send read receipts for incoming messages. `false` leaves them unread. |
|
|
2010
|
+
| `refreshVersion` | `true` | **iOS sessions only.** Check the App Store for the current build on every `init()` and write it into the session file. Set `false` to keep announcing whatever the session registered with — see [Keeping the Announced Version Current](#keeping-the-announced-version-current). Android is unaffected either way. |
|
|
2008
2011
|
| `pino` | off | Debug logging. `true` turns it on at `debug` level; an object is passed to `pino` as-is. |
|
|
2009
2012
|
| `sentCacheSize` | `2000` | How many sent messages keep their plaintext so a retry receipt naming them can be answered. |
|
|
2010
2013
|
| `maxRetryResends` | `5` | How many times one message may be re-sent in answer to retry receipts before the client gives up. |
|
|
@@ -2571,6 +2574,60 @@ of a pile by prefix.
|
|
|
2571
2574
|
> is moved unless you ask — `wa migrate-sessions` does that, one number or all
|
|
2572
2575
|
> of them, and re-running it is safe.
|
|
2573
2576
|
|
|
2577
|
+
### Keeping the Announced Version Current
|
|
2578
|
+
|
|
2579
|
+
Every connection announces the WhatsApp build it claims to be. The server checks
|
|
2580
|
+
it, and when it stops recognising the number it refuses the handshake with
|
|
2581
|
+
`405` — a failure that says nothing about the version and has nothing to do with
|
|
2582
|
+
the account.
|
|
2583
|
+
|
|
2584
|
+
A session records the version it registered with. Left alone it announces that
|
|
2585
|
+
same number forever, so a number registered in spring is still claiming a spring
|
|
2586
|
+
build in autumn, and one day the connect simply stops working.
|
|
2587
|
+
|
|
2588
|
+
**iOS sessions now refresh themselves.** On every `init()` the client asks the
|
|
2589
|
+
App Store what the current build is and writes it into `<phone>.json` before the
|
|
2590
|
+
handshake. Nothing to run, nothing to remember:
|
|
2591
|
+
|
|
2592
|
+
```js
|
|
2593
|
+
client.on('version_update', ({ from, to }) => {
|
|
2594
|
+
console.log('announcing', to, 'instead of', from)
|
|
2595
|
+
})
|
|
2596
|
+
|
|
2597
|
+
await client.init('40756469325')
|
|
2598
|
+
```
|
|
2599
|
+
|
|
2600
|
+
Three rules keep this from being the thing that breaks a working session:
|
|
2601
|
+
|
|
2602
|
+
- **It never goes backwards.** The App Store lookup answers with a pinned
|
|
2603
|
+
fallback rather than failing when it cannot reach the network, and that
|
|
2604
|
+
fallback can easily be older than what the session holds. Moving a session to
|
|
2605
|
+
an older version is the one outcome that makes a `405` *more* likely.
|
|
2606
|
+
- **`WA_VERSION` still wins.** A version pinned on the way out of a `405` is a
|
|
2607
|
+
decision, and is never quietly replaced.
|
|
2608
|
+
- **A failed lookup changes nothing.** The session keeps the version it has and
|
|
2609
|
+
the connect carries on.
|
|
2610
|
+
|
|
2611
|
+
Set `{ refreshVersion: false }` on the client to turn it off.
|
|
2612
|
+
|
|
2613
|
+
> [!NOTE]
|
|
2614
|
+
> **Android is deliberately left alone.** Its version comes out of the APK the
|
|
2615
|
+
> token material was read from, and the two have to agree — the registration
|
|
2616
|
+
> token is computed from that build. Refreshing the announced version behind the
|
|
2617
|
+
> caller's back would put it out of step with the token. Android keeps the
|
|
2618
|
+
> explicit flow: `wa apk-material --download`, then `wa refresh-version`.
|
|
2619
|
+
|
|
2620
|
+
The manual tool still works on both platforms, and is the only way to move an
|
|
2621
|
+
Android session:
|
|
2622
|
+
|
|
2623
|
+
```bash
|
|
2624
|
+
wa refresh-version 40756469325 # current build for the platform
|
|
2625
|
+
wa refresh-version 40756469325 --version 2.25.1.2 # or one you name
|
|
2626
|
+
```
|
|
2627
|
+
|
|
2628
|
+
Companion sessions have never had this problem: `connectWeb()` reads the live
|
|
2629
|
+
web revision on every connect already.
|
|
2630
|
+
|
|
2574
2631
|
### One-time Pre-keys
|
|
2575
2632
|
|
|
2576
2633
|
A pre-key is a one-shot Diffie-Hellman key the account leaves with the server so
|
package/lib/Client.js
CHANGED
|
@@ -379,6 +379,13 @@ class WhalibmobClient extends EventEmitter {
|
|
|
379
379
|
// automatically on the first refused login. Set { autoFixNumber: false } to
|
|
380
380
|
// be told about it instead.
|
|
381
381
|
this._autoFixNumber = opts.autoFixNumber !== false;
|
|
382
|
+
// An iOS session checks the App Store for the current build on every
|
|
383
|
+
// connect and writes it back — see _refreshIosVersion(). Set
|
|
384
|
+
// { refreshVersion: false } to keep announcing whatever the session
|
|
385
|
+
// registered with. Android is not affected either way: its version comes
|
|
386
|
+
// out of the APK the token material was read from, and that stays a
|
|
387
|
+
// deliberate step.
|
|
388
|
+
this._refreshVersion = opts.refreshVersion !== false;
|
|
382
389
|
this._numberFixAttempted = false;
|
|
383
390
|
this._pingTimer = null;
|
|
384
391
|
this._keepTimer = null;
|
|
@@ -497,6 +504,67 @@ class WhalibmobClient extends EventEmitter {
|
|
|
497
504
|
return this.init(phoneNumber);
|
|
498
505
|
}
|
|
499
506
|
|
|
507
|
+
/**
|
|
508
|
+
* Bring an iOS session's announced version up to date, on every connect.
|
|
509
|
+
*
|
|
510
|
+
* A session records the version it registered with and announces that
|
|
511
|
+
* forever after. Months later the server stops accepting it and the connect
|
|
512
|
+
* is refused with a 405 that says nothing about why — and the only remaining
|
|
513
|
+
* move, before this, was to run `wa refresh-version` by hand or register the
|
|
514
|
+
* number again. The companion path never had that problem because
|
|
515
|
+
* connectWeb() reads the live revision every time; this is the same idea for
|
|
516
|
+
* the primary one.
|
|
517
|
+
*
|
|
518
|
+
* iOS only, and deliberately so. Android's version comes out of the APK the
|
|
519
|
+
* token material was read from — the two have to agree, because the token is
|
|
520
|
+
* computed from that build — so refreshing it behind the caller's back would
|
|
521
|
+
* put the announced version and the token out of step. Android keeps its
|
|
522
|
+
* Play Store flow exactly as it was.
|
|
523
|
+
*
|
|
524
|
+
* Three rules keep this from being the thing that breaks a working session:
|
|
525
|
+
*
|
|
526
|
+
* • It never goes backwards. fetchIosVersion() answers with the pinned
|
|
527
|
+
* fallback rather than failing when the lookup does not work, and that
|
|
528
|
+
* fallback can easily be older than what a session already holds. Moving
|
|
529
|
+
* a session to an older version is the one outcome that makes a 405 more
|
|
530
|
+
* likely rather than less.
|
|
531
|
+
* • WA_VERSION still wins. Someone who has pinned a version on the way out
|
|
532
|
+
* of a 405 must not have it quietly replaced.
|
|
533
|
+
* • It never throws, and it never blocks for long. A lookup that fails
|
|
534
|
+
* leaves the session exactly as it was and the connect carries on.
|
|
535
|
+
*/
|
|
536
|
+
async _refreshIosVersion() {
|
|
537
|
+
if (!this._refreshVersion || this._mode === 'web' || !this._store) return;
|
|
538
|
+
if (process.env.WA_VERSION) {
|
|
539
|
+
_whaDbg('[DBG] VERSION refresh skipped — WA_VERSION is set');
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const device = this._store.device || getDeviceConfig();
|
|
544
|
+
if (device.os === 'android') return; // the APK flow owns this
|
|
545
|
+
|
|
546
|
+
try {
|
|
547
|
+
const { compareVersions } = require('./Registration');
|
|
548
|
+
const before = this._store.version;
|
|
549
|
+
const live = await fetchIosVersion(!!device.business);
|
|
550
|
+
if (!live) return;
|
|
551
|
+
|
|
552
|
+
if (before && compareVersions(live, before) <= 0) {
|
|
553
|
+
_whaDbg('[DBG] VERSION already current (' + before + ', App Store says ' + live + ')');
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
this._store.version = live;
|
|
558
|
+
this._persistStore();
|
|
559
|
+
_whaDbg('[DBG] VERSION refreshed ' + (before || 'none') + ' → ' + live +
|
|
560
|
+
' (App Store listing)');
|
|
561
|
+
this.emit('version_update', { from: before || null, to: live, source: 'App Store listing' });
|
|
562
|
+
} catch (err) {
|
|
563
|
+
// A session that cannot ask keeps the version it has.
|
|
564
|
+
_whaDbg('[DBG] VERSION refresh failed: ' + (err && err.message));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
500
568
|
async init(phoneNumber) {
|
|
501
569
|
phoneNumber = String(phoneNumber).replace(/\D/g, '');
|
|
502
570
|
this._phoneNumber = phoneNumber;
|
|
@@ -526,6 +594,10 @@ class WhalibmobClient extends EventEmitter {
|
|
|
526
594
|
);
|
|
527
595
|
}
|
|
528
596
|
|
|
597
|
+
// Before the handshake, which reads the version straight out of the store.
|
|
598
|
+
// iOS only; Android keeps its APK flow. See _refreshIosVersion().
|
|
599
|
+
await this._refreshIosVersion();
|
|
600
|
+
|
|
529
601
|
const signalFile = path.join(this._sessionDir, `${phoneNumber}.signal.json`);
|
|
530
602
|
const skFile = path.join(this._sessionDir, `${phoneNumber}.sk.json`);
|
|
531
603
|
const tcTokenFile = path.join(this._sessionDir, `${phoneNumber}.tctoken.json`);
|
|
@@ -3361,6 +3433,19 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3361
3433
|
const isLid = fromJid.endsWith('@lid');
|
|
3362
3434
|
const mappedPn = isLid && this._lidToPn ? this._lidToPn.get(fromUser) : null;
|
|
3363
3435
|
const fromPhone = isLid ? mappedPn : fromUser;
|
|
3436
|
+
// Before throwing the cache away: the notification says what changed and
|
|
3437
|
+
// carries the hash of the list it should produce. Apply it, check the
|
|
3438
|
+
// hash, and if it matches there is nothing to invalidate and nothing to
|
|
3439
|
+
// ask usync about — see DeviceManager.verifyDeviceDelta().
|
|
3440
|
+
if (fromPhone && this._devMgr) {
|
|
3441
|
+
const delta = this._parseDeviceDelta(node);
|
|
3442
|
+
if (delta && this._devMgr.verifyDeviceDelta(fromPhone, delta.changes, delta.hash)) {
|
|
3443
|
+
_whaDbg('[DBG] NOTIF_DEVICES delta verified for ' + fromPhone +
|
|
3444
|
+
' — cache kept, no usync needed');
|
|
3445
|
+
return;
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3364
3449
|
if (fromUser && this._devMgr) {
|
|
3365
3450
|
this._devMgr._dcDel(isLid ? [fromUser, 'lid:' + fromUser] : [fromUser]);
|
|
3366
3451
|
if (mappedPn) this._devMgr._dcDel([mappedPn]);
|
|
@@ -3412,6 +3497,54 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3412
3497
|
}
|
|
3413
3498
|
}
|
|
3414
3499
|
|
|
3500
|
+
/**
|
|
3501
|
+
* Read a `devices` notification as a change plus the hash it should produce.
|
|
3502
|
+
*
|
|
3503
|
+
* <notification type="devices" from="…">
|
|
3504
|
+
* <add device_hash="2:…"><device jid="40711111111:3@s.whatsapp.net"/></add>
|
|
3505
|
+
* </notification>
|
|
3506
|
+
*
|
|
3507
|
+
* Children are `add`, `remove`, or `update`. Only the first two say something
|
|
3508
|
+
* that can be applied; `update` does not name what it changed, so it is
|
|
3509
|
+
* treated as unreadable here and the caller falls back to invalidating.
|
|
3510
|
+
*
|
|
3511
|
+
* All children must agree on the hash — the server sends one per child, and a
|
|
3512
|
+
* notification carrying two different ones is describing intermediate states
|
|
3513
|
+
* this cannot reconstruct.
|
|
3514
|
+
*
|
|
3515
|
+
* @returns {{changes: Array<{tag, deviceId}>, hash: string}|null}
|
|
3516
|
+
*/
|
|
3517
|
+
_parseDeviceDelta(node) {
|
|
3518
|
+
if (!node || !Array.isArray(node.content)) return null;
|
|
3519
|
+
|
|
3520
|
+
const changes = [];
|
|
3521
|
+
let hash = null;
|
|
3522
|
+
|
|
3523
|
+
for (const child of node.content) {
|
|
3524
|
+
if (!child || !child.description) continue;
|
|
3525
|
+
const tag = child.description;
|
|
3526
|
+
if (tag !== 'add' && tag !== 'remove') return null; // 'update' and friends
|
|
3527
|
+
|
|
3528
|
+
const childHash = child.attrs && child.attrs.device_hash;
|
|
3529
|
+
if (!childHash) return null;
|
|
3530
|
+
if (hash === null) hash = String(childHash);
|
|
3531
|
+
else if (hash !== String(childHash)) return null; // disagreeing children
|
|
3532
|
+
|
|
3533
|
+
const deviceNode = findChild(child, 'device');
|
|
3534
|
+
const jid = deviceNode && deviceNode.attrs && deviceNode.attrs.jid;
|
|
3535
|
+
if (!jid) return null;
|
|
3536
|
+
|
|
3537
|
+
const raw = String(jid).split('@')[0];
|
|
3538
|
+
const colon = raw.indexOf(':');
|
|
3539
|
+
const device = colon >= 0 ? parseInt(raw.slice(colon + 1), 10) : 0;
|
|
3540
|
+
if (!Number.isFinite(device)) return null;
|
|
3541
|
+
|
|
3542
|
+
changes.push({ tag, deviceId: device });
|
|
3543
|
+
}
|
|
3544
|
+
|
|
3545
|
+
return changes.length && hash ? { changes, hash } : null;
|
|
3546
|
+
}
|
|
3547
|
+
|
|
3415
3548
|
_processDeviceUpdate(devicesNode) {
|
|
3416
3549
|
// Called from _handleNotification(type='account_sync') and type='devices'.
|
|
3417
3550
|
// Rebuilds the device cache for the affected phones from the server-provided list.
|
package/lib/DeviceManager.js
CHANGED
|
@@ -1178,6 +1178,78 @@ class DeviceManager {
|
|
|
1178
1178
|
}
|
|
1179
1179
|
|
|
1180
1180
|
// ─── Invalidate cached device list (used on phash mismatch / 421 retry) ───
|
|
1181
|
+
/**
|
|
1182
|
+
* Apply a device-list change and check the result against the server's own
|
|
1183
|
+
* hash of what the list should now be.
|
|
1184
|
+
*
|
|
1185
|
+
* A `devices` notification says what changed — one device added, one removed —
|
|
1186
|
+
* and carries `device_hash`, the participant hash of the list the server
|
|
1187
|
+
* believes we should end up with. That hash is the thing worth having: it
|
|
1188
|
+
* turns "I applied the change, probably correctly" into something checkable.
|
|
1189
|
+
*
|
|
1190
|
+
* So the change is applied to the cached list, the hash of the result is
|
|
1191
|
+
* computed, and the two are compared. Equal, and the cache is provably right
|
|
1192
|
+
* and is kept. Different, and the cache is wrong in some way this side cannot
|
|
1193
|
+
* see, so it is thrown away and the next send asks usync for the whole list.
|
|
1194
|
+
*
|
|
1195
|
+
* That second branch is exactly what this did for every notification before —
|
|
1196
|
+
* drop everything, ask again. Which is safe, and costs a round trip each time
|
|
1197
|
+
* somebody opens WhatsApp Web. This keeps the cache when it can prove it is
|
|
1198
|
+
* right, and falls back to the old behaviour when it cannot.
|
|
1199
|
+
*
|
|
1200
|
+
* Safe by construction: if this hash were computed differently from the
|
|
1201
|
+
* server's, nothing would ever match and every notification would take the
|
|
1202
|
+
* fallback — which is where it started.
|
|
1203
|
+
*
|
|
1204
|
+
* @param {string} phone digits, the cache key
|
|
1205
|
+
* @param {Array<{tag: string, deviceId: number}>} changes
|
|
1206
|
+
* @param {string} serverHash the notification's device_hash
|
|
1207
|
+
* @returns {boolean} whether the result verified and the cache was kept
|
|
1208
|
+
*/
|
|
1209
|
+
verifyDeviceDelta(phone, changes, serverHash) {
|
|
1210
|
+
if (!phone || !serverHash || !Array.isArray(changes) || !changes.length) return false;
|
|
1211
|
+
|
|
1212
|
+
// No baseline means there is nothing to apply a change to. Caching just the
|
|
1213
|
+
// one device named in the notification would make a list of one look
|
|
1214
|
+
// authoritative, which is how a recipient's other devices stop being
|
|
1215
|
+
// written to.
|
|
1216
|
+
const cached = this._dcGet(phone);
|
|
1217
|
+
if (!cached || !cached.length) return false;
|
|
1218
|
+
|
|
1219
|
+
const next = new Set(cached);
|
|
1220
|
+
for (const { tag, deviceId } of changes) {
|
|
1221
|
+
if (!Number.isFinite(deviceId)) return false;
|
|
1222
|
+
if (tag === 'add') next.add(deviceId);
|
|
1223
|
+
else if (tag === 'remove') next.delete(deviceId);
|
|
1224
|
+
else return false; // 'update' and anything unknown: do not guess
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
const ids = [...next].sort((a, b) => a - b);
|
|
1228
|
+
const jids = ids.map(d => makeDeviceJid(phone, d));
|
|
1229
|
+
|
|
1230
|
+
let ours;
|
|
1231
|
+
try {
|
|
1232
|
+
// Lazily required: MessageSender requires this file, so taking it at the
|
|
1233
|
+
// top would close the cycle before either module finishes loading.
|
|
1234
|
+
const { computePhash } = require('./messages/MessageSender');
|
|
1235
|
+
ours = computePhash(jids);
|
|
1236
|
+
} catch (_) {
|
|
1237
|
+
return false;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
if (ours !== String(serverHash)) {
|
|
1241
|
+
_whaDbg('[DBG] DEV_HASH mismatch for ' + phone + ' ours=' + ours +
|
|
1242
|
+
' server=' + serverHash + ' — dropping cache');
|
|
1243
|
+
this._dcDel([phone]);
|
|
1244
|
+
return false;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
this._dcSet(phone, ids);
|
|
1248
|
+
_whaDbg('[DBG] DEV_HASH verified for ' + phone + ' ids=[' + ids.join(',') +
|
|
1249
|
+
'] hash=' + ours);
|
|
1250
|
+
return true;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1181
1253
|
clearCache(phones) {
|
|
1182
1254
|
if (!phones || phones.length === 0) {
|
|
1183
1255
|
this._dcFlush();
|
|
@@ -1187,10 +1259,13 @@ class DeviceManager {
|
|
|
1187
1259
|
}
|
|
1188
1260
|
|
|
1189
1261
|
// ─── Build participants node ───────────────────────────────────────────────
|
|
1190
|
-
static buildParticipantsNode(encryptedList, mediaSubtype) {
|
|
1262
|
+
static buildParticipantsNode(encryptedList, mediaSubtype, decryptFail) {
|
|
1191
1263
|
const toNodes = encryptedList.map(({ jid, type, ciphertext }) => {
|
|
1192
1264
|
const encAttrs = { type, v: '2' };
|
|
1193
1265
|
if (mediaSubtype) encAttrs.mediatype = mediaSubtype;
|
|
1266
|
+
// What the recipient shows if this payload will not decrypt — see
|
|
1267
|
+
// resolveDecryptFail() in MessageSender.
|
|
1268
|
+
if (decryptFail) encAttrs['decrypt-fail'] = decryptFail;
|
|
1194
1269
|
// Always encode the jid as a binary JID object (JID_PAIR / AD_JID) so the
|
|
1195
1270
|
// server can route the per-device enc payload to the correct Signal session.
|
|
1196
1271
|
// Raw UTF-8 strings are silently dropped for @lid recipients.
|
package/lib/Registration.js
CHANGED
|
@@ -2137,7 +2137,7 @@ async function verifyCode(store, code, opts) {
|
|
|
2137
2137
|
throw new Error(`Verification failed: ${reason || JSON.stringify(result)}`);
|
|
2138
2138
|
}
|
|
2139
2139
|
|
|
2140
|
-
module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, fetchIosVersion, fetchAndroidVersion, fetchWaVersion, currentVersionFor, refreshSessionVersion, parsePhone, getCountryMeta, assertRegistrationKeys, parseSocksProxy, socksProxyUrl };
|
|
2140
|
+
module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, fetchIosVersion, fetchAndroidVersion, fetchWaVersion, currentVersionFor, refreshSessionVersion, compareVersions, parsePhone, getCountryMeta, assertRegistrationKeys, parseSocksProxy, socksProxyUrl };
|
|
2141
2141
|
|
|
2142
2142
|
// The HTTP reader, exposed for tests. Not part of the public API.
|
|
2143
2143
|
module.exports._http = { readHttpResponse, parseHttpResponse, decodeChunkedBody };
|
|
@@ -124,6 +124,35 @@ function _getCurve() {
|
|
|
124
124
|
// Build ADVSignedDeviceIdentity for our primary iOS device.
|
|
125
125
|
// Returns Buffer of encoded proto WITH accountSignatureKey included.
|
|
126
126
|
// Result is cached in store.advIdentity so we only build it once per registration.
|
|
127
|
+
// What the recipient's client should do when one of our payloads will not
|
|
128
|
+
// decrypt for them.
|
|
129
|
+
//
|
|
130
|
+
// The attribute rides on the <enc> node and is read on the other side, not by
|
|
131
|
+
// the server. Absent, a failure draws the "Waiting for this message" placeholder
|
|
132
|
+
// in their chat. `hide` draws nothing at all.
|
|
133
|
+
//
|
|
134
|
+
// That placeholder is right for a real message — the person can see something
|
|
135
|
+
// arrived and ask for it again. It is nonsense for the three kinds below, which
|
|
136
|
+
// are invisible operations on a message that is already there: a reaction that
|
|
137
|
+
// will not decrypt is a heart that never appears, not a message to wait for, and
|
|
138
|
+
// a failed revoke or edit would leave a ghost bubble referring to nothing.
|
|
139
|
+
//
|
|
140
|
+
// The rule is the reference clients' rule, narrowed to what this library sends:
|
|
141
|
+
//
|
|
142
|
+
// reaction sendReaction() → stanza type 'reaction'
|
|
143
|
+
// edit editMessage() → edit bit '1'
|
|
144
|
+
// revoke deleteMessage(forEveryone: true) → edit bit '7' or '8'
|
|
145
|
+
//
|
|
146
|
+
// Everything else gets nothing, which is the same as saying "show it".
|
|
147
|
+
function resolveDecryptFail(mediaType, options) {
|
|
148
|
+
if (mediaType === 'reaction') return 'hide';
|
|
149
|
+
// Any edit bit: 1 is an edit, 7 and 8 are the two revoke forms. The reference
|
|
150
|
+
// client keys off the presence of the attribute rather than its value, and so
|
|
151
|
+
// does this.
|
|
152
|
+
if (options && options.edit) return 'hide';
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
127
156
|
function _buildOrGetAdvIdentity(store) {
|
|
128
157
|
// Only ever return an identity the server actually issued.
|
|
129
158
|
//
|
|
@@ -909,6 +938,7 @@ class MessageSender {
|
|
|
909
938
|
const ownPhone = String(this._store.phoneNumber);
|
|
910
939
|
const ownMainJid = `${ownPhone}@s.whatsapp.net`;
|
|
911
940
|
const mediaSubtype = options._mediaSubtype || null;
|
|
941
|
+
const decryptFail = resolveDecryptFail(mediaType, options);
|
|
912
942
|
|
|
913
943
|
// For primary iOS devices (registered via SMS), advIdentity is never set
|
|
914
944
|
// because the server only sends device-identity for linked secondary devices.
|
|
@@ -1040,7 +1070,8 @@ class MessageSender {
|
|
|
1040
1070
|
// Always use <participants><to jid=...><enc> structure — required by MD protocol.
|
|
1041
1071
|
// Old iOS non-MD used direct <enc> in <message>, but the WhatsApp server now
|
|
1042
1072
|
// enforces the MD participants wrapper even for single-device sends.
|
|
1043
|
-
const participantsNode = DeviceManager.buildParticipantsNode(
|
|
1073
|
+
const participantsNode = DeviceManager.buildParticipantsNode(
|
|
1074
|
+
encryptedList, mediaSubtype, decryptFail);
|
|
1044
1075
|
msgContent = [...devIdNodes, participantsNode];
|
|
1045
1076
|
}
|
|
1046
1077
|
|
|
@@ -1159,6 +1190,7 @@ class MessageSender {
|
|
|
1159
1190
|
const ownPhone = String(this._store.phoneNumber);
|
|
1160
1191
|
const ownJid = `${ownPhone}@s.whatsapp.net`;
|
|
1161
1192
|
const mediaSubtype = options._mediaSubtype || null;
|
|
1193
|
+
const decryptFail = resolveDecryptFail(mediaType, options);
|
|
1162
1194
|
|
|
1163
1195
|
const isStatus = isStatusBroadcast(groupJid);
|
|
1164
1196
|
|
|
@@ -1405,10 +1437,17 @@ class MessageSender {
|
|
|
1405
1437
|
// participant nodes as on the skmsg, so an image sent to a group carries
|
|
1406
1438
|
// mediatype on both. Passing null here left the SKDM <enc> nodes
|
|
1407
1439
|
// unlabelled.
|
|
1408
|
-
msgContent.push(DeviceManager.buildParticipantsNode(
|
|
1440
|
+
msgContent.push(DeviceManager.buildParticipantsNode(
|
|
1441
|
+
skdmEncrypted, mediaSubtype, decryptFail));
|
|
1409
1442
|
}
|
|
1410
1443
|
const skmsgAttrs = { type: 'skmsg', v: '2' };
|
|
1411
1444
|
if (mediaSubtype) skmsgAttrs.mediatype = mediaSubtype;
|
|
1445
|
+
// On the skmsg too, and that is where it matters most in a group: the
|
|
1446
|
+
// participant nodes carry the sender-key distribution, but the reaction
|
|
1447
|
+
// itself travels in here. The two reference clients disagree — one sets it
|
|
1448
|
+
// on both, the other only on the participant nodes — and the one that sets
|
|
1449
|
+
// both is the one whose reasoning holds up.
|
|
1450
|
+
if (decryptFail) skmsgAttrs['decrypt-fail'] = decryptFail;
|
|
1412
1451
|
msgContent.push(new BinaryNode('enc', skmsgAttrs, Buffer.isBuffer(skmsgCiphertext) ? skmsgCiphertext : Buffer.from(skmsgCiphertext)));
|
|
1413
1452
|
|
|
1414
1453
|
// Same reporting token a direct message carries. A poll posted to a group
|