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/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
- // whatsmeow's InviteLinkPrefix — the one prefix group invite codes are wrapped in.
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
- // ── Feature 3 (part B): Handle <ib> dirty-state notifications ──────────────
1176
+ // ── <ib> dirty-state notifications ────────────────────────────────────────
1164
1177
  //
1165
- // After login the server sends <ib><dirty type="account_sync"…/></ib> to
1166
- // tell the client that its app state is stale. We respond with a sync IQ
1167
- // for each dirty collection. We track the version number for each
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
- // Only these are valid WhatsApp app-state collections.
1223
- // Other dirty types (e.g. "groups") use different IQs and must be ignored here.
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: ['critical_block', 'critical_unblock_low', 'regular_low', 'regular_high', 'regular'],
1226
- critical_block: ['critical_block'],
1227
- critical_unblock_low:['critical_unblock_low'],
1228
- regular_low: ['regular_low'],
1229
- regular_high: ['regular_high'],
1230
- regular: ['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
- // "groups" is not an app-state collection, but it is still a dirty bit
1238
- // the server expects to be cleared. Ignoring it meant the server
1239
- // re-announced it on every single connection, forever.
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
- const id = this._genMsgId();
1250
- const collectionNodes = [...collections].map(name => {
1251
- const version = String(this._appStateVersions[name] || 0);
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
- _whaDbg('[DBG] SEND appStateSync IQ id=' + id + ' collections=' + [...collections].join(','));
1260
- this._socket.sendNode(new BinaryNode('iq', {
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', {}, collectionNodes)]));
1266
-
1267
- // When the server replies, update our version numbers so future syncs
1268
- // request incremental patches rather than a full snapshot.
1269
- this._pendingIqs.set(id, (resp) => {
1270
- if (!resp || !Array.isArray(resp.content)) return;
1271
- const syncNode = findChild(resp, 'sync');
1272
- if (!syncNode || !Array.isArray(syncNode.content)) return;
1273
- for (const colNode of syncNode.content) {
1274
- if (!colNode || colNode.description !== 'collection') continue;
1275
- const colAttrs = colNode.attrs || {};
1276
- if (colAttrs.name && colAttrs.version !== undefined) {
1277
- this._appStateVersions[colAttrs.name] = parseInt(colAttrs.version, 10) || 0;
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 { jid: pJid, role: pRole };
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
- // Mark entire chat as read — sends IQ to server (xmlns: 'w', count: '-1')
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 id = this._genMsgId();
4401
- const node = new BinaryNode('iq', {
4402
- id,
4403
- to: 's.whatsapp.net',
4404
- type: 'set',
4405
- xmlns: 'w'
4406
- }, [
4407
- new BinaryNode('read', { jid: jidFull, count: '-1', index: '0', owner: 'false' }, null)
4408
- ]);
4409
- const chat = this._getChatState(jid);
4410
- chat.markedAsUnread = false;
4411
- this.emit('chat_read', { jid: jidFull, read: true });
4412
- return this._sendIq(node);
4413
- }
4414
-
4415
- // Mark entire chat as unread — local state only (no WhatsApp server equivalent for mobile)
4416
- markChatUnread(jid) {
4417
- const chat = this._getChatState(makeJid(jid));
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
- // Mute a chat — sends IQ to server (xmlns: 'w:web')
4423
- // durationMs: milliseconds, 0 or negative = mute indefinitely
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 = makeJid(jid);
4426
- const muteEnd = (durationMs && durationMs > 0)
4427
- ? Math.floor((Date.now() + durationMs) / 1000)
4428
- : 0;
4429
- const id = this._genMsgId();
4430
- const attrs = { jid: jidFull, add: '1' };
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 = muteEnd > 0 ? (muteEnd * 1000) : -1;
4441
- this.emit('chat_muted', { jid: jidFull, muted: true, until: chat.muteUntilMs });
4442
- return this._sendIq(node);
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 id = this._genMsgId();
4449
- const node = new BinaryNode('iq', {
4450
- id,
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', { jid: jidFull, add: '0' }, null)]);
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
- // Pin / unpin a chat (mobile: local state)
4555
- pinChat(jid) {
4556
- const chat = this._getChatState(jid);
4557
- chat.pinnedAt = Math.floor(Date.now() / 1000);
4558
- this.emit('chat_pinned', { jid, pinned: true });
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
- unpinChat(jid) {
4562
- const chat = this._getChatState(jid);
4563
- chat.pinnedAt = 0;
4564
- this.emit('chat_pinned', { jid, pinned: false });
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
- // Archive / unarchive a chat (mobile: local state)
4568
- archiveChat(jid) {
4569
- const chat = this._getChatState(jid);
4570
- chat.archived = true;
4571
- this.emit('chat_archived', { jid, archived: true });
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
- unarchiveChat(jid) {
4575
- const chat = this._getChatState(jid);
4576
- chat.archived = false;
4577
- this.emit('chat_archived', { jid, archived: false });
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
- // Star / unstar a message (mobile: local state)
4581
- starMessage(msgId, chatJid) {
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
- unstarMessage(msgId, chatJid) {
4589
- const key = `${chatJid}:${msgId}`;
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
- this._starredMessages.delete(key);
4592
- this.emit('message_starred', { msgId, chatJid, starred: false });
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
- // Built the way whatsmeow's CreateGroup does: every participant is a binary
4712
- // JID (a raw string is dropped for an @lid), a LID participant also carries
4713
- // the phone number the server matches it against, and each carries our privacy
4714
- // token so the invite is not treated as an anonymous reach-out. The setting
4715
- // nodes are siblings of the participants inside <create>.
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
- const jid = makeJid(p);
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
- // whatsmeow sends trigger="1" alongside the expiration; without it the
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
- const baseAttrs = { subject, key: String(Math.floor(Date.now() / 1000)) };
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
- // Same shape whatsmeow's UpdateGroupParticipants builds: binary JIDs, and on
4836
- // an add, the phone number behind a LID plus our privacy token for them.
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) return [];
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 || !Array.isArray(actionNode.content)) return [];
4864
- return actionNode.content
4865
- .filter(n => n && n.description === 'participant' && n.attrs && !n.attrs.error)
4866
- .map(n => n.attrs.jid || n.attrs.value)
4867
- .filter(Boolean);
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 id = this._genMsgId();
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: makeJid(groupJid),
5408
+ to: jidStrToObj(gJid),
4877
5409
  type: 'set'
4878
5410
  }, [
4879
5411
  new BinaryNode('subject', {}, Buffer.from(subject, 'utf8'))
4880
5412
  ]);
4881
- return this._sendIq(node);
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
- // whatsmeow's SetGroupTopic sends prev=<current topic id> so the server can
4888
- // reject an edit made against a description someone else has already replaced.
4889
- // Without it the change is refused. The current id is looked up from the group
4890
- // metadata when the caller does not supply one.
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
- return this._sendIq(node);
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, exactly as
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
- // buf: Buffer with JPEG image data, or null to remove
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
- new BinaryNode('picture', { type: 'image' }, buf || null)
4991
- ]);
4992
- return this._sendIq(node);
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", which is how whatsmeow's
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
- // whatsmeow maps these two specifically: 410 is a revoked link, 406 an
5029
- // invalid one. Anything else is passed through as the server worded it.
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(n => n.attrs && (n.attrs.user || n.attrs.jid))
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: makeJid(groupJid),
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) return [];
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 || !Array.isArray(actionNode.content)) return [];
5180
- return actionNode.content
5181
- .filter(n => n && n.description === 'participant' && !n.attrs.error)
5182
- .map(n => n.attrs && (n.attrs.value || n.attrs.jid))
5183
- .filter(Boolean);
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