whalibmob 5.5.90 → 5.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/Client.js CHANGED
@@ -193,6 +193,11 @@ class WhalibmobClient extends EventEmitter {
193
193
  this._signal = null;
194
194
  this._devMgr = null;
195
195
  this._sessionDir = opts.sessionDir || process.env.HOME + '/.waSession';
196
+ // 'mobile' — registered over SMS as the account's primary device (default,
197
+ // and what every existing caller gets). 'web' — linked to an existing
198
+ // account as a companion, via pairing code.
199
+ this._mode = 'mobile';
200
+ this._webSessionFile = null;
196
201
  this._pingTimer = null;
197
202
  this._keepTimer = null;
198
203
  this._connected = false;
@@ -296,6 +301,8 @@ class WhalibmobClient extends EventEmitter {
296
301
  }
297
302
 
298
303
  async _connectSocket() {
304
+ if (this._mode === 'web') return this._connectWebSocket();
305
+
299
306
  const socket = new NoiseSocket(this._store);
300
307
  this._socket = socket;
301
308
 
@@ -313,6 +320,406 @@ class WhalibmobClient extends EventEmitter {
313
320
  return socket;
314
321
  }
315
322
 
323
+ // ──────────────────────────────────────────────────────────────────────────
324
+ // WHATSAPP WEB (companion) MODE
325
+ //
326
+ // Same library, same public API, different way in. Instead of registering a
327
+ // number over SMS as the account's primary device, we link to an account that
328
+ // already exists — the way the web client and the desktop apps do. Everything
329
+ // above the connection is unchanged: the Signal layer, the sender, the group
330
+ // and media code all read the same store fields.
331
+ //
332
+ // Two things do differ once connected, and both are handled below:
333
+ // · our own JID carries a device index, so we are no longer device 0 and
334
+ // the account's phone becomes just another device we encrypt to;
335
+ // · the primary ships history over, which a primary device never receives.
336
+ // ──────────────────────────────────────────────────────────────────────────
337
+
338
+ /**
339
+ * Connect as a companion device.
340
+ *
341
+ * If the session has never been paired this resolves as soon as the encrypted
342
+ * channel is up, leaving the caller to call requestPairingCode(). If it has,
343
+ * it behaves like connect(): resolves once the server sends <success>.
344
+ *
345
+ * @param {string} phoneNumber account phone number, digits only
346
+ * @param {object} [opts] { syncFullHistory, browser }
347
+ */
348
+ async connectWeb(phoneNumber, opts) {
349
+ return this.initWeb(phoneNumber, opts);
350
+ }
351
+
352
+ async initWeb(phoneNumber, opts) {
353
+ opts = opts || {};
354
+ phoneNumber = String(phoneNumber).replace(/\D/g, '');
355
+ this._phoneNumber = phoneNumber;
356
+ this._mode = 'web';
357
+
358
+ const { createNewWebStore, loadWebStore, saveWebStore, webSessionPath } =
359
+ require('./WebStore');
360
+
361
+ this._webSessionFile = webSessionPath(this._sessionDir, phoneNumber);
362
+ this._store = loadWebStore(this._webSessionFile) || createNewWebStore(phoneNumber);
363
+
364
+ if (opts.syncFullHistory !== undefined) this._store.syncFullHistory = !!opts.syncFullHistory;
365
+ if (opts.browser) this._store.browser = opts.browser;
366
+ saveWebStore(this._store, this._webSessionFile);
367
+
368
+ // Signal state lives in its own files so a web link and an SMS
369
+ // registration for the same number never share sessions or pre-keys.
370
+ const signalFile = path.join(this._sessionDir, `${phoneNumber}.web.signal.json`);
371
+ const skFile = path.join(this._sessionDir, `${phoneNumber}.web.sk.json`);
372
+ const tcTokenFile = path.join(this._sessionDir, `${phoneNumber}.web.tctoken.json`);
373
+
374
+ const { TcTokenStore } = require('./messages/TcTokenStore');
375
+ this._tcTokenStore = new TcTokenStore(tcTokenFile);
376
+
377
+ this._signal = SignalProtocol.fromStore(this._store, signalFile, skFile);
378
+ this._devMgr = new DeviceManager(this);
379
+ this._devMgr.attachSession(this._sessionDir, phoneNumber + '.web');
380
+
381
+ const persistedLid = this._signal.store.getLidMappings
382
+ ? this._signal.store.getLidMappings() : {};
383
+ for (const [phone, lid] of Object.entries(persistedLid)) {
384
+ this._pnToLid.set(phone, lid);
385
+ this._lidToPn.set(lid, phone);
386
+ }
387
+
388
+ if (this._store.me && this._store.me.lid) this._myLid = this._store.me.lid;
389
+
390
+ await this._connectWebSocket();
391
+ return this;
392
+ }
393
+
394
+ _saveWebStore() {
395
+ if (this._mode !== 'web' || !this._webSessionFile) return;
396
+ const { saveWebStore } = require('./WebStore');
397
+ saveWebStore(this._store, this._webSessionFile);
398
+ }
399
+
400
+ async _connectWebSocket() {
401
+ const { encodeCompanionRegisterPayload, encodeCompanionLoginPayload } =
402
+ require('./webproto');
403
+
404
+ const store = this._store;
405
+ const registered = !!store.registered && !!(store.me && store.me.id);
406
+
407
+ const payloadOpts = {
408
+ version: store.webVersion,
409
+ browser: store.browser,
410
+ countryCode: 'US',
411
+ pushName: store.name && store.name !== 'User' ? store.name : undefined,
412
+ syncFullHistory: store.syncFullHistory !== false,
413
+ registrationId: store.registrationId,
414
+ identityKeyPair: store.identityKeyPair,
415
+ signedPreKey: store.signedPreKey
416
+ };
417
+
418
+ const socket = new NoiseSocket(store, {
419
+ transport: 'web',
420
+ // An unpaired companion never gets <success> on this connection — the
421
+ // server answers with pair-device and waits for a human to act.
422
+ expectSuccess: registered,
423
+ buildPayload: () => registered
424
+ ? encodeCompanionLoginPayload(Object.assign({}, payloadOpts, {
425
+ username: store.phoneNumber,
426
+ device: store.deviceIndex || 0
427
+ }))
428
+ : encodeCompanionRegisterPayload(payloadOpts)
429
+ });
430
+
431
+ this._socket = socket;
432
+ this._sender = new MessageSender(this);
433
+
434
+ socket.on('open', (sn) => this._onOpen(sn));
435
+ socket.on('secured', () => {
436
+ _whaDbg('[DBG] WEB_CHANNEL_SECURED — awaiting pairing');
437
+ this.emit('web_ready_for_pairing');
438
+ });
439
+ socket.on('node', node => this._onNode(node));
440
+ socket.on('close', () => this._onClose());
441
+ socket.on('error', err => this.emit('error', err));
442
+
443
+ await socket.connect();
444
+ return socket;
445
+ }
446
+
447
+ /**
448
+ * Ask WhatsApp for an 8-character pairing code.
449
+ *
450
+ * The account owner enters it on their phone under
451
+ * Settings → Linked Devices → Link a device → Link with phone number instead.
452
+ * The code is a password, not an identifier: it never travels to the server
453
+ * in the clear, and both sides run it through PBKDF2 to wrap the ephemeral
454
+ * keys they exchange.
455
+ *
456
+ * Resolves with the code as soon as the request is on the wire. The link
457
+ * itself completes later — wait for the 'paired' or 'open' event.
458
+ *
459
+ * @param {string} [phoneNumber] defaults to the number initWeb was given
460
+ * @param {string} [customCode] exactly 8 characters, if you want to choose
461
+ * @returns {Promise<string>}
462
+ */
463
+ async requestPairingCode(phoneNumber, customCode) {
464
+ if (this._mode !== 'web') {
465
+ throw new Error('requestPairingCode is only available in web mode — call connectWeb() first');
466
+ }
467
+ if (!this._socket || !this._socket.secured) {
468
+ throw new Error('Not connected — call connectWeb() before requesting a pairing code');
469
+ }
470
+ if (this._store.registered) {
471
+ throw new Error('This session is already linked — no pairing code needed');
472
+ }
473
+
474
+ const { generatePairingCode, buildWrappedEphemeralPub, generateEphemeralKeyPair } =
475
+ require('./PairingCode');
476
+ const { platformTypeFor } = require('./webproto');
477
+
478
+ if (customCode != null && String(customCode).length !== 8) {
479
+ throw new Error('A custom pairing code must be exactly 8 characters');
480
+ }
481
+
482
+ const phone = String(phoneNumber || this._phoneNumber || this._store.phoneNumber)
483
+ .replace(/\D/g, '');
484
+ if (!phone) throw new Error('requestPairingCode: a phone number is required');
485
+
486
+ const code = customCode ? String(customCode).toUpperCase() : generatePairingCode();
487
+
488
+ // A fresh ephemeral pair per attempt: a code that was requested and never
489
+ // used must not be able to complete a later exchange.
490
+ this._store.pairingEphemeralKeyPair = generateEphemeralKeyPair();
491
+ this._store.pairingCode = code;
492
+ this._store.phoneNumber = phone;
493
+ this._store.me = { id: phone + '@s.whatsapp.net', name: '~', lid: null };
494
+ this._saveWebStore();
495
+
496
+ const browser = this._store.browser || ['Ubuntu', 'Chrome', '120.0.0.0'];
497
+ const wrapped = await buildWrappedEphemeralPub(code, this._store.pairingEphemeralKeyPair.public);
498
+
499
+ // The bare 32-byte curve point, not the 33-byte Signal form.
500
+ //
501
+ // whalibmob stores every public key with the 0x05 type prefix, and the
502
+ // Noise handshake strips it on the way out. This node had no such strip, so
503
+ // the primary would have hashed 33 bytes where it expected 32 and the link
504
+ // would fail with nothing to point at.
505
+ const { stripKeyPrefix } = require('./PairingCode');
506
+ const noisePub = stripKeyPrefix(this._store.noiseKeyPair.public);
507
+
508
+ const node = new BinaryNode('iq', {
509
+ id: this._genMsgId(),
510
+ to: 's.whatsapp.net',
511
+ type: 'set',
512
+ xmlns: 'md'
513
+ }, [
514
+ new BinaryNode('link_code_companion_reg', {
515
+ jid: this._store.me.id,
516
+ stage: 'companion_hello',
517
+ should_show_push_notification: 'true'
518
+ }, [
519
+ new BinaryNode('link_code_pairing_wrapped_companion_ephemeral_pub', {}, wrapped),
520
+ new BinaryNode('companion_server_auth_key_pub', {}, noisePub),
521
+ new BinaryNode('companion_platform_id', {}, Buffer.from(
522
+ String(platformTypeFor(browser[1])), 'utf8')),
523
+ new BinaryNode('companion_platform_display', {}, Buffer.from(
524
+ `${browser[1]} (${browser[0]})`, 'utf8')),
525
+ new BinaryNode('link_code_pairing_nonce', {}, Buffer.from('0', 'utf8'))
526
+ ])
527
+ ]);
528
+
529
+ _whaDbg('[DBG] REQUEST_PAIRING_CODE phone=' + phone + ' code=' + code);
530
+ this._socket.sendNode(node);
531
+ this.emit('pairing_code', { code, phoneNumber: phone });
532
+ return code;
533
+ }
534
+
535
+ /**
536
+ * Stage 3 of the pairing exchange, driven by the server.
537
+ *
538
+ * Arrives once the account owner has typed the code into their phone. We
539
+ * unwrap the primary's ephemeral key with the key derived from that code, do
540
+ * the two Diffie-Hellmans, and store adv_secret — the key pair-success will
541
+ * be verified under.
542
+ */
543
+ async _handleCompanionFinish(node) {
544
+ const { buildCompanionFinishBundle } = require('./PairingCode');
545
+
546
+ const reg = findChild(node, 'link_code_companion_reg');
547
+ if (!reg) return;
548
+
549
+ const childBuf = (desc) => {
550
+ const c = findChild(reg, desc);
551
+ if (!c) return null;
552
+ return Buffer.isBuffer(c.content) ? c.content
553
+ : ArrayBuffer.isView(c.content) ? Buffer.from(c.content)
554
+ : typeof c.content === 'string' ? Buffer.from(c.content, 'utf8')
555
+ : null;
556
+ };
557
+
558
+ const ref = childBuf('link_code_pairing_ref');
559
+ const primaryIdentityPub = childBuf('primary_identity_pub');
560
+ const wrappedEphemeral = childBuf('link_code_pairing_wrapped_primary_ephemeral_pub');
561
+
562
+ if (!ref || !primaryIdentityPub || !wrappedEphemeral) {
563
+ _whaDbg('[DBG] COMPANION_FINISH missing fields — aborting');
564
+ this.emit('error', new Error('Pairing failed: incomplete companion_finish from server'));
565
+ return;
566
+ }
567
+
568
+ try {
569
+ const { advSecretKey, wrappedKeyBundle } = await buildCompanionFinishBundle({
570
+ pairingCode: this._store.pairingCode,
571
+ pairingEphemeralPrivate: this._store.pairingEphemeralKeyPair.private,
572
+ primaryEphemeralPubWrapped: wrappedEphemeral,
573
+ primaryIdentityPub,
574
+ identityKeyPair: this._store.identityKeyPair
575
+ });
576
+
577
+ this._store.advSecretKey = advSecretKey;
578
+ this._saveWebStore();
579
+ _whaDbg('[DBG] COMPANION_FINISH adv_secret derived');
580
+
581
+ // Baileys awaits this one rather than firing and forgetting, so a server
582
+ // rejection surfaces as an error here instead of as a silent stall
583
+ // waiting for a pair-success that is never coming.
584
+ const finishResult = await this._sendIq(new BinaryNode('iq', {
585
+ to: 's.whatsapp.net',
586
+ type: 'set',
587
+ id: this._genMsgId(),
588
+ xmlns: 'md'
589
+ }, [
590
+ new BinaryNode('link_code_companion_reg', {
591
+ jid: this._store.me.id,
592
+ stage: 'companion_finish'
593
+ }, [
594
+ new BinaryNode('link_code_pairing_wrapped_key_bundle', {}, wrappedKeyBundle),
595
+ new BinaryNode('companion_identity_public', {},
596
+ require('./PairingCode').stripKeyPrefix(this._store.identityKeyPair.public)),
597
+ new BinaryNode('link_code_pairing_ref', {}, ref)
598
+ ])
599
+ ]));
600
+
601
+ if (finishResult && finishResult.attrs && finishResult.attrs.type === 'error') {
602
+ const errNode = findChild(finishResult, 'error');
603
+ const code = errNode && errNode.attrs && errNode.attrs.code;
604
+ throw new Error('server rejected companion_finish' + (code ? ' (' + code + ')' : ''));
605
+ }
606
+ _whaDbg('[DBG] COMPANION_FINISH accepted — awaiting pair-success');
607
+ } catch (err) {
608
+ _whaDbg('[DBG] COMPANION_FINISH_ERR ' + (err && err.message));
609
+ this.emit('error', new Error('Pairing failed: ' + (err && err.message)));
610
+ }
611
+ }
612
+
613
+ /**
614
+ * <iq><pair-success> — the primary has authorised us.
615
+ *
616
+ * Verify what it sent, counter-sign it, reply, and persist. The server closes
617
+ * the connection right after with a 515; the reconnect logs in normally and
618
+ * that is when history starts arriving.
619
+ */
620
+ _handlePairSuccess(node) {
621
+ const { configureSuccessfulPairing } = require('./CompanionPairing');
622
+ try {
623
+ const result = configureSuccessfulPairing(node, {
624
+ advSecretKey: this._store.advSecretKey,
625
+ identityKeyPair: this._store.identityKeyPair
626
+ }, BinaryNode);
627
+
628
+ this._store.me = result.me;
629
+ this._store.deviceIndex = result.deviceIndex;
630
+ this._store.platform = result.platform;
631
+ // The same field the mobile path fills from <success>: the signed device
632
+ // identity that rides on outgoing pkmsg stanzas.
633
+ this._store.advIdentity = result.accountEnc;
634
+ this._store.registered = true;
635
+ this._store.pairingCode = null;
636
+ if (result.me && result.me.name) this._store.name = result.me.name;
637
+ this._saveWebStore();
638
+
639
+ if (result.me && result.me.lid) {
640
+ this._myLid = result.me.lid;
641
+ const lidUser = String(result.me.lid).split('@')[0].split(':')[0];
642
+ const pnUser = String(result.me.id).split('@')[0].split(':')[0];
643
+ this._lidToPn.set(lidUser, pnUser);
644
+ this._pnToLid.set(pnUser, lidUser);
645
+ }
646
+
647
+ this._socket.sendNode(result.reply);
648
+ this._sendUnifiedSession();
649
+ _whaDbg('[DBG] PAIR_SUCCESS jid=' + result.me.id + ' lid=' + result.me.lid +
650
+ ' device=' + result.deviceIndex);
651
+ this.emit('paired', {
652
+ jid: result.me.id,
653
+ lid: result.me.lid,
654
+ deviceIndex: result.deviceIndex,
655
+ platform: result.platform
656
+ });
657
+ } catch (err) {
658
+ _whaDbg('[DBG] PAIR_SUCCESS_ERR ' + (err && err.message));
659
+ this.emit('error', err);
660
+ }
661
+ }
662
+
663
+ /**
664
+ * <iq><pair-device> — the QR path.
665
+ *
666
+ * We do not display QR codes, but the node still has to be acknowledged or
667
+ * the server keeps re-sending refs. The refs are surfaced as an event so a
668
+ * caller that wants to render a QR can.
669
+ */
670
+ _handlePairDevice(node) {
671
+ const id = node.attrs && node.attrs.id;
672
+ if (id) {
673
+ this._socket.sendNode(new BinaryNode('iq', {
674
+ to: 's.whatsapp.net', type: 'result', id
675
+ }, null));
676
+ }
677
+ const pairDevice = findChild(node, 'pair-device');
678
+ const refs = pairDevice && Array.isArray(pairDevice.content)
679
+ ? pairDevice.content
680
+ .filter(c => c && c.description === 'ref')
681
+ .map(c => (Buffer.isBuffer(c.content) ? c.content.toString('utf8') : String(c.content)))
682
+ : [];
683
+ this.emit('pair_device', { refs });
684
+ }
685
+
686
+ /**
687
+ * The JID this device sends as.
688
+ *
689
+ * A primary is device 0 and its JID is the bare number. A companion occupies
690
+ * a numbered slot, and the account's phone is then a peer we encrypt to like
691
+ * any other linked device — which is exactly the difference the fan-out code
692
+ * needs to know about.
693
+ */
694
+ _ownDeviceJid() {
695
+ const phone = String(this._store.phoneNumber);
696
+ const idx = (this._mode === 'web' && this._store.deviceIndex) || 0;
697
+ return idx > 0 ? `${phone}:${idx}@s.whatsapp.net` : `${phone}@s.whatsapp.net`;
698
+ }
699
+
700
+ // <ib><unified_session/> — the telemetry ping a web client emits after
701
+ // pairing and after each login. It carries no state we need; sending it keeps
702
+ // our traffic shaped like the client we are claiming to be. Failures are
703
+ // ignored, as they are in the reference.
704
+ _sendUnifiedSession() {
705
+ if (this._mode !== 'web' || !this._socket) return;
706
+ try {
707
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
708
+ const id = ((Date.now() + 3 * 24 * 60 * 60 * 1000) % WEEK_MS).toString();
709
+ this._socket.sendNode(new BinaryNode('ib', {}, [
710
+ new BinaryNode('unified_session', { id }, null)
711
+ ]));
712
+ } catch (_) {}
713
+ }
714
+
715
+ /** Same, in LID space. */
716
+ _ownDeviceLidJid() {
717
+ if (!this._myLid) return null;
718
+ const lidUser = String(this._myLid).split('@')[0].split(':')[0];
719
+ const idx = (this._mode === 'web' && this._store.deviceIndex) || 0;
720
+ return idx > 0 ? `${lidUser}:${idx}@lid` : `${lidUser}@lid`;
721
+ }
722
+
316
723
  disconnect() {
317
724
  this._reconnecting = false;
318
725
  this._reconnectTry = 0;
@@ -372,6 +779,9 @@ class WhalibmobClient extends EventEmitter {
372
779
  this._requestMediaConnection();
373
780
  this._uploadPreKeys();
374
781
 
782
+ // Same telemetry ping the web client sends on every completed login.
783
+ this._sendUnifiedSession();
784
+
375
785
  // Warm the privacy settings so read receipts respect them from the first
376
786
  // message rather than from whenever something first asks for them.
377
787
  this.queryPrivacySettings({ force: true }).catch(err =>
@@ -457,18 +867,34 @@ class WhalibmobClient extends EventEmitter {
457
867
  if (bytes && bytes.length > 0) {
458
868
  this._store.advIdentity = bytes;
459
869
  // Persist to session file so it survives restart
460
- const sessionFile = path.join(
461
- this._sessionDir,
462
- `${this._store.phoneNumber}.json`
463
- );
464
- try {
465
- const { saveStore } = require('./Store');
466
- saveStore(this._store, sessionFile);
467
- } catch (_) {}
870
+ if (this._mode === 'web') {
871
+ this._saveWebStore();
872
+ } else {
873
+ const sessionFile = path.join(
874
+ this._sessionDir,
875
+ `${this._store.phoneNumber}.json`
876
+ );
877
+ try {
878
+ const { saveStore } = require('./Store');
879
+ saveStore(this._store, sessionFile);
880
+ } catch (_) {}
881
+ }
468
882
  }
469
883
  } else if (this._store.advIdentity) {
470
884
  // Already have it persisted from a previous session — keep using it
471
885
  }
886
+
887
+ // A companion learns its own LID from pair-success, not from <success>, so
888
+ // keep what pairing established rather than leaving it unset on reconnect.
889
+ if (this._mode === 'web') {
890
+ if (!this._myLid && this._store.me && this._store.me.lid) {
891
+ this._myLid = this._store.me.lid;
892
+ }
893
+ if (attrs.lid && this._store.me) {
894
+ this._store.me.lid = String(attrs.lid);
895
+ this._saveWebStore();
896
+ }
897
+ }
472
898
  }
473
899
 
474
900
  // ── Feature 3 (part A): Activate the connection ────────────────────────────
@@ -645,10 +1071,31 @@ class WhalibmobClient extends EventEmitter {
645
1071
  else if (tag === 'ib') this._handleIb(node);
646
1072
  else if (tag === 'success') this.emit('session_refresh', { node }); // late success (re-auth)
647
1073
  else if (tag === 'failure') this._handleFailure(node);
648
- else if (tag === 'stream:error') this.emit('stream_error', { reason: node.attrs && node.attrs.reason });
1074
+ else if (tag === 'stream:error') this._handleStreamError(node);
649
1075
  else this.emit('node', node);
650
1076
  }
651
1077
 
1078
+ // A stream error ends the connection; the reconnect in _onClose picks it up.
1079
+ //
1080
+ // 515 is not a failure. The server sends it immediately after a companion
1081
+ // finishes pairing to say "you are linked now, start over as a normal
1082
+ // client" — the next connection carries the login payload instead of the
1083
+ // pairing bundle, and that is the one that receives history.
1084
+ _handleStreamError(node) {
1085
+ const attrs = (node && node.attrs) || {};
1086
+ const code = attrs.code ? String(attrs.code) : null;
1087
+ const reason = attrs.reason || code || 'unknown';
1088
+
1089
+ if (code === '515') {
1090
+ _whaDbg('[DBG] STREAM_ERROR 515 — restart required after pairing, reconnecting');
1091
+ this.emit('restart_required', { reason });
1092
+ return;
1093
+ }
1094
+
1095
+ _whaDbg('[DBG] STREAM_ERROR code=' + code + ' reason=' + reason);
1096
+ this.emit('stream_error', { reason, code });
1097
+ }
1098
+
652
1099
  // ─── IQ handling ──────────────────────────────────────────────────────────
653
1100
 
654
1101
  _handleIq(node) {
@@ -656,6 +1103,18 @@ class WhalibmobClient extends EventEmitter {
656
1103
  const type = node.attrs && node.attrs.type;
657
1104
  const xmlns = node.attrs && node.attrs.xmlns;
658
1105
 
1106
+ // Companion pairing — these arrive unsolicited, so they are checked before
1107
+ // the pending-IQ table rather than after it.
1108
+ //
1109
+ // pair-device is matched on type=set; pair-success is matched on any type,
1110
+ // the way Baileys distinguishes them ('CB:iq,type:set,pair-device' against
1111
+ // a bare 'CB:iq,,pair-success'). Requiring type=set on pair-success would
1112
+ // drop the stanza that completes the link.
1113
+ if (this._mode === 'web') {
1114
+ if (type === 'set' && findChild(node, 'pair-device')) { this._handlePairDevice(node); return; }
1115
+ if (findChild(node, 'pair-success')) { this._handlePairSuccess(node); return; }
1116
+ }
1117
+
659
1118
  // Resolve pending IQ
660
1119
  if (id) {
661
1120
  const handler = this._pendingIqs.get(id);
@@ -1099,6 +1558,69 @@ class WhalibmobClient extends EventEmitter {
1099
1558
  return base;
1100
1559
  }
1101
1560
 
1561
+ // The key a privacy token is stored and looked up under.
1562
+ //
1563
+ // Both references resolve it to the contact's LID and fall back to the plain
1564
+ // JID only when no LID is known — whatsmeow's resolveTCTokenStorageLID and
1565
+ // Baileys' resolveTcTokenJid are the same three lines:
1566
+ //
1567
+ // if (isLidUser(jid)) return jid
1568
+ // const lid = await getLIDForPN(jid)
1569
+ // return lid ?? jid
1570
+ //
1571
+ // whalibmob resolved it on the receiving side but not on the sending side,
1572
+ // which put the two halves out of step: a token a contact issued us arrived
1573
+ // on a privacy_token notification and was filed under their LID, while every
1574
+ // send looked it up under their phone JID and found nothing. The token was
1575
+ // therefore never attached to anything, and the server kept counting our
1576
+ // messages as anonymous reach-outs — which is what error 463 is.
1577
+ _resolveTcTokenJid(jid) {
1578
+ const { normalizeJidForTcToken } = require('./messages/TcTokenStore');
1579
+ const norm = normalizeJidForTcToken(jid);
1580
+ if (norm.endsWith('@lid')) return norm;
1581
+ const lidUser = this._pnToLid && this._pnToLid.get(norm.split('@')[0]);
1582
+ return lidUser ? lidUser + '@lid' : norm;
1583
+ }
1584
+
1585
+ // The JID a token is *issued* to, which is not the JID it is *stored* under.
1586
+ //
1587
+ // Storage is always the LID. Issuance follows AB prop 14303
1588
+ // (lid_trusted_token_issue_to_lid), whose WA Web default is off — so the IQ
1589
+ // names the phone JID. Baileys implements exactly this in resolveIssuanceJid
1590
+ // and defaults the prop to false; whatsmeow issues to the LID instead. We
1591
+ // follow WA Web, and more importantly we now follow it in one place: the
1592
+ // send path, the 463 recovery and the post-identity-change re-issue used to
1593
+ // each pick a different address family for the same contact.
1594
+ _resolveTcTokenIssuanceJid(jid) {
1595
+ const { normalizeJidForTcToken } = require('./messages/TcTokenStore');
1596
+ const norm = normalizeJidForTcToken(jid);
1597
+ if (!norm.endsWith('@lid')) return norm;
1598
+ const pnUser = this._lidToPn && this._lidToPn.get(norm.split('@')[0]);
1599
+ return pnUser ? pnUser + '@s.whatsapp.net' : norm;
1600
+ }
1601
+
1602
+ // The one way to read a token that is safe to put on the wire.
1603
+ //
1604
+ // whatsmeow funnels every attach site through ensureTCToken, which returns
1605
+ // nothing for an expired token. whalibmob had the expiry check on the message
1606
+ // path only; presence subscribe, group create, participant add and the
1607
+ // profile-picture query each tested `token.length` and nothing else, so once
1608
+ // a token aged past the 28-day window those four kept sending it. Same rule
1609
+ // everywhere now, and the dead token gets cleared on the way out.
1610
+ _tcTokenFor(jid) {
1611
+ if (!this._tcTokenStore) return null;
1612
+ const { tcTokenExpired } = require('./messages/TcTokenStore');
1613
+ const tcJid = this._resolveTcTokenJid(jid);
1614
+ const entry = this._tcTokenStore.get(tcJid);
1615
+ if (!entry || !entry.token || !entry.token.length) return null;
1616
+ if (tcTokenExpired(entry.timestamp)) {
1617
+ this._tcTokenStore.clearToken(tcJid);
1618
+ _whaDbg('[DBG] TCTOKEN_EXPIRED jid=' + tcJid + ' — cleared');
1619
+ return null;
1620
+ }
1621
+ return entry.token;
1622
+ }
1623
+
1102
1624
  // True when a JID names this account, in either address family.
1103
1625
  _isOwnUser(jid) {
1104
1626
  if (!jid) return false;
@@ -1344,6 +1866,11 @@ class WhalibmobClient extends EventEmitter {
1344
1866
  this._handlePrivacyTokenNotification(node);
1345
1867
  }
1346
1868
 
1869
+ if (type === 'link_code_companion_reg') {
1870
+ // The account owner just typed our pairing code into their phone.
1871
+ this._handleCompanionFinish(node);
1872
+ }
1873
+
1347
1874
  if (type === 'md-msg-hist') {
1348
1875
  // Server signals that history sync data is available.
1349
1876
  // The actual encrypted notification may arrive as:
@@ -1953,6 +2480,7 @@ class WhalibmobClient extends EventEmitter {
1953
2480
  _storeTcTokenFromIqResult(result, fallbackJid, issueTs, opts) {
1954
2481
  const saveSenderTs = !opts || opts.saveSenderTs !== false; // default true
1955
2482
  if (!this._tcTokenStore) return;
2483
+ const { isRegularTcTokenUser } = require('./messages/TcTokenStore');
1956
2484
 
1957
2485
  // ── Debug: dump result structure so we can trace server response ─────────
1958
2486
  if (!result) {
@@ -2002,10 +2530,21 @@ class WhalibmobClient extends EventEmitter {
2002
2530
  // → always prefer fallbackJid; only fall back to a.jid when fallbackJid is absent
2003
2531
  const jid = fallbackJid || String(a.jid);
2004
2532
  const t = a.t ? parseInt(String(a.t), 10) : issueTs;
2533
+ // Baileys drops anything that is not a regular user here — the guard
2534
+ // is against a malformed or spoofed notification filing a token under
2535
+ // the PSA account or a bot.
2536
+ if (!isRegularTcTokenUser(jid)) {
2537
+ _whaDbg('[DBG] STORE_TCTOKEN skipped non-user jid=' + jid);
2538
+ continue;
2539
+ }
2005
2540
  if (bytes && bytes.length) {
2006
- this._tcTokenStore.setToken(jid, bytes, t);
2007
- tokenStoredCount++;
2008
- _whaDbg('[DBG] TCTOKEN_STORED jid=' + jid + ' t=' + t + ' len=' + bytes.length);
2541
+ // setToken refuses a timestamp-less or older-than-stored token.
2542
+ if (this._tcTokenStore.setToken(jid, bytes, t)) {
2543
+ tokenStoredCount++;
2544
+ _whaDbg('[DBG] TCTOKEN_STORED jid=' + jid + ' t=' + t + ' len=' + bytes.length);
2545
+ } else {
2546
+ _whaDbg('[DBG] STORE_TCTOKEN rejected (no/older timestamp) jid=' + jid + ' t=' + t);
2547
+ }
2009
2548
  } else {
2010
2549
  _whaDbg('[DBG] STORE_TCTOKEN token node has no bytes jid=' + jid);
2011
2550
  }
@@ -2021,7 +2560,9 @@ class WhalibmobClient extends EventEmitter {
2021
2560
  // ── we flood the server with privacy IQs. ─────
2022
2561
  // ── For incoming privacy_token notifications saveSenderTs=false so ─────
2023
2562
  // ── we don't overwrite our own send-dedupe timestamp with the peer's ─────
2024
- if (saveSenderTs && issueTs != null) {
2563
+ // Same user gate as the token itself — no dedupe row for the PSA account
2564
+ // or a bot, since we never issue to them in the first place.
2565
+ if (saveSenderTs && issueTs != null && isRegularTcTokenUser(fallbackJid)) {
2025
2566
  try {
2026
2567
  this._tcTokenStore.setSenderTimestamp(fallbackJid, issueTs);
2027
2568
  _whaDbg('[DBG] SENDER_TS_SAVED jid=' + fallbackJid + ' ts=' + issueTs +
@@ -2081,11 +2622,9 @@ class WhalibmobClient extends EventEmitter {
2081
2622
  // from = LID_USER@s.whatsapp.net — WA sends LID notifications this way.
2082
2623
  // The user part is already the LID user; convert domain to @lid.
2083
2624
  storageJid = fromUser + '@lid';
2084
- } else if (this._pnToLid && this._pnToLid.has(fromUser)) {
2085
- // from is a PN → convert to LID for storage (Baileys always stores under LID)
2086
- storageJid = this._pnToLid.get(fromUser) + '@lid';
2087
2625
  } else {
2088
- storageJid = normalizeJidForTcToken(rawFrom);
2626
+ // Same resolver the send side uses, so both halves agree on the key.
2627
+ storageJid = this._resolveTcTokenJid(rawFrom);
2089
2628
  }
2090
2629
 
2091
2630
  _whaDbg('[DBG] PRIVACY_TOKEN_NOTIF storageJid=' + storageJid);
@@ -2112,11 +2651,7 @@ class WhalibmobClient extends EventEmitter {
2112
2651
 
2113
2652
  void (async () => {
2114
2653
  try {
2115
- // Resolve storage JID: PN→LID if available, otherwise use from as-is
2116
- const fromNorm = normalizeJidForTcToken(from);
2117
- const fromUser = fromNorm.split('@')[0];
2118
- const lidUser = this._pnToLid && this._pnToLid.get(fromUser);
2119
- const tcJid = lidUser ? (lidUser + '@lid') : fromNorm;
2654
+ const tcJid = this._resolveTcTokenJid(from);
2120
2655
 
2121
2656
  const entry = this._tcTokenStore.get(tcJid);
2122
2657
  const senderTs = entry && entry.senderTimestamp;
@@ -2128,9 +2663,15 @@ class WhalibmobClient extends EventEmitter {
2128
2663
  _whaDbg('[DBG] REISSUE_TC_TOKEN_AFTER_IDENTITY_CHANGE jid=' + tcJid +
2129
2664
  ' prevSenderTs=' + senderTs);
2130
2665
 
2131
- const issueTs = Math.floor(Date.now() / 1000);
2132
- const result = await this._issuePrivacyTokens(tcJid, issueTs);
2133
- this._storeTcTokenFromIqResult(result, tcJid, issueTs);
2666
+ // Baileys re-issues with the *previous* senderTimestamp, not a fresh
2667
+ // one, and stores the result without touching the dedupe state. That
2668
+ // matters: this is a repair of an existing grant after the peer's
2669
+ // device changed, not a new grant, so it must not move the 7-day
2670
+ // bucket that governs regular issuance. We were stamping it with
2671
+ // Date.now(), which silently skipped the next scheduled issuance.
2672
+ const issueJid = this._resolveTcTokenIssuanceJid(from);
2673
+ const result = await this._issuePrivacyTokens(issueJid, senderTs);
2674
+ this._storeTcTokenFromIqResult(result, tcJid, senderTs, { saveSenderTs: false });
2134
2675
  } catch (e) {
2135
2676
  _whaDbg('[DBG] REISSUE_TC_TOKEN_ERR from=' + from +
2136
2677
  ' err=' + (e && e.message));
@@ -2152,7 +2693,12 @@ class WhalibmobClient extends EventEmitter {
2152
2693
  */
2153
2694
  _handleHistorySync(notification, msgId) {
2154
2695
  if (!notification) return;
2155
- const phone = this._store && this._store.phoneNumber;
2696
+ // Web-mode history lands in its own files. A number can be registered over
2697
+ // SMS and separately linked as a companion, and only the companion ever
2698
+ // receives history — merging the two would be merging different accounts'
2699
+ // views of the same number.
2700
+ const phone = (this._store && this._store.phoneNumber) +
2701
+ (this._mode === 'web' ? '.web' : '');
2156
2702
  const sessionDir = this._sessionDir;
2157
2703
 
2158
2704
  _whaDbg('[DBG] HIST_SYNC start syncType=' + notification.syncType +
@@ -2539,15 +3085,13 @@ class WhalibmobClient extends EventEmitter {
2539
3085
  subscribeToPresence(jids) {
2540
3086
  if (!this._socket || !this._connected) return;
2541
3087
  const arr = Array.isArray(jids) ? jids : [jids];
2542
- const { normalizeJidForTcToken } = require('./messages/TcTokenStore');
3088
+ const { isRegularTcTokenUser } = require('./messages/TcTokenStore');
2543
3089
  for (const raw of arr) {
2544
3090
  const jid = makeJid(raw);
2545
- const entry = this._tcTokenStore && this._tcTokenStore.get(
2546
- normalizeJidForTcToken(jid)
2547
- );
2548
- const children = (entry && entry.token && entry.token.length)
2549
- ? [new BinaryNode('tctoken', {}, entry.token)]
2550
- : null;
3091
+ // Groups and newsletters have no privacy token; Baileys guards the same
3092
+ // way before building the node.
3093
+ const token = isRegularTcTokenUser(jid) ? this._tcTokenFor(jid) : null;
3094
+ const children = token ? [new BinaryNode('tctoken', {}, token)] : null;
2551
3095
  this._socket.sendNode(new BinaryNode('presence', {
2552
3096
  to: jidStrToObj(jid),
2553
3097
  type: 'subscribe'
@@ -2682,12 +3226,8 @@ class WhalibmobClient extends EventEmitter {
2682
3226
  iqAttrs = { id: this._genMsgId(), xmlns: 'w:g2', to: jidStrToObj(jidFull), type: 'get' };
2683
3227
  content = [new BinaryNode('pictures', {}, [new BinaryNode('picture', attrs, null)])];
2684
3228
  } else {
2685
- const { normalizeJidForTcToken } = require('./messages/TcTokenStore');
2686
- const entry = this._tcTokenStore &&
2687
- this._tcTokenStore.get(normalizeJidForTcToken(jidFull));
2688
- const picChildren = (entry && entry.token && entry.token.length)
2689
- ? [new BinaryNode('tctoken', {}, entry.token)]
2690
- : null;
3229
+ const picToken = this._tcTokenFor(jidFull);
3230
+ const picChildren = picToken ? [new BinaryNode('tctoken', {}, picToken)] : null;
2691
3231
  iqAttrs = {
2692
3232
  id: this._genMsgId(),
2693
3233
  xmlns: 'w:profile:picture',
@@ -2735,7 +3275,7 @@ class WhalibmobClient extends EventEmitter {
2735
3275
  const info = await this.queryPictureInfo(jid, opts);
2736
3276
  if (!info || !info.url) return null;
2737
3277
  const { httpGetBuffer } = require('./MediaService');
2738
- return httpGetBuffer(info.url);
3278
+ return httpGetBuffer(info.url, { web: this._mode === 'web' });
2739
3279
  }
2740
3280
 
2741
3281
  // Parse the <list> a blocklist query or update comes back with.
@@ -3007,26 +3547,39 @@ class WhalibmobClient extends EventEmitter {
3007
3547
  }
3008
3548
 
3009
3549
  const ownJid = String(this._store.phoneNumber) + '@s.whatsapp.net';
3010
- let recipients;
3550
+ let recipients, source;
3011
3551
 
3012
3552
  if (privacy.type === 'whitelist') {
3013
3553
  recipients = privacy.list.slice();
3554
+ source = 'whitelist';
3014
3555
  } 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 => {
3556
+ const hist = this.getHistoryStore() || {};
3557
+ const isPerson = (jid) => !jid.endsWith('@g.us') && !jid.endsWith('@broadcast') &&
3558
+ !jid.endsWith('@newsletter') && !this._isOwnUser(jid);
3559
+
3560
+ // Contacts proper: entries the history sync gave a name to.
3561
+ const contacts = Object.keys(hist.contacts || {}).filter(jid => {
3020
3562
  const c = hist.contacts[jid];
3021
- return c && (c.name || c.displayName) && !jid.endsWith('@g.us') &&
3022
- !jid.endsWith('@broadcast');
3563
+ return c && (c.name || c.displayName) && isPerson(jid);
3023
3564
  });
3565
+
3566
+ // A named contact list is what WhatsApp itself posts to. When history has
3567
+ // not brought one — a fresh session, or a sync that carried chats without
3568
+ // names — the people you actually have conversations with are the next
3569
+ // best thing, and far better than posting to nobody.
3570
+ let pool = contacts;
3571
+ source = 'contacts';
3572
+ if (pool.length === 0) {
3573
+ pool = Object.keys(hist.chats || {}).filter(isPerson);
3574
+ source = 'chats';
3575
+ }
3576
+
3024
3577
  const blocked = new Set(privacy.type === 'blacklist' ? privacy.list : []);
3025
- recipients = named.filter(jid => !blocked.has(jid));
3578
+ recipients = pool.filter(jid => !blocked.has(jid));
3026
3579
  }
3027
3580
 
3028
3581
  if (!recipients.some(j => this._isOwnUser(j))) recipients.push(ownJid);
3029
- _whaDbg('[DBG] STATUS_RECIPIENTS type=' + privacy.type +
3582
+ _whaDbg('[DBG] STATUS_RECIPIENTS type=' + privacy.type + ' source=' + source +
3030
3583
  ' count=' + recipients.length);
3031
3584
  return recipients;
3032
3585
  }
@@ -3756,7 +4309,6 @@ class WhalibmobClient extends EventEmitter {
3756
4309
  async createGroup(subject, participants, opts) {
3757
4310
  opts = opts || {};
3758
4311
  const id = this._genMsgId();
3759
- const { normalizeJidForTcToken } = require('./messages/TcTokenStore');
3760
4312
  // participant nodes MUST use attr `jid`, not `value`
3761
4313
  const partNodes = (participants || []).map(p => {
3762
4314
  const jid = makeJid(p);
@@ -3765,11 +4317,8 @@ class WhalibmobClient extends EventEmitter {
3765
4317
  const pn = this._lidToPn.get(jid.split('@')[0].split(':')[0]);
3766
4318
  if (pn) attrs.phone_number = jidStrToObj(pn + '@s.whatsapp.net');
3767
4319
  }
3768
- const entry = this._tcTokenStore &&
3769
- this._tcTokenStore.get(normalizeJidForTcToken(jid));
3770
- const children = (entry && entry.token && entry.token.length)
3771
- ? [new BinaryNode('privacy', {}, entry.token)]
3772
- : null;
4320
+ const token = this._tcTokenFor(jid);
4321
+ const children = token ? [new BinaryNode('privacy', {}, token)] : null;
3773
4322
  return new BinaryNode('participant', attrs, children);
3774
4323
  });
3775
4324
 
@@ -3876,7 +4425,6 @@ class WhalibmobClient extends EventEmitter {
3876
4425
  async _groupParticipantAction(groupJid, action, jids) {
3877
4426
  const gJid = makeJid(groupJid);
3878
4427
  const arr = Array.isArray(jids) ? jids : [jids];
3879
- const { normalizeJidForTcToken } = require('./messages/TcTokenStore');
3880
4428
  // Same shape whatsmeow's UpdateGroupParticipants builds: binary JIDs, and on
3881
4429
  // an add, the phone number behind a LID plus our privacy token for them.
3882
4430
  const participantNodes = arr.map(j => {
@@ -3888,11 +4436,8 @@ class WhalibmobClient extends EventEmitter {
3888
4436
  const pn = this._lidToPn.get(jid.split('@')[0].split(':')[0]);
3889
4437
  if (pn) attrs.phone_number = jidStrToObj(pn + '@s.whatsapp.net');
3890
4438
  }
3891
- const entry = this._tcTokenStore &&
3892
- this._tcTokenStore.get(normalizeJidForTcToken(jid));
3893
- if (entry && entry.token && entry.token.length) {
3894
- children = [new BinaryNode('privacy', {}, entry.token)];
3895
- }
4439
+ const token = this._tcTokenFor(jid);
4440
+ if (token) children = [new BinaryNode('privacy', {}, token)];
3896
4441
  }
3897
4442
  return new BinaryNode('participant', attrs, children);
3898
4443
  });