webmaxsocket 1.1.4 → 1.1.5

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/index.js CHANGED
@@ -13,6 +13,7 @@ const { Opcode, getOpcodeName } = require('./lib/opcodes');
13
13
  const { UserAgentPayload } = require('./lib/userAgent');
14
14
  const { downloadUrlToTempFile, extFromContentType, extFromAttachType } = require('./lib/downloadMedia');
15
15
  const { resolveIncomingLogMode, printIncomingLog } = require('./lib/incomingLog');
16
+ const { parseQrTrackId } = require('./lib/qrWebLogin');
16
17
 
17
18
  module.exports = {
18
19
  WebMaxClient,
@@ -30,6 +31,7 @@ module.exports = {
30
31
  extFromContentType,
31
32
  extFromAttachType,
32
33
  resolveIncomingLogMode,
33
- printIncomingLog
34
+ printIncomingLog,
35
+ parseQrTrackId
34
36
  };
35
37
 
package/lib/client.js CHANGED
@@ -11,6 +11,7 @@ const { EventTypes, ChatActions } = require('./constants');
11
11
  const { Opcode, DeviceType, getOpcodeName } = require('./opcodes');
12
12
  const { UserAgentPayload } = require('./userAgent');
13
13
  const { resolveIncomingLogMode, printIncomingLog } = require('./incomingLog');
14
+ const { parseQrTrackId } = require('./qrWebLogin');
14
15
 
15
16
  /**
16
17
  * Загружает конфиг: { token, agent }
@@ -79,34 +80,50 @@ class WebMaxClient extends EventEmitter {
79
80
 
80
81
  this._providedToken = token;
81
82
  this._saveTokenToSession = options.saveToken !== false;
82
- this.origin = 'https://web.max.ru';
83
+ this.origin = options.origin || 'https://web.max.ru';
84
+ /** Доп. заголовок для ws (например Referer при QR с max.ru, пока Origin остаётся web.max.ru — иначе 403). */
85
+ this._wsReferer = options.referer || options.wsReferer || null;
83
86
  this.session = new SessionManager(this.sessionName);
84
87
 
85
88
  const deviceTypeMap = { 1: 'WEB', 2: 'IOS', 3: 'ANDROID' };
86
89
  const rawDeviceType = options.deviceType ?? configObj.device_type ?? configObj.deviceType ?? this.session.get('deviceType');
87
90
  const deviceType = deviceTypeMap[rawDeviceType] || rawDeviceType || 'WEB';
88
- const uaString = agent || configObj.headerUserAgent || configObj.ua || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36';
91
+ const uaString =
92
+ agent ||
93
+ configObj.headerUserAgent ||
94
+ configObj.ua ||
95
+ this.session.get('headerUserAgent') ||
96
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36';
89
97
  const webDefaults = {
90
98
  deviceType: deviceType,
91
- locale: options.locale || configObj.locale || 'ru',
92
- deviceLocale: options.deviceLocale || configObj.deviceLocale || configObj.locale || 'ru',
99
+ locale: options.locale || configObj.locale || this.session.get('locale') || 'ru',
100
+ deviceLocale:
101
+ options.deviceLocale ||
102
+ configObj.deviceLocale ||
103
+ configObj.locale ||
104
+ this.session.get('deviceLocale') ||
105
+ this.session.get('locale') ||
106
+ 'ru',
93
107
  osVersion:
94
108
  options.osVersion ||
95
109
  configObj.osVersion ||
110
+ this.session.get('osVersion') ||
96
111
  (deviceType === 'IOS' ? '18.6.2' : deviceType === 'ANDROID' ? '14' : 'Windows 11'),
97
112
  deviceName:
98
113
  options.deviceName ||
99
114
  configObj.deviceName ||
115
+ this.session.get('deviceName') ||
100
116
  (deviceType === 'IOS' ? 'Safari' : deviceType === 'ANDROID' ? 'Chrome' : 'Chrome'),
101
117
  headerUserAgent: options.headerUserAgent || options.ua || uaString,
102
118
  // Ниже 25.12.13 сервер может отвечать qr_login.disabled на GET_QR (см. PyMax _login).
103
- appVersion: options.appVersion || configObj.appVersion || '25.12.14',
119
+ appVersion: options.appVersion || configObj.appVersion || this.session.get('appVersion') || '26.3.9',
104
120
  screen:
105
121
  options.screen ||
106
122
  configObj.screen ||
123
+ this.session.get('screen') ||
107
124
  (deviceType === 'IOS' ? '390x844 3.0x' : deviceType === 'ANDROID' ? '360x780 3.0x' : '1080x1920 1.0x'),
108
- timezone: options.timezone || configObj.timezone || 'Europe/Moscow',
109
- buildNumber: options.buildNumber ?? configObj.buildNumber,
125
+ timezone: options.timezone || configObj.timezone || this.session.get('timezone') || 'Europe/Moscow',
126
+ buildNumber: options.buildNumber ?? configObj.buildNumber ?? this.session.get('buildNumber'),
110
127
  clientSessionId: options.clientSessionId ?? configObj.clientSessionId ?? this.session.get('clientSessionId'),
111
128
  release: options.release ?? configObj.release
112
129
  };
@@ -127,8 +144,8 @@ class WebMaxClient extends EventEmitter {
127
144
  this.isConnected = false;
128
145
  this.isAuthorized = false;
129
146
  this.reconnectAttempts = 0;
130
- this.maxReconnectAttempts = options.maxReconnectAttempts || 5;
131
- this.reconnectDelay = options.reconnectDelay || 3000;
147
+ this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
148
+ this.reconnectDelay = options.reconnectDelay ?? 3000;
132
149
 
133
150
  // Protocol fields
134
151
  this.seq = 0;
@@ -316,7 +333,7 @@ class WebMaxClient extends EventEmitter {
316
333
  async requestQR() {
317
334
  console.log('Запрос QR-кода для авторизации...');
318
335
 
319
- const response = await this.sendAndWait(Opcode.GET_QR, {});
336
+ const response = await this.sendAndWait(Opcode.GET_QR, undefined);
320
337
 
321
338
  throwIfGetQRRejected(response.payload);
322
339
 
@@ -336,19 +353,456 @@ class WebMaxClient extends EventEmitter {
336
353
  return response.payload;
337
354
  }
338
355
 
356
+ /**
357
+ * На TCP после ответа LOGIN сервер может закрыть сокет — перед следующим RPC переподключаемся и снова LOGIN.
358
+ */
359
+ async _ensureTcpSocketReadyForRpc() {
360
+ const st = this._socketTransport;
361
+ if (st && st.socket && !st.socket.destroyed) return;
362
+ const token = this._token || this.session.get('token');
363
+ if (!token) {
364
+ throw new Error('Нет токена для переподключения TCP');
365
+ }
366
+ console.log('Переподключение TCP (сокет закрыт после предыдущего запроса)…');
367
+ this.isConnected = false;
368
+ await this.connect();
369
+ this._token = token;
370
+ await this.sync();
371
+ this.isAuthorized = true;
372
+ }
373
+
339
374
  /**
340
375
  * Завершение авторизации по QR-коду
376
+ * @param {string} trackId — UUID (веб GET_QR) или opaque token `qr_…` (max.ru/qr/v1/auth)
341
377
  */
342
378
  async loginByQR(trackId) {
343
- const response = await this.sendAndWait(Opcode.LOGIN_BY_QR, { trackId });
344
-
379
+ if (this._useSocketTransport) {
380
+ await this._ensureTcpSocketReadyForRpc();
381
+ }
382
+ const id = String(trackId).trim();
383
+ const payload = id.startsWith('qr_') ? { token: id } : { trackId: id };
384
+ const response = await this.sendAndWait(Opcode.LOGIN_BY_QR, payload);
385
+
345
386
  if (response.payload && response.payload.error) {
346
387
  throw new Error(`QR login error: ${JSON.stringify(response.payload.error)}`);
347
388
  }
348
-
389
+
349
390
  return response.payload;
350
391
  }
351
392
 
393
+ async approveQR(qrLink) {
394
+ if (this._useSocketTransport) {
395
+ await this._ensureTcpSocketReadyForRpc();
396
+ }
397
+ if (!this.isAuthorized) {
398
+ throw new Error('Требуется авторизованная сессия для одобрения QR');
399
+ }
400
+ const link = String(qrLink).trim();
401
+ const response = await this.sendAndWait(Opcode.AUTH_QR_APPROVE, { qrLink: link });
402
+ if (response.payload && response.payload.error) {
403
+ throw new Error(`QR approve error: ${JSON.stringify(response.payload.error)}`);
404
+ }
405
+ return response.payload;
406
+ }
407
+
408
+ /**
409
+ * LOGIN_BY_QR: как web.max.ru — сначала только trackId (HAR), затем запасные варианты + токен аккаунта.
410
+ */
411
+ async _loginByQrWebMax(web, trackId, accountToken) {
412
+ const id = String(trackId).trim();
413
+ if (id.startsWith('qr_')) {
414
+ const response = await web.sendAndWait(Opcode.LOGIN_BY_QR, { token: id });
415
+ if (response.payload && response.payload.error) {
416
+ throw new Error(`QR login error: ${JSON.stringify(response.payload.error)}`);
417
+ }
418
+ return response.payload;
419
+ }
420
+ const payloads = [{ trackId: id }, { track: id }, { authTrackId: id }];
421
+ if (accountToken) {
422
+ payloads.push(
423
+ { trackId: id, token: accountToken },
424
+ { track: id, token: accountToken },
425
+ { trackId: id, account_token: accountToken },
426
+ { track: id, account_token: accountToken }
427
+ );
428
+ }
429
+
430
+ let lastErr;
431
+ for (const payload of payloads) {
432
+ try {
433
+ const response = await web.sendAndWait(Opcode.LOGIN_BY_QR, payload);
434
+ if (response.payload && response.payload.error) {
435
+ const err = response.payload.error;
436
+ const msg =
437
+ typeof err === 'string'
438
+ ? err
439
+ : err && typeof err.message === 'string'
440
+ ? err.message
441
+ : response.payload.localizedMessage || JSON.stringify(err);
442
+ lastErr = new Error(msg);
443
+ if (/track\.not\.found/i.test(String(msg))) {
444
+ throw new Error(
445
+ `${msg}\n` +
446
+ 'Трек QR устарел или уже использован. Откройте web.max.ru, дождитесь нового QR и сразу сохраните qr.png.'
447
+ );
448
+ }
449
+ if (/proto\.payload/i.test(String(msg))) {
450
+ continue;
451
+ }
452
+ continue;
453
+ }
454
+ return response.payload;
455
+ } catch (e) {
456
+ lastErr = e;
457
+ if (e && e.message && /track\.not\.found|Трек QR устарел/i.test(e.message)) {
458
+ throw e;
459
+ }
460
+ }
461
+ }
462
+ throw lastErr || new Error('LOGIN_BY_QR не удался');
463
+ }
464
+
465
+ /**
466
+ * Как в web.max.ru (HAR): опрос GET_QR_STATUS, пока status.loginAvailable !== true (после скана в приложении Max).
467
+ * Без этого LOGIN_BY_QR часто даёт track.not.found — трек не «готов» к подтверждению.
468
+ */
469
+ async _waitForWebQrLoginAvailable(web, trackId, options = {}) {
470
+ const pollingInterval = options.pollingInterval ?? 5000;
471
+ const defaultTtlMs = options.defaultTtlMs ?? 180000;
472
+ let expiresAt = Date.now() + defaultTtlMs;
473
+
474
+ console.log(
475
+ '\n📱 Нужно подтверждение с телефона (как в официальном web-клиенте):\n' +
476
+ ' Max на телефоне → раздел с входом по QR к веб-версии / устройства — наведите на тот же QR (тот же аккаунт).\n' +
477
+ ' Чтобы вкладка браузера не отправила LOGIN_BY_QR раньше скрипта: после сохранения qr.png закройте вкладку web.max.ru,\n' +
478
+ ' затем запустите этот скрипт и отсканируйте QR с файла/второго монитора.\n' +
479
+ ' Ожидание loginAvailable на сервере…'
480
+ );
481
+
482
+ while (true) {
483
+ const now = Date.now();
484
+ if (now >= expiresAt) {
485
+ throw new Error(
486
+ 'Нет loginAvailable до истечения времени: отсканируйте QR приложением на тот же аккаунт или обновите qr.png.'
487
+ );
488
+ }
489
+
490
+ const statusResponse = await web.checkQRStatus(trackId);
491
+ if (statusResponse.status && statusResponse.status.expiresAt != null) {
492
+ const ex = Number(statusResponse.status.expiresAt);
493
+ if (Number.isFinite(ex)) {
494
+ expiresAt = Math.min(expiresAt, ex);
495
+ }
496
+ }
497
+ if (statusResponse.status && statusResponse.status.loginAvailable) {
498
+ console.log('\n✅ Сервер готов к LOGIN_BY_QR (loginAvailable).');
499
+ return;
500
+ }
501
+
502
+ process.stdout.write('.');
503
+
504
+ const wait = Math.min(
505
+ pollingInterval,
506
+ Math.max(0, expiresAt - Date.now())
507
+ );
508
+ await new Promise((r) => setTimeout(r, wait || pollingInterval));
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Подтверждение входа по QR: новое TCP-соединение (только SESSION_INIT), затем LOGIN_BY_QR — без LOGIN на этом сокете.
514
+ */
515
+ async _approveWebLoginByQrViaEphemeralTcp(trackId) {
516
+ const accountToken = this._token || this.session.get('token');
517
+ const transport = new MaxSocketTransport({
518
+ deviceId: this.deviceId,
519
+ deviceType: this.userAgent.deviceType,
520
+ ua: this.userAgent.headerUserAgent,
521
+ debug: this.debug,
522
+ });
523
+ await transport.connect();
524
+ await transport.handshake(this.userAgent);
525
+ try {
526
+ const id = String(trackId).trim();
527
+ if (id.startsWith('qr_')) {
528
+ const result = await transport.sendAndWait(Opcode.LOGIN_BY_QR, { token: id });
529
+ return result.payload;
530
+ }
531
+ const payloads = [
532
+ { trackId: id },
533
+ { track: id },
534
+ { trackId: id, token: accountToken },
535
+ { track: id, token: accountToken },
536
+ ];
537
+ let lastErr;
538
+ for (const payload of payloads) {
539
+ try {
540
+ const result = await transport.sendAndWait(Opcode.LOGIN_BY_QR, payload);
541
+ return result.payload;
542
+ } catch (e) {
543
+ lastErr = e;
544
+ }
545
+ }
546
+ throw lastErr || new Error('LOGIN_BY_QR (TCP) не удался');
547
+ } finally {
548
+ await transport.close();
549
+ }
550
+ }
551
+
552
+ /**
553
+ * Подтверждение входа в web.max по QR при основной сессии на TCP: отдельный WebSocket + LOGIN + LOGIN_BY_QR.
554
+ * На том же TCP после LOGIN сервер не принимает LOGIN_BY_QR («Недопустимое состояние сессии»).
555
+ * WEB-клиент: не используем IOS deviceId (даёт login.cred); берём webDeviceId из сессии или новый UUID.
556
+ * Сначала пробуем webToken для sync, затем основной token. Без успешного sync LOGIN_BY_QR часто даёт auth_by_track.no.attempts.
557
+ */
558
+ async _approveWebLoginByQrViaEphemeralWeb(trackId, options = {}, qrSource = '') {
559
+ const {
560
+ retry = true,
561
+ saveWebSession = true,
562
+ waitForPhoneScan = true,
563
+ qrPollingInterval = 5000,
564
+ } = options;
565
+ const accountToken = this._token || this.session.get('token');
566
+ if (!accountToken) {
567
+ throw new Error('Нет токена сессии');
568
+ }
569
+
570
+ const refererForMaxQr =
571
+ /https?:\/\/max\.ru\//i.test(String(qrSource)) && !/web\.max\.ru/i.test(String(qrSource))
572
+ ? 'https://max.ru/'
573
+ : null;
574
+
575
+ const webUa =
576
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36';
577
+ const webDeviceId = this.session.get('webDeviceId') || uuidv4();
578
+
579
+ const ephemeralName = `_web_qr_${uuidv4().replace(/-/g, '').slice(0, 12)}`;
580
+ const web = new this.constructor({
581
+ name: ephemeralName,
582
+ deviceType: 'WEB',
583
+ deviceId: webDeviceId,
584
+ // Не делим clientSessionId с параллельным TCP (IOS) — иначе сервер может обрывать WS (1006).
585
+ clientSessionId: Math.floor(Math.random() * 0x7fffffff),
586
+ headerUserAgent: webUa,
587
+ appVersion: '26.3.9',
588
+ screen: '1920x1080 1.0x',
589
+ osVersion: 'Windows 11',
590
+ deviceName: 'Chrome',
591
+ saveToken: false,
592
+ debug: this.debug,
593
+ apiUrl: this.apiUrl,
594
+ origin: 'https://web.max.ru',
595
+ referer: refererForMaxQr,
596
+ maxReconnectAttempts: 0
597
+ });
598
+ web.handleReconnect = () => {};
599
+
600
+ const wrapTrackError = (e) => {
601
+ const m = e && e.message ? e.message : String(e);
602
+ if (/auth_by_track|no\.attempts|Недопустимое состояние|track\.not\.found/i.test(m)) {
603
+ return new Error(
604
+ `${m}\n` +
605
+ 'Подсказка: WebSocket принимает только Origin web.max.ru; для max.ru/:auth/ добавлен Referer. Сделайте новый скрин qr.png сразу после появления QR; старый трек даёт track.not.found.'
606
+ );
607
+ }
608
+ return e;
609
+ };
610
+
611
+ try {
612
+ console.log(
613
+ `Отдельное WebSocket (Origin=web.max.ru${refererForMaxQr ? ', Referer=max.ru' : ''}): ws-api принимает только Origin web.max.ru; при QR с max.ru добавлен Referer.`
614
+ );
615
+ await web.connect();
616
+
617
+ const webTok = this.session.get('webToken');
618
+ let syncOk = false;
619
+ // IOS-токен на WEB даёт login.cred и сервер часто рвёт сокет — не вызываем sync с ним.
620
+ // Только отдельный webToken из прошлого входа в web.max.
621
+ if (webTok && webTok !== accountToken) {
622
+ try {
623
+ web._token = webTok;
624
+ await web.sync();
625
+ syncOk = true;
626
+ web.isAuthorized = true;
627
+ if (saveWebSession && web.deviceId) {
628
+ this.session.set('webDeviceId', web.deviceId);
629
+ }
630
+ } catch (e) {
631
+ const msg = e && e.message ? e.message : String(e);
632
+ console.log(`WEB LOGIN (sync) с webToken: ${msg}`);
633
+ }
634
+ }
635
+
636
+ if (waitForPhoneScan) {
637
+ await this._waitForWebQrLoginAvailable(web, trackId, {
638
+ pollingInterval: qrPollingInterval,
639
+ });
640
+ }
641
+
642
+ const attempts = retry ? 2 : 1;
643
+ let lastErr;
644
+ for (let attempt = 1; attempt <= attempts; attempt++) {
645
+ try {
646
+ const wsOpen = web.ws && web.ws.readyState === WebSocket.OPEN;
647
+ if (!web.isConnected || !wsOpen) {
648
+ console.log('Переподключение WebSocket перед LOGIN_BY_QR (после ошибки LOGIN сокет часто закрывается)…');
649
+ if (web.ws) {
650
+ try {
651
+ web.ws.removeAllListeners();
652
+ } catch (_) {}
653
+ try {
654
+ web.ws.close();
655
+ } catch (_) {}
656
+ web.ws = null;
657
+ }
658
+ web.isConnected = false;
659
+ web.pendingRequests.clear();
660
+ await web.connect();
661
+ }
662
+
663
+ let loginData;
664
+ if (syncOk) {
665
+ loginData = await this._loginByQrWebMax(web, trackId, null);
666
+ } else {
667
+ loginData = await this._loginByQrWebMax(web, trackId, accountToken);
668
+ }
669
+
670
+ if (saveWebSession && loginData && typeof loginData === 'object') {
671
+ this.session.set('webQrTrackId', trackId);
672
+ const loginAttrs = loginData.tokenAttrs && loginData.tokenAttrs.LOGIN;
673
+ const wtoken = loginAttrs && loginAttrs.token;
674
+ if (wtoken) {
675
+ this.session.set('webToken', wtoken);
676
+ }
677
+ if (loginData.deviceId != null) {
678
+ this.session.set('webDeviceId', loginData.deviceId);
679
+ }
680
+ if (loginData.clientSessionId != null) {
681
+ this.session.set('webClientSessionId', loginData.clientSessionId);
682
+ }
683
+ }
684
+
685
+ console.log('✅ Вход в веб-версию по QR подтверждён (LOGIN_BY_QR через WebSocket)');
686
+ return loginData;
687
+ } catch (e) {
688
+ lastErr = wrapTrackError(e);
689
+ if (attempt < attempts) {
690
+ await new Promise((r) => setTimeout(r, 400));
691
+ continue;
692
+ }
693
+ throw lastErr;
694
+ }
695
+ }
696
+ throw lastErr;
697
+ } finally {
698
+ try {
699
+ await web.stop();
700
+ web.session.destroy();
701
+ } catch (_) {}
702
+ }
703
+ }
704
+
705
+ /**
706
+ * Подтвердить вход в веб-версию (web.max.ru) по URL из QR или trackId,
707
+ * используя уже авторизованную сессию (в т.ч. TCP после SMS/токена).
708
+ * Аналог сценария с `qr_url` + `account_token` на сторонних сервисах: здесь токен уже в сессии.
709
+ *
710
+ * @param {string} qrUrlOrTrackId — полный URL из QR, либо UUID trackId
711
+ * @param {{ retry?: boolean, saveWebSession?: boolean, waitForPhoneScan?: boolean, qrPollingInterval?: number }} [options]
712
+ * @returns {Promise<object>} payload ответа LOGIN_BY_QR
713
+ */
714
+ async approveWebLoginByQr(qrUrlOrTrackId, options = {}) {
715
+ const { retry = true, saveWebSession = true } = options;
716
+
717
+ const trackId = parseQrTrackId(qrUrlOrTrackId);
718
+ if (!trackId) {
719
+ throw new Error(
720
+ 'Не удалось извлечь идентификатор из QR: ожидается URL max.ru/qr/v1/auth?token=qr_…, web.max с trackId или UUID'
721
+ );
722
+ }
723
+
724
+ const token = this._token || this.session.get('token');
725
+ if (!token) {
726
+ throw new Error('Нет токена в сессии для подтверждения входа');
727
+ }
728
+
729
+ if (this._useSocketTransport) {
730
+ if (!this.isAuthorized) {
731
+ throw new Error(
732
+ 'Нужна авторизованная сессия: выполните connect() + sync() с действующим токеном'
733
+ );
734
+ }
735
+ if (process.env.MAX_QR_TRY_TCP_FIRST === '1') {
736
+ try {
737
+ const loginData = await this._approveWebLoginByQrViaEphemeralTcp(trackId);
738
+ if (saveWebSession && loginData && typeof loginData === 'object') {
739
+ this.session.set('webQrTrackId', trackId);
740
+ const loginAttrs = loginData.tokenAttrs && loginData.tokenAttrs.LOGIN;
741
+ const wtoken = loginAttrs && loginAttrs.token;
742
+ if (wtoken) {
743
+ this.session.set('webToken', wtoken);
744
+ }
745
+ if (loginData.deviceId != null) {
746
+ this.session.set('webDeviceId', loginData.deviceId);
747
+ }
748
+ if (loginData.clientSessionId != null) {
749
+ this.session.set('webClientSessionId', loginData.clientSessionId);
750
+ }
751
+ }
752
+ console.log('✅ Вход в веб-версию по QR подтверждён (LOGIN_BY_QR, новое TCP-соединение)');
753
+ return loginData;
754
+ } catch (e) {
755
+ const msg = e && e.message ? e.message : String(e);
756
+ console.log(`LOGIN_BY_QR по новому TCP: ${msg} — пробуем WebSocket (Origin web.max.ru)…`);
757
+ }
758
+ }
759
+ return await this._approveWebLoginByQrViaEphemeralWeb(trackId, options, qrUrlOrTrackId);
760
+ }
761
+
762
+ if (!this.isConnected) {
763
+ throw new Error('Нет соединения: сначала await client.connect()');
764
+ }
765
+ if (!this.isAuthorized) {
766
+ throw new Error(
767
+ 'Нужна авторизованная сессия: выполните start() или connect() + sync() с действующим токеном'
768
+ );
769
+ }
770
+
771
+ let lastErr;
772
+ const attempts = retry ? 2 : 1;
773
+ for (let attempt = 1; attempt <= attempts; attempt++) {
774
+ try {
775
+ const loginData = await this.loginByQR(trackId);
776
+
777
+ if (saveWebSession && loginData && typeof loginData === 'object') {
778
+ this.session.set('webQrTrackId', trackId);
779
+ const loginAttrs = loginData.tokenAttrs && loginData.tokenAttrs.LOGIN;
780
+ const wtoken = loginAttrs && loginAttrs.token;
781
+ if (wtoken) {
782
+ this.session.set('webToken', wtoken);
783
+ }
784
+ if (loginData.deviceId != null) {
785
+ this.session.set('webDeviceId', loginData.deviceId);
786
+ }
787
+ if (loginData.clientSessionId != null) {
788
+ this.session.set('webClientSessionId', loginData.clientSessionId);
789
+ }
790
+ }
791
+
792
+ console.log('✅ Вход в веб-версию по QR подтверждён (LOGIN_BY_QR)');
793
+ return loginData;
794
+ } catch (e) {
795
+ lastErr = e;
796
+ if (attempt < attempts) {
797
+ await new Promise((r) => setTimeout(r, 400));
798
+ continue;
799
+ }
800
+ throw e;
801
+ }
802
+ }
803
+ throw lastErr;
804
+ }
805
+
352
806
  /**
353
807
  * Опрос статуса QR-кода
354
808
  */
@@ -409,7 +863,7 @@ class WebMaxClient extends EventEmitter {
409
863
  }
410
864
 
411
865
  console.log('Запрос QR-кода для привязки устройства...');
412
- const response = await this.sendAndWait(Opcode.GET_QR, {});
866
+ const response = await this.sendAndWait(Opcode.GET_QR, undefined);
413
867
 
414
868
  throwIfGetQRRejected(response.payload);
415
869
 
@@ -471,7 +925,7 @@ class WebMaxClient extends EventEmitter {
471
925
  );
472
926
  await webQr.connect();
473
927
 
474
- const response = await webQr.sendAndWait(Opcode.GET_QR, {});
928
+ const response = await webQr.sendAndWait(Opcode.GET_QR, undefined);
475
929
  throwIfGetQRRejected(response.payload);
476
930
 
477
931
  const qrData = response.payload;
@@ -840,9 +1294,12 @@ class WebMaxClient extends EventEmitter {
840
1294
 
841
1295
  return new Promise((resolve, reject) => {
842
1296
  const headers = {
843
- 'Origin': this.origin,
1297
+ Origin: this.origin,
844
1298
  'User-Agent': this._handshakeUserAgent.headerUserAgent
845
1299
  };
1300
+ if (this._wsReferer) {
1301
+ headers.Referer = this._wsReferer;
1302
+ }
846
1303
 
847
1304
  this.ws = new WebSocket(this.apiUrl, {
848
1305
  headers: headers
@@ -872,8 +1329,9 @@ class WebMaxClient extends EventEmitter {
872
1329
  reject(error);
873
1330
  });
874
1331
 
875
- this.ws.on('close', () => {
876
- console.log('WebSocket соединение закрыто');
1332
+ this.ws.on('close', (code, reason) => {
1333
+ const rs = reason && reason.length ? reason.toString() : '';
1334
+ console.log(`WebSocket соединение закрыто (code=${code}${rs ? `, ${rs}` : ''})`);
877
1335
  this.isConnected = false;
878
1336
  const err = new Error('Соединение закрыто');
879
1337
  for (const [, pending] of this.pendingRequests) {
@@ -959,10 +1417,13 @@ class WebMaxClient extends EventEmitter {
959
1417
  * Обработка переподключения
960
1418
  */
961
1419
  handleReconnect() {
1420
+ if (this.maxReconnectAttempts <= 0) {
1421
+ return;
1422
+ }
962
1423
  if (this.reconnectAttempts < this.maxReconnectAttempts) {
963
1424
  this.reconnectAttempts++;
964
1425
  console.log(`Попытка переподключения ${this.reconnectAttempts}/${this.maxReconnectAttempts}...`);
965
-
1426
+
966
1427
  setTimeout(() => {
967
1428
  this.connect();
968
1429
  }, this.reconnectDelay);
@@ -983,8 +1444,12 @@ class WebMaxClient extends EventEmitter {
983
1444
  console.log(`📥 ${getOpcodeName(message.opcode)} (seq=${message.seq})${payload}`);
984
1445
  }
985
1446
 
986
- // Обработка ответов на запросы по seq
987
- if (message.seq && this.pendingRequests.has(message.seq)) {
1447
+ // Обработка ответов на запросы по seq (seq может быть 0 — не использовать truthiness)
1448
+ if (
1449
+ message.seq !== undefined &&
1450
+ message.seq !== null &&
1451
+ this.pendingRequests.has(message.seq)
1452
+ ) {
988
1453
  const pending = this.pendingRequests.get(message.seq);
989
1454
  this.pendingRequests.delete(message.seq);
990
1455
 
@@ -1090,17 +1555,21 @@ class WebMaxClient extends EventEmitter {
1090
1555
 
1091
1556
  /**
1092
1557
  * Создает сообщение в протоколе Max API
1558
+ * payload не включаем в JSON, если undefined — как web.max.ru для GET_QR (только opcode).
1093
1559
  */
1094
1560
  makeMessage(opcode, payload, cmd = 0) {
1095
1561
  this.seq += 1;
1096
-
1097
- return {
1562
+
1563
+ const message = {
1098
1564
  ver: this.ver,
1099
1565
  cmd: cmd,
1100
1566
  seq: this.seq,
1101
1567
  opcode: opcode,
1102
- payload: payload
1103
1568
  };
1569
+ if (payload !== undefined) {
1570
+ message.payload = payload;
1571
+ }
1572
+ return message;
1104
1573
  }
1105
1574
 
1106
1575
  /**
package/lib/opcodes.js CHANGED
@@ -1,7 +1,3 @@
1
- /**
2
- * Opcodes для протокола Max API
3
- */
4
-
5
1
  const Opcode = {
6
2
  PING: 1,
7
3
  DEBUG: 2,
@@ -30,7 +26,6 @@ const Opcode = {
30
26
  UPLOAD_ATTACH_PREP: 65,
31
27
  MSG_DELETE: 66,
32
28
  MSG_EDIT: 67,
33
- /** Подписка / отписка на канал (subscribe: true|false) */
34
29
  CHAT_SUBSCRIBE: 75,
35
30
  CHAT_MEMBERS_UPDATE: 77,
36
31
  PHOTO_UPLOAD: 80,
@@ -54,18 +49,15 @@ const Opcode = {
54
49
  FOLDERS_DELETE: 276,
55
50
  GET_QR: 288,
56
51
  GET_QR_STATUS: 289,
52
+ AUTH_QR_APPROVE: 290,
57
53
  LOGIN_BY_QR: 291,
58
54
  };
59
55
 
60
- // Обратная карта для расшифровки опкодов
61
56
  const OpcodeNames = {};
62
57
  for (const [name, code] of Object.entries(Opcode)) {
63
58
  OpcodeNames[code] = name;
64
59
  }
65
60
 
66
- /**
67
- * Получить название опкода
68
- */
69
61
  function getOpcodeName(code) {
70
62
  return OpcodeNames[code] || `UNKNOWN_${code}`;
71
63
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Извлекает идентификатор входа по QR: UUID trackId или opaque token (qr_…).
3
+ * Поддерживаются:
4
+ * - https://max.ru/qr/v1/auth?token=qr_…
5
+ * - https://max.ru/login/qr/<uuid> или :auth/<uuid>
6
+ * - web.max / trackId в query
7
+ * - «голый» UUID или строка qr_…
8
+ *
9
+ * @param {string} qrUrlOrTrackId
10
+ * @returns {string|null}
11
+ */
12
+ function parseQrTrackId(qrUrlOrTrackId) {
13
+ const s = String(qrUrlOrTrackId == null ? '' : qrUrlOrTrackId).trim();
14
+ if (!s) return null;
15
+
16
+ let decoded = s;
17
+ try {
18
+ decoded = decodeURIComponent(s);
19
+ } catch (_) {
20
+ decoded = s;
21
+ }
22
+
23
+ const uuidRe = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
24
+ const qrTokenRe = /^qr_[a-zA-Z0-9_-]+$/i;
25
+
26
+ if (qrTokenRe.test(decoded)) {
27
+ return decoded;
28
+ }
29
+
30
+ try {
31
+ if (/^https?:\/\//i.test(decoded)) {
32
+ const u = new URL(decoded);
33
+ const tokenParam = u.searchParams.get('token');
34
+ if (tokenParam && qrTokenRe.test(tokenParam.trim())) {
35
+ return tokenParam.trim();
36
+ }
37
+ for (const key of ['trackId', 'track_id', 'track', 'tid']) {
38
+ const v = u.searchParams.get(key);
39
+ if (v) {
40
+ const m = String(v).match(uuidRe);
41
+ if (m) return m[0];
42
+ }
43
+ }
44
+ // https://max.ru/auth/<uuid> или max.ru/:auth/<uuid> (как в QR)
45
+ const pathSegs = u.pathname.split('/').filter(Boolean);
46
+ for (const seg of pathSegs) {
47
+ if (uuidRe.test(seg)) {
48
+ const m = seg.match(uuidRe);
49
+ if (m) return m[0];
50
+ }
51
+ }
52
+ }
53
+ } catch (_) {}
54
+
55
+ const m = decoded.match(uuidRe);
56
+ return m ? m[0] : null;
57
+ }
58
+
59
+ module.exports = { parseQrTrackId };
@@ -65,6 +65,7 @@ function unpackPacket(data) {
65
65
 
66
66
  /** JSON для логов: bigint → string, обрезка длинных строк */
67
67
  function safeJsonForLog(obj, maxLen = 12000) {
68
+ if (obj === undefined) return '(no payload)';
68
69
  try {
69
70
  const s = JSON.stringify(
70
71
  obj,
@@ -152,7 +153,8 @@ class MaxSocketTransport {
152
153
  cmd,
153
154
  seq: this.seq,
154
155
  opcode,
155
- payload: payload || {}
156
+ // undefined — пустое тело пакета (как GET_QR без payload в web); null/{} — явный объект
157
+ payload: payload === undefined ? undefined : payload || {},
156
158
  };
157
159
  }
158
160
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webmaxsocket",
3
- "version": "1.1.4",
3
+ "version": "1.1.5",
4
4
  "description": "Node.js client for Max Messenger with QR code and token authentication",
5
5
  "main": "index.js",
6
6
  "scripts": {