webmaxsocket 1.1.0 → 1.1.2
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 +145 -18
- package/index.js +8 -1
- package/lib/client.js +305 -41
- package/lib/downloadMedia.js +124 -0
- package/lib/entities/Message.js +41 -8
- package/lib/incomingLog.js +59 -0
- package/lib/socketTransport.js +45 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,14 +7,17 @@
|
|
|
7
7
|
## ✨ Особенности / Features
|
|
8
8
|
|
|
9
9
|
- ✅ **QR-код авторизация** / QR code authentication
|
|
10
|
+
- ✅ **QR для привязки устройства** (`showLinkDeviceQR`) после входа по SMS/TCP — тот же сценарий, что «Профиль → Устройства → Подключить устройство» в приложении
|
|
10
11
|
- ✅ **Token авторизация** / Token authentication
|
|
11
12
|
- ✅ **Два транспорта:** WebSocket (WEB) и TCP Socket (IOS/ANDROID)
|
|
12
13
|
- ✅ **Автоматическое сохранение сессий** / Automatic session storage
|
|
13
14
|
- ✅ **Автовыбор транспорта** после QR-авторизации (переход на TCP)
|
|
14
15
|
- ✅ **Отправка и получение сообщений** / Send and receive messages
|
|
16
|
+
- ✅ **Скачивание вложений по URL** (`baseUrl` из `attaches`) во временный файл — `downloadUrlToTempFile`, `message.downloadAttachment()`
|
|
15
17
|
- ✅ **Редактирование и удаление сообщений** / Edit and delete messages
|
|
16
18
|
- ✅ **Event-driven архитектура** / Event-driven architecture
|
|
17
19
|
- ✅ **Обработка входящих уведомлений** / Handle incoming notifications
|
|
20
|
+
- ✅ **Встроенный лог входящих** (`logIncoming`, `WEBMAX_DEBUG`, `WEBMAX_SILENT`) — JSON в консоль без ручных обработчиков
|
|
18
21
|
- ✅ **TypeScript-ready** структура / TypeScript-ready structure
|
|
19
22
|
|
|
20
23
|
## 📦 Установка / Installation
|
|
@@ -61,8 +64,7 @@ async function main() {
|
|
|
61
64
|
|
|
62
65
|
// Автоответ / Auto-reply
|
|
63
66
|
await message.reply({
|
|
64
|
-
text: `Привет! Я получил: "${message.text}"
|
|
65
|
-
cid: Date.now()
|
|
67
|
+
text: `Привет! Я получил: "${message.text}"`
|
|
66
68
|
});
|
|
67
69
|
});
|
|
68
70
|
|
|
@@ -83,8 +85,7 @@ main().catch(console.error);
|
|
|
83
85
|
🔐 АВТОРИЗАЦИЯ ЧЕРЕЗ QR-КОД
|
|
84
86
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
85
87
|
|
|
86
|
-
📱
|
|
87
|
-
➡️ Настройки → Устройства → Подключить устройство
|
|
88
|
+
📱 На телефоне: Профиль → Устройства / Безопасность → Подключить устройство
|
|
88
89
|
📸 Отсканируйте QR-код
|
|
89
90
|
|
|
90
91
|
█████████████████████████████
|
|
@@ -126,6 +127,29 @@ node example-sms.js
|
|
|
126
127
|
node example-sms.js +79001234567 # с номером в аргументе
|
|
127
128
|
```
|
|
128
129
|
|
|
130
|
+
#### QR после входа: привязка второго устройства (IOS/ANDROID)
|
|
131
|
+
|
|
132
|
+
Когда вы уже авторизованы по **TCP** (SMS и сохранённая сессия), запрос **`GET_QR` на том же соединении недоступен** (ответ сервера: недопустимое состояние сессии). Для сценария как в приложении — **показать QR, телефон сканирует** — используйте метод **`showLinkDeviceQR()`**: библиотека открывает **отдельное краткоживущее WebSocket-подключение** (как у [web.max.ru](https://web.max.ru)), запрашивает QR, печатает его в консоль и при необходимости ждёт сканирования.
|
|
133
|
+
|
|
134
|
+
Требования: активное соединение и **`isAuthorized`** (обычно после `await client.start()`).
|
|
135
|
+
|
|
136
|
+
```javascript
|
|
137
|
+
await client.start();
|
|
138
|
+
|
|
139
|
+
// Показать QR и ждать, пока отсканируют в приложении Max на телефоне
|
|
140
|
+
await client.showLinkDeviceQR();
|
|
141
|
+
|
|
142
|
+
// Только показать QR и вернуть данные (без ожидания скана)
|
|
143
|
+
const data = await client.showLinkDeviceQR({ waitForScan: false });
|
|
144
|
+
// data: { qrLink, trackId, pollingInterval, expiresAt }
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Опции: `waitForScan` (по умолчанию `true`), `small` — компактный QR в терминале.
|
|
148
|
+
|
|
149
|
+
**Версия клиента:** для выдачи QR сервер ожидает актуальный **`appVersion`** в User-Agent (не ниже **25.12.13**). В конструкторе по умолчанию используется **25.12.14**; при необходимости передайте `appVersion: '25.21.3'` или новее.
|
|
150
|
+
|
|
151
|
+
Если сервер отвечает **`qr_login.disabled`**, проверьте версию приложения в опциях, откройте [web.max.ru](https://web.max.ru) в браузере или войдите на втором устройстве по номеру телефона.
|
|
152
|
+
|
|
129
153
|
#### Способ 3: Token авторизация
|
|
130
154
|
|
|
131
155
|
Если у вас уже есть токен (от другого сервиса/приложения):
|
|
@@ -172,15 +196,39 @@ const client = new WebMaxClient({
|
|
|
172
196
|
name: 'session', // Имя сессии (для сохранения авторизации)
|
|
173
197
|
token: 'An_Sx6H...', // Токен авторизации (опционально)
|
|
174
198
|
configPath: 'myconfig', // Путь к config файлу (опционально)
|
|
175
|
-
deviceType: 'WEB', // Тип устройства: 'WEB', 'IOS', 'ANDROID' (опционально)
|
|
199
|
+
deviceType: 'WEB', // Тип устройства: 'WEB', 'IOS', 'ANDROID', 'DESKTOP' (опционально)
|
|
176
200
|
saveToken: true, // Сохранять токен в сессию (по умолчанию true)
|
|
177
|
-
debug: false, //
|
|
201
|
+
debug: false, // TCP/WebSocket: краткий лог opcode; также учитывается WEBMAX_DEBUG=1, DEBUG=1
|
|
202
|
+
// Лог входящих (JSON в консоль), см. ниже:
|
|
203
|
+
logIncoming: undefined, // false | 'messages' | 'verbose' — по умолчанию см. таблицу
|
|
204
|
+
logIncomingVerbose: false, // явно включить verbose (как logIncoming: 'verbose')
|
|
178
205
|
apiUrl: 'wss://...', // URL WebSocket API (опционально)
|
|
179
206
|
maxReconnectAttempts: 5,// Максимальное количество попыток переподключения
|
|
180
|
-
reconnectDelay: 3000
|
|
207
|
+
reconnectDelay: 3000, // Задержка между попытками переподключения (мс)
|
|
208
|
+
// User-Agent / клиент (важно для GET_QR, см. showLinkDeviceQR):
|
|
209
|
+
appVersion: '25.12.14', // Рекомендуется ≥ 25.12.13 для запроса QR
|
|
210
|
+
ua: 'Mozilla/5.0 ...', // или headerUserAgent
|
|
211
|
+
osVersion: 'Windows 11',
|
|
212
|
+
screen: '1920x1080 1.0x',
|
|
213
|
+
timezone: 'Europe/Moscow',
|
|
214
|
+
locale: 'ru',
|
|
215
|
+
buildNumber: 0x97cb, // опционально
|
|
216
|
+
clientSessionId: 1 // опционально
|
|
181
217
|
});
|
|
182
218
|
```
|
|
183
219
|
|
|
220
|
+
**Лог входящих (`logIncoming`):** печать блоков `📥 [incoming:…]` с JSON.
|
|
221
|
+
|
|
222
|
+
| Значение / условие | Поведение |
|
|
223
|
+
|--------------------|-----------|
|
|
224
|
+
| `logIncoming: false` | Выкл. |
|
|
225
|
+
| `logIncoming: 'messages'` | Только входящие сообщения (`message.rawData`). |
|
|
226
|
+
| `logIncoming: 'verbose'` или `logIncomingVerbose: true` | Сообщения + `connected`, `raw_message`, `message_removed`, `chat_action`, `error`. |
|
|
227
|
+
| не указано | По умолчанию: как `'messages'`; если **`WEBMAX_DEBUG=1`** — как `'verbose'`. Явное **`logIncoming: 'messages'`** отключает расширенный режим даже при `WEBMAX_DEBUG`. |
|
|
228
|
+
| **`WEBMAX_SILENT=1`** | Выкл. всех дампов. |
|
|
229
|
+
|
|
230
|
+
Ручной вывод в том же формате: `client.logIncoming('my_label', data)`. Текущий режим: `client.incomingLogMode` (`'off' \| 'messages' \| 'verbose'`). Низкоуровнево: `resolveIncomingLogMode(options)`, `printIncomingLog(label, payload)` из пакета.
|
|
231
|
+
|
|
184
232
|
#### Методы
|
|
185
233
|
|
|
186
234
|
##### `start()`
|
|
@@ -206,6 +254,24 @@ const authSession = await client.authorizeBySMS('+79001234567');
|
|
|
206
254
|
await authSession.sendCode('123456');
|
|
207
255
|
```
|
|
208
256
|
|
|
257
|
+
##### `showLinkDeviceQR(options)`
|
|
258
|
+
|
|
259
|
+
Показать в консоли **QR-код для привязки устройства** (как в приложении Max: телефон сканирует QR). Нужна **уже выполненная авторизация** (`start()` или `connect` + `sync`).
|
|
260
|
+
|
|
261
|
+
- Для **WEB** запрос выполняется по текущему WebSocket.
|
|
262
|
+
- Для **IOS/ANDROID** после входа по TCP используется **второе** WebSocket-подключение без повторного `LOGIN` на той сессии (иначе `GET_QR` на том же TCP недоступен).
|
|
263
|
+
|
|
264
|
+
```javascript
|
|
265
|
+
await client.showLinkDeviceQR();
|
|
266
|
+
await client.showLinkDeviceQR({ waitForScan: false, small: false });
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Возвращает `Promise<{ qrLink, trackId, pollingInterval, expiresAt }>`.
|
|
270
|
+
|
|
271
|
+
##### `requestQR()`, `checkQRStatus(trackId)`, `loginByQR(trackId)`, `authorizeByQR()`
|
|
272
|
+
|
|
273
|
+
Низкоуровневые шаги QR-авторизации для **WEB** (первый вход без SMS). Обычно достаточно `start()` без токена или `authorizeByQR()`.
|
|
274
|
+
|
|
209
275
|
##### `sendMessage(options)`
|
|
210
276
|
|
|
211
277
|
Отправляет сообщение в чат с уведомлением (notify: true).
|
|
@@ -214,7 +280,7 @@ await authSession.sendCode('123456');
|
|
|
214
280
|
const message = await client.sendMessage({
|
|
215
281
|
chatId: 123,
|
|
216
282
|
text: 'Привет!',
|
|
217
|
-
cid
|
|
283
|
+
// cid опционально; на TCP не используйте Date.now() (нужен int32)
|
|
218
284
|
replyTo: null, // ID сообщения для ответа (опционально)
|
|
219
285
|
attachments: [] // Вложения (опционально)
|
|
220
286
|
});
|
|
@@ -228,7 +294,6 @@ const message = await client.sendMessage({
|
|
|
228
294
|
const message = await client.sendMessageChannel({
|
|
229
295
|
chatId: 123,
|
|
230
296
|
text: 'Сообщение в канал',
|
|
231
|
-
cid: Date.now(),
|
|
232
297
|
replyTo: null, // ID сообщения для ответа (опционально)
|
|
233
298
|
attachments: [] // Вложения (опционально)
|
|
234
299
|
});
|
|
@@ -391,13 +456,11 @@ client.onError(async (error) => {
|
|
|
391
456
|
|
|
392
457
|
##### `reply(options)`
|
|
393
458
|
|
|
394
|
-
|
|
459
|
+
Отправляет текст **в тот же чат**. По умолчанию **без** цитаты исходного сообщения (`link REPLY`), т.к. на TCP-сокете сервер часто возвращает «Ошибка валидации» для ответа-цитаты. Чтобы попробовать ответ с цитатой: `{ text: '...', quote: true }`.
|
|
395
460
|
|
|
396
461
|
```javascript
|
|
397
|
-
await message.reply({
|
|
398
|
-
|
|
399
|
-
cid: Date.now()
|
|
400
|
-
});
|
|
462
|
+
await message.reply({ text: 'Ответ на сообщение' });
|
|
463
|
+
await message.reply({ text: '...', quote: true });
|
|
401
464
|
```
|
|
402
465
|
|
|
403
466
|
##### `edit(options)`
|
|
@@ -426,6 +489,59 @@ await message.delete();
|
|
|
426
489
|
await message.forward(789);
|
|
427
490
|
```
|
|
428
491
|
|
|
492
|
+
##### `downloadAttachment(index, options?)`
|
|
493
|
+
|
|
494
|
+
Скачивает вложение по полю **`baseUrl`** (или `url`) из `message.attachments[index]` в **временный файл**. По умолчанию каталог — `os.tmpdir()` (на Windows обычно `%TEMP%`). Имя файла генерируется автоматически; расширение берётся из заголовка `Content-Type`, при необходимости — из типа вложения (`_type`, например `PHOTO`).
|
|
495
|
+
|
|
496
|
+
Возвращает `{ path, contentType }`.
|
|
497
|
+
|
|
498
|
+
```javascript
|
|
499
|
+
if (message.attachments.length) {
|
|
500
|
+
const { path, contentType } = await message.downloadAttachment(0);
|
|
501
|
+
console.log('Сохранено:', path, contentType);
|
|
502
|
+
// после обработки можно удалить: fs.unlinkSync(path)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Свой каталог или имя файла:
|
|
506
|
+
await message.downloadAttachment(0, {
|
|
507
|
+
dir: './downloads',
|
|
508
|
+
filename: 'photo.webp'
|
|
509
|
+
});
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
### Утилиты скачивания медиа / Media download helpers
|
|
513
|
+
|
|
514
|
+
Экспортируются из пакета наряду с `WebMaxClient`:
|
|
515
|
+
|
|
516
|
+
```javascript
|
|
517
|
+
const {
|
|
518
|
+
downloadUrlToTempFile,
|
|
519
|
+
extFromContentType,
|
|
520
|
+
extFromAttachType
|
|
521
|
+
} = require('webmaxsocket');
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
##### `downloadUrlToTempFile(url, options?)`
|
|
525
|
+
|
|
526
|
+
HTTP(S)-запрос с следованием редиректам, запись тела ответа в файл.
|
|
527
|
+
|
|
528
|
+
| Опция | Описание |
|
|
529
|
+
|--------|----------|
|
|
530
|
+
| `dir` | Каталог (по умолчанию `os.tmpdir()`) |
|
|
531
|
+
| `filename` | Имя файла (только basename); если не задано — `max-media-<time>-<random>.<ext>` |
|
|
532
|
+
| `extFallback` | Расширение, если по `Content-Type` определить не удалось (например `'.jpg'`) |
|
|
533
|
+
|
|
534
|
+
```javascript
|
|
535
|
+
const { path, contentType } = await downloadUrlToTempFile(
|
|
536
|
+
'https://i.oneme.ru/i?r=...',
|
|
537
|
+
{ extFallback: '.jpg' }
|
|
538
|
+
);
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
##### `extFromContentType(contentType)` / `extFromAttachType(attachType)`
|
|
542
|
+
|
|
543
|
+
Вспомогательные функции для подбора расширения по MIME или по `_type` вложения (`PHOTO`, `VIDEO`, …).
|
|
544
|
+
|
|
429
545
|
### User
|
|
430
546
|
|
|
431
547
|
Класс, представляющий пользователя.
|
|
@@ -532,6 +648,10 @@ node example-ios.js
|
|
|
532
648
|
node example-ios.js --debug
|
|
533
649
|
```
|
|
534
650
|
|
|
651
|
+
### Пример 5: QR для второго устройства после SMS
|
|
652
|
+
|
|
653
|
+
После успешного `start()` с сохранённой сессией IOS/Android вызовите `showLinkDeviceQR()` (см. раздел **«QR после входа»** выше).
|
|
654
|
+
|
|
535
655
|
## Структура проекта
|
|
536
656
|
|
|
537
657
|
```
|
|
@@ -543,6 +663,8 @@ webmaxsocket/
|
|
|
543
663
|
│ ├── userAgent.js # UserAgent генератор
|
|
544
664
|
│ ├── opcodes.js # Протокол опкоды
|
|
545
665
|
│ ├── constants.js # Константы
|
|
666
|
+
│ ├── downloadMedia.js # Скачивание медиа по URL во временный файл
|
|
667
|
+
│ ├── incomingLog.js # Режим logIncoming / печать входящих
|
|
546
668
|
│ └── entities/
|
|
547
669
|
│ ├── User.js # Класс пользователя
|
|
548
670
|
│ ├── Message.js # Класс сообщения
|
|
@@ -580,8 +702,7 @@ const client2 = new WebMaxClient({ name: 'account1' }); // phone не требу
|
|
|
580
702
|
try {
|
|
581
703
|
const message = await client.sendMessage({
|
|
582
704
|
chatId: 123,
|
|
583
|
-
text: 'Привет!'
|
|
584
|
-
cid: Date.now()
|
|
705
|
+
text: 'Привет!'
|
|
585
706
|
});
|
|
586
707
|
} catch (error) {
|
|
587
708
|
console.error('Ошибка:', error.message);
|
|
@@ -609,11 +730,17 @@ DEBUG=1 node example.js
|
|
|
609
730
|
|
|
610
731
|
1. **TCP Socket после QR-авторизации:** После первой успешной QR-авторизации клиент автоматически сохраняет `clientSessionId` и переключается на TCP Socket транспорт при следующем запуске для повышения стабильности.
|
|
611
732
|
|
|
612
|
-
2.
|
|
733
|
+
2. **QR для нового устройства после входа по SMS/TCP:** Используйте `showLinkDeviceQR()`. Это не отдельный опкод в протоколе, а тот же `GET_QR`, что и у веб-клиента; для уже залогиненного TCP-сокета запрос выполняется через **эфемерное WebSocket-подключение** (временный файл сессии `_link_qr_*` удаляется после завершения).
|
|
734
|
+
|
|
735
|
+
3. **Версия `appVersion` и QR:** Слишком старая версия в User-Agent может привести к ответу `qr_login.disabled` на `GET_QR`. Задайте в конструкторе актуальную строку (по умолчанию **25.12.14**).
|
|
736
|
+
|
|
737
|
+
4. **Разница между sendMessage и sendMessageChannel:**
|
|
613
738
|
- `sendMessage()` - отправка с уведомлением (notify: true) для обычных чатов
|
|
614
739
|
- `sendMessageChannel()` - отправка без уведомления (notify: false) для каналов
|
|
615
740
|
|
|
616
|
-
|
|
741
|
+
5. **Автоматический выбор транспорта:** Клиент автоматически определяет какой транспорт использовать на основе `deviceType` в сессии или config файле.
|
|
742
|
+
|
|
743
|
+
6. **`cid` при отправке сообщений (TCP/Socket):** сервер проверяет **signed int32**. Не передавайте `Date.now()` (миллисекунды ~1e12) — будет «Ошибка валидации». Либо не указывайте `cid` (клиент подставит свой), либо передайте целое в диапазоне **−2³¹ … 2³¹−1**.
|
|
617
744
|
|
|
618
745
|
## 🔗 Ссылки / Links
|
|
619
746
|
|
package/index.js
CHANGED
|
@@ -11,6 +11,8 @@ const { User, Message, ChatAction } = require('./lib/entities');
|
|
|
11
11
|
const { ChatActions, EventTypes, MessageTypes } = require('./lib/constants');
|
|
12
12
|
const { Opcode, getOpcodeName } = require('./lib/opcodes');
|
|
13
13
|
const { UserAgentPayload } = require('./lib/userAgent');
|
|
14
|
+
const { downloadUrlToTempFile, extFromContentType, extFromAttachType } = require('./lib/downloadMedia');
|
|
15
|
+
const { resolveIncomingLogMode, printIncomingLog } = require('./lib/incomingLog');
|
|
14
16
|
|
|
15
17
|
module.exports = {
|
|
16
18
|
WebMaxClient,
|
|
@@ -23,6 +25,11 @@ module.exports = {
|
|
|
23
25
|
MessageTypes,
|
|
24
26
|
Opcode,
|
|
25
27
|
getOpcodeName,
|
|
26
|
-
UserAgentPayload
|
|
28
|
+
UserAgentPayload,
|
|
29
|
+
downloadUrlToTempFile,
|
|
30
|
+
extFromContentType,
|
|
31
|
+
extFromAttachType,
|
|
32
|
+
resolveIncomingLogMode,
|
|
33
|
+
printIncomingLog
|
|
27
34
|
};
|
|
28
35
|
|
package/lib/client.js
CHANGED
|
@@ -10,6 +10,7 @@ const { Message, ChatAction, User } = require('./entities');
|
|
|
10
10
|
const { EventTypes, ChatActions } = require('./constants');
|
|
11
11
|
const { Opcode, DeviceType, getOpcodeName } = require('./opcodes');
|
|
12
12
|
const { UserAgentPayload } = require('./userAgent');
|
|
13
|
+
const { resolveIncomingLogMode, printIncomingLog } = require('./incomingLog');
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Загружает конфиг: { token, agent }
|
|
@@ -30,6 +31,30 @@ function loadSessionConfig(configPath) {
|
|
|
30
31
|
return JSON.parse(data);
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Понятная ошибка при отказе сервера отдать QR (часто qr_login.disabled для неофициального WEB-handshake).
|
|
36
|
+
*/
|
|
37
|
+
function throwIfGetQRRejected(payload) {
|
|
38
|
+
if (!payload || !payload.error) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const err = payload.error;
|
|
42
|
+
const text =
|
|
43
|
+
typeof err === 'string'
|
|
44
|
+
? err
|
|
45
|
+
: err && typeof err.message === 'string'
|
|
46
|
+
? err.message
|
|
47
|
+
: JSON.stringify(err);
|
|
48
|
+
if (String(text).includes('qr_login.disabled')) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
'Сервер Max отказал в выдаче QR (qr_login.disabled). Частые причины: устаревший appVersion в User-Agent (нужно ≥ 25.12.13), ' +
|
|
51
|
+
'или отключение QR для данного клиента на стороне VK. Проверьте https://web.max.ru в браузере. ' +
|
|
52
|
+
'Второй телефон к аккаунту можно добавить и обычным входом по номеру в приложении Max.'
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
throw new Error(`QR request error: ${text}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
33
58
|
/**
|
|
34
59
|
* Основной клиент для работы с API Max
|
|
35
60
|
*/
|
|
@@ -63,17 +88,27 @@ class WebMaxClient extends EventEmitter {
|
|
|
63
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';
|
|
64
89
|
const webDefaults = {
|
|
65
90
|
deviceType: deviceType,
|
|
66
|
-
locale: configObj.locale || 'ru',
|
|
67
|
-
deviceLocale: configObj.deviceLocale || configObj.locale || 'ru',
|
|
68
|
-
osVersion:
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
91
|
+
locale: options.locale || configObj.locale || 'ru',
|
|
92
|
+
deviceLocale: options.deviceLocale || configObj.deviceLocale || configObj.locale || 'ru',
|
|
93
|
+
osVersion:
|
|
94
|
+
options.osVersion ||
|
|
95
|
+
configObj.osVersion ||
|
|
96
|
+
(deviceType === 'IOS' ? '18.6.2' : deviceType === 'ANDROID' ? '14' : 'Windows 11'),
|
|
97
|
+
deviceName:
|
|
98
|
+
options.deviceName ||
|
|
99
|
+
configObj.deviceName ||
|
|
100
|
+
(deviceType === 'IOS' ? 'Safari' : deviceType === 'ANDROID' ? 'Chrome' : 'Chrome'),
|
|
101
|
+
headerUserAgent: options.headerUserAgent || options.ua || uaString,
|
|
102
|
+
// Ниже 25.12.13 сервер может отвечать qr_login.disabled на GET_QR (см. PyMax _login).
|
|
103
|
+
appVersion: options.appVersion || configObj.appVersion || '25.12.14',
|
|
104
|
+
screen:
|
|
105
|
+
options.screen ||
|
|
106
|
+
configObj.screen ||
|
|
107
|
+
(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,
|
|
110
|
+
clientSessionId: options.clientSessionId ?? configObj.clientSessionId ?? this.session.get('clientSessionId'),
|
|
111
|
+
release: options.release ?? configObj.release
|
|
77
112
|
};
|
|
78
113
|
this._handshakeUserAgent = new UserAgentPayload(webDefaults);
|
|
79
114
|
this.userAgent = this._handshakeUserAgent;
|
|
@@ -110,7 +145,39 @@ class WebMaxClient extends EventEmitter {
|
|
|
110
145
|
|
|
111
146
|
this.messageQueue = [];
|
|
112
147
|
this.pendingRequests = new Map();
|
|
113
|
-
this.debug =
|
|
148
|
+
this.debug =
|
|
149
|
+
Boolean(options.debug) ||
|
|
150
|
+
process.env.DEBUG === '1' ||
|
|
151
|
+
process.env.WEBMAX_DEBUG === '1';
|
|
152
|
+
/** Режим JSON-лога входящих: off | messages | verbose (см. logIncoming в README) */
|
|
153
|
+
this._incomingLogMode = resolveIncomingLogMode(options);
|
|
154
|
+
this._wireIncomingLogListeners();
|
|
155
|
+
/** client id локальных исходящих сообщений (int32, часто ждут валидацию на сервере) */
|
|
156
|
+
this._clientSendCid = 1 + Math.floor(Math.random() * 0xfffff);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Текущий режим лога входящих: `off` | `messages` | `verbose`.
|
|
161
|
+
*/
|
|
162
|
+
get incomingLogMode() {
|
|
163
|
+
return this._incomingLogMode;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Ручной вывод в формате `[incoming:label]` (как внутренний лог).
|
|
168
|
+
*/
|
|
169
|
+
logIncoming(label, payload) {
|
|
170
|
+
printIncomingLog(label, payload);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
_wireIncomingLogListeners() {
|
|
174
|
+
if (this._incomingLogMode !== 'verbose') return;
|
|
175
|
+
this.once('connected', () => {
|
|
176
|
+
printIncomingLog('connected', { event: 'connected' });
|
|
177
|
+
});
|
|
178
|
+
this.on('raw_message', (data) => {
|
|
179
|
+
printIncomingLog('raw_message', data);
|
|
180
|
+
});
|
|
114
181
|
}
|
|
115
182
|
|
|
116
183
|
/**
|
|
@@ -247,11 +314,9 @@ class WebMaxClient extends EventEmitter {
|
|
|
247
314
|
console.log('Запрос QR-кода для авторизации...');
|
|
248
315
|
|
|
249
316
|
const response = await this.sendAndWait(Opcode.GET_QR, {});
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
254
|
-
|
|
317
|
+
|
|
318
|
+
throwIfGetQRRejected(response.payload);
|
|
319
|
+
|
|
255
320
|
return response.payload;
|
|
256
321
|
}
|
|
257
322
|
|
|
@@ -315,6 +380,127 @@ class WebMaxClient extends EventEmitter {
|
|
|
315
380
|
}
|
|
316
381
|
}
|
|
317
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Вывести в консоль QR-код для привязки нового устройства (тот же поток, что и веб-вход).
|
|
385
|
+
* Требуется уже авторизованная сессия (после SMS/QR и sync).
|
|
386
|
+
* На телефоне: Профиль → Устройства / Безопасность → Подключить устройство (QR).
|
|
387
|
+
*
|
|
388
|
+
* @param {object} [options]
|
|
389
|
+
* @param {boolean} [options.waitForScan=true] — ждать, пока QR отсканируют
|
|
390
|
+
* @param {boolean} [options.small=true] — компактный QR в терминале
|
|
391
|
+
* @returns {Promise<{ qrLink: string, trackId: string, pollingInterval: number, expiresAt: number }>}
|
|
392
|
+
*/
|
|
393
|
+
async showLinkDeviceQR(options = {}) {
|
|
394
|
+
const { waitForScan = true, small = true } = options;
|
|
395
|
+
|
|
396
|
+
if (!this.isConnected) {
|
|
397
|
+
throw new Error('Нет соединения: сначала await client.connect()');
|
|
398
|
+
}
|
|
399
|
+
if (!this.isAuthorized) {
|
|
400
|
+
throw new Error('Нужна авторизация: войдите в аккаунт и выполните sync, затем вызывайте showLinkDeviceQR');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// После LOGIN по TCP сервер не принимает GET_QR («Недопустимое состояние сессии») — тот же QR, что в веб-клиенте, только до авторизации по WebSocket.
|
|
404
|
+
if (this._useSocketTransport) {
|
|
405
|
+
return await this._showLinkDeviceQRViaEphemeralWeb(options);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
console.log('Запрос QR-кода для привязки устройства...');
|
|
409
|
+
const response = await this.sendAndWait(Opcode.GET_QR, {});
|
|
410
|
+
|
|
411
|
+
throwIfGetQRRejected(response.payload);
|
|
412
|
+
|
|
413
|
+
const qrData = response.payload;
|
|
414
|
+
if (!qrData.qrLink || !qrData.trackId || !qrData.pollingInterval || !qrData.expiresAt) {
|
|
415
|
+
throw new Error('Неполные данные QR-кода от сервера');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
await this._printLinkDeviceQRConsole(qrData.qrLink, small);
|
|
419
|
+
console.log('\n💡 Или откройте ссылку: ' + qrData.qrLink);
|
|
420
|
+
console.log('='.repeat(70) + '\n');
|
|
421
|
+
|
|
422
|
+
if (waitForScan) {
|
|
423
|
+
await this.pollQRStatus(qrData.trackId, qrData.pollingInterval, qrData.expiresAt);
|
|
424
|
+
console.log('\n✅ Устройство подключено. Проверьте вход на телефоне.');
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
qrLink: qrData.qrLink,
|
|
429
|
+
trackId: qrData.trackId,
|
|
430
|
+
pollingInterval: qrData.pollingInterval,
|
|
431
|
+
expiresAt: qrData.expiresAt
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
_printLinkDeviceQRConsole(qrLink, small = true) {
|
|
436
|
+
console.log('\n' + '='.repeat(70));
|
|
437
|
+
console.log('📱 ПРИВЯЗКА НОВОГО УСТРОЙСТВА');
|
|
438
|
+
console.log('='.repeat(70));
|
|
439
|
+
console.log('\nНа телефоне откройте Max — как при добавлении устройства в приложении:');
|
|
440
|
+
console.log('➡️ Профиль → Устройства / Безопасность → Подключить устройство (вход по QR)');
|
|
441
|
+
console.log('📸 Наведите камеру на QR ниже — это тот же поток, что у веб-клиента:\n');
|
|
442
|
+
return new Promise((resolve) => {
|
|
443
|
+
qrcode.generate(qrLink, { small }, (qrCode) => {
|
|
444
|
+
console.log(qrCode);
|
|
445
|
+
resolve();
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* QR для привязки устройства при основой сессии на TCP (IOS/ANDROID): кратковременный WEB-клиент без LOGIN.
|
|
452
|
+
*/
|
|
453
|
+
async _showLinkDeviceQRViaEphemeralWeb(options = {}) {
|
|
454
|
+
const { waitForScan = true, small = true } = options;
|
|
455
|
+
const ephemeralName = `_link_qr_${uuidv4().replace(/-/g, '').slice(0, 12)}`;
|
|
456
|
+
const webQr = new this.constructor({
|
|
457
|
+
name: ephemeralName,
|
|
458
|
+
deviceType: 'WEB',
|
|
459
|
+
debug: this.debug,
|
|
460
|
+
apiUrl: this.apiUrl,
|
|
461
|
+
origin: this.origin,
|
|
462
|
+
maxReconnectAttempts: 0
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
try {
|
|
466
|
+
console.log(
|
|
467
|
+
'Отдельное WebSocket-подключение (как у web.max.ru): на уже залогиненном TCP запрос QR другим способом недоступен.'
|
|
468
|
+
);
|
|
469
|
+
await webQr.connect();
|
|
470
|
+
|
|
471
|
+
const response = await webQr.sendAndWait(Opcode.GET_QR, {});
|
|
472
|
+
throwIfGetQRRejected(response.payload);
|
|
473
|
+
|
|
474
|
+
const qrData = response.payload;
|
|
475
|
+
if (!qrData.qrLink || !qrData.trackId || !qrData.pollingInterval || !qrData.expiresAt) {
|
|
476
|
+
throw new Error('Неполные данные QR-кода от сервера');
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
await this._printLinkDeviceQRConsole(qrData.qrLink, small);
|
|
480
|
+
|
|
481
|
+
console.log('\n💡 Или откройте ссылку: ' + qrData.qrLink);
|
|
482
|
+
console.log('='.repeat(70) + '\n');
|
|
483
|
+
|
|
484
|
+
if (waitForScan) {
|
|
485
|
+
await webQr.pollQRStatus(qrData.trackId, qrData.pollingInterval, qrData.expiresAt);
|
|
486
|
+
await webQr.loginByQR(qrData.trackId);
|
|
487
|
+
console.log('\n✅ Устройство подключено. Проверьте телефон.');
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
qrLink: qrData.qrLink,
|
|
492
|
+
trackId: qrData.trackId,
|
|
493
|
+
pollingInterval: qrData.pollingInterval,
|
|
494
|
+
expiresAt: qrData.expiresAt
|
|
495
|
+
};
|
|
496
|
+
} finally {
|
|
497
|
+
try {
|
|
498
|
+
await webQr.stop();
|
|
499
|
+
webQr.session.destroy();
|
|
500
|
+
} catch (_) {}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
318
504
|
/**
|
|
319
505
|
* Авторизация через QR-код
|
|
320
506
|
*/
|
|
@@ -331,13 +517,15 @@ class WebMaxClient extends EventEmitter {
|
|
|
331
517
|
console.log('\n' + '='.repeat(70));
|
|
332
518
|
console.log('🔐 АВТОРИЗАЦИЯ ЧЕРЕЗ QR-КОД');
|
|
333
519
|
console.log('='.repeat(70));
|
|
334
|
-
console.log('\n📱
|
|
335
|
-
console.log('➡️ Настройки → Устройства → Подключить устройство');
|
|
520
|
+
console.log('\n📱 На телефоне: Профиль → Устройства / Безопасность → Подключить устройство');
|
|
336
521
|
console.log('📸 Отсканируйте QR-код ниже:\n');
|
|
337
522
|
|
|
338
|
-
// Отображаем QR-код в консоли
|
|
339
|
-
|
|
340
|
-
|
|
523
|
+
// Отображаем QR-код в консоли (ждём вывод, затем опрос статуса)
|
|
524
|
+
await new Promise((resolve) => {
|
|
525
|
+
qrcode.generate(qrData.qrLink, { small: true }, (qrCode) => {
|
|
526
|
+
console.log(qrCode);
|
|
527
|
+
resolve();
|
|
528
|
+
});
|
|
341
529
|
});
|
|
342
530
|
|
|
343
531
|
console.log('\n💡 Или откройте ссылку: ' + qrData.qrLink);
|
|
@@ -846,12 +1034,16 @@ class WebMaxClient extends EventEmitter {
|
|
|
846
1034
|
}
|
|
847
1035
|
|
|
848
1036
|
const message = new Message(messageData, this);
|
|
849
|
-
|
|
1037
|
+
|
|
1038
|
+
if (this._incomingLogMode === 'messages' || this._incomingLogMode === 'verbose') {
|
|
1039
|
+
printIncomingLog('message', message.rawData);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
850
1042
|
// Попытка загрузить информацию об отправителе если её нет
|
|
851
1043
|
if (!message.sender && message.senderId && message.senderId !== this.me?.id) {
|
|
852
1044
|
await message.fetchSender();
|
|
853
1045
|
}
|
|
854
|
-
|
|
1046
|
+
|
|
855
1047
|
await this.triggerHandlers(EventTypes.MESSAGE, message);
|
|
856
1048
|
}
|
|
857
1049
|
|
|
@@ -860,6 +1052,9 @@ class WebMaxClient extends EventEmitter {
|
|
|
860
1052
|
*/
|
|
861
1053
|
async handleRemovedMessage(data) {
|
|
862
1054
|
const message = new Message(data, this);
|
|
1055
|
+
if (this._incomingLogMode === 'verbose') {
|
|
1056
|
+
printIncomingLog('message_removed', message.rawData);
|
|
1057
|
+
}
|
|
863
1058
|
await this.triggerHandlers(EventTypes.MESSAGE_REMOVED, message);
|
|
864
1059
|
}
|
|
865
1060
|
|
|
@@ -868,6 +1063,9 @@ class WebMaxClient extends EventEmitter {
|
|
|
868
1063
|
*/
|
|
869
1064
|
async handleChatAction(data) {
|
|
870
1065
|
const action = new ChatAction(data, this);
|
|
1066
|
+
if (this._incomingLogMode === 'verbose') {
|
|
1067
|
+
printIncomingLog('chat_action', action.rawData);
|
|
1068
|
+
}
|
|
871
1069
|
await this.triggerHandlers(EventTypes.CHAT_ACTION, action);
|
|
872
1070
|
}
|
|
873
1071
|
|
|
@@ -919,6 +1117,73 @@ class WebMaxClient extends EventEmitter {
|
|
|
919
1117
|
});
|
|
920
1118
|
}
|
|
921
1119
|
|
|
1120
|
+
_nextClientMessageId() {
|
|
1121
|
+
const n = this._clientSendCid;
|
|
1122
|
+
this._clientSendCid = (this._clientSendCid % 0x7fffffff) + 1;
|
|
1123
|
+
return n;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/** int32: Date.now() и большие числа ломают валидацию MSG_SEND на TCP */
|
|
1127
|
+
_normalizeOutgoingCid(cid) {
|
|
1128
|
+
if (cid == null || cid === '') return this._nextClientMessageId();
|
|
1129
|
+
const n = Number(cid);
|
|
1130
|
+
if (!Number.isFinite(n)) return this._nextClientMessageId();
|
|
1131
|
+
const x = Math.trunc(n);
|
|
1132
|
+
if (x > 2147483647 || x < -2147483648) {
|
|
1133
|
+
return this._nextClientMessageId();
|
|
1134
|
+
}
|
|
1135
|
+
return x;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* messageId для REPLY: int, если безопасно, иначе строка (длинные id).
|
|
1140
|
+
*/
|
|
1141
|
+
_normalizeReplyMessageId(replyTo) {
|
|
1142
|
+
if (replyTo == null || replyTo === '') return null;
|
|
1143
|
+
if (typeof replyTo === 'number' && Number.isFinite(replyTo)) return replyTo;
|
|
1144
|
+
if (typeof replyTo === 'bigint') return Number(replyTo);
|
|
1145
|
+
if (typeof replyTo === 'string' && /^-?\d+$/.test(replyTo)) {
|
|
1146
|
+
const n = Number(replyTo);
|
|
1147
|
+
if (Number.isSafeInteger(n)) return n;
|
|
1148
|
+
return replyTo;
|
|
1149
|
+
}
|
|
1150
|
+
return String(replyTo);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* Собирает тело message для MSG_SEND: без link: null; cid в int32; elements для текста.
|
|
1155
|
+
*/
|
|
1156
|
+
_buildOutgoingMessageBody(text, cid, replyTo, attachments) {
|
|
1157
|
+
const t = text == null ? '' : String(text);
|
|
1158
|
+
const cidVal = this._normalizeOutgoingCid(cid);
|
|
1159
|
+
|
|
1160
|
+
const body = {
|
|
1161
|
+
text: t,
|
|
1162
|
+
cid: cidVal,
|
|
1163
|
+
elements: []
|
|
1164
|
+
};
|
|
1165
|
+
|
|
1166
|
+
const att = attachments || [];
|
|
1167
|
+
if (att.length) {
|
|
1168
|
+
body.attaches = att;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
if (replyTo != null && replyTo !== '') {
|
|
1172
|
+
body.link = {
|
|
1173
|
+
type: 'REPLY',
|
|
1174
|
+
messageId: this._normalizeReplyMessageId(replyTo)
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
return body;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
_normalizeChatId(chatId) {
|
|
1181
|
+
if (chatId == null) return chatId;
|
|
1182
|
+
if (typeof chatId === 'bigint') return Number(chatId);
|
|
1183
|
+
const n = Number(chatId);
|
|
1184
|
+
return Number.isNaN(n) ? chatId : n;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
922
1187
|
/**
|
|
923
1188
|
* Отправка сообщения (с уведомлением)
|
|
924
1189
|
*/
|
|
@@ -930,14 +1195,8 @@ class WebMaxClient extends EventEmitter {
|
|
|
930
1195
|
const { chatId, text, cid, replyTo, attachments } = options;
|
|
931
1196
|
|
|
932
1197
|
const payload = {
|
|
933
|
-
chatId: chatId,
|
|
934
|
-
message:
|
|
935
|
-
text: text || '',
|
|
936
|
-
cid: cid || Date.now(),
|
|
937
|
-
elements: [],
|
|
938
|
-
attaches: attachments || [],
|
|
939
|
-
link: replyTo ? { type: 'REPLY', messageId: replyTo } : null
|
|
940
|
-
},
|
|
1198
|
+
chatId: this._normalizeChatId(chatId),
|
|
1199
|
+
message: this._buildOutgoingMessageBody(text, cid, replyTo, attachments),
|
|
941
1200
|
notify: true
|
|
942
1201
|
};
|
|
943
1202
|
|
|
@@ -961,14 +1220,8 @@ class WebMaxClient extends EventEmitter {
|
|
|
961
1220
|
const { chatId, text, cid, replyTo, attachments } = options;
|
|
962
1221
|
|
|
963
1222
|
const payload = {
|
|
964
|
-
chatId: chatId,
|
|
965
|
-
message:
|
|
966
|
-
text: text || '',
|
|
967
|
-
cid: cid || Date.now(),
|
|
968
|
-
elements: [],
|
|
969
|
-
attaches: attachments || [],
|
|
970
|
-
link: replyTo ? { type: 'REPLY', messageId: replyTo } : null
|
|
971
|
-
},
|
|
1223
|
+
chatId: this._normalizeChatId(chatId),
|
|
1224
|
+
message: this._buildOutgoingMessageBody(text, cid, replyTo, attachments),
|
|
972
1225
|
notify: false
|
|
973
1226
|
};
|
|
974
1227
|
|
|
@@ -1097,8 +1350,19 @@ class WebMaxClient extends EventEmitter {
|
|
|
1097
1350
|
* Выполнение зарегистрированных обработчиков
|
|
1098
1351
|
*/
|
|
1099
1352
|
async triggerHandlers(eventType, data = null) {
|
|
1353
|
+
if (
|
|
1354
|
+
eventType === EventTypes.ERROR &&
|
|
1355
|
+
data !== null &&
|
|
1356
|
+
this._incomingLogMode === 'verbose'
|
|
1357
|
+
) {
|
|
1358
|
+
printIncomingLog('error', {
|
|
1359
|
+
message: data && data.message,
|
|
1360
|
+
stack: data && data.stack
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1100
1364
|
const handlers = this.handlers[eventType] || [];
|
|
1101
|
-
|
|
1365
|
+
|
|
1102
1366
|
for (const handler of handlers) {
|
|
1103
1367
|
try {
|
|
1104
1368
|
if (data !== null) {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Скачивание медиа по публичному URL (например baseUrl из attaches) во временный файл.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const https = require('https');
|
|
9
|
+
const http = require('http');
|
|
10
|
+
const { pipeline } = require('stream/promises');
|
|
11
|
+
|
|
12
|
+
const UA = 'Mozilla/5.0 (compatible; WebMaxSocket/1.1)';
|
|
13
|
+
|
|
14
|
+
const CONTENT_TYPE_EXT = {
|
|
15
|
+
'image/jpeg': '.jpg',
|
|
16
|
+
'image/jpg': '.jpg',
|
|
17
|
+
'image/png': '.png',
|
|
18
|
+
'image/webp': '.webp',
|
|
19
|
+
'image/gif': '.gif',
|
|
20
|
+
'video/mp4': '.mp4',
|
|
21
|
+
'video/webm': '.webm',
|
|
22
|
+
'application/octet-stream': '.bin'
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function extFromContentType(ct) {
|
|
26
|
+
if (!ct) return '';
|
|
27
|
+
const main = String(ct).split(';')[0].trim().toLowerCase();
|
|
28
|
+
return CONTENT_TYPE_EXT[main] || '';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function extFromAttachType(t) {
|
|
32
|
+
if (!t) return '';
|
|
33
|
+
const u = String(t).toUpperCase();
|
|
34
|
+
if (u === 'PHOTO' || u === 'IMAGE') return '.jpg';
|
|
35
|
+
if (u === 'VIDEO') return '.mp4';
|
|
36
|
+
if (u === 'VOICE' || u === 'AUDIO') return '.ogg';
|
|
37
|
+
if (u === 'FILE') return '.bin';
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} urlString
|
|
43
|
+
* @param {number} maxRedirects
|
|
44
|
+
* @returns {Promise<import('http').IncomingMessage>}
|
|
45
|
+
*/
|
|
46
|
+
async function getFinalResponse(urlString, maxRedirects = 10) {
|
|
47
|
+
let url = String(urlString);
|
|
48
|
+
for (let i = 0; i < maxRedirects; i++) {
|
|
49
|
+
const res = await new Promise((resolve, reject) => {
|
|
50
|
+
const lib = url.startsWith('https') ? https : http;
|
|
51
|
+
const req = lib.request(
|
|
52
|
+
url,
|
|
53
|
+
{ method: 'GET', headers: { 'User-Agent': UA } },
|
|
54
|
+
resolve
|
|
55
|
+
);
|
|
56
|
+
req.on('error', reject);
|
|
57
|
+
req.end();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
61
|
+
res.resume();
|
|
62
|
+
url = new URL(res.headers.location, url).href;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (res.statusCode !== 200) {
|
|
67
|
+
res.resume();
|
|
68
|
+
const err = new Error(`HTTP ${res.statusCode}`);
|
|
69
|
+
err.statusCode = res.statusCode;
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return res;
|
|
74
|
+
}
|
|
75
|
+
throw new Error('Too many redirects');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Скачивает URL во временный файл (по умолчанию каталог ОС: os.tmpdir()).
|
|
80
|
+
*
|
|
81
|
+
* @param {string} url
|
|
82
|
+
* @param {{ dir?: string, filename?: string, extFallback?: string }} [options]
|
|
83
|
+
* @returns {Promise<{ path: string, contentType: string }>}
|
|
84
|
+
*/
|
|
85
|
+
async function downloadUrlToTempFile(url, options = {}) {
|
|
86
|
+
if (!url || typeof url !== 'string') {
|
|
87
|
+
throw new Error('downloadUrlToTempFile: нужен URL строкой');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const dir = options.dir != null ? String(options.dir) : os.tmpdir();
|
|
91
|
+
const res = await getFinalResponse(url);
|
|
92
|
+
const ct = (res.headers['content-type'] || '').trim();
|
|
93
|
+
let ext = extFromContentType(ct);
|
|
94
|
+
if (!ext && options.extFallback) {
|
|
95
|
+
ext = options.extFallback.startsWith('.')
|
|
96
|
+
? options.extFallback
|
|
97
|
+
: `.${options.extFallback}`;
|
|
98
|
+
}
|
|
99
|
+
if (!ext) ext = '.bin';
|
|
100
|
+
|
|
101
|
+
const base =
|
|
102
|
+
options.filename ||
|
|
103
|
+
`max-media-${Date.now()}-${Math.random().toString(36).slice(2, 11)}${ext}`;
|
|
104
|
+
const safeName = path.basename(base);
|
|
105
|
+
const destPath = path.join(dir, safeName);
|
|
106
|
+
|
|
107
|
+
const ws = fs.createWriteStream(destPath);
|
|
108
|
+
try {
|
|
109
|
+
await pipeline(res, ws);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
try {
|
|
112
|
+
await fs.promises.unlink(destPath);
|
|
113
|
+
} catch (_) {}
|
|
114
|
+
throw e;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { path: destPath, contentType: ct };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
downloadUrlToTempFile,
|
|
122
|
+
extFromContentType,
|
|
123
|
+
extFromAttachType
|
|
124
|
+
};
|
package/lib/entities/Message.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const User = require('./User');
|
|
2
|
+
const { downloadUrlToTempFile, extFromAttachType } = require('../downloadMedia');
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Класс представляющий сообщение
|
|
@@ -6,7 +7,12 @@ const User = require('./User');
|
|
|
6
7
|
class Message {
|
|
7
8
|
constructor(data, client) {
|
|
8
9
|
this.client = client;
|
|
9
|
-
this.id =
|
|
10
|
+
this.id =
|
|
11
|
+
data.id ??
|
|
12
|
+
data.messageId ??
|
|
13
|
+
data.message_id ??
|
|
14
|
+
(data.message && (data.message.id ?? data.message.messageId)) ??
|
|
15
|
+
null;
|
|
10
16
|
this.cid = data.cid || null;
|
|
11
17
|
this.chatId = data.chatId || data.chat_id || null;
|
|
12
18
|
|
|
@@ -68,20 +74,26 @@ class Message {
|
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
/**
|
|
71
|
-
*
|
|
77
|
+
* Отправить текст в этот же чат (как ответ собеседнику).
|
|
78
|
+
* По умолчанию без цитаты (без link REPLY): на TCP-сокете сервер часто отклоняет MSG_SEND с link.
|
|
79
|
+
* Цитата/ветка: reply({ text, quote: true }) — если сервер вернёт ошибку, оставьте quote: false.
|
|
72
80
|
*/
|
|
73
81
|
async reply(options) {
|
|
74
82
|
if (typeof options === 'string') {
|
|
75
83
|
options = { text: options };
|
|
76
84
|
}
|
|
77
85
|
|
|
78
|
-
|
|
86
|
+
const { text, cid, attachments, quote = false } = options;
|
|
87
|
+
const payload = {
|
|
79
88
|
chatId: this.chatId,
|
|
80
|
-
text
|
|
81
|
-
cid
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
89
|
+
text,
|
|
90
|
+
cid,
|
|
91
|
+
attachments
|
|
92
|
+
};
|
|
93
|
+
if (quote && this.id != null && this.id !== '') {
|
|
94
|
+
payload.replyTo = this.id;
|
|
95
|
+
}
|
|
96
|
+
return await this.client.sendMessage(payload);
|
|
85
97
|
}
|
|
86
98
|
|
|
87
99
|
/**
|
|
@@ -110,6 +122,27 @@ class Message {
|
|
|
110
122
|
});
|
|
111
123
|
}
|
|
112
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Скачать вложение по его baseUrl (HTTPS) во временный файл.
|
|
127
|
+
* По умолчанию каталог: {@link https://nodejs.org/api/os.html#ostmpdir os.tmpdir()}.
|
|
128
|
+
*
|
|
129
|
+
* @param {number} [index=0] индекс в массиве attachments
|
|
130
|
+
* @param {{ dir?: string, filename?: string }} [options]
|
|
131
|
+
* @returns {Promise<{ path: string, contentType: string }>}
|
|
132
|
+
*/
|
|
133
|
+
async downloadAttachment(index = 0, options = {}) {
|
|
134
|
+
const att = this.attachments[index];
|
|
135
|
+
if (!att) {
|
|
136
|
+
throw new Error(`Нет вложения с индексом ${index}`);
|
|
137
|
+
}
|
|
138
|
+
const url = att.baseUrl || att.url;
|
|
139
|
+
if (!url || typeof url !== 'string') {
|
|
140
|
+
throw new Error('У вложения нет baseUrl/url для скачивания');
|
|
141
|
+
}
|
|
142
|
+
const extFallback = extFromAttachType(att._type || att.type);
|
|
143
|
+
return downloadUrlToTempFile(url, { ...options, extFallback });
|
|
144
|
+
}
|
|
145
|
+
|
|
113
146
|
/**
|
|
114
147
|
* Переслать сообщение
|
|
115
148
|
*/
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Режим логирования входящих событий для WebMaxClient.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {object} [options] опции конструктора WebMaxClient (`logIncoming`, `logIncomingVerbose`, …)
|
|
7
|
+
* @returns {{ mode: 'off' | 'messages' | 'verbose' }}
|
|
8
|
+
*
|
|
9
|
+
* - `logIncoming: false` — выкл.
|
|
10
|
+
* - `logIncoming: 'verbose'` или `logIncomingVerbose: true` — JSON сообщений + connected, raw_message, removed, chat_action, error.
|
|
11
|
+
* - `logIncoming: 'messages'` — только JSON входящих сообщений (даже при WEBMAX_DEBUG).
|
|
12
|
+
* - `WEBMAX_SILENT=1` — выкл. дампов.
|
|
13
|
+
* - `WEBMAX_DEBUG=1` — режим verbose, если не задано явно `logIncoming: 'messages'`.
|
|
14
|
+
*/
|
|
15
|
+
function resolveIncomingLogMode(options = {}) {
|
|
16
|
+
const silent = process.env.WEBMAX_SILENT === '1';
|
|
17
|
+
const envVerbose = process.env.WEBMAX_DEBUG === '1';
|
|
18
|
+
|
|
19
|
+
const li = options.logIncoming;
|
|
20
|
+
|
|
21
|
+
if (li === false) {
|
|
22
|
+
return { mode: 'off' };
|
|
23
|
+
}
|
|
24
|
+
if (li === 'verbose' || options.logIncomingVerbose === true) {
|
|
25
|
+
return { mode: 'verbose' };
|
|
26
|
+
}
|
|
27
|
+
if (silent) {
|
|
28
|
+
return { mode: 'off' };
|
|
29
|
+
}
|
|
30
|
+
if (li === 'messages') {
|
|
31
|
+
return { mode: 'messages' };
|
|
32
|
+
}
|
|
33
|
+
if (envVerbose) {
|
|
34
|
+
return { mode: 'verbose' };
|
|
35
|
+
}
|
|
36
|
+
return { mode: 'messages' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} label
|
|
41
|
+
* @param {unknown} payload
|
|
42
|
+
*/
|
|
43
|
+
function printIncomingLog(label, payload) {
|
|
44
|
+
try {
|
|
45
|
+
const s = JSON.stringify(
|
|
46
|
+
payload,
|
|
47
|
+
(k, v) => (typeof v === 'bigint' ? v.toString() : v),
|
|
48
|
+
2
|
|
49
|
+
);
|
|
50
|
+
console.log(`\n📥 [incoming:${label}]\n${s}\n`);
|
|
51
|
+
} catch (_) {
|
|
52
|
+
console.log(`\n📥 [incoming:${label}]`, payload, '\n');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = {
|
|
57
|
+
resolveIncomingLogMode,
|
|
58
|
+
printIncomingLog
|
|
59
|
+
};
|
package/lib/socketTransport.js
CHANGED
|
@@ -63,6 +63,20 @@ function unpackPacket(data) {
|
|
|
63
63
|
return { ver, cmd, seq, opcode, payload };
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/** JSON для логов: bigint → string, обрезка длинных строк */
|
|
67
|
+
function safeJsonForLog(obj, maxLen = 12000) {
|
|
68
|
+
try {
|
|
69
|
+
const s = JSON.stringify(
|
|
70
|
+
obj,
|
|
71
|
+
(k, v) => (typeof v === 'bigint' ? v.toString() : v),
|
|
72
|
+
2
|
|
73
|
+
);
|
|
74
|
+
return s.length > maxLen ? `${s.slice(0, maxLen)}\n… [truncated ${s.length - maxLen} chars]` : s;
|
|
75
|
+
} catch (_) {
|
|
76
|
+
return String(obj);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
66
80
|
function readExactlyFromBuffer(transport, n) {
|
|
67
81
|
return new Promise((resolve) => {
|
|
68
82
|
const tryResolve = () => {
|
|
@@ -104,6 +118,10 @@ class MaxSocketTransport {
|
|
|
104
118
|
if (this.debug) console.log('[Socket]', ...args);
|
|
105
119
|
}
|
|
106
120
|
|
|
121
|
+
_debugErr(...args) {
|
|
122
|
+
if (this.debug) console.error('[Socket]', ...args);
|
|
123
|
+
}
|
|
124
|
+
|
|
107
125
|
connect() {
|
|
108
126
|
return new Promise((resolve, reject) => {
|
|
109
127
|
const opts = {
|
|
@@ -180,9 +198,13 @@ class MaxSocketTransport {
|
|
|
180
198
|
async sendAndWait(opcode, payload, cmd = 0, timeout = 20000) {
|
|
181
199
|
if (!this.socket || this.socket.destroyed) throw new Error('Socket not connected');
|
|
182
200
|
|
|
183
|
-
const
|
|
184
|
-
const seqKey =
|
|
185
|
-
const packet = packPacket(
|
|
201
|
+
const outMsg = this._makeMessage(opcode, payload, cmd);
|
|
202
|
+
const seqKey = outMsg.seq % 256;
|
|
203
|
+
const packet = packPacket(outMsg.ver, outMsg.cmd, outMsg.seq, outMsg.opcode, outMsg.payload);
|
|
204
|
+
|
|
205
|
+
if (this.debug) {
|
|
206
|
+
this._log('→', getOpcodeName(opcode), `(seq=${outMsg.seq})`, safeJsonForLog(payload));
|
|
207
|
+
}
|
|
186
208
|
|
|
187
209
|
let pendingRef;
|
|
188
210
|
const promise = new Promise((resolve, reject) => {
|
|
@@ -212,8 +234,26 @@ class MaxSocketTransport {
|
|
|
212
234
|
|
|
213
235
|
const result = await promise;
|
|
214
236
|
if (result.payload && result.payload.error) {
|
|
215
|
-
const
|
|
216
|
-
|
|
237
|
+
const err = result.payload.error;
|
|
238
|
+
const localized = result.payload.localizedMessage;
|
|
239
|
+
const errText =
|
|
240
|
+
localized ||
|
|
241
|
+
(typeof err === 'string' ? err : err && err.message != null ? String(err.message) : '') ||
|
|
242
|
+
JSON.stringify(err);
|
|
243
|
+
|
|
244
|
+
if (this.debug) {
|
|
245
|
+
this._debugErr('RPC error response', {
|
|
246
|
+
opcode: getOpcodeName(opcode),
|
|
247
|
+
opcodeNum: opcode,
|
|
248
|
+
seq: outMsg.seq,
|
|
249
|
+
outgoingPayload: safeJsonForLog(payload),
|
|
250
|
+
fullResponsePayload: safeJsonForLog(result.payload)
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const e = new Error(errText);
|
|
255
|
+
if (this.debug) e.rawPayload = result.payload;
|
|
256
|
+
throw e;
|
|
217
257
|
}
|
|
218
258
|
return result;
|
|
219
259
|
}
|