whalibmob 5.7.2 → 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 +920 -172
- 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/messages/TcTokenStore.js +44 -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);
|
|
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;
|
|
1278
1426
|
}
|
|
1427
|
+
throw err;
|
|
1279
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;
|
|
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();
|
|
@@ -1904,8 +2282,43 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1904
2282
|
const { normalizeJidForTcToken } = require('./messages/TcTokenStore');
|
|
1905
2283
|
const norm = normalizeJidForTcToken(jid);
|
|
1906
2284
|
if (norm.endsWith('@lid')) return norm;
|
|
1907
|
-
|
|
1908
|
-
|
|
2285
|
+
|
|
2286
|
+
const pnUser = norm.split('@')[0];
|
|
2287
|
+
let lidUser = this._pnToLid && this._pnToLid.get(pnUser);
|
|
2288
|
+
|
|
2289
|
+
// The in-memory map starts empty and is filled by usync and by incoming
|
|
2290
|
+
// messages, so a send early in a process — or one that hits a warm device
|
|
2291
|
+
// cache and never runs usync — could find nothing and fall back to the
|
|
2292
|
+
// phone JID. The mapping is on disk in the Signal store either way, which
|
|
2293
|
+
// is the same place whatsmeow reads it from, so consult that before giving
|
|
2294
|
+
// up. The consequence of not doing so is not cosmetic: the lookup lands on
|
|
2295
|
+
// a key with no token while a live one sits under the LID, and the message
|
|
2296
|
+
// goes out without one.
|
|
2297
|
+
if (!lidUser) {
|
|
2298
|
+
const store = this._signal && this._signal.store;
|
|
2299
|
+
const persisted = store && typeof store.getLidMappings === 'function'
|
|
2300
|
+
? store.getLidMappings() : null;
|
|
2301
|
+
const fromDisk = persisted && persisted[pnUser];
|
|
2302
|
+
if (fromDisk) {
|
|
2303
|
+
lidUser = String(fromDisk);
|
|
2304
|
+
if (this._pnToLid) this._pnToLid.set(pnUser, lidUser);
|
|
2305
|
+
if (this._lidToPn) this._lidToPn.set(lidUser, pnUser);
|
|
2306
|
+
_whaDbg('[DBG] TCTOKEN_LID_FROM_DISK pn=' + pnUser + ' lid=' + lidUser);
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
if (!lidUser) return norm;
|
|
2311
|
+
|
|
2312
|
+
const lidJid = lidUser + '@lid';
|
|
2313
|
+
|
|
2314
|
+
// Anything already filed under the phone JID belongs with the LID entry.
|
|
2315
|
+
// Left split, the two halves each hold part of the state and neither is
|
|
2316
|
+
// complete. Cheap when there is nothing to move, which is the usual case.
|
|
2317
|
+
if (this._tcTokenStore && this._tcTokenStore.mergeEntry(norm, lidJid)) {
|
|
2318
|
+
_whaDbg('[DBG] TCTOKEN_MERGED ' + norm + ' → ' + lidJid);
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
return lidJid;
|
|
1909
2322
|
}
|
|
1910
2323
|
|
|
1911
2324
|
// The JID a token is *issued* to, which is not the JID it is *stored* under.
|
|
@@ -2613,7 +3026,20 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2613
3026
|
if (this._signal && this._signal.store && this._signal.store.setLidMapping)
|
|
2614
3027
|
this._signal.store.setLidMapping(pnUser, lidUser);
|
|
2615
3028
|
}
|
|
2616
|
-
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
|
+
};
|
|
2617
3043
|
});
|
|
2618
3044
|
|
|
2619
3045
|
// description — the id is the "topic id" the server wants echoed back as
|
|
@@ -2633,6 +3059,13 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2633
3059
|
if (buf) description = buf.toString('utf8');
|
|
2634
3060
|
}
|
|
2635
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
|
+
|
|
2636
3069
|
// ephemeral
|
|
2637
3070
|
const ephNode = children.find(n => n && n.description === 'ephemeral');
|
|
2638
3071
|
const ephemeral = ephNode && ephNode.attrs ? parseInt(ephNode.attrs.expiration, 10) || 0 : 0;
|
|
@@ -2641,6 +3074,22 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2641
3074
|
const announce = children.some(n => n && n.description === 'announcement');
|
|
2642
3075
|
const restrict = children.some(n => n && n.description === 'locked');
|
|
2643
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
|
+
|
|
2644
3093
|
const groupId = a.id || '';
|
|
2645
3094
|
const jid = groupId.includes('@') ? groupId : groupId + '@g.us';
|
|
2646
3095
|
|
|
@@ -2660,9 +3109,41 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2660
3109
|
subjectBy: a.s_o || null,
|
|
2661
3110
|
description,
|
|
2662
3111
|
descriptionId,
|
|
3112
|
+
descriptionBy,
|
|
3113
|
+
descriptionByPn,
|
|
3114
|
+
descriptionTime,
|
|
2663
3115
|
ephemeral,
|
|
2664
3116
|
onlyAdminsSend: announce,
|
|
2665
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,
|
|
2666
3147
|
addressingMode,
|
|
2667
3148
|
participants
|
|
2668
3149
|
};
|
|
@@ -3650,6 +4131,49 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3650
4131
|
return httpGetBuffer(info.url, { web: this._mode === 'web' });
|
|
3651
4132
|
}
|
|
3652
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
|
+
|
|
3653
4177
|
// Parse the <list> a blocklist query or update comes back with.
|
|
3654
4178
|
//
|
|
3655
4179
|
// whatsmeow's parseBlocklist walks every child and takes its jid, without
|
|
@@ -4359,69 +4883,68 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4359
4883
|
// blockContact, unblockContact, changeEphemeralTimer
|
|
4360
4884
|
// ──────────────────────────────────────────────────────────────────────────
|
|
4361
4885
|
|
|
4362
|
-
//
|
|
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().
|
|
4363
4888
|
async markChatRead(jid) {
|
|
4364
4889
|
const jidFull = makeJid(jid);
|
|
4365
|
-
const
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
chat.markedAsUnread = true;
|
|
4384
|
-
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;
|
|
4385
4908
|
}
|
|
4386
4909
|
|
|
4387
|
-
//
|
|
4388
|
-
//
|
|
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.
|
|
4389
4912
|
async muteChat(jid, durationMs) {
|
|
4390
|
-
const jidFull
|
|
4391
|
-
const
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
const
|
|
4396
|
-
if (muteEnd > 0) attrs.t = String(muteEnd);
|
|
4397
|
-
const node = new BinaryNode('iq', {
|
|
4398
|
-
id,
|
|
4399
|
-
to: 's.whatsapp.net',
|
|
4400
|
-
type: 'set',
|
|
4401
|
-
xmlns: 'w:web'
|
|
4402
|
-
}, [new BinaryNode('mute', attrs, null)]);
|
|
4403
|
-
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);
|
|
4404
4919
|
chat.muted = true;
|
|
4405
|
-
chat.muteUntilMs =
|
|
4406
|
-
this.emit('chat_muted', { jid: jidFull, muted: true, until: chat.muteUntilMs });
|
|
4407
|
-
return
|
|
4920
|
+
chat.muteUntilMs = endMs || -1;
|
|
4921
|
+
this.emit('chat_muted', { jid: jidFull, muted: true, until: chat.muteUntilMs, synced });
|
|
4922
|
+
return synced;
|
|
4408
4923
|
}
|
|
4409
4924
|
|
|
4410
|
-
// Unmute a chat — sends IQ to server (xmlns: 'w:web', add: '0')
|
|
4411
4925
|
async unmuteChat(jid) {
|
|
4412
4926
|
const jidFull = makeJid(jid);
|
|
4413
|
-
const
|
|
4414
|
-
|
|
4415
|
-
|
|
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(),
|
|
4416
4944
|
to: 's.whatsapp.net',
|
|
4417
4945
|
type: 'set',
|
|
4418
4946
|
xmlns: 'w:web'
|
|
4419
|
-
}, [new BinaryNode('mute',
|
|
4420
|
-
const chat = this._getChatState(jid);
|
|
4421
|
-
chat.muted = false;
|
|
4422
|
-
chat.muteUntilMs = 0;
|
|
4423
|
-
this.emit('chat_muted', { jid: jidFull, muted: false });
|
|
4424
|
-
return this._sendIq(node);
|
|
4947
|
+
}, [new BinaryNode('mute', attrs, null)]));
|
|
4425
4948
|
}
|
|
4426
4949
|
|
|
4427
4950
|
// Block a contact. Returns the updated block list.
|
|
@@ -4516,45 +5039,68 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4516
5039
|
return this._parseBlocklist(findChild(resp, 'list')).jids;
|
|
4517
5040
|
}
|
|
4518
5041
|
|
|
4519
|
-
//
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
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;
|
|
4524
5066
|
}
|
|
4525
5067
|
|
|
4526
|
-
|
|
4527
|
-
const
|
|
4528
|
-
|
|
4529
|
-
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;
|
|
4530
5074
|
}
|
|
4531
5075
|
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
const
|
|
4535
|
-
|
|
4536
|
-
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;
|
|
4537
5082
|
}
|
|
4538
5083
|
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
4542
|
-
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);
|
|
4543
5088
|
}
|
|
4544
5089
|
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
const key = `${chatJid}:${msgId}`;
|
|
4548
|
-
if (!this._starredMessages) this._starredMessages = new Set();
|
|
4549
|
-
this._starredMessages.add(key);
|
|
4550
|
-
this.emit('message_starred', { msgId, chatJid, starred: true });
|
|
5090
|
+
async unstarMessage(msgId, chatJid, fromMe) {
|
|
5091
|
+
return this._setStar(msgId, chatJid, fromMe, false);
|
|
4551
5092
|
}
|
|
4552
5093
|
|
|
4553
|
-
|
|
4554
|
-
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));
|
|
4555
5098
|
if (!this._starredMessages) this._starredMessages = new Set();
|
|
4556
|
-
|
|
4557
|
-
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;
|
|
4558
5104
|
}
|
|
4559
5105
|
|
|
4560
5106
|
// Mark a voice/audio message as played (sends 'played' receipt)
|
|
@@ -4673,17 +5219,19 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4673
5219
|
// opts: { ephemeralSeconds, announce, locked, joinApprovalRequired,
|
|
4674
5220
|
// memberAddMode, parent, linkedParentJid }
|
|
4675
5221
|
//
|
|
4676
|
-
//
|
|
4677
|
-
//
|
|
4678
|
-
//
|
|
4679
|
-
//
|
|
4680
|
-
//
|
|
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>.
|
|
4681
5227
|
async createGroup(subject, participants, opts) {
|
|
4682
5228
|
opts = opts || {};
|
|
4683
5229
|
const id = this._genMsgId();
|
|
4684
5230
|
// participant nodes MUST use attr `jid`, not `value`
|
|
4685
5231
|
const partNodes = (participants || []).map(p => {
|
|
4686
|
-
|
|
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));
|
|
4687
5235
|
const attrs = { jid: jidStrToObj(jid) };
|
|
4688
5236
|
if (jid.endsWith('@lid')) {
|
|
4689
5237
|
const pn = this._lidToPn.get(jid.split('@')[0].split(':')[0]);
|
|
@@ -4708,7 +5256,7 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4708
5256
|
if (opts.locked) settingNodes.push(new BinaryNode('locked', {}, null));
|
|
4709
5257
|
if (opts.announce) settingNodes.push(new BinaryNode('announcement', {}, null));
|
|
4710
5258
|
if (opts.ephemeralSeconds) {
|
|
4711
|
-
//
|
|
5259
|
+
// trigger="1" has to travel alongside the expiration; without it the
|
|
4712
5260
|
// server accepts the group but leaves disappearing messages off.
|
|
4713
5261
|
settingNodes.push(new BinaryNode('ephemeral', {
|
|
4714
5262
|
expiration: String(opts.ephemeralSeconds),
|
|
@@ -4725,7 +5273,11 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4725
5273
|
Buffer.from(String(opts.memberAddMode), 'utf8')));
|
|
4726
5274
|
}
|
|
4727
5275
|
|
|
4728
|
-
|
|
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() };
|
|
4729
5281
|
const createChildren = [...partNodes, ...settingNodes];
|
|
4730
5282
|
const node = new BinaryNode('iq', {
|
|
4731
5283
|
id,
|
|
@@ -4772,7 +5324,12 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4772
5324
|
return this._sendIq(node);
|
|
4773
5325
|
}
|
|
4774
5326
|
|
|
4775
|
-
// 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.
|
|
4776
5333
|
async addGroupParticipants(groupJid, jids) {
|
|
4777
5334
|
return this._groupParticipantAction(groupJid, 'add', jids);
|
|
4778
5335
|
}
|
|
@@ -4797,10 +5354,10 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4797
5354
|
async _groupParticipantAction(groupJid, action, jids) {
|
|
4798
5355
|
const gJid = makeJid(groupJid);
|
|
4799
5356
|
const arr = Array.isArray(jids) ? jids : [jids];
|
|
4800
|
-
//
|
|
4801
|
-
//
|
|
5357
|
+
// Binary JIDs, and on an add, the phone number behind a LID plus our
|
|
5358
|
+
// privacy token for them.
|
|
4802
5359
|
const participantNodes = arr.map(j => {
|
|
4803
|
-
const jid = makeJid(j);
|
|
5360
|
+
const jid = toNonAdJid(makeJid(j));
|
|
4804
5361
|
const attrs = { jid: jidStrToObj(jid) };
|
|
4805
5362
|
let children = null;
|
|
4806
5363
|
if (action === 'add') {
|
|
@@ -4823,36 +5380,48 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4823
5380
|
new BinaryNode(action, {}, participantNodes)
|
|
4824
5381
|
]);
|
|
4825
5382
|
const resp = await this._sendIq(node);
|
|
4826
|
-
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
|
+
}
|
|
4827
5391
|
const actionNode = findChild(resp, action);
|
|
4828
|
-
if (!actionNode
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
|
|
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);
|
|
4833
5399
|
}
|
|
4834
5400
|
|
|
4835
5401
|
// Change group name/subject
|
|
4836
5402
|
async changeGroupSubject(groupJid, subject) {
|
|
4837
|
-
const
|
|
5403
|
+
const gJid = makeJid(groupJid);
|
|
5404
|
+
const id = this._genMsgId();
|
|
4838
5405
|
const node = new BinaryNode('iq', {
|
|
4839
5406
|
id,
|
|
4840
5407
|
xmlns: 'w:g2',
|
|
4841
|
-
to:
|
|
5408
|
+
to: jidStrToObj(gJid),
|
|
4842
5409
|
type: 'set'
|
|
4843
5410
|
}, [
|
|
4844
5411
|
new BinaryNode('subject', {}, Buffer.from(subject, 'utf8'))
|
|
4845
5412
|
]);
|
|
4846
|
-
|
|
5413
|
+
const resp = await this._sendIq(node);
|
|
5414
|
+
this.invalidateGroupMetadata(gJid);
|
|
5415
|
+
return resp;
|
|
4847
5416
|
}
|
|
4848
5417
|
|
|
4849
5418
|
// Change group description
|
|
4850
5419
|
// description: string or null/'' to remove
|
|
4851
5420
|
//
|
|
4852
|
-
//
|
|
4853
|
-
//
|
|
4854
|
-
//
|
|
4855
|
-
//
|
|
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.
|
|
4856
5425
|
async changeGroupDescription(groupJid, description, opts) {
|
|
4857
5426
|
opts = opts || {};
|
|
4858
5427
|
const gJid = makeJid(groupJid);
|
|
@@ -4883,13 +5452,14 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4883
5452
|
}, [
|
|
4884
5453
|
new BinaryNode('description', descAttrs, children)
|
|
4885
5454
|
]);
|
|
4886
|
-
|
|
5455
|
+
const resp = await this._sendIq(node);
|
|
5456
|
+
this.invalidateGroupMetadata(gJid);
|
|
5457
|
+
return resp;
|
|
4887
5458
|
}
|
|
4888
5459
|
|
|
4889
5460
|
// ─── Group settings ───────────────────────────────────────────────────────
|
|
4890
5461
|
//
|
|
4891
|
-
// Both are a single empty node whose TAG carries the state
|
|
4892
|
-
// whatsmeow's SetGroupAnnounce / SetGroupLocked send them — there is no
|
|
5462
|
+
// Both are a single empty node whose TAG carries the state — there is no
|
|
4893
5463
|
// attribute form of these.
|
|
4894
5464
|
|
|
4895
5465
|
// announce=true → only admins can send messages
|
|
@@ -4941,27 +5511,42 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4941
5511
|
return resp;
|
|
4942
5512
|
}
|
|
4943
5513
|
|
|
4944
|
-
// Change group picture
|
|
4945
|
-
//
|
|
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.
|
|
4946
5521
|
async changeGroupPicture(groupJid, buf) {
|
|
4947
5522
|
const id = this._genMsgId();
|
|
4948
5523
|
const node = new BinaryNode('iq', {
|
|
4949
5524
|
id,
|
|
4950
5525
|
xmlns: 'w:profile:picture',
|
|
4951
|
-
target: makeJid(groupJid),
|
|
5526
|
+
target: jidStrToObj(makeJid(groupJid)),
|
|
4952
5527
|
to: 's.whatsapp.net',
|
|
4953
5528
|
type: 'set'
|
|
4954
|
-
}, [
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
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;
|
|
4958
5544
|
}
|
|
4959
5545
|
|
|
4960
5546
|
// Get the group's invite link.
|
|
4961
5547
|
//
|
|
4962
5548
|
// reset=true revokes the current link and returns a freshly minted one — the
|
|
4963
|
-
// same IQ with type="set" instead of "get"
|
|
4964
|
-
// GetGroupInviteLink handles its reset flag.
|
|
5549
|
+
// same IQ with type="set" instead of "get".
|
|
4965
5550
|
async queryGroupInviteLink(groupJid, reset) {
|
|
4966
5551
|
const code = await this._queryGroupInviteCode(groupJid, reset);
|
|
4967
5552
|
return code ? INVITE_LINK_PREFIX + code : null;
|
|
@@ -4990,8 +5575,8 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4990
5575
|
if (resp.attrs && resp.attrs.type === 'error') {
|
|
4991
5576
|
const errNode = findChild(resp, 'error');
|
|
4992
5577
|
const codeAttr = errNode && errNode.attrs && errNode.attrs.code;
|
|
4993
|
-
//
|
|
4994
|
-
//
|
|
5578
|
+
// 410 is a revoked link, 406 an invalid one. Anything else is passed
|
|
5579
|
+
// through as the server worded it.
|
|
4995
5580
|
const reason = String(codeAttr) === '410' ? 'the invite link has been revoked'
|
|
4996
5581
|
: String(codeAttr) === '406' ? 'the invite link is not valid'
|
|
4997
5582
|
: 'server rejected the request' +
|
|
@@ -5099,13 +5684,16 @@ class WhalibmobClient extends EventEmitter {
|
|
|
5099
5684
|
return this._groupSettingIq(groupJid, body.description, body.content);
|
|
5100
5685
|
}
|
|
5101
5686
|
|
|
5102
|
-
// 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.
|
|
5103
5691
|
async queryGroupPendingParticipants(groupJid) {
|
|
5104
5692
|
const id = this._genMsgId();
|
|
5105
5693
|
const node = new BinaryNode('iq', {
|
|
5106
5694
|
id,
|
|
5107
5695
|
xmlns: 'w:g2',
|
|
5108
|
-
to: makeJid(groupJid),
|
|
5696
|
+
to: jidStrToObj(makeJid(groupJid)),
|
|
5109
5697
|
type: 'get'
|
|
5110
5698
|
}, [
|
|
5111
5699
|
new BinaryNode('membership_approval_requests', {}, null)
|
|
@@ -5116,22 +5704,26 @@ class WhalibmobClient extends EventEmitter {
|
|
|
5116
5704
|
if (!reqNode || !Array.isArray(reqNode.content)) return [];
|
|
5117
5705
|
return reqNode.content
|
|
5118
5706
|
.filter(n => n && n.description === 'membership_approval_request')
|
|
5119
|
-
.map(
|
|
5707
|
+
.map(parseJoinRequestNode)
|
|
5120
5708
|
.filter(Boolean);
|
|
5121
5709
|
}
|
|
5122
5710
|
|
|
5123
|
-
// 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.
|
|
5124
5715
|
async approveGroupParticipants(groupJid, approve, jids) {
|
|
5716
|
+
const gJid = makeJid(groupJid);
|
|
5125
5717
|
const arr = Array.isArray(jids) ? jids : [jids];
|
|
5126
5718
|
const action = approve ? 'approve' : 'reject';
|
|
5127
5719
|
const partNodes = arr.map(j =>
|
|
5128
|
-
new BinaryNode('participant', { jid: makeJid(j) }, null)
|
|
5720
|
+
new BinaryNode('participant', { jid: jidStrToObj(toNonAdJid(makeJid(j))) }, null)
|
|
5129
5721
|
);
|
|
5130
5722
|
const id = this._genMsgId();
|
|
5131
5723
|
const node = new BinaryNode('iq', {
|
|
5132
5724
|
id,
|
|
5133
5725
|
xmlns: 'w:g2',
|
|
5134
|
-
to:
|
|
5726
|
+
to: jidStrToObj(gJid),
|
|
5135
5727
|
type: 'set'
|
|
5136
5728
|
}, [
|
|
5137
5729
|
new BinaryNode('membership_requests_action', {}, [
|
|
@@ -5139,13 +5731,169 @@ class WhalibmobClient extends EventEmitter {
|
|
|
5139
5731
|
])
|
|
5140
5732
|
]);
|
|
5141
5733
|
const resp = await this._sendIq(node);
|
|
5142
|
-
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
|
+
}
|
|
5143
5742
|
const actionNode = findChildDeep(resp, action);
|
|
5144
|
-
if (!actionNode
|
|
5145
|
-
|
|
5146
|
-
|
|
5147
|
-
|
|
5148
|
-
|
|
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;
|
|
5149
5897
|
}
|
|
5150
5898
|
}
|
|
5151
5899
|
|