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.
package/lib/Client.js CHANGED
@@ -1898,8 +1898,18 @@ class WhalibmobClient extends EventEmitter {
1898
1898
  const dirtyTypes = [];
1899
1899
  for (const child of children) {
1900
1900
  if (!child || child.description !== 'dirty') continue;
1901
- const type = child.attrs && child.attrs.type;
1902
- if (type) dirtyTypes.push(type);
1901
+ const attrs = child.attrs || {};
1902
+ if (!attrs.type) continue;
1903
+ // The announcement carries the moment the change happened, and clearing
1904
+ // the bit means naming that moment back. A <clean> without it does not
1905
+ // acknowledge anything in particular, so the server keeps announcing the
1906
+ // same change on every connection from now on — which is what it did:
1907
+ // the timestamp was on the wire and never read off it. whatsmeow's
1908
+ // MarkNotDirty takes it as an argument for exactly this reason.
1909
+ dirtyTypes.push({
1910
+ type: String(attrs.type),
1911
+ timestamp: attrs.timestamp ? String(attrs.timestamp) : null
1912
+ });
1903
1913
  }
1904
1914
 
1905
1915
  if (dirtyTypes.length > 0) {
@@ -1922,15 +1932,23 @@ class WhalibmobClient extends EventEmitter {
1922
1932
  /**
1923
1933
  * Acknowledge a dirty bit so the server stops announcing it.
1924
1934
  *
1925
- * `<ib><dirty type="..."/></ib>` is the server saying something changed while
1926
- * we were away. Whatever the client does about it, the bit has to be cleared
1927
- * or the same announcement arrives on every connection.
1935
+ * `<ib><dirty type="..." timestamp="..."/></ib>` is the server saying
1936
+ * something changed while we were away, and when. Whatever the client does
1937
+ * about it, the bit has to be cleared or the same announcement arrives on
1938
+ * every connection.
1939
+ *
1940
+ * The timestamp is half of that acknowledgement: it says *which* change is
1941
+ * being cleared. Every `<clean>` this sent went out without one, because the
1942
+ * attribute was never read off the `<dirty>` that carried it — so nothing was
1943
+ * acknowledged in particular and the server kept announcing. whatsmeow's
1944
+ * MarkNotDirty takes it as an argument for the same reason.
1928
1945
  *
1929
1946
  * @param {string} type the dirty type, e.g. 'groups'
1930
1947
  * @param {number|string} [fromTs] the timestamp the announcement carried
1948
+ * @returns {boolean} whether the acknowledgement went out
1931
1949
  */
1932
- _cleanDirtyBits(type, fromTs) {
1933
- if (!this._socket || !this._connected) return;
1950
+ markNotDirty(type, fromTs) {
1951
+ if (!this._socket || !this._connected) return false;
1934
1952
  const attrs = { type: String(type) };
1935
1953
  if (fromTs) attrs.timestamp = String(fromTs);
1936
1954
  _whaDbg('[DBG] CLEAN_DIRTY type=' + type + (fromTs ? ' t=' + fromTs : ''));
@@ -1940,6 +1958,7 @@ class WhalibmobClient extends EventEmitter {
1940
1958
  type: 'set',
1941
1959
  xmlns: 'urn:xmpp:whatsapp:dirty'
1942
1960
  }, [new BinaryNode('clean', attrs, null)]));
1961
+ return true;
1943
1962
  }
1944
1963
 
1945
1964
  /**
@@ -1948,7 +1967,7 @@ class WhalibmobClient extends EventEmitter {
1948
1967
  * Best effort on purpose: the bit has to be cleared either way, or the server
1949
1968
  * announces the same change on every connection from now on.
1950
1969
  */
1951
- _refreshGroupsAfterDirty(type) {
1970
+ _refreshGroupsAfterDirty(type, fromTs) {
1952
1971
  Promise.resolve()
1953
1972
  .then(() => this.fetchAllGroups())
1954
1973
  .then(groups => {
@@ -1957,7 +1976,7 @@ class WhalibmobClient extends EventEmitter {
1957
1976
  .catch(err => {
1958
1977
  _whaDbg('[DBG] DIRTY groups — refresh failed: ' + (err && err.message));
1959
1978
  })
1960
- .then(() => this._cleanDirtyBits(type));
1979
+ .then(() => this.markNotDirty(type, fromTs));
1961
1980
  }
1962
1981
 
1963
1982
  _sendAppStateSyncForTypes(dirtyTypes) {
@@ -1974,8 +1993,10 @@ class WhalibmobClient extends EventEmitter {
1974
1993
  regular: ['regular']
1975
1994
  };
1976
1995
 
1996
+ // Each entry is { type, timestamp } — the timestamp is what the
1997
+ // announcement said, and it goes back on the <clean> that acknowledges it.
1977
1998
  const collections = new Set();
1978
- for (const type of dirtyTypes) {
1999
+ for (const { type, timestamp } of dirtyTypes) {
1979
2000
  const mapped = COLLECTION_MAP[type];
1980
2001
  if (!mapped) {
1981
2002
  if (type === 'groups') {
@@ -1983,14 +2004,14 @@ class WhalibmobClient extends EventEmitter {
1983
2004
  // away. Clearing it without looking leaves every cached group sitting
1984
2005
  // on the membership and settings it had before we disconnected, so
1985
2006
  // re-read them first and acknowledge after.
1986
- this._refreshGroupsAfterDirty(type);
2007
+ this._refreshGroupsAfterDirty(type, timestamp);
1987
2008
  continue;
1988
2009
  }
1989
2010
  // Not an app-state collection, but still a dirty bit the server expects
1990
2011
  // to be cleared. Ignoring it meant the server re-announced it on every
1991
2012
  // single connection, forever.
1992
2013
  _whaDbg('[DBG] DIRTY type=' + type + ' — not an app-state collection, clearing the bit');
1993
- this._cleanDirtyBits(type);
2014
+ this.markNotDirty(type, timestamp);
1994
2015
  continue;
1995
2016
  }
1996
2017
  for (const c of mapped) collections.add(c);
@@ -2003,8 +2024,8 @@ class WhalibmobClient extends EventEmitter {
2003
2024
  // session that registered its own number — would otherwise leave it set
2004
2025
  // forever and be told about the same change on every single connection.
2005
2026
  const clearAll = () => {
2006
- for (const type of dirtyTypes) {
2007
- if (COLLECTION_MAP[type]) this._cleanDirtyBits(type);
2027
+ for (const { type, timestamp } of dirtyTypes) {
2028
+ if (COLLECTION_MAP[type]) this.markNotDirty(type, timestamp);
2008
2029
  }
2009
2030
  };
2010
2031
 
@@ -3960,26 +3981,28 @@ class WhalibmobClient extends EventEmitter {
3960
3981
  // hash, and if it matches there is nothing to invalidate and nothing to
3961
3982
  // ask usync about — see DeviceManager.verifyDeviceDelta().
3962
3983
  if (fromUser && this._devMgr) {
3963
- const delta = this._parseDeviceDelta(node);
3964
- if (delta) {
3984
+ const steps = this._parseDeviceDelta(node);
3985
+ if (steps) {
3965
3986
  // The primary half is keyed the way the notification is addressed.
3966
- const priKey = isLid ? 'lid:' + fromUser : fromUser;
3967
- const priServer = isLid ? 'lid' : 's.whatsapp.net';
3968
- const priOk = this._devMgr.verifyDeviceDelta(
3969
- priKey, fromUser, delta.changes, delta.hash, priServer);
3970
-
3971
3987
  // The secondary half only exists when the notification arrived under
3972
3988
  // the number and named the LID alongside it.
3973
- if (!isLid && lidUser && delta.lidHash && delta.lidChanges) {
3974
- const lidOk = this._devMgr.verifyDeviceDelta(
3975
- 'lid:' + lidUser, lidUser, delta.lidChanges, delta.lidHash, 'lid');
3989
+ const { primaryOk, lidOk } = this._devMgr.applyDeviceDelta({
3990
+ priKey: isLid ? 'lid:' + fromUser : fromUser,
3991
+ priUser: fromUser,
3992
+ priServer: isLid ? 'lid' : 's.whatsapp.net',
3993
+ lidKey: (!isLid && lidUser) ? 'lid:' + lidUser : null,
3994
+ lidUser: (!isLid && lidUser) ? lidUser : null,
3995
+ steps
3996
+ });
3997
+
3998
+ if (!isLid && lidUser) {
3976
3999
  _whaDbg('[DBG] NOTIF_DEVICES lid delta ' +
3977
4000
  (lidOk ? 'verified' : 'unverified') + ' for lid:' + lidUser);
3978
4001
  }
3979
4002
 
3980
- if (priOk) {
3981
- _whaDbg('[DBG] NOTIF_DEVICES delta verified for ' + priKey +
3982
- ' — cache kept, no usync needed');
4003
+ if (primaryOk) {
4004
+ _whaDbg('[DBG] NOTIF_DEVICES delta verified over ' + steps.length +
4005
+ ' step(s) — cache kept, no usync needed');
3983
4006
  return;
3984
4007
  }
3985
4008
  }
@@ -4037,39 +4060,44 @@ class WhalibmobClient extends EventEmitter {
4037
4060
  }
4038
4061
 
4039
4062
  /**
4040
- * Read a `devices` notification as a change plus the hash it should produce.
4063
+ * Read a `devices` notification as an ordered list of steps.
4041
4064
  *
4042
4065
  * <notification type="devices" from="…">
4043
4066
  * <add device_hash="2:…"><device jid="40711111111:3@s.whatsapp.net"/></add>
4067
+ * <add device_hash="2:…"><device jid="40711111111:4@s.whatsapp.net"/></add>
4044
4068
  * </notification>
4045
4069
  *
4046
- * Children are `add`, `remove`, or `update`. Only the first two say something
4047
- * that can be applied; `update` does not name what it changed, so it is
4048
- * treated as unreadable here and the caller falls back to invalidating.
4070
+ * Each child is one change, and the `device_hash` on it is the hash of the
4071
+ * device list **after that change** — not of the notification as a whole. So
4072
+ * children in a multi-change notification carry different hashes, by design,
4073
+ * and each has to be checked against the list as it stands at that point.
4074
+ *
4075
+ * This used to demand that every child agree on one hash and give up when
4076
+ * they did not, which is exactly what a two-change notification looks like:
4077
+ * someone linking a laptop and a tablet in the same sitting produced two
4078
+ * children, two hashes, and the whole thing was thrown away — cache dropped,
4079
+ * full usync round-trip on the next send. Steps are now kept separately and
4080
+ * applied one at a time, the way whatsmeow's handleDeviceNotification does.
4049
4081
  *
4050
- * All children must agree on the hash — the server sends one per child, and a
4051
- * notification carrying two different ones is describing intermediate states
4052
- * this cannot reconstruct.
4082
+ * Children are `add`, `remove`, or `update`. `update` does not name what it
4083
+ * changed, so it survives as a step that tells the applier to drop the cache
4084
+ * and carry on with the rest. A tag this does not know is skipped, and a
4085
+ * child that cannot be read at all rejects the notification: guessing at a
4086
+ * change we could not parse is how a device list quietly goes wrong.
4053
4087
  *
4054
- * Each child may also carry `device_lid_hash`, and each `<device>` a `lid`
4055
- * beside its `jid`, describing the same change to the contact's LID device
4056
- * list. That half is optional: missing on any child, and only the phone-number
4057
- * half is returned, which is what the caller then verifies. It is never a
4058
- * reason to reject the notification outright — the two lists are hashed
4059
- * independently by the server and are checked independently here.
4088
+ * Each child may also carry `device_lid_hash`, and its `<device>` a `lid`
4089
+ * beside the `jid`, describing the same change to the contact's LID device
4090
+ * list. That half is per-step and optional — a step without it leaves the LID
4091
+ * list alone rather than invalidating the step, because the server hashes the
4092
+ * two lists independently and they are checked independently here.
4060
4093
  *
4061
- * @returns {{changes: Array<{tag, deviceId}>, hash: string,
4062
- * lidChanges: Array<{tag, deviceId}>|null, lidHash: string|null}|null}
4094
+ * @returns {Array<{tag: 'add'|'remove'|'update', deviceId: number|null,
4095
+ * hash: string|null, lidDeviceId: number|null,
4096
+ * lidHash: string|null}>|null}
4063
4097
  */
4064
4098
  _parseDeviceDelta(node) {
4065
4099
  if (!node || !Array.isArray(node.content)) return null;
4066
4100
 
4067
- const changes = [];
4068
- const lidChanges = [];
4069
- let hash = null;
4070
- let lidHash = null;
4071
- let lidOk = true; // every child so far carried a usable LID half
4072
-
4073
4101
  // "user:3@server" / "user@server" → 3 / 0, or null when unreadable.
4074
4102
  const deviceIdOf = (jid) => {
4075
4103
  if (!jid) return null;
@@ -4080,40 +4108,43 @@ class WhalibmobClient extends EventEmitter {
4080
4108
  return Number.isFinite(device) ? device : null;
4081
4109
  };
4082
4110
 
4111
+ const steps = [];
4112
+
4083
4113
  for (const child of node.content) {
4084
4114
  if (!child || !child.description) continue;
4085
4115
  const tag = child.description;
4086
- if (tag !== 'add' && tag !== 'remove') return null; // 'update' and friends
4087
4116
 
4088
- const childHash = child.attrs && child.attrs.device_hash;
4089
- if (!childHash) return null;
4090
- if (hash === null) hash = String(childHash);
4091
- else if (hash !== String(childHash)) return null; // disagreeing children
4117
+ if (tag === 'update') {
4118
+ steps.push({ tag, deviceId: null, hash: null, lidDeviceId: null, lidHash: null });
4119
+ continue;
4120
+ }
4121
+ if (tag !== 'add' && tag !== 'remove') continue; // not ours to interpret
4122
+
4123
+ const attrs = child.attrs || {};
4124
+ const hash = attrs.device_hash ? String(attrs.device_hash) : null;
4125
+ if (!hash) return null;
4092
4126
 
4093
4127
  const deviceNode = findChild(child, 'device');
4094
- const attrs = (deviceNode && deviceNode.attrs) || {};
4095
- const device = deviceIdOf(attrs.jid);
4096
- if (device === null) return null;
4097
- changes.push({ tag, deviceId: device });
4098
-
4099
- // LID half — optional, and giving up on it costs the number half nothing.
4100
- if (!lidOk) continue;
4101
- const childLidHash = child.attrs.device_lid_hash;
4102
- const lidDevice = deviceIdOf(attrs.lid);
4103
- if (!childLidHash || lidDevice === null) { lidOk = false; continue; }
4104
- if (lidHash === null) lidHash = String(childLidHash);
4105
- else if (lidHash !== String(childLidHash)) { lidOk = false; continue; }
4106
- lidChanges.push({ tag, deviceId: lidDevice });
4107
- }
4108
-
4109
- if (!changes.length || !hash) return null;
4110
- const haveLid = lidOk && lidHash && lidChanges.length === changes.length;
4111
- return {
4112
- changes,
4113
- hash,
4114
- lidChanges: haveLid ? lidChanges : null,
4115
- lidHash: haveLid ? lidHash : null
4116
- };
4128
+ const dattrs = (deviceNode && deviceNode.attrs) || {};
4129
+ const deviceId = deviceIdOf(dattrs.jid);
4130
+ if (deviceId === null) return null;
4131
+
4132
+ // Both halves of the LID pair or neither: a hash with no device to apply
4133
+ // it to, or a device with no hash to check it against, is not a step.
4134
+ const lidHash = attrs.device_lid_hash ? String(attrs.device_lid_hash) : null;
4135
+ const lidDeviceId = deviceIdOf(dattrs.lid);
4136
+ const haveLid = !!lidHash && lidDeviceId !== null;
4137
+
4138
+ steps.push({
4139
+ tag,
4140
+ deviceId,
4141
+ hash,
4142
+ lidDeviceId: haveLid ? lidDeviceId : null,
4143
+ lidHash: haveLid ? lidHash : null
4144
+ });
4145
+ }
4146
+
4147
+ return steps.length ? steps : null;
4117
4148
  }
4118
4149
 
4119
4150
  _processDeviceUpdate(devicesNode) {
@@ -6285,9 +6316,59 @@ class WhalibmobClient extends EventEmitter {
6285
6316
  const keyName = getMediaKeyName(KEY_NAME[d.type] || d.type);
6286
6317
  if (!keyName) throw new Error('downloadMedia: unsupported media type "' + d.type + '"');
6287
6318
 
6319
+ // Both digests go down by default. The message says what the file should
6320
+ // be; there is no reason to take the CDN's word for it. `verify: false`
6321
+ // turns them off for a caller that wants the bytes whatever they are — the
6322
+ // MAC is checked either way, and that is the one that authenticates.
6288
6323
  return downloadMedia(url, d.mediaKey, keyName, {
6289
6324
  web: this._mode === 'web',
6290
- fileEncSha256: opts.verify ? d.fileEncSha256 : undefined
6325
+ verify: opts.verify !== false,
6326
+ fileEncSha256: d.fileEncSha256,
6327
+ fileSha256: d.fileSha256
6328
+ });
6329
+ }
6330
+
6331
+ /**
6332
+ * Download the preview image of a message that quoted a link.
6333
+ *
6334
+ * A link preview's thumbnail is a media file in its own right — its own
6335
+ * directPath, its own mediaKey, its own digests — carried beside the text
6336
+ * rather than inside it, and encrypted under a key name of its own. So
6337
+ * `downloadMedia` cannot fetch it: that reads the message's own media, and a
6338
+ * text message has none. This is whatsmeow's DownloadThumbnail.
6339
+ *
6340
+ * The small inline preview is a different thing and needs no download: it is
6341
+ * already on the decoded message as `jpegThumbnail`.
6342
+ *
6343
+ * @param {object} decoded a decoded message, or the `message` event payload
6344
+ * @param {object} [opts] `verify: false` to skip the digest checks
6345
+ * @returns {Promise<Buffer>} the full-size preview image
6346
+ */
6347
+ async downloadThumbnail(decoded, opts) {
6348
+ opts = opts || {};
6349
+ const d = (decoded && decoded.decoded) ? decoded.decoded : decoded;
6350
+ if (!d) throw new Error('downloadThumbnail: a decoded message is required');
6351
+
6352
+ const thumb = d.thumbnail;
6353
+ if (!thumb || !thumb.mediaKey) {
6354
+ throw new Error('downloadThumbnail: this message carries no downloadable ' +
6355
+ 'thumbnail (a link preview without one, or not a link at all)');
6356
+ }
6357
+
6358
+ const { downloadMedia, getThumbnailKeyName, resolveMediaUrl } =
6359
+ require('./MediaService');
6360
+ const keyName = getThumbnailKeyName(d.type === 'text' ? 'text' : d.type);
6361
+ if (!keyName) {
6362
+ throw new Error('downloadThumbnail: no thumbnail key name for "' + d.type + '"');
6363
+ }
6364
+ const url = resolveMediaUrl(null, thumb.directPath);
6365
+ if (!url) throw new Error('downloadThumbnail: the thumbnail has no CDN location');
6366
+
6367
+ return downloadMedia(url, thumb.mediaKey, keyName, {
6368
+ web: this._mode === 'web',
6369
+ verify: opts.verify !== false,
6370
+ fileEncSha256: thumb.thumbnailEncSha256,
6371
+ fileSha256: thumb.thumbnailSha256
6291
6372
  });
6292
6373
  }
6293
6374
 
@@ -7290,6 +7371,169 @@ class WhalibmobClient extends EventEmitter {
7290
7371
  return this._sender.sendPoll(to, question, options, selectableCount, opts);
7291
7372
  }
7292
7373
 
7374
+ /**
7375
+ * Read a vote on a poll.
7376
+ *
7377
+ * A vote arrives as its own message and the ballot inside it is encrypted, so
7378
+ * nothing about who voted for what is visible in the stanza. The key is
7379
+ * derived from the poll's message secret — the `encKey` `sendPoll` returned,
7380
+ * or `decoded.encKey` on a poll somebody else sent us:
7381
+ *
7382
+ * key = HKDF-SHA256(secret, salt = ∅,
7383
+ * info = pollId ‖ pollSender ‖ voter ‖ "Poll Vote", 32)
7384
+ * AAD = pollId ‖ 0x00 ‖ voter
7385
+ * ballot = AES-256-GCM-open(key, encIv, encPayload, AAD)
7386
+ *
7387
+ * The ballot names the chosen options by SHA-256 of their text, not by index
7388
+ * or by name, so pass `options` — the poll's option strings, in any order —
7389
+ * and the choices come back as text. Without them you get the hashes and can
7390
+ * match them yourself.
7391
+ *
7392
+ * Both JIDs are taken without their device: a vote belongs to an account.
7393
+ *
7394
+ * This is whatsmeow's DecryptPollVote. Nothing here could read a vote before:
7395
+ * `sendPoll` handed back the key and left the rest to the caller.
7396
+ *
7397
+ * @param {object} vote the `message` event payload, or its `decoded`
7398
+ * @param {object} opts
7399
+ * @param {Buffer} opts.encKey the poll's message secret (aka messageSecret)
7400
+ * @param {string} [opts.voter] who voted; defaults to the message's sender
7401
+ * @param {string} [opts.pollSender] who sent the poll; defaults to the key's
7402
+ * remoteJid, or to the voter when the poll is
7403
+ * ours
7404
+ * @param {string[]} [opts.options] the poll's options, to name the choices
7405
+ * @returns {{selected: string[], selectedHashes: Buffer[], votedAt: number|null}}
7406
+ */
7407
+ decryptPollVote(vote, opts) {
7408
+ opts = opts || {};
7409
+ const d = (vote && vote.decoded) ? vote.decoded : vote;
7410
+ if (!d || d.type !== 'pollVote') {
7411
+ throw new Error('decryptPollVote: this is not a vote on a poll');
7412
+ }
7413
+ if (!d.encPayload || !d.encIv) {
7414
+ throw new Error('decryptPollVote: the vote carries no encrypted ballot');
7415
+ }
7416
+ const secret = opts.encKey || opts.messageSecret;
7417
+ if (!secret || secret.length !== 32) {
7418
+ throw new Error('decryptPollVote: the poll\'s 32-byte encKey is required — ' +
7419
+ 'it is what sendPoll returned, or decoded.encKey on a poll we received');
7420
+ }
7421
+
7422
+ const key = d.pollKey || {};
7423
+ const pollId = String(key.id || '');
7424
+ if (!pollId) throw new Error('decryptPollVote: the vote does not name a poll');
7425
+
7426
+ // Who voted, and who the poll belongs to. `fromMe` on the key means the
7427
+ // poll and the vote came from the same account, which is the one case the
7428
+ // key's remoteJid names the chat rather than the poll's author.
7429
+ const voter = toNonAdJid(String(
7430
+ opts.voter || (vote && vote.participant) || (vote && vote.from) || ''));
7431
+ if (!voter || voter.startsWith('@')) {
7432
+ throw new Error('decryptPollVote: cannot tell who cast this vote — pass opts.voter');
7433
+ }
7434
+ const pollSender = toNonAdJid(String(
7435
+ opts.pollSender || (key.fromMe ? voter : (key.remoteJid || voter))));
7436
+
7437
+ const info = Buffer.concat([
7438
+ Buffer.from(pollId, 'utf8'),
7439
+ Buffer.from(pollSender, 'utf8'),
7440
+ Buffer.from(voter, 'utf8'),
7441
+ Buffer.from('Poll Vote', 'utf8')
7442
+ ]);
7443
+ const { hkdfSha256 } = require('./hkdf');
7444
+ const voteKey = hkdfSha256(Buffer.from(secret), Buffer.alloc(0), info, 32);
7445
+
7446
+ const aad = Buffer.concat([
7447
+ Buffer.from(pollId, 'utf8'), Buffer.from([0]), Buffer.from(voter, 'utf8')
7448
+ ]);
7449
+
7450
+ // GCM's tag is the last 16 bytes of the payload, as it is everywhere else
7451
+ // in this protocol.
7452
+ const payload = Buffer.from(d.encPayload);
7453
+ const ciphertext = payload.subarray(0, payload.length - 16);
7454
+ const tag = payload.subarray(payload.length - 16);
7455
+
7456
+ let plaintext;
7457
+ try {
7458
+ const dec = crypto.createDecipheriv('aes-256-gcm', voteKey, Buffer.from(d.encIv));
7459
+ dec.setAAD(aad);
7460
+ dec.setAuthTag(tag);
7461
+ plaintext = Buffer.concat([dec.update(ciphertext), dec.final()]);
7462
+ } catch (_) {
7463
+ throw new Error('decryptPollVote: the ballot would not open — wrong encKey, ' +
7464
+ 'or the wrong voter/pollSender for this poll');
7465
+ }
7466
+
7467
+ // PollVoteMessage { selectedOptions = 1, repeated bytes } — each entry is
7468
+ // the SHA-256 of one option's text.
7469
+ const { decodeFields } = require('./proto/MessageProto');
7470
+ const parsed = decodeFields(plaintext);
7471
+ const raw = parsed[1] == null ? [] : (Array.isArray(parsed[1]) ? parsed[1] : [parsed[1]]);
7472
+ const selectedHashes = raw.filter(Buffer.isBuffer);
7473
+
7474
+ let selected = [];
7475
+ if (Array.isArray(opts.options) && opts.options.length) {
7476
+ const byHash = new Map(opts.options.map(o => [
7477
+ crypto.createHash('sha256').update(String(o), 'utf8').digest('hex'), String(o)
7478
+ ]));
7479
+ selected = selectedHashes
7480
+ .map(h => byHash.get(h.toString('hex')))
7481
+ .filter(o => o !== undefined);
7482
+ }
7483
+
7484
+ return {
7485
+ selected,
7486
+ selectedHashes,
7487
+ votedAt: d.senderTimestampMs != null ? d.senderTimestampMs : null
7488
+ };
7489
+ }
7490
+
7491
+ /**
7492
+ * Unlink this device from the account.
7493
+ *
7494
+ * The companion asks the server to remove it, the same way the Linked Devices
7495
+ * screen on the phone does, and then closes the connection. Everything on
7496
+ * disk stays where it is — the session it describes no longer exists, so a
7497
+ * later `connectWeb()` pairs afresh rather than trying to resume something
7498
+ * the server has forgotten. Delete the session directory yourself if you want
7499
+ * the keys gone as well.
7500
+ *
7501
+ * Web only: an account that registered its own number is not a companion and
7502
+ * has nothing to unlink from.
7503
+ *
7504
+ * @returns {Promise<boolean>} whether the server accepted the removal
7505
+ */
7506
+ async logout() {
7507
+ if (this._mode !== 'web') {
7508
+ throw new Error('logout: only a companion can unlink itself — a session ' +
7509
+ 'that registered its own number has nothing to unlink from');
7510
+ }
7511
+ if (!this._socket || !this._connected) throw new Error('Not connected');
7512
+
7513
+ const ownJid = toNonAdJid(this._ownDeviceJid() || '');
7514
+ if (!ownJid || ownJid.startsWith('@')) throw new Error('logout: not logged in');
7515
+
7516
+ let accepted = false;
7517
+ try {
7518
+ const resp = await this._sendIq(new BinaryNode('iq', {
7519
+ id: this._genMsgId(),
7520
+ to: 's.whatsapp.net',
7521
+ type: 'set',
7522
+ xmlns: 'md'
7523
+ }, [new BinaryNode('remove-companion-device', {
7524
+ jid: jidStrToObj(ownJid),
7525
+ reason: 'user_initiated'
7526
+ }, null)]));
7527
+ accepted = !!(resp && !(resp.attrs && resp.attrs.type === 'error'));
7528
+ } finally {
7529
+ // The connection goes either way. A server that refused the removal is
7530
+ // still a server we asked to be forgotten by, and staying connected on
7531
+ // that basis is worse than dropping.
7532
+ this.disconnect();
7533
+ }
7534
+ return accepted;
7535
+ }
7536
+
7293
7537
  // ─── Location ─────────────────────────────────────────────────────────────
7294
7538
  // sendLocation(to, latitude, longitude, opts)
7295
7539
  // opts: { name, address, url, id, contextInfo }
@@ -55,9 +55,13 @@ function getDeviceConfig() {
55
55
  const profileKey = _normalise(process.env.WA_DEVICE || 'samsungs24ultra');
56
56
  const found = _ANDROID_MAP[profileKey] || null;
57
57
  if (found) {
58
- return Object.assign({}, found, business
58
+ const picked = Object.assign({}, found, business
59
59
  ? { business: true, platform: PLATFORM.ANDROID_BUSINESS }
60
60
  : { business: false });
61
+ // A named profile carries the handset's own RAM; WA_DEVICE_RAM overrides
62
+ // it for someone running the same model in a different memory variant.
63
+ if (process.env.WA_DEVICE_RAM) picked.ram = process.env.WA_DEVICE_RAM;
64
+ return picked;
61
65
  }
62
66
 
63
67
  return {
@@ -69,6 +73,9 @@ function getDeviceConfig() {
69
73
  osVersion: process.env.WA_DEVICE_OS_VERSION || '14',
70
74
  osBuildNumber: process.env.WA_DEVICE_BUILD || 'UP1A.231005.007',
71
75
  modelId: process.env.WA_DEVICE_MODEL_ID || 'SM-S928B',
76
+ // What the handset reports as usable RAM, in GiB. Registration sends it,
77
+ // and it has to belong to the handset beside it — see deviceRam().
78
+ ram: process.env.WA_DEVICE_RAM || '11.55',
72
79
  deviceModelType: 2
73
80
  };
74
81
  }