whalibmob 5.5.93 → 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/README.md +459 -2
- package/cli.js +151 -5
- package/index.js +28 -0
- package/lib/Client.js +480 -11
- package/lib/CompanionPairing.js +247 -0
- package/lib/HistorySyncHandler.js +6 -1
- package/lib/MediaService.js +58 -12
- package/lib/PairingCode.js +198 -0
- package/lib/WebSocketStream.js +98 -0
- package/lib/WebStore.js +117 -0
- package/lib/constants.js +25 -0
- package/lib/messages/MessageSender.js +26 -7
- package/lib/noise.js +72 -7
- package/lib/signal/SignalProtocol.js +14 -5
- package/lib/webproto.js +273 -0
- package/package.json +3 -2
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
|
-
|
|
461
|
-
this.
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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.
|
|
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);
|
|
@@ -1407,6 +1866,11 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1407
1866
|
this._handlePrivacyTokenNotification(node);
|
|
1408
1867
|
}
|
|
1409
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
|
+
|
|
1410
1874
|
if (type === 'md-msg-hist') {
|
|
1411
1875
|
// Server signals that history sync data is available.
|
|
1412
1876
|
// The actual encrypted notification may arrive as:
|
|
@@ -2229,7 +2693,12 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2229
2693
|
*/
|
|
2230
2694
|
_handleHistorySync(notification, msgId) {
|
|
2231
2695
|
if (!notification) return;
|
|
2232
|
-
|
|
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' : '');
|
|
2233
2702
|
const sessionDir = this._sessionDir;
|
|
2234
2703
|
|
|
2235
2704
|
_whaDbg('[DBG] HIST_SYNC start syncType=' + notification.syncType +
|
|
@@ -2806,7 +3275,7 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2806
3275
|
const info = await this.queryPictureInfo(jid, opts);
|
|
2807
3276
|
if (!info || !info.url) return null;
|
|
2808
3277
|
const { httpGetBuffer } = require('./MediaService');
|
|
2809
|
-
return httpGetBuffer(info.url);
|
|
3278
|
+
return httpGetBuffer(info.url, { web: this._mode === 'web' });
|
|
2810
3279
|
}
|
|
2811
3280
|
|
|
2812
3281
|
// Parse the <list> a blocklist query or update comes back with.
|