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/README.md +98 -6
- package/index.d.ts +85 -2
- package/lib/Client.js +321 -77
- package/lib/DeviceConfig.js +8 -1
- package/lib/DeviceManager.js +102 -36
- package/lib/MediaService.js +78 -13
- package/lib/Registration.js +82 -7
- package/lib/Store.js +12 -0
- package/lib/constants.js +15 -15
- package/lib/proto/MessageProto.js +72 -3
- package/package.json +1 -1
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
|
|
1902
|
-
if (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
|
|
1926
|
-
* we were away. Whatever the client does
|
|
1927
|
-
* or the same announcement arrives on
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
3964
|
-
if (
|
|
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
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
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 (
|
|
3981
|
-
_whaDbg('[DBG] NOTIF_DEVICES delta verified
|
|
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
|
|
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
|
-
*
|
|
4047
|
-
*
|
|
4048
|
-
*
|
|
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
|
-
*
|
|
4051
|
-
*
|
|
4052
|
-
* this
|
|
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
|
|
4055
|
-
* beside
|
|
4056
|
-
* list. That half is optional
|
|
4057
|
-
*
|
|
4058
|
-
*
|
|
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 {
|
|
4062
|
-
*
|
|
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
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
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
|
|
4095
|
-
const
|
|
4096
|
-
if (
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
//
|
|
4100
|
-
|
|
4101
|
-
const
|
|
4102
|
-
const
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
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
|
-
|
|
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 }
|
package/lib/DeviceConfig.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|