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/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);
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
- const lidUser = this._pnToLid && this._pnToLid.get(norm.split('@')[0]);
1908
- return lidUser ? lidUser + '@lid' : norm;
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 { 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
+ };
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
- // 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().
4363
4888
  async markChatRead(jid) {
4364
4889
  const jidFull = makeJid(jid);
4365
- const id = this._genMsgId();
4366
- const node = new BinaryNode('iq', {
4367
- id,
4368
- to: 's.whatsapp.net',
4369
- type: 'set',
4370
- xmlns: 'w'
4371
- }, [
4372
- new BinaryNode('read', { jid: jidFull, count: '-1', index: '0', owner: 'false' }, null)
4373
- ]);
4374
- const chat = this._getChatState(jid);
4375
- chat.markedAsUnread = false;
4376
- this.emit('chat_read', { jid: jidFull, read: true });
4377
- return this._sendIq(node);
4378
- }
4379
-
4380
- // Mark entire chat as unread — local state only (no WhatsApp server equivalent for mobile)
4381
- markChatUnread(jid) {
4382
- const chat = this._getChatState(makeJid(jid));
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
- // Mute a chat — sends IQ to server (xmlns: 'w:web')
4388
- // 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.
4389
4912
  async muteChat(jid, durationMs) {
4390
- const jidFull = makeJid(jid);
4391
- const muteEnd = (durationMs && durationMs > 0)
4392
- ? Math.floor((Date.now() + durationMs) / 1000)
4393
- : 0;
4394
- const id = this._genMsgId();
4395
- const attrs = { jid: jidFull, add: '1' };
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 = muteEnd > 0 ? (muteEnd * 1000) : -1;
4406
- this.emit('chat_muted', { jid: jidFull, muted: true, until: chat.muteUntilMs });
4407
- 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;
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 id = this._genMsgId();
4414
- const node = new BinaryNode('iq', {
4415
- 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(),
4416
4944
  to: 's.whatsapp.net',
4417
4945
  type: 'set',
4418
4946
  xmlns: 'w:web'
4419
- }, [new BinaryNode('mute', { jid: jidFull, add: '0' }, null)]);
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
- // Pin / unpin a chat (mobile: local state)
4520
- pinChat(jid) {
4521
- const chat = this._getChatState(jid);
4522
- chat.pinnedAt = Math.floor(Date.now() / 1000);
4523
- 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;
4524
5066
  }
4525
5067
 
4526
- unpinChat(jid) {
4527
- const chat = this._getChatState(jid);
4528
- chat.pinnedAt = 0;
4529
- 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;
4530
5074
  }
4531
5075
 
4532
- // Archive / unarchive a chat (mobile: local state)
4533
- archiveChat(jid) {
4534
- const chat = this._getChatState(jid);
4535
- chat.archived = true;
4536
- 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;
4537
5082
  }
4538
5083
 
4539
- unarchiveChat(jid) {
4540
- const chat = this._getChatState(jid);
4541
- chat.archived = false;
4542
- 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);
4543
5088
  }
4544
5089
 
4545
- // Star / unstar a message (mobile: local state)
4546
- starMessage(msgId, chatJid) {
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
- unstarMessage(msgId, chatJid) {
4554
- 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));
4555
5098
  if (!this._starredMessages) this._starredMessages = new Set();
4556
- this._starredMessages.delete(key);
4557
- 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;
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
- // Built the way whatsmeow's CreateGroup does: every participant is a binary
4677
- // JID (a raw string is dropped for an @lid), a LID participant also carries
4678
- // the phone number the server matches it against, and each carries our privacy
4679
- // token so the invite is not treated as an anonymous reach-out. The setting
4680
- // 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>.
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
- 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));
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
- // whatsmeow sends trigger="1" alongside the expiration; without it the
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
- 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() };
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
- // Same shape whatsmeow's UpdateGroupParticipants builds: binary JIDs, and on
4801
- // 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.
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) 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
+ }
4827
5391
  const actionNode = findChild(resp, action);
4828
- if (!actionNode || !Array.isArray(actionNode.content)) return [];
4829
- return actionNode.content
4830
- .filter(n => n && n.description === 'participant' && n.attrs && !n.attrs.error)
4831
- .map(n => n.attrs.jid || n.attrs.value)
4832
- .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);
4833
5399
  }
4834
5400
 
4835
5401
  // Change group name/subject
4836
5402
  async changeGroupSubject(groupJid, subject) {
4837
- const id = this._genMsgId();
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: makeJid(groupJid),
5408
+ to: jidStrToObj(gJid),
4842
5409
  type: 'set'
4843
5410
  }, [
4844
5411
  new BinaryNode('subject', {}, Buffer.from(subject, 'utf8'))
4845
5412
  ]);
4846
- return this._sendIq(node);
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
- // whatsmeow's SetGroupTopic sends prev=<current topic id> so the server can
4853
- // reject an edit made against a description someone else has already replaced.
4854
- // Without it the change is refused. The current id is looked up from the group
4855
- // 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.
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
- return this._sendIq(node);
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, exactly as
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
- // 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.
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
- new BinaryNode('picture', { type: 'image' }, buf || null)
4956
- ]);
4957
- 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;
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", which is how whatsmeow's
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
- // whatsmeow maps these two specifically: 410 is a revoked link, 406 an
4994
- // 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.
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(n => n.attrs && (n.attrs.user || n.attrs.jid))
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: makeJid(groupJid),
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) 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
+ }
5143
5742
  const actionNode = findChildDeep(resp, action);
5144
- if (!actionNode || !Array.isArray(actionNode.content)) return [];
5145
- return actionNode.content
5146
- .filter(n => n && n.description === 'participant' && !n.attrs.error)
5147
- .map(n => n.attrs && (n.attrs.value || n.attrs.jid))
5148
- .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;
5149
5897
  }
5150
5898
  }
5151
5899