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 +3 -1
- package/lib/client.js +493 -24
- package/lib/opcodes.js +1 -9
- package/lib/qrWebLogin.js +59 -0
- package/lib/socketTransport.js +3 -1
- package/package.json +1 -1
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 =
|
|
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:
|
|
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 || '
|
|
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
|
|
131
|
-
this.reconnectDelay = options.reconnectDelay
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 };
|
package/lib/socketTransport.js
CHANGED
|
@@ -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
|
-
|
|
156
|
+
// undefined — пустое тело пакета (как GET_QR без payload в web); null/{} — явный объект
|
|
157
|
+
payload: payload === undefined ? undefined : payload || {},
|
|
156
158
|
};
|
|
157
159
|
}
|
|
158
160
|
|