whalibmob 5.17.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/lib/Client.js CHANGED
@@ -3433,6 +3433,19 @@ class WhalibmobClient extends EventEmitter {
3433
3433
  const isLid = fromJid.endsWith('@lid');
3434
3434
  const mappedPn = isLid && this._lidToPn ? this._lidToPn.get(fromUser) : null;
3435
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
+
3436
3449
  if (fromUser && this._devMgr) {
3437
3450
  this._devMgr._dcDel(isLid ? [fromUser, 'lid:' + fromUser] : [fromUser]);
3438
3451
  if (mappedPn) this._devMgr._dcDel([mappedPn]);
@@ -3484,6 +3497,54 @@ class WhalibmobClient extends EventEmitter {
3484
3497
  }
3485
3498
  }
3486
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
+
3487
3548
  _processDeviceUpdate(devicesNode) {
3488
3549
  // Called from _handleNotification(type='account_sync') and type='devices'.
3489
3550
  // Rebuilds the device cache for the affected phones from the server-provided list.
@@ -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.
@@ -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(encryptedList, mediaSubtype);
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(skdmEncrypted, mediaSubtype));
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.17.0",
3
+ "version": "5.18.0",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API and web ",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",