webmaxsocket 1.1.3 → 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/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;
@@ -154,6 +171,9 @@ class WebMaxClient extends EventEmitter {
154
171
  this._wireIncomingLogListeners();
155
172
  /** client id локальных исходящих сообщений (int32, часто ждут валидацию на сервере) */
156
173
  this._clientSendCid = 1 + Math.floor(Math.random() * 0xfffff);
174
+ /** После HTTP POST видео/файла — ждём NOTIF_ATTACH */
175
+ this._uploadPendingVideo = new Map();
176
+ this._uploadPendingFile = new Map();
157
177
  }
158
178
 
159
179
  /**
@@ -313,7 +333,7 @@ class WebMaxClient extends EventEmitter {
313
333
  async requestQR() {
314
334
  console.log('Запрос QR-кода для авторизации...');
315
335
 
316
- const response = await this.sendAndWait(Opcode.GET_QR, {});
336
+ const response = await this.sendAndWait(Opcode.GET_QR, undefined);
317
337
 
318
338
  throwIfGetQRRejected(response.payload);
319
339
 
@@ -333,19 +353,456 @@ class WebMaxClient extends EventEmitter {
333
353
  return response.payload;
334
354
  }
335
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
+
336
374
  /**
337
375
  * Завершение авторизации по QR-коду
376
+ * @param {string} trackId — UUID (веб GET_QR) или opaque token `qr_…` (max.ru/qr/v1/auth)
338
377
  */
339
378
  async loginByQR(trackId) {
340
- const response = await this.sendAndWait(Opcode.LOGIN_BY_QR, { trackId });
341
-
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
+
342
386
  if (response.payload && response.payload.error) {
343
387
  throw new Error(`QR login error: ${JSON.stringify(response.payload.error)}`);
344
388
  }
345
-
389
+
346
390
  return response.payload;
347
391
  }
348
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
+
349
806
  /**
350
807
  * Опрос статуса QR-кода
351
808
  */
@@ -406,7 +863,7 @@ class WebMaxClient extends EventEmitter {
406
863
  }
407
864
 
408
865
  console.log('Запрос QR-кода для привязки устройства...');
409
- const response = await this.sendAndWait(Opcode.GET_QR, {});
866
+ const response = await this.sendAndWait(Opcode.GET_QR, undefined);
410
867
 
411
868
  throwIfGetQRRejected(response.payload);
412
869
 
@@ -468,7 +925,7 @@ class WebMaxClient extends EventEmitter {
468
925
  );
469
926
  await webQr.connect();
470
927
 
471
- const response = await webQr.sendAndWait(Opcode.GET_QR, {});
928
+ const response = await webQr.sendAndWait(Opcode.GET_QR, undefined);
472
929
  throwIfGetQRRejected(response.payload);
473
930
 
474
931
  const qrData = response.payload;
@@ -837,9 +1294,12 @@ class WebMaxClient extends EventEmitter {
837
1294
 
838
1295
  return new Promise((resolve, reject) => {
839
1296
  const headers = {
840
- 'Origin': this.origin,
1297
+ Origin: this.origin,
841
1298
  'User-Agent': this._handshakeUserAgent.headerUserAgent
842
1299
  };
1300
+ if (this._wsReferer) {
1301
+ headers.Referer = this._wsReferer;
1302
+ }
843
1303
 
844
1304
  this.ws = new WebSocket(this.apiUrl, {
845
1305
  headers: headers
@@ -869,8 +1329,9 @@ class WebMaxClient extends EventEmitter {
869
1329
  reject(error);
870
1330
  });
871
1331
 
872
- this.ws.on('close', () => {
873
- 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}` : ''})`);
874
1335
  this.isConnected = false;
875
1336
  const err = new Error('Соединение закрыто');
876
1337
  for (const [, pending] of this.pendingRequests) {
@@ -939,6 +1400,10 @@ class WebMaxClient extends EventEmitter {
939
1400
  }
940
1401
  break;
941
1402
 
1403
+ case Opcode.NOTIF_ATTACH:
1404
+ this._handleNotifAttach(data.payload);
1405
+ break;
1406
+
942
1407
  default:
943
1408
  this.emit('raw_message', data);
944
1409
  }
@@ -952,10 +1417,13 @@ class WebMaxClient extends EventEmitter {
952
1417
  * Обработка переподключения
953
1418
  */
954
1419
  handleReconnect() {
1420
+ if (this.maxReconnectAttempts <= 0) {
1421
+ return;
1422
+ }
955
1423
  if (this.reconnectAttempts < this.maxReconnectAttempts) {
956
1424
  this.reconnectAttempts++;
957
1425
  console.log(`Попытка переподключения ${this.reconnectAttempts}/${this.maxReconnectAttempts}...`);
958
-
1426
+
959
1427
  setTimeout(() => {
960
1428
  this.connect();
961
1429
  }, this.reconnectDelay);
@@ -976,8 +1444,12 @@ class WebMaxClient extends EventEmitter {
976
1444
  console.log(`📥 ${getOpcodeName(message.opcode)} (seq=${message.seq})${payload}`);
977
1445
  }
978
1446
 
979
- // Обработка ответов на запросы по seq
980
- 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
+ ) {
981
1453
  const pending = this.pendingRequests.get(message.seq);
982
1454
  this.pendingRequests.delete(message.seq);
983
1455
 
@@ -1006,7 +1478,11 @@ class WebMaxClient extends EventEmitter {
1006
1478
  case Opcode.PING:
1007
1479
  await this.sendPong();
1008
1480
  break;
1009
-
1481
+
1482
+ case Opcode.NOTIF_ATTACH:
1483
+ this._handleNotifAttach(message.payload);
1484
+ break;
1485
+
1010
1486
  default:
1011
1487
  this.emit('raw_message', message);
1012
1488
  }
@@ -1079,17 +1555,21 @@ class WebMaxClient extends EventEmitter {
1079
1555
 
1080
1556
  /**
1081
1557
  * Создает сообщение в протоколе Max API
1558
+ * payload не включаем в JSON, если undefined — как web.max.ru для GET_QR (только opcode).
1082
1559
  */
1083
1560
  makeMessage(opcode, payload, cmd = 0) {
1084
1561
  this.seq += 1;
1085
-
1086
- return {
1562
+
1563
+ const message = {
1087
1564
  ver: this.ver,
1088
1565
  cmd: cmd,
1089
1566
  seq: this.seq,
1090
1567
  opcode: opcode,
1091
- payload: payload
1092
1568
  };
1569
+ if (payload !== undefined) {
1570
+ message.payload = payload;
1571
+ }
1572
+ return message;
1093
1573
  }
1094
1574
 
1095
1575
  /**
@@ -1192,6 +1672,73 @@ class WebMaxClient extends EventEmitter {
1192
1672
  return Number.isNaN(n) ? chatId : n;
1193
1673
  }
1194
1674
 
1675
+ /**
1676
+ * NOTIF_ATTACH (136): готовность вложения после загрузки видео/файла.
1677
+ */
1678
+ _handleNotifAttach(payload) {
1679
+ if (!payload || typeof payload !== 'object') return;
1680
+ const vid = payload.videoId;
1681
+ if (vid != null) {
1682
+ const k = String(vid);
1683
+ const fn = this._uploadPendingVideo.get(k);
1684
+ if (fn) {
1685
+ this._uploadPendingVideo.delete(k);
1686
+ fn();
1687
+ }
1688
+ }
1689
+ const fid = payload.fileId;
1690
+ if (fid != null) {
1691
+ const k = String(fid);
1692
+ const fn = this._uploadPendingFile.get(k);
1693
+ if (fn) {
1694
+ this._uploadPendingFile.delete(k);
1695
+ fn();
1696
+ }
1697
+ }
1698
+ }
1699
+
1700
+ /**
1701
+ * @param {Map<string, function(): void>} map
1702
+ */
1703
+ _waitUploadNotif(map, id, label, timeoutMs = 120000) {
1704
+ return new Promise((resolve, reject) => {
1705
+ const k = String(id);
1706
+ const t = setTimeout(() => {
1707
+ map.delete(k);
1708
+ reject(new Error(`Таймаут ожидания NOTIF_ATTACH (${label})`));
1709
+ }, timeoutMs);
1710
+ map.set(k, () => {
1711
+ clearTimeout(t);
1712
+ resolve();
1713
+ });
1714
+ });
1715
+ }
1716
+
1717
+ async _postMultipartUpload(uploadUrl, buf, fname, mime) {
1718
+ const { Blob } = require('buffer');
1719
+ if (typeof fetch !== 'function') {
1720
+ throw new Error('upload: нужен Node.js 18+ с глобальным fetch');
1721
+ }
1722
+ const form = new FormData();
1723
+ form.append('file', new Blob([buf], { type: mime }), fname);
1724
+ const res = await fetch(uploadUrl, {
1725
+ method: 'POST',
1726
+ body: form,
1727
+ headers: {
1728
+ Accept: '*/*',
1729
+ 'Accept-Language': 'ru-RU,ru;q=0.9',
1730
+ Origin: 'https://web.max.ru',
1731
+ Referer: 'https://web.max.ru/',
1732
+ 'User-Agent': this.userAgent.headerUserAgent || 'Mozilla/5.0'
1733
+ }
1734
+ });
1735
+ if (!res.ok) {
1736
+ const t = await res.text();
1737
+ throw new Error(`HTTP загрузка: ${res.status} ${t.slice(0, 300)}`);
1738
+ }
1739
+ return res;
1740
+ }
1741
+
1195
1742
  /**
1196
1743
  * Отправка сообщения (с уведомлением)
1197
1744
  */
@@ -1242,18 +1789,218 @@ class WebMaxClient extends EventEmitter {
1242
1789
  return response.payload;
1243
1790
  }
1244
1791
 
1792
+ /**
1793
+ * Загрузка локального изображения на сервер Max; результат передать в `attachments` у sendMessage / reply.
1794
+ * Схема: PHOTO_UPLOAD → UPLOAD_ATTACH_PREP → HTTP POST на выданный URL. Нужен Node 18+ (fetch, FormData).
1795
+ *
1796
+ * @param {number|string|bigint} chatId
1797
+ * @param {string} filePath путь к файлу (.png, .jpg, …)
1798
+ * @returns {Promise<{ _type: 'PHOTO', photoToken: string }>}
1799
+ */
1800
+ async uploadPhoto(chatId, filePath) {
1801
+ const fsp = require('fs/promises');
1802
+ const path = require('path');
1803
+
1804
+ const buf = await fsp.readFile(filePath);
1805
+ const ext = path.extname(filePath).toLowerCase();
1806
+ const mime =
1807
+ ext === '.png'
1808
+ ? 'image/png'
1809
+ : ext === '.webp'
1810
+ ? 'image/webp'
1811
+ : ext === '.gif'
1812
+ ? 'image/gif'
1813
+ : 'image/jpeg';
1814
+ const fname = path.basename(filePath) || 'image.jpg';
1815
+
1816
+ const r1 = await this.sendAndWait(Opcode.PHOTO_UPLOAD, { count: 1 });
1817
+ const p1 = r1.payload;
1818
+ if (p1 && p1.error) {
1819
+ const e = new Error(
1820
+ typeof p1.error === 'string' ? p1.error : JSON.stringify(p1.error)
1821
+ );
1822
+ e.rawPayload = p1;
1823
+ throw e;
1824
+ }
1825
+ const uploadUrl = p1 && p1.url;
1826
+ if (!uploadUrl) {
1827
+ throw new Error('PHOTO_UPLOAD: нет url в ответе');
1828
+ }
1829
+
1830
+ await this.sendAndWait(Opcode.UPLOAD_ATTACH_PREP, {
1831
+ chatId: this._normalizeChatId(chatId),
1832
+ type: 'PHOTO'
1833
+ });
1834
+
1835
+ const res = await this._postMultipartUpload(uploadUrl, buf, fname, mime);
1836
+ const obj = await res.json();
1837
+ const photos = obj.photos;
1838
+ let first;
1839
+ if (Array.isArray(photos)) {
1840
+ [first] = photos;
1841
+ } else if (photos && typeof photos === 'object') {
1842
+ first = Object.values(photos)[0];
1843
+ }
1844
+ const token = first && first.token;
1845
+ if (!token) {
1846
+ throw new Error(`PHOTO upload: неожиданный JSON: ${JSON.stringify(obj).slice(0, 400)}`);
1847
+ }
1848
+
1849
+ return {
1850
+ _type: 'PHOTO',
1851
+ photoToken: token
1852
+ };
1853
+ }
1854
+
1855
+ /**
1856
+ * Загрузка видео; результат для `attachments: [{ _type: 'VIDEO', videoId, token }]`.
1857
+ * После HTTP POST ждёт NOTIF_ATTACH (opcode 136).
1858
+ */
1859
+ async uploadVideo(chatId, filePath) {
1860
+ const fsp = require('fs/promises');
1861
+ const path = require('path');
1862
+
1863
+ const buf = await fsp.readFile(filePath);
1864
+ const ext = path.extname(filePath).toLowerCase();
1865
+ const fname = path.basename(filePath) || 'video.mp4';
1866
+ const mime =
1867
+ ext === '.webm' ? 'video/webm' : ext === '.mov' ? 'video/quicktime' : 'video/mp4';
1868
+
1869
+ const r = await this.sendAndWait(Opcode.VIDEO_UPLOAD, { count: 1 });
1870
+ const p = r.payload;
1871
+ if (p && p.error) {
1872
+ const e = new Error(
1873
+ typeof p.error === 'string' ? p.error : JSON.stringify(p.error)
1874
+ );
1875
+ e.rawPayload = p;
1876
+ throw e;
1877
+ }
1878
+ const info = p && p.info && p.info[0];
1879
+ if (!info) {
1880
+ throw new Error('VIDEO_UPLOAD: нет info в ответе');
1881
+ }
1882
+ const { url: uploadUrl, videoId, token } = info;
1883
+ if (!uploadUrl || videoId == null || token == null) {
1884
+ throw new Error('VIDEO_UPLOAD: нет url, videoId или token');
1885
+ }
1886
+
1887
+ const waitReady = this._waitUploadNotif(this._uploadPendingVideo, videoId, 'VIDEO');
1888
+
1889
+ await this.sendAndWait(Opcode.UPLOAD_ATTACH_PREP, {
1890
+ chatId: this._normalizeChatId(chatId),
1891
+ type: 'VIDEO'
1892
+ });
1893
+
1894
+ await this._postMultipartUpload(uploadUrl, buf, fname, mime);
1895
+
1896
+ await waitReady;
1897
+
1898
+ return { _type: 'VIDEO', videoId, token };
1899
+ }
1900
+
1901
+ /**
1902
+ * Загрузка произвольного файла (документ, архив, **аудио** и т.д.) для `attachments: [{ _type: 'FILE', fileId }]`.
1903
+ * После HTTP POST ждёт NOTIF_ATTACH.
1904
+ *
1905
+ * @param {{ filename?: string, mimeType?: string }} [options]
1906
+ */
1907
+ async uploadFile(chatId, filePath, options = {}) {
1908
+ const fsp = require('fs/promises');
1909
+ const path = require('path');
1910
+
1911
+ const buf = await fsp.readFile(filePath);
1912
+ const ext = path.extname(filePath).toLowerCase();
1913
+ const fname = options.filename || path.basename(filePath) || 'file.bin';
1914
+ const mime =
1915
+ options.mimeType ||
1916
+ this._mimeGuessForFile(ext);
1917
+
1918
+ const r = await this.sendAndWait(Opcode.FILE_UPLOAD, { count: 1 });
1919
+ const p = r.payload;
1920
+ if (p && p.error) {
1921
+ const e = new Error(
1922
+ typeof p.error === 'string' ? p.error : JSON.stringify(p.error)
1923
+ );
1924
+ e.rawPayload = p;
1925
+ throw e;
1926
+ }
1927
+ const info = p && p.info && p.info[0];
1928
+ if (!info) {
1929
+ throw new Error('FILE_UPLOAD: нет info в ответе');
1930
+ }
1931
+ const { url: uploadUrl, fileId } = info;
1932
+ if (!uploadUrl || fileId == null) {
1933
+ throw new Error('FILE_UPLOAD: нет url или fileId');
1934
+ }
1935
+
1936
+ const waitReady = this._waitUploadNotif(this._uploadPendingFile, fileId, 'FILE');
1937
+
1938
+ await this.sendAndWait(Opcode.UPLOAD_ATTACH_PREP, {
1939
+ chatId: this._normalizeChatId(chatId),
1940
+ type: 'FILE'
1941
+ });
1942
+
1943
+ await this._postMultipartUpload(uploadUrl, buf, fname, mime);
1944
+
1945
+ await waitReady;
1946
+
1947
+ return { _type: 'FILE', fileId };
1948
+ }
1949
+
1950
+ /**
1951
+ * Загрузка аудио как файла (удобно для .mp3, .ogg, .m4a, .wav).
1952
+ * Внутри вызывает uploadFile() с подходящим MIME.
1953
+ */
1954
+ async uploadAudio(chatId, filePath) {
1955
+ const path = require('path');
1956
+ const ext = path.extname(filePath).toLowerCase();
1957
+ const mime =
1958
+ ext === '.mp3'
1959
+ ? 'audio/mpeg'
1960
+ : ext === '.ogg' || ext === '.oga'
1961
+ ? 'audio/ogg'
1962
+ : ext === '.m4a' || ext === '.aac'
1963
+ ? 'audio/mp4'
1964
+ : ext === '.wav'
1965
+ ? 'audio/wav'
1966
+ : ext === '.flac'
1967
+ ? 'audio/flac'
1968
+ : 'audio/mpeg';
1969
+ return this.uploadFile(chatId, filePath, { mimeType: mime });
1970
+ }
1971
+
1972
+ _mimeGuessForFile(ext) {
1973
+ const m = {
1974
+ '.png': 'image/png',
1975
+ '.jpg': 'image/jpeg',
1976
+ '.jpeg': 'image/jpeg',
1977
+ '.gif': 'image/gif',
1978
+ '.webp': 'image/webp',
1979
+ '.pdf': 'application/pdf',
1980
+ '.zip': 'application/zip',
1981
+ '.mp3': 'audio/mpeg',
1982
+ '.ogg': 'audio/ogg',
1983
+ '.m4a': 'audio/mp4',
1984
+ '.wav': 'audio/wav',
1985
+ '.flac': 'audio/flac',
1986
+ '.mp4': 'video/mp4',
1987
+ '.webm': 'video/webm'
1988
+ };
1989
+ return m[ext] || 'application/octet-stream';
1990
+ }
1991
+
1245
1992
  /**
1246
1993
  * Редактирование сообщения
1247
1994
  */
1248
1995
  async editMessage(options) {
1249
- const { messageId, chatId, text } = options;
1996
+ const { messageId, chatId, text, attachments } = options;
1250
1997
 
1251
1998
  const payload = {
1252
1999
  chatId: chatId,
1253
2000
  messageId: messageId,
1254
2001
  text: text || '',
1255
2002
  elements: [],
1256
- attaches: []
2003
+ attaches: Array.isArray(attachments) && attachments.length ? attachments : []
1257
2004
  };
1258
2005
 
1259
2006
  const response = await this.sendAndWait(Opcode.MSG_EDIT, payload);
@@ -1354,6 +2101,360 @@ class WebMaxClient extends EventEmitter {
1354
2101
  return messages.map(msg => new Message(msg, this));
1355
2102
  }
1356
2103
 
2104
+ /**
2105
+ * Закрепить сообщение в чате (CHAT_UPDATE).
2106
+ */
2107
+ async pinMessage({ chatId, messageId, notifyPin = false }) {
2108
+ return await this.sendAndWait(Opcode.CHAT_UPDATE, {
2109
+ chatId: this._normalizeChatId(chatId),
2110
+ messageId: String(messageId),
2111
+ notifyPin: !!notifyPin
2112
+ });
2113
+ }
2114
+
2115
+ /**
2116
+ * Поставить реакцию-эмодзи на сообщение.
2117
+ */
2118
+ async setMessageReaction({ chatId, messageId, emoji }) {
2119
+ return await this.sendAndWait(Opcode.MSG_REACTION, {
2120
+ chatId: this._normalizeChatId(chatId),
2121
+ messageId: String(messageId),
2122
+ reaction: {
2123
+ reactionType: 'EMOJI',
2124
+ id: String(emoji)
2125
+ }
2126
+ });
2127
+ }
2128
+
2129
+ /**
2130
+ * Снять свою реакцию с сообщения.
2131
+ */
2132
+ async cancelMessageReaction({ chatId, messageId }) {
2133
+ return await this.sendAndWait(Opcode.MSG_CANCEL_REACTION, {
2134
+ chatId: this._normalizeChatId(chatId),
2135
+ messageId: String(messageId)
2136
+ });
2137
+ }
2138
+
2139
+ /**
2140
+ * Список реакций на сообщение.
2141
+ */
2142
+ async getMessageReactions({ chatId, messageId, count = 100 }) {
2143
+ return await this.sendAndWait(Opcode.MSG_GET_REACTIONS, {
2144
+ chatId: this._normalizeChatId(chatId),
2145
+ messageId: String(messageId),
2146
+ count
2147
+ });
2148
+ }
2149
+
2150
+ /**
2151
+ * Информация о чатах по id (opcode 48).
2152
+ */
2153
+ async getChatInfo(chatIds) {
2154
+ const ids = Array.isArray(chatIds) ? chatIds : [chatIds];
2155
+ const response = await this.sendAndWait(Opcode.CHAT_INFO, { chatIds: ids });
2156
+ return response.payload;
2157
+ }
2158
+
2159
+ /**
2160
+ * Разрешить ссылку: канал, инвайт join/…, URL max.ru (LINK_INFO).
2161
+ */
2162
+ async resolveLink(link) {
2163
+ const response = await this.sendAndWait(Opcode.LINK_INFO, { link: String(link) });
2164
+ return response.payload;
2165
+ }
2166
+
2167
+ /**
2168
+ * Вступить по ссылке (канал, группа и т.д.).
2169
+ */
2170
+ async joinChatByLink(link) {
2171
+ const response = await this.sendAndWait(Opcode.CHAT_JOIN, { link: String(link) });
2172
+ return response.payload;
2173
+ }
2174
+
2175
+ /**
2176
+ * Подписка / отписка на канал.
2177
+ */
2178
+ async setChatSubscription(chatId, subscribe) {
2179
+ return await this.sendAndWait(Opcode.CHAT_SUBSCRIBE, {
2180
+ chatId: this._normalizeChatId(chatId),
2181
+ subscribe: !!subscribe
2182
+ });
2183
+ }
2184
+
2185
+ /**
2186
+ * Создать групповой чат (CONTROL в MSG_SEND).
2187
+ */
2188
+ async createGroup({ title, userIds }) {
2189
+ const cid = this._nextClientMessageId();
2190
+ return await this.sendAndWait(Opcode.MSG_SEND, {
2191
+ message: {
2192
+ text: '',
2193
+ cid,
2194
+ elements: [],
2195
+ attaches: [
2196
+ {
2197
+ _type: 'CONTROL',
2198
+ event: 'new',
2199
+ chatType: 'CHAT',
2200
+ title,
2201
+ userIds
2202
+ }
2203
+ ]
2204
+ },
2205
+ notify: true
2206
+ });
2207
+ }
2208
+
2209
+ /**
2210
+ * Создать канал (CONTROL в MSG_SEND).
2211
+ */
2212
+ async createChannel({ title }) {
2213
+ const cid = this._nextClientMessageId();
2214
+ return await this.sendAndWait(Opcode.MSG_SEND, {
2215
+ message: {
2216
+ text: '',
2217
+ cid,
2218
+ elements: [],
2219
+ attaches: [
2220
+ {
2221
+ _type: 'CONTROL',
2222
+ event: 'new',
2223
+ title,
2224
+ chatType: 'CHANNEL'
2225
+ }
2226
+ ]
2227
+ },
2228
+ notify: true
2229
+ });
2230
+ }
2231
+
2232
+ /**
2233
+ * Отключить уведомления в чате (CONFIG), как «не беспокоить» для чата.
2234
+ */
2235
+ async muteChat(chatId, mute = true) {
2236
+ const id = String(this._normalizeChatId(chatId));
2237
+ return await this.sendAndWait(Opcode.CONFIG, {
2238
+ settings: {
2239
+ chats: {
2240
+ [id]: {
2241
+ dontDisturbUntil: mute ? -1 : 0
2242
+ }
2243
+ }
2244
+ }
2245
+ });
2246
+ }
2247
+
2248
+ /**
2249
+ * Участники чата (не более 500 за запрос).
2250
+ */
2251
+ async getChatMembers({ chatId, marker = 0, count = 500, type = 'MEMBER' }) {
2252
+ if (count > 500) {
2253
+ throw new Error('getChatMembers: count не больше 500');
2254
+ }
2255
+ return await this.sendAndWait(Opcode.CHAT_MEMBERS, {
2256
+ type,
2257
+ marker,
2258
+ chatId: this._normalizeChatId(chatId),
2259
+ count
2260
+ });
2261
+ }
2262
+
2263
+ /**
2264
+ * Пригласить пользователей в чат.
2265
+ */
2266
+ async inviteToChat({ chatId, userIds, showHistory = true }) {
2267
+ return await this.sendAndWait(Opcode.CHAT_MEMBERS_UPDATE, {
2268
+ chatId: this._normalizeChatId(chatId),
2269
+ userIds,
2270
+ showHistory,
2271
+ operation: 'add'
2272
+ });
2273
+ }
2274
+
2275
+ /**
2276
+ * Исключить пользователей из чата.
2277
+ */
2278
+ async removeFromChat({ chatId, userIds, cleanMsgPeriod = 0 }) {
2279
+ return await this.sendAndWait(Opcode.CHAT_MEMBERS_UPDATE, {
2280
+ chatId: this._normalizeChatId(chatId),
2281
+ userIds,
2282
+ operation: 'remove',
2283
+ cleanMsgPeriod
2284
+ });
2285
+ }
2286
+
2287
+ /**
2288
+ * Назначить администраторов. `permissions` — битовая маска прав (по умолчанию 120).
2289
+ */
2290
+ async addChatAdmins({ chatId, userIds, permissions = 120 }) {
2291
+ return await this.sendAndWait(Opcode.CHAT_MEMBERS_UPDATE, {
2292
+ chatId: this._normalizeChatId(chatId),
2293
+ userIds,
2294
+ type: 'ADMIN',
2295
+ operation: 'add',
2296
+ permissions
2297
+ });
2298
+ }
2299
+
2300
+ /**
2301
+ * Снять права администратора.
2302
+ */
2303
+ async removeChatAdmins({ chatId, userIds }) {
2304
+ return await this.sendAndWait(Opcode.CHAT_MEMBERS_UPDATE, {
2305
+ chatId: this._normalizeChatId(chatId),
2306
+ userIds,
2307
+ type: 'ADMIN',
2308
+ operation: 'remove'
2309
+ });
2310
+ }
2311
+
2312
+ /**
2313
+ * Передать владение группой.
2314
+ */
2315
+ async transferChatOwnership({ chatId, newOwnerId }) {
2316
+ return await this.sendAndWait(Opcode.CHAT_UPDATE, {
2317
+ chatId: this._normalizeChatId(chatId),
2318
+ changeOwnerId: newOwnerId
2319
+ });
2320
+ }
2321
+
2322
+ /**
2323
+ * Настройки группы: например ONLY_OWNER_CAN_CHANGE_ICON_TITLE, ALL_CAN_PIN_MESSAGE, ONLY_ADMIN_CAN_ADD_MEMBER.
2324
+ */
2325
+ async setGroupOptions({ chatId, options }) {
2326
+ return await this.sendAndWait(Opcode.CHAT_UPDATE, {
2327
+ chatId: this._normalizeChatId(chatId),
2328
+ options
2329
+ });
2330
+ }
2331
+
2332
+ /**
2333
+ * Несколько контактов по id (сырой ответ CONTACT_INFO).
2334
+ */
2335
+ async getContacts(contactIds) {
2336
+ const ids = Array.isArray(contactIds) ? contactIds : [contactIds];
2337
+ const response = await this.sendAndWait(Opcode.CONTACT_INFO, { contactIds: ids });
2338
+ return response.payload;
2339
+ }
2340
+
2341
+ /**
2342
+ * Добавить пользователя в контакты.
2343
+ */
2344
+ async addContact(userId) {
2345
+ return await this.sendAndWait(Opcode.CONTACT_UPDATE, {
2346
+ contactId: userId,
2347
+ action: 'ADD'
2348
+ });
2349
+ }
2350
+
2351
+ /**
2352
+ * Заблокировать пользователя.
2353
+ */
2354
+ async blockUser(userId) {
2355
+ return await this.sendAndWait(Opcode.CONTACT_UPDATE, {
2356
+ contactId: userId,
2357
+ action: 'BLOCK'
2358
+ });
2359
+ }
2360
+
2361
+ /**
2362
+ * Изменить своё имя / описание (PROFILE).
2363
+ */
2364
+ async updateProfile({ firstName, lastName, description } = {}) {
2365
+ const payload = {};
2366
+ if (firstName !== undefined) payload.firstName = firstName;
2367
+ if (lastName !== undefined) payload.lastName = lastName;
2368
+ if (description !== undefined) payload.description = description;
2369
+ return await this.sendAndWait(Opcode.PROFILE, payload);
2370
+ }
2371
+
2372
+ /**
2373
+ * Скрыть статус «в сети» для других.
2374
+ */
2375
+ async setHiddenOnline(hidden) {
2376
+ return await this.sendAndWait(Opcode.CONFIG, {
2377
+ settings: {
2378
+ user: { HIDDEN: !!hidden }
2379
+ }
2380
+ });
2381
+ }
2382
+
2383
+ /**
2384
+ * Кто может найти вас по телефону: 'ALL' | 'CONTACTS' или true/false (как ALL/CONTACTS).
2385
+ */
2386
+ async setFindableByPhone(mode) {
2387
+ const v =
2388
+ mode === true || mode === 'ALL'
2389
+ ? 'ALL'
2390
+ : mode === false || mode === 'CONTACTS'
2391
+ ? 'CONTACTS'
2392
+ : String(mode);
2393
+ return await this.sendAndWait(Opcode.CONFIG, {
2394
+ settings: {
2395
+ user: { SEARCH_BY_PHONE: v }
2396
+ }
2397
+ });
2398
+ }
2399
+
2400
+ /**
2401
+ * Кто может звонить: 'ALL' | 'CONTACTS'.
2402
+ */
2403
+ async setCallsPrivacyMode(mode) {
2404
+ const v =
2405
+ mode === true || mode === 'ALL'
2406
+ ? 'ALL'
2407
+ : mode === false || mode === 'CONTACTS'
2408
+ ? 'CONTACTS'
2409
+ : String(mode);
2410
+ return await this.sendAndWait(Opcode.CONFIG, {
2411
+ settings: {
2412
+ user: { INCOMING_CALL: v }
2413
+ }
2414
+ });
2415
+ }
2416
+
2417
+ /**
2418
+ * Кто может приглашать вас в чаты: 'ALL' | 'CONTACTS'.
2419
+ */
2420
+ async setChatsInvitePrivacy(mode) {
2421
+ const v =
2422
+ mode === true || mode === 'ALL'
2423
+ ? 'ALL'
2424
+ : mode === false || mode === 'CONTACTS'
2425
+ ? 'CONTACTS'
2426
+ : String(mode);
2427
+ return await this.sendAndWait(Opcode.CONFIG, {
2428
+ settings: {
2429
+ user: { CHATS_INVITE: v }
2430
+ }
2431
+ });
2432
+ }
2433
+
2434
+ /**
2435
+ * Удобно: канал по @username (resolveLink на https://max.ru/username).
2436
+ */
2437
+ async resolveChannelByUsername(username) {
2438
+ const u = String(username).replace(/^@/, '');
2439
+ return this.resolveLink(`https://max.ru/${u}`);
2440
+ }
2441
+
2442
+ /**
2443
+ * Вступить в канал по @username.
2444
+ */
2445
+ async joinChannelByUsername(username) {
2446
+ const u = String(username).replace(/^@/, '');
2447
+ return this.joinChatByLink(`https://max.ru/${u}`);
2448
+ }
2449
+
2450
+ /**
2451
+ * Инвайт по хэшу из ссылки join/XXXX.
2452
+ */
2453
+ async resolveInviteHash(hash) {
2454
+ const h = String(hash).replace(/^join\//, '');
2455
+ return this.resolveLink(`join/${h}`);
2456
+ }
2457
+
1357
2458
  /**
1358
2459
  * Выполнение зарегистрированных обработчиков
1359
2460
  */