whalibmob 5.5.83 → 5.5.84

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/cli.js CHANGED
@@ -362,7 +362,9 @@ const HELP = `
362
362
 
363
363
  Contacts
364
364
  /whatsapp <phone...> check which numbers have WhatsApp
365
- /picture <jid> get profile picture URL of a contact
365
+ /picture <jid> [file] profile picture URL; downloads it when a file is given
366
+ /read <jid> <msgId...> mark messages as read (blue ticks)
367
+ /autoread on|off auto-send read receipts for incoming messages
366
368
  /contact about <jid> get bio/status text of a contact
367
369
 
368
370
  Chats
@@ -393,7 +395,7 @@ const HELP = `
393
395
  /group desc <jid> <text> change description
394
396
  /group invite <jid> get invite link
395
397
  /group revoke <jid> revoke invite link
396
- /group join <code> join by invite code
398
+ /group join <code|link> join by invite code or full link
397
399
  /group invite-info <code|url> preview group metadata from invite link
398
400
  /group photo <jid> <file> change group picture (JPEG)
399
401
  /group meta <jid> query group metadata + participants
@@ -1003,11 +1005,37 @@ async function handleLine(line) {
1003
1005
  }
1004
1006
 
1005
1007
  case '/picture': {
1008
+ requireConn();
1009
+ const jid = normalizeJid(p[1]);
1010
+ const dest = p[2];
1011
+ if (!jid) { fail('usage: /picture <jid> [file]'); break; }
1012
+ const info = await _client.queryPictureInfo(jid);
1013
+ if (!info || !info.url) { out(' (no picture or private)'); break; }
1014
+ out(' ' + info.url);
1015
+ if (info.id) out(' id ' + info.id);
1016
+ if (!dest) break;
1017
+ const buf = await _client.downloadProfilePicture(jid);
1018
+ if (!buf) { fail('download returned nothing'); break; }
1019
+ require('fs').writeFileSync(dest, buf);
1020
+ out(' saved ' + dest + ' (' + buf.length + ' bytes)');
1021
+ break;
1022
+ }
1023
+
1024
+ case '/read': {
1006
1025
  requireConn();
1007
1026
  const jid = normalizeJid(p[1]);
1008
- if (!jid) { fail('usage: /picture <jid>'); break; }
1009
- const url = await _client.queryPicture(jid);
1010
- out(' ' + (url || '(no picture or private)'));
1027
+ const ids = p.slice(2);
1028
+ if (!jid || !ids.length) { fail('usage: /read <jid> <msgId...>'); break; }
1029
+ _client.markRead(jid, ids);
1030
+ out('marked read ' + ids.length + ' message(s) in ' + jid);
1031
+ break;
1032
+ }
1033
+
1034
+ case '/autoread': {
1035
+ const val = (p[1] || '').toLowerCase();
1036
+ if (val !== 'on' && val !== 'off') { fail('usage: /autoread on|off'); break; }
1037
+ if (_client) _client.autoRead = (val === 'on');
1038
+ out('auto read receipts ' + val);
1011
1039
  break;
1012
1040
  }
1013
1041
 
@@ -1232,14 +1260,15 @@ async function handleLine(line) {
1232
1260
  else if (sub === 'revoke') {
1233
1261
  const gj = asGroupJid(p[2]);
1234
1262
  if (!gj) { fail('usage: /group revoke <jid>'); break; }
1235
- await _client.revokeGroupInvite(gj);
1263
+ const fresh = await _client.revokeGroupInvite(gj);
1236
1264
  out('invite link revoked');
1265
+ if (fresh) out(' new link ' + fresh);
1237
1266
  }
1238
1267
  else if (sub === 'join') {
1239
1268
  const code = p[2];
1240
- if (!code) { fail('usage: /group join <code>'); break; }
1241
- const jid = await _client.acceptGroupInvite(code);
1242
- out('joined ' + (jid || 'ok'));
1269
+ if (!code) { fail('usage: /group join <code|link>'); break; }
1270
+ const r = await _client.joinGroupWithLink(code);
1271
+ out((r.pendingApproval ? 'join request sent ' : 'joined ') + r.jid);
1243
1272
  }
1244
1273
  else if (sub === 'invite-info') {
1245
1274
  const code = p[2];
package/lib/Client.js CHANGED
@@ -21,7 +21,7 @@ const { NodeCache } = require('@cacheable/node-cache');
21
21
  // Group metadata cache lifetime — matches DeviceManager's device cache.
22
22
  const GROUP_META_CACHE_TTL = '5m';
23
23
  const { SignalProtocol } = require('./signal/SignalProtocol');
24
- const { DeviceManager } = require('./DeviceManager');
24
+ const { DeviceManager, jidStrToObj } = require('./DeviceManager');
25
25
  const {
26
26
  processHistorySyncNotification,
27
27
  decodeHistorySyncNotification,
@@ -30,6 +30,18 @@ const {
30
30
  HistorySyncType
31
31
  } = require('./HistorySyncHandler');
32
32
 
33
+ // whatsmeow's InviteLinkPrefix — the one prefix group invite codes are wrapped in.
34
+ const INVITE_LINK_PREFIX = 'https://chat.whatsapp.com/';
35
+
36
+ // Accept a full invite URL or a bare code and return the code.
37
+ function stripInviteUrl(codeOrUrl) {
38
+ return String(codeOrUrl || '')
39
+ .trim()
40
+ .replace(/^https?:\/\/chat\.whatsapp\.com\//i, '')
41
+ .replace(/[?#].*$/, '')
42
+ .trim();
43
+ }
44
+
33
45
  const PING_INTERVAL_MS = 25000;
34
46
  const KEEPALIVE_INTERVAL = 20000;
35
47
  const RECONNECT_BACKOFF = [1000, 2000, 4000, 8000, 15000, 30000];
@@ -163,6 +175,12 @@ class WhalibmobClient extends EventEmitter {
163
175
  this._tcTokenStore = null; // TcTokenStore — loaded in init()
164
176
  this._inFlightTcTokenIssuance = new Set(); // dedupe concurrent proactive issuePrivacyTokens per JID
165
177
  this._inFlight463Recoveries = new Set(); // dedupe concurrent 463-triggered token issuances per JID (separate from proactive)
178
+ // Blue-tick every message as it arrives. Set to false for whatsmeow's model,
179
+ // where read is only ever sent by an explicit markRead() call.
180
+ this.autoRead = opts.autoRead !== false;
181
+ // Flipped by setOnline(). Delivery receipts sent while this is false carry
182
+ // type="inactive", which the sender's app records but does not render.
183
+ this._sendActiveReceipts = false;
166
184
  if (opts.pino !== undefined) _whaConfigLogger(opts.pino);
167
185
  }
168
186
 
@@ -667,6 +685,7 @@ class WhalibmobClient extends EventEmitter {
667
685
 
668
686
  if (skmsgNode && isGroup) {
669
687
  this._sendMessageAck(id, fromRaw, partRaw);
688
+ this._sendDeliveryReceipt(id, fromRaw, partRaw);
670
689
  // SKDM must be processed (awaited) before we attempt skmsg decryption,
671
690
  // because the first message from a sender always bundles both together.
672
691
  if (participantsNode) {
@@ -678,6 +697,7 @@ class WhalibmobClient extends EventEmitter {
678
697
 
679
698
  if (dmEncNode && this._signal) {
680
699
  this._sendMessageAck(id, fromRaw, partRaw);
700
+ this._sendDeliveryReceipt(id, fromRaw, partRaw);
681
701
  this._decryptDMMessage({ node, from, participant, fromRaw, partRaw, senderPn, id, ts, dmEncNode });
682
702
  return;
683
703
  }
@@ -690,6 +710,7 @@ class WhalibmobClient extends EventEmitter {
690
710
  ) : null;
691
711
 
692
712
  this._sendMessageAck(id, fromRaw, partRaw);
713
+ this._sendDeliveryReceipt(id, fromRaw, partRaw);
693
714
  this.emit('message', { id, from, participant, ts, text, node });
694
715
  this._sendReadReceipt(id, fromRaw, partRaw);
695
716
  }
@@ -1009,15 +1030,55 @@ class WhalibmobClient extends EventEmitter {
1009
1030
  this._socket.sendNode(new BinaryNode('ack', attrs, null));
1010
1031
  }
1011
1032
 
1012
- // Read receipt — sent after decryption succeeds.
1013
- // Gives sender 2 blue checkmarks.
1033
+ // True when a JID names this account, in either address family.
1034
+ _isOwnUser(jid) {
1035
+ if (!jid) return false;
1036
+ const user = String(jid).split('@')[0].split(':')[0];
1037
+ if (!user) return false;
1038
+ if (user === String(this._store && this._store.phoneNumber)) return true;
1039
+ const ownLid = this._myLid ? String(this._myLid).split('@')[0].split(':')[0] : null;
1040
+ return !!ownLid && user === ownLid;
1041
+ }
1042
+
1043
+ // Delivery receipt — the sender's two grey ticks.
1044
+ //
1045
+ // Distinct from the <ack> above: the ack tells the server we took the stanza,
1046
+ // the receipt tells the sender it reached a device. whatsmeow sends both for
1047
+ // every incoming message (sendAck + sendMessageReceipt) and leaves the blue
1048
+ // ticks to an explicit MarkRead. whalibmob only ever sent the ack and then
1049
+ // jumped straight to type="read", so a sender never saw the grey stage.
1050
+ //
1051
+ // A client that has not marked itself available sends this with
1052
+ // type="inactive": the sender's app records the delivery but does not render
1053
+ // it, which is how WhatsApp Web behaves in the background.
1054
+ _sendDeliveryReceipt(msgId, fromJid, partJid) {
1055
+ if (!this._socket || !this._connected) return;
1056
+ const attrs = { id: msgId, to: fromJid };
1057
+ if (partJid && String(partJid) !== String(fromJid)) attrs.participant = partJid;
1058
+ // A message that came from one of our own other devices is receipted with
1059
+ // type="sender", never as a delivery to ourselves.
1060
+ if (this._isOwnUser(partJid || fromJid)) {
1061
+ attrs.type = 'sender';
1062
+ } else if (!this._sendActiveReceipts) {
1063
+ attrs.type = 'inactive';
1064
+ }
1065
+ this._socket.sendNode(new BinaryNode('receipt', attrs, null));
1066
+ }
1067
+
1068
+ // Read receipt — the sender's two blue ticks.
1069
+ //
1070
+ // Sent automatically after a message decodes unless autoRead is turned off, in
1071
+ // which case marking a chat read is up to the caller via markRead(). Turning
1072
+ // it off gives whatsmeow's model, where read is always explicit.
1014
1073
  // fromJid / partJid are raw JID objects (AD_JID encoding for correct routing).
1015
1074
  _sendReadReceipt(msgId, fromJid, partJid) {
1016
1075
  if (!this._socket || !this._connected) return;
1076
+ if (this.autoRead === false) return;
1017
1077
  const node = new BinaryNode('receipt', {
1018
1078
  id: msgId,
1019
1079
  type: 'read',
1020
- to: fromJid
1080
+ to: fromJid,
1081
+ t: String(Math.floor(Date.now() / 1000))
1021
1082
  }, null);
1022
1083
  if (partJid && String(partJid) !== String(fromJid)) {
1023
1084
  node.attrs.participant = partJid;
@@ -1523,8 +1584,11 @@ class WhalibmobClient extends EventEmitter {
1523
1584
  return { jid: pJid, role: pRole };
1524
1585
  });
1525
1586
 
1526
- // description
1587
+ // description — the id is the "topic id" the server wants echoed back as
1588
+ // prev= on the next edit, so it has to survive the parse.
1527
1589
  const descNode = children.find(n => n && n.description === 'description');
1590
+ const descriptionId = (descNode && descNode.attrs && descNode.attrs.id)
1591
+ ? String(descNode.attrs.id) : null;
1528
1592
  let description = null;
1529
1593
  if (descNode) {
1530
1594
  const bodyNode = Array.isArray(descNode.content)
@@ -1563,6 +1627,7 @@ class WhalibmobClient extends EventEmitter {
1563
1627
  subjectTime: parseInt(a.s_t, 10) || 0,
1564
1628
  subjectBy: a.s_o || null,
1565
1629
  description,
1630
+ descriptionId,
1566
1631
  ephemeral,
1567
1632
  onlyAdminsSend: announce,
1568
1633
  onlyAdminsEdit: restrict,
@@ -2182,12 +2247,54 @@ class WhalibmobClient extends EventEmitter {
2182
2247
 
2183
2248
  // ─── Public API ───────────────────────────────────────────────────────────
2184
2249
 
2250
+ // Alias kept from the original API — setOnline is the implementation.
2185
2251
  setPresence(available) {
2186
- if (this._sender) this._sender.sendPresence(available);
2252
+ return this.setOnline(available);
2187
2253
  }
2188
2254
 
2189
- markRead(jid, messageIds) {
2190
- if (this._sender) this._sender.markRead(jid, messageIds);
2255
+ // Mark messages as read — the sender's two blue ticks.
2256
+ //
2257
+ // jid chat the messages are in (the group JID for a group)
2258
+ // messageIds one id or an array of ids, all from the SAME sender
2259
+ // opts { participant, type } — participant is who sent them and is
2260
+ // required in a group; type may be 'read' (default) or 'played'
2261
+ // to mark a voice note as listened to.
2262
+ //
2263
+ // Shaped the way whatsmeow's MarkRead builds it: the FIRST id on the receipt
2264
+ // and the rest as <item> children of a <list> node. whalibmob used the last id
2265
+ // and put the items straight into <receipt>, which is not a shape the server
2266
+ // reads back — the extra ids were silently ignored, so only one message per
2267
+ // call was ever marked. The participant was missing entirely, so a group read
2268
+ // never told the server whose message had been read.
2269
+ markRead(jid, messageIds, opts) {
2270
+ if (!this._socket || !this._connected) return;
2271
+ const ids = (Array.isArray(messageIds) ? messageIds : [messageIds])
2272
+ .map(String).filter(Boolean);
2273
+ if (ids.length === 0) return;
2274
+
2275
+ opts = opts || {};
2276
+ const chatJid = makeJid(jid);
2277
+ const isGroup = chatJid.endsWith('@g.us') || chatJid.endsWith('@community')
2278
+ || chatJid.endsWith('@broadcast');
2279
+
2280
+ const attrs = {
2281
+ id: ids[0],
2282
+ type: opts.type === 'played' ? 'played' : 'read',
2283
+ to: jidStrToObj(chatJid),
2284
+ t: String(Math.floor(Date.now() / 1000))
2285
+ };
2286
+ // Only group and broadcast receipts carry a participant; on a 1:1 chat the
2287
+ // `to` already identifies the sender.
2288
+ if (isGroup && opts.participant) {
2289
+ attrs.participant = jidStrToObj(makeJid(opts.participant));
2290
+ }
2291
+
2292
+ const children = ids.length > 1
2293
+ ? [new BinaryNode('list', {}, ids.slice(1).map(
2294
+ id => new BinaryNode('item', { id }, null)))]
2295
+ : null;
2296
+
2297
+ this._socket.sendNode(new BinaryNode('receipt', attrs, children));
2191
2298
  }
2192
2299
 
2193
2300
  async sendText(to, text, opts) {
@@ -2229,43 +2336,89 @@ class WhalibmobClient extends EventEmitter {
2229
2336
  // PRESENCE
2230
2337
  // ──────────────────────────────────────────────────────────────────────────
2231
2338
 
2232
- // Set global online/offline status)
2339
+ // Our own JID in the family a chat is addressed with — the LID when the chat
2340
+ // is LID-addressed, the phone JID otherwise. chatstate carries a `from`, and
2341
+ // it has to be in the same space as the `to` beside it.
2342
+ _ownJidFor(chatJid) {
2343
+ const str = String(chatJid || '');
2344
+ if (str.endsWith('@lid') && this._myLid) {
2345
+ return String(this._myLid).split('@')[0].split(':')[0] + '@lid';
2346
+ }
2347
+ return String(this._store && this._store.phoneNumber) + '@s.whatsapp.net';
2348
+ }
2349
+
2350
+ // Set global online/offline status.
2351
+ //
2352
+ // whatsmeow refuses to send this without a push name (ErrNoPushName): the
2353
+ // server takes the display name from this node, and sending it empty makes
2354
+ // every other user see "-" instead of a name. Going available also flips the
2355
+ // client into sending active delivery receipts — see _sendDeliveryReceipt.
2356
+ //
2357
+ // Returns true when the node went out, false when it was skipped.
2233
2358
  setOnline(available) {
2234
- if (!this._socket || !this._connected) return;
2235
- const name = (this._store && this._store.pushName) || '';
2359
+ if (!this._socket || !this._connected) return false;
2360
+ const name = (this._store && (this._store.pushName || this._store.name)) || '';
2361
+ if (!name) {
2362
+ _whaDbg('[DBG] PRESENCE skipped: no push name set — other users would see "-"');
2363
+ return false;
2364
+ }
2365
+ this._sendActiveReceipts = !!available;
2236
2366
  this._socket.sendNode(new BinaryNode('presence', {
2237
2367
  name,
2238
2368
  type: available ? 'available' : 'unavailable'
2239
2369
  }, null));
2370
+ return true;
2240
2371
  }
2241
2372
 
2242
- // Set typing/recording presence in a specific chat)
2373
+ // Set typing/recording presence in a specific chat.
2243
2374
  // presence: 'composing' | 'paused' | 'recording'
2375
+ //
2376
+ // whatsmeow's SendChatPresence puts both `from` (our own JID) and `to` on the
2377
+ // chatstate node and expresses recording as composing with media="audio" —
2378
+ // there is no separate recording state in the protocol. The `from` was missing
2379
+ // here, and both JIDs were raw strings, which the server ignores for @lid
2380
+ // chats, so the indicator never showed for a LID-addressed contact.
2244
2381
  setChatPresence(chatJid, presence) {
2245
2382
  if (!this._socket || !this._connected) return;
2246
- const jid = makeJid(chatJid);
2383
+ const jid = makeJid(chatJid);
2384
+ const attrs = {
2385
+ from: jidStrToObj(this._ownJidFor(jid)),
2386
+ to: jidStrToObj(jid)
2387
+ };
2247
2388
  if (presence === 'composing' || presence === 'recording') {
2248
- const attrs = {};
2249
- if (presence === 'recording') attrs.media = 'audio';
2250
- this._socket.sendNode(new BinaryNode('chatstate', { to: jid }, [
2251
- new BinaryNode('composing', attrs, null)
2389
+ const stateAttrs = presence === 'recording' ? { media: 'audio' } : {};
2390
+ this._socket.sendNode(new BinaryNode('chatstate', attrs, [
2391
+ new BinaryNode('composing', stateAttrs, null)
2252
2392
  ]));
2253
2393
  } else {
2254
- this._socket.sendNode(new BinaryNode('chatstate', { to: jid }, [
2394
+ this._socket.sendNode(new BinaryNode('chatstate', attrs, [
2255
2395
  new BinaryNode('paused', {}, null)
2256
2396
  ]));
2257
2397
  }
2258
2398
  }
2259
2399
 
2260
- // Subscribe to presence updates for contacts
2400
+ // Subscribe to presence updates for contacts.
2401
+ //
2402
+ // whatsmeow attaches the contact's privacy token when it has one; without it
2403
+ // the server may decline to send their presence. It also notes that the server
2404
+ // only forwards presence to clients that have marked themselves available, so
2405
+ // call setOnline(true) first.
2261
2406
  subscribeToPresence(jids) {
2262
2407
  if (!this._socket || !this._connected) return;
2263
2408
  const arr = Array.isArray(jids) ? jids : [jids];
2264
- for (const jid of arr) {
2409
+ const { normalizeJidForTcToken } = require('./messages/TcTokenStore');
2410
+ for (const raw of arr) {
2411
+ const jid = makeJid(raw);
2412
+ const entry = this._tcTokenStore && this._tcTokenStore.get(
2413
+ normalizeJidForTcToken(jid)
2414
+ );
2415
+ const children = (entry && entry.token && entry.token.length)
2416
+ ? [new BinaryNode('tctoken', {}, entry.token)]
2417
+ : null;
2265
2418
  this._socket.sendNode(new BinaryNode('presence', {
2266
- to: makeJid(jid),
2419
+ to: jidStrToObj(jid),
2267
2420
  type: 'subscribe'
2268
- }, null));
2421
+ }, children));
2269
2422
  }
2270
2423
  }
2271
2424
 
@@ -2359,22 +2512,97 @@ class WhalibmobClient extends EventEmitter {
2359
2512
  }
2360
2513
 
2361
2514
  // Query profile picture URL
2362
- async queryPicture(jid) {
2515
+ // Profile picture URL, or null when there is none.
2516
+ // Unchanged signature; queryPictureInfo has the rest of what the server sends.
2517
+ async queryPicture(jid, opts) {
2518
+ const info = await this.queryPictureInfo(jid, opts);
2519
+ return (info && info.url) || null;
2520
+ }
2521
+
2522
+ // Full profile picture info, the way whatsmeow's GetProfilePictureInfo returns
2523
+ // it: { id, url, type, directPath, hash }, or null when the user has no
2524
+ // picture or it has not changed since opts.existingId.
2525
+ //
2526
+ // opts: { preview, existingId, isCommunity }
2527
+ // preview ask for the small thumbnail instead of the full image
2528
+ // existingId the id you already hold — the server answers 304 and this
2529
+ // returns null when it still matches
2530
+ // isCommunity resolve a community's picture, which goes through w:g2
2531
+ //
2532
+ // The target was a raw string before, which the server drops for an @lid, and
2533
+ // the reply's status codes were not read at all: a 204 (no picture set) has no
2534
+ // url attribute, so it came back as null with no way to tell it apart from a
2535
+ // failed query.
2536
+ async queryPictureInfo(jid, opts) {
2537
+ opts = opts || {};
2363
2538
  const jidFull = makeJid(jid);
2364
- const id = this._genMsgId();
2365
- const node = new BinaryNode('iq', {
2366
- id,
2367
- xmlns: 'w:profile:picture',
2368
- to: 's.whatsapp.net',
2369
- target: jidFull,
2370
- type: 'get'
2371
- }, [
2372
- new BinaryNode('picture', { query: 'url', type: 'image' }, null)
2373
- ]);
2374
- const resp = await this._sendIq(node);
2539
+ const attrs = {
2540
+ query: 'url',
2541
+ type: opts.preview ? 'preview' : 'image'
2542
+ };
2543
+ if (opts.existingId) attrs.id = String(opts.existingId);
2544
+ if (opts.inviteCode) attrs.invite = String(opts.inviteCode);
2545
+
2546
+ let iqAttrs, content;
2547
+ if (opts.isCommunity) {
2548
+ attrs.parent_group_jid = jidStrToObj(jidFull);
2549
+ iqAttrs = { id: this._genMsgId(), xmlns: 'w:g2', to: jidStrToObj(jidFull), type: 'get' };
2550
+ content = [new BinaryNode('pictures', {}, [new BinaryNode('picture', attrs, null)])];
2551
+ } else {
2552
+ const { normalizeJidForTcToken } = require('./messages/TcTokenStore');
2553
+ const entry = this._tcTokenStore &&
2554
+ this._tcTokenStore.get(normalizeJidForTcToken(jidFull));
2555
+ const picChildren = (entry && entry.token && entry.token.length)
2556
+ ? [new BinaryNode('tctoken', {}, entry.token)]
2557
+ : null;
2558
+ iqAttrs = {
2559
+ id: this._genMsgId(),
2560
+ xmlns: 'w:profile:picture',
2561
+ to: 's.whatsapp.net',
2562
+ target: jidStrToObj(jidFull),
2563
+ type: 'get'
2564
+ };
2565
+ content = [new BinaryNode('picture', attrs, picChildren)];
2566
+ }
2567
+
2568
+ const resp = await this._sendIq(new BinaryNode('iq', iqAttrs, content));
2375
2569
  if (!resp) return null;
2376
- const pic = findChild(resp, 'picture');
2377
- return (pic && pic.attrs && pic.attrs.url) || null;
2570
+ if (resp.attrs && resp.attrs.type === 'error') {
2571
+ const errNode = findChild(resp, 'error');
2572
+ const code = errNode && errNode.attrs && String(errNode.attrs.code);
2573
+ // 404 = no picture set, 401/403 = their privacy settings hide it. Neither
2574
+ // is a failure worth throwing over.
2575
+ if (code === '404' || code === '401' || code === '403') return null;
2576
+ throw new Error('queryPictureInfo: server rejected the request' +
2577
+ (code ? ' (' + code + ')' : ''));
2578
+ }
2579
+
2580
+ const wrapper = opts.isCommunity ? findChild(resp, 'pictures') : resp;
2581
+ const pic = wrapper ? findChild(wrapper, 'picture') : null;
2582
+ if (!pic || !pic.attrs) return null;
2583
+
2584
+ const status = pic.attrs.status ? parseInt(pic.attrs.status, 10) : 0;
2585
+ if (status === 304 || status === 204) return null; // unchanged / not set
2586
+
2587
+ return {
2588
+ id: pic.attrs.id ? String(pic.attrs.id) : null,
2589
+ url: pic.attrs.url ? String(pic.attrs.url) : null,
2590
+ type: pic.attrs.type ? String(pic.attrs.type) : null,
2591
+ directPath: pic.attrs.direct_path ? String(pic.attrs.direct_path) : null,
2592
+ hash: pic.attrs.hash
2593
+ ? Buffer.from(String(pic.attrs.hash), 'base64')
2594
+ : null
2595
+ };
2596
+ }
2597
+
2598
+ // Download a profile picture and hand back the JPEG bytes, or null when the
2599
+ // user has none. Profile pictures are served unencrypted, unlike message
2600
+ // media, so this is a plain fetch of the URL the query returns.
2601
+ async downloadProfilePicture(jid, opts) {
2602
+ const info = await this.queryPictureInfo(jid, opts);
2603
+ if (!info || !info.url) return null;
2604
+ const { httpGetBuffer } = require('./MediaService');
2605
+ return httpGetBuffer(info.url);
2378
2606
  }
2379
2607
 
2380
2608
  // Query blocked contacts list
@@ -3042,18 +3270,69 @@ class WhalibmobClient extends EventEmitter {
3042
3270
 
3043
3271
  // Create a group
3044
3272
  // participants: array of phone numbers or JIDs
3273
+ // Create a group.
3274
+ //
3275
+ // opts: { ephemeralSeconds, announce, locked, joinApprovalRequired,
3276
+ // memberAddMode, parent, linkedParentJid }
3277
+ //
3278
+ // Built the way whatsmeow's CreateGroup does: every participant is a binary
3279
+ // JID (a raw string is dropped for an @lid), a LID participant also carries
3280
+ // the phone number the server matches it against, and each carries our privacy
3281
+ // token so the invite is not treated as an anonymous reach-out. The setting
3282
+ // nodes are siblings of the participants inside <create>.
3045
3283
  async createGroup(subject, participants, opts) {
3046
3284
  opts = opts || {};
3047
3285
  const id = this._genMsgId();
3286
+ const { normalizeJidForTcToken } = require('./messages/TcTokenStore');
3048
3287
  // participant nodes MUST use attr `jid`, not `value`
3049
3288
  const partNodes = (participants || []).map(p => {
3050
- const jid = makeJid(p);
3051
- return new BinaryNode('participant', { jid }, null);
3289
+ const jid = makeJid(p);
3290
+ const attrs = { jid: jidStrToObj(jid) };
3291
+ if (jid.endsWith('@lid')) {
3292
+ const pn = this._lidToPn.get(jid.split('@')[0].split(':')[0]);
3293
+ if (pn) attrs.phone_number = jidStrToObj(pn + '@s.whatsapp.net');
3294
+ }
3295
+ const entry = this._tcTokenStore &&
3296
+ this._tcTokenStore.get(normalizeJidForTcToken(jid));
3297
+ const children = (entry && entry.token && entry.token.length)
3298
+ ? [new BinaryNode('privacy', {}, entry.token)]
3299
+ : null;
3300
+ return new BinaryNode('participant', attrs, children);
3052
3301
  });
3302
+
3303
+ const settingNodes = [];
3304
+ if (opts.parent) {
3305
+ settingNodes.push(new BinaryNode('parent', {
3306
+ default_membership_approval_mode:
3307
+ opts.defaultMembershipApprovalMode || 'request_required'
3308
+ }, null));
3309
+ } else if (opts.linkedParentJid) {
3310
+ settingNodes.push(new BinaryNode('linked_parent', {
3311
+ jid: jidStrToObj(makeJid(opts.linkedParentJid))
3312
+ }, null));
3313
+ }
3314
+ if (opts.locked) settingNodes.push(new BinaryNode('locked', {}, null));
3315
+ if (opts.announce) settingNodes.push(new BinaryNode('announcement', {}, null));
3316
+ if (opts.ephemeralSeconds) {
3317
+ // whatsmeow sends trigger="1" alongside the expiration; without it the
3318
+ // server accepts the group but leaves disappearing messages off.
3319
+ settingNodes.push(new BinaryNode('ephemeral', {
3320
+ expiration: String(opts.ephemeralSeconds),
3321
+ trigger: '1'
3322
+ }, null));
3323
+ }
3324
+ if (opts.joinApprovalRequired) {
3325
+ settingNodes.push(new BinaryNode('membership_approval_mode', {}, [
3326
+ new BinaryNode('group_join', { state: 'on' }, null)
3327
+ ]));
3328
+ }
3329
+ if (opts.memberAddMode) {
3330
+ settingNodes.push(new BinaryNode('member_add_mode', {},
3331
+ Buffer.from(String(opts.memberAddMode), 'utf8')));
3332
+ }
3333
+
3053
3334
  const baseAttrs = { subject, key: String(Math.floor(Date.now() / 1000)) };
3054
- const createChildren = opts.ephemeralSeconds
3055
- ? [...partNodes, new BinaryNode('ephemeral', { expiration: String(opts.ephemeralSeconds) }, null)]
3056
- : partNodes;
3335
+ const createChildren = [...partNodes, ...settingNodes];
3057
3336
  const node = new BinaryNode('iq', {
3058
3337
  id,
3059
3338
  xmlns: 'w:g2',
@@ -3124,14 +3403,31 @@ class WhalibmobClient extends EventEmitter {
3124
3403
  async _groupParticipantAction(groupJid, action, jids) {
3125
3404
  const gJid = makeJid(groupJid);
3126
3405
  const arr = Array.isArray(jids) ? jids : [jids];
3127
- const participantNodes = arr.map(j =>
3128
- new BinaryNode('participant', { jid: makeJid(j) }, null)
3129
- );
3406
+ const { normalizeJidForTcToken } = require('./messages/TcTokenStore');
3407
+ // Same shape whatsmeow's UpdateGroupParticipants builds: binary JIDs, and on
3408
+ // an add, the phone number behind a LID plus our privacy token for them.
3409
+ const participantNodes = arr.map(j => {
3410
+ const jid = makeJid(j);
3411
+ const attrs = { jid: jidStrToObj(jid) };
3412
+ let children = null;
3413
+ if (action === 'add') {
3414
+ if (jid.endsWith('@lid')) {
3415
+ const pn = this._lidToPn.get(jid.split('@')[0].split(':')[0]);
3416
+ if (pn) attrs.phone_number = jidStrToObj(pn + '@s.whatsapp.net');
3417
+ }
3418
+ const entry = this._tcTokenStore &&
3419
+ this._tcTokenStore.get(normalizeJidForTcToken(jid));
3420
+ if (entry && entry.token && entry.token.length) {
3421
+ children = [new BinaryNode('privacy', {}, entry.token)];
3422
+ }
3423
+ }
3424
+ return new BinaryNode('participant', attrs, children);
3425
+ });
3130
3426
  const id = this._genMsgId();
3131
3427
  const node = new BinaryNode('iq', {
3132
3428
  id,
3133
3429
  xmlns: 'w:g2',
3134
- to: gJid,
3430
+ to: jidStrToObj(gJid),
3135
3431
  type: 'set'
3136
3432
  }, [
3137
3433
  new BinaryNode(action, {}, participantNodes)
@@ -3161,19 +3457,38 @@ class WhalibmobClient extends EventEmitter {
3161
3457
  }
3162
3458
 
3163
3459
  // Change group description
3164
- // description: string or null to remove
3165
- async changeGroupDescription(groupJid, description) {
3166
- const id = this._genMsgId();
3167
- const children = description != null
3168
- ? [new BinaryNode('body', {}, Buffer.from(description, 'utf8'))]
3169
- : null;
3170
- const descAttrs = description != null
3171
- ? { id: this._genMsgId() }
3172
- : { delete: 'true' };
3460
+ // description: string or null/'' to remove
3461
+ //
3462
+ // whatsmeow's SetGroupTopic sends prev=<current topic id> so the server can
3463
+ // reject an edit made against a description someone else has already replaced.
3464
+ // Without it the change is refused. The current id is looked up from the group
3465
+ // metadata when the caller does not supply one.
3466
+ async changeGroupDescription(groupJid, description, opts) {
3467
+ opts = opts || {};
3468
+ const gJid = makeJid(groupJid);
3469
+ const id = this._genMsgId();
3470
+ const isDelete = description == null || description === '';
3471
+
3472
+ let prevId = opts.prevId;
3473
+ if (prevId === undefined) {
3474
+ try {
3475
+ const meta = await this.getGroupMetadata(gJid);
3476
+ prevId = meta && meta.descriptionId;
3477
+ } catch (_) { prevId = null; }
3478
+ }
3479
+
3480
+ const descAttrs = { id: opts.newId || this._genMsgId() };
3481
+ if (prevId) descAttrs.prev = prevId;
3482
+ let children = [new BinaryNode('body', {}, Buffer.from(String(description), 'utf8'))];
3483
+ if (isDelete) {
3484
+ descAttrs.delete = 'true';
3485
+ children = null;
3486
+ }
3487
+
3173
3488
  const node = new BinaryNode('iq', {
3174
3489
  id,
3175
3490
  xmlns: 'w:g2',
3176
- to: makeJid(groupJid),
3491
+ to: jidStrToObj(gJid),
3177
3492
  type: 'set'
3178
3493
  }, [
3179
3494
  new BinaryNode('description', descAttrs, children)
@@ -3181,6 +3496,61 @@ class WhalibmobClient extends EventEmitter {
3181
3496
  return this._sendIq(node);
3182
3497
  }
3183
3498
 
3499
+ // ─── Group settings ───────────────────────────────────────────────────────
3500
+ //
3501
+ // Both are a single empty node whose TAG carries the state, exactly as
3502
+ // whatsmeow's SetGroupAnnounce / SetGroupLocked send them — there is no
3503
+ // attribute form of these.
3504
+
3505
+ // announce=true → only admins can send messages
3506
+ async setGroupAnnounce(groupJid, announce) {
3507
+ return this._groupSettingIq(groupJid, announce ? 'announcement' : 'not_announcement');
3508
+ }
3509
+
3510
+ // locked=true → only admins can edit group info (subject, description, icon)
3511
+ async setGroupLocked(groupJid, locked) {
3512
+ return this._groupSettingIq(groupJid, locked ? 'locked' : 'unlocked');
3513
+ }
3514
+
3515
+ // mode=true → new members must be approved by an admin
3516
+ async setGroupJoinApprovalMode(groupJid, mode) {
3517
+ return this._groupSettingIq(groupJid, 'membership_approval_mode', [
3518
+ new BinaryNode('group_join', { state: mode ? 'on' : 'off' }, null)
3519
+ ]);
3520
+ }
3521
+
3522
+ // mode: 'all_member_add' | 'admin_add'
3523
+ async setGroupMemberAddMode(groupJid, mode) {
3524
+ return this._groupSettingIq(groupJid, 'member_add_mode',
3525
+ Buffer.from(String(mode), 'utf8'));
3526
+ }
3527
+
3528
+ // content is passed to the setting node verbatim: null for the flag settings
3529
+ // whose state lives in the tag, child nodes for membership_approval_mode, raw
3530
+ // bytes for member_add_mode.
3531
+ async _groupSettingIq(groupJid, tag, content) {
3532
+ const node = new BinaryNode('iq', {
3533
+ id: this._genMsgId(),
3534
+ xmlns: 'w:g2',
3535
+ to: jidStrToObj(makeJid(groupJid)),
3536
+ type: 'set'
3537
+ }, [
3538
+ new BinaryNode(tag, {}, content === undefined ? null : content)
3539
+ ]);
3540
+ const resp = await this._sendIq(node);
3541
+ if (!resp) throw new Error(tag + ': no reply from server (IQ timed out)');
3542
+ if (resp.attrs && resp.attrs.type === 'error') {
3543
+ const errNode = findChild(resp, 'error');
3544
+ const code = errNode && errNode.attrs && errNode.attrs.code;
3545
+ const text = errNode && errNode.attrs && errNode.attrs.text;
3546
+ throw new Error(tag + ': server rejected the request' +
3547
+ (code ? ' (' + code + (text ? ' ' + text : '') + ')' : ''));
3548
+ }
3549
+ // The group's own view of these flags is now stale.
3550
+ this.invalidateGroupMetadata(groupJid);
3551
+ return resp;
3552
+ }
3553
+
3184
3554
  // Change group picture
3185
3555
  // buf: Buffer with JPEG image data, or null to remove
3186
3556
  async changeGroupPicture(groupJid, buf) {
@@ -3197,17 +3567,63 @@ class WhalibmobClient extends EventEmitter {
3197
3567
  return this._sendIq(node);
3198
3568
  }
3199
3569
 
3200
- // Get group invite link
3201
- async queryGroupInviteLink(groupJid) {
3202
- const code = await this._queryGroupInviteCode(groupJid);
3203
- return code ? 'https://chat.whatsapp.com/' + code : null;
3570
+ // Get the group's invite link.
3571
+ //
3572
+ // reset=true revokes the current link and returns a freshly minted one — the
3573
+ // same IQ with type="set" instead of "get", which is how whatsmeow's
3574
+ // GetGroupInviteLink handles its reset flag.
3575
+ async queryGroupInviteLink(groupJid, reset) {
3576
+ const code = await this._queryGroupInviteCode(groupJid, reset);
3577
+ return code ? INVITE_LINK_PREFIX + code : null;
3578
+ }
3579
+
3580
+ // Revoke the current invite link and return the replacement.
3581
+ async revokeGroupInviteLink(groupJid) {
3582
+ return this.queryGroupInviteLink(groupJid, true);
3583
+ }
3584
+
3585
+ // Join a group from an invite link or bare code.
3586
+ // Returns the JID of the group that was joined. When the group requires admin
3587
+ // approval the server answers with a membership_approval_request instead, and
3588
+ // that JID is returned — the join is pending, not complete.
3589
+ async joinGroupWithLink(inviteCodeOrUrl) {
3590
+ const code = stripInviteUrl(inviteCodeOrUrl);
3591
+ const node = new BinaryNode('iq', {
3592
+ id: this._genMsgId(),
3593
+ xmlns: 'w:g2',
3594
+ to: 'g.us',
3595
+ type: 'set'
3596
+ }, [new BinaryNode('invite', { code }, null)]);
3597
+
3598
+ const resp = await this._sendIq(node);
3599
+ if (!resp) throw new Error('joinGroupWithLink: no reply from server (IQ timed out)');
3600
+ if (resp.attrs && resp.attrs.type === 'error') {
3601
+ const errNode = findChild(resp, 'error');
3602
+ const codeAttr = errNode && errNode.attrs && errNode.attrs.code;
3603
+ // whatsmeow maps these two specifically: 410 is a revoked link, 406 an
3604
+ // invalid one. Anything else is passed through as the server worded it.
3605
+ const reason = String(codeAttr) === '410' ? 'the invite link has been revoked'
3606
+ : String(codeAttr) === '406' ? 'the invite link is not valid'
3607
+ : 'server rejected the request' +
3608
+ (codeAttr ? ' (' + codeAttr + ')' : '');
3609
+ throw new Error('joinGroupWithLink: ' + reason);
3610
+ }
3611
+ const pending = findChild(resp, 'membership_approval_request');
3612
+ if (pending && pending.attrs && pending.attrs.jid) {
3613
+ return { jid: String(pending.attrs.jid), pendingApproval: true };
3614
+ }
3615
+ const grp = findChild(resp, 'group');
3616
+ if (!grp || !grp.attrs || !grp.attrs.jid) {
3617
+ throw new Error('joinGroupWithLink: server reply contained no <group> node');
3618
+ }
3619
+ return { jid: String(grp.attrs.jid), pendingApproval: false };
3204
3620
  }
3205
3621
 
3206
3622
  // queryGroupInviteInfo — fetch group metadata from an invite link code
3207
3623
  // Returns parsed group metadata (same shape as getGroupMetadata) or null.
3208
3624
  // inviteCode is the bare code (no URL prefix needed; URL also accepted).
3209
3625
  async queryGroupInviteInfo(inviteCodeOrUrl) {
3210
- const code = String(inviteCodeOrUrl).replace(/.*chat\.whatsapp\.com\//, '').trim();
3626
+ const code = stripInviteUrl(inviteCodeOrUrl);
3211
3627
  const id = this._genMsgId();
3212
3628
  const node = new BinaryNode('iq', {
3213
3629
  id,
@@ -3224,13 +3640,13 @@ class WhalibmobClient extends EventEmitter {
3224
3640
  return this._parseGroupNode(grp);
3225
3641
  }
3226
3642
 
3227
- async _queryGroupInviteCode(groupJid) {
3643
+ async _queryGroupInviteCode(groupJid, reset) {
3228
3644
  const id = this._genMsgId();
3229
3645
  const node = new BinaryNode('iq', {
3230
3646
  id,
3231
3647
  xmlns: 'w:g2',
3232
- to: makeJid(groupJid),
3233
- type: 'get'
3648
+ to: jidStrToObj(makeJid(groupJid)),
3649
+ type: reset ? 'set' : 'get'
3234
3650
  }, [
3235
3651
  new BinaryNode('invite', {}, null)
3236
3652
  ]);
@@ -3241,34 +3657,24 @@ class WhalibmobClient extends EventEmitter {
3241
3657
  }
3242
3658
 
3243
3659
  // Revoke group invite
3660
+ // Revoke the current invite link. Returns the replacement link.
3661
+ //
3662
+ // This threw the reply away, so a caller had no way to learn the new code and
3663
+ // had to query the link again. It is the same IQ revokeGroupInviteLink sends.
3244
3664
  async revokeGroupInvite(groupJid) {
3245
- const id = this._genMsgId();
3246
- const node = new BinaryNode('iq', {
3247
- id,
3248
- xmlns: 'w:g2',
3249
- to: makeJid(groupJid),
3250
- type: 'set'
3251
- }, [
3252
- new BinaryNode('invite', {}, null)
3253
- ]);
3254
- return this._sendIq(node);
3665
+ return this.revokeGroupInviteLink(groupJid);
3255
3666
  }
3256
3667
 
3257
- // Join group by invite code
3258
- async acceptGroupInvite(inviteCode) {
3259
- const id = this._genMsgId();
3260
- const node = new BinaryNode('iq', {
3261
- id,
3262
- xmlns: 'w:g2',
3263
- to: 'g.us',
3264
- type: 'set'
3265
- }, [
3266
- new BinaryNode('invite', { code: inviteCode }, null)
3267
- ]);
3268
- const resp = await this._sendIq(node);
3269
- if (!resp) return null;
3270
- const grp = findChild(resp, 'group');
3271
- return (grp && grp.attrs && (grp.attrs.jid || grp.attrs.value)) || null;
3668
+ // Join a group by invite code or full link. Returns the group JID, or null.
3669
+ //
3670
+ // Accepts a full https://chat.whatsapp.com/... link as well as a bare code —
3671
+ // it used to pass whatever it was given straight through as the code, so a
3672
+ // pasted link was rejected. A group that gates joins on admin approval answers
3673
+ // with membership_approval_request rather than group; that JID is returned too
3674
+ // (use joinGroupWithLink when you need to tell the two apart).
3675
+ async acceptGroupInvite(inviteCodeOrUrl) {
3676
+ const r = await this.joinGroupWithLink(inviteCodeOrUrl).catch(() => null);
3677
+ return r ? r.jid : null;
3272
3678
  }
3273
3679
 
3274
3680
  // Change group settings
@@ -3297,13 +3703,10 @@ class WhalibmobClient extends EventEmitter {
3297
3703
  default:
3298
3704
  throw new Error('Unknown group setting: ' + setting);
3299
3705
  }
3300
- const node = new BinaryNode('iq', {
3301
- id,
3302
- xmlns: 'w:g2',
3303
- to: makeJid(groupJid),
3304
- type: 'set'
3305
- }, [body]);
3306
- return this._sendIq(node);
3706
+ // Routed through the shared helper so a refusal from the server (not an
3707
+ // admin, not a member) surfaces as an error instead of resolving null, and
3708
+ // so the group's cached metadata does not keep reporting the old flags.
3709
+ return this._groupSettingIq(groupJid, body.description, body.content);
3307
3710
  }
3308
3711
 
3309
3712
  // Query participants pending approval
@@ -211,6 +211,9 @@ module.exports = {
211
211
  decryptMedia,
212
212
  uploadMedia,
213
213
  downloadMedia,
214
+ // Plain unauthenticated GET. Profile pictures are served in the clear, unlike
215
+ // message media, so they need a fetch that does not try to decrypt anything.
216
+ httpGetBuffer: _httpGet,
214
217
  deriveMediaKeyData,
215
218
  getMediaKeyName
216
219
  };
@@ -1452,23 +1452,15 @@ class MessageSender {
1452
1452
 
1453
1453
  // ─── Utility: mark read, presence, receipt, ping ─────────────────────────
1454
1454
 
1455
- markRead(jid, messageIds) {
1456
- const ids = Array.isArray(messageIds) ? messageIds : [messageIds];
1457
- const toJid = makeJid(jid);
1458
- const extras = ids.slice(0, -1).map(id => new BinaryNode('item', { id }, null));
1459
- this._socket.sendNode(new BinaryNode('receipt', {
1460
- to: toJid,
1461
- type: 'read',
1462
- id: ids[ids.length - 1],
1463
- t: String(msNow())
1464
- }, extras));
1455
+ // Both of these live on Client now — it is the side that knows our LID, the
1456
+ // privacy tokens and the online flag the receipt shape depends on. Kept here
1457
+ // so anything holding a MessageSender keeps working.
1458
+ markRead(jid, messageIds, opts) {
1459
+ return this._client.markRead(jid, messageIds, opts);
1465
1460
  }
1466
1461
 
1467
1462
  sendPresence(available) {
1468
- this._socket.sendNode(new BinaryNode('presence', {
1469
- name: (this._store && this._store.name) || 'User',
1470
- type: available ? 'available' : 'unavailable'
1471
- }, null));
1463
+ return this._client.setOnline(available);
1472
1464
  }
1473
1465
 
1474
1466
  sendReceipt(msgId, from, type) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.5.83",
3
+ "version": "5.5.84",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API no web",
5
5
  "author": "Kunboruto50",
6
6
  "main": "index.js",