webmaxsocket 1.1.4 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,12 +31,18 @@
31
31
  npm install webmaxsocket
32
32
  ```
33
33
 
34
- ### Зависимости для Socket транспорта (IOS/ANDROID)
34
+ ### Зависимости для Socket транспорта (IOS/ANDROID) / Socket transport dependencies
35
35
 
36
- Для работы с TCP Socket транспортом требуется библиотека `lz4`. Если при установке возникают проблемы с `node-gyp`:
36
+ Ответы сервера по TCP содержат полезную нагрузку в **LZ4**-блоках (поверх **msgpack**). Для распаковки используется **`lz4js`** — чистый JavaScript, **без node-gyp** и нативной сборки, в том числе на Windows без Visual Studio C++. Он входит в зависимости `webmaxsocket` и ставится вместе с пакетом. При необходимости можно доустановить вручную:
37
37
 
38
38
  ```bash
39
- npm install lz4 --ignore-scripts
39
+ npm install lz4js
40
+ ```
41
+
42
+ Дополнительно можно установить нативный модуль **`lz4`**, если в окружении доступна сборка C++:
43
+
44
+ ```bash
45
+ npm install lz4
40
46
  ```
41
47
 
42
48
  **Примечание:** Для обычной QR-авторизации (WEB) дополнительные зависимости не нужны. Socket транспорт используется только после сохранения сессии или при явном указании `deviceType: 'IOS'`/`'ANDROID'`.
@@ -668,7 +674,7 @@ ChatActions.RECORDING_VIDEO // Записывает видео
668
674
 
669
675
  ### MaxSocketTransport
670
676
 
671
- Низкоуровневый TCP Socket транспорт для IOS/ANDROID (api.oneme.ru).
677
+ Низкоуровневый TCP Socket транспорт для IOS/ANDROID (api.oneme.ru). Входящие пакеты с флагом сжатия распаковываются через **LZ4** (см. раздел **«Зависимости для Socket транспорта»** выше).
672
678
 
673
679
  #### Прямое использование (advanced)
674
680
 
@@ -827,6 +833,8 @@ DEBUG=1 node example.js
827
833
 
828
834
  7. **TCP и keep-alive (PING):** сервер периодически шлёт `PING`. На WebSocket клиент отвечает `sendPong`; на **TCP** раньше ответ не отправлялся — соединение могло обрываться через несколько минут, после чего процесс Node **завершался** (нечем держать event loop). Сейчас на TCP автоматически шлётся тот же ответ, что и у WebSocket (`PING` с пустым payload).
829
835
 
836
+ 8. **LZ4:** для IOS/ANDROID входящие данные распаковываются из LZ4-блоков; **`lz4js`** входит в зависимости пакета. При необходимости можно установить нативный **`lz4`** (см. раздел **«Зависимости для Socket транспорта»**).
837
+
830
838
  ## 🔗 Ссылки / Links
831
839
 
832
840
  - [GitHub Repository](https://github.com/Tellarion/webmaxsocket)
package/api.package.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Краткий перечень возможностей для работы с библиотекой. Подробности и примеры — в `README.md`.
4
4
 
5
+ **Зависимости TCP (IOS/ANDROID):** ответы по сокету сжаты **LZ4**; для распаковки используется **`lz4js`**. См. `README.md` → «Зависимости для Socket транспорта».
6
+
5
7
  ---
6
8
 
7
9
  ## Экспорт пакета (`require('webmaxsocket')`)
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 }
@@ -67,46 +68,73 @@ class WebMaxClient extends EventEmitter {
67
68
  this.apiUrl = options.apiUrl || 'wss://ws-api.oneme.ru/websocket';
68
69
 
69
70
  // Загрузка из config — token, ua (agent), device_type
70
- let token = options.token || null;
71
71
  let agent = options.ua || options.agent || options.headerUserAgent || null;
72
72
  let configObj = {};
73
73
  const configPath = options.configPath || options.config;
74
74
  if (configPath) {
75
75
  configObj = loadSessionConfig(configPath);
76
- token = token || configObj.token || null;
77
76
  agent = agent || configObj.agent || configObj.ua || configObj.headerUserAgent || null;
78
77
  }
79
-
80
- this._providedToken = token;
78
+
79
+ const optTok = options.token;
80
+ this._explicitOptionToken =
81
+ optTok !== undefined && optTok !== null && String(optTok).trim() !== ''
82
+ ? String(optTok).trim()
83
+ : null;
84
+ this._configFileToken =
85
+ configPath &&
86
+ configObj.token !== undefined &&
87
+ configObj.token !== null &&
88
+ String(configObj.token).trim() !== ''
89
+ ? String(configObj.token).trim()
90
+ : null;
91
+ /** Сырые токены из опций/config (без sessions); приоритет при старте см. _resolveTokenForStart */
92
+ this._providedToken = this._explicitOptionToken || this._configFileToken;
81
93
  this._saveTokenToSession = options.saveToken !== false;
82
- this.origin = 'https://web.max.ru';
94
+ this.origin = options.origin || 'https://web.max.ru';
95
+ /** Доп. заголовок для ws (например Referer при QR с max.ru, пока Origin остаётся web.max.ru — иначе 403). */
96
+ this._wsReferer = options.referer || options.wsReferer || null;
83
97
  this.session = new SessionManager(this.sessionName);
84
98
 
85
99
  const deviceTypeMap = { 1: 'WEB', 2: 'IOS', 3: 'ANDROID' };
86
100
  const rawDeviceType = options.deviceType ?? configObj.device_type ?? configObj.deviceType ?? this.session.get('deviceType');
87
101
  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';
102
+ const uaString =
103
+ agent ||
104
+ configObj.headerUserAgent ||
105
+ configObj.ua ||
106
+ this.session.get('headerUserAgent') ||
107
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36';
89
108
  const webDefaults = {
90
109
  deviceType: deviceType,
91
- locale: options.locale || configObj.locale || 'ru',
92
- deviceLocale: options.deviceLocale || configObj.deviceLocale || configObj.locale || 'ru',
110
+ locale: options.locale || configObj.locale || this.session.get('locale') || 'ru',
111
+ deviceLocale:
112
+ options.deviceLocale ||
113
+ configObj.deviceLocale ||
114
+ configObj.locale ||
115
+ this.session.get('deviceLocale') ||
116
+ this.session.get('locale') ||
117
+ 'ru',
93
118
  osVersion:
94
119
  options.osVersion ||
95
120
  configObj.osVersion ||
121
+ this.session.get('osVersion') ||
96
122
  (deviceType === 'IOS' ? '18.6.2' : deviceType === 'ANDROID' ? '14' : 'Windows 11'),
97
123
  deviceName:
98
124
  options.deviceName ||
99
125
  configObj.deviceName ||
126
+ this.session.get('deviceName') ||
100
127
  (deviceType === 'IOS' ? 'Safari' : deviceType === 'ANDROID' ? 'Chrome' : 'Chrome'),
101
128
  headerUserAgent: options.headerUserAgent || options.ua || uaString,
102
129
  // Ниже 25.12.13 сервер может отвечать qr_login.disabled на GET_QR (см. PyMax _login).
103
- appVersion: options.appVersion || configObj.appVersion || '25.12.14',
130
+ appVersion: options.appVersion || configObj.appVersion || this.session.get('appVersion') || '26.3.9',
104
131
  screen:
105
132
  options.screen ||
106
133
  configObj.screen ||
134
+ this.session.get('screen') ||
107
135
  (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,
136
+ timezone: options.timezone || configObj.timezone || this.session.get('timezone') || 'Europe/Moscow',
137
+ buildNumber: options.buildNumber ?? configObj.buildNumber ?? this.session.get('buildNumber'),
110
138
  clientSessionId: options.clientSessionId ?? configObj.clientSessionId ?? this.session.get('clientSessionId'),
111
139
  release: options.release ?? configObj.release
112
140
  };
@@ -127,8 +155,8 @@ class WebMaxClient extends EventEmitter {
127
155
  this.isConnected = false;
128
156
  this.isAuthorized = false;
129
157
  this.reconnectAttempts = 0;
130
- this.maxReconnectAttempts = options.maxReconnectAttempts || 5;
131
- this.reconnectDelay = options.reconnectDelay || 3000;
158
+ this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
159
+ this.reconnectDelay = options.reconnectDelay ?? 3000;
132
160
 
133
161
  // Protocol fields
134
162
  this.seq = 0;
@@ -149,6 +177,9 @@ class WebMaxClient extends EventEmitter {
149
177
  Boolean(options.debug) ||
150
178
  process.env.DEBUG === '1' ||
151
179
  process.env.WEBMAX_DEBUG === '1';
180
+ this._authDebug = Boolean(options.authDebug) || process.env.WEBMAX_AUTH_DEBUG === '1';
181
+ /** После успешного sync копировать sessions/<name>.json → <name>.last_ok.json */
182
+ this._sessionBackupOnSuccess = options.sessionBackup !== false;
152
183
  /** Режим JSON-лога входящих: off | messages | verbose (см. logIncoming в README) */
153
184
  this._incomingLogMode = resolveIncomingLogMode(options);
154
185
  this._wireIncomingLogListeners();
@@ -183,6 +214,55 @@ class WebMaxClient extends EventEmitter {
183
214
  });
184
215
  }
185
216
 
217
+ /**
218
+ * Приоритет токена: явный options.token → сохранённая sessions/<name>.json → token из config.
219
+ * Иначе после SMS в sessions лежит новый токен, а из config подставлялся старый и sync падал с «обновите config».
220
+ */
221
+ _resolveTokenForStart() {
222
+ if (this._explicitOptionToken) {
223
+ return { token: this._explicitOptionToken, source: 'options.token' };
224
+ }
225
+ const sessionTok = this.session.get('token');
226
+ if (sessionTok && String(sessionTok).trim() !== '') {
227
+ return { token: String(sessionTok).trim(), source: 'session_file' };
228
+ }
229
+ if (this._configFileToken) {
230
+ return { token: this._configFileToken, source: 'config_file' };
231
+ }
232
+ return { token: null, source: null };
233
+ }
234
+
235
+ _logAuthContext(phase, extra = {}) {
236
+ if (!this.debug && !this._authDebug) return;
237
+ let libVersion = 'unknown';
238
+ try {
239
+ libVersion = require(path.join(__dirname, '..', 'package.json')).version;
240
+ } catch (_) {
241
+ /* ignore */
242
+ }
243
+ const tok = this._token || this.session.get('token');
244
+ const tokenPreview =
245
+ tok && String(tok).length > 14
246
+ ? `${String(tok).slice(0, 8)}…${String(tok).slice(-4)}`
247
+ : tok
248
+ ? '(short)'
249
+ : '(none)';
250
+ const payload = {
251
+ phase,
252
+ libVersion,
253
+ node: process.version,
254
+ cwd: process.cwd(),
255
+ sessionName: this.sessionName,
256
+ sessionPath: this.session.sessionFile,
257
+ backupPath: path.join(this.session.sessionDir, `${this.sessionName}.last_ok.json`),
258
+ tokenPreview,
259
+ deviceId: this.deviceId,
260
+ userAgent: this.userAgent.toJSON(),
261
+ ...extra
262
+ };
263
+ console.log('[webmaxsocket:auth]', JSON.stringify(payload, null, 2));
264
+ }
265
+
186
266
  /**
187
267
  * Регистрация обработчика события start
188
268
  */
@@ -264,32 +344,47 @@ class WebMaxClient extends EventEmitter {
264
344
  // Подключаемся к WebSocket или Socket
265
345
  await this.connect();
266
346
 
267
- // Приоритет: 1) переданный токен, 2) сохранённая сессия, 3) QR-авторизация
268
- const tokenToUse = this._providedToken || this.session.get('token');
269
-
347
+ const { token: tokenToUse, source: tokenSource } = this._resolveTokenForStart();
348
+
270
349
  if (tokenToUse) {
271
- if (this._providedToken) {
350
+ if (tokenSource === 'session_file') {
351
+ console.log('✅ Найдена сохраненная сессия');
352
+ } else {
272
353
  console.log('✅ Вход по токену (token auth)');
273
354
  if (this._saveTokenToSession) {
274
- this.session.set('token', this._providedToken);
355
+ this.session.set('token', tokenToUse);
275
356
  this.session.set('deviceId', this.deviceId);
276
357
  }
277
- } else {
278
- console.log('✅ Найдена сохраненная сессия');
279
358
  }
280
359
  this._token = tokenToUse;
281
-
360
+
361
+ this._logAuthContext('start:before_sync', { tokenSource });
362
+
282
363
  try {
283
364
  await this.sync();
284
365
  this.isAuthorized = true;
285
366
  } catch (error) {
286
- const wasTokenAuth = !!this._providedToken;
367
+ const fromConfigOrOptions =
368
+ tokenSource === 'config_file' || tokenSource === 'options.token';
287
369
  this.session.clear();
370
+ this._explicitOptionToken = null;
371
+ this._configFileToken = null;
288
372
  this._providedToken = null;
289
- if (wasTokenAuth) {
290
- throw new Error(`Токен недействителен или сессия истекла. Обновите токен в config. (${error.message})`);
373
+ if (fromConfigOrOptions) {
374
+ throw new Error(
375
+ `Токен недействителен или сессия истекла. Обновите token в config или удалите/очистите token в config, чтобы использовалась актуальная сессия из sessions/. (${error.message})`
376
+ );
291
377
  }
292
378
  console.log('⚠️ Сессия истекла, требуется повторная авторизация');
379
+ if (this.debug || this._authDebug) {
380
+ console.log(
381
+ '[webmaxsocket:auth] Подсказка: для восстановления попробуйте скопировать sessions/' +
382
+ this.sessionName +
383
+ '.last_ok.json поверх sessions/' +
384
+ this.sessionName +
385
+ '.json (если копия есть).'
386
+ );
387
+ }
293
388
  await this.authorize();
294
389
  }
295
390
  } else {
@@ -316,7 +411,7 @@ class WebMaxClient extends EventEmitter {
316
411
  async requestQR() {
317
412
  console.log('Запрос QR-кода для авторизации...');
318
413
 
319
- const response = await this.sendAndWait(Opcode.GET_QR, {});
414
+ const response = await this.sendAndWait(Opcode.GET_QR, undefined);
320
415
 
321
416
  throwIfGetQRRejected(response.payload);
322
417
 
@@ -336,19 +431,456 @@ class WebMaxClient extends EventEmitter {
336
431
  return response.payload;
337
432
  }
338
433
 
434
+ /**
435
+ * На TCP после ответа LOGIN сервер может закрыть сокет — перед следующим RPC переподключаемся и снова LOGIN.
436
+ */
437
+ async _ensureTcpSocketReadyForRpc() {
438
+ const st = this._socketTransport;
439
+ if (st && st.socket && !st.socket.destroyed) return;
440
+ const token = this._token || this.session.get('token');
441
+ if (!token) {
442
+ throw new Error('Нет токена для переподключения TCP');
443
+ }
444
+ console.log('Переподключение TCP (сокет закрыт после предыдущего запроса)…');
445
+ this.isConnected = false;
446
+ await this.connect();
447
+ this._token = token;
448
+ await this.sync();
449
+ this.isAuthorized = true;
450
+ }
451
+
339
452
  /**
340
453
  * Завершение авторизации по QR-коду
454
+ * @param {string} trackId — UUID (веб GET_QR) или opaque token `qr_…` (max.ru/qr/v1/auth)
341
455
  */
342
456
  async loginByQR(trackId) {
343
- const response = await this.sendAndWait(Opcode.LOGIN_BY_QR, { trackId });
344
-
457
+ if (this._useSocketTransport) {
458
+ await this._ensureTcpSocketReadyForRpc();
459
+ }
460
+ const id = String(trackId).trim();
461
+ const payload = id.startsWith('qr_') ? { token: id } : { trackId: id };
462
+ const response = await this.sendAndWait(Opcode.LOGIN_BY_QR, payload);
463
+
345
464
  if (response.payload && response.payload.error) {
346
465
  throw new Error(`QR login error: ${JSON.stringify(response.payload.error)}`);
347
466
  }
348
-
467
+
349
468
  return response.payload;
350
469
  }
351
470
 
471
+ async approveQR(qrLink) {
472
+ if (this._useSocketTransport) {
473
+ await this._ensureTcpSocketReadyForRpc();
474
+ }
475
+ if (!this.isAuthorized) {
476
+ throw new Error('Требуется авторизованная сессия для одобрения QR');
477
+ }
478
+ const link = String(qrLink).trim();
479
+ const response = await this.sendAndWait(Opcode.AUTH_QR_APPROVE, { qrLink: link });
480
+ if (response.payload && response.payload.error) {
481
+ throw new Error(`QR approve error: ${JSON.stringify(response.payload.error)}`);
482
+ }
483
+ return response.payload;
484
+ }
485
+
486
+ /**
487
+ * LOGIN_BY_QR: как web.max.ru — сначала только trackId (HAR), затем запасные варианты + токен аккаунта.
488
+ */
489
+ async _loginByQrWebMax(web, trackId, accountToken) {
490
+ const id = String(trackId).trim();
491
+ if (id.startsWith('qr_')) {
492
+ const response = await web.sendAndWait(Opcode.LOGIN_BY_QR, { token: id });
493
+ if (response.payload && response.payload.error) {
494
+ throw new Error(`QR login error: ${JSON.stringify(response.payload.error)}`);
495
+ }
496
+ return response.payload;
497
+ }
498
+ const payloads = [{ trackId: id }, { track: id }, { authTrackId: id }];
499
+ if (accountToken) {
500
+ payloads.push(
501
+ { trackId: id, token: accountToken },
502
+ { track: id, token: accountToken },
503
+ { trackId: id, account_token: accountToken },
504
+ { track: id, account_token: accountToken }
505
+ );
506
+ }
507
+
508
+ let lastErr;
509
+ for (const payload of payloads) {
510
+ try {
511
+ const response = await web.sendAndWait(Opcode.LOGIN_BY_QR, payload);
512
+ if (response.payload && response.payload.error) {
513
+ const err = response.payload.error;
514
+ const msg =
515
+ typeof err === 'string'
516
+ ? err
517
+ : err && typeof err.message === 'string'
518
+ ? err.message
519
+ : response.payload.localizedMessage || JSON.stringify(err);
520
+ lastErr = new Error(msg);
521
+ if (/track\.not\.found/i.test(String(msg))) {
522
+ throw new Error(
523
+ `${msg}\n` +
524
+ 'Трек QR устарел или уже использован. Откройте web.max.ru, дождитесь нового QR и сразу сохраните qr.png.'
525
+ );
526
+ }
527
+ if (/proto\.payload/i.test(String(msg))) {
528
+ continue;
529
+ }
530
+ continue;
531
+ }
532
+ return response.payload;
533
+ } catch (e) {
534
+ lastErr = e;
535
+ if (e && e.message && /track\.not\.found|Трек QR устарел/i.test(e.message)) {
536
+ throw e;
537
+ }
538
+ }
539
+ }
540
+ throw lastErr || new Error('LOGIN_BY_QR не удался');
541
+ }
542
+
543
+ /**
544
+ * Как в web.max.ru (HAR): опрос GET_QR_STATUS, пока status.loginAvailable !== true (после скана в приложении Max).
545
+ * Без этого LOGIN_BY_QR часто даёт track.not.found — трек не «готов» к подтверждению.
546
+ */
547
+ async _waitForWebQrLoginAvailable(web, trackId, options = {}) {
548
+ const pollingInterval = options.pollingInterval ?? 5000;
549
+ const defaultTtlMs = options.defaultTtlMs ?? 180000;
550
+ let expiresAt = Date.now() + defaultTtlMs;
551
+
552
+ console.log(
553
+ '\n📱 Нужно подтверждение с телефона (как в официальном web-клиенте):\n' +
554
+ ' Max на телефоне → раздел с входом по QR к веб-версии / устройства — наведите на тот же QR (тот же аккаунт).\n' +
555
+ ' Чтобы вкладка браузера не отправила LOGIN_BY_QR раньше скрипта: после сохранения qr.png закройте вкладку web.max.ru,\n' +
556
+ ' затем запустите этот скрипт и отсканируйте QR с файла/второго монитора.\n' +
557
+ ' Ожидание loginAvailable на сервере…'
558
+ );
559
+
560
+ while (true) {
561
+ const now = Date.now();
562
+ if (now >= expiresAt) {
563
+ throw new Error(
564
+ 'Нет loginAvailable до истечения времени: отсканируйте QR приложением на тот же аккаунт или обновите qr.png.'
565
+ );
566
+ }
567
+
568
+ const statusResponse = await web.checkQRStatus(trackId);
569
+ if (statusResponse.status && statusResponse.status.expiresAt != null) {
570
+ const ex = Number(statusResponse.status.expiresAt);
571
+ if (Number.isFinite(ex)) {
572
+ expiresAt = Math.min(expiresAt, ex);
573
+ }
574
+ }
575
+ if (statusResponse.status && statusResponse.status.loginAvailable) {
576
+ console.log('\n✅ Сервер готов к LOGIN_BY_QR (loginAvailable).');
577
+ return;
578
+ }
579
+
580
+ process.stdout.write('.');
581
+
582
+ const wait = Math.min(
583
+ pollingInterval,
584
+ Math.max(0, expiresAt - Date.now())
585
+ );
586
+ await new Promise((r) => setTimeout(r, wait || pollingInterval));
587
+ }
588
+ }
589
+
590
+ /**
591
+ * Подтверждение входа по QR: новое TCP-соединение (только SESSION_INIT), затем LOGIN_BY_QR — без LOGIN на этом сокете.
592
+ */
593
+ async _approveWebLoginByQrViaEphemeralTcp(trackId) {
594
+ const accountToken = this._token || this.session.get('token');
595
+ const transport = new MaxSocketTransport({
596
+ deviceId: this.deviceId,
597
+ deviceType: this.userAgent.deviceType,
598
+ ua: this.userAgent.headerUserAgent,
599
+ debug: this.debug,
600
+ });
601
+ await transport.connect();
602
+ await transport.handshake(this.userAgent);
603
+ try {
604
+ const id = String(trackId).trim();
605
+ if (id.startsWith('qr_')) {
606
+ const result = await transport.sendAndWait(Opcode.LOGIN_BY_QR, { token: id });
607
+ return result.payload;
608
+ }
609
+ const payloads = [
610
+ { trackId: id },
611
+ { track: id },
612
+ { trackId: id, token: accountToken },
613
+ { track: id, token: accountToken },
614
+ ];
615
+ let lastErr;
616
+ for (const payload of payloads) {
617
+ try {
618
+ const result = await transport.sendAndWait(Opcode.LOGIN_BY_QR, payload);
619
+ return result.payload;
620
+ } catch (e) {
621
+ lastErr = e;
622
+ }
623
+ }
624
+ throw lastErr || new Error('LOGIN_BY_QR (TCP) не удался');
625
+ } finally {
626
+ await transport.close();
627
+ }
628
+ }
629
+
630
+ /**
631
+ * Подтверждение входа в web.max по QR при основной сессии на TCP: отдельный WebSocket + LOGIN + LOGIN_BY_QR.
632
+ * На том же TCP после LOGIN сервер не принимает LOGIN_BY_QR («Недопустимое состояние сессии»).
633
+ * WEB-клиент: не используем IOS deviceId (даёт login.cred); берём webDeviceId из сессии или новый UUID.
634
+ * Сначала пробуем webToken для sync, затем основной token. Без успешного sync LOGIN_BY_QR часто даёт auth_by_track.no.attempts.
635
+ */
636
+ async _approveWebLoginByQrViaEphemeralWeb(trackId, options = {}, qrSource = '') {
637
+ const {
638
+ retry = true,
639
+ saveWebSession = true,
640
+ waitForPhoneScan = true,
641
+ qrPollingInterval = 5000,
642
+ } = options;
643
+ const accountToken = this._token || this.session.get('token');
644
+ if (!accountToken) {
645
+ throw new Error('Нет токена сессии');
646
+ }
647
+
648
+ const refererForMaxQr =
649
+ /https?:\/\/max\.ru\//i.test(String(qrSource)) && !/web\.max\.ru/i.test(String(qrSource))
650
+ ? 'https://max.ru/'
651
+ : null;
652
+
653
+ const webUa =
654
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36';
655
+ const webDeviceId = this.session.get('webDeviceId') || uuidv4();
656
+
657
+ const ephemeralName = `_web_qr_${uuidv4().replace(/-/g, '').slice(0, 12)}`;
658
+ const web = new this.constructor({
659
+ name: ephemeralName,
660
+ deviceType: 'WEB',
661
+ deviceId: webDeviceId,
662
+ // Не делим clientSessionId с параллельным TCP (IOS) — иначе сервер может обрывать WS (1006).
663
+ clientSessionId: Math.floor(Math.random() * 0x7fffffff),
664
+ headerUserAgent: webUa,
665
+ appVersion: '26.3.9',
666
+ screen: '1920x1080 1.0x',
667
+ osVersion: 'Windows 11',
668
+ deviceName: 'Chrome',
669
+ saveToken: false,
670
+ debug: this.debug,
671
+ apiUrl: this.apiUrl,
672
+ origin: 'https://web.max.ru',
673
+ referer: refererForMaxQr,
674
+ maxReconnectAttempts: 0
675
+ });
676
+ web.handleReconnect = () => {};
677
+
678
+ const wrapTrackError = (e) => {
679
+ const m = e && e.message ? e.message : String(e);
680
+ if (/auth_by_track|no\.attempts|Недопустимое состояние|track\.not\.found/i.test(m)) {
681
+ return new Error(
682
+ `${m}\n` +
683
+ 'Подсказка: WebSocket принимает только Origin web.max.ru; для max.ru/:auth/ добавлен Referer. Сделайте новый скрин qr.png сразу после появления QR; старый трек даёт track.not.found.'
684
+ );
685
+ }
686
+ return e;
687
+ };
688
+
689
+ try {
690
+ console.log(
691
+ `Отдельное WebSocket (Origin=web.max.ru${refererForMaxQr ? ', Referer=max.ru' : ''}): ws-api принимает только Origin web.max.ru; при QR с max.ru добавлен Referer.`
692
+ );
693
+ await web.connect();
694
+
695
+ const webTok = this.session.get('webToken');
696
+ let syncOk = false;
697
+ // IOS-токен на WEB даёт login.cred и сервер часто рвёт сокет — не вызываем sync с ним.
698
+ // Только отдельный webToken из прошлого входа в web.max.
699
+ if (webTok && webTok !== accountToken) {
700
+ try {
701
+ web._token = webTok;
702
+ await web.sync();
703
+ syncOk = true;
704
+ web.isAuthorized = true;
705
+ if (saveWebSession && web.deviceId) {
706
+ this.session.set('webDeviceId', web.deviceId);
707
+ }
708
+ } catch (e) {
709
+ const msg = e && e.message ? e.message : String(e);
710
+ console.log(`WEB LOGIN (sync) с webToken: ${msg}`);
711
+ }
712
+ }
713
+
714
+ if (waitForPhoneScan) {
715
+ await this._waitForWebQrLoginAvailable(web, trackId, {
716
+ pollingInterval: qrPollingInterval,
717
+ });
718
+ }
719
+
720
+ const attempts = retry ? 2 : 1;
721
+ let lastErr;
722
+ for (let attempt = 1; attempt <= attempts; attempt++) {
723
+ try {
724
+ const wsOpen = web.ws && web.ws.readyState === WebSocket.OPEN;
725
+ if (!web.isConnected || !wsOpen) {
726
+ console.log('Переподключение WebSocket перед LOGIN_BY_QR (после ошибки LOGIN сокет часто закрывается)…');
727
+ if (web.ws) {
728
+ try {
729
+ web.ws.removeAllListeners();
730
+ } catch (_) {}
731
+ try {
732
+ web.ws.close();
733
+ } catch (_) {}
734
+ web.ws = null;
735
+ }
736
+ web.isConnected = false;
737
+ web.pendingRequests.clear();
738
+ await web.connect();
739
+ }
740
+
741
+ let loginData;
742
+ if (syncOk) {
743
+ loginData = await this._loginByQrWebMax(web, trackId, null);
744
+ } else {
745
+ loginData = await this._loginByQrWebMax(web, trackId, accountToken);
746
+ }
747
+
748
+ if (saveWebSession && loginData && typeof loginData === 'object') {
749
+ this.session.set('webQrTrackId', trackId);
750
+ const loginAttrs = loginData.tokenAttrs && loginData.tokenAttrs.LOGIN;
751
+ const wtoken = loginAttrs && loginAttrs.token;
752
+ if (wtoken) {
753
+ this.session.set('webToken', wtoken);
754
+ }
755
+ if (loginData.deviceId != null) {
756
+ this.session.set('webDeviceId', loginData.deviceId);
757
+ }
758
+ if (loginData.clientSessionId != null) {
759
+ this.session.set('webClientSessionId', loginData.clientSessionId);
760
+ }
761
+ }
762
+
763
+ console.log('✅ Вход в веб-версию по QR подтверждён (LOGIN_BY_QR через WebSocket)');
764
+ return loginData;
765
+ } catch (e) {
766
+ lastErr = wrapTrackError(e);
767
+ if (attempt < attempts) {
768
+ await new Promise((r) => setTimeout(r, 400));
769
+ continue;
770
+ }
771
+ throw lastErr;
772
+ }
773
+ }
774
+ throw lastErr;
775
+ } finally {
776
+ try {
777
+ await web.stop();
778
+ web.session.destroy();
779
+ } catch (_) {}
780
+ }
781
+ }
782
+
783
+ /**
784
+ * Подтвердить вход в веб-версию (web.max.ru) по URL из QR или trackId,
785
+ * используя уже авторизованную сессию (в т.ч. TCP после SMS/токена).
786
+ * Аналог сценария с `qr_url` + `account_token` на сторонних сервисах: здесь токен уже в сессии.
787
+ *
788
+ * @param {string} qrUrlOrTrackId — полный URL из QR, либо UUID trackId
789
+ * @param {{ retry?: boolean, saveWebSession?: boolean, waitForPhoneScan?: boolean, qrPollingInterval?: number }} [options]
790
+ * @returns {Promise<object>} payload ответа LOGIN_BY_QR
791
+ */
792
+ async approveWebLoginByQr(qrUrlOrTrackId, options = {}) {
793
+ const { retry = true, saveWebSession = true } = options;
794
+
795
+ const trackId = parseQrTrackId(qrUrlOrTrackId);
796
+ if (!trackId) {
797
+ throw new Error(
798
+ 'Не удалось извлечь идентификатор из QR: ожидается URL max.ru/qr/v1/auth?token=qr_…, web.max с trackId или UUID'
799
+ );
800
+ }
801
+
802
+ const token = this._token || this.session.get('token');
803
+ if (!token) {
804
+ throw new Error('Нет токена в сессии для подтверждения входа');
805
+ }
806
+
807
+ if (this._useSocketTransport) {
808
+ if (!this.isAuthorized) {
809
+ throw new Error(
810
+ 'Нужна авторизованная сессия: выполните connect() + sync() с действующим токеном'
811
+ );
812
+ }
813
+ if (process.env.MAX_QR_TRY_TCP_FIRST === '1') {
814
+ try {
815
+ const loginData = await this._approveWebLoginByQrViaEphemeralTcp(trackId);
816
+ if (saveWebSession && loginData && typeof loginData === 'object') {
817
+ this.session.set('webQrTrackId', trackId);
818
+ const loginAttrs = loginData.tokenAttrs && loginData.tokenAttrs.LOGIN;
819
+ const wtoken = loginAttrs && loginAttrs.token;
820
+ if (wtoken) {
821
+ this.session.set('webToken', wtoken);
822
+ }
823
+ if (loginData.deviceId != null) {
824
+ this.session.set('webDeviceId', loginData.deviceId);
825
+ }
826
+ if (loginData.clientSessionId != null) {
827
+ this.session.set('webClientSessionId', loginData.clientSessionId);
828
+ }
829
+ }
830
+ console.log('✅ Вход в веб-версию по QR подтверждён (LOGIN_BY_QR, новое TCP-соединение)');
831
+ return loginData;
832
+ } catch (e) {
833
+ const msg = e && e.message ? e.message : String(e);
834
+ console.log(`LOGIN_BY_QR по новому TCP: ${msg} — пробуем WebSocket (Origin web.max.ru)…`);
835
+ }
836
+ }
837
+ return await this._approveWebLoginByQrViaEphemeralWeb(trackId, options, qrUrlOrTrackId);
838
+ }
839
+
840
+ if (!this.isConnected) {
841
+ throw new Error('Нет соединения: сначала await client.connect()');
842
+ }
843
+ if (!this.isAuthorized) {
844
+ throw new Error(
845
+ 'Нужна авторизованная сессия: выполните start() или connect() + sync() с действующим токеном'
846
+ );
847
+ }
848
+
849
+ let lastErr;
850
+ const attempts = retry ? 2 : 1;
851
+ for (let attempt = 1; attempt <= attempts; attempt++) {
852
+ try {
853
+ const loginData = await this.loginByQR(trackId);
854
+
855
+ if (saveWebSession && loginData && typeof loginData === 'object') {
856
+ this.session.set('webQrTrackId', trackId);
857
+ const loginAttrs = loginData.tokenAttrs && loginData.tokenAttrs.LOGIN;
858
+ const wtoken = loginAttrs && loginAttrs.token;
859
+ if (wtoken) {
860
+ this.session.set('webToken', wtoken);
861
+ }
862
+ if (loginData.deviceId != null) {
863
+ this.session.set('webDeviceId', loginData.deviceId);
864
+ }
865
+ if (loginData.clientSessionId != null) {
866
+ this.session.set('webClientSessionId', loginData.clientSessionId);
867
+ }
868
+ }
869
+
870
+ console.log('✅ Вход в веб-версию по QR подтверждён (LOGIN_BY_QR)');
871
+ return loginData;
872
+ } catch (e) {
873
+ lastErr = e;
874
+ if (attempt < attempts) {
875
+ await new Promise((r) => setTimeout(r, 400));
876
+ continue;
877
+ }
878
+ throw e;
879
+ }
880
+ }
881
+ throw lastErr;
882
+ }
883
+
352
884
  /**
353
885
  * Опрос статуса QR-кода
354
886
  */
@@ -409,7 +941,7 @@ class WebMaxClient extends EventEmitter {
409
941
  }
410
942
 
411
943
  console.log('Запрос QR-кода для привязки устройства...');
412
- const response = await this.sendAndWait(Opcode.GET_QR, {});
944
+ const response = await this.sendAndWait(Opcode.GET_QR, undefined);
413
945
 
414
946
  throwIfGetQRRejected(response.payload);
415
947
 
@@ -471,7 +1003,7 @@ class WebMaxClient extends EventEmitter {
471
1003
  );
472
1004
  await webQr.connect();
473
1005
 
474
- const response = await webQr.sendAndWait(Opcode.GET_QR, {});
1006
+ const response = await webQr.sendAndWait(Opcode.GET_QR, undefined);
475
1007
  throwIfGetQRRejected(response.payload);
476
1008
 
477
1009
  const qrData = response.payload;
@@ -648,10 +1180,11 @@ class WebMaxClient extends EventEmitter {
648
1180
  this._token = token;
649
1181
 
650
1182
  console.log('✅ Авторизация по SMS успешна!');
651
-
1183
+ this._logAuthContext('sms:after_login', { tokenSource: 'sms' });
1184
+
652
1185
  // Выполняем sync
653
1186
  await this.sync();
654
-
1187
+
655
1188
  return token;
656
1189
  }
657
1190
  };
@@ -685,7 +1218,9 @@ class WebMaxClient extends EventEmitter {
685
1218
  */
686
1219
  async sync() {
687
1220
  console.log('🔄 Синхронизация с сервером...');
688
-
1221
+
1222
+ this._logAuthContext('sync');
1223
+
689
1224
  const token = this._token || this.session.get('token');
690
1225
 
691
1226
  if (!token) {
@@ -735,7 +1270,27 @@ class WebMaxClient extends EventEmitter {
735
1270
  } else {
736
1271
  console.log('⚠️ Данные пользователя не найдены в ответе sync');
737
1272
  }
738
-
1273
+
1274
+ try {
1275
+ let libVersion = 'unknown';
1276
+ try {
1277
+ libVersion = require(path.join(__dirname, '..', 'package.json')).version;
1278
+ } catch (_) {
1279
+ /* ignore */
1280
+ }
1281
+ this.session.set('_authMeta', {
1282
+ lastSyncOkAt: new Date().toISOString(),
1283
+ webmaxsocket: libVersion,
1284
+ node: process.version
1285
+ });
1286
+ } catch (_) {
1287
+ /* ignore */
1288
+ }
1289
+ if (this._sessionBackupOnSuccess) {
1290
+ this.session.copyToLastOk();
1291
+ }
1292
+ this._logAuthContext('sync:ok', { userId: this.me?.id });
1293
+
739
1294
  return responsePayload;
740
1295
  }
741
1296
 
@@ -773,13 +1328,18 @@ class WebMaxClient extends EventEmitter {
773
1328
  }
774
1329
 
775
1330
  this._token = token;
776
-
1331
+
1332
+ this._logAuthContext('connectWithSession:before_sync', { tokenSource: 'session_file' });
1333
+
777
1334
  try {
778
1335
  await this.sync();
779
1336
  this.isAuthorized = true;
780
1337
  console.log('Подключение с сохраненной сессией успешно');
781
1338
  } catch (error) {
782
1339
  console.log('Сессия истекла, требуется повторная авторизация');
1340
+ if (this.debug || this._authDebug) {
1341
+ console.log('[webmaxsocket:auth] sync error:', error.message);
1342
+ }
783
1343
  this.session.clear();
784
1344
  await this.authorize();
785
1345
  }
@@ -840,9 +1400,12 @@ class WebMaxClient extends EventEmitter {
840
1400
 
841
1401
  return new Promise((resolve, reject) => {
842
1402
  const headers = {
843
- 'Origin': this.origin,
1403
+ Origin: this.origin,
844
1404
  'User-Agent': this._handshakeUserAgent.headerUserAgent
845
1405
  };
1406
+ if (this._wsReferer) {
1407
+ headers.Referer = this._wsReferer;
1408
+ }
846
1409
 
847
1410
  this.ws = new WebSocket(this.apiUrl, {
848
1411
  headers: headers
@@ -872,8 +1435,9 @@ class WebMaxClient extends EventEmitter {
872
1435
  reject(error);
873
1436
  });
874
1437
 
875
- this.ws.on('close', () => {
876
- console.log('WebSocket соединение закрыто');
1438
+ this.ws.on('close', (code, reason) => {
1439
+ const rs = reason && reason.length ? reason.toString() : '';
1440
+ console.log(`WebSocket соединение закрыто (code=${code}${rs ? `, ${rs}` : ''})`);
877
1441
  this.isConnected = false;
878
1442
  const err = new Error('Соединение закрыто');
879
1443
  for (const [, pending] of this.pendingRequests) {
@@ -959,10 +1523,13 @@ class WebMaxClient extends EventEmitter {
959
1523
  * Обработка переподключения
960
1524
  */
961
1525
  handleReconnect() {
1526
+ if (this.maxReconnectAttempts <= 0) {
1527
+ return;
1528
+ }
962
1529
  if (this.reconnectAttempts < this.maxReconnectAttempts) {
963
1530
  this.reconnectAttempts++;
964
1531
  console.log(`Попытка переподключения ${this.reconnectAttempts}/${this.maxReconnectAttempts}...`);
965
-
1532
+
966
1533
  setTimeout(() => {
967
1534
  this.connect();
968
1535
  }, this.reconnectDelay);
@@ -983,8 +1550,12 @@ class WebMaxClient extends EventEmitter {
983
1550
  console.log(`📥 ${getOpcodeName(message.opcode)} (seq=${message.seq})${payload}`);
984
1551
  }
985
1552
 
986
- // Обработка ответов на запросы по seq
987
- if (message.seq && this.pendingRequests.has(message.seq)) {
1553
+ // Обработка ответов на запросы по seq (seq может быть 0 — не использовать truthiness)
1554
+ if (
1555
+ message.seq !== undefined &&
1556
+ message.seq !== null &&
1557
+ this.pendingRequests.has(message.seq)
1558
+ ) {
988
1559
  const pending = this.pendingRequests.get(message.seq);
989
1560
  this.pendingRequests.delete(message.seq);
990
1561
 
@@ -1090,17 +1661,21 @@ class WebMaxClient extends EventEmitter {
1090
1661
 
1091
1662
  /**
1092
1663
  * Создает сообщение в протоколе Max API
1664
+ * payload не включаем в JSON, если undefined — как web.max.ru для GET_QR (только opcode).
1093
1665
  */
1094
1666
  makeMessage(opcode, payload, cmd = 0) {
1095
1667
  this.seq += 1;
1096
-
1097
- return {
1668
+
1669
+ const message = {
1098
1670
  ver: this.ver,
1099
1671
  cmd: cmd,
1100
1672
  seq: this.seq,
1101
1673
  opcode: opcode,
1102
- payload: payload
1103
1674
  };
1675
+ if (payload !== undefined) {
1676
+ message.payload = payload;
1677
+ }
1678
+ return message;
1104
1679
  }
1105
1680
 
1106
1681
  /**
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 };
package/lib/session.js CHANGED
@@ -40,6 +40,22 @@ class SessionManager {
40
40
  return false;
41
41
  }
42
42
 
43
+ /**
44
+ * Копия последнего успешного состояния (после удачного sync) — для ручного восстановления:
45
+ * скопируйте `.last_ok.json` на место основного файла сессии.
46
+ */
47
+ copyToLastOk() {
48
+ try {
49
+ if (!fs.existsSync(this.sessionFile)) return false;
50
+ const okPath = path.join(this.sessionDir, `${this.sessionName}.last_ok.json`);
51
+ fs.copyFileSync(this.sessionFile, okPath);
52
+ return true;
53
+ } catch (error) {
54
+ console.error('Ошибка резервной копии сессии:', error.message);
55
+ return false;
56
+ }
57
+ }
58
+
43
59
  /**
44
60
  * Сохраняет данные сессии в файл
45
61
  */
@@ -6,8 +6,29 @@
6
6
 
7
7
  const tls = require('tls');
8
8
  const { encode: msgpackEncode, decode: msgpackDecode } = require('@msgpack/msgpack');
9
- const lz4Binding = require('lz4/lib/binding.js');
10
9
  const { v4: uuidv4 } = require('uuid');
10
+
11
+ /**
12
+ * Распаковка LZ4-блока (как в протоколе Max).
13
+ * Сначала нативный `lz4` (нужен node-gyp / VS C++ на Windows), иначе чистый JS `lz4js`.
14
+ */
15
+ function resolveLz4Uncompress() {
16
+ try {
17
+ const lz4Binding = require('lz4/lib/binding.js');
18
+ return (inp, out) => lz4Binding.uncompress(inp, out);
19
+ } catch (_) {
20
+ try {
21
+ const lz4js = require('lz4js');
22
+ return (inp, out) => lz4js.decompressBlock(inp, out, 0, inp.length, 0);
23
+ } catch (e) {
24
+ throw new Error(
25
+ 'LZ4: установите зависимость `lz4js` (npm i lz4js) или соберите нативный `lz4` (Visual Studio, C++ workload). ' +
26
+ e.message
27
+ );
28
+ }
29
+ }
30
+ }
31
+ const lz4Uncompress = resolveLz4Uncompress();
11
32
  const { Opcode, getOpcodeName } = require('./opcodes');
12
33
  const { UserAgentPayload } = require('./userAgent');
13
34
 
@@ -50,7 +71,7 @@ function unpackPacket(data) {
50
71
  try {
51
72
  if (compFlag !== 0) {
52
73
  const out = Buffer.alloc(Math.max(payloadLength * 20, 256 * 1024));
53
- const n = lz4Binding.uncompress(payloadBytes, out);
74
+ const n = lz4Uncompress(payloadBytes, out);
54
75
  if (n > 0) payload = msgpackDecode(out.subarray(0, n));
55
76
  } else {
56
77
  payload = msgpackDecode(payloadBytes);
@@ -65,6 +86,7 @@ function unpackPacket(data) {
65
86
 
66
87
  /** JSON для логов: bigint → string, обрезка длинных строк */
67
88
  function safeJsonForLog(obj, maxLen = 12000) {
89
+ if (obj === undefined) return '(no payload)';
68
90
  try {
69
91
  const s = JSON.stringify(
70
92
  obj,
@@ -152,7 +174,8 @@ class MaxSocketTransport {
152
174
  cmd,
153
175
  seq: this.seq,
154
176
  opcode,
155
- payload: payload || {}
177
+ // undefined — пустое тело пакета (как GET_QR без payload в web); null/{} — явный объект
178
+ payload: payload === undefined ? undefined : payload || {},
156
179
  };
157
180
  }
158
181
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webmaxsocket",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
4
4
  "description": "Node.js client for Max Messenger with QR code and token authentication",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -38,7 +38,7 @@
38
38
  "homepage": "https://github.com/Tellarion/webmaxsocket#readme",
39
39
  "dependencies": {
40
40
  "@msgpack/msgpack": "^3.1.3",
41
- "lz4": "^0.6.5",
41
+ "lz4js": "^0.2.0",
42
42
  "qrcode-terminal": "^0.12.0",
43
43
  "uuid": "^9.0.0",
44
44
  "ws": "^8.18.0"