webmaxsocket 1.1.1 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,9 +13,11 @@
13
13
  - ✅ **Автоматическое сохранение сессий** / Automatic session storage
14
14
  - ✅ **Автовыбор транспорта** после QR-авторизации (переход на TCP)
15
15
  - ✅ **Отправка и получение сообщений** / Send and receive messages
16
+ - ✅ **Скачивание вложений по URL** (`baseUrl` из `attaches`) во временный файл — `downloadUrlToTempFile`, `message.downloadAttachment()`
16
17
  - ✅ **Редактирование и удаление сообщений** / Edit and delete messages
17
18
  - ✅ **Event-driven архитектура** / Event-driven architecture
18
19
  - ✅ **Обработка входящих уведомлений** / Handle incoming notifications
20
+ - ✅ **Встроенный лог входящих** (`logIncoming`, `WEBMAX_DEBUG`, `WEBMAX_SILENT`) — JSON в консоль без ручных обработчиков
19
21
  - ✅ **TypeScript-ready** структура / TypeScript-ready structure
20
22
 
21
23
  ## 📦 Установка / Installation
@@ -62,8 +64,7 @@ async function main() {
62
64
 
63
65
  // Автоответ / Auto-reply
64
66
  await message.reply({
65
- text: `Привет! Я получил: "${message.text}"`,
66
- cid: Date.now()
67
+ text: `Привет! Я получил: "${message.text}"`
67
68
  });
68
69
  });
69
70
 
@@ -197,7 +198,10 @@ const client = new WebMaxClient({
197
198
  configPath: 'myconfig', // Путь к config файлу (опционально)
198
199
  deviceType: 'WEB', // Тип устройства: 'WEB', 'IOS', 'ANDROID', 'DESKTOP' (опционально)
199
200
  saveToken: true, // Сохранять токен в сессию (по умолчанию true)
200
- 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')
201
205
  apiUrl: 'wss://...', // URL WebSocket API (опционально)
202
206
  maxReconnectAttempts: 5,// Максимальное количество попыток переподключения
203
207
  reconnectDelay: 3000, // Задержка между попытками переподключения (мс)
@@ -213,6 +217,18 @@ const client = new WebMaxClient({
213
217
  });
214
218
  ```
215
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
+
216
232
  #### Методы
217
233
 
218
234
  ##### `start()`
@@ -264,7 +280,7 @@ await client.showLinkDeviceQR({ waitForScan: false, small: false });
264
280
  const message = await client.sendMessage({
265
281
  chatId: 123,
266
282
  text: 'Привет!',
267
- cid: Date.now(),
283
+ // cid опционально; на TCP не используйте Date.now() (нужен int32)
268
284
  replyTo: null, // ID сообщения для ответа (опционально)
269
285
  attachments: [] // Вложения (опционально)
270
286
  });
@@ -278,7 +294,6 @@ const message = await client.sendMessage({
278
294
  const message = await client.sendMessageChannel({
279
295
  chatId: 123,
280
296
  text: 'Сообщение в канал',
281
- cid: Date.now(),
282
297
  replyTo: null, // ID сообщения для ответа (опционально)
283
298
  attachments: [] // Вложения (опционально)
284
299
  });
@@ -441,13 +456,11 @@ client.onError(async (error) => {
441
456
 
442
457
  ##### `reply(options)`
443
458
 
444
- Отвечает на сообщение.
459
+ Отправляет текст **в тот же чат**. По умолчанию **без** цитаты исходного сообщения (`link REPLY`), т.к. на TCP-сокете сервер часто возвращает «Ошибка валидации» для ответа-цитаты. Чтобы попробовать ответ с цитатой: `{ text: '...', quote: true }`.
445
460
 
446
461
  ```javascript
447
- await message.reply({
448
- text: 'Ответ на сообщение',
449
- cid: Date.now()
450
- });
462
+ await message.reply({ text: 'Ответ на сообщение' });
463
+ await message.reply({ text: '...', quote: true });
451
464
  ```
452
465
 
453
466
  ##### `edit(options)`
@@ -476,6 +489,59 @@ await message.delete();
476
489
  await message.forward(789);
477
490
  ```
478
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
+
479
545
  ### User
480
546
 
481
547
  Класс, представляющий пользователя.
@@ -597,6 +663,8 @@ webmaxsocket/
597
663
  │ ├── userAgent.js # UserAgent генератор
598
664
  │ ├── opcodes.js # Протокол опкоды
599
665
  │ ├── constants.js # Константы
666
+ │ ├── downloadMedia.js # Скачивание медиа по URL во временный файл
667
+ │ ├── incomingLog.js # Режим logIncoming / печать входящих
600
668
  │ └── entities/
601
669
  │ ├── User.js # Класс пользователя
602
670
  │ ├── Message.js # Класс сообщения
@@ -634,8 +702,7 @@ const client2 = new WebMaxClient({ name: 'account1' }); // phone не требу
634
702
  try {
635
703
  const message = await client.sendMessage({
636
704
  chatId: 123,
637
- text: 'Привет!',
638
- cid: Date.now()
705
+ text: 'Привет!'
639
706
  });
640
707
  } catch (error) {
641
708
  console.error('Ошибка:', error.message);
@@ -673,6 +740,10 @@ DEBUG=1 node example.js
673
740
 
674
741
  5. **Автоматический выбор транспорта:** Клиент автоматически определяет какой транспорт использовать на основе `deviceType` в сессии или config файле.
675
742
 
743
+ 6. **`cid` при отправке сообщений (TCP/Socket):** сервер проверяет **signed int32**. Не передавайте `Date.now()` (миллисекунды ~1e12) — будет «Ошибка валидации». Либо не указывайте `cid` (клиент подставит свой), либо передайте целое в диапазоне **−2³¹ … 2³¹−1**.
744
+
745
+ 7. **TCP и keep-alive (PING):** сервер периодически шлёт `PING`. На WebSocket клиент отвечает `sendPong`; на **TCP** раньше ответ не отправлялся — соединение могло обрываться через несколько минут, после чего процесс Node **завершался** (нечем держать event loop). Сейчас на TCP автоматически шлётся тот же ответ, что и у WebSocket (`PING` с пустым payload).
746
+
676
747
  ## 🔗 Ссылки / Links
677
748
 
678
749
  - [GitHub Repository](https://github.com/Tellarion/webmaxsocket)
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 }
@@ -144,7 +145,39 @@ class WebMaxClient extends EventEmitter {
144
145
 
145
146
  this.messageQueue = [];
146
147
  this.pendingRequests = new Map();
147
- this.debug = options.debug || process.env.DEBUG === '1';
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
+ });
148
181
  }
149
182
 
150
183
  /**
@@ -896,8 +929,16 @@ class WebMaxClient extends EventEmitter {
896
929
  break;
897
930
 
898
931
  case Opcode.PING:
932
+ // Иначе сервер рвёт TCP через ~минуты; WebSocket здесь шлёт sendPong (тот же opcode PING + {})
933
+ if (
934
+ this._socketTransport &&
935
+ this._socketTransport.socket &&
936
+ !this._socketTransport.socket.destroyed
937
+ ) {
938
+ this._socketTransport.sendOneWay(Opcode.PING, {});
939
+ }
899
940
  break;
900
-
941
+
901
942
  default:
902
943
  this.emit('raw_message', data);
903
944
  }
@@ -1001,12 +1042,16 @@ class WebMaxClient extends EventEmitter {
1001
1042
  }
1002
1043
 
1003
1044
  const message = new Message(messageData, this);
1004
-
1045
+
1046
+ if (this._incomingLogMode === 'messages' || this._incomingLogMode === 'verbose') {
1047
+ printIncomingLog('message', message.rawData);
1048
+ }
1049
+
1005
1050
  // Попытка загрузить информацию об отправителе если её нет
1006
1051
  if (!message.sender && message.senderId && message.senderId !== this.me?.id) {
1007
1052
  await message.fetchSender();
1008
1053
  }
1009
-
1054
+
1010
1055
  await this.triggerHandlers(EventTypes.MESSAGE, message);
1011
1056
  }
1012
1057
 
@@ -1015,6 +1060,9 @@ class WebMaxClient extends EventEmitter {
1015
1060
  */
1016
1061
  async handleRemovedMessage(data) {
1017
1062
  const message = new Message(data, this);
1063
+ if (this._incomingLogMode === 'verbose') {
1064
+ printIncomingLog('message_removed', message.rawData);
1065
+ }
1018
1066
  await this.triggerHandlers(EventTypes.MESSAGE_REMOVED, message);
1019
1067
  }
1020
1068
 
@@ -1023,6 +1071,9 @@ class WebMaxClient extends EventEmitter {
1023
1071
  */
1024
1072
  async handleChatAction(data) {
1025
1073
  const action = new ChatAction(data, this);
1074
+ if (this._incomingLogMode === 'verbose') {
1075
+ printIncomingLog('chat_action', action.rawData);
1076
+ }
1026
1077
  await this.triggerHandlers(EventTypes.CHAT_ACTION, action);
1027
1078
  }
1028
1079
 
@@ -1074,6 +1125,73 @@ class WebMaxClient extends EventEmitter {
1074
1125
  });
1075
1126
  }
1076
1127
 
1128
+ _nextClientMessageId() {
1129
+ const n = this._clientSendCid;
1130
+ this._clientSendCid = (this._clientSendCid % 0x7fffffff) + 1;
1131
+ return n;
1132
+ }
1133
+
1134
+ /** int32: Date.now() и большие числа ломают валидацию MSG_SEND на TCP */
1135
+ _normalizeOutgoingCid(cid) {
1136
+ if (cid == null || cid === '') return this._nextClientMessageId();
1137
+ const n = Number(cid);
1138
+ if (!Number.isFinite(n)) return this._nextClientMessageId();
1139
+ const x = Math.trunc(n);
1140
+ if (x > 2147483647 || x < -2147483648) {
1141
+ return this._nextClientMessageId();
1142
+ }
1143
+ return x;
1144
+ }
1145
+
1146
+ /**
1147
+ * messageId для REPLY: int, если безопасно, иначе строка (длинные id).
1148
+ */
1149
+ _normalizeReplyMessageId(replyTo) {
1150
+ if (replyTo == null || replyTo === '') return null;
1151
+ if (typeof replyTo === 'number' && Number.isFinite(replyTo)) return replyTo;
1152
+ if (typeof replyTo === 'bigint') return Number(replyTo);
1153
+ if (typeof replyTo === 'string' && /^-?\d+$/.test(replyTo)) {
1154
+ const n = Number(replyTo);
1155
+ if (Number.isSafeInteger(n)) return n;
1156
+ return replyTo;
1157
+ }
1158
+ return String(replyTo);
1159
+ }
1160
+
1161
+ /**
1162
+ * Собирает тело message для MSG_SEND: без link: null; cid в int32; elements для текста.
1163
+ */
1164
+ _buildOutgoingMessageBody(text, cid, replyTo, attachments) {
1165
+ const t = text == null ? '' : String(text);
1166
+ const cidVal = this._normalizeOutgoingCid(cid);
1167
+
1168
+ const body = {
1169
+ text: t,
1170
+ cid: cidVal,
1171
+ elements: []
1172
+ };
1173
+
1174
+ const att = attachments || [];
1175
+ if (att.length) {
1176
+ body.attaches = att;
1177
+ }
1178
+
1179
+ if (replyTo != null && replyTo !== '') {
1180
+ body.link = {
1181
+ type: 'REPLY',
1182
+ messageId: this._normalizeReplyMessageId(replyTo)
1183
+ };
1184
+ }
1185
+ return body;
1186
+ }
1187
+
1188
+ _normalizeChatId(chatId) {
1189
+ if (chatId == null) return chatId;
1190
+ if (typeof chatId === 'bigint') return Number(chatId);
1191
+ const n = Number(chatId);
1192
+ return Number.isNaN(n) ? chatId : n;
1193
+ }
1194
+
1077
1195
  /**
1078
1196
  * Отправка сообщения (с уведомлением)
1079
1197
  */
@@ -1085,14 +1203,8 @@ class WebMaxClient extends EventEmitter {
1085
1203
  const { chatId, text, cid, replyTo, attachments } = options;
1086
1204
 
1087
1205
  const payload = {
1088
- chatId: chatId,
1089
- message: {
1090
- text: text || '',
1091
- cid: cid || Date.now(),
1092
- elements: [],
1093
- attaches: attachments || [],
1094
- link: replyTo ? { type: 'REPLY', messageId: replyTo } : null
1095
- },
1206
+ chatId: this._normalizeChatId(chatId),
1207
+ message: this._buildOutgoingMessageBody(text, cid, replyTo, attachments),
1096
1208
  notify: true
1097
1209
  };
1098
1210
 
@@ -1116,14 +1228,8 @@ class WebMaxClient extends EventEmitter {
1116
1228
  const { chatId, text, cid, replyTo, attachments } = options;
1117
1229
 
1118
1230
  const payload = {
1119
- chatId: chatId,
1120
- message: {
1121
- text: text || '',
1122
- cid: cid || Date.now(),
1123
- elements: [],
1124
- attaches: attachments || [],
1125
- link: replyTo ? { type: 'REPLY', messageId: replyTo } : null
1126
- },
1231
+ chatId: this._normalizeChatId(chatId),
1232
+ message: this._buildOutgoingMessageBody(text, cid, replyTo, attachments),
1127
1233
  notify: false
1128
1234
  };
1129
1235
 
@@ -1252,8 +1358,19 @@ class WebMaxClient extends EventEmitter {
1252
1358
  * Выполнение зарегистрированных обработчиков
1253
1359
  */
1254
1360
  async triggerHandlers(eventType, data = null) {
1361
+ if (
1362
+ eventType === EventTypes.ERROR &&
1363
+ data !== null &&
1364
+ this._incomingLogMode === 'verbose'
1365
+ ) {
1366
+ printIncomingLog('error', {
1367
+ message: data && data.message,
1368
+ stack: data && data.stack
1369
+ });
1370
+ }
1371
+
1255
1372
  const handlers = this.handlers[eventType] || [];
1256
-
1373
+
1257
1374
  for (const handler of handlers) {
1258
1375
  try {
1259
1376
  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
+ };
@@ -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 = data.id || data.messageId || null;
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
- return await this.client.sendMessage({
86
+ const { text, cid, attachments, quote = false } = options;
87
+ const payload = {
79
88
  chatId: this.chatId,
80
- text: options.text,
81
- cid: options.cid || Date.now(),
82
- replyTo: this.id,
83
- ...options
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
+ };
@@ -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 = {
@@ -138,6 +156,16 @@ class MaxSocketTransport {
138
156
  };
139
157
  }
140
158
 
159
+ /**
160
+ * Однонаправленная отправка без ожидания ответа (ответ на серверный PING — как WebSocket sendPong).
161
+ */
162
+ sendOneWay(opcode, payload = {}, cmd = 0) {
163
+ if (!this.socket || this.socket.destroyed) return;
164
+ const msg = this._makeMessage(opcode, payload, cmd);
165
+ const packet = packPacket(msg.ver, msg.cmd, msg.seq, msg.opcode, msg.payload);
166
+ this.socket.write(packet);
167
+ }
168
+
141
169
  _startRecvLoop() {
142
170
  const readNext = async () => {
143
171
  if (!this.socket || this.socket.destroyed) return;
@@ -180,9 +208,13 @@ class MaxSocketTransport {
180
208
  async sendAndWait(opcode, payload, cmd = 0, timeout = 20000) {
181
209
  if (!this.socket || this.socket.destroyed) throw new Error('Socket not connected');
182
210
 
183
- const msg = this._makeMessage(opcode, payload, cmd);
184
- const seqKey = msg.seq % 256;
185
- const packet = packPacket(msg.ver, msg.cmd, msg.seq, msg.opcode, msg.payload);
211
+ const outMsg = this._makeMessage(opcode, payload, cmd);
212
+ const seqKey = outMsg.seq % 256;
213
+ const packet = packPacket(outMsg.ver, outMsg.cmd, outMsg.seq, outMsg.opcode, outMsg.payload);
214
+
215
+ if (this.debug) {
216
+ this._log('→', getOpcodeName(opcode), `(seq=${outMsg.seq})`, safeJsonForLog(payload));
217
+ }
186
218
 
187
219
  let pendingRef;
188
220
  const promise = new Promise((resolve, reject) => {
@@ -212,8 +244,26 @@ class MaxSocketTransport {
212
244
 
213
245
  const result = await promise;
214
246
  if (result.payload && result.payload.error) {
215
- const errMsg = result.payload.localizedMessage || result.payload.error?.message || JSON.stringify(result.payload.error);
216
- throw new Error(errMsg);
247
+ const err = result.payload.error;
248
+ const localized = result.payload.localizedMessage;
249
+ const errText =
250
+ localized ||
251
+ (typeof err === 'string' ? err : err && err.message != null ? String(err.message) : '') ||
252
+ JSON.stringify(err);
253
+
254
+ if (this.debug) {
255
+ this._debugErr('RPC error response', {
256
+ opcode: getOpcodeName(opcode),
257
+ opcodeNum: opcode,
258
+ seq: outMsg.seq,
259
+ outgoingPayload: safeJsonForLog(payload),
260
+ fullResponsePayload: safeJsonForLog(result.payload)
261
+ });
262
+ }
263
+
264
+ const e = new Error(errText);
265
+ if (this.debug) e.rawPayload = result.payload;
266
+ throw e;
217
267
  }
218
268
  return result;
219
269
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webmaxsocket",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "description": "Node.js client for Max Messenger with QR code and token authentication",
5
5
  "main": "index.js",
6
6
  "scripts": {