whalibmob 5.7.3 → 5.9.1
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 +533 -143
- package/cli.js +213 -55
- package/index.js +13 -1
- package/lib/Client.js +899 -177
- package/lib/GroupParticipant.js +141 -0
- package/lib/MediaService.js +28 -1
- package/lib/appstate/AppStateStore.js +100 -0
- package/lib/appstate/AppStateSync.js +292 -0
- package/lib/appstate/LTHash.js +119 -0
- package/lib/appstate/Mutations.js +106 -0
- package/lib/appstate/SyncdProto.js +263 -0
- package/lib/messages/MessageSender.js +32 -0
- package/lib/proto/MessageProto.js +75 -10
- package/package.json +1 -1
package/lib/Client.js
CHANGED
|
@@ -12,6 +12,14 @@ const { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, assert
|
|
|
12
12
|
const { getDeviceConfig } = require('./DeviceConfig');
|
|
13
13
|
const { createNewStore, saveStore, loadStore, toSixParts, fromSixParts } = require('./Store');
|
|
14
14
|
const { BinaryNode } = require('./BinaryNode');
|
|
15
|
+
const { AppStateStore, COLLECTIONS } = require('./appstate/AppStateStore');
|
|
16
|
+
const AppStateSync = require('./appstate/AppStateSync');
|
|
17
|
+
const SyncdProto = require('./appstate/SyncdProto');
|
|
18
|
+
const AppMutations = require('./appstate/Mutations');
|
|
19
|
+
const {
|
|
20
|
+
GroupParticipantResult, GroupJoinRequest,
|
|
21
|
+
parseParticipantNodes, parseJoinRequestNode
|
|
22
|
+
} = require('./GroupParticipant');
|
|
15
23
|
|
|
16
24
|
// How many times a message may be re-sent in answer to retry receipts before
|
|
17
25
|
// the client stops. Each resend can itself draw retries, so this is what keeps
|
|
@@ -28,6 +36,7 @@ const {
|
|
|
28
36
|
decodeHistorySyncNotification,
|
|
29
37
|
decodeAppStateSyncKeyShare,
|
|
30
38
|
saveAppStateSyncKeys,
|
|
39
|
+
loadAppStateSyncKeys,
|
|
31
40
|
HistorySyncType
|
|
32
41
|
} = require('./HistorySyncHandler');
|
|
33
42
|
|
|
@@ -38,7 +47,7 @@ const STATUS_BROADCAST_JID = 'status@broadcast';
|
|
|
38
47
|
const CALL_AUDIO_PREFIX = 'https://call.whatsapp.com/voice/';
|
|
39
48
|
const CALL_VIDEO_PREFIX = 'https://call.whatsapp.com/video/';
|
|
40
49
|
|
|
41
|
-
//
|
|
50
|
+
// The one prefix group invite codes are wrapped in.
|
|
42
51
|
const INVITE_LINK_PREFIX = 'https://chat.whatsapp.com/';
|
|
43
52
|
|
|
44
53
|
// The names privacy settings actually go by on the wire, and the friendly
|
|
@@ -233,7 +242,6 @@ class WhalibmobClient extends EventEmitter {
|
|
|
233
242
|
// keyIds handed out by unresolved retries — never advertise one twice, or
|
|
234
243
|
// the second sender's pkmsg arrives after the key was consumed and deleted.
|
|
235
244
|
this._retryAdvertisedPreKeys = new Set();
|
|
236
|
-
this._appStateVersions = {}; // collectionName → version (int)
|
|
237
245
|
this._sentMsgCache = new Map(); // msgId → {plaintext, toJid, msgId, mediaType, options, recipientPhone}
|
|
238
246
|
this._tcTokenStore = null; // TcTokenStore — loaded in init()
|
|
239
247
|
this._inFlightTcTokenIssuance = new Set(); // dedupe concurrent proactive issuePrivacyTokens per JID
|
|
@@ -305,6 +313,8 @@ class WhalibmobClient extends EventEmitter {
|
|
|
305
313
|
|
|
306
314
|
const { TcTokenStore } = require('./messages/TcTokenStore');
|
|
307
315
|
this._tcTokenStore = new TcTokenStore(tcTokenFile);
|
|
316
|
+
this._appState = new AppStateStore(
|
|
317
|
+
path.join(this._sessionDir, `${phoneNumber}.appState.json`));
|
|
308
318
|
|
|
309
319
|
this._signal = SignalProtocol.fromStore(this._store, signalFile, skFile);
|
|
310
320
|
this._devMgr = new DeviceManager(this);
|
|
@@ -491,6 +501,8 @@ class WhalibmobClient extends EventEmitter {
|
|
|
491
501
|
|
|
492
502
|
const { TcTokenStore } = require('./messages/TcTokenStore');
|
|
493
503
|
this._tcTokenStore = new TcTokenStore(tcTokenFile);
|
|
504
|
+
this._appState = new AppStateStore(
|
|
505
|
+
path.join(this._sessionDir, `${phoneNumber}.web.appState.json`));
|
|
494
506
|
|
|
495
507
|
this._signal = SignalProtocol.fromStore(this._store, signalFile, skFile);
|
|
496
508
|
this._devMgr = new DeviceManager(this);
|
|
@@ -944,7 +956,8 @@ class WhalibmobClient extends EventEmitter {
|
|
|
944
956
|
// Every per-session file shares the phone number as its stem.
|
|
945
957
|
const SUFFIXES = ['.json', '.signal.json', '.sk.json', '.tctoken.json',
|
|
946
958
|
'.device-cache.json', '.lid-mapping.json',
|
|
947
|
-
'.lid-reverse-mapping.json', '.history.json', '.messages.json'
|
|
959
|
+
'.lid-reverse-mapping.json', '.history.json', '.messages.json',
|
|
960
|
+
'.appState.json', '.appStateKeys.json'];
|
|
948
961
|
const moved = [];
|
|
949
962
|
for (const suffix of SUFFIXES) {
|
|
950
963
|
const from = path.join(this._sessionDir, current + suffix);
|
|
@@ -1160,12 +1173,11 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1160
1173
|
}, [new BinaryNode('active', {}, null)]));
|
|
1161
1174
|
}
|
|
1162
1175
|
|
|
1163
|
-
// ──
|
|
1176
|
+
// ── <ib> dirty-state notifications ────────────────────────────────────────
|
|
1164
1177
|
//
|
|
1165
|
-
// After login the server sends <ib><dirty type="account_sync"
|
|
1166
|
-
//
|
|
1167
|
-
//
|
|
1168
|
-
// collection in _appStateVersions so we don't repeatedly request version 0.
|
|
1178
|
+
// After login the server sends <ib><dirty type="account_sync"/></ib> to say
|
|
1179
|
+
// our app state is behind. Each dirty type names what has moved on; the sync
|
|
1180
|
+
// that follows asks for it from the version we last stored.
|
|
1169
1181
|
_handleIb(node) {
|
|
1170
1182
|
const children = Array.isArray(node.content) ? node.content : [];
|
|
1171
1183
|
|
|
@@ -1216,27 +1228,53 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1216
1228
|
}, [new BinaryNode('clean', attrs, null)]));
|
|
1217
1229
|
}
|
|
1218
1230
|
|
|
1231
|
+
/**
|
|
1232
|
+
* Re-read every group we belong to, then acknowledge the dirty bit.
|
|
1233
|
+
*
|
|
1234
|
+
* Best effort on purpose: the bit has to be cleared either way, or the server
|
|
1235
|
+
* announces the same change on every connection from now on.
|
|
1236
|
+
*/
|
|
1237
|
+
_refreshGroupsAfterDirty(type) {
|
|
1238
|
+
Promise.resolve()
|
|
1239
|
+
.then(() => this.fetchAllGroups())
|
|
1240
|
+
.then(groups => {
|
|
1241
|
+
_whaDbg('[DBG] DIRTY groups — refreshed ' + (groups ? groups.length : 0) + ' group(s)');
|
|
1242
|
+
})
|
|
1243
|
+
.catch(err => {
|
|
1244
|
+
_whaDbg('[DBG] DIRTY groups — refresh failed: ' + (err && err.message));
|
|
1245
|
+
})
|
|
1246
|
+
.then(() => this._cleanDirtyBits(type));
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1219
1249
|
_sendAppStateSyncForTypes(dirtyTypes) {
|
|
1220
1250
|
if (!this._socket || !this._connected) return;
|
|
1221
1251
|
|
|
1222
|
-
//
|
|
1223
|
-
//
|
|
1252
|
+
// "account_sync" means everything; the rest name one collection each.
|
|
1253
|
+
// Anything not in here is a dirty bit that travels by some other route.
|
|
1224
1254
|
const COLLECTION_MAP = {
|
|
1225
|
-
account_sync:
|
|
1226
|
-
critical_block:
|
|
1227
|
-
critical_unblock_low:['critical_unblock_low'],
|
|
1228
|
-
regular_low:
|
|
1229
|
-
regular_high:
|
|
1230
|
-
regular:
|
|
1255
|
+
account_sync: COLLECTIONS.slice(),
|
|
1256
|
+
critical_block: ['critical_block'],
|
|
1257
|
+
critical_unblock_low: ['critical_unblock_low'],
|
|
1258
|
+
regular_low: ['regular_low'],
|
|
1259
|
+
regular_high: ['regular_high'],
|
|
1260
|
+
regular: ['regular']
|
|
1231
1261
|
};
|
|
1232
1262
|
|
|
1233
1263
|
const collections = new Set();
|
|
1234
1264
|
for (const type of dirtyTypes) {
|
|
1235
1265
|
const mapped = COLLECTION_MAP[type];
|
|
1236
1266
|
if (!mapped) {
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1267
|
+
if (type === 'groups') {
|
|
1268
|
+
// The bit means something about our groups changed while we were
|
|
1269
|
+
// away. Clearing it without looking leaves every cached group sitting
|
|
1270
|
+
// on the membership and settings it had before we disconnected, so
|
|
1271
|
+
// re-read them first and acknowledge after.
|
|
1272
|
+
this._refreshGroupsAfterDirty(type);
|
|
1273
|
+
continue;
|
|
1274
|
+
}
|
|
1275
|
+
// Not an app-state collection, but still a dirty bit the server expects
|
|
1276
|
+
// to be cleared. Ignoring it meant the server re-announced it on every
|
|
1277
|
+
// single connection, forever.
|
|
1240
1278
|
_whaDbg('[DBG] DIRTY type=' + type + ' — not an app-state collection, clearing the bit');
|
|
1241
1279
|
this._cleanDirtyBits(type);
|
|
1242
1280
|
continue;
|
|
@@ -1245,41 +1283,381 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1245
1283
|
}
|
|
1246
1284
|
|
|
1247
1285
|
if (collections.size === 0) return;
|
|
1286
|
+
this.syncAppState([...collections]).catch(err => {
|
|
1287
|
+
_whaDbg('[DBG] APP_STATE sync failed: ' + (err && err.message));
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1248
1290
|
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1291
|
+
/**
|
|
1292
|
+
* Pull app state down from the server and apply it.
|
|
1293
|
+
*
|
|
1294
|
+
* This is how everything that is not a message reaches a linked device:
|
|
1295
|
+
* which chats are pinned, archived, muted or marked unread, which messages
|
|
1296
|
+
* are starred, what your contacts are called. It is also the only way those
|
|
1297
|
+
* changes travel *back* — see the chat methods further down, which write
|
|
1298
|
+
* patches rather than mutating anything locally.
|
|
1299
|
+
*
|
|
1300
|
+
* A collection is asked for from the version we hold, and the server answers
|
|
1301
|
+
* with the patches since then. From version 0 it sends a snapshot instead:
|
|
1302
|
+
* the whole collection, which is also what we fall back to when the running
|
|
1303
|
+
* hash stops agreeing with the server's.
|
|
1304
|
+
*
|
|
1305
|
+
* @param {string[]} [names] collections to sync; all of them by default
|
|
1306
|
+
* @param {object} [opts] { snapshot: true } to force a full re-read
|
|
1307
|
+
* @returns {Promise<{applied: number, collections: object}>}
|
|
1308
|
+
*/
|
|
1309
|
+
async syncAppState(names, opts) {
|
|
1310
|
+
opts = opts || {};
|
|
1311
|
+
if (!this._socket || !this._connected) throw new Error('Not connected');
|
|
1312
|
+
if (!this._appState) throw new Error('No session — call init() first');
|
|
1313
|
+
|
|
1314
|
+
const wanted = (names && names.length ? names : COLLECTIONS)
|
|
1315
|
+
.filter(n => COLLECTIONS.includes(n));
|
|
1316
|
+
if (!wanted.length) return { applied: 0, collections: {} };
|
|
1317
|
+
|
|
1318
|
+
const keys = this._appStateKeys();
|
|
1319
|
+
if (!Object.keys(keys).length) {
|
|
1320
|
+
// The keys arrive from the primary device in an encrypted message. Until
|
|
1321
|
+
// one has, there is nothing that can be decrypted and asking would only
|
|
1322
|
+
// throw the patches away.
|
|
1323
|
+
_whaDbg('[DBG] APP_STATE no sync keys yet — skipping');
|
|
1324
|
+
return { applied: 0, collections: {}, waitingForKeys: true };
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
const nodes = wanted.map(name => {
|
|
1328
|
+
const st = this._appState.get(name);
|
|
1329
|
+
const wantSnapshot = !!opts.snapshot || !st.version;
|
|
1252
1330
|
return new BinaryNode('collection', {
|
|
1253
1331
|
name,
|
|
1254
|
-
version,
|
|
1255
|
-
return_snapshot: 'false'
|
|
1332
|
+
version: String(wantSnapshot ? 0 : st.version),
|
|
1333
|
+
return_snapshot: wantSnapshot ? 'true' : 'false'
|
|
1256
1334
|
}, null);
|
|
1257
1335
|
});
|
|
1258
1336
|
|
|
1259
|
-
|
|
1260
|
-
this.
|
|
1261
|
-
id,
|
|
1337
|
+
const resp = await this._sendIq(new BinaryNode('iq', {
|
|
1338
|
+
id: this._genMsgId(),
|
|
1262
1339
|
to: 's.whatsapp.net',
|
|
1263
1340
|
type: 'set',
|
|
1264
1341
|
xmlns: 'w:sync:app:state'
|
|
1265
|
-
}, [new BinaryNode('sync', {},
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1342
|
+
}, [new BinaryNode('sync', {}, nodes)]));
|
|
1343
|
+
|
|
1344
|
+
if (!resp) throw new Error('syncAppState: no reply from server (IQ timed out)');
|
|
1345
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
1346
|
+
const errNode = findChild(resp, 'error');
|
|
1347
|
+
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
1348
|
+
throw new Error('syncAppState: server rejected the request' +
|
|
1349
|
+
(code ? ' (' + code + ')' : ''));
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
return this._applyAppStateResponse(resp, opts);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* The sync keys the primary device has shared, keyed by base64 id.
|
|
1357
|
+
*
|
|
1358
|
+
* They are stored under their hex id but every reference on the wire is
|
|
1359
|
+
* base64, so both spellings are returned and a lookup either way works.
|
|
1360
|
+
*/
|
|
1361
|
+
_appStateKeys() {
|
|
1362
|
+
if (!this._store || !this._store.phoneNumber) return {};
|
|
1363
|
+
let raw;
|
|
1364
|
+
try { raw = loadAppStateSyncKeys(this._sessionDir, this._store.phoneNumber) || {}; }
|
|
1365
|
+
catch (_) { raw = {}; }
|
|
1366
|
+
const out = {};
|
|
1367
|
+
for (const [hexId, v] of Object.entries(raw)) {
|
|
1368
|
+
out[hexId] = v;
|
|
1369
|
+
try { out[Buffer.from(hexId, 'hex').toString('base64')] = v; } catch (_) {}
|
|
1370
|
+
}
|
|
1371
|
+
return out;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
async _applyAppStateResponse(resp, opts) {
|
|
1375
|
+
const syncNode = findChild(resp, 'sync');
|
|
1376
|
+
const cols = (syncNode && Array.isArray(syncNode.content))
|
|
1377
|
+
? syncNode.content.filter(n => n && n.description === 'collection') : [];
|
|
1378
|
+
|
|
1379
|
+
const keys = this._appStateKeys();
|
|
1380
|
+
const getKey = (b64) => keys[b64] || null;
|
|
1381
|
+
const summary = {};
|
|
1382
|
+
let applied = 0;
|
|
1383
|
+
let more = [];
|
|
1384
|
+
|
|
1385
|
+
for (const col of cols) {
|
|
1386
|
+
const name = col.attrs && col.attrs.name;
|
|
1387
|
+
if (!name || !COLLECTIONS.includes(name)) continue;
|
|
1388
|
+
|
|
1389
|
+
const mutations = [];
|
|
1390
|
+
const collect = (m) => mutations.push(m);
|
|
1391
|
+
let result = null;
|
|
1392
|
+
let snapshotted = false;
|
|
1393
|
+
|
|
1394
|
+
try {
|
|
1395
|
+
const snapNode = findChild(col, 'snapshot');
|
|
1396
|
+
if (snapNode && snapNode.content) {
|
|
1397
|
+
const blob = await this._fetchAppStateBlob(
|
|
1398
|
+
SyncdProto.decodeExternalBlobReference(getNodeContent(snapNode)));
|
|
1399
|
+
const snap = SyncdProto.decodeSnapshot(blob);
|
|
1400
|
+
result = AppStateSync.decodeSnapshot(name, snap, getKey, collect, true);
|
|
1401
|
+
snapshotted = true;
|
|
1402
|
+
} else {
|
|
1403
|
+
const patchesNode = findChild(col, 'patches') || col;
|
|
1404
|
+
const raw = (Array.isArray(patchesNode.content) ? patchesNode.content : [])
|
|
1405
|
+
.filter(n => n && n.description === 'patch' && getNodeContent(n))
|
|
1406
|
+
.map(n => SyncdProto.decodePatch(getNodeContent(n)));
|
|
1407
|
+
|
|
1408
|
+
// A patch too big to inline is moved into a CDN blob and referenced.
|
|
1409
|
+
for (const p of raw) {
|
|
1410
|
+
if (!p.externalMutations) continue;
|
|
1411
|
+
const blob = await this._fetchAppStateBlob(p.externalMutations);
|
|
1412
|
+
p.mutations = p.mutations.concat(SyncdProto.decodeMutations(blob));
|
|
1413
|
+
}
|
|
1414
|
+
if (!raw.length) { summary[name] = { patches: 0, applied: 0 }; continue; }
|
|
1415
|
+
result = AppStateSync.decodePatches(
|
|
1416
|
+
name, raw, this._appState.get(name), getKey, collect, true);
|
|
1278
1417
|
}
|
|
1418
|
+
} catch (err) {
|
|
1419
|
+
if (err && err.isMissingKey) {
|
|
1420
|
+
// Nothing can be read until the primary shares that key. Leave the
|
|
1421
|
+
// collection where it is so the next sync picks up from here.
|
|
1422
|
+
_whaDbg('[DBG] APP_STATE ' + name + ' waiting for key ' + err.keyId);
|
|
1423
|
+
summary[name] = { waitingForKey: err.keyId };
|
|
1424
|
+
this.emit('app_state_key_missing', { collection: name, keyId: err.keyId });
|
|
1425
|
+
continue;
|
|
1426
|
+
}
|
|
1427
|
+
throw err;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// Our hash and the server's have parted company. Every patch after the
|
|
1431
|
+
// break would be applied to the wrong base, so throw our copy away and
|
|
1432
|
+
// ask for the whole collection instead — once. If the snapshot itself
|
|
1433
|
+
// does not verify, keep what we could read and say so.
|
|
1434
|
+
if (result.macFailed && !snapshotted && !opts.snapshot) {
|
|
1435
|
+
_whaDbg('[DBG] APP_STATE ' + name + ' hash mismatch — refetching as snapshot');
|
|
1436
|
+
this._appState.reset(name);
|
|
1437
|
+
more.push(name);
|
|
1438
|
+
continue;
|
|
1279
1439
|
}
|
|
1440
|
+
|
|
1441
|
+
this._appState.set(name, result.state);
|
|
1442
|
+
applied += this._applyAppStateMutations(name, mutations);
|
|
1443
|
+
summary[name] = {
|
|
1444
|
+
version: result.state.version,
|
|
1445
|
+
applied: mutations.length,
|
|
1446
|
+
skipped: result.skipped || 0,
|
|
1447
|
+
snapshot: snapshotted,
|
|
1448
|
+
macOk: snapshotted ? result.macOk !== false : !result.macFailed
|
|
1449
|
+
};
|
|
1450
|
+
|
|
1451
|
+
if (col.attrs && col.attrs.has_more_patches === 'true') more.push(name);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
if (more.length && !opts._retried) {
|
|
1455
|
+
const again = await this.syncAppState(more, Object.assign({}, opts, { _retried: true }));
|
|
1456
|
+
applied += again.applied;
|
|
1457
|
+
Object.assign(summary, again.collections);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
this.emit('app_state_sync', { collections: summary, applied });
|
|
1461
|
+
return { applied, collections: summary };
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/** Fetch and decrypt an app-state blob the server pushed to the CDN. */
|
|
1465
|
+
async _fetchAppStateBlob(ref) {
|
|
1466
|
+
if (!ref || !ref.mediaKey) throw new Error('app state blob reference is incomplete');
|
|
1467
|
+
const { downloadMedia, resolveMediaUrl } = require('./MediaService');
|
|
1468
|
+
const url = resolveMediaUrl(null, ref.directPath);
|
|
1469
|
+
if (!url) throw new Error('app state blob has no directPath');
|
|
1470
|
+
return downloadMedia(url, ref.mediaKey, 'WhatsApp App State Keys', {
|
|
1471
|
+
web: this._mode === 'web'
|
|
1280
1472
|
});
|
|
1281
1473
|
}
|
|
1282
1474
|
|
|
1475
|
+
/**
|
|
1476
|
+
* Fold decoded mutations into the local view and announce them.
|
|
1477
|
+
*
|
|
1478
|
+
* These are changes made somewhere else — on the phone, or on another linked
|
|
1479
|
+
* device — so they are applied without being echoed back as patches of our
|
|
1480
|
+
* own, which would bounce between devices forever.
|
|
1481
|
+
*/
|
|
1482
|
+
_applyAppStateMutations(collection, mutations) {
|
|
1483
|
+
let count = 0;
|
|
1484
|
+
for (const m of mutations) {
|
|
1485
|
+
const what = AppMutations.describeIndex(m.index);
|
|
1486
|
+
if (!what) continue;
|
|
1487
|
+
const removed = m.operation === SyncdProto.REMOVE;
|
|
1488
|
+
const a = m.action || {};
|
|
1489
|
+
const jid = what.jid ? makeJid(what.jid) : null;
|
|
1490
|
+
|
|
1491
|
+
switch (what.kind) {
|
|
1492
|
+
case 'pin': {
|
|
1493
|
+
const pinned = !removed && !!(a.pinAction && a.pinAction.pinned);
|
|
1494
|
+
this._getChatState(jid).pinnedAt = pinned ? (a.timestamp ? Math.floor(a.timestamp / 1000) : Math.floor(Date.now() / 1000)) : 0;
|
|
1495
|
+
this.emit('chat_pinned', { jid, pinned, remote: true });
|
|
1496
|
+
break;
|
|
1497
|
+
}
|
|
1498
|
+
case 'archive': {
|
|
1499
|
+
const archived = !removed && !!(a.archiveChatAction && a.archiveChatAction.archived);
|
|
1500
|
+
this._getChatState(jid).archived = archived;
|
|
1501
|
+
this.emit('chat_archived', { jid, archived, remote: true });
|
|
1502
|
+
break;
|
|
1503
|
+
}
|
|
1504
|
+
case 'mute': {
|
|
1505
|
+
const mute = a.muteAction || {};
|
|
1506
|
+
const muted = !removed && !!mute.muted;
|
|
1507
|
+
const chat = this._getChatState(jid);
|
|
1508
|
+
chat.muted = muted;
|
|
1509
|
+
chat.muteUntilMs = muted ? (Number(mute.muteEndTimestamp) || -1) : 0;
|
|
1510
|
+
this.emit('chat_muted', { jid, muted, until: chat.muteUntilMs, remote: true });
|
|
1511
|
+
break;
|
|
1512
|
+
}
|
|
1513
|
+
case 'markRead': {
|
|
1514
|
+
const read = !removed && !!(a.markChatAsReadAction && a.markChatAsReadAction.read);
|
|
1515
|
+
this._getChatState(jid).markedAsUnread = !read;
|
|
1516
|
+
this.emit('chat_read', { jid, read, remote: true });
|
|
1517
|
+
break;
|
|
1518
|
+
}
|
|
1519
|
+
case 'star': {
|
|
1520
|
+
const starred = !removed && !!(a.starAction && a.starAction.starred);
|
|
1521
|
+
if (!this._starredMessages) this._starredMessages = new Set();
|
|
1522
|
+
const key = jid + ':' + what.msgId;
|
|
1523
|
+
if (starred) this._starredMessages.add(key); else this._starredMessages.delete(key);
|
|
1524
|
+
this.emit('message_starred', {
|
|
1525
|
+
msgId: what.msgId, chatJid: jid, starred, fromMe: what.fromMe, remote: true
|
|
1526
|
+
});
|
|
1527
|
+
break;
|
|
1528
|
+
}
|
|
1529
|
+
case 'contact': {
|
|
1530
|
+
const c = a.contactAction || {};
|
|
1531
|
+
this.emit('contact_update', {
|
|
1532
|
+
jid,
|
|
1533
|
+
name: c.fullName || c.firstName || null,
|
|
1534
|
+
firstName: c.firstName || null,
|
|
1535
|
+
lid: c.lidJid || null,
|
|
1536
|
+
username: c.username || null,
|
|
1537
|
+
removed,
|
|
1538
|
+
remote: true
|
|
1539
|
+
});
|
|
1540
|
+
break;
|
|
1541
|
+
}
|
|
1542
|
+
case 'pushName': {
|
|
1543
|
+
const name = (a.pushNameSetting && a.pushNameSetting.name) || null;
|
|
1544
|
+
if (name && this._store) this._store.pushName = name;
|
|
1545
|
+
this.emit('push_name_update', { name, remote: true });
|
|
1546
|
+
break;
|
|
1547
|
+
}
|
|
1548
|
+
case 'clear':
|
|
1549
|
+
case 'delete':
|
|
1550
|
+
this.emit('chat_removed', { jid, kind: what.kind, remote: true });
|
|
1551
|
+
break;
|
|
1552
|
+
default:
|
|
1553
|
+
// Something the server tracks that this library does not model yet.
|
|
1554
|
+
// Reported rather than dropped, so it is visible that it happened.
|
|
1555
|
+
this.emit('app_state_mutation', {
|
|
1556
|
+
collection, index: m.index, action: a, removed
|
|
1557
|
+
});
|
|
1558
|
+
break;
|
|
1559
|
+
}
|
|
1560
|
+
count++;
|
|
1561
|
+
}
|
|
1562
|
+
return count;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/**
|
|
1566
|
+
* Whether this session can write app state at all.
|
|
1567
|
+
*
|
|
1568
|
+
* App-state keys travel one way: the primary device shares them with the
|
|
1569
|
+
* companions it has linked. A companion always gets one. A session that
|
|
1570
|
+
* registered over SMS *is* the primary, so unless it has linked a companion
|
|
1571
|
+
* of its own there is no key in existence and nothing to sign a patch with.
|
|
1572
|
+
*/
|
|
1573
|
+
canSyncAppState() {
|
|
1574
|
+
return !!this._newestAppStateKey(this._appStateKeys());
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/**
|
|
1578
|
+
* Write a change into app state when that is possible, and fall back to
|
|
1579
|
+
* whatever the connection can do otherwise.
|
|
1580
|
+
*
|
|
1581
|
+
* Without this, every chat setting would throw on an SMS session — which is
|
|
1582
|
+
* exactly the connection least able to do anything about it, since the key it
|
|
1583
|
+
* is being asked for is one only it could ever create.
|
|
1584
|
+
*
|
|
1585
|
+
* @returns {Promise<boolean>} whether the change was written to app state,
|
|
1586
|
+
* and so will reach other devices
|
|
1587
|
+
*/
|
|
1588
|
+
async _appStatePatchOrFallback(desc, fallback) {
|
|
1589
|
+
if (this.canSyncAppState()) {
|
|
1590
|
+
await this._sendAppStatePatch(desc);
|
|
1591
|
+
return true;
|
|
1592
|
+
}
|
|
1593
|
+
if (typeof fallback === 'function') await fallback();
|
|
1594
|
+
return false;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
/**
|
|
1598
|
+
* Write one change into app state, so it reaches the phone and every other
|
|
1599
|
+
* linked device.
|
|
1600
|
+
*
|
|
1601
|
+
* The local view is updated from the same description that goes on the wire,
|
|
1602
|
+
* so what a caller sees immediately is what the other devices will see.
|
|
1603
|
+
*/
|
|
1604
|
+
async _sendAppStatePatch(desc) {
|
|
1605
|
+
if (!this._socket || !this._connected) throw new Error('Not connected');
|
|
1606
|
+
if (!this._appState) throw new Error('No session — call init() first');
|
|
1607
|
+
|
|
1608
|
+
const chosen = this._newestAppStateKey(this._appStateKeys());
|
|
1609
|
+
if (!chosen) {
|
|
1610
|
+
throw new Error('cannot write app state yet: the primary device has not ' +
|
|
1611
|
+
'shared a sync key with this session');
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
const state = this._appState.get(desc.name);
|
|
1615
|
+
const { patch, state: next } = AppStateSync.buildPatch(
|
|
1616
|
+
desc, chosen.id, chosen.keyData, state);
|
|
1617
|
+
|
|
1618
|
+
const resp = await this._sendIq(new BinaryNode('iq', {
|
|
1619
|
+
id: this._genMsgId(),
|
|
1620
|
+
to: 's.whatsapp.net',
|
|
1621
|
+
type: 'set',
|
|
1622
|
+
xmlns: 'w:sync:app:state'
|
|
1623
|
+
}, [
|
|
1624
|
+
new BinaryNode('sync', {}, [
|
|
1625
|
+
new BinaryNode('collection', {
|
|
1626
|
+
name: desc.name,
|
|
1627
|
+
version: String(next.version - 1),
|
|
1628
|
+
return_snapshot: 'false'
|
|
1629
|
+
}, [new BinaryNode('patch', {}, patch)])
|
|
1630
|
+
])
|
|
1631
|
+
]));
|
|
1632
|
+
|
|
1633
|
+
if (!resp) throw new Error('app state patch: no reply from server (IQ timed out)');
|
|
1634
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
1635
|
+
const errNode = findChild(resp, 'error');
|
|
1636
|
+
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
1637
|
+
// The server did not take it, so our state must not move on either.
|
|
1638
|
+
throw new Error('app state patch rejected by server' + (code ? ' (' + code + ')' : ''));
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
this._appState.set(desc.name, next);
|
|
1642
|
+
return next.version;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// The most recently shared key, which is the one to sign new patches with.
|
|
1646
|
+
//
|
|
1647
|
+
// Both spellings of every id are in the map, so the hex ones are passed over
|
|
1648
|
+
// — a patch names its key in base64.
|
|
1649
|
+
_newestAppStateKey(keys) {
|
|
1650
|
+
let best = null;
|
|
1651
|
+
for (const [id, v] of Object.entries(keys || {})) {
|
|
1652
|
+
if (!v || !v.keyData) continue;
|
|
1653
|
+
if (/^[0-9a-f]+$/i.test(id)) continue;
|
|
1654
|
+
if (!best || (v.timestamp || 0) > (best.timestamp || 0)) {
|
|
1655
|
+
best = { id, keyData: v.keyData, timestamp: v.timestamp || 0 };
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
return best;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1283
1661
|
_onClose() {
|
|
1284
1662
|
this._connected = false;
|
|
1285
1663
|
this._stopTimers();
|
|
@@ -1606,13 +1984,13 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1606
1984
|
if (decoded.type === 'protocol') {
|
|
1607
1985
|
_whaDbg('[DBG] PROTO subtype=' + decoded.subtype + ' id=' + id);
|
|
1608
1986
|
|
|
1609
|
-
//
|
|
1610
|
-
|
|
1987
|
+
// Keyed off what the message actually carried rather than off the
|
|
1988
|
+
// type that announced it. A payload that is present is the payload,
|
|
1989
|
+
// whatever the type number turns out to mean.
|
|
1990
|
+
if (decoded.historySyncNotification) {
|
|
1611
1991
|
this._handleHistorySync(decoded.historySyncNotification, id);
|
|
1612
1992
|
}
|
|
1613
|
-
|
|
1614
|
-
// ── APP_STATE_SYNC_KEY_SHARE ──────────────────────────────────────
|
|
1615
|
-
if (decoded.subtype === 'app_state_sync_key_share' && decoded.appStateSyncKeyShare) {
|
|
1993
|
+
if (decoded.appStateSyncKeyShare) {
|
|
1616
1994
|
this._handleAppStateSyncKeyShare(decoded.appStateSyncKeyShare);
|
|
1617
1995
|
}
|
|
1618
1996
|
|
|
@@ -1647,10 +2025,10 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1647
2025
|
const decoded = this._decodeMsg(plaintext);
|
|
1648
2026
|
if (decoded.type === 'senderKeyDistribution') return;
|
|
1649
2027
|
if (decoded.type === 'protocol') {
|
|
1650
|
-
if (decoded.
|
|
2028
|
+
if (decoded.historySyncNotification) {
|
|
1651
2029
|
this._handleHistorySync(decoded.historySyncNotification, id);
|
|
1652
2030
|
}
|
|
1653
|
-
if (decoded.
|
|
2031
|
+
if (decoded.appStateSyncKeyShare) {
|
|
1654
2032
|
this._handleAppStateSyncKeyShare(decoded.appStateSyncKeyShare);
|
|
1655
2033
|
}
|
|
1656
2034
|
return;
|
|
@@ -2648,7 +3026,20 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2648
3026
|
if (this._signal && this._signal.store && this._signal.store.setLidMapping)
|
|
2649
3027
|
this._signal.store.setLidMapping(pnUser, lidUser);
|
|
2650
3028
|
}
|
|
2651
|
-
return {
|
|
3029
|
+
return {
|
|
3030
|
+
jid: pJid,
|
|
3031
|
+
role: pRole,
|
|
3032
|
+
// Both address families, whichever way round the server named them,
|
|
3033
|
+
// so a caller does not have to reach into the LID maps to find out
|
|
3034
|
+
// who a participant is.
|
|
3035
|
+
phoneNumber: pJid.endsWith('@lid') ? pPhone : pJid,
|
|
3036
|
+
lid: pJid.endsWith('@lid') ? pJid : pLid,
|
|
3037
|
+
displayName: n.attrs.display_name ? String(n.attrs.display_name) : null,
|
|
3038
|
+
username: n.attrs.participant_username ? String(n.attrs.participant_username)
|
|
3039
|
+
: n.attrs.username ? String(n.attrs.username) : null,
|
|
3040
|
+
isAdmin: pRole === 'admin' || pRole === 'superadmin',
|
|
3041
|
+
isSuperAdmin: pRole === 'superadmin'
|
|
3042
|
+
};
|
|
2652
3043
|
});
|
|
2653
3044
|
|
|
2654
3045
|
// description — the id is the "topic id" the server wants echoed back as
|
|
@@ -2668,6 +3059,13 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2668
3059
|
if (buf) description = buf.toString('utf8');
|
|
2669
3060
|
}
|
|
2670
3061
|
|
|
3062
|
+
const descriptionBy = (descNode && descNode.attrs && descNode.attrs.participant)
|
|
3063
|
+
? toNonAdJid(String(descNode.attrs.participant)) : null;
|
|
3064
|
+
const descriptionByPn = (descNode && descNode.attrs && descNode.attrs.participant_pn)
|
|
3065
|
+
? toNonAdJid(String(descNode.attrs.participant_pn)) : null;
|
|
3066
|
+
const descriptionTime = (descNode && descNode.attrs)
|
|
3067
|
+
? parseInt(descNode.attrs.t, 10) || 0 : 0;
|
|
3068
|
+
|
|
2671
3069
|
// ephemeral
|
|
2672
3070
|
const ephNode = children.find(n => n && n.description === 'ephemeral');
|
|
2673
3071
|
const ephemeral = ephNode && ephNode.attrs ? parseInt(ephNode.attrs.expiration, 10) || 0 : 0;
|
|
@@ -2676,6 +3074,22 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2676
3074
|
const announce = children.some(n => n && n.description === 'announcement');
|
|
2677
3075
|
const restrict = children.some(n => n && n.description === 'locked');
|
|
2678
3076
|
|
|
3077
|
+
// The rest of the flags the server reports. These used to be dropped, which
|
|
3078
|
+
// meant a group's join-approval and member-add settings could be written but
|
|
3079
|
+
// never read back — changing one and then asking for the metadata reported
|
|
3080
|
+
// nothing at all about it.
|
|
3081
|
+
const joinApprovalMode = children.some(n => n && n.description === 'membership_approval_mode');
|
|
3082
|
+
const parentNode = children.find(n => n && n.description === 'parent');
|
|
3083
|
+
const linkedParentNode = children.find(n => n && n.description === 'linked_parent');
|
|
3084
|
+
const memberAddNode = children.find(n => n && n.description === 'member_add_mode');
|
|
3085
|
+
const memberAddBuf = memberAddNode ? getNodeContent(memberAddNode) : null;
|
|
3086
|
+
const memberAddMode = memberAddBuf ? memberAddBuf.toString('utf8') : null;
|
|
3087
|
+
// An incognito group hides members' phone numbers from each other, and a
|
|
3088
|
+
// suspended one has been taken down — both are things a caller has to be
|
|
3089
|
+
// able to see rather than discover by having every send refused.
|
|
3090
|
+
const isIncognito = children.some(n => n && n.description === 'incognito');
|
|
3091
|
+
const isSuspended = children.some(n => n && n.description === 'suspended');
|
|
3092
|
+
|
|
2679
3093
|
const groupId = a.id || '';
|
|
2680
3094
|
const jid = groupId.includes('@') ? groupId : groupId + '@g.us';
|
|
2681
3095
|
|
|
@@ -2695,9 +3109,41 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2695
3109
|
subjectBy: a.s_o || null,
|
|
2696
3110
|
description,
|
|
2697
3111
|
descriptionId,
|
|
3112
|
+
descriptionBy,
|
|
3113
|
+
descriptionByPn,
|
|
3114
|
+
descriptionTime,
|
|
2698
3115
|
ephemeral,
|
|
2699
3116
|
onlyAdminsSend: announce,
|
|
2700
3117
|
onlyAdminsEdit: restrict,
|
|
3118
|
+
// Whether new members have to be waved through by an admin.
|
|
3119
|
+
joinApprovalMode,
|
|
3120
|
+
// 'admin_add' | 'all_member_add' | null when the server did not say.
|
|
3121
|
+
memberAddMode,
|
|
3122
|
+
// A community's own node, and the community a plain group belongs to.
|
|
3123
|
+
isCommunity: !!parentNode,
|
|
3124
|
+
// How a community waves its incoming sub-group members through by default.
|
|
3125
|
+
defaultMembershipApprovalMode:
|
|
3126
|
+
(parentNode && parentNode.attrs && parentNode.attrs.default_membership_approval_mode)
|
|
3127
|
+
? String(parentNode.attrs.default_membership_approval_mode) : null,
|
|
3128
|
+
isCommunityAnnounce: children.some(n => n && n.description === 'default_sub_group'),
|
|
3129
|
+
linkedParent: (linkedParentNode && linkedParentNode.attrs && linkedParentNode.attrs.jid)
|
|
3130
|
+
? String(linkedParentNode.attrs.jid) : null,
|
|
3131
|
+
isIncognito,
|
|
3132
|
+
isSuspended,
|
|
3133
|
+
// The phone-number and username forms of the two people named in the
|
|
3134
|
+
// header, when the server sends them alongside the addresses it used.
|
|
3135
|
+
creatorPn: a.creator_pn ? toNonAdJid(String(a.creator_pn)) : null,
|
|
3136
|
+
creatorUsername: a.creator_username || null,
|
|
3137
|
+
creatorCountry: a.creator_country_code || null,
|
|
3138
|
+
subjectByPn: a.s_o_pn ? toNonAdJid(String(a.s_o_pn)) : null,
|
|
3139
|
+
subjectByUsername: a.s_o_username || null,
|
|
3140
|
+
notify: a.notify || null,
|
|
3141
|
+
// Version stamps the server bumps whenever the participant list or the
|
|
3142
|
+
// announce flag changes; a cache can compare them instead of re-reading.
|
|
3143
|
+
participantVersion: a.p_v_id || null,
|
|
3144
|
+
announceVersion: a.a_v_id || null,
|
|
3145
|
+
// The server's own count, which can exceed the participant list it sent.
|
|
3146
|
+
size: a.size ? parseInt(a.size, 10) || participants.length : participants.length,
|
|
2701
3147
|
addressingMode,
|
|
2702
3148
|
participants
|
|
2703
3149
|
};
|
|
@@ -3254,6 +3700,15 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3254
3700
|
}
|
|
3255
3701
|
|
|
3256
3702
|
this.emit('app_state_keys', { keys: keyShare.keys });
|
|
3703
|
+
|
|
3704
|
+
// The key is what app state was waiting on. Anything the server has been
|
|
3705
|
+
// holding for us can be read now, so go and get it rather than sitting on
|
|
3706
|
+
// it until the next dirty bit happens along.
|
|
3707
|
+
if (this._connected && this._appState) {
|
|
3708
|
+
this.syncAppState().catch(err => {
|
|
3709
|
+
_whaDbg('[DBG] APP_STATE sync after key share failed: ' + (err && err.message));
|
|
3710
|
+
});
|
|
3711
|
+
}
|
|
3257
3712
|
}
|
|
3258
3713
|
|
|
3259
3714
|
/**
|
|
@@ -3685,6 +4140,49 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3685
4140
|
return httpGetBuffer(info.url, { web: this._mode === 'web' });
|
|
3686
4141
|
}
|
|
3687
4142
|
|
|
4143
|
+
// Download and decrypt the media on a received message.
|
|
4144
|
+
//
|
|
4145
|
+
// Pass the decoded message straight through — `msg.decoded` from a `message`
|
|
4146
|
+
// event, or the object itself:
|
|
4147
|
+
//
|
|
4148
|
+
// client.on('message', async (msg) => {
|
|
4149
|
+
// if (msg.decoded && msg.decoded.mediaKey) {
|
|
4150
|
+
// fs.writeFileSync('out.jpg', await client.downloadMedia(msg.decoded))
|
|
4151
|
+
// }
|
|
4152
|
+
// })
|
|
4153
|
+
//
|
|
4154
|
+
// The CDN applies the same browser check on the way down as on the way up, so
|
|
4155
|
+
// a companion has to identify itself as one here too. Doing it by hand means
|
|
4156
|
+
// knowing that, and knowing which key name each media type derives from — get
|
|
4157
|
+
// either wrong and the download is refused or the decryption produces garbage.
|
|
4158
|
+
//
|
|
4159
|
+
// A file already saved from a previous run can be verified against the
|
|
4160
|
+
// message with { verify: true }, which checks fileEncSha256 before decrypting.
|
|
4161
|
+
async downloadMedia(decoded, opts) {
|
|
4162
|
+
opts = opts || {};
|
|
4163
|
+
const d = (decoded && decoded.decoded) ? decoded.decoded : decoded;
|
|
4164
|
+
if (!d || !d.type) throw new Error('downloadMedia: a decoded message is required');
|
|
4165
|
+
if (!d.mediaKey) throw new Error('downloadMedia: this message carries no media');
|
|
4166
|
+
|
|
4167
|
+
|
|
4168
|
+
// A voice note is an audio message with PTT set, and a GIF a video with the
|
|
4169
|
+
// playback flag — both derive from the plain type's keys.
|
|
4170
|
+
const KEY_NAME = {
|
|
4171
|
+
image: 'image', video: 'video', audio: 'audio', voice: 'ptt',
|
|
4172
|
+
ptt: 'ptt', document: 'document', sticker: 'sticker', gif: 'gif'
|
|
4173
|
+
};
|
|
4174
|
+
const { downloadMedia, getMediaKeyName, resolveMediaUrl } = require('./MediaService');
|
|
4175
|
+
const url = resolveMediaUrl(d.url, d.directPath);
|
|
4176
|
+
if (!url) throw new Error('downloadMedia: the message has no CDN location');
|
|
4177
|
+
const keyName = getMediaKeyName(KEY_NAME[d.type] || d.type);
|
|
4178
|
+
if (!keyName) throw new Error('downloadMedia: unsupported media type "' + d.type + '"');
|
|
4179
|
+
|
|
4180
|
+
return downloadMedia(url, d.mediaKey, keyName, {
|
|
4181
|
+
web: this._mode === 'web',
|
|
4182
|
+
fileEncSha256: opts.verify ? d.fileEncSha256 : undefined
|
|
4183
|
+
});
|
|
4184
|
+
}
|
|
4185
|
+
|
|
3688
4186
|
// Parse the <list> a blocklist query or update comes back with.
|
|
3689
4187
|
//
|
|
3690
4188
|
// whatsmeow's parseBlocklist walks every child and takes its jid, without
|
|
@@ -4394,69 +4892,68 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4394
4892
|
// blockContact, unblockContact, changeEphemeralTimer
|
|
4395
4893
|
// ──────────────────────────────────────────────────────────────────────────
|
|
4396
4894
|
|
|
4397
|
-
//
|
|
4895
|
+
// Clears the unread badge on a chat. This is the chat's own read flag — to
|
|
4896
|
+
// send read receipts for particular messages, use markRead().
|
|
4398
4897
|
async markChatRead(jid) {
|
|
4399
4898
|
const jidFull = makeJid(jid);
|
|
4400
|
-
const
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
chat.markedAsUnread = true;
|
|
4419
|
-
this.emit('chat_read', { jid: makeJid(jid), read: false });
|
|
4899
|
+
const synced = await this._appStatePatchOrFallback(
|
|
4900
|
+
AppMutations.markReadPatch(jidFull, true),
|
|
4901
|
+
() => this._sendIq(new BinaryNode('iq', {
|
|
4902
|
+
id: this._genMsgId(), to: 's.whatsapp.net', type: 'set', xmlns: 'w'
|
|
4903
|
+
}, [new BinaryNode('read',
|
|
4904
|
+
{ jid: jidFull, count: '-1', index: '0', owner: 'false' }, null)])));
|
|
4905
|
+
this._getChatState(jidFull).markedAsUnread = false;
|
|
4906
|
+
this.emit('chat_read', { jid: jidFull, read: true, synced });
|
|
4907
|
+
return synced;
|
|
4908
|
+
}
|
|
4909
|
+
|
|
4910
|
+
async markChatUnread(jid) {
|
|
4911
|
+
const jidFull = makeJid(jid);
|
|
4912
|
+
const synced = await this._appStatePatchOrFallback(
|
|
4913
|
+
AppMutations.markReadPatch(jidFull, false));
|
|
4914
|
+
this._getChatState(jidFull).markedAsUnread = true;
|
|
4915
|
+
this.emit('chat_read', { jid: jidFull, read: false, synced });
|
|
4916
|
+
return synced;
|
|
4420
4917
|
}
|
|
4421
4918
|
|
|
4422
|
-
//
|
|
4423
|
-
//
|
|
4919
|
+
// durationMs is how long from now to stay muted; 0 or omitted means until
|
|
4920
|
+
// it is unmuted by hand. The wire carries an absolute end in milliseconds.
|
|
4424
4921
|
async muteChat(jid, durationMs) {
|
|
4425
|
-
const jidFull
|
|
4426
|
-
const
|
|
4427
|
-
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
const
|
|
4431
|
-
if (muteEnd > 0) attrs.t = String(muteEnd);
|
|
4432
|
-
const node = new BinaryNode('iq', {
|
|
4433
|
-
id,
|
|
4434
|
-
to: 's.whatsapp.net',
|
|
4435
|
-
type: 'set',
|
|
4436
|
-
xmlns: 'w:web'
|
|
4437
|
-
}, [new BinaryNode('mute', attrs, null)]);
|
|
4438
|
-
const chat = this._getChatState(jid);
|
|
4922
|
+
const jidFull = makeJid(jid);
|
|
4923
|
+
const endMs = (durationMs && durationMs > 0) ? Date.now() + durationMs : 0;
|
|
4924
|
+
const synced = await this._appStatePatchOrFallback(
|
|
4925
|
+
AppMutations.mutePatch(jidFull, endMs || 1),
|
|
4926
|
+
() => this._muteIq(jidFull, endMs ? Math.floor(endMs / 1000) : 0));
|
|
4927
|
+
const chat = this._getChatState(jidFull);
|
|
4439
4928
|
chat.muted = true;
|
|
4440
|
-
chat.muteUntilMs =
|
|
4441
|
-
this.emit('chat_muted', { jid: jidFull, muted: true, until: chat.muteUntilMs });
|
|
4442
|
-
return
|
|
4929
|
+
chat.muteUntilMs = endMs || -1;
|
|
4930
|
+
this.emit('chat_muted', { jid: jidFull, muted: true, until: chat.muteUntilMs, synced });
|
|
4931
|
+
return synced;
|
|
4443
4932
|
}
|
|
4444
4933
|
|
|
4445
|
-
// Unmute a chat — sends IQ to server (xmlns: 'w:web', add: '0')
|
|
4446
4934
|
async unmuteChat(jid) {
|
|
4447
4935
|
const jidFull = makeJid(jid);
|
|
4448
|
-
const
|
|
4449
|
-
|
|
4450
|
-
|
|
4936
|
+
const synced = await this._appStatePatchOrFallback(
|
|
4937
|
+
AppMutations.mutePatch(jidFull, 0),
|
|
4938
|
+
() => this._muteIq(jidFull, null));
|
|
4939
|
+
const chat = this._getChatState(jidFull);
|
|
4940
|
+
chat.muted = false;
|
|
4941
|
+
chat.muteUntilMs = 0;
|
|
4942
|
+
this.emit('chat_muted', { jid: jidFull, muted: false, synced });
|
|
4943
|
+
return synced;
|
|
4944
|
+
}
|
|
4945
|
+
|
|
4946
|
+
// The mute a primary sends for itself. A companion never uses this — its
|
|
4947
|
+
// mutes travel as app state so the phone learns about them.
|
|
4948
|
+
_muteIq(jidFull, untilSeconds) {
|
|
4949
|
+
const attrs = { jid: jidFull, add: untilSeconds === null ? '0' : '1' };
|
|
4950
|
+
if (untilSeconds) attrs.t = String(untilSeconds);
|
|
4951
|
+
return this._sendIq(new BinaryNode('iq', {
|
|
4952
|
+
id: this._genMsgId(),
|
|
4451
4953
|
to: 's.whatsapp.net',
|
|
4452
4954
|
type: 'set',
|
|
4453
4955
|
xmlns: 'w:web'
|
|
4454
|
-
}, [new BinaryNode('mute',
|
|
4455
|
-
const chat = this._getChatState(jid);
|
|
4456
|
-
chat.muted = false;
|
|
4457
|
-
chat.muteUntilMs = 0;
|
|
4458
|
-
this.emit('chat_muted', { jid: jidFull, muted: false });
|
|
4459
|
-
return this._sendIq(node);
|
|
4956
|
+
}, [new BinaryNode('mute', attrs, null)]));
|
|
4460
4957
|
}
|
|
4461
4958
|
|
|
4462
4959
|
// Block a contact. Returns the updated block list.
|
|
@@ -4551,45 +5048,68 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4551
5048
|
return this._parseBlocklist(findChild(resp, 'list')).jids;
|
|
4552
5049
|
}
|
|
4553
5050
|
|
|
4554
|
-
//
|
|
4555
|
-
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
|
|
5051
|
+
// ─── Chat state ───────────────────────────────────────────────────────────
|
|
5052
|
+
//
|
|
5053
|
+
// Pinning, archiving, muting, marking read and starring are not local
|
|
5054
|
+
// preferences — they are app state, which is how a change reaches the phone
|
|
5055
|
+
// and every other linked device. Each of these writes a patch and waits for
|
|
5056
|
+
// the server to take it; the local view only moves once it has.
|
|
5057
|
+
//
|
|
5058
|
+
// These used to set a field on an in-memory object and emit an event, so a
|
|
5059
|
+
// pin lasted until the process exited and never appeared anywhere else.
|
|
5060
|
+
|
|
5061
|
+
async pinChat(jid) {
|
|
5062
|
+
const jidFull = makeJid(jid);
|
|
5063
|
+
const synced = await this._appStatePatchOrFallback(AppMutations.pinPatch(jidFull, true));
|
|
5064
|
+
this._getChatState(jidFull).pinnedAt = Math.floor(Date.now() / 1000);
|
|
5065
|
+
this.emit('chat_pinned', { jid: jidFull, pinned: true, synced });
|
|
5066
|
+
return synced;
|
|
5067
|
+
}
|
|
5068
|
+
|
|
5069
|
+
async unpinChat(jid) {
|
|
5070
|
+
const jidFull = makeJid(jid);
|
|
5071
|
+
const synced = await this._appStatePatchOrFallback(AppMutations.pinPatch(jidFull, false));
|
|
5072
|
+
this._getChatState(jidFull).pinnedAt = 0;
|
|
5073
|
+
this.emit('chat_pinned', { jid: jidFull, pinned: false, synced });
|
|
5074
|
+
return synced;
|
|
4559
5075
|
}
|
|
4560
5076
|
|
|
4561
|
-
|
|
4562
|
-
const
|
|
4563
|
-
|
|
4564
|
-
this.
|
|
5077
|
+
async archiveChat(jid) {
|
|
5078
|
+
const jidFull = makeJid(jid);
|
|
5079
|
+
const synced = await this._appStatePatchOrFallback(AppMutations.archivePatch(jidFull, true));
|
|
5080
|
+
this._getChatState(jidFull).archived = true;
|
|
5081
|
+
this.emit('chat_archived', { jid: jidFull, archived: true, synced });
|
|
5082
|
+
return synced;
|
|
4565
5083
|
}
|
|
4566
5084
|
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
const
|
|
4570
|
-
|
|
4571
|
-
this.emit('chat_archived', { jid, archived:
|
|
5085
|
+
async unarchiveChat(jid) {
|
|
5086
|
+
const jidFull = makeJid(jid);
|
|
5087
|
+
const synced = await this._appStatePatchOrFallback(AppMutations.archivePatch(jidFull, false));
|
|
5088
|
+
this._getChatState(jidFull).archived = false;
|
|
5089
|
+
this.emit('chat_archived', { jid: jidFull, archived: false, synced });
|
|
5090
|
+
return synced;
|
|
4572
5091
|
}
|
|
4573
5092
|
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
this.
|
|
5093
|
+
// fromMe says whether the message being starred is one of ours. It is part of
|
|
5094
|
+
// the index, so getting it wrong stars a different message — or nothing.
|
|
5095
|
+
async starMessage(msgId, chatJid, fromMe) {
|
|
5096
|
+
return this._setStar(msgId, chatJid, fromMe, true);
|
|
4578
5097
|
}
|
|
4579
5098
|
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
const key = `${chatJid}:${msgId}`;
|
|
4583
|
-
if (!this._starredMessages) this._starredMessages = new Set();
|
|
4584
|
-
this._starredMessages.add(key);
|
|
4585
|
-
this.emit('message_starred', { msgId, chatJid, starred: true });
|
|
5099
|
+
async unstarMessage(msgId, chatJid, fromMe) {
|
|
5100
|
+
return this._setStar(msgId, chatJid, fromMe, false);
|
|
4586
5101
|
}
|
|
4587
5102
|
|
|
4588
|
-
|
|
4589
|
-
const
|
|
5103
|
+
async _setStar(msgId, chatJid, fromMe, starred) {
|
|
5104
|
+
const jidFull = makeJid(chatJid);
|
|
5105
|
+
const synced = await this._appStatePatchOrFallback(
|
|
5106
|
+
AppMutations.starPatch(jidFull, msgId, !!fromMe, starred));
|
|
4590
5107
|
if (!this._starredMessages) this._starredMessages = new Set();
|
|
4591
|
-
|
|
4592
|
-
this.
|
|
5108
|
+
const key = jidFull + ':' + msgId;
|
|
5109
|
+
if (starred) this._starredMessages.add(key); else this._starredMessages.delete(key);
|
|
5110
|
+
this.emit('message_starred',
|
|
5111
|
+
{ msgId, chatJid: jidFull, starred, fromMe: !!fromMe, synced });
|
|
5112
|
+
return synced;
|
|
4593
5113
|
}
|
|
4594
5114
|
|
|
4595
5115
|
// Mark a voice/audio message as played (sends 'played' receipt)
|
|
@@ -4708,17 +5228,19 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4708
5228
|
// opts: { ephemeralSeconds, announce, locked, joinApprovalRequired,
|
|
4709
5229
|
// memberAddMode, parent, linkedParentJid }
|
|
4710
5230
|
//
|
|
4711
|
-
//
|
|
4712
|
-
//
|
|
4713
|
-
//
|
|
4714
|
-
//
|
|
4715
|
-
//
|
|
5231
|
+
// Every participant goes out as a binary JID (a raw string is dropped for an
|
|
5232
|
+
// @lid), a LID participant also carries the phone number the server matches it
|
|
5233
|
+
// against, and each carries our privacy token so the invitation is not treated
|
|
5234
|
+
// as an anonymous reach-out. The setting nodes are siblings of the
|
|
5235
|
+
// participants inside <create>.
|
|
4716
5236
|
async createGroup(subject, participants, opts) {
|
|
4717
5237
|
opts = opts || {};
|
|
4718
5238
|
const id = this._genMsgId();
|
|
4719
5239
|
// participant nodes MUST use attr `jid`, not `value`
|
|
4720
5240
|
const partNodes = (participants || []).map(p => {
|
|
4721
|
-
|
|
5241
|
+
// A participant is the person, never one of their devices — a JID that
|
|
5242
|
+
// still carries a device suffix is not one the server will match.
|
|
5243
|
+
const jid = toNonAdJid(makeJid(p));
|
|
4722
5244
|
const attrs = { jid: jidStrToObj(jid) };
|
|
4723
5245
|
if (jid.endsWith('@lid')) {
|
|
4724
5246
|
const pn = this._lidToPn.get(jid.split('@')[0].split(':')[0]);
|
|
@@ -4743,7 +5265,7 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4743
5265
|
if (opts.locked) settingNodes.push(new BinaryNode('locked', {}, null));
|
|
4744
5266
|
if (opts.announce) settingNodes.push(new BinaryNode('announcement', {}, null));
|
|
4745
5267
|
if (opts.ephemeralSeconds) {
|
|
4746
|
-
//
|
|
5268
|
+
// trigger="1" has to travel alongside the expiration; without it the
|
|
4747
5269
|
// server accepts the group but leaves disappearing messages off.
|
|
4748
5270
|
settingNodes.push(new BinaryNode('ephemeral', {
|
|
4749
5271
|
expiration: String(opts.ephemeralSeconds),
|
|
@@ -4760,7 +5282,11 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4760
5282
|
Buffer.from(String(opts.memberAddMode), 'utf8')));
|
|
4761
5283
|
}
|
|
4762
5284
|
|
|
4763
|
-
|
|
5285
|
+
// `key` is what the server echoes back on the group-create notification so
|
|
5286
|
+
// the client can tell its own creation apart from one it merely witnessed.
|
|
5287
|
+
// A per-second value collides when two groups are made in the same second;
|
|
5288
|
+
// a message id does not.
|
|
5289
|
+
const baseAttrs = { subject, key: this._genMsgId() };
|
|
4764
5290
|
const createChildren = [...partNodes, ...settingNodes];
|
|
4765
5291
|
const node = new BinaryNode('iq', {
|
|
4766
5292
|
id,
|
|
@@ -4807,7 +5333,12 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4807
5333
|
return this._sendIq(node);
|
|
4808
5334
|
}
|
|
4809
5335
|
|
|
4810
|
-
// Add participants to group
|
|
5336
|
+
// Add participants to group.
|
|
5337
|
+
//
|
|
5338
|
+
// Returns one result per participant, successes and failures alike — see
|
|
5339
|
+
// _groupParticipantAction. A participant refused with 403 usually carries an
|
|
5340
|
+
// `addRequest`, which sendGroupInvite() turns into an invitation they can act
|
|
5341
|
+
// on themselves.
|
|
4811
5342
|
async addGroupParticipants(groupJid, jids) {
|
|
4812
5343
|
return this._groupParticipantAction(groupJid, 'add', jids);
|
|
4813
5344
|
}
|
|
@@ -4832,10 +5363,10 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4832
5363
|
async _groupParticipantAction(groupJid, action, jids) {
|
|
4833
5364
|
const gJid = makeJid(groupJid);
|
|
4834
5365
|
const arr = Array.isArray(jids) ? jids : [jids];
|
|
4835
|
-
//
|
|
4836
|
-
//
|
|
5366
|
+
// Binary JIDs, and on an add, the phone number behind a LID plus our
|
|
5367
|
+
// privacy token for them.
|
|
4837
5368
|
const participantNodes = arr.map(j => {
|
|
4838
|
-
const jid = makeJid(j);
|
|
5369
|
+
const jid = toNonAdJid(makeJid(j));
|
|
4839
5370
|
const attrs = { jid: jidStrToObj(jid) };
|
|
4840
5371
|
let children = null;
|
|
4841
5372
|
if (action === 'add') {
|
|
@@ -4858,36 +5389,48 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4858
5389
|
new BinaryNode(action, {}, participantNodes)
|
|
4859
5390
|
]);
|
|
4860
5391
|
const resp = await this._sendIq(node);
|
|
4861
|
-
if (!resp)
|
|
5392
|
+
if (!resp) throw new Error(action + ': no reply from server (IQ timed out)');
|
|
5393
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
5394
|
+
const errNode = findChild(resp, 'error');
|
|
5395
|
+
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
5396
|
+
const text = errNode && errNode.attrs && errNode.attrs.text;
|
|
5397
|
+
throw new Error(action + ': server rejected the request' +
|
|
5398
|
+
(code ? ' (' + code + (text ? ' ' + text : '') + ')' : ''));
|
|
5399
|
+
}
|
|
4862
5400
|
const actionNode = findChild(resp, action);
|
|
4863
|
-
if (!actionNode
|
|
4864
|
-
|
|
4865
|
-
|
|
4866
|
-
|
|
4867
|
-
|
|
5401
|
+
if (!actionNode) {
|
|
5402
|
+
throw new Error(action + ': server reply contained no <' + action + '> node');
|
|
5403
|
+
}
|
|
5404
|
+
// The membership changed, so anything we have cached about the group is now
|
|
5405
|
+
// one edit behind.
|
|
5406
|
+
this.invalidateGroupMetadata(gJid);
|
|
5407
|
+
return parseParticipantNodes(actionNode);
|
|
4868
5408
|
}
|
|
4869
5409
|
|
|
4870
5410
|
// Change group name/subject
|
|
4871
5411
|
async changeGroupSubject(groupJid, subject) {
|
|
4872
|
-
const
|
|
5412
|
+
const gJid = makeJid(groupJid);
|
|
5413
|
+
const id = this._genMsgId();
|
|
4873
5414
|
const node = new BinaryNode('iq', {
|
|
4874
5415
|
id,
|
|
4875
5416
|
xmlns: 'w:g2',
|
|
4876
|
-
to:
|
|
5417
|
+
to: jidStrToObj(gJid),
|
|
4877
5418
|
type: 'set'
|
|
4878
5419
|
}, [
|
|
4879
5420
|
new BinaryNode('subject', {}, Buffer.from(subject, 'utf8'))
|
|
4880
5421
|
]);
|
|
4881
|
-
|
|
5422
|
+
const resp = await this._sendIq(node);
|
|
5423
|
+
this.invalidateGroupMetadata(gJid);
|
|
5424
|
+
return resp;
|
|
4882
5425
|
}
|
|
4883
5426
|
|
|
4884
5427
|
// Change group description
|
|
4885
5428
|
// description: string or null/'' to remove
|
|
4886
5429
|
//
|
|
4887
|
-
//
|
|
4888
|
-
//
|
|
4889
|
-
//
|
|
4890
|
-
//
|
|
5430
|
+
// prev=<current topic id> has to go out with the edit so the server can reject
|
|
5431
|
+
// one made against a description someone else has already replaced. Without it
|
|
5432
|
+
// the change is refused. The current id is looked up from the group metadata
|
|
5433
|
+
// when the caller does not supply one.
|
|
4891
5434
|
async changeGroupDescription(groupJid, description, opts) {
|
|
4892
5435
|
opts = opts || {};
|
|
4893
5436
|
const gJid = makeJid(groupJid);
|
|
@@ -4918,13 +5461,14 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4918
5461
|
}, [
|
|
4919
5462
|
new BinaryNode('description', descAttrs, children)
|
|
4920
5463
|
]);
|
|
4921
|
-
|
|
5464
|
+
const resp = await this._sendIq(node);
|
|
5465
|
+
this.invalidateGroupMetadata(gJid);
|
|
5466
|
+
return resp;
|
|
4922
5467
|
}
|
|
4923
5468
|
|
|
4924
5469
|
// ─── Group settings ───────────────────────────────────────────────────────
|
|
4925
5470
|
//
|
|
4926
|
-
// Both are a single empty node whose TAG carries the state
|
|
4927
|
-
// whatsmeow's SetGroupAnnounce / SetGroupLocked send them — there is no
|
|
5471
|
+
// Both are a single empty node whose TAG carries the state — there is no
|
|
4928
5472
|
// attribute form of these.
|
|
4929
5473
|
|
|
4930
5474
|
// announce=true → only admins can send messages
|
|
@@ -4976,27 +5520,42 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4976
5520
|
return resp;
|
|
4977
5521
|
}
|
|
4978
5522
|
|
|
4979
|
-
// Change group picture
|
|
4980
|
-
//
|
|
5523
|
+
// Change group picture.
|
|
5524
|
+
//
|
|
5525
|
+
// buf: Buffer with JPEG image data, or null to remove the current picture.
|
|
5526
|
+
// Returns the new picture id, or 'remove' when the picture was taken down.
|
|
5527
|
+
//
|
|
5528
|
+
// Removal is the IQ with no body at all — an empty <picture> node is a
|
|
5529
|
+
// malformed upload rather than a delete, and the server answers 406 for it.
|
|
4981
5530
|
async changeGroupPicture(groupJid, buf) {
|
|
4982
5531
|
const id = this._genMsgId();
|
|
4983
5532
|
const node = new BinaryNode('iq', {
|
|
4984
5533
|
id,
|
|
4985
5534
|
xmlns: 'w:profile:picture',
|
|
4986
|
-
target: makeJid(groupJid),
|
|
5535
|
+
target: jidStrToObj(makeJid(groupJid)),
|
|
4987
5536
|
to: 's.whatsapp.net',
|
|
4988
5537
|
type: 'set'
|
|
4989
|
-
}, [
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
5538
|
+
}, buf ? [new BinaryNode('picture', { type: 'image' }, buf)] : null);
|
|
5539
|
+
const resp = await this._sendIq(node);
|
|
5540
|
+
if (!resp) throw new Error('changeGroupPicture: no reply from server (IQ timed out)');
|
|
5541
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
5542
|
+
const errNode = findChild(resp, 'error');
|
|
5543
|
+
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
5544
|
+
throw new Error('changeGroupPicture: ' +
|
|
5545
|
+
(String(code) === '406' ? 'the image was rejected (must be a JPEG)'
|
|
5546
|
+
: String(code) === '403' ? 'you are not allowed to change this group picture'
|
|
5547
|
+
: 'server rejected the request' + (code ? ' (' + code + ')' : '')));
|
|
5548
|
+
}
|
|
5549
|
+
if (!buf) return 'remove';
|
|
5550
|
+
const picNode = findChild(resp, 'picture');
|
|
5551
|
+
return (picNode && picNode.attrs && picNode.attrs.id)
|
|
5552
|
+
? String(picNode.attrs.id) : null;
|
|
4993
5553
|
}
|
|
4994
5554
|
|
|
4995
5555
|
// Get the group's invite link.
|
|
4996
5556
|
//
|
|
4997
5557
|
// reset=true revokes the current link and returns a freshly minted one — the
|
|
4998
|
-
// same IQ with type="set" instead of "get"
|
|
4999
|
-
// GetGroupInviteLink handles its reset flag.
|
|
5558
|
+
// same IQ with type="set" instead of "get".
|
|
5000
5559
|
async queryGroupInviteLink(groupJid, reset) {
|
|
5001
5560
|
const code = await this._queryGroupInviteCode(groupJid, reset);
|
|
5002
5561
|
return code ? INVITE_LINK_PREFIX + code : null;
|
|
@@ -5025,8 +5584,8 @@ class WhalibmobClient extends EventEmitter {
|
|
|
5025
5584
|
if (resp.attrs && resp.attrs.type === 'error') {
|
|
5026
5585
|
const errNode = findChild(resp, 'error');
|
|
5027
5586
|
const codeAttr = errNode && errNode.attrs && errNode.attrs.code;
|
|
5028
|
-
//
|
|
5029
|
-
//
|
|
5587
|
+
// 410 is a revoked link, 406 an invalid one. Anything else is passed
|
|
5588
|
+
// through as the server worded it.
|
|
5030
5589
|
const reason = String(codeAttr) === '410' ? 'the invite link has been revoked'
|
|
5031
5590
|
: String(codeAttr) === '406' ? 'the invite link is not valid'
|
|
5032
5591
|
: 'server rejected the request' +
|
|
@@ -5134,13 +5693,16 @@ class WhalibmobClient extends EventEmitter {
|
|
|
5134
5693
|
return this._groupSettingIq(groupJid, body.description, body.content);
|
|
5135
5694
|
}
|
|
5136
5695
|
|
|
5137
|
-
// Query participants pending approval
|
|
5696
|
+
// Query participants pending approval.
|
|
5697
|
+
//
|
|
5698
|
+
// Each entry stringifies to the JID and also carries `requestedAt`, the unix
|
|
5699
|
+
// second the person asked to join, so a list can be shown oldest-first.
|
|
5138
5700
|
async queryGroupPendingParticipants(groupJid) {
|
|
5139
5701
|
const id = this._genMsgId();
|
|
5140
5702
|
const node = new BinaryNode('iq', {
|
|
5141
5703
|
id,
|
|
5142
5704
|
xmlns: 'w:g2',
|
|
5143
|
-
to: makeJid(groupJid),
|
|
5705
|
+
to: jidStrToObj(makeJid(groupJid)),
|
|
5144
5706
|
type: 'get'
|
|
5145
5707
|
}, [
|
|
5146
5708
|
new BinaryNode('membership_approval_requests', {}, null)
|
|
@@ -5151,22 +5713,26 @@ class WhalibmobClient extends EventEmitter {
|
|
|
5151
5713
|
if (!reqNode || !Array.isArray(reqNode.content)) return [];
|
|
5152
5714
|
return reqNode.content
|
|
5153
5715
|
.filter(n => n && n.description === 'membership_approval_request')
|
|
5154
|
-
.map(
|
|
5716
|
+
.map(parseJoinRequestNode)
|
|
5155
5717
|
.filter(Boolean);
|
|
5156
5718
|
}
|
|
5157
5719
|
|
|
5158
|
-
// Approve or reject pending join requests
|
|
5720
|
+
// Approve or reject pending join requests.
|
|
5721
|
+
//
|
|
5722
|
+
// Returns one result per participant, refusals included, on the same terms as
|
|
5723
|
+
// the add/remove/promote/demote actions.
|
|
5159
5724
|
async approveGroupParticipants(groupJid, approve, jids) {
|
|
5725
|
+
const gJid = makeJid(groupJid);
|
|
5160
5726
|
const arr = Array.isArray(jids) ? jids : [jids];
|
|
5161
5727
|
const action = approve ? 'approve' : 'reject';
|
|
5162
5728
|
const partNodes = arr.map(j =>
|
|
5163
|
-
new BinaryNode('participant', { jid: makeJid(j) }, null)
|
|
5729
|
+
new BinaryNode('participant', { jid: jidStrToObj(toNonAdJid(makeJid(j))) }, null)
|
|
5164
5730
|
);
|
|
5165
5731
|
const id = this._genMsgId();
|
|
5166
5732
|
const node = new BinaryNode('iq', {
|
|
5167
5733
|
id,
|
|
5168
5734
|
xmlns: 'w:g2',
|
|
5169
|
-
to:
|
|
5735
|
+
to: jidStrToObj(gJid),
|
|
5170
5736
|
type: 'set'
|
|
5171
5737
|
}, [
|
|
5172
5738
|
new BinaryNode('membership_requests_action', {}, [
|
|
@@ -5174,13 +5740,169 @@ class WhalibmobClient extends EventEmitter {
|
|
|
5174
5740
|
])
|
|
5175
5741
|
]);
|
|
5176
5742
|
const resp = await this._sendIq(node);
|
|
5177
|
-
if (!resp)
|
|
5743
|
+
if (!resp) throw new Error(action + ': no reply from server (IQ timed out)');
|
|
5744
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
5745
|
+
const errNode = findChild(resp, 'error');
|
|
5746
|
+
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
5747
|
+
const text = errNode && errNode.attrs && errNode.attrs.text;
|
|
5748
|
+
throw new Error(action + ': server rejected the request' +
|
|
5749
|
+
(code ? ' (' + code + (text ? ' ' + text : '') + ')' : ''));
|
|
5750
|
+
}
|
|
5178
5751
|
const actionNode = findChildDeep(resp, action);
|
|
5179
|
-
if (!actionNode
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
5183
|
-
|
|
5752
|
+
if (!actionNode) {
|
|
5753
|
+
throw new Error(action + ': server reply contained no <' + action + '> node');
|
|
5754
|
+
}
|
|
5755
|
+
this.invalidateGroupMetadata(gJid);
|
|
5756
|
+
return parseParticipantNodes(actionNode);
|
|
5757
|
+
}
|
|
5758
|
+
|
|
5759
|
+
// ─── Personal group invitations ───────────────────────────────────────────
|
|
5760
|
+
//
|
|
5761
|
+
// A group invite link is public: anyone holding it can join. These three are
|
|
5762
|
+
// the other kind — an invitation minted for one named person, which is the
|
|
5763
|
+
// only way into a group for someone whose privacy settings stop them from
|
|
5764
|
+
// being added outright. The code comes back attached to the refusal from
|
|
5765
|
+
// addGroupParticipants (`result.addRequest`), travels to them as a message
|
|
5766
|
+
// built by sendGroupInvite, and is spent by acceptGroupInviteMessage.
|
|
5767
|
+
|
|
5768
|
+
// Send someone a personal invitation to a group.
|
|
5769
|
+
//
|
|
5770
|
+
// opts: { groupName, caption, jpegThumbnail, isCommunity, id, contextInfo }
|
|
5771
|
+
//
|
|
5772
|
+
// The group name is filled in from the group's own metadata when the caller
|
|
5773
|
+
// does not supply one, so the bubble does not arrive nameless.
|
|
5774
|
+
async sendGroupInvite(to, groupJid, inviteCode, inviteExpiration, opts) {
|
|
5775
|
+
if (!this._sender || !this._connected) throw new Error('Not connected');
|
|
5776
|
+
opts = opts || {};
|
|
5777
|
+
const gJid = makeJid(groupJid);
|
|
5778
|
+
let groupName = opts.groupName;
|
|
5779
|
+
if (groupName === undefined) {
|
|
5780
|
+
try {
|
|
5781
|
+
const meta = await this.getGroupMetadata(gJid);
|
|
5782
|
+
groupName = (meta && meta.subject) || '';
|
|
5783
|
+
} catch (_) { groupName = ''; }
|
|
5784
|
+
}
|
|
5785
|
+
return this._sender.sendGroupInvite(to, gJid, inviteCode, inviteExpiration,
|
|
5786
|
+
Object.assign({}, opts, { groupName }));
|
|
5787
|
+
}
|
|
5788
|
+
|
|
5789
|
+
// Look at a group behind a personal invitation without accepting it.
|
|
5790
|
+
//
|
|
5791
|
+
// inviterJid is who sent the invitation; code and expiration come from the
|
|
5792
|
+
// invite message itself.
|
|
5793
|
+
async queryGroupInviteMessageInfo(groupJid, inviterJid, code, expiration) {
|
|
5794
|
+
const node = new BinaryNode('iq', {
|
|
5795
|
+
id: this._genMsgId(),
|
|
5796
|
+
xmlns: 'w:g2',
|
|
5797
|
+
to: jidStrToObj(makeJid(groupJid)),
|
|
5798
|
+
type: 'get'
|
|
5799
|
+
}, [
|
|
5800
|
+
new BinaryNode('query', {}, [
|
|
5801
|
+
new BinaryNode('add_request', {
|
|
5802
|
+
code: String(code),
|
|
5803
|
+
expiration: String(expiration || 0),
|
|
5804
|
+
admin: jidStrToObj(makeJid(inviterJid))
|
|
5805
|
+
}, null)
|
|
5806
|
+
])
|
|
5807
|
+
]);
|
|
5808
|
+
const resp = await this._sendIq(node);
|
|
5809
|
+
if (!resp) throw new Error('queryGroupInviteMessageInfo: no reply from server (IQ timed out)');
|
|
5810
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
5811
|
+
const errNode = findChild(resp, 'error');
|
|
5812
|
+
const errCode = errNode && errNode.attrs && errNode.attrs.code;
|
|
5813
|
+
throw new Error('queryGroupInviteMessageInfo: ' +
|
|
5814
|
+
(String(errCode) === '410' ? 'the invitation has expired'
|
|
5815
|
+
: String(errCode) === '406' ? 'the invitation is not valid'
|
|
5816
|
+
: 'server rejected the request' + (errCode ? ' (' + errCode + ')' : '')));
|
|
5817
|
+
}
|
|
5818
|
+
const grp = findChild(resp, 'group');
|
|
5819
|
+
if (!grp) throw new Error('queryGroupInviteMessageInfo: server reply contained no <group> node');
|
|
5820
|
+
return this._parseGroupNode(grp);
|
|
5821
|
+
}
|
|
5822
|
+
|
|
5823
|
+
// Accept a personal invitation. Returns the JID of the group joined.
|
|
5824
|
+
async acceptGroupInviteMessage(groupJid, inviterJid, code, expiration) {
|
|
5825
|
+
const gJid = makeJid(groupJid);
|
|
5826
|
+
const node = new BinaryNode('iq', {
|
|
5827
|
+
id: this._genMsgId(),
|
|
5828
|
+
xmlns: 'w:g2',
|
|
5829
|
+
to: jidStrToObj(gJid),
|
|
5830
|
+
type: 'set'
|
|
5831
|
+
}, [
|
|
5832
|
+
new BinaryNode('accept', {
|
|
5833
|
+
code: String(code),
|
|
5834
|
+
expiration: String(expiration || 0),
|
|
5835
|
+
admin: jidStrToObj(makeJid(inviterJid))
|
|
5836
|
+
}, null)
|
|
5837
|
+
]);
|
|
5838
|
+
const resp = await this._sendIq(node);
|
|
5839
|
+
if (!resp) throw new Error('acceptGroupInviteMessage: no reply from server (IQ timed out)');
|
|
5840
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
5841
|
+
const errNode = findChild(resp, 'error');
|
|
5842
|
+
const errCode = errNode && errNode.attrs && errNode.attrs.code;
|
|
5843
|
+
throw new Error('acceptGroupInviteMessage: ' +
|
|
5844
|
+
(String(errCode) === '410' ? 'the invitation has expired'
|
|
5845
|
+
: String(errCode) === '406' ? 'the invitation is not valid'
|
|
5846
|
+
: 'server rejected the request' + (errCode ? ' (' + errCode + ')' : '')));
|
|
5847
|
+
}
|
|
5848
|
+
this.invalidateGroupMetadata(gJid);
|
|
5849
|
+
return (resp.attrs && resp.attrs.from) ? String(resp.attrs.from) : gJid;
|
|
5850
|
+
}
|
|
5851
|
+
|
|
5852
|
+
// Withdraw a personal invitation you sent, before it is used.
|
|
5853
|
+
async revokeGroupInviteForParticipant(groupJid, invitedJid) {
|
|
5854
|
+
const node = new BinaryNode('iq', {
|
|
5855
|
+
id: this._genMsgId(),
|
|
5856
|
+
xmlns: 'w:g2',
|
|
5857
|
+
to: jidStrToObj(makeJid(groupJid)),
|
|
5858
|
+
type: 'set'
|
|
5859
|
+
}, [
|
|
5860
|
+
new BinaryNode('revoke', {}, [
|
|
5861
|
+
new BinaryNode('participant', { jid: jidStrToObj(toNonAdJid(makeJid(invitedJid))) }, null)
|
|
5862
|
+
])
|
|
5863
|
+
]);
|
|
5864
|
+
const resp = await this._sendIq(node);
|
|
5865
|
+
if (!resp) throw new Error('revokeGroupInviteForParticipant: no reply from server (IQ timed out)');
|
|
5866
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
5867
|
+
const errNode = findChild(resp, 'error');
|
|
5868
|
+
const errCode = errNode && errNode.attrs && errNode.attrs.code;
|
|
5869
|
+
throw new Error('revokeGroupInviteForParticipant: server rejected the request' +
|
|
5870
|
+
(errCode ? ' (' + errCode + ')' : ''));
|
|
5871
|
+
}
|
|
5872
|
+
return true;
|
|
5873
|
+
}
|
|
5874
|
+
|
|
5875
|
+
// Add participants, and for every one the server refuses because they cannot
|
|
5876
|
+
// be added directly, send them a personal invitation instead.
|
|
5877
|
+
//
|
|
5878
|
+
// Returns the same list addGroupParticipants returns, with `invited` set on
|
|
5879
|
+
// the entries an invitation went out to.
|
|
5880
|
+
async addGroupParticipantsOrInvite(groupJid, jids, opts) {
|
|
5881
|
+
opts = opts || {};
|
|
5882
|
+
const gJid = makeJid(groupJid);
|
|
5883
|
+
const results = await this.addGroupParticipants(gJid, jids);
|
|
5884
|
+
const pending = results.filter(r => r.needsInvite);
|
|
5885
|
+
if (!pending.length) return results;
|
|
5886
|
+
|
|
5887
|
+
let groupName = opts.groupName;
|
|
5888
|
+
if (groupName === undefined) {
|
|
5889
|
+
try {
|
|
5890
|
+
const meta = await this.getGroupMetadata(gJid);
|
|
5891
|
+
groupName = (meta && meta.subject) || '';
|
|
5892
|
+
} catch (_) { groupName = ''; }
|
|
5893
|
+
}
|
|
5894
|
+
|
|
5895
|
+
for (const r of pending) {
|
|
5896
|
+
try {
|
|
5897
|
+
await this.sendGroupInvite(r.jid, gJid, r.addRequest.code,
|
|
5898
|
+
r.addRequest.expiration, Object.assign({}, opts, { groupName }));
|
|
5899
|
+
r.invited = true;
|
|
5900
|
+
} catch (err) {
|
|
5901
|
+
r.invited = false;
|
|
5902
|
+
r.inviteError = err.message || String(err);
|
|
5903
|
+
}
|
|
5904
|
+
}
|
|
5905
|
+
return results;
|
|
5184
5906
|
}
|
|
5185
5907
|
}
|
|
5186
5908
|
|