whalibmob 5.7.3 → 5.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -97,6 +97,7 @@ npm install -g whalibmob
97
97
  - [Pin / Unpin](#pin--unpin)
98
98
  - [Archive / Unarchive](#archive--unarchive)
99
99
  - [Star / Unstar a Message](#star--unstar-a-message-cli)
100
+ - [Sync App State (CLI)](#cli-app-state)
100
101
  - [Disappearing Messages](#cli-disappearing-messages)
101
102
  - [Default Disappearing Timer](#default-disappearing-timer)
102
103
  - [Block / Unblock](#block--unblock)
@@ -118,9 +119,10 @@ npm install -g whalibmob
118
119
  - [List Group Participants](#list-group-participants)
119
120
  - [Pending Join Requests](#pending-join-requests)
120
121
  - [Approve / Reject Join Requests](#approve--reject-join-requests)
122
+ - [Personal Invitations (CLI)](#cli-personal-invitations)
121
123
  - [Group Settings](#group-settings)
122
124
  - [Community Commands](#community-commands-cli)
123
- - [Newsletter / Channel Commands](#newsletter-channel-commands)
125
+ - [Newsletter / Channel Commands](#newsletter--channel-commands)
124
126
  - [Business Profile Command](#business-profile-command-cli)
125
127
  - [Registration Commands (in-shell)](#registration-commands-in-shell)
126
128
  - [Connection Commands (in-shell)](#connection-commands-in-shell)
@@ -201,6 +203,7 @@ npm install -g whalibmob
201
203
  - [Mark a Chat Read / Unread](#mark-a-chat-read--unread)
202
204
  - [Pin / Unpin a Chat](#pin--unpin-a-chat)
203
205
  - [Star / Unstar a Message](#star--unstar-a-message)
206
+ - [Reading Changes Made Elsewhere](#app-state-sync)
204
207
  - [Disappearing Messages](#disappearing-messages)
205
208
  - [User Queries](#user-queries)
206
209
  - [Check If a Number Has WhatsApp](#check-if-a-number-has-whatsapp)
@@ -244,6 +247,7 @@ npm install -g whalibmob
244
247
  - [Query Metadata](#query-metadata)
245
248
  - [Get Request Join List](#get-request-join-list)
246
249
  - [Approve / Reject Request Join](#approve--reject-request-join)
250
+ - [Personal Invitations](#personal-invitations)
247
251
  - [Toggle Ephemeral in Group](#toggle-ephemeral-in-group)
248
252
  - [WhatsApp IDs](#whatsapp-ids)
249
253
  - [Transport](#transport)
@@ -549,6 +553,8 @@ History arrives in chunks over the first minute or so after linking, largest fir
549
553
  | `<phone>.web.signal.json` | Signal sessions and pre-keys for the linked device |
550
554
  | `<phone>.web.sk.json` | group SenderKeys |
551
555
  | `<phone>.web.tctoken.json` | privacy tokens |
556
+ | `<phone>.web.appState.json` | app-state version and hash per collection |
557
+ | `<phone>.appStateKeys.json` | app-state sync keys shared by the phone |
552
558
  | `<phone>.web.history.json` | synced chats, contacts, push names, LID↔PN mappings |
553
559
  | `<phone>.web.messages.json` | flat map of message id → message metadata |
554
560
 
@@ -988,7 +994,7 @@ try {
988
994
 
989
995
  ### `initAuthCreds`
990
996
 
991
- Creates a fresh credential store for the given phone number. Functionally equivalent to `createNewStore` but also initialises the Baileys-compatible extra fields that the library expects for account sync: `nextPreKeyId`, `firstUnuploadedPreKeyId`, `accountSyncCounter`, `accountSettings`, and `advSecretKey`.
997
+ Creates a fresh credential store for the given phone number. Functionally equivalent to `createNewStore` but also initialises the extra fields the library expects for account sync: `nextPreKeyId`, `firstUnuploadedPreKeyId`, `accountSyncCounter`, `accountSettings`, and `advSecretKey`.
992
998
 
993
999
  ```js
994
1000
  const { initAuthCreds, saveStore } = require('whalibmob')
@@ -1093,13 +1099,24 @@ connect()
1093
1099
  | `group_update` | `{ type, groupJid, actor, participants, subject, timestamp }` | Member added / removed / promoted / demoted, subject or settings changed |
1094
1100
  | `notification` | node object | Group or contact update notification |
1095
1101
  | `call` | `{ from }` | Incoming call event |
1096
- | `chat_read` | `{ jid, read }` | Chat marked read (`read: true`) or unread (`read: false`) |
1097
- | `chat_muted` | `{ jid, muted, until }` | Chat muted or unmuted; `until` is epoch ms (−1 = indefinite) |
1098
- | `chat_pinned` | `{ jid, pinned }` | Chat pinned or unpinned |
1102
+ | `chat_read` | `{ jid, read, remote?, synced? }` | Chat marked read (`read: true`) or unread (`read: false`) |
1103
+ | `chat_muted` | `{ jid, muted, until, remote?, synced? }` | Chat muted or unmuted; `until` is epoch ms (−1 = indefinite) |
1104
+ | `chat_pinned` | `{ jid, pinned, remote?, synced? }` | Chat pinned or unpinned |
1099
1105
  | `blocklist` | `{ action, dhash, prevDhash, changes }` | Block list changed on another device; `changes` is `[{ jid, action }]` |
1100
1106
  | `privacy_settings` | `{ changes, settings }` | Privacy settings changed on another device |
1101
- | `chat_archived` | `{ jid, archived }` | Chat archived or unarchived |
1102
- | `message_starred` | `{ msgId, chatJid, starred }` | Message starred or unstarred |
1107
+ | `chat_archived` | `{ jid, archived, remote?, synced? }` | Chat archived or unarchived |
1108
+ | `message_starred` | `{ msgId, chatJid, starred, fromMe?, remote?, synced? }` | Message starred or unstarred |
1109
+ | `chat_removed` | `{ jid, kind, remote }` | A chat was cleared or deleted on another device |
1110
+ | `contact_update` | `{ jid, name, firstName, lid, username, removed, remote }` | A contact was renamed or removed elsewhere |
1111
+ | `push_name_update` | `{ name, remote }` | Your own display name changed on another device |
1112
+ | `app_state_sync` | `{ collections, applied }` | An app-state sync finished; see [Reading Changes Made Elsewhere](#app-state-sync) |
1113
+ | `app_state_mutation` | `{ collection, index, action, removed }` | An app-state change this library does not model |
1114
+ | `app_state_key_missing` | `{ collection, keyId }` | App state cannot be read until your phone shares this key |
1115
+
1116
+ `remote: true` on a chat event means the change was made on your phone or another
1117
+ linked device rather than by this session. Your own calls carry `synced` instead,
1118
+ saying whether the change reached app state — see
1119
+ [Modifying Chats](#modifying-chats).
1103
1120
  | `stream_error` | `{ reason }` | Server sent a fatal stream error |
1104
1121
  | `decrypt_error` | `{ id, from, participant, err }` | Failed to decrypt an incoming message |
1105
1122
  | `session_refresh` | `{ node }` | Late re-authentication success; Signal session refreshed |
@@ -1161,6 +1178,10 @@ The `decoded` object shape per message type:
1161
1178
  // Contact (vCard)
1162
1179
  { type: 'contact', displayName: string, vcard: string }
1163
1180
 
1181
+ // Personal invitation into a group — see Personal Invitations
1182
+ { type: 'groupInvite', groupJid: string, inviteCode: string, inviteExpiration: number,
1183
+ groupName: string, jpegThumbnail: Buffer|null, caption: string, isCommunity: boolean }
1184
+
1164
1185
  // Protocol (revoke, ephemeral, etc.)
1165
1186
  { type: 'protocol', subtype: string }
1166
1187
  ```
@@ -1269,7 +1290,8 @@ The library automatically writes these files to `sessionDir` per account. You do
1269
1290
  |---|---|
1270
1291
  | `<phone>.history.json` | Chats, contacts, push names, LID↔PN mappings, tcTokens |
1271
1292
  | `<phone>.messages.json` | Flat map of `msgId → message metadata` |
1272
- | `<phone>.appStateKeys.json` | App-state sync keys (used for app-state patch decryption) |
1293
+ | `<phone>.appStateKeys.json` | App-state sync keys, shared by your primary device |
1294
+ | `<phone>.appState.json` | Per-collection app-state version, hash and index map |
1273
1295
  | `<phone>.tctoken.json` | Trusted-contact token store (tcToken per contact JID) |
1274
1296
 
1275
1297
  ### Reading the History Store
@@ -1380,6 +1402,9 @@ Token storage uses the **LID JID** of the contact (e.g. `112345678901234@lid`) a
1380
1402
  | Persist chats / contacts / push names | ✅ | Written to `<phone>.history.json` |
1381
1403
  | Persist message metadata | ✅ | Written to `<phone>.messages.json` |
1382
1404
  | Persist app-state sync keys | ✅ | Written to `<phone>.appStateKeys.json` |
1405
+ | Sync app state when the server says it changed | ✅ | Pins, archives, mutes, stars, contact names |
1406
+ | Verify app-state MACs and LT hash | ✅ | A collection that drifts is re-read from a snapshot |
1407
+ | Persist app-state versions across restarts | ✅ | Written to `<phone>.appState.json` |
1383
1408
  | Seed tcTokens into memory on connect | ✅ | Prevents error 463 on first send after reconnect |
1384
1409
  | Attach tcToken to every outbound DM | ✅ | |
1385
1410
  | Issue fresh tcTokens after each send | ✅ | Once per 7-day bucket per contact |
@@ -1422,115 +1447,64 @@ await client.init('919634847671')
1422
1447
 
1423
1448
  ## Receiving Media
1424
1449
 
1425
- When a media message arrives, `msg.decoded` contains a CDN `url` and a `mediaKey`.
1426
- The actual file is stored encrypted on WhatsApp's CDN and must be downloaded and decrypted.
1427
-
1428
- **Decryption uses two steps:**
1429
- 1. HKDF-SHA256 expands `mediaKey` into IV, cipher key, and MAC key.
1430
- 2. AES-256-CBC decrypts the ciphertext; a 10-byte HMAC-SHA256 MAC is verified first.
1450
+ When a media message arrives, `msg.decoded` carries the CDN location and the
1451
+ `mediaKey` the file is encrypted under. `client.downloadMedia()` fetches it and
1452
+ hands back the plaintext bytes:
1431
1453
 
1432
1454
  ```js
1433
- const crypto = require('crypto')
1434
- const https = require('https')
1435
- const http = require('http')
1436
- const fs = require('fs')
1437
- const path = require('path')
1438
-
1439
- // HKDF info strings per media type
1440
- const MEDIA_HKDF_INFO = {
1441
- image: 'WhatsApp Image Keys',
1442
- video: 'WhatsApp Video Keys',
1443
- audio: 'WhatsApp Audio Keys',
1444
- voice: 'WhatsApp Audio Keys',
1445
- document: 'WhatsApp Document Keys',
1446
- sticker: 'WhatsApp Image Keys',
1447
- }
1455
+ const fs = require('fs')
1448
1456
 
1449
- function deriveMediaKeys(mediaKey, mediaType) {
1450
- const info = Buffer.from(MEDIA_HKDF_INFO[mediaType] || 'WhatsApp Image Keys', 'utf8')
1451
- const expanded = Buffer.from(crypto.hkdfSync('sha256', mediaKey, Buffer.alloc(0), info, 112))
1452
- return {
1453
- iv: expanded.slice(0, 16),
1454
- cipherKey: expanded.slice(16, 48),
1455
- macKey: expanded.slice(48, 80),
1456
- }
1457
- }
1457
+ const EXT = { image: '.jpg', video: '.mp4', audio: '.ogg', voice: '.ogg',
1458
+ sticker: '.webp', document: '' }
1458
1459
 
1459
- function decryptMedia(encrypted, mediaKey, mediaType) {
1460
- const { iv, cipherKey, macKey } = deriveMediaKeys(mediaKey, mediaType)
1461
- const ciphertext = encrypted.slice(0, -10)
1462
- const fileMac = encrypted.slice(-10)
1463
-
1464
- // Verify MAC
1465
- const hmac = crypto.createHmac('sha256', macKey)
1466
- hmac.update(iv)
1467
- hmac.update(ciphertext)
1468
- const computed = hmac.digest().slice(0, 10)
1469
- if (!computed.equals(fileMac)) throw new Error('MAC mismatch — corrupt file or wrong key')
1470
-
1471
- // Decrypt
1472
- const decipher = crypto.createDecipheriv('aes-256-cbc', cipherKey, iv)
1473
- return Buffer.concat([decipher.update(ciphertext), decipher.final()])
1474
- }
1460
+ client.on('message', async (msg) => {
1461
+ const d = msg.decoded
1462
+ if (!d || !d.mediaKey) return
1475
1463
 
1476
- function downloadBuffer(url) {
1477
- return new Promise((resolve, reject) => {
1478
- const lib = url.startsWith('https') ? https : http
1479
- const req = lib.get(url, { headers: { 'User-Agent': 'WhatsApp/2.26.7.75 A' } }, res => {
1480
- if (res.statusCode !== 200) { res.resume(); return reject(new Error('HTTP ' + res.statusCode)) }
1481
- const chunks = []
1482
- res.on('data', c => chunks.push(c))
1483
- res.on('end', () => resolve(Buffer.concat(chunks)))
1484
- res.on('error', reject)
1485
- })
1486
- req.on('error', reject)
1487
- req.setTimeout(30000, () => { req.destroy(); reject(new Error('timeout')) })
1488
- })
1489
- }
1464
+ try {
1465
+ const bytes = await client.downloadMedia(d)
1466
+ const name = d.fileName || (msg.id + (EXT[d.type] || ''))
1467
+ fs.writeFileSync(name, bytes)
1468
+ console.log('saved', d.type, 'to', name)
1469
+ } catch (e) {
1470
+ console.error('media download failed:', e.message)
1471
+ }
1472
+ })
1473
+ ```
1490
1474
 
1491
- async function downloadAndDecrypt(msgId, mediaType, url, mediaKey, opts) {
1492
- const extensions = { image: '.jpg', video: '.mp4', audio: '.ogg', voice: '.ogg',
1493
- document: '', sticker: '.webp' }
1494
- let ext = extensions[mediaType] || ''
1495
- if (mediaType === 'document' && opts && opts.fileName) ext = path.extname(opts.fileName) || '.bin'
1475
+ It works the same in both modes, and that is the point: the CDN applies a
1476
+ browser check on the way **down** as well as on the way up, so a companion has to
1477
+ identify itself as one here too. Doing this by hand means knowing that, and
1478
+ knowing which HKDF key name each media type derives from — a voice note derives
1479
+ from the PTT keys and a GIF from the video ones, not from their own. Get either
1480
+ wrong and the download is refused or the decryption yields garbage.
1496
1481
 
1497
- const encrypted = await downloadBuffer(url)
1498
- const decrypted = decryptMedia(encrypted, mediaKey, mediaType)
1482
+ The whole message object is accepted as well as its `decoded` half, so
1483
+ `client.downloadMedia(msg)` does the same thing.
1499
1484
 
1500
- const outPath = path.join('./media', msgId + ext)
1501
- fs.mkdirSync('./media', { recursive: true })
1502
- fs.writeFileSync(outPath, decrypted)
1503
- return outPath
1504
- }
1505
- ```
1485
+ **Verifying the file**
1506
1486
 
1507
- **Using it in the `message` event:**
1487
+ Pass `{ verify: true }` to check the download against the message's
1488
+ `fileEncSha256` before decrypting it. The MAC already proves the plaintext was
1489
+ not tampered with; this catches a truncated or substituted download earlier, and
1490
+ names that failure separately from a decryption one.
1508
1491
 
1509
1492
  ```js
1510
- const MEDIA_TYPES = new Set(['image', 'video', 'audio', 'voice', 'document', 'sticker'])
1493
+ const bytes = await client.downloadMedia(d, { verify: true })
1494
+ ```
1511
1495
 
1512
- client.on('message', async msg => {
1513
- const d = msg.decoded
1514
- if (!d) return
1496
+ **What happens underneath**
1515
1497
 
1516
- // Resolve the real phone JID (works even with LID from-fields)
1517
- const spn = msg.node && msg.node.attrs && msg.node.attrs.sender_pn
1518
- const senderJid = spn ? (spn.user + '@s.whatsapp.net') : msg.from
1498
+ 1. The encrypted blob is fetched from `url`, or from `directPath` when the
1499
+ message carries no absolute URL.
1500
+ 2. HKDF-SHA256 expands `mediaKey` into an IV, a cipher key and a MAC key, using
1501
+ the info string for that media type.
1502
+ 3. The trailing 10-byte HMAC-SHA256 is verified, then AES-256-CBC decrypts the
1503
+ rest.
1519
1504
 
1520
- if (d.type === 'text') {
1521
- console.log('text from', senderJid, ':', d.text)
1522
- }
1523
-
1524
- if (MEDIA_TYPES.has(d.type) && d.url && d.mediaKey) {
1525
- try {
1526
- const filePath = await downloadAndDecrypt(msg.id, d.type, d.url, d.mediaKey, { fileName: d.fileName })
1527
- console.log('saved', d.type, 'to', filePath)
1528
- } catch (e) {
1529
- console.error('media download failed:', e.message)
1530
- }
1531
- }
1532
- })
1533
- ```
1505
+ `downloadMedia` throws with the reason rather than returning empty: a message
1506
+ with no media, no CDN location, an unsupported type, or a file that does not
1507
+ match the message all say so.
1534
1508
 
1535
1509
  ## Sending Messages
1536
1510
 
@@ -1850,40 +1824,156 @@ client.setChatPresence('919634847671@s.whatsapp.net', 'paused') // stopped
1850
1824
 
1851
1825
  ## Modifying Chats
1852
1826
 
1827
+ Pinning, archiving, muting, marking read and starring are **app state**. That is
1828
+ WhatsApp's own synchronised settings store — the same one your phone writes to —
1829
+ so a change made here shows up on the phone and on every other linked device,
1830
+ and survives reinstalling.
1831
+
1832
+ Each of these is `async`, sends a patch, and waits for the server to accept it.
1833
+ The local view only moves once the change is actually stored, and they throw if
1834
+ the server refuses.
1835
+
1836
+ They return a boolean: **whether the change reached app state**, and so whether
1837
+ other devices will see it.
1838
+
1839
+ ```js
1840
+ const synced = await client.pinChat('919634847671@s.whatsapp.net')
1841
+ if (!synced) console.log('pinned here, but your phone will not know')
1842
+ ```
1843
+
1844
+ > [!IMPORTANT]
1845
+ > **App state needs a key, and where that key comes from depends on how you
1846
+ > connected.**
1847
+ >
1848
+ > A **linked session** (pairing code) is a companion. Your phone shares an app
1849
+ > state key with it automatically, shortly after linking — so everything on this
1850
+ > page works, in both directions.
1851
+ >
1852
+ > An **SMS session** *is* the primary device. Nobody shares a key with it,
1853
+ > because it is the device that would create one. Unless you have linked a
1854
+ > companion to it, there is no app state to read or write.
1855
+ >
1856
+ > Check with `client.canSyncAppState()`.
1857
+ >
1858
+ > When there is no key, these calls **do not throw**. `muteChat`, `unmuteChat`
1859
+ > and `markChatRead` fall back to the request a primary device sends for itself,
1860
+ > which is what this library did before app state existed. `pinChat`,
1861
+ > `archiveChat` and `starMessage` have no such request, so they update this
1862
+ > session only. Either way the return value is `false`, which is how you tell.
1863
+
1853
1864
  ### Archive / Unarchive a Chat
1854
1865
 
1855
1866
  ```js
1856
- client.archiveChat('919634847671@s.whatsapp.net')
1857
- client.unarchiveChat('919634847671@s.whatsapp.net')
1867
+ await client.archiveChat('919634847671@s.whatsapp.net')
1868
+ await client.unarchiveChat('919634847671@s.whatsapp.net')
1858
1869
  ```
1859
1870
 
1860
1871
  ### Mute / Unmute a Chat
1861
1872
 
1862
1873
  ```js
1863
- await client.muteChat('919634847671@s.whatsapp.net', 8 * 60 * 60 * 1000) // mute for 8 hours (ms)
1864
- await client.muteChat('919634847671@s.whatsapp.net', 0) // mute indefinitely
1874
+ await client.muteChat('919634847671@s.whatsapp.net', 8 * 60 * 60 * 1000) // 8 hours
1875
+ await client.muteChat('919634847671@s.whatsapp.net', 0) // until unmuted
1865
1876
  await client.unmuteChat('919634847671@s.whatsapp.net')
1866
1877
  ```
1867
1878
 
1868
1879
  ### Mark a Chat Read / Unread
1869
1880
 
1881
+ This is the chat's own unread badge. To send read receipts (blue ticks) for
1882
+ particular messages, use `markRead()` instead.
1883
+
1870
1884
  ```js
1871
- await client.markChatRead('919634847671@s.whatsapp.net') // sends IQ to server
1872
- client.markChatUnread('919634847671@s.whatsapp.net') // local state only
1885
+ await client.markChatRead('919634847671@s.whatsapp.net')
1886
+ await client.markChatUnread('919634847671@s.whatsapp.net')
1873
1887
  ```
1874
1888
 
1875
1889
  ### Pin / Unpin a Chat
1876
1890
 
1877
1891
  ```js
1878
- client.pinChat('919634847671@s.whatsapp.net')
1879
- client.unpinChat('919634847671@s.whatsapp.net')
1892
+ await client.pinChat('919634847671@s.whatsapp.net')
1893
+ await client.unpinChat('919634847671@s.whatsapp.net')
1880
1894
  ```
1881
1895
 
1882
1896
  ### Star / Unstar a Message
1883
1897
 
1898
+ The third argument says whether the message being starred is one you sent. It is
1899
+ part of how the star is filed, so getting it wrong stars a different message.
1900
+
1884
1901
  ```js
1885
- client.starMessage('MSGID123', '919634847671@s.whatsapp.net')
1886
- client.unstarMessage('MSGID123', '919634847671@s.whatsapp.net')
1902
+ await client.starMessage('MSGID123', '919634847671@s.whatsapp.net', true) // yours
1903
+ await client.unstarMessage('MSGID123', '919634847671@s.whatsapp.net', false) // theirs
1904
+ ```
1905
+
1906
+ <a id="app-state-sync"></a>
1907
+
1908
+ ### Reading Changes Made Elsewhere
1909
+
1910
+ The traffic runs both ways. When you pin a chat on your phone, mute a group from
1911
+ another linked device, or rename a contact, that change is waiting in app state
1912
+ for this session to pick up.
1913
+
1914
+ `syncAppState()` fetches it. It is called for you whenever the server says
1915
+ something has moved — so with a listener attached you generally never need to
1916
+ call it by hand. On an SMS session with no companions linked there is nothing to
1917
+ fetch, and it reports `waitingForKeys` instead.
1918
+
1919
+ ```js
1920
+ client.on('chat_pinned', (u) => u.remote && console.log('pinned elsewhere:', u.jid))
1921
+ client.on('chat_archived', (u) => u.remote && console.log('archived elsewhere:', u.jid))
1922
+ client.on('chat_muted', (u) => u.remote && console.log('muted elsewhere:', u.jid, u.until))
1923
+ client.on('chat_read', (u) => u.remote && console.log('read elsewhere:', u.jid))
1924
+ client.on('message_starred', (u) => u.remote && console.log('starred elsewhere:', u.msgId))
1925
+ client.on('contact_update', (u) => console.log('contact renamed:', u.jid, u.name))
1926
+ client.on('push_name_update',(u) => console.log('your display name is now', u.name))
1927
+ ```
1928
+
1929
+ `remote: true` marks a change as somebody else's doing. Your own calls emit the
1930
+ same events without it — they carry `synced` instead — so a listener can tell the
1931
+ two apart and avoid echoing a change back where it came from.
1932
+
1933
+ To pull on demand:
1934
+
1935
+ ```js
1936
+ // everything
1937
+ const r = await client.syncAppState()
1938
+ console.log(r.applied, 'change(s)')
1939
+
1940
+ // or just one part of it
1941
+ await client.syncAppState(['regular_low'])
1942
+
1943
+ // re-read everything from scratch, discarding what we hold
1944
+ await client.syncAppState(null, { snapshot: true })
1945
+ ```
1946
+
1947
+ The result reports each collection separately:
1948
+
1949
+ ```js
1950
+ {
1951
+ applied: 3,
1952
+ collections: {
1953
+ regular_low: { version: 41, applied: 3, skipped: 0, snapshot: false, macOk: true }
1954
+ }
1955
+ }
1956
+ ```
1957
+
1958
+ The five collections are `critical_block`, `critical_unblock_low`,
1959
+ `regular_high`, `regular_low` and `regular`. Which one a setting lives in is
1960
+ WhatsApp's choice, not yours — the methods above already file each change where
1961
+ it belongs.
1962
+
1963
+ **When it repairs itself.** Each collection carries a running hash that has to
1964
+ keep agreeing with the server's. If it stops — a patch went missing, or one
1965
+ could not be decrypted — the incremental history is no longer trustworthy, so
1966
+ that collection is thrown away and re-read whole. This happens on its own, once
1967
+ per sync, and shows up as `snapshot: true` in the result.
1968
+
1969
+ **Events for anything not modelled here.** WhatsApp tracks more in app state than
1970
+ this library turns into methods. Rather than dropping those, they are emitted
1971
+ raw, so it is at least visible that something happened:
1972
+
1973
+ ```js
1974
+ client.on('app_state_mutation', ({ collection, index, action, removed }) => {
1975
+ console.log('unhandled app state change', index)
1976
+ })
1887
1977
  ```
1888
1978
 
1889
1979
  ### Disappearing Messages
@@ -1974,9 +2064,17 @@ const fs = require('fs')
1974
2064
  await client.changeProfilePicture(fs.readFileSync('./avatar.jpg'))
1975
2065
 
1976
2066
  // change a group's picture (you must be admin)
1977
- await client.changeGroupPicture('120363000000000000@g.us', fs.readFileSync('./group.jpg'))
2067
+ // returns the new picture id, or 'remove' when the picture was taken down
2068
+ const picId = await client.changeGroupPicture('120363000000000000@g.us',
2069
+ fs.readFileSync('./group.jpg'))
2070
+
2071
+ // pass null to remove the current picture
2072
+ await client.changeGroupPicture('120363000000000000@g.us', null)
1978
2073
  ```
1979
2074
 
2075
+ `changeGroupPicture` throws when the server refuses — `406` for an image that is
2076
+ not a JPEG it will take, `403` when you are not an admin of that group.
2077
+
1980
2078
  ## Privacy
1981
2079
 
1982
2080
  ### Block / Unblock User
@@ -2063,15 +2161,55 @@ console.log('members', group.participants.map(p => p.jid))
2063
2161
 
2064
2162
  ### Add / Remove or Demote / Promote
2065
2163
 
2164
+ Each of these returns one result per participant — the ones that went through
2165
+ and the ones that did not. The server decides every participant separately, so a
2166
+ call that half worked tells you which half and why.
2167
+
2066
2168
  ```js
2067
2169
  const groupJid = '120363000000000000@g.us'
2068
2170
 
2069
- await client.addGroupParticipants(groupJid, ['919634847671@s.whatsapp.net'])
2171
+ const results = await client.addGroupParticipants(groupJid, [
2172
+ '919634847671@s.whatsapp.net',
2173
+ '12345678901@s.whatsapp.net'
2174
+ ])
2175
+
2176
+ for (const r of results) {
2177
+ if (r.ok) console.log('added', r.jid)
2178
+ else console.log('failed', r.jid, r.status, r.needsInvite ? '(invite instead)' : '')
2179
+ }
2180
+
2070
2181
  await client.removeGroupParticipants(groupJid, ['919634847671@s.whatsapp.net'])
2071
2182
  await client.promoteGroupParticipants(groupJid, ['919634847671@s.whatsapp.net'])
2072
2183
  await client.demoteGroupParticipants(groupJid, ['919634847671@s.whatsapp.net'])
2073
2184
  ```
2074
2185
 
2186
+ Each result looks like this:
2187
+
2188
+ ```js
2189
+ {
2190
+ jid: '919634847671@s.whatsapp.net',
2191
+ status: '403', // '200' when the action went through
2192
+ error: 403, // null on success
2193
+ ok: false, // getter: error == null
2194
+ admin: null, // 'admin' | 'superadmin' | null
2195
+ phoneNumber: '919634847671@s.whatsapp.net',
2196
+ lid: '112713111982325@lid', // when the server told us one
2197
+ displayName: null,
2198
+ // Only on a refused add: the code a personal invitation is built from.
2199
+ addRequest: { code: 'AbCdEfGh', expiration: 1790000000 },
2200
+ needsInvite: true // getter: true when addRequest holds a code
2201
+ }
2202
+ ```
2203
+
2204
+ The common error codes are `403` (their privacy settings do not allow it),
2205
+ `404` (not on WhatsApp), `408` (not a member), `409` (already a member) and
2206
+ `401` (you are not allowed to do this).
2207
+
2208
+ > [!TIP]
2209
+ > A result object stringifies to its JID, so `results.join(', ')` and
2210
+ > `String(results[0])` read exactly as they did when these methods returned a
2211
+ > plain list of JID strings.
2212
+
2075
2213
  ### Change Subject
2076
2214
 
2077
2215
  ```js
@@ -2165,27 +2303,87 @@ for (const g of groups) {
2165
2303
 
2166
2304
  ```js
2167
2305
  const meta = await client.getGroupMetadata('120363000000000000@g.us')
2168
- // returns: { jid, subject, creation, creator, subjectTime, subjectBy,
2169
- // description, ephemeral, onlyAdminsSend, onlyAdminsEdit, participants[] }
2170
2306
  console.log(meta.subject, meta.participants.length + ' members')
2171
2307
  ```
2172
2308
 
2309
+ ```js
2310
+ {
2311
+ jid: '120363000000000000@g.us',
2312
+ subject: 'My Group',
2313
+ size: 57, // the server's own count
2314
+ creation: 1705315800,
2315
+ creator: '919634847671@s.whatsapp.net',
2316
+ subjectTime: 1705315900,
2317
+ subjectBy: '919634847671@s.whatsapp.net',
2318
+ description: 'Group description here',
2319
+ descriptionId: 'DESC1', // echoed back as `prev` on the next edit
2320
+ descriptionBy: '919634847671@s.whatsapp.net',
2321
+ descriptionByPn: '919634847671@s.whatsapp.net',
2322
+ descriptionTime: 1705315950,
2323
+ ephemeral: 86400, // 0 when disappearing messages are off
2324
+ onlyAdminsSend: false,
2325
+ onlyAdminsEdit: false,
2326
+ joinApprovalMode: true, // new members need an admin's approval
2327
+ memberAddMode: 'admin_add', // 'admin_add' | 'all_member_add' | null
2328
+ isCommunity: false,
2329
+ isCommunityAnnounce: false,
2330
+ defaultMembershipApprovalMode: null, // communities only
2331
+ linkedParent: null, // the community this group belongs to
2332
+ isIncognito: false, // members' phone numbers hidden from each other
2333
+ isSuspended: false, // the group has been taken down
2334
+ notify: 'My Group',
2335
+ creatorPn: '919634847671@s.whatsapp.net',
2336
+ creatorUsername: null,
2337
+ creatorCountry: 'IN',
2338
+ subjectByPn: '919634847671@s.whatsapp.net',
2339
+ subjectByUsername: null,
2340
+ participantVersion: 'PV1', // bumped when the member list changes
2341
+ announceVersion: 'AV1', // bumped when the announce flag changes
2342
+ addressingMode: 'lid', // 'lid' | 'pn'
2343
+ participants: [
2344
+ {
2345
+ jid: '112713111982325@lid',
2346
+ role: 'admin', // 'admin' | 'superadmin' | 'member'
2347
+ isAdmin: true,
2348
+ isSuperAdmin: false,
2349
+ phoneNumber: '919634847671@s.whatsapp.net',
2350
+ lid: '112713111982325@lid',
2351
+ displayName: null,
2352
+ username: null
2353
+ }
2354
+ ]
2355
+ }
2356
+ ```
2357
+
2358
+ Both addresses are filled in on every participant whichever way round the server
2359
+ named them, so you never have to resolve a LID by hand to know who somebody is.
2360
+
2173
2361
  ### Get Request Join List
2174
2362
 
2175
2363
  ```js
2176
2364
  const pending = await client.queryGroupPendingParticipants('120363000000000000@g.us')
2177
- console.log(pending)
2365
+
2366
+ for (const r of pending) {
2367
+ console.log(r.jid, 'asked at', new Date(r.requestedAt * 1000).toISOString())
2368
+ }
2178
2369
  ```
2179
2370
 
2371
+ Each entry is `{ jid, requestedAt }` — `requestedAt` is unix seconds, or `0` when
2372
+ the server did not say. Like the participant results, an entry stringifies to its
2373
+ JID.
2374
+
2180
2375
  ### Approve / Reject Request Join
2181
2376
 
2182
- The second parameter is a boolean: `true` to approve, `false` to reject.
2377
+ The second parameter is a boolean: `true` to approve, `false` to reject. The
2378
+ return value is the same list of per-participant results the add/remove calls
2379
+ give you.
2183
2380
 
2184
2381
  ```js
2185
2382
  // approve join requests
2186
- await client.approveGroupParticipants('120363000000000000@g.us', true, [
2383
+ const done = await client.approveGroupParticipants('120363000000000000@g.us', true, [
2187
2384
  '919634847671@s.whatsapp.net'
2188
2385
  ])
2386
+ console.log(done.filter(r => !r.ok)) // whoever could not be let in, and why
2189
2387
 
2190
2388
  // reject join requests
2191
2389
  await client.approveGroupParticipants('120363000000000000@g.us', false, [
@@ -2193,6 +2391,82 @@ await client.approveGroupParticipants('120363000000000000@g.us', false, [
2193
2391
  ])
2194
2392
  ```
2195
2393
 
2394
+ ### Personal Invitations
2395
+
2396
+ An invite link is public — anyone holding it can join. A personal invitation is
2397
+ the other kind: minted for one named person, and the only way into a group for
2398
+ somebody whose privacy settings stop them from being added outright.
2399
+
2400
+ The whole flow starts with a refused add. When the server turns a participant
2401
+ away for that reason it hands back a code, which travels to them as a message
2402
+ they can tap.
2403
+
2404
+ ```js
2405
+ const groupJid = '120363000000000000@g.us'
2406
+
2407
+ // add whoever can be added, and invite whoever cannot — in one call
2408
+ const results = await client.addGroupParticipantsOrInvite(groupJid, [
2409
+ '919634847671@s.whatsapp.net',
2410
+ '12345678901@s.whatsapp.net'
2411
+ ])
2412
+
2413
+ for (const r of results) {
2414
+ if (r.ok) console.log('added', r.jid)
2415
+ else if (r.invited) console.log('invited', r.jid)
2416
+ else console.log('failed', r.jid, r.status, r.inviteError || '')
2417
+ }
2418
+ ```
2419
+
2420
+ Or drive it yourself, if you want to decide who gets an invitation:
2421
+
2422
+ ```js
2423
+ const results = await client.addGroupParticipants(groupJid, [
2424
+ '919634847671@s.whatsapp.net'
2425
+ ])
2426
+
2427
+ for (const r of results.filter(x => x.needsInvite)) {
2428
+ await client.sendGroupInvite(r.jid, groupJid,
2429
+ r.addRequest.code, r.addRequest.expiration,
2430
+ { caption: 'Come join us' })
2431
+ }
2432
+ ```
2433
+
2434
+ `sendGroupInvite(to, groupJid, code, expiration, opts)` accepts
2435
+ `{ groupName, caption, jpegThumbnail, isCommunity, id, contextInfo }`. The group
2436
+ name is filled in from the group's own metadata when you do not supply one.
2437
+
2438
+ On the receiving side, an invitation arrives as an ordinary `message` event whose
2439
+ `decoded.type` is `'groupInvite'`:
2440
+
2441
+ ```js
2442
+ client.on('message', async (msg) => {
2443
+ const d = msg.decoded
2444
+ if (!d || d.type !== 'groupInvite') return
2445
+
2446
+ // look before you leap — this does not join anything
2447
+ const info = await client.queryGroupInviteMessageInfo(
2448
+ d.groupJid, msg.participant || msg.from, d.inviteCode, d.inviteExpiration)
2449
+ console.log(info.subject, info.size + ' members')
2450
+
2451
+ // and accept it
2452
+ const jid = await client.acceptGroupInviteMessage(
2453
+ d.groupJid, msg.participant || msg.from, d.inviteCode, d.inviteExpiration)
2454
+ console.log('joined', jid)
2455
+ })
2456
+ ```
2457
+
2458
+ A decoded invitation carries `{ groupJid, inviteCode, inviteExpiration,
2459
+ groupName, jpegThumbnail, caption, isCommunity }`.
2460
+
2461
+ To withdraw an invitation you sent before it is used:
2462
+
2463
+ ```js
2464
+ await client.revokeGroupInviteForParticipant(groupJid, '919634847671@s.whatsapp.net')
2465
+ ```
2466
+
2467
+ An expired or already-spent invitation throws rather than resolving to nothing,
2468
+ so the two cases are easy to tell apart.
2469
+
2196
2470
  ### Toggle Ephemeral in Group
2197
2471
 
2198
2472
  ```js
@@ -2780,6 +3054,19 @@ wa> /contact about 919634847671@s.whatsapp.net
2780
3054
 
2781
3055
  ### Chat Management Commands
2782
3056
 
3057
+ On a **linked** (pairing-code) session these write to app state, so a change here
3058
+ reaches your phone and every other linked device.
3059
+
3060
+ On an **SMS** session this device is the primary and there is no app state key
3061
+ unless you have linked a companion to it. `/mute`, `/unmute` and `/read` still
3062
+ send the request a primary makes for itself; `/pin`, `/archive` and `/star`
3063
+ update this session only. The command says which happened:
3064
+
3065
+ ```sh
3066
+ wa> /pin 919634847671@s.whatsapp.net
3067
+ pinned (this session only — no app state key)
3068
+ ```
3069
+
2783
3070
  #### Mark Read / Unread
2784
3071
 
2785
3072
  ```sh
@@ -2816,11 +3103,52 @@ wa> /unarchive 919634847671@s.whatsapp.net
2816
3103
 
2817
3104
  #### Star / Unstar a Message (CLI)
2818
3105
 
3106
+ Add `me` when the message is one you sent — it is part of how the star is filed,
3107
+ so leaving it off on your own message stars the wrong thing.
3108
+
2819
3109
  ```sh
2820
- wa> /star 919634847671@s.whatsapp.net 3EB0ABCDEF123456
3110
+ wa> /star 919634847671@s.whatsapp.net 3EB0ABCDEF123456 me
2821
3111
  wa> /unstar 919634847671@s.whatsapp.net 3EB0ABCDEF123456
2822
3112
  ```
2823
3113
 
3114
+ <a id="cli-app-state"></a>
3115
+
3116
+ #### Sync App State
3117
+
3118
+ Pulls in pins, archives, mutes, stars and contact names changed on your phone or
3119
+ another linked device. This happens on its own whenever the server says
3120
+ something moved; the command is for pulling on demand.
3121
+
3122
+ ```sh
3123
+ wa> /appstate
3124
+ syncing app state...
3125
+ ──────────────────────────────────────────────────
3126
+ critical_block v3 0 change(s)
3127
+ critical_unblock_low v18 2 change(s)
3128
+ regular_high v7 0 change(s)
3129
+ regular_low v41 3 change(s)
3130
+ regular v2 0 change(s)
3131
+ total 5 change(s) applied
3132
+ ──────────────────────────────────────────────────
3133
+
3134
+ # just one part of it
3135
+ wa> /appstate regular_low
3136
+
3137
+ # throw away what we hold and re-read everything
3138
+ wa> /appstate --snapshot
3139
+ ```
3140
+
3141
+ Changes that arrive on their own are printed as they land:
3142
+
3143
+ ```sh
3144
+ pinned 919634847671@s.whatsapp.net
3145
+ muted 120363000000000000@g.us until 2026-08-01T09:00:00.000Z
3146
+ contact 12345678901@s.whatsapp.net → Ion
3147
+ ```
3148
+
3149
+ If your phone has not yet shared a sync key with this session, the command says
3150
+ so — leave WhatsApp open on the phone for a moment and try again.
3151
+
2824
3152
  #### CLI Disappearing Messages
2825
3153
 
2826
3154
  | Duration | Seconds |
@@ -2900,13 +3228,17 @@ left 120363000000000000@g.us
2900
3228
 
2901
3229
  ```sh
2902
3230
  # add participants
2903
- wa> /group add 120363000000000000@g.us 919634847671@s.whatsapp.net
3231
+ wa> /group add 120363000000000000@g.us 919634847671@s.whatsapp.net 12345678901@s.whatsapp.net
3232
+ added 919634847671@s.whatsapp.net
3233
+ failed 12345678901@s.whatsapp.net — their privacy settings do not allow it (403) · can be invited instead
2904
3234
 
2905
3235
  # remove participants
2906
3236
  wa> /group remove 120363000000000000@g.us 919634847671@s.whatsapp.net
3237
+ removed 919634847671@s.whatsapp.net
2907
3238
  ```
2908
3239
 
2909
- Multiple participants can be listed, separated by spaces.
3240
+ Multiple participants can be listed, separated by spaces. Every participant is
3241
+ reported on its own line, because the server decides each one separately.
2910
3242
 
2911
3243
  #### Promote / Demote Admins
2912
3244
 
@@ -2938,7 +3270,7 @@ Reads the image from disk and sets it as the group's profile picture. You must b
2938
3270
 
2939
3271
  ```sh
2940
3272
  wa> /group photo 120363000000000000@g.us ./group-logo.jpg
2941
- group picture updated
3273
+ group picture updated id=1705315800
2942
3274
  ```
2943
3275
 
2944
3276
  #### Get Invite Link
@@ -2995,17 +3327,34 @@ wa> /group invite-info "https://chat.whatsapp.com/AbCdEfGhIjKlMnOpQrStUv"
2995
3327
 
2996
3328
  ```sh
2997
3329
  wa> /group meta 120363000000000000@g.us
2998
- jid 120363000000000000@g.us
2999
- subject My Group
3000
- description Group description here
3001
- creator 919634847671@s.whatsapp.net
3002
- created 2024-01-15 10:30:00
3003
- participants 3
3004
- onlyAdminsSend false
3005
- onlyAdminsEdit true
3006
- ephemeral 0
3330
+ jid 120363000000000000@g.us
3331
+ subject My Group
3332
+ creator 919634847671@s.whatsapp.net
3333
+ created 2024-01-15T10:30:00.000Z
3334
+ description Group description here
3335
+ ephemeral off
3336
+ only admins send no
3337
+ only admins edit yes
3338
+ join approval required
3339
+ who can add admins only
3340
+ size 3
3341
+ participants (3)
3342
+ 112713111982325@lid (919634847671@s.whatsapp.net) [admin]
3343
+ 229063524376784@lid (12345678901@s.whatsapp.net)
3344
+ 98765432109@s.whatsapp.net
3007
3345
  ```
3008
3346
 
3347
+ A participant addressed by LID is shown with the phone number behind it when
3348
+ the server sends one. Two more lines appear only when they apply:
3349
+
3350
+ ```sh
3351
+ suspended yes — this group has been taken down
3352
+ incognito yes — phone numbers are hidden
3353
+ ```
3354
+
3355
+ A suspended group answers every send with a refusal and nothing else, so it is
3356
+ worth checking here before hunting for the cause elsewhere.
3357
+
3009
3358
  #### List All Groups
3010
3359
 
3011
3360
  Fetches all groups you are a member of and prints a numbered list:
@@ -3024,9 +3373,9 @@ Lists all participants of a group with their roles:
3024
3373
 
3025
3374
  ```sh
3026
3375
  wa> /group participants 120363000000000000@g.us
3027
- participants (3)
3028
- 919634847671@s.whatsapp.net [admin]
3029
- 12345678901@s.whatsapp.net
3376
+ My Group (3 participants)
3377
+ 112713111982325@lid (919634847671@s.whatsapp.net) [admin]
3378
+ 229063524376784@lid (12345678901@s.whatsapp.net)
3030
3379
  98765432109@s.whatsapp.net
3031
3380
  ```
3032
3381
 
@@ -3037,8 +3386,8 @@ Lists users who have requested to join a group (only visible when `approve_parti
3037
3386
  ```sh
3038
3387
  wa> /group pending 120363000000000000@g.us
3039
3388
  pending (2)
3040
- 919634847671@s.whatsapp.net
3041
- 12345678901@s.whatsapp.net
3389
+ 919634847671@s.whatsapp.net 2026-07-20T09:12:00.000Z
3390
+ 12345678901@s.whatsapp.net 2026-07-21T14:03:20.000Z
3042
3391
  ```
3043
3392
 
3044
3393
  #### Approve / Reject Join Requests
@@ -3046,14 +3395,52 @@ wa> /group pending 120363000000000000@g.us
3046
3395
  ```sh
3047
3396
  # approve one or more pending members
3048
3397
  wa> /group approve 120363000000000000@g.us 919634847671@s.whatsapp.net
3049
- approved 919634847671@s.whatsapp.net
3398
+ approved 919634847671@s.whatsapp.net
3050
3399
 
3051
3400
  # reject one or more pending members
3052
3401
  wa> /group reject 120363000000000000@g.us 919634847671@s.whatsapp.net
3053
- rejected 919634847671@s.whatsapp.net
3402
+ rejected 919634847671@s.whatsapp.net
3403
+ ```
3404
+
3405
+ Multiple JIDs can be listed, separated by spaces. Anyone the server would not let
3406
+ through is listed separately with the reason.
3407
+
3408
+ <a id="cli-personal-invitations"></a>
3409
+
3410
+ #### Personal Invitations
3411
+
3412
+ For someone whose privacy settings do not let them be added to a group directly,
3413
+ `add-invite` adds whoever it can and sends the rest a personal invitation:
3414
+
3415
+ ```sh
3416
+ wa> /group add-invite 120363000000000000@g.us 919634847671@s.whatsapp.net 12345678901@s.whatsapp.net
3417
+ added 919634847671@s.whatsapp.net
3418
+ failed 12345678901@s.whatsapp.net — their privacy settings do not allow it (403) · can be invited instead · invitation sent
3419
+ ```
3420
+
3421
+ An invitation that arrives for you shows the command that accepts it:
3422
+
3423
+ ```sh
3424
+ message from 919634847671@s.whatsapp.net
3425
+ id 3EB0A1B2C3D4
3426
+ type group invitation My Group
3427
+ accept with /group accept-invite 120363000000000000@g.us 919634847671@s.whatsapp.net AbCdEfGh 1790000000
3054
3428
  ```
3055
3429
 
3056
- Multiple JIDs can be listed, separated by spaces.
3430
+ ```sh
3431
+ # look at the group without joining it
3432
+ wa> /group preview-invite 120363000000000000@g.us 919634847671@s.whatsapp.net AbCdEfGh 1790000000
3433
+
3434
+ # join
3435
+ wa> /group accept-invite 120363000000000000@g.us 919634847671@s.whatsapp.net AbCdEfGh 1790000000
3436
+ joined 120363000000000000@g.us
3437
+
3438
+ # send one by hand
3439
+ wa> /group send-invite 120363000000000000@g.us 12345678901@s.whatsapp.net AbCdEfGh 1790000000
3440
+
3441
+ # take one back before it is used
3442
+ wa> /group revoke-invite 120363000000000000@g.us 12345678901@s.whatsapp.net
3443
+ ```
3057
3444
 
3058
3445
  #### Group Settings
3059
3446
 
@@ -3255,8 +3642,10 @@ wa> /quit
3255
3642
  | `/unpin <jid>` | Unpin a chat |
3256
3643
  | `/archive <jid>` | Archive a chat |
3257
3644
  | `/unarchive <jid>` | Unarchive a chat |
3258
- | `/star <jid> <msgId>` | Star a message |
3259
- | `/unstar <jid> <msgId>` | Unstar a message |
3645
+ | `/star <jid> <msgId> [me]` | Star a message (`me` if you sent it) |
3646
+ | `/unstar <jid> <msgId> [me]` | Unstar a message |
3647
+ | `/appstate [collection...]` | Pull pins/archives/mutes/stars from your phone |
3648
+ | `/appstate --snapshot` | Re-read all app state from scratch |
3260
3649
  | `/ephemeral <jid> <seconds>` | Set disappearing messages timer for a chat |
3261
3650
  | `/ephemeral-default <seconds>` | Set global default ephemeral timer for new chats |
3262
3651
  | `/block <jid>` | Block a contact |