whalibmob 5.29.2 → 5.29.4

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.
@@ -1206,57 +1206,123 @@ class DeviceManager {
1206
1206
  * and sends both hashes on the same notification — so each is verified on its
1207
1207
  * own, and one going wrong does not cost the other.
1208
1208
  *
1209
- * @param {string} cacheKey the cache entry: digits, or 'lid:<user>'
1210
- * @param {string} user the JID user part the ids belong to
1211
- * @param {Array<{tag: string, deviceId: number}>} changes
1212
- * @param {string} serverHash device_hash, or device_lid_hash
1213
- * @param {string} [server] 's.whatsapp.net' (default) or 'lid'
1214
- * @returns {boolean} whether the result verified and the cache was kept
1209
+ * The steps are applied one at a time, and each is checked against its own
1210
+ * hash: `device_hash` describes the list **after that change**, not after the
1211
+ * notification as a whole. A contact linking two devices in one sitting sends
1212
+ * two children with two different hashes, and reading them as one change with
1213
+ * one hash — which is what this did — meant the notification could never
1214
+ * verify and the cache was dropped every time. Stepping through them the way
1215
+ * whatsmeow's handleDeviceNotification does keeps the cache across exactly
1216
+ * the case that used to defeat it.
1217
+ *
1218
+ * A step that fails to verify drops the cache but not the running list, so a
1219
+ * later step whose hash does match puts a proven list back — the same
1220
+ * recovery whatsmeow gets from mutating its local copy after deleting the
1221
+ * cached one.
1222
+ *
1223
+ * @param {object} opts
1224
+ * @param {string} opts.priKey the cache entry: digits, or 'lid:<user>'
1225
+ * @param {string} opts.priUser the JID user part the ids belong to
1226
+ * @param {string} [opts.priServer] 's.whatsapp.net' (default) or 'lid'
1227
+ * @param {string} [opts.lidKey] cache entry for the LID half, when known
1228
+ * @param {string} [opts.lidUser] the LID user part
1229
+ * @param {Array} opts.steps from Client._parseDeviceDelta
1230
+ * @returns {{primaryOk: boolean, lidOk: boolean}} which halves ended verified
1215
1231
  */
1216
- verifyDeviceDelta(cacheKey, user, changes, serverHash, server) {
1217
- server = server || 's.whatsapp.net';
1218
- if (!cacheKey || !user || !serverHash) return false;
1219
- if (!Array.isArray(changes) || !changes.length) return false;
1232
+ applyDeviceDelta(opts) {
1233
+ opts = opts || {};
1234
+ const priKey = opts.priKey;
1235
+ const priUser = opts.priUser;
1236
+ const priServer = opts.priServer || 's.whatsapp.net';
1237
+ const steps = opts.steps;
1238
+ const out = { primaryOk: false, lidOk: false };
1239
+
1240
+ if (!priKey || !priUser) return out;
1241
+ if (!Array.isArray(steps) || !steps.length) return out;
1220
1242
 
1221
1243
  // No baseline means there is nothing to apply a change to. Caching just the
1222
1244
  // one device named in the notification would make a list of one look
1223
1245
  // authoritative, which is how a recipient's other devices stop being
1224
1246
  // written to.
1225
- const cached = this._dcGet(cacheKey);
1226
- if (!cached || !cached.length) return false;
1227
-
1228
- const next = new Set(cached);
1229
- for (const { tag, deviceId } of changes) {
1230
- if (!Number.isFinite(deviceId)) return false;
1231
- if (tag === 'add') next.add(deviceId);
1232
- else if (tag === 'remove') next.delete(deviceId);
1233
- else return false; // 'update' and anything unknown: do not guess
1247
+ const cached = this._dcGet(priKey);
1248
+ if (!cached || !cached.length) return out;
1249
+
1250
+ const haveLid = !!(opts.lidKey && opts.lidUser);
1251
+ const pri = new Set(cached);
1252
+ // The LID half starts from whatever is on file, empty included. It cannot
1253
+ // go wrong the way the primary half could: a list built from nothing has to
1254
+ // pass the server's hash before it is written anywhere.
1255
+ const lid = haveLid ? new Set(this._dcGet(opts.lidKey) || []) : null;
1256
+
1257
+ for (const step of steps) {
1258
+ if (!step) continue;
1259
+
1260
+ // `update` does not say what changed. The list on file cannot be trusted
1261
+ // and cannot be repaired, so it goes — and the remaining steps still get
1262
+ // their turn, as they do in whatsmeow.
1263
+ if (step.tag === 'update') {
1264
+ this._dcDel([priKey]);
1265
+ out.primaryOk = false;
1266
+ _whaDbg('[DBG] DEV_HASH update for ' + priKey + ' — cache dropped');
1267
+ continue;
1268
+ }
1269
+ if (step.tag !== 'add' && step.tag !== 'remove') continue;
1270
+ if (!Number.isFinite(step.deviceId) || !step.hash) continue;
1271
+
1272
+ if (step.tag === 'add') pri.add(step.deviceId);
1273
+ else pri.delete(step.deviceId);
1274
+
1275
+ const ids = [...pri].sort((a, b) => a - b);
1276
+ const ours = this._phashOfDevices(priUser, ids, priServer);
1277
+ if (ours && ours === step.hash) {
1278
+ this._dcSet(priKey, ids);
1279
+ out.primaryOk = true;
1280
+ _whaDbg('[DBG] DEV_HASH verified for ' + priKey + ' ids=[' + ids.join(',') +
1281
+ '] hash=' + ours);
1282
+ } else {
1283
+ this._dcDel([priKey]);
1284
+ out.primaryOk = false;
1285
+ _whaDbg('[DBG] DEV_HASH mismatch for ' + priKey + ' ours=' + ours +
1286
+ ' server=' + step.hash + ' — dropping cache');
1287
+ }
1288
+
1289
+ // The LID half of the same change, checked on its own. The server hashes
1290
+ // the two lists separately and one going wrong does not cost the other.
1291
+ if (!haveLid || step.lidDeviceId === null || !step.lidHash) continue;
1292
+
1293
+ if (step.tag === 'add') lid.add(step.lidDeviceId);
1294
+ else lid.delete(step.lidDeviceId);
1295
+
1296
+ const lidIds = [...lid].sort((a, b) => a - b);
1297
+ const lidOurs = this._phashOfDevices(opts.lidUser, lidIds, 'lid');
1298
+ if (lidOurs && lidOurs === step.lidHash) {
1299
+ this._dcSet(opts.lidKey, lidIds);
1300
+ out.lidOk = true;
1301
+ _whaDbg('[DBG] DEV_HASH verified for ' + opts.lidKey + ' ids=[' +
1302
+ lidIds.join(',') + '] hash=' + lidOurs);
1303
+ } else {
1304
+ this._dcDel([opts.lidKey]);
1305
+ out.lidOk = false;
1306
+ _whaDbg('[DBG] DEV_HASH mismatch for ' + opts.lidKey + ' ours=' + lidOurs +
1307
+ ' server=' + step.lidHash + ' — dropping cache');
1308
+ }
1234
1309
  }
1235
1310
 
1236
- const ids = [...next].sort((a, b) => a - b);
1237
- const jids = ids.map(d => makeDeviceJid(user, d, server));
1311
+ return out;
1312
+ }
1238
1313
 
1239
- let ours;
1314
+ // The participant hash of one user's device ids, or null when it cannot be
1315
+ // computed. Same function the group fan-out and the server both use, which is
1316
+ // the whole reason the comparison above means anything.
1317
+ _phashOfDevices(user, deviceIds, server) {
1240
1318
  try {
1241
1319
  // Lazily required: MessageSender requires this file, so taking it at the
1242
1320
  // top would close the cycle before either module finishes loading.
1243
1321
  const { computePhash } = require('./messages/MessageSender');
1244
- ours = computePhash(jids);
1322
+ return computePhash(deviceIds.map(d => makeDeviceJid(user, d, server)));
1245
1323
  } catch (_) {
1246
- return false;
1247
- }
1248
-
1249
- if (ours !== String(serverHash)) {
1250
- _whaDbg('[DBG] DEV_HASH mismatch for ' + cacheKey + ' ours=' + ours +
1251
- ' server=' + serverHash + ' — dropping cache');
1252
- this._dcDel([cacheKey]);
1253
- return false;
1324
+ return null;
1254
1325
  }
1255
-
1256
- this._dcSet(cacheKey, ids);
1257
- _whaDbg('[DBG] DEV_HASH verified for ' + cacheKey + ' ids=[' + ids.join(',') +
1258
- '] hash=' + ours);
1259
- return true;
1260
1326
  }
1261
1327
 
1262
1328
  clearCache(phones) {
@@ -229,10 +229,6 @@ function _tryUpload(host, mediaPath, token, auth, encrypted, web) {
229
229
  });
230
230
  }
231
231
 
232
- // opts.fileEncSha256, when given, is checked against the blob as it arrived.
233
- // The MAC inside decryptMedia already proves the plaintext was not tampered
234
- // with, but this catches a truncated or substituted download before spending
235
- // the work on it, and tells the two failures apart.
236
232
  // Where a message's media actually lives. Most carry an absolute `url`, but a
237
233
  // message that arrived through history sync often has only the `directPath`,
238
234
  // which is that URL with the CDN host taken off the front.
@@ -245,19 +241,72 @@ function resolveMediaUrl(url, directPath) {
245
241
  return 'https://' + CDN_HOST + (p.startsWith('/') ? '' : '/') + p;
246
242
  }
247
243
 
244
+ // Compare a digest against what the message said it should be. Returns null
245
+ // when it matches or when there is nothing to compare against, and the reason
246
+ // when it does not.
247
+ function _digestMismatch(actual, expected, what) {
248
+ if (!expected || !expected.length) return null;
249
+ const want = Buffer.from(expected);
250
+ // timingSafeEqual throws on a length mismatch rather than returning false.
251
+ if (want.length !== actual.length) return what + ' is the wrong length';
252
+ return crypto.timingSafeEqual(actual, want) ? null : what + ' mismatch';
253
+ }
254
+
255
+ /**
256
+ * Download one media file and decrypt it.
257
+ *
258
+ * Three things are checked, and all three are checked by default — the message
259
+ * says what the file should be, so there is no reason to take the CDN's word
260
+ * for it:
261
+ *
262
+ * fileEncSha256 the blob as it arrived off the CDN
263
+ * the MAC inside decryptMedia, the one that actually authenticates
264
+ * fileSha256 the plaintext, after decrypting
265
+ *
266
+ * The MAC is the guarantee — its key comes from the mediaKey, which only the
267
+ * sender and the recipients hold — and it was always checked. The two digests
268
+ * were not: fileEncSha256 only when the caller asked for it, and fileSha256
269
+ * never at all, though it was written on every upload. whatsmeow checks both on
270
+ * every download (ErrInvalidMediaSHA256), and now so does this.
271
+ *
272
+ * They tell failures apart, too. A truncated or substituted download is caught
273
+ * before any work is spent decrypting it, and a plaintext that decrypts but is
274
+ * not the file the message described is a different fault from a corrupt blob.
275
+ *
276
+ * @param {object} [opts]
277
+ * @param {Buffer} [opts.fileEncSha256] digest of the encrypted blob
278
+ * @param {Buffer} [opts.fileSha256] digest of the decrypted file
279
+ * @param {boolean} [opts.verify=true] pass false to skip both digest checks;
280
+ * the MAC is always checked either way
281
+ */
248
282
  async function downloadMedia(url, mediaKey, keyName, opts) {
283
+ opts = opts || {};
284
+ const check = opts.verify !== false;
249
285
  const encrypted = await _httpGet(url, opts);
250
- const expected = opts && opts.fileEncSha256;
251
- if (expected && expected.length) {
252
- const actual = crypto.createHash('sha256').update(encrypted).digest();
253
- const want = Buffer.from(expected);
254
- // timingSafeEqual throws on a length mismatch rather than returning false.
255
- if (want.length !== actual.length || !crypto.timingSafeEqual(actual, want)) {
256
- throw new Error('downloadMedia: the downloaded file does not match the message ' +
257
- '(fileEncSha256 mismatch)');
286
+
287
+ if (check) {
288
+ const bad = _digestMismatch(
289
+ crypto.createHash('sha256').update(encrypted).digest(),
290
+ opts.fileEncSha256, 'fileEncSha256');
291
+ if (bad) {
292
+ throw new Error('downloadMedia: the downloaded file does not match the ' +
293
+ 'message (' + bad + ')');
294
+ }
295
+ }
296
+
297
+ const plaintext = decryptMedia(encrypted, Buffer.from(mediaKey), keyName);
298
+
299
+ if (check) {
300
+ const bad = _digestMismatch(
301
+ crypto.createHash('sha256').update(plaintext).digest(),
302
+ opts.fileSha256, 'fileSha256');
303
+ if (bad) {
304
+ throw new Error('downloadMedia: the decrypted file is not the one the ' +
305
+ 'message described (' + bad + ')');
258
306
  }
259
307
  }
260
- return decryptMedia(encrypted, Buffer.from(mediaKey), keyName);
308
+
309
+ return plaintext;
261
310
  }
262
311
 
263
312
  // The CDN applies the same Origin check on the way down as on the way up, so a
@@ -307,11 +356,26 @@ function _httpGet(url, opts, depth) {
307
356
  });
308
357
  }
309
358
 
359
+ // A thumbnail is encrypted under a key name of its own, not under the key name
360
+ // of the message that carries it. The one in use is the link-preview thumbnail
361
+ // on an extendedTextMessage — it has its own directPath and its own digests
362
+ // beside the message's, and reading it with 'WhatsApp Image Keys' produces a
363
+ // MAC failure and nothing else. There is no upload path because nothing here
364
+ // uploads one; they arrive with a link preview the sender built.
365
+ const THUMBNAIL_KEY_NAME = {
366
+ extendedText: 'WhatsApp Link Thumbnail Keys',
367
+ text: 'WhatsApp Link Thumbnail Keys'
368
+ };
369
+
310
370
  function getMediaKeyName(mediaType) {
311
371
  const typeInfo = MEDIA_PATH[mediaType];
312
372
  return typeInfo ? typeInfo.keyName : null;
313
373
  }
314
374
 
375
+ function getThumbnailKeyName(mediaType) {
376
+ return THUMBNAIL_KEY_NAME[mediaType] || null;
377
+ }
378
+
315
379
  module.exports = {
316
380
  MEDIA_PATH,
317
381
  encryptMedia,
@@ -323,5 +387,6 @@ module.exports = {
323
387
  httpGetBuffer: _httpGet,
324
388
  deriveMediaKeyData,
325
389
  getMediaKeyName,
390
+ getThumbnailKeyName,
326
391
  resolveMediaUrl
327
392
  };
@@ -478,8 +478,32 @@ const COUNTRY_META = {
478
478
  '998': { mcc: '434', mnc: '03', lg: 'ru', lc: 'UZ' }, // Uzbekistan (UzMobile)
479
479
  };
480
480
 
481
- function getCountryMeta(cc) {
482
- return COUNTRY_META[cc] || { mcc: '000', mnc: '000', lg: 'en', lc: 'US' };
481
+ // The carrier metadata for a country calling code.
482
+ //
483
+ // The table names one operator per country — Romania is always Orange, Brazil
484
+ // always Vivo — because that is the best a table keyed by calling code can do.
485
+ // It cannot be better: number portability means the prefix stopped naming the
486
+ // operator years ago, so guessing from the number would be wrong more often
487
+ // than the table is.
488
+ //
489
+ // So the caller can say instead. Whoever is registering knows which SIM is in
490
+ // the phone, and sim_mcc/sim_mnc are meant to describe that SIM; WA_SIM_MCC and
491
+ // WA_SIM_MNC (or `simMcc`/`simMnc` in the options) override the guess with the
492
+ // truth. The language and locale stay with the country either way.
493
+ //
494
+ // MNC keeps whatever width it is given: 226/10 and 226/010 are different
495
+ // networks to the server, and normalising them would break one of the two.
496
+ function getCountryMeta(cc, opts) {
497
+ const base = COUNTRY_META[cc] || { mcc: '000', mnc: '000', lg: 'en', lc: 'US' };
498
+
499
+ const mcc = (opts && opts.simMcc) || process.env.WA_SIM_MCC;
500
+ const mnc = (opts && opts.simMnc) || process.env.WA_SIM_MNC;
501
+ if (!mcc && !mnc) return base;
502
+
503
+ const out = Object.assign({}, base);
504
+ if (mcc) out.mcc = String(mcc).trim();
505
+ if (mnc) out.mnc = String(mnc).trim();
506
+ return out;
483
507
  }
484
508
 
485
509
  // ---------- Phone number parsing ----------
@@ -1201,6 +1225,57 @@ function waitHint(response, method) {
1201
1225
  return retry > 0 ? retry : null;
1202
1226
  }
1203
1227
 
1228
+ // ---------- device_ram ----------
1229
+ //
1230
+ // What the handset reports as usable RAM, in GiB, from
1231
+ // ActivityManager.MemoryInfo.totalMem — which is always below the nominal
1232
+ // figure on the box, because the kernel, the GPU and the secure world reserve a
1233
+ // slice before Android ever sees it. A 12 GB phone reports around 11.5.
1234
+ //
1235
+ // This was the constant 3.57 on every request, while the same payload declared
1236
+ // a Galaxy S24 Ultra beside it. There is no such handset: the S24 Ultra ships
1237
+ // with 12 GB and no variant of it has ever had 3.57. Two fields of one request
1238
+ // describing two different phones is not a fingerprint worth defending — it is
1239
+ // simply wrong, in the way a wrong `os_version` would be. It now comes off the
1240
+ // declared profile, so whichever handset the caller says they are on, the
1241
+ // memory belongs to it.
1242
+ const DEFAULT_DEVICE_RAM = '5.62';
1243
+
1244
+ function deviceRam(device) {
1245
+ const ram = device && device.ram;
1246
+ if (ram == null) return DEFAULT_DEVICE_RAM;
1247
+ const n = Number(ram);
1248
+ // Two decimals, the way the client formats it.
1249
+ return Number.isFinite(n) && n > 0 ? n.toFixed(2) : DEFAULT_DEVICE_RAM;
1250
+ }
1251
+
1252
+ // ---------- pid ----------
1253
+ //
1254
+ // The process id of the app making the request.
1255
+ //
1256
+ // This sent `process.pid` — the Node process. Inside a container that is 1, and
1257
+ // pid 1 on Android is `init`; no application process is ever 1. Elsewhere it is
1258
+ // whatever the host happened to hand out, which is as likely to be 7 as 24000.
1259
+ //
1260
+ // Android hands out pids from a counter that starts near 300 and wraps at
1261
+ // /proc/sys/kernel/pid_max, 32768 by default, so an app launched on a phone
1262
+ // that has been up any length of time lands well inside that range. One is
1263
+ // derived per store here, from the same identity material everything else in
1264
+ // the payload comes from — deterministic on purpose, because /exist, /code and
1265
+ // /register are three requests from one running app and a process does not
1266
+ // change its pid between them. Restoring a saved session keeps the pid it had,
1267
+ // for the same reason.
1268
+ function getAppPid(store) {
1269
+ if (store._appPid) return store._appPid;
1270
+ const seed = (store.identityId && Buffer.isBuffer(store.identityId))
1271
+ ? store.identityId
1272
+ : Buffer.from(String(store.phoneNumber || ''), 'utf8');
1273
+ const h = crypto.createHash('sha256').update(seed).update('pid').digest();
1274
+ // 1024..32767 — clear of the kernel and early-boot range, inside pid_max.
1275
+ store._appPid = String(1024 + (h.readUInt32BE(0) % (32768 - 1024)));
1276
+ return store._appPid;
1277
+ }
1278
+
1204
1279
  function buildClientMetrics(attempt) {
1205
1280
  const json = '{"attempts":' + (attempt || 1)
1206
1281
  + ',"app_campaign_download_source":"google-play|unknown"'
@@ -1250,9 +1325,9 @@ function getRequestVerificationCodeParameters(store, method, meta, device, attem
1250
1325
  'advertising_id', store.advertisingId || '',
1251
1326
  'hasinrc', '1',
1252
1327
  'roaming_type', '0',
1253
- 'device_ram', '3.57',
1328
+ 'device_ram', deviceRam(device),
1254
1329
  'education_screen_displayed', 'true',
1255
- 'pid', String(process.pid),
1330
+ 'pid', getAppPid(store),
1256
1331
  'cellular_strength', '5',
1257
1332
  'backup_token', store.backupToken ? toUrlHex(store.backupToken) : '',
1258
1333
  'tos_version', '5',
@@ -1354,7 +1429,7 @@ function buildVerifiedNameCertificate(store, device) {
1354
1429
 
1355
1430
  async function buildPayload(store, waVersion, useToken, extraPairs) {
1356
1431
  const { cc, national } = parsePhone(store.phoneNumber);
1357
- const meta = getCountryMeta(cc);
1432
+ const meta = getCountryMeta(cc, store);
1358
1433
  const device = store.device || getDeviceConfig();
1359
1434
  const token = useToken ? computeToken(waVersion, national, device) : null;
1360
1435
  // Android formats fdid lowercase, iOS uppercase (matches the native clients).
@@ -1826,7 +1901,7 @@ async function checkNumberStatus(phoneNumber) {
1826
1901
  const store = createNewStore(phoneNumber);
1827
1902
  const waVersion = await fetchWaVersion(_device);
1828
1903
  const { cc } = parsePhone(phoneNumber);
1829
- const meta = getCountryMeta(cc);
1904
+ const meta = getCountryMeta(cc, store);
1830
1905
 
1831
1906
  function hasBlock(r) {
1832
1907
  return r && !!r.custom_block_screen;
@@ -1988,7 +2063,7 @@ async function requestSmsCode(store, method, opts) {
1988
2063
 
1989
2064
  // Derive real MCC/MNC from the phone number being registered.
1990
2065
  const { cc: _regCc } = parsePhone(store.phoneNumber);
1991
- const _regMeta = getCountryMeta(_regCc);
2066
+ const _regMeta = getCountryMeta(_regCc, store);
1992
2067
 
1993
2068
  async function _tryMethod(m) {
1994
2069
  // Retry loop:
package/lib/Store.js CHANGED
@@ -113,6 +113,12 @@ function createNewStore(phoneNumber, opts) {
113
113
  registered: false,
114
114
  codePending: false, // true after /code request, cleared on successful /register
115
115
  name: normalizePushName(opts.name) || 'User',
116
+ // Which SIM is actually in the phone. The country table can only guess one
117
+ // operator per calling code — number portability broke the prefix→operator
118
+ // link long ago — so whoever is registering can name theirs instead. Null
119
+ // leaves the table's guess in place. See getCountryMeta in Registration.js.
120
+ simMcc: opts.simMcc ? String(opts.simMcc).trim() : null,
121
+ simMnc: opts.simMnc ? String(opts.simMnc).trim() : null,
116
122
  version,
117
123
  device,
118
124
  // ADVSignedDeviceIdentity bytes received from server <success> node.
@@ -194,6 +200,8 @@ function storeToJson(store) {
194
200
  advertisingId: store.advertisingId || null,
195
201
  backupToken: store.backupToken ? store.backupToken.toString('base64') : null,
196
202
  registered: !!store.registered,
203
+ simMcc: store.simMcc || null,
204
+ simMnc: store.simMnc || null,
197
205
  codePending: store.codePending || false,
198
206
  // Which delivery method the pending code actually went out by. The two
199
207
  // registration steps are normally separate commands, so the confirmation
@@ -246,6 +254,10 @@ function storeFromJson(obj) {
246
254
  advertisingId: obj.advertisingId || uuidv4(),
247
255
  backupToken: obj.backupToken ? Buffer.from(obj.backupToken, 'base64') : crypto.randomBytes(20),
248
256
  registered: !!obj.registered,
257
+ // Null on stores written before the SIM could be declared, which simply
258
+ // means the country table's guess stands.
259
+ simMcc: obj.simMcc || null,
260
+ simMnc: obj.simMnc || null,
249
261
  codePending: obj.codePending || false,
250
262
  codeMethod: obj.codeMethod || null,
251
263
  // Absent on stores written before push tokens existed; null simply means
package/lib/constants.js CHANGED
@@ -169,91 +169,91 @@ const ANDROID_DEVICE_PROFILES = {
169
169
  os: 'android', platform: PLATFORM.ANDROID,
170
170
  model: 'Samsung Galaxy S24 Ultra', manufacturer: 'Samsung',
171
171
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
172
- modelId: 'SM-S928B', deviceModelType: 2
172
+ modelId: 'SM-S928B', deviceModelType: 2, ram: '11.55'
173
173
  },
174
174
  'samsung-s24': {
175
175
  os: 'android', platform: PLATFORM.ANDROID,
176
176
  model: 'Samsung Galaxy S24', manufacturer: 'Samsung',
177
177
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
178
- modelId: 'SM-S921B', deviceModelType: 2
178
+ modelId: 'SM-S921B', deviceModelType: 2, ram: '7.63'
179
179
  },
180
180
  'samsung-s23': {
181
181
  os: 'android', platform: PLATFORM.ANDROID,
182
182
  model: 'Samsung Galaxy S23', manufacturer: 'Samsung',
183
183
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
184
- modelId: 'SM-S911B', deviceModelType: 2
184
+ modelId: 'SM-S911B', deviceModelType: 2, ram: '7.63'
185
185
  },
186
186
  'samsung-s23-ultra': {
187
187
  os: 'android', platform: PLATFORM.ANDROID,
188
188
  model: 'Samsung Galaxy S23 Ultra', manufacturer: 'Samsung',
189
189
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
190
- modelId: 'SM-S918B', deviceModelType: 2
190
+ modelId: 'SM-S918B', deviceModelType: 2, ram: '11.55'
191
191
  },
192
192
  'samsung-a55': {
193
193
  os: 'android', platform: PLATFORM.ANDROID,
194
194
  model: 'Samsung Galaxy A55', manufacturer: 'Samsung',
195
195
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
196
- modelId: 'SM-A556B', deviceModelType: 1
196
+ modelId: 'SM-A556B', deviceModelType: 1, ram: '7.44'
197
197
  },
198
198
  'pixel8pro': {
199
199
  os: 'android', platform: PLATFORM.ANDROID,
200
200
  model: 'Pixel 8 Pro', manufacturer: 'Google',
201
201
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
202
- modelId: 'Pixel 8 Pro', deviceModelType: 2
202
+ modelId: 'Pixel 8 Pro', deviceModelType: 2, ram: '11.44'
203
203
  },
204
204
  'pixel8': {
205
205
  os: 'android', platform: PLATFORM.ANDROID,
206
206
  model: 'Pixel 8', manufacturer: 'Google',
207
207
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
208
- modelId: 'Pixel 8', deviceModelType: 2
208
+ modelId: 'Pixel 8', deviceModelType: 2, ram: '7.53'
209
209
  },
210
210
  'pixel7': {
211
211
  os: 'android', platform: PLATFORM.ANDROID,
212
212
  model: 'Pixel 7', manufacturer: 'Google',
213
213
  osVersion: '14', osBuildNumber: 'TP1A.221005.002',
214
- modelId: 'Pixel 7', deviceModelType: 2
214
+ modelId: 'Pixel 7', deviceModelType: 2, ram: '7.53'
215
215
  },
216
216
  'pixel7a': {
217
217
  os: 'android', platform: PLATFORM.ANDROID,
218
218
  model: 'Pixel 7a', manufacturer: 'Google',
219
219
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
220
- modelId: 'Pixel 7a', deviceModelType: 1
220
+ modelId: 'Pixel 7a', deviceModelType: 1, ram: '7.44'
221
221
  },
222
222
  'xiaomi14': {
223
223
  os: 'android', platform: PLATFORM.ANDROID,
224
224
  model: 'Xiaomi 14', manufacturer: 'Xiaomi',
225
225
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
226
- modelId: '23127PN0CG', deviceModelType: 2
226
+ modelId: '23127PN0CG', deviceModelType: 2, ram: '11.36'
227
227
  },
228
228
  'xiaomi13': {
229
229
  os: 'android', platform: PLATFORM.ANDROID,
230
230
  model: 'Xiaomi 13', manufacturer: 'Xiaomi',
231
231
  osVersion: '13', osBuildNumber: 'TQ3A.230901.001',
232
- modelId: '2211133G', deviceModelType: 2
232
+ modelId: '2211133G', deviceModelType: 2, ram: '7.51'
233
233
  },
234
234
  'oneplus12': {
235
235
  os: 'android', platform: PLATFORM.ANDROID,
236
236
  model: 'OnePlus 12', manufacturer: 'OnePlus',
237
237
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
238
- modelId: 'CPH2573', deviceModelType: 2
238
+ modelId: 'CPH2573', deviceModelType: 2, ram: '11.48'
239
239
  },
240
240
  'oneplus11': {
241
241
  os: 'android', platform: PLATFORM.ANDROID,
242
242
  model: 'OnePlus 11', manufacturer: 'OnePlus',
243
243
  osVersion: '13', osBuildNumber: 'TQ3A.230901.001',
244
- modelId: 'CPH2449', deviceModelType: 2
244
+ modelId: 'CPH2449', deviceModelType: 2, ram: '15.32'
245
245
  },
246
246
  'oppo-find-x7': {
247
247
  os: 'android', platform: PLATFORM.ANDROID,
248
248
  model: 'OPPO Find X7', manufacturer: 'OPPO',
249
249
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
250
- modelId: 'CPH2599', deviceModelType: 2
250
+ modelId: 'CPH2599', deviceModelType: 2, ram: '11.40'
251
251
  },
252
252
  'realme-gt5': {
253
253
  os: 'android', platform: PLATFORM.ANDROID,
254
254
  model: 'realme GT 5 Pro', manufacturer: 'realme',
255
255
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
256
- modelId: 'RMX3888', deviceModelType: 2
256
+ modelId: 'RMX3888', deviceModelType: 2, ram: '15.28'
257
257
  }
258
258
  };
259
259
 
@@ -794,11 +794,47 @@ function decodeMessageContainer(buf, depth) {
794
794
  }
795
795
 
796
796
  // Field 6: extendedTextMessage — field 1 = text
797
+ //
798
+ // A text message with a link in it comes as one of these, and everything
799
+ // the sender's client built for the preview rides along: the title and
800
+ // description it scraped, and a thumbnail. The thumbnail is a media file of
801
+ // its own — its own directPath, its own digests, its own mediaKey — not a
802
+ // part of the message, which is why only the inline jpegThumbnail was ever
803
+ // reachable and the full-size one could not be fetched at all.
804
+ //
805
+ // text=1 matchedText=2 description=5 title=6 jpegThumbnail=16
806
+ // thumbnailDirectPath=19 thumbnailSha256=20 thumbnailEncSha256=21
807
+ // mediaKey=22
797
808
  if (!msgResult && f[6] && Buffer.isBuffer(f[6])) {
798
809
  try {
799
- const ext = _decodeFields(f[6]);
810
+ const ext = _decodeFields(f[6]);
800
811
  const text = _str(ext[1]);
801
- if (text) msgResult = { type: 'text', text };
812
+ if (text) {
813
+ msgResult = { type: 'text', text };
814
+
815
+ const matchedText = _str(ext[2]);
816
+ const description = _str(ext[5]);
817
+ const title = _str(ext[6]);
818
+ if (matchedText) msgResult.matchedText = matchedText;
819
+ if (description) msgResult.description = description;
820
+ if (title) msgResult.title = title;
821
+ if (Buffer.isBuffer(ext[16]) && ext[16].length) {
822
+ msgResult.jpegThumbnail = ext[16];
823
+ }
824
+
825
+ // The downloadable preview image, when the sender attached one. All
826
+ // four are needed together: without the key it cannot be decrypted,
827
+ // and without the digests it cannot be checked.
828
+ const thumbPath = _str(ext[19]);
829
+ if (thumbPath && Buffer.isBuffer(ext[22]) && ext[22].length) {
830
+ msgResult.thumbnail = {
831
+ directPath: thumbPath,
832
+ mediaKey: ext[22],
833
+ thumbnailSha256: Buffer.isBuffer(ext[20]) ? ext[20] : null,
834
+ thumbnailEncSha256: Buffer.isBuffer(ext[21]) ? ext[21] : null
835
+ };
836
+ }
837
+ }
802
838
  } catch (_) {}
803
839
  }
804
840
 
@@ -1044,9 +1080,42 @@ function decodeMessageContainer(buf, depth) {
1044
1080
  } catch (_) { msgResult = { type: 'poll', question: '', options: [] }; }
1045
1081
  }
1046
1082
 
1047
- // Field 50: pollUpdateMessage (a vote on an existing poll)
1083
+ // Field 50: pollUpdateMessage — somebody voted on a poll.
1084
+ //
1085
+ // PollUpdateMessage { pollCreationMessageKey=1, vote=2,
1086
+ // senderTimestampMs=3 }
1087
+ // MessageKey { remoteJid=1, fromMe=2, id=3 }
1088
+ // PollEncValue { encPayload=1, encIV=2 }
1089
+ //
1090
+ // The vote itself is encrypted under a key derived from the poll's message
1091
+ // secret, so the ballot cannot be read from the stanza alone — see
1092
+ // decryptPollVote. Every one of these decoded to the bare word 'pollVote'
1093
+ // and nothing else, which left no way to tell which poll had been voted on,
1094
+ // let alone what the vote was.
1048
1095
  if (!msgResult && f[50] && Buffer.isBuffer(f[50])) {
1049
1096
  msgResult = { type: 'pollVote' };
1097
+ try {
1098
+ const upd = _decodeFields(f[50]);
1099
+
1100
+ if (Buffer.isBuffer(upd[1])) {
1101
+ const key = _decodeFields(upd[1]);
1102
+ msgResult.pollKey = {
1103
+ remoteJid: _str(key[1]) || null,
1104
+ fromMe: !!key[2],
1105
+ id: _str(key[3]) || null
1106
+ };
1107
+ }
1108
+
1109
+ if (Buffer.isBuffer(upd[2])) {
1110
+ const vote = _decodeFields(upd[2]);
1111
+ if (Buffer.isBuffer(vote[1]) && Buffer.isBuffer(vote[2])) {
1112
+ msgResult.encPayload = vote[1];
1113
+ msgResult.encIv = vote[2];
1114
+ }
1115
+ }
1116
+
1117
+ if (typeof upd[3] === 'number') msgResult.senderTimestampMs = upd[3];
1118
+ } catch (_) { /* the bare type is still worth reporting */ }
1050
1119
  }
1051
1120
 
1052
1121
  // If we found a real message, attach skdmInfo / messageSecret and return.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.29.2",
3
+ "version": "5.29.4",
4
4
  "description": "Node.js library for WhatsApp — register a number over SMS, or link as a companion by QR. Signal E2E encryption, media, groups, channels.",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",