whalibmob 5.7.3 → 5.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +532 -143
- package/cli.js +206 -55
- package/index.js +13 -1
- package/lib/Client.js +883 -170
- 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 +55 -0
- 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();
|
|
@@ -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
|
};
|
|
@@ -3685,6 +4131,49 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3685
4131
|
return httpGetBuffer(info.url, { web: this._mode === 'web' });
|
|
3686
4132
|
}
|
|
3687
4133
|
|
|
4134
|
+
// Download and decrypt the media on a received message.
|
|
4135
|
+
//
|
|
4136
|
+
// Pass the decoded message straight through — `msg.decoded` from a `message`
|
|
4137
|
+
// event, or the object itself:
|
|
4138
|
+
//
|
|
4139
|
+
// client.on('message', async (msg) => {
|
|
4140
|
+
// if (msg.decoded && msg.decoded.mediaKey) {
|
|
4141
|
+
// fs.writeFileSync('out.jpg', await client.downloadMedia(msg.decoded))
|
|
4142
|
+
// }
|
|
4143
|
+
// })
|
|
4144
|
+
//
|
|
4145
|
+
// The CDN applies the same browser check on the way down as on the way up, so
|
|
4146
|
+
// a companion has to identify itself as one here too. Doing it by hand means
|
|
4147
|
+
// knowing that, and knowing which key name each media type derives from — get
|
|
4148
|
+
// either wrong and the download is refused or the decryption produces garbage.
|
|
4149
|
+
//
|
|
4150
|
+
// A file already saved from a previous run can be verified against the
|
|
4151
|
+
// message with { verify: true }, which checks fileEncSha256 before decrypting.
|
|
4152
|
+
async downloadMedia(decoded, opts) {
|
|
4153
|
+
opts = opts || {};
|
|
4154
|
+
const d = (decoded && decoded.decoded) ? decoded.decoded : decoded;
|
|
4155
|
+
if (!d || !d.type) throw new Error('downloadMedia: a decoded message is required');
|
|
4156
|
+
if (!d.mediaKey) throw new Error('downloadMedia: this message carries no media');
|
|
4157
|
+
|
|
4158
|
+
|
|
4159
|
+
// A voice note is an audio message with PTT set, and a GIF a video with the
|
|
4160
|
+
// playback flag — both derive from the plain type's keys.
|
|
4161
|
+
const KEY_NAME = {
|
|
4162
|
+
image: 'image', video: 'video', audio: 'audio', voice: 'ptt',
|
|
4163
|
+
ptt: 'ptt', document: 'document', sticker: 'sticker', gif: 'gif'
|
|
4164
|
+
};
|
|
4165
|
+
const { downloadMedia, getMediaKeyName, resolveMediaUrl } = require('./MediaService');
|
|
4166
|
+
const url = resolveMediaUrl(d.url, d.directPath);
|
|
4167
|
+
if (!url) throw new Error('downloadMedia: the message has no CDN location');
|
|
4168
|
+
const keyName = getMediaKeyName(KEY_NAME[d.type] || d.type);
|
|
4169
|
+
if (!keyName) throw new Error('downloadMedia: unsupported media type "' + d.type + '"');
|
|
4170
|
+
|
|
4171
|
+
return downloadMedia(url, d.mediaKey, keyName, {
|
|
4172
|
+
web: this._mode === 'web',
|
|
4173
|
+
fileEncSha256: opts.verify ? d.fileEncSha256 : undefined
|
|
4174
|
+
});
|
|
4175
|
+
}
|
|
4176
|
+
|
|
3688
4177
|
// Parse the <list> a blocklist query or update comes back with.
|
|
3689
4178
|
//
|
|
3690
4179
|
// whatsmeow's parseBlocklist walks every child and takes its jid, without
|
|
@@ -4394,69 +4883,68 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4394
4883
|
// blockContact, unblockContact, changeEphemeralTimer
|
|
4395
4884
|
// ──────────────────────────────────────────────────────────────────────────
|
|
4396
4885
|
|
|
4397
|
-
//
|
|
4886
|
+
// Clears the unread badge on a chat. This is the chat's own read flag — to
|
|
4887
|
+
// send read receipts for particular messages, use markRead().
|
|
4398
4888
|
async markChatRead(jid) {
|
|
4399
4889
|
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 });
|
|
4890
|
+
const synced = await this._appStatePatchOrFallback(
|
|
4891
|
+
AppMutations.markReadPatch(jidFull, true),
|
|
4892
|
+
() => this._sendIq(new BinaryNode('iq', {
|
|
4893
|
+
id: this._genMsgId(), to: 's.whatsapp.net', type: 'set', xmlns: 'w'
|
|
4894
|
+
}, [new BinaryNode('read',
|
|
4895
|
+
{ jid: jidFull, count: '-1', index: '0', owner: 'false' }, null)])));
|
|
4896
|
+
this._getChatState(jidFull).markedAsUnread = false;
|
|
4897
|
+
this.emit('chat_read', { jid: jidFull, read: true, synced });
|
|
4898
|
+
return synced;
|
|
4899
|
+
}
|
|
4900
|
+
|
|
4901
|
+
async markChatUnread(jid) {
|
|
4902
|
+
const jidFull = makeJid(jid);
|
|
4903
|
+
const synced = await this._appStatePatchOrFallback(
|
|
4904
|
+
AppMutations.markReadPatch(jidFull, false));
|
|
4905
|
+
this._getChatState(jidFull).markedAsUnread = true;
|
|
4906
|
+
this.emit('chat_read', { jid: jidFull, read: false, synced });
|
|
4907
|
+
return synced;
|
|
4420
4908
|
}
|
|
4421
4909
|
|
|
4422
|
-
//
|
|
4423
|
-
//
|
|
4910
|
+
// durationMs is how long from now to stay muted; 0 or omitted means until
|
|
4911
|
+
// it is unmuted by hand. The wire carries an absolute end in milliseconds.
|
|
4424
4912
|
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);
|
|
4913
|
+
const jidFull = makeJid(jid);
|
|
4914
|
+
const endMs = (durationMs && durationMs > 0) ? Date.now() + durationMs : 0;
|
|
4915
|
+
const synced = await this._appStatePatchOrFallback(
|
|
4916
|
+
AppMutations.mutePatch(jidFull, endMs || 1),
|
|
4917
|
+
() => this._muteIq(jidFull, endMs ? Math.floor(endMs / 1000) : 0));
|
|
4918
|
+
const chat = this._getChatState(jidFull);
|
|
4439
4919
|
chat.muted = true;
|
|
4440
|
-
chat.muteUntilMs =
|
|
4441
|
-
this.emit('chat_muted', { jid: jidFull, muted: true, until: chat.muteUntilMs });
|
|
4442
|
-
return
|
|
4920
|
+
chat.muteUntilMs = endMs || -1;
|
|
4921
|
+
this.emit('chat_muted', { jid: jidFull, muted: true, until: chat.muteUntilMs, synced });
|
|
4922
|
+
return synced;
|
|
4443
4923
|
}
|
|
4444
4924
|
|
|
4445
|
-
// Unmute a chat — sends IQ to server (xmlns: 'w:web', add: '0')
|
|
4446
4925
|
async unmuteChat(jid) {
|
|
4447
4926
|
const jidFull = makeJid(jid);
|
|
4448
|
-
const
|
|
4449
|
-
|
|
4450
|
-
|
|
4927
|
+
const synced = await this._appStatePatchOrFallback(
|
|
4928
|
+
AppMutations.mutePatch(jidFull, 0),
|
|
4929
|
+
() => this._muteIq(jidFull, null));
|
|
4930
|
+
const chat = this._getChatState(jidFull);
|
|
4931
|
+
chat.muted = false;
|
|
4932
|
+
chat.muteUntilMs = 0;
|
|
4933
|
+
this.emit('chat_muted', { jid: jidFull, muted: false, synced });
|
|
4934
|
+
return synced;
|
|
4935
|
+
}
|
|
4936
|
+
|
|
4937
|
+
// The mute a primary sends for itself. A companion never uses this — its
|
|
4938
|
+
// mutes travel as app state so the phone learns about them.
|
|
4939
|
+
_muteIq(jidFull, untilSeconds) {
|
|
4940
|
+
const attrs = { jid: jidFull, add: untilSeconds === null ? '0' : '1' };
|
|
4941
|
+
if (untilSeconds) attrs.t = String(untilSeconds);
|
|
4942
|
+
return this._sendIq(new BinaryNode('iq', {
|
|
4943
|
+
id: this._genMsgId(),
|
|
4451
4944
|
to: 's.whatsapp.net',
|
|
4452
4945
|
type: 'set',
|
|
4453
4946
|
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);
|
|
4947
|
+
}, [new BinaryNode('mute', attrs, null)]));
|
|
4460
4948
|
}
|
|
4461
4949
|
|
|
4462
4950
|
// Block a contact. Returns the updated block list.
|
|
@@ -4551,45 +5039,68 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4551
5039
|
return this._parseBlocklist(findChild(resp, 'list')).jids;
|
|
4552
5040
|
}
|
|
4553
5041
|
|
|
4554
|
-
//
|
|
4555
|
-
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
|
|
5042
|
+
// ─── Chat state ───────────────────────────────────────────────────────────
|
|
5043
|
+
//
|
|
5044
|
+
// Pinning, archiving, muting, marking read and starring are not local
|
|
5045
|
+
// preferences — they are app state, which is how a change reaches the phone
|
|
5046
|
+
// and every other linked device. Each of these writes a patch and waits for
|
|
5047
|
+
// the server to take it; the local view only moves once it has.
|
|
5048
|
+
//
|
|
5049
|
+
// These used to set a field on an in-memory object and emit an event, so a
|
|
5050
|
+
// pin lasted until the process exited and never appeared anywhere else.
|
|
5051
|
+
|
|
5052
|
+
async pinChat(jid) {
|
|
5053
|
+
const jidFull = makeJid(jid);
|
|
5054
|
+
const synced = await this._appStatePatchOrFallback(AppMutations.pinPatch(jidFull, true));
|
|
5055
|
+
this._getChatState(jidFull).pinnedAt = Math.floor(Date.now() / 1000);
|
|
5056
|
+
this.emit('chat_pinned', { jid: jidFull, pinned: true, synced });
|
|
5057
|
+
return synced;
|
|
5058
|
+
}
|
|
5059
|
+
|
|
5060
|
+
async unpinChat(jid) {
|
|
5061
|
+
const jidFull = makeJid(jid);
|
|
5062
|
+
const synced = await this._appStatePatchOrFallback(AppMutations.pinPatch(jidFull, false));
|
|
5063
|
+
this._getChatState(jidFull).pinnedAt = 0;
|
|
5064
|
+
this.emit('chat_pinned', { jid: jidFull, pinned: false, synced });
|
|
5065
|
+
return synced;
|
|
4559
5066
|
}
|
|
4560
5067
|
|
|
4561
|
-
|
|
4562
|
-
const
|
|
4563
|
-
|
|
4564
|
-
this.
|
|
5068
|
+
async archiveChat(jid) {
|
|
5069
|
+
const jidFull = makeJid(jid);
|
|
5070
|
+
const synced = await this._appStatePatchOrFallback(AppMutations.archivePatch(jidFull, true));
|
|
5071
|
+
this._getChatState(jidFull).archived = true;
|
|
5072
|
+
this.emit('chat_archived', { jid: jidFull, archived: true, synced });
|
|
5073
|
+
return synced;
|
|
4565
5074
|
}
|
|
4566
5075
|
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
const
|
|
4570
|
-
|
|
4571
|
-
this.emit('chat_archived', { jid, archived:
|
|
5076
|
+
async unarchiveChat(jid) {
|
|
5077
|
+
const jidFull = makeJid(jid);
|
|
5078
|
+
const synced = await this._appStatePatchOrFallback(AppMutations.archivePatch(jidFull, false));
|
|
5079
|
+
this._getChatState(jidFull).archived = false;
|
|
5080
|
+
this.emit('chat_archived', { jid: jidFull, archived: false, synced });
|
|
5081
|
+
return synced;
|
|
4572
5082
|
}
|
|
4573
5083
|
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
this.
|
|
5084
|
+
// fromMe says whether the message being starred is one of ours. It is part of
|
|
5085
|
+
// the index, so getting it wrong stars a different message — or nothing.
|
|
5086
|
+
async starMessage(msgId, chatJid, fromMe) {
|
|
5087
|
+
return this._setStar(msgId, chatJid, fromMe, true);
|
|
4578
5088
|
}
|
|
4579
5089
|
|
|
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 });
|
|
5090
|
+
async unstarMessage(msgId, chatJid, fromMe) {
|
|
5091
|
+
return this._setStar(msgId, chatJid, fromMe, false);
|
|
4586
5092
|
}
|
|
4587
5093
|
|
|
4588
|
-
|
|
4589
|
-
const
|
|
5094
|
+
async _setStar(msgId, chatJid, fromMe, starred) {
|
|
5095
|
+
const jidFull = makeJid(chatJid);
|
|
5096
|
+
const synced = await this._appStatePatchOrFallback(
|
|
5097
|
+
AppMutations.starPatch(jidFull, msgId, !!fromMe, starred));
|
|
4590
5098
|
if (!this._starredMessages) this._starredMessages = new Set();
|
|
4591
|
-
|
|
4592
|
-
this.
|
|
5099
|
+
const key = jidFull + ':' + msgId;
|
|
5100
|
+
if (starred) this._starredMessages.add(key); else this._starredMessages.delete(key);
|
|
5101
|
+
this.emit('message_starred',
|
|
5102
|
+
{ msgId, chatJid: jidFull, starred, fromMe: !!fromMe, synced });
|
|
5103
|
+
return synced;
|
|
4593
5104
|
}
|
|
4594
5105
|
|
|
4595
5106
|
// Mark a voice/audio message as played (sends 'played' receipt)
|
|
@@ -4708,17 +5219,19 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4708
5219
|
// opts: { ephemeralSeconds, announce, locked, joinApprovalRequired,
|
|
4709
5220
|
// memberAddMode, parent, linkedParentJid }
|
|
4710
5221
|
//
|
|
4711
|
-
//
|
|
4712
|
-
//
|
|
4713
|
-
//
|
|
4714
|
-
//
|
|
4715
|
-
//
|
|
5222
|
+
// Every participant goes out as a binary JID (a raw string is dropped for an
|
|
5223
|
+
// @lid), a LID participant also carries the phone number the server matches it
|
|
5224
|
+
// against, and each carries our privacy token so the invitation is not treated
|
|
5225
|
+
// as an anonymous reach-out. The setting nodes are siblings of the
|
|
5226
|
+
// participants inside <create>.
|
|
4716
5227
|
async createGroup(subject, participants, opts) {
|
|
4717
5228
|
opts = opts || {};
|
|
4718
5229
|
const id = this._genMsgId();
|
|
4719
5230
|
// participant nodes MUST use attr `jid`, not `value`
|
|
4720
5231
|
const partNodes = (participants || []).map(p => {
|
|
4721
|
-
|
|
5232
|
+
// A participant is the person, never one of their devices — a JID that
|
|
5233
|
+
// still carries a device suffix is not one the server will match.
|
|
5234
|
+
const jid = toNonAdJid(makeJid(p));
|
|
4722
5235
|
const attrs = { jid: jidStrToObj(jid) };
|
|
4723
5236
|
if (jid.endsWith('@lid')) {
|
|
4724
5237
|
const pn = this._lidToPn.get(jid.split('@')[0].split(':')[0]);
|
|
@@ -4743,7 +5256,7 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4743
5256
|
if (opts.locked) settingNodes.push(new BinaryNode('locked', {}, null));
|
|
4744
5257
|
if (opts.announce) settingNodes.push(new BinaryNode('announcement', {}, null));
|
|
4745
5258
|
if (opts.ephemeralSeconds) {
|
|
4746
|
-
//
|
|
5259
|
+
// trigger="1" has to travel alongside the expiration; without it the
|
|
4747
5260
|
// server accepts the group but leaves disappearing messages off.
|
|
4748
5261
|
settingNodes.push(new BinaryNode('ephemeral', {
|
|
4749
5262
|
expiration: String(opts.ephemeralSeconds),
|
|
@@ -4760,7 +5273,11 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4760
5273
|
Buffer.from(String(opts.memberAddMode), 'utf8')));
|
|
4761
5274
|
}
|
|
4762
5275
|
|
|
4763
|
-
|
|
5276
|
+
// `key` is what the server echoes back on the group-create notification so
|
|
5277
|
+
// the client can tell its own creation apart from one it merely witnessed.
|
|
5278
|
+
// A per-second value collides when two groups are made in the same second;
|
|
5279
|
+
// a message id does not.
|
|
5280
|
+
const baseAttrs = { subject, key: this._genMsgId() };
|
|
4764
5281
|
const createChildren = [...partNodes, ...settingNodes];
|
|
4765
5282
|
const node = new BinaryNode('iq', {
|
|
4766
5283
|
id,
|
|
@@ -4807,7 +5324,12 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4807
5324
|
return this._sendIq(node);
|
|
4808
5325
|
}
|
|
4809
5326
|
|
|
4810
|
-
// Add participants to group
|
|
5327
|
+
// Add participants to group.
|
|
5328
|
+
//
|
|
5329
|
+
// Returns one result per participant, successes and failures alike — see
|
|
5330
|
+
// _groupParticipantAction. A participant refused with 403 usually carries an
|
|
5331
|
+
// `addRequest`, which sendGroupInvite() turns into an invitation they can act
|
|
5332
|
+
// on themselves.
|
|
4811
5333
|
async addGroupParticipants(groupJid, jids) {
|
|
4812
5334
|
return this._groupParticipantAction(groupJid, 'add', jids);
|
|
4813
5335
|
}
|
|
@@ -4832,10 +5354,10 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4832
5354
|
async _groupParticipantAction(groupJid, action, jids) {
|
|
4833
5355
|
const gJid = makeJid(groupJid);
|
|
4834
5356
|
const arr = Array.isArray(jids) ? jids : [jids];
|
|
4835
|
-
//
|
|
4836
|
-
//
|
|
5357
|
+
// Binary JIDs, and on an add, the phone number behind a LID plus our
|
|
5358
|
+
// privacy token for them.
|
|
4837
5359
|
const participantNodes = arr.map(j => {
|
|
4838
|
-
const jid = makeJid(j);
|
|
5360
|
+
const jid = toNonAdJid(makeJid(j));
|
|
4839
5361
|
const attrs = { jid: jidStrToObj(jid) };
|
|
4840
5362
|
let children = null;
|
|
4841
5363
|
if (action === 'add') {
|
|
@@ -4858,36 +5380,48 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4858
5380
|
new BinaryNode(action, {}, participantNodes)
|
|
4859
5381
|
]);
|
|
4860
5382
|
const resp = await this._sendIq(node);
|
|
4861
|
-
if (!resp)
|
|
5383
|
+
if (!resp) throw new Error(action + ': no reply from server (IQ timed out)');
|
|
5384
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
5385
|
+
const errNode = findChild(resp, 'error');
|
|
5386
|
+
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
5387
|
+
const text = errNode && errNode.attrs && errNode.attrs.text;
|
|
5388
|
+
throw new Error(action + ': server rejected the request' +
|
|
5389
|
+
(code ? ' (' + code + (text ? ' ' + text : '') + ')' : ''));
|
|
5390
|
+
}
|
|
4862
5391
|
const actionNode = findChild(resp, action);
|
|
4863
|
-
if (!actionNode
|
|
4864
|
-
|
|
4865
|
-
|
|
4866
|
-
|
|
4867
|
-
|
|
5392
|
+
if (!actionNode) {
|
|
5393
|
+
throw new Error(action + ': server reply contained no <' + action + '> node');
|
|
5394
|
+
}
|
|
5395
|
+
// The membership changed, so anything we have cached about the group is now
|
|
5396
|
+
// one edit behind.
|
|
5397
|
+
this.invalidateGroupMetadata(gJid);
|
|
5398
|
+
return parseParticipantNodes(actionNode);
|
|
4868
5399
|
}
|
|
4869
5400
|
|
|
4870
5401
|
// Change group name/subject
|
|
4871
5402
|
async changeGroupSubject(groupJid, subject) {
|
|
4872
|
-
const
|
|
5403
|
+
const gJid = makeJid(groupJid);
|
|
5404
|
+
const id = this._genMsgId();
|
|
4873
5405
|
const node = new BinaryNode('iq', {
|
|
4874
5406
|
id,
|
|
4875
5407
|
xmlns: 'w:g2',
|
|
4876
|
-
to:
|
|
5408
|
+
to: jidStrToObj(gJid),
|
|
4877
5409
|
type: 'set'
|
|
4878
5410
|
}, [
|
|
4879
5411
|
new BinaryNode('subject', {}, Buffer.from(subject, 'utf8'))
|
|
4880
5412
|
]);
|
|
4881
|
-
|
|
5413
|
+
const resp = await this._sendIq(node);
|
|
5414
|
+
this.invalidateGroupMetadata(gJid);
|
|
5415
|
+
return resp;
|
|
4882
5416
|
}
|
|
4883
5417
|
|
|
4884
5418
|
// Change group description
|
|
4885
5419
|
// description: string or null/'' to remove
|
|
4886
5420
|
//
|
|
4887
|
-
//
|
|
4888
|
-
//
|
|
4889
|
-
//
|
|
4890
|
-
//
|
|
5421
|
+
// prev=<current topic id> has to go out with the edit so the server can reject
|
|
5422
|
+
// one made against a description someone else has already replaced. Without it
|
|
5423
|
+
// the change is refused. The current id is looked up from the group metadata
|
|
5424
|
+
// when the caller does not supply one.
|
|
4891
5425
|
async changeGroupDescription(groupJid, description, opts) {
|
|
4892
5426
|
opts = opts || {};
|
|
4893
5427
|
const gJid = makeJid(groupJid);
|
|
@@ -4918,13 +5452,14 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4918
5452
|
}, [
|
|
4919
5453
|
new BinaryNode('description', descAttrs, children)
|
|
4920
5454
|
]);
|
|
4921
|
-
|
|
5455
|
+
const resp = await this._sendIq(node);
|
|
5456
|
+
this.invalidateGroupMetadata(gJid);
|
|
5457
|
+
return resp;
|
|
4922
5458
|
}
|
|
4923
5459
|
|
|
4924
5460
|
// ─── Group settings ───────────────────────────────────────────────────────
|
|
4925
5461
|
//
|
|
4926
|
-
// Both are a single empty node whose TAG carries the state
|
|
4927
|
-
// whatsmeow's SetGroupAnnounce / SetGroupLocked send them — there is no
|
|
5462
|
+
// Both are a single empty node whose TAG carries the state — there is no
|
|
4928
5463
|
// attribute form of these.
|
|
4929
5464
|
|
|
4930
5465
|
// announce=true → only admins can send messages
|
|
@@ -4976,27 +5511,42 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4976
5511
|
return resp;
|
|
4977
5512
|
}
|
|
4978
5513
|
|
|
4979
|
-
// Change group picture
|
|
4980
|
-
//
|
|
5514
|
+
// Change group picture.
|
|
5515
|
+
//
|
|
5516
|
+
// buf: Buffer with JPEG image data, or null to remove the current picture.
|
|
5517
|
+
// Returns the new picture id, or 'remove' when the picture was taken down.
|
|
5518
|
+
//
|
|
5519
|
+
// Removal is the IQ with no body at all — an empty <picture> node is a
|
|
5520
|
+
// malformed upload rather than a delete, and the server answers 406 for it.
|
|
4981
5521
|
async changeGroupPicture(groupJid, buf) {
|
|
4982
5522
|
const id = this._genMsgId();
|
|
4983
5523
|
const node = new BinaryNode('iq', {
|
|
4984
5524
|
id,
|
|
4985
5525
|
xmlns: 'w:profile:picture',
|
|
4986
|
-
target: makeJid(groupJid),
|
|
5526
|
+
target: jidStrToObj(makeJid(groupJid)),
|
|
4987
5527
|
to: 's.whatsapp.net',
|
|
4988
5528
|
type: 'set'
|
|
4989
|
-
}, [
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
5529
|
+
}, buf ? [new BinaryNode('picture', { type: 'image' }, buf)] : null);
|
|
5530
|
+
const resp = await this._sendIq(node);
|
|
5531
|
+
if (!resp) throw new Error('changeGroupPicture: no reply from server (IQ timed out)');
|
|
5532
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
5533
|
+
const errNode = findChild(resp, 'error');
|
|
5534
|
+
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
5535
|
+
throw new Error('changeGroupPicture: ' +
|
|
5536
|
+
(String(code) === '406' ? 'the image was rejected (must be a JPEG)'
|
|
5537
|
+
: String(code) === '403' ? 'you are not allowed to change this group picture'
|
|
5538
|
+
: 'server rejected the request' + (code ? ' (' + code + ')' : '')));
|
|
5539
|
+
}
|
|
5540
|
+
if (!buf) return 'remove';
|
|
5541
|
+
const picNode = findChild(resp, 'picture');
|
|
5542
|
+
return (picNode && picNode.attrs && picNode.attrs.id)
|
|
5543
|
+
? String(picNode.attrs.id) : null;
|
|
4993
5544
|
}
|
|
4994
5545
|
|
|
4995
5546
|
// Get the group's invite link.
|
|
4996
5547
|
//
|
|
4997
5548
|
// 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.
|
|
5549
|
+
// same IQ with type="set" instead of "get".
|
|
5000
5550
|
async queryGroupInviteLink(groupJid, reset) {
|
|
5001
5551
|
const code = await this._queryGroupInviteCode(groupJid, reset);
|
|
5002
5552
|
return code ? INVITE_LINK_PREFIX + code : null;
|
|
@@ -5025,8 +5575,8 @@ class WhalibmobClient extends EventEmitter {
|
|
|
5025
5575
|
if (resp.attrs && resp.attrs.type === 'error') {
|
|
5026
5576
|
const errNode = findChild(resp, 'error');
|
|
5027
5577
|
const codeAttr = errNode && errNode.attrs && errNode.attrs.code;
|
|
5028
|
-
//
|
|
5029
|
-
//
|
|
5578
|
+
// 410 is a revoked link, 406 an invalid one. Anything else is passed
|
|
5579
|
+
// through as the server worded it.
|
|
5030
5580
|
const reason = String(codeAttr) === '410' ? 'the invite link has been revoked'
|
|
5031
5581
|
: String(codeAttr) === '406' ? 'the invite link is not valid'
|
|
5032
5582
|
: 'server rejected the request' +
|
|
@@ -5134,13 +5684,16 @@ class WhalibmobClient extends EventEmitter {
|
|
|
5134
5684
|
return this._groupSettingIq(groupJid, body.description, body.content);
|
|
5135
5685
|
}
|
|
5136
5686
|
|
|
5137
|
-
// Query participants pending approval
|
|
5687
|
+
// Query participants pending approval.
|
|
5688
|
+
//
|
|
5689
|
+
// Each entry stringifies to the JID and also carries `requestedAt`, the unix
|
|
5690
|
+
// second the person asked to join, so a list can be shown oldest-first.
|
|
5138
5691
|
async queryGroupPendingParticipants(groupJid) {
|
|
5139
5692
|
const id = this._genMsgId();
|
|
5140
5693
|
const node = new BinaryNode('iq', {
|
|
5141
5694
|
id,
|
|
5142
5695
|
xmlns: 'w:g2',
|
|
5143
|
-
to: makeJid(groupJid),
|
|
5696
|
+
to: jidStrToObj(makeJid(groupJid)),
|
|
5144
5697
|
type: 'get'
|
|
5145
5698
|
}, [
|
|
5146
5699
|
new BinaryNode('membership_approval_requests', {}, null)
|
|
@@ -5151,22 +5704,26 @@ class WhalibmobClient extends EventEmitter {
|
|
|
5151
5704
|
if (!reqNode || !Array.isArray(reqNode.content)) return [];
|
|
5152
5705
|
return reqNode.content
|
|
5153
5706
|
.filter(n => n && n.description === 'membership_approval_request')
|
|
5154
|
-
.map(
|
|
5707
|
+
.map(parseJoinRequestNode)
|
|
5155
5708
|
.filter(Boolean);
|
|
5156
5709
|
}
|
|
5157
5710
|
|
|
5158
|
-
// Approve or reject pending join requests
|
|
5711
|
+
// Approve or reject pending join requests.
|
|
5712
|
+
//
|
|
5713
|
+
// Returns one result per participant, refusals included, on the same terms as
|
|
5714
|
+
// the add/remove/promote/demote actions.
|
|
5159
5715
|
async approveGroupParticipants(groupJid, approve, jids) {
|
|
5716
|
+
const gJid = makeJid(groupJid);
|
|
5160
5717
|
const arr = Array.isArray(jids) ? jids : [jids];
|
|
5161
5718
|
const action = approve ? 'approve' : 'reject';
|
|
5162
5719
|
const partNodes = arr.map(j =>
|
|
5163
|
-
new BinaryNode('participant', { jid: makeJid(j) }, null)
|
|
5720
|
+
new BinaryNode('participant', { jid: jidStrToObj(toNonAdJid(makeJid(j))) }, null)
|
|
5164
5721
|
);
|
|
5165
5722
|
const id = this._genMsgId();
|
|
5166
5723
|
const node = new BinaryNode('iq', {
|
|
5167
5724
|
id,
|
|
5168
5725
|
xmlns: 'w:g2',
|
|
5169
|
-
to:
|
|
5726
|
+
to: jidStrToObj(gJid),
|
|
5170
5727
|
type: 'set'
|
|
5171
5728
|
}, [
|
|
5172
5729
|
new BinaryNode('membership_requests_action', {}, [
|
|
@@ -5174,13 +5731,169 @@ class WhalibmobClient extends EventEmitter {
|
|
|
5174
5731
|
])
|
|
5175
5732
|
]);
|
|
5176
5733
|
const resp = await this._sendIq(node);
|
|
5177
|
-
if (!resp)
|
|
5734
|
+
if (!resp) throw new Error(action + ': no reply from server (IQ timed out)');
|
|
5735
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
5736
|
+
const errNode = findChild(resp, 'error');
|
|
5737
|
+
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
5738
|
+
const text = errNode && errNode.attrs && errNode.attrs.text;
|
|
5739
|
+
throw new Error(action + ': server rejected the request' +
|
|
5740
|
+
(code ? ' (' + code + (text ? ' ' + text : '') + ')' : ''));
|
|
5741
|
+
}
|
|
5178
5742
|
const actionNode = findChildDeep(resp, action);
|
|
5179
|
-
if (!actionNode
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
5183
|
-
|
|
5743
|
+
if (!actionNode) {
|
|
5744
|
+
throw new Error(action + ': server reply contained no <' + action + '> node');
|
|
5745
|
+
}
|
|
5746
|
+
this.invalidateGroupMetadata(gJid);
|
|
5747
|
+
return parseParticipantNodes(actionNode);
|
|
5748
|
+
}
|
|
5749
|
+
|
|
5750
|
+
// ─── Personal group invitations ───────────────────────────────────────────
|
|
5751
|
+
//
|
|
5752
|
+
// A group invite link is public: anyone holding it can join. These three are
|
|
5753
|
+
// the other kind — an invitation minted for one named person, which is the
|
|
5754
|
+
// only way into a group for someone whose privacy settings stop them from
|
|
5755
|
+
// being added outright. The code comes back attached to the refusal from
|
|
5756
|
+
// addGroupParticipants (`result.addRequest`), travels to them as a message
|
|
5757
|
+
// built by sendGroupInvite, and is spent by acceptGroupInviteMessage.
|
|
5758
|
+
|
|
5759
|
+
// Send someone a personal invitation to a group.
|
|
5760
|
+
//
|
|
5761
|
+
// opts: { groupName, caption, jpegThumbnail, isCommunity, id, contextInfo }
|
|
5762
|
+
//
|
|
5763
|
+
// The group name is filled in from the group's own metadata when the caller
|
|
5764
|
+
// does not supply one, so the bubble does not arrive nameless.
|
|
5765
|
+
async sendGroupInvite(to, groupJid, inviteCode, inviteExpiration, opts) {
|
|
5766
|
+
if (!this._sender || !this._connected) throw new Error('Not connected');
|
|
5767
|
+
opts = opts || {};
|
|
5768
|
+
const gJid = makeJid(groupJid);
|
|
5769
|
+
let groupName = opts.groupName;
|
|
5770
|
+
if (groupName === undefined) {
|
|
5771
|
+
try {
|
|
5772
|
+
const meta = await this.getGroupMetadata(gJid);
|
|
5773
|
+
groupName = (meta && meta.subject) || '';
|
|
5774
|
+
} catch (_) { groupName = ''; }
|
|
5775
|
+
}
|
|
5776
|
+
return this._sender.sendGroupInvite(to, gJid, inviteCode, inviteExpiration,
|
|
5777
|
+
Object.assign({}, opts, { groupName }));
|
|
5778
|
+
}
|
|
5779
|
+
|
|
5780
|
+
// Look at a group behind a personal invitation without accepting it.
|
|
5781
|
+
//
|
|
5782
|
+
// inviterJid is who sent the invitation; code and expiration come from the
|
|
5783
|
+
// invite message itself.
|
|
5784
|
+
async queryGroupInviteMessageInfo(groupJid, inviterJid, code, expiration) {
|
|
5785
|
+
const node = new BinaryNode('iq', {
|
|
5786
|
+
id: this._genMsgId(),
|
|
5787
|
+
xmlns: 'w:g2',
|
|
5788
|
+
to: jidStrToObj(makeJid(groupJid)),
|
|
5789
|
+
type: 'get'
|
|
5790
|
+
}, [
|
|
5791
|
+
new BinaryNode('query', {}, [
|
|
5792
|
+
new BinaryNode('add_request', {
|
|
5793
|
+
code: String(code),
|
|
5794
|
+
expiration: String(expiration || 0),
|
|
5795
|
+
admin: jidStrToObj(makeJid(inviterJid))
|
|
5796
|
+
}, null)
|
|
5797
|
+
])
|
|
5798
|
+
]);
|
|
5799
|
+
const resp = await this._sendIq(node);
|
|
5800
|
+
if (!resp) throw new Error('queryGroupInviteMessageInfo: no reply from server (IQ timed out)');
|
|
5801
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
5802
|
+
const errNode = findChild(resp, 'error');
|
|
5803
|
+
const errCode = errNode && errNode.attrs && errNode.attrs.code;
|
|
5804
|
+
throw new Error('queryGroupInviteMessageInfo: ' +
|
|
5805
|
+
(String(errCode) === '410' ? 'the invitation has expired'
|
|
5806
|
+
: String(errCode) === '406' ? 'the invitation is not valid'
|
|
5807
|
+
: 'server rejected the request' + (errCode ? ' (' + errCode + ')' : '')));
|
|
5808
|
+
}
|
|
5809
|
+
const grp = findChild(resp, 'group');
|
|
5810
|
+
if (!grp) throw new Error('queryGroupInviteMessageInfo: server reply contained no <group> node');
|
|
5811
|
+
return this._parseGroupNode(grp);
|
|
5812
|
+
}
|
|
5813
|
+
|
|
5814
|
+
// Accept a personal invitation. Returns the JID of the group joined.
|
|
5815
|
+
async acceptGroupInviteMessage(groupJid, inviterJid, code, expiration) {
|
|
5816
|
+
const gJid = makeJid(groupJid);
|
|
5817
|
+
const node = new BinaryNode('iq', {
|
|
5818
|
+
id: this._genMsgId(),
|
|
5819
|
+
xmlns: 'w:g2',
|
|
5820
|
+
to: jidStrToObj(gJid),
|
|
5821
|
+
type: 'set'
|
|
5822
|
+
}, [
|
|
5823
|
+
new BinaryNode('accept', {
|
|
5824
|
+
code: String(code),
|
|
5825
|
+
expiration: String(expiration || 0),
|
|
5826
|
+
admin: jidStrToObj(makeJid(inviterJid))
|
|
5827
|
+
}, null)
|
|
5828
|
+
]);
|
|
5829
|
+
const resp = await this._sendIq(node);
|
|
5830
|
+
if (!resp) throw new Error('acceptGroupInviteMessage: no reply from server (IQ timed out)');
|
|
5831
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
5832
|
+
const errNode = findChild(resp, 'error');
|
|
5833
|
+
const errCode = errNode && errNode.attrs && errNode.attrs.code;
|
|
5834
|
+
throw new Error('acceptGroupInviteMessage: ' +
|
|
5835
|
+
(String(errCode) === '410' ? 'the invitation has expired'
|
|
5836
|
+
: String(errCode) === '406' ? 'the invitation is not valid'
|
|
5837
|
+
: 'server rejected the request' + (errCode ? ' (' + errCode + ')' : '')));
|
|
5838
|
+
}
|
|
5839
|
+
this.invalidateGroupMetadata(gJid);
|
|
5840
|
+
return (resp.attrs && resp.attrs.from) ? String(resp.attrs.from) : gJid;
|
|
5841
|
+
}
|
|
5842
|
+
|
|
5843
|
+
// Withdraw a personal invitation you sent, before it is used.
|
|
5844
|
+
async revokeGroupInviteForParticipant(groupJid, invitedJid) {
|
|
5845
|
+
const node = new BinaryNode('iq', {
|
|
5846
|
+
id: this._genMsgId(),
|
|
5847
|
+
xmlns: 'w:g2',
|
|
5848
|
+
to: jidStrToObj(makeJid(groupJid)),
|
|
5849
|
+
type: 'set'
|
|
5850
|
+
}, [
|
|
5851
|
+
new BinaryNode('revoke', {}, [
|
|
5852
|
+
new BinaryNode('participant', { jid: jidStrToObj(toNonAdJid(makeJid(invitedJid))) }, null)
|
|
5853
|
+
])
|
|
5854
|
+
]);
|
|
5855
|
+
const resp = await this._sendIq(node);
|
|
5856
|
+
if (!resp) throw new Error('revokeGroupInviteForParticipant: no reply from server (IQ timed out)');
|
|
5857
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
5858
|
+
const errNode = findChild(resp, 'error');
|
|
5859
|
+
const errCode = errNode && errNode.attrs && errNode.attrs.code;
|
|
5860
|
+
throw new Error('revokeGroupInviteForParticipant: server rejected the request' +
|
|
5861
|
+
(errCode ? ' (' + errCode + ')' : ''));
|
|
5862
|
+
}
|
|
5863
|
+
return true;
|
|
5864
|
+
}
|
|
5865
|
+
|
|
5866
|
+
// Add participants, and for every one the server refuses because they cannot
|
|
5867
|
+
// be added directly, send them a personal invitation instead.
|
|
5868
|
+
//
|
|
5869
|
+
// Returns the same list addGroupParticipants returns, with `invited` set on
|
|
5870
|
+
// the entries an invitation went out to.
|
|
5871
|
+
async addGroupParticipantsOrInvite(groupJid, jids, opts) {
|
|
5872
|
+
opts = opts || {};
|
|
5873
|
+
const gJid = makeJid(groupJid);
|
|
5874
|
+
const results = await this.addGroupParticipants(gJid, jids);
|
|
5875
|
+
const pending = results.filter(r => r.needsInvite);
|
|
5876
|
+
if (!pending.length) return results;
|
|
5877
|
+
|
|
5878
|
+
let groupName = opts.groupName;
|
|
5879
|
+
if (groupName === undefined) {
|
|
5880
|
+
try {
|
|
5881
|
+
const meta = await this.getGroupMetadata(gJid);
|
|
5882
|
+
groupName = (meta && meta.subject) || '';
|
|
5883
|
+
} catch (_) { groupName = ''; }
|
|
5884
|
+
}
|
|
5885
|
+
|
|
5886
|
+
for (const r of pending) {
|
|
5887
|
+
try {
|
|
5888
|
+
await this.sendGroupInvite(r.jid, gJid, r.addRequest.code,
|
|
5889
|
+
r.addRequest.expiration, Object.assign({}, opts, { groupName }));
|
|
5890
|
+
r.invited = true;
|
|
5891
|
+
} catch (err) {
|
|
5892
|
+
r.invited = false;
|
|
5893
|
+
r.inviteError = err.message || String(err);
|
|
5894
|
+
}
|
|
5895
|
+
}
|
|
5896
|
+
return results;
|
|
5184
5897
|
}
|
|
5185
5898
|
}
|
|
5186
5899
|
|