whalibmob 5.7.3 → 5.9.1

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