whalibmob 5.5.86 → 5.5.89

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
@@ -166,6 +166,7 @@ npm install -g whalibmob
166
166
  - [Document Message](#document-message)
167
167
  - [Sticker Message](#sticker-message)
168
168
  - [Status / Stories](#status--stories)
169
+ - [Status Privacy](#status-privacy)
169
170
  - [Send States in Chat](#send-states-in-chat)
170
171
  - [Reading Messages](#reading-messages)
171
172
  - [Mark Voice Message Played](#mark-voice-message-played)
@@ -189,6 +190,7 @@ npm install -g whalibmob
189
190
  - [Privacy](#privacy)
190
191
  - [Block / Unblock User](#block--unblock-user)
191
192
  - [Get Block List](#get-block-list)
193
+ - [Read Privacy Settings](#read-privacy-settings)
192
194
  - [Update Privacy Settings](#update-privacy-settings)
193
195
  - [Update Default Disappearing Mode](#update-default-disappearing-mode)
194
196
  - [Communities](#communities)
@@ -605,6 +607,8 @@ connect()
605
607
  | `chat_read` | `{ jid, read }` | Chat marked read (`read: true`) or unread (`read: false`) |
606
608
  | `chat_muted` | `{ jid, muted, until }` | Chat muted or unmuted; `until` is epoch ms (−1 = indefinite) |
607
609
  | `chat_pinned` | `{ jid, pinned }` | Chat pinned or unpinned |
610
+ | `blocklist` | `{ action, dhash, prevDhash, changes }` | Block list changed on another device; `changes` is `[{ jid, action }]` |
611
+ | `privacy_settings` | `{ changes, settings }` | Privacy settings changed on another device |
608
612
  | `chat_archived` | `{ jid, archived }` | Chat archived or unarchived |
609
613
  | `message_starred` | `{ msgId, chatJid, starred }` | Message starred or unstarred |
610
614
  | `stream_error` | `{ reason }` | Server sent a fatal stream error |
@@ -1287,11 +1291,41 @@ await client.sendSticker('919634847671@s.whatsapp.net', './sticker.webp')
1287
1291
 
1288
1292
  ## Status / Stories
1289
1293
 
1294
+ A Status is posted to `status@broadcast` and travels the SenderKey path a group
1295
+ message does — one encrypted body plus a key distribution to every recipient.
1296
+ Who those recipients are comes from your status privacy settings, resolved
1297
+ against the contacts in the synced history store.
1298
+
1290
1299
  ```js
1291
- // post a text Status to status@broadcast
1300
+ // text
1292
1301
  await client.sendStatus('Good morning!')
1302
+
1303
+ // photo, video, voice — same arguments as the matching send* methods
1304
+ await client.sendStatus({ image: './photo.jpg', caption: 'Look at this' })
1305
+ await client.sendStatus({ video: './clip.mp4' })
1306
+ await client.sendStatus({ audio: './voice.ogg' })
1307
+
1308
+ // styled text status
1309
+ await client.sendStatus('Colorful', { backgroundArgb: 0xFF25D366, font: 3 })
1310
+
1311
+ // post to an explicit list instead of the privacy-derived one
1312
+ await client.sendStatus('Hi', { recipients: ['919634847671@s.whatsapp.net'] })
1293
1313
  ```
1294
1314
 
1315
+ ### Status Privacy
1316
+
1317
+ ```js
1318
+ const [def, ...rest] = await client.queryStatusPrivacy()
1319
+ // def = { type: 'contacts' | 'blacklist' | 'whitelist', isDefault, list }
1320
+ ```
1321
+
1322
+ `contacts` sends to everyone in your address book, `blacklist` to everyone
1323
+ except `list`, `whitelist` to `list` only. Your own JID is always included so
1324
+ the post reaches your other devices.
1325
+
1326
+ If history has not synced yet the contact list is unknown, and `sendStatus`
1327
+ will say so rather than post to nobody — pass `recipients` in that case.
1328
+
1295
1329
  ## Send States in Chat
1296
1330
 
1297
1331
  ### Reading Messages
@@ -1457,8 +1491,12 @@ await client.changeGroupPicture('120363000000000000@g.us', fs.readFileSync('./gr
1457
1491
  ### Block / Unblock User
1458
1492
 
1459
1493
  ```js
1460
- await client.blockContact('919634847671@s.whatsapp.net')
1494
+ // both return the updated block list, and throw if the server refuses
1495
+ const blocked = await client.blockContact('919634847671@s.whatsapp.net')
1461
1496
  await client.unblockContact('919634847671@s.whatsapp.net')
1497
+
1498
+ // blocking done from the phone arrives as an event
1499
+ client.on('blocklist', ({ changes }) => console.log(changes))
1462
1500
  ```
1463
1501
 
1464
1502
  ### Get Block List
@@ -1471,17 +1509,40 @@ console.log(list) // [ '919634847671@s.whatsapp.net', ... ]
1471
1509
  ### Update Privacy Settings
1472
1510
 
1473
1511
  ```js
1474
- // type: 'last_seen' | 'profile_picture' | 'status' | 'online' | 'read_receipts' | 'groups_add'
1475
- // value: 'all' | 'contacts' | 'contact_blacklist' | 'none' | 'match_last_seen'
1512
+ // type: 'last_seen' | 'profile_picture' | 'status' | 'online' | 'read_receipts'
1513
+ // 'groups_add' | 'call_add' | 'messages' | 'defense' | 'stickers'
1514
+ // value: 'all' | 'contacts' | 'contact_blacklist' | 'contact_allowlist' | 'none'
1515
+ // 'match_last_seen' | 'known' | 'on_standard' | 'off'
1476
1516
 
1517
+ // returns the settings as they now stand; throws if the server refuses
1477
1518
  await client.changePrivacySetting('last_seen', 'contacts')
1478
1519
  await client.changePrivacySetting('profile_picture', 'contacts')
1479
1520
  await client.changePrivacySetting('status', 'contacts')
1480
1521
  await client.changePrivacySetting('online', 'match_last_seen')
1481
1522
  await client.changePrivacySetting('read_receipts', 'none')
1482
1523
  await client.changePrivacySetting('groups_add', 'contacts')
1524
+ await client.changePrivacySetting('call_add', 'known')
1483
1525
  ```
1484
1526
 
1527
+ ### Read Privacy Settings
1528
+
1529
+ ```js
1530
+ const s = await client.queryPrivacySettings()
1531
+ // { lastSeen, profile, status, online, readReceipts,
1532
+ // groupAdd, callAdd, messages, defense, stickers }
1533
+ // anything the server did not report is null
1534
+
1535
+ await client.queryPrivacySettings({ force: true }) // skip the cache
1536
+
1537
+ // a change made on the phone arrives as an event
1538
+ client.on('privacy_settings', ({ changes, settings }) => console.log(changes))
1539
+ ```
1540
+
1541
+ Turning `read_receipts` off changes what read receipts go out: they become
1542
+ `read-self`, which syncs the read state across your own devices without telling
1543
+ the sender. The settings are fetched once after connect so this holds from the
1544
+ first message.
1545
+
1485
1546
  ### Update Default Disappearing Mode
1486
1547
 
1487
1548
  ```js
package/cli.js CHANGED
@@ -336,7 +336,8 @@ const HELP = `
336
336
  /react <jid> <msgId> <emoji> react to a message
337
337
  /edit <jid> <msgId> <text> edit a sent message
338
338
  /delete <jid> <msgId> [all] delete message (add 'all' for everyone)
339
- /status <text> post a Status/Story
339
+ /status <text> post a text Status/Story
340
+ /status image|video|audio <file> [caption] post a media Status/Story
340
341
  /forward <jid> <text|msgObj> forward text (or decoded msg) with forwarded flag
341
342
  /poll <jid> <question> | <opt1> | <opt2> [selectable=N] send a poll
342
343
  /location <jid> <lat> <lon> [name] [| address] send a location pin
@@ -354,11 +355,13 @@ const HELP = `
354
355
  /name <text> change display name
355
356
  /about <text> change own bio / about text
356
357
  /photo <file> change own profile picture (JPEG)
357
- /privacy <type> <value> change privacy setting
358
+ /privacy [<type> <value>] show or change privacy settings
358
359
  types: last_seen profile_picture status
359
360
  online read_receipts groups_add
361
+ call_add messages defense stickers
360
362
  values: all contacts contact_blacklist
361
- none match_last_seen
363
+ contact_allowlist none known
364
+ match_last_seen on_standard off
362
365
 
363
366
  Contacts
364
367
  /whatsapp <phone...> check which numbers have WhatsApp
@@ -828,8 +831,19 @@ async function handleLine(line) {
828
831
  case '/status': {
829
832
  requireConn();
830
833
  const [, ...rest] = p;
831
- if (!rest.length) { fail('usage: /status <text>'); break; }
832
- const r = await _client.sendStatus(rest.join(' '));
834
+ if (!rest.length) {
835
+ fail('usage: /status <text> | /status image|video|audio <file> [caption]');
836
+ break;
837
+ }
838
+ const kind = rest[0] && rest[0].toLowerCase();
839
+ let r;
840
+ if ((kind === 'image' || kind === 'video' || kind === 'audio') && rest[1]) {
841
+ const opts = { [kind]: rest[1] };
842
+ if (rest.length > 2) opts.caption = rest.slice(2).join(' ');
843
+ r = await _client.sendStatus(opts);
844
+ } else {
845
+ r = await _client.sendStatus(rest.join(' '));
846
+ }
833
847
  out('status posted ' + (r && r.id ? r.id : ''));
834
848
  break;
835
849
  }
@@ -968,10 +982,19 @@ async function handleLine(line) {
968
982
  case '/privacy': {
969
983
  requireConn();
970
984
  const [, type, value] = p;
971
- if (!type || !value) {
972
- fail('usage: /privacy <type> <value>');
973
- out(' types: last_seen profile_picture status online read_receipts groups_add');
974
- out(' values: all contacts contact_blacklist none match_last_seen');
985
+ // with no arguments, show what the settings currently are
986
+ if (!type) {
987
+ const s = await _client.queryPrivacySettings({ force: true });
988
+ out(' privacy settings');
989
+ for (const k of Object.keys(s)) out(' ' + k.padEnd(14) + (s[k] || '(unset)'));
990
+ break;
991
+ }
992
+ if (!value) {
993
+ fail('usage: /privacy [<type> <value>]');
994
+ out(' types: last_seen profile_picture status online read_receipts');
995
+ out(' groups_add call_add messages defense stickers');
996
+ out(' values: all contacts contact_blacklist contact_allowlist none');
997
+ out(' match_last_seen known on_standard off');
975
998
  break;
976
999
  }
977
1000
  await _client.changePrivacySetting(type, value);
@@ -1161,8 +1184,8 @@ async function handleLine(line) {
1161
1184
  requireConn();
1162
1185
  const jid = normalizeJid(p[1]);
1163
1186
  if (!jid) { fail('usage: /block <jid>'); break; }
1164
- await _client.blockContact(jid);
1165
- out('blocked ' + jid);
1187
+ const after = await _client.blockContact(jid);
1188
+ out('blocked ' + jid + ' (' + after.length + ' blocked in total)');
1166
1189
  break;
1167
1190
  }
1168
1191
 
@@ -1170,8 +1193,8 @@ async function handleLine(line) {
1170
1193
  requireConn();
1171
1194
  const jid = normalizeJid(p[1]);
1172
1195
  if (!jid) { fail('usage: /unblock <jid>'); break; }
1173
- await _client.unblockContact(jid);
1174
- out('unblocked ' + jid);
1196
+ const left = await _client.unblockContact(jid);
1197
+ out('unblocked ' + jid + ' (' + left.length + ' blocked in total)');
1175
1198
  break;
1176
1199
  }
1177
1200
 
package/lib/Client.js CHANGED
@@ -30,6 +30,9 @@ const {
30
30
  HistorySyncType
31
31
  } = require('./HistorySyncHandler');
32
32
 
33
+ // Status posts are messages addressed here.
34
+ const STATUS_BROADCAST_JID = 'status@broadcast';
35
+
33
36
  // The two prefixes a call-link token is wrapped in, as WhatsApp Web uses them.
34
37
  const CALL_AUDIO_PREFIX = 'https://call.whatsapp.com/voice/';
35
38
  const CALL_VIDEO_PREFIX = 'https://call.whatsapp.com/video/';
@@ -37,6 +40,47 @@ const CALL_VIDEO_PREFIX = 'https://call.whatsapp.com/video/';
37
40
  // whatsmeow's InviteLinkPrefix — the one prefix group invite codes are wrapped in.
38
41
  const INVITE_LINK_PREFIX = 'https://chat.whatsapp.com/';
39
42
 
43
+ // The names privacy settings actually go by on the wire, and the friendly
44
+ // aliases this library has always accepted for them. whatsmeow's
45
+ // PrivacySettingType constants are the right-hand column.
46
+ const PRIVACY_NAME_ALIASES = {
47
+ last_seen: 'last', last: 'last',
48
+ profile_picture: 'profile', profile_pic: 'profile', profile: 'profile',
49
+ status: 'status',
50
+ online: 'online',
51
+ read_receipts: 'readreceipts', readreceipts: 'readreceipts',
52
+ groups_add: 'groupadd', group_add: 'groupadd', groupadd: 'groupadd',
53
+ call_add: 'calladd', calladd: 'calladd',
54
+ messages: 'messages',
55
+ defense: 'defense',
56
+ stickers: 'stickers'
57
+ };
58
+
59
+ // Wire name → the key it is reported under in the settings object.
60
+ const PRIVACY_WIRE_TO_KEY = {
61
+ last: 'lastSeen',
62
+ profile: 'profile',
63
+ status: 'status',
64
+ online: 'online',
65
+ readreceipts: 'readReceipts',
66
+ groupadd: 'groupAdd',
67
+ calladd: 'callAdd',
68
+ messages: 'messages',
69
+ defense: 'defense',
70
+ stickers: 'stickers'
71
+ };
72
+
73
+ // Reduce a JID to its bare user — whatsmeow's ToNonAD. Blocking, and anything
74
+ // else that names an account rather than one of its devices, wants this form.
75
+ function toNonAdJid(input) {
76
+ const s = String(input || '');
77
+ const at = s.indexOf('@');
78
+ const user = at >= 0 ? s.slice(0, at) : s;
79
+ const server = at >= 0 ? s.slice(at + 1) : 's.whatsapp.net';
80
+ const colon = user.indexOf(':');
81
+ return (colon >= 0 ? user.slice(0, colon) : user) + '@' + server;
82
+ }
83
+
40
84
  // Accept a full invite URL or a bare code and return the code.
41
85
  function stripInviteUrl(codeOrUrl) {
42
86
  return String(codeOrUrl || '')
@@ -185,6 +229,9 @@ class WhalibmobClient extends EventEmitter {
185
229
  // Flipped by setOnline(). Delivery receipts sent while this is false carry
186
230
  // type="inactive", which the sender's app records but does not render.
187
231
  this._sendActiveReceipts = false;
232
+ // Cached privacy settings, as whatsmeow caches them. Warmed after connect
233
+ // and refreshed whenever the server reports a change; markRead reads it.
234
+ this._privacySettings = null;
188
235
  if (opts.pino !== undefined) _whaConfigLogger(opts.pino);
189
236
  }
190
237
 
@@ -325,6 +372,11 @@ class WhalibmobClient extends EventEmitter {
325
372
  this._requestMediaConnection();
326
373
  this._uploadPreKeys();
327
374
 
375
+ // Warm the privacy settings so read receipts respect them from the first
376
+ // message rather than from whenever something first asks for them.
377
+ this.queryPrivacySettings({ force: true }).catch(err =>
378
+ _whaDbg('[DBG] PRIVACY_FETCH_FAILED ' + (err && err.message)));
379
+
328
380
  // ── Reconnect: flush stale device cache + background re-usync ─────────────
329
381
  // On first connect _hasConnectedOnce is false — nothing extra to do.
330
382
  // On every reconnect (network drop / NAT reset / server restart) we flush the
@@ -1034,6 +1086,19 @@ class WhalibmobClient extends EventEmitter {
1034
1086
  this._socket.sendNode(new BinaryNode('ack', attrs, null));
1035
1087
  }
1036
1088
 
1089
+ // A read receipt only tells the sender we read it when read receipts are on.
1090
+ // whatsmeow's MarkRead checks the same setting and downgrades "read" to
1091
+ // "read-self", which marks the message read across our own devices without
1092
+ // reporting it to the other side. Nothing here looked at the setting, so a
1093
+ // user who had turned read receipts off still sent blue ticks.
1094
+ _readReceiptType(base) {
1095
+ const settings = this._privacySettings;
1096
+ if (settings && settings.readReceipts === 'none' && base === 'read') {
1097
+ return 'read-self';
1098
+ }
1099
+ return base;
1100
+ }
1101
+
1037
1102
  // True when a JID names this account, in either address family.
1038
1103
  _isOwnUser(jid) {
1039
1104
  if (!jid) return false;
@@ -1080,7 +1145,7 @@ class WhalibmobClient extends EventEmitter {
1080
1145
  if (this.autoRead === false) return;
1081
1146
  const node = new BinaryNode('receipt', {
1082
1147
  id: msgId,
1083
- type: 'read',
1148
+ type: this._readReceiptType('read'),
1084
1149
  to: fromJid,
1085
1150
  t: String(Math.floor(Date.now() / 1000))
1086
1151
  }, null);
@@ -1201,6 +1266,46 @@ class WhalibmobClient extends EventEmitter {
1201
1266
 
1202
1267
  // ─── Notification handling ────────────────────────────────────────────────
1203
1268
 
1269
+ // <privacy><category name="..." value="..."/></privacy> inside an account_sync
1270
+ // notification. The node carries only what changed, so it is folded into the
1271
+ // cached settings rather than replacing them.
1272
+ _handlePrivacySettingsNotification(node) {
1273
+ const changed = this._parsePrivacySettings(node);
1274
+ const merged = Object.assign({}, this._privacySettings || {});
1275
+ const changes = {};
1276
+ for (const key of Object.keys(changed)) {
1277
+ if (changed[key] !== null) { merged[key] = changed[key]; changes[key] = changed[key]; }
1278
+ }
1279
+ this._privacySettings = merged;
1280
+ _whaDbg('[DBG] PRIVACY_CHANGE ' + JSON.stringify(changes));
1281
+ this.emit('privacy_settings', { changes, settings: merged });
1282
+ }
1283
+
1284
+ // <blocklist action="..." dhash="..." prev_dhash="..."><item jid=... action=.../></blocklist>
1285
+ // Mirrors whatsmeow's handleBlocklist: the action on the node describes the
1286
+ // whole change, each child names one JID and what happened to it.
1287
+ _handleBlocklistNotification(node) {
1288
+ const attrs = node.attrs || {};
1289
+ const changes = [];
1290
+ if (Array.isArray(node.content)) {
1291
+ for (const child of node.content) {
1292
+ if (!child || !child.attrs || !child.attrs.jid) continue;
1293
+ changes.push({
1294
+ jid: String(child.attrs.jid),
1295
+ action: child.attrs.action ? String(child.attrs.action) : null
1296
+ });
1297
+ }
1298
+ }
1299
+ _whaDbg('[DBG] BLOCKLIST_CHANGE action=' + (attrs.action || '') +
1300
+ ' changes=' + changes.map(c => c.action + ':' + c.jid).join(','));
1301
+ this.emit('blocklist', {
1302
+ action: attrs.action ? String(attrs.action) : null,
1303
+ dhash: attrs.dhash ? String(attrs.dhash) : null,
1304
+ prevDhash: attrs.prev_dhash ? String(attrs.prev_dhash) : null,
1305
+ changes
1306
+ });
1307
+ }
1308
+
1204
1309
  _handleNotification(node) {
1205
1310
  const attrs = node.attrs || {};
1206
1311
  const type = attrs.type || '';
@@ -1216,6 +1321,16 @@ class WhalibmobClient extends EventEmitter {
1216
1321
  // Account sync notification — may contain device list updates
1217
1322
  const devicesNode = findChildDeep(node, 'devices');
1218
1323
  if (devicesNode) this._processDeviceUpdate(devicesNode);
1324
+ // ...and block list changes. Blocking someone from the phone arrives
1325
+ // here; nothing was watching for it, so the client's idea of who is
1326
+ // blocked only ever changed when it did the blocking itself.
1327
+ const blocklistNode = findChildDeep(node, 'blocklist');
1328
+ if (blocklistNode) this._handleBlocklistNotification(blocklistNode);
1329
+ // ...and privacy setting changes, which arrive the same way. whatsmeow
1330
+ // re-reads the settings on this and raises an event; nothing was watching
1331
+ // the node, so a change made on the phone never reached the cache.
1332
+ const privacyNode = findChildDeep(node, 'privacy');
1333
+ if (privacyNode) this._handlePrivacySettingsNotification(privacyNode);
1219
1334
  }
1220
1335
 
1221
1336
  if (type === 'w:gp2') {
@@ -2297,7 +2412,7 @@ class WhalibmobClient extends EventEmitter {
2297
2412
 
2298
2413
  const attrs = {
2299
2414
  id: ids[0],
2300
- type: opts.type === 'played' ? 'played' : 'read',
2415
+ type: this._readReceiptType(opts.type === 'played' ? 'played' : 'read'),
2301
2416
  to: jidStrToObj(chatJid),
2302
2417
  t: String(Math.floor(Date.now() / 1000))
2303
2418
  };
@@ -2623,7 +2738,29 @@ class WhalibmobClient extends EventEmitter {
2623
2738
  return httpGetBuffer(info.url);
2624
2739
  }
2625
2740
 
2626
- // Query blocked contacts list
2741
+ // Parse the <list> a blocklist query or update comes back with.
2742
+ //
2743
+ // whatsmeow's parseBlocklist walks every child and takes its jid, without
2744
+ // caring what the tag is called, and keeps the dhash off the list itself.
2745
+ // The JIDs are stringified here: the binary decoder hands them back as JID
2746
+ // objects, and while those print correctly they are not strings, so the array
2747
+ // this used to return failed any includes() or === against a JID string —
2748
+ // which is exactly what the documented shape invites you to do.
2749
+ _parseBlocklist(listNode) {
2750
+ const jids = [];
2751
+ if (listNode && Array.isArray(listNode.content)) {
2752
+ for (const child of listNode.content) {
2753
+ if (!child || !child.attrs || !child.attrs.jid) continue;
2754
+ jids.push(String(child.attrs.jid));
2755
+ }
2756
+ }
2757
+ const dhash = (listNode && listNode.attrs && listNode.attrs.dhash)
2758
+ ? String(listNode.attrs.dhash) : null;
2759
+ return { dhash, jids };
2760
+ }
2761
+
2762
+ // Query blocked contacts list. Returns an array of JID strings; never throws,
2763
+ // an unreachable server yields an empty list.
2627
2764
  async queryBlockList() {
2628
2765
  const id = this._genMsgId();
2629
2766
  const node = new BinaryNode('iq', {
@@ -2634,11 +2771,8 @@ class WhalibmobClient extends EventEmitter {
2634
2771
  }, null);
2635
2772
  const resp = await this._sendIq(node);
2636
2773
  if (!resp) return [];
2637
- const list = findChild(resp, 'list');
2638
- if (!list || !Array.isArray(list.content)) return [];
2639
- return list.content
2640
- .filter(n => n && n.description === 'item' && n.attrs && n.attrs.jid)
2641
- .map(n => n.attrs.jid);
2774
+ if (resp.attrs && resp.attrs.type === 'error') return [];
2775
+ return this._parseBlocklist(findChild(resp, 'list')).jids;
2642
2776
  }
2643
2777
 
2644
2778
  // Change display name
@@ -2683,29 +2817,114 @@ class WhalibmobClient extends EventEmitter {
2683
2817
  return this._sendIq(node);
2684
2818
  }
2685
2819
 
2686
- // Change privacy settings
2687
- // type: 'last_seen'|'profile_picture'|'status'|'online'|'read_receipts'|'groups_add'
2688
- // value: 'all'|'contacts'|'contact_blacklist'|'none'|'match_last_seen'
2689
- // excluded: array of JIDs excluded from value (for contact_blacklist)
2820
+ // ─── Privacy settings ─────────────────────────────────────────────────────
2821
+
2822
+ // Read the current privacy settings.
2823
+ //
2824
+ // Returns { lastSeen, profile, status, online, readReceipts, groupAdd,
2825
+ // callAdd, messages, defense, stickers } — any the server did not
2826
+ // report come back null. Served from cache unless opts.force is set.
2827
+ //
2828
+ // There was no way to read these at all before, which also meant nothing
2829
+ // could act on them; see markRead for what that cost.
2830
+ async queryPrivacySettings(opts) {
2831
+ opts = opts || {};
2832
+ if (this._privacySettings && !opts.force) return this._privacySettings;
2833
+
2834
+ const node = new BinaryNode('iq', {
2835
+ id: this._genMsgId(),
2836
+ xmlns: 'privacy',
2837
+ to: 's.whatsapp.net',
2838
+ type: 'get'
2839
+ }, [new BinaryNode('privacy', {}, null)]);
2840
+
2841
+ const resp = await this._sendIq(node);
2842
+ if (!resp) throw new Error('queryPrivacySettings: no reply from server (IQ timed out)');
2843
+ if (resp.attrs && resp.attrs.type === 'error') {
2844
+ const errNode = findChild(resp, 'error');
2845
+ const code = errNode && errNode.attrs && errNode.attrs.code;
2846
+ throw new Error('queryPrivacySettings: server rejected the request' +
2847
+ (code ? ' (' + code + ')' : ''));
2848
+ }
2849
+ const privacyNode = findChild(resp, 'privacy');
2850
+ this._privacySettings = this._parsePrivacySettings(privacyNode);
2851
+ return this._privacySettings;
2852
+ }
2853
+
2854
+ // <privacy><category name="..." value="..."/>...</privacy>
2855
+ _parsePrivacySettings(privacyNode) {
2856
+ const out = {};
2857
+ for (const key of Object.keys(PRIVACY_WIRE_TO_KEY)) out[PRIVACY_WIRE_TO_KEY[key]] = null;
2858
+ if (privacyNode && Array.isArray(privacyNode.content)) {
2859
+ for (const child of privacyNode.content) {
2860
+ if (!child || child.description !== 'category' || !child.attrs) continue;
2861
+ const key = PRIVACY_WIRE_TO_KEY[String(child.attrs.name)];
2862
+ if (key) out[key] = child.attrs.value ? String(child.attrs.value) : null;
2863
+ }
2864
+ }
2865
+ return out;
2866
+ }
2867
+
2868
+ // Change a privacy setting. Returns the settings as they now stand.
2869
+ //
2870
+ // type: 'last_seen' | 'profile_picture' | 'status' | 'online' |
2871
+ // 'read_receipts' | 'groups_add' | 'call_add' | 'messages' |
2872
+ // 'defense' | 'stickers' (the protocol's own spellings work too)
2873
+ // value: 'all' | 'contacts' | 'contact_blacklist' | 'contact_allowlist' |
2874
+ // 'none' | 'match_last_seen' | 'known' | 'on_standard' | 'off'
2875
+ // excluded: JIDs to exclude — see the note below
2876
+ //
2877
+ // The names above are friendly aliases. What goes on the wire is what
2878
+ // whatsmeow sends: last, profile, status, online, readreceipts, groupadd,
2879
+ // calladd, messages, defense, stickers. Four of the six names this used to
2880
+ // put on the wire — last_seen, profile_picture, read_receipts, groups_add —
2881
+ // are not names the server knows, so those settings never changed and the
2882
+ // discarded reply meant it never said so either.
2690
2883
  async changePrivacySetting(type, value, excluded) {
2691
- const categoryAttrs = { name: type, value };
2884
+ const name = PRIVACY_NAME_ALIASES[String(type)];
2885
+ if (!name) {
2886
+ throw new Error('changePrivacySetting: unknown setting "' + type + '" — one of ' +
2887
+ Object.keys(PRIVACY_NAME_ALIASES).join(', '));
2888
+ }
2889
+
2890
+ // Neither whatsmeow nor WhatsApp Web puts <user> children on this IQ; the
2891
+ // exclusion list is maintained separately. Kept for callers that pass it,
2892
+ // and only emitted when they do.
2692
2893
  const children = [];
2693
2894
  if (excluded && excluded.length > 0) {
2694
2895
  for (const jid of excluded) {
2695
- children.push(new BinaryNode('user', { jid: makeJid(jid) }, null));
2896
+ children.push(new BinaryNode('user', { jid: jidStrToObj(toNonAdJid(makeJid(jid))) }, null));
2696
2897
  }
2697
2898
  }
2698
- const categoryNode = new BinaryNode('category', categoryAttrs, children.length > 0 ? children : null);
2699
- const id = this._genMsgId();
2899
+
2700
2900
  const node = new BinaryNode('iq', {
2701
- id,
2901
+ id: this._genMsgId(),
2702
2902
  xmlns: 'privacy',
2703
2903
  to: 's.whatsapp.net',
2704
2904
  type: 'set'
2705
2905
  }, [
2706
- new BinaryNode('privacy', {}, [categoryNode])
2906
+ new BinaryNode('privacy', {}, [
2907
+ new BinaryNode('category', { name, value: String(value) },
2908
+ children.length > 0 ? children : null)
2909
+ ])
2707
2910
  ]);
2708
- return this._sendIq(node);
2911
+
2912
+ const resp = await this._sendIq(node);
2913
+ if (!resp) throw new Error('changePrivacySetting: no reply from server (IQ timed out)');
2914
+ if (resp.attrs && resp.attrs.type === 'error') {
2915
+ const errNode = findChild(resp, 'error');
2916
+ const code = errNode && errNode.attrs && errNode.attrs.code;
2917
+ const text = errNode && errNode.attrs && errNode.attrs.text;
2918
+ throw new Error('changePrivacySetting: server rejected ' + name + '=' + value +
2919
+ (code ? ' (' + code + (text ? ' ' + text : '') + ')' : ''));
2920
+ }
2921
+
2922
+ // whatsmeow folds the change into its cached copy and hands the whole thing
2923
+ // back rather than making the caller re-query.
2924
+ const key = PRIVACY_WIRE_TO_KEY[name];
2925
+ this._privacySettings = Object.assign({}, this._privacySettings || {},
2926
+ { [key]: String(value) });
2927
+ return this._privacySettings;
2709
2928
  }
2710
2929
 
2711
2930
  // ──────────────────────────────────────────────────────────────────────────
@@ -2726,10 +2945,132 @@ class WhalibmobClient extends EventEmitter {
2726
2945
  return this._sender.deleteMessage(origMsgId, chatJid, fromMe, forEveryone, opts);
2727
2946
  }
2728
2947
 
2729
- // Send a WhatsApp Status/Story
2730
- async sendStatus(text, opts) {
2948
+ // ─── Status / Stories ─────────────────────────────────────────────────────
2949
+
2950
+ // Who your Status posts go to.
2951
+ //
2952
+ // Returns [{ type, isDefault, list }] with the default first, as whatsmeow's
2953
+ // GetStatusPrivacy does. type is 'contacts', 'blacklist' or 'whitelist'; list
2954
+ // holds the JIDs the last two are built from. A server that has never been
2955
+ // told otherwise answers nothing, which means the default: all contacts.
2956
+ async queryStatusPrivacy() {
2957
+ const node = new BinaryNode('iq', {
2958
+ id: this._genMsgId(),
2959
+ xmlns: 'status',
2960
+ to: 's.whatsapp.net',
2961
+ type: 'get'
2962
+ }, [new BinaryNode('privacy', {}, null)]);
2963
+
2964
+ const resp = await this._sendIq(node);
2965
+ if (!resp || (resp.attrs && resp.attrs.type === 'error')) {
2966
+ return [{ type: 'contacts', isDefault: true, list: [] }];
2967
+ }
2968
+
2969
+ const privacyNode = findChild(resp, 'privacy');
2970
+ const out = [];
2971
+ if (privacyNode && Array.isArray(privacyNode.content)) {
2972
+ for (const listNode of privacyNode.content) {
2973
+ if (!listNode || listNode.description !== 'list' || !listNode.attrs) continue;
2974
+ const entry = {
2975
+ type: listNode.attrs.type ? String(listNode.attrs.type) : 'contacts',
2976
+ isDefault: String(listNode.attrs.default) === 'true',
2977
+ list: []
2978
+ };
2979
+ if (Array.isArray(listNode.content)) {
2980
+ for (const u of listNode.content) {
2981
+ if (u && u.description === 'user' && u.attrs && u.attrs.jid) {
2982
+ entry.list.push(String(u.attrs.jid));
2983
+ }
2984
+ }
2985
+ }
2986
+ // The default is always reported first.
2987
+ if (entry.isDefault) out.unshift(entry); else out.push(entry);
2988
+ }
2989
+ }
2990
+ return out.length ? out : [{ type: 'contacts', isDefault: true, list: [] }];
2991
+ }
2992
+
2993
+ // Resolve the status privacy settings into the JIDs a post actually goes to,
2994
+ // the way whatsmeow's getStatusBroadcastRecipients does: a whitelist is the
2995
+ // list itself, anything else is every known contact minus the blacklist, and
2996
+ // our own JID is added so the post lands on our own devices too.
2997
+ //
2998
+ // Contacts come from the synced history store — this client has no separate
2999
+ // contact database — so a session that has not synced history yet has to be
3000
+ // given the list explicitly.
3001
+ async _getStatusRecipients() {
3002
+ let privacy;
3003
+ try {
3004
+ privacy = (await this.queryStatusPrivacy())[0];
3005
+ } catch (_) {
3006
+ privacy = { type: 'contacts', list: [] };
3007
+ }
3008
+
3009
+ const ownJid = String(this._store.phoneNumber) + '@s.whatsapp.net';
3010
+ let recipients;
3011
+
3012
+ if (privacy.type === 'whitelist') {
3013
+ recipients = privacy.list.slice();
3014
+ } else {
3015
+ const hist = this.getHistoryStore();
3016
+ const contacts = (hist && hist.contacts) ? Object.keys(hist.contacts) : [];
3017
+ // Only real contacts — an entry the history sync created purely to hold a
3018
+ // push name is not somebody you have in your address book.
3019
+ const named = contacts.filter(jid => {
3020
+ const c = hist.contacts[jid];
3021
+ return c && (c.name || c.displayName) && !jid.endsWith('@g.us') &&
3022
+ !jid.endsWith('@broadcast');
3023
+ });
3024
+ const blocked = new Set(privacy.type === 'blacklist' ? privacy.list : []);
3025
+ recipients = named.filter(jid => !blocked.has(jid));
3026
+ }
3027
+
3028
+ if (!recipients.some(j => this._isOwnUser(j))) recipients.push(ownJid);
3029
+ _whaDbg('[DBG] STATUS_RECIPIENTS type=' + privacy.type +
3030
+ ' count=' + recipients.length);
3031
+ return recipients;
3032
+ }
3033
+
3034
+ // Post a Status/Story.
3035
+ //
3036
+ // sendStatus('Good morning!') text
3037
+ // sendStatus({ image: './a.jpg', caption: 'hi' }) photo
3038
+ // sendStatus({ video: './v.mp4' }) video
3039
+ // sendStatus({ audio: './v.ogg' }) voice
3040
+ //
3041
+ // opts (also accepted as fields of the object form):
3042
+ // recipients post to these JIDs instead of the privacy-derived list
3043
+ // backgroundArgb background colour of a text status, e.g. 0xFF25D366
3044
+ // textArgb, font the rest of a text status' styling
3045
+ //
3046
+ // Text posted with a background or font becomes an ExtendedTextMessage, which
3047
+ // is what carries them; plain text stays a plain conversation.
3048
+ async sendStatus(content, opts) {
2731
3049
  if (!this._sender || !this._connected) throw new Error('Not connected');
2732
- return this._sender.sendText('status@broadcast', text, opts);
3050
+ opts = Object.assign({},
3051
+ (content && typeof content === 'object') ? content : {},
3052
+ opts || {});
3053
+
3054
+ const recipients = opts.recipients || opts.statusJidList ||
3055
+ await this._getStatusRecipients();
3056
+ const sendOpts = Object.assign({}, opts, { _statusRecipients: recipients });
3057
+
3058
+ if (opts.image) return this._sender.sendImage(STATUS_BROADCAST_JID, opts.image, sendOpts);
3059
+ if (opts.video) return this._sender.sendVideo(STATUS_BROADCAST_JID, opts.video, sendOpts);
3060
+ if (opts.audio) return this._sender.sendAudio(STATUS_BROADCAST_JID, opts.audio, sendOpts);
3061
+
3062
+ const text = (typeof content === 'string') ? content : (opts.text || opts.caption || '');
3063
+ const styled = opts.backgroundArgb != null || opts.textArgb != null || opts.font != null;
3064
+ if (!styled) return this._sender.sendText(STATUS_BROADCAST_JID, text, sendOpts);
3065
+
3066
+ const { encodeMessage, encodeExtendedText } = require('./proto/MessageProto');
3067
+ const buf = encodeMessage('extendedText', encodeExtendedText(text, {
3068
+ backgroundArgb: opts.backgroundArgb,
3069
+ textArgb: opts.textArgb,
3070
+ font: opts.font
3071
+ }));
3072
+ return this._sender._sendMessage(STATUS_BROADCAST_JID,
3073
+ generateMessageId(), buf, 'text', sendOpts);
2733
3074
  }
2734
3075
 
2735
3076
  // Forward a message
@@ -3158,32 +3499,52 @@ class WhalibmobClient extends EventEmitter {
3158
3499
  return this._sendIq(node);
3159
3500
  }
3160
3501
 
3161
- // Block a contact
3502
+ // Block a contact. Returns the updated block list.
3162
3503
  async blockContact(jid) {
3163
- const id = this._genMsgId();
3164
- const node = new BinaryNode('iq', {
3165
- id,
3166
- xmlns: 'blocklist',
3167
- to: 's.whatsapp.net',
3168
- type: 'set'
3169
- }, [
3170
- new BinaryNode('item', { action: 'block', jid: makeJid(jid) }, null)
3171
- ]);
3172
- return this._sendIq(node);
3504
+ return this._updateBlocklist(jid, 'block');
3173
3505
  }
3174
3506
 
3175
- // Unblock a contact
3507
+ // Unblock a contact. Returns the updated block list.
3176
3508
  async unblockContact(jid) {
3177
- const id = this._genMsgId();
3509
+ return this._updateBlocklist(jid, 'unblock');
3510
+ }
3511
+
3512
+ // The one IQ behind both, as whatsmeow's UpdateBlocklist sends it:
3513
+ //
3514
+ // <iq xmlns="blocklist" type="set" to="s.whatsapp.net">
3515
+ // <item jid="<JID>" action="block|unblock"/>
3516
+ // </iq>
3517
+ //
3518
+ // Three things were wrong with the pair this replaces. The jid went out as a
3519
+ // raw string rather than a binary JID. It was not reduced to its bare user
3520
+ // first, so blocking a contact you happened to name with a device suffix sent
3521
+ // the server a device JID it has nothing to block. And the reply was thrown
3522
+ // away without being looked at — including a type="error" reply — so a block
3523
+ // the server refused came back looking exactly like one that worked.
3524
+ async _updateBlocklist(jid, action) {
3525
+ const target = toNonAdJid(makeJid(jid));
3178
3526
  const node = new BinaryNode('iq', {
3179
- id,
3527
+ id: this._genMsgId(),
3180
3528
  xmlns: 'blocklist',
3181
3529
  to: 's.whatsapp.net',
3182
3530
  type: 'set'
3183
3531
  }, [
3184
- new BinaryNode('item', { action: 'unblock', jid: makeJid(jid) }, null)
3532
+ new BinaryNode('item', { jid: jidStrToObj(target), action }, null)
3185
3533
  ]);
3186
- return this._sendIq(node);
3534
+
3535
+ const resp = await this._sendIq(node);
3536
+ if (!resp) {
3537
+ throw new Error(action + 'Contact: no reply from server (IQ timed out)');
3538
+ }
3539
+ if (resp.attrs && resp.attrs.type === 'error') {
3540
+ const errNode = findChild(resp, 'error');
3541
+ const code = errNode && errNode.attrs && errNode.attrs.code;
3542
+ const text = errNode && errNode.attrs && errNode.attrs.text;
3543
+ throw new Error(action + 'Contact: server rejected the request' +
3544
+ (code ? ' (' + code + (text ? ' ' + text : '') + ')' : ''));
3545
+ }
3546
+ // The server answers with the whole list as it now stands.
3547
+ return this._parseBlocklist(findChild(resp, 'list')).jids;
3187
3548
  }
3188
3549
 
3189
3550
  // Pin / unpin a chat (mobile: local state)
@@ -37,6 +37,16 @@ function isGroupJid(jid) {
37
37
  return jid.endsWith('@g.us') || jid.endsWith('@community');
38
38
  }
39
39
 
40
+ // A Status/Story is posted as a message to status@broadcast, and it travels the
41
+ // SenderKey path a group message does — one skmsg plus a distribution to every
42
+ // recipient — not the 1:1 path. Baileys calls the pair isGroupOrStatus;
43
+ // whatsmeow routes BroadcastServer into sendGroup for the same reason.
44
+ const STATUS_BROADCAST_JID = 'status@broadcast';
45
+
46
+ function isStatusBroadcast(jid) {
47
+ return String(jid) === STATUS_BROADCAST_JID;
48
+ }
49
+
40
50
  function phoneFromJid(jid) {
41
51
  const at = jid.indexOf('@');
42
52
  const user = at >= 0 ? jid.slice(0, at) : jid;
@@ -615,7 +625,11 @@ class MessageSender {
615
625
 
616
626
  async _sendMessage(toJid, msgId, plaintext, mediaType, options) {
617
627
  options = options || {};
618
- if (isGroupJid(toJid)) {
628
+ // status@broadcast is neither a group JID nor a user JID. It used to fall
629
+ // through to the 1:1 path, which took "status" for a phone number, ran a
630
+ // usync for it and tried to open a Signal session with status@s.whatsapp.net
631
+ // — so a Status could not be posted at all.
632
+ if (isGroupJid(toJid) || isStatusBroadcast(toJid)) {
619
633
  try {
620
634
  return await this._sendGroupMessage(toJid, msgId, plaintext, mediaType, options);
621
635
  } catch (err) {
@@ -1086,9 +1100,27 @@ class MessageSender {
1086
1100
  const ownJid = `${ownPhone}@s.whatsapp.net`;
1087
1101
  const mediaSubtype = options._mediaSubtype || null;
1088
1102
 
1103
+ const isStatus = isStatusBroadcast(groupJid);
1104
+
1105
+ // A Status has no group metadata; its recipients come from the account's
1106
+ // status privacy settings, or from a list the caller supplied.
1107
+ let members;
1108
+ if (isStatus) {
1109
+ members = options._statusRecipients ||
1110
+ (typeof this._client._getStatusRecipients === 'function'
1111
+ ? await this._client._getStatusRecipients()
1112
+ : []);
1113
+ if (!members.length) {
1114
+ throw new Error(
1115
+ 'Cannot post a Status: no recipients. Sync history so the contact ' +
1116
+ 'list is known, or pass { recipients: [...] }.'
1117
+ );
1118
+ }
1119
+ }
1120
+
1089
1121
  // Auto-fetch group metadata if member cache is empty (first send to this group)
1090
- let members = this._client._getGroupMembers(groupJid);
1091
- if (members.length === 0 && typeof this._client.getGroupMetadata === 'function') {
1122
+ if (!isStatus) members = this._client._getGroupMembers(groupJid);
1123
+ if (!isStatus && members.length === 0 && typeof this._client.getGroupMetadata === 'function') {
1092
1124
  _whaDbg('[DBG] GROUP_SEND auto-fetch metadata for ' + groupJid);
1093
1125
  let metaErr = null;
1094
1126
  try {
@@ -1113,8 +1145,13 @@ class MessageSender {
1113
1145
  }
1114
1146
  }
1115
1147
 
1116
- // Determine addressing mode for this group (lid = modern groups, pn = legacy)
1117
- const groupAddressingMode = this._client._groupAddressingMode.get(groupJid) || 'lid';
1148
+ // Determine addressing mode for this group (lid = modern groups, pn = legacy).
1149
+ // A Status carries none: whatsmeow sets addressing_mode only for the group
1150
+ // server, and its recipients are ordinary contacts, so everything below
1151
+ // stays in phone space.
1152
+ const groupAddressingMode = isStatus
1153
+ ? null
1154
+ : (this._client._groupAddressingMode.get(groupJid) || 'lid');
1118
1155
  // A stanza addressed with addressing_mode='lid' must carry participants in
1119
1156
  // LID space only. Mixing @s.whatsapp.net targets into it is exactly what
1120
1157
  // DeviceManager warns about, and the server answers it with ack 479.
@@ -1285,10 +1322,10 @@ class MessageSender {
1285
1322
  to: jidStrToObj(groupJid),
1286
1323
  id: msgId,
1287
1324
  type: mediaType,
1288
- addressing_mode: groupAddressingMode,
1289
1325
  phash,
1290
1326
  t: String(msNow())
1291
1327
  };
1328
+ if (groupAddressingMode) stanzaAttrs.addressing_mode = groupAddressingMode;
1292
1329
  if (options.edit) stanzaAttrs.edit = String(options.edit);
1293
1330
 
1294
1331
  // Cache the plaintext here too, not just on the DM path. A group recipient
@@ -53,6 +53,15 @@ function bytes(b) { return b ? Buffer.from(b) : Buffer.alloc(0); }
53
53
  function str(s) { return s ? Buffer.from(s, 'utf8') : Buffer.alloc(0); }
54
54
  function bool(b) { return encodeVarint(b ? 1 : 0); }
55
55
 
56
+ const WIRE_32 = 5;
57
+
58
+ // fixed32 — four little-endian bytes, wire type 5. ARGB colours use this.
59
+ function fixed32(fieldNum, value) {
60
+ const buf = Buffer.allocUnsafe(4);
61
+ buf.writeUInt32LE((value >>> 0), 0);
62
+ return Buffer.concat([tag(fieldNum, WIRE_32), buf]);
63
+ }
64
+
56
65
  function encodeDouble(fieldNum, value) {
57
66
  const t = tag(fieldNum, WIRE_64);
58
67
  const buf = Buffer.allocUnsafe(8);
@@ -342,8 +351,19 @@ function encodeProtocolMessage(opts) {
342
351
 
343
352
  // ─── Extended text message for editing ───────────────────────────────────────
344
353
  // ExtendedTextMessage { text=1 } — used when editing a text message
345
- function encodeExtendedText(text) {
346
- return field(1, WIRE_LEN, str(text));
354
+ // ExtendedTextMessage { text=1, textArgb=7, backgroundArgb=8, font=9,
355
+ // jpegThumbnail=16, contextInfo=17 }
356
+ //
357
+ // The colour fields are what a text Status is drawn with; both are fixed32
358
+ // (wire type 5), not varints, so they are written little-endian in four bytes.
359
+ function encodeExtendedText(text, opts) {
360
+ opts = opts || {};
361
+ const parts = [field(1, WIRE_LEN, str(text))];
362
+ if (opts.textArgb != null) parts.push(fixed32(7, opts.textArgb));
363
+ if (opts.backgroundArgb != null) parts.push(fixed32(8, opts.backgroundArgb));
364
+ if (opts.font != null) parts.push(field(9, WIRE_VARINT, varint(opts.font)));
365
+ if (opts.contextInfo) parts.push(field(17, WIRE_LEN, encodeContextInfo(opts.contextInfo)));
366
+ return Buffer.concat(parts);
347
367
  }
348
368
 
349
369
  // ─── Protobuf decoder ─────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.5.86",
3
+ "version": "5.5.89",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API no web",
5
5
  "author": "Kunboruto50",
6
6
  "main": "index.js",