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 +12 -4
- package/api.package.md +2 -0
- package/index.js +3 -1
- package/lib/client.js +619 -44
- package/lib/opcodes.js +1 -9
- package/lib/qrWebLogin.js +59 -0
- package/lib/session.js +16 -0
- package/lib/socketTransport.js +26 -3
- package/package.json +2 -2
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
|
-
|
|
36
|
+
Ответы сервера по TCP содержат полезную нагрузку в **LZ4**-блоках (поверх **msgpack**). Для распаковки используется **`lz4js`** — чистый JavaScript, **без node-gyp** и нативной сборки, в том числе на Windows без Visual Studio C++. Он входит в зависимости `webmaxsocket` и ставится вместе с пакетом. При необходимости можно доустановить вручную:
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
|
-
npm install
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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 || '
|
|
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
|
|
131
|
-
this.reconnectDelay = options.reconnectDelay
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
347
|
+
const { token: tokenToUse, source: tokenSource } = this._resolveTokenForStart();
|
|
348
|
+
|
|
270
349
|
if (tokenToUse) {
|
|
271
|
-
if (
|
|
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',
|
|
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
|
|
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 (
|
|
290
|
-
throw new Error(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
*/
|
package/lib/socketTransport.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
41
|
+
"lz4js": "^0.2.0",
|
|
42
42
|
"qrcode-terminal": "^0.12.0",
|
|
43
43
|
"uuid": "^9.0.0",
|
|
44
44
|
"ws": "^8.18.0"
|