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 +83 -12
- package/index.js +8 -1
- package/lib/client.js +138 -21
- package/lib/downloadMedia.js +124 -0
- package/lib/entities/Message.js +41 -8
- package/lib/incomingLog.js +59 -0
- package/lib/socketTransport.js +55 -5
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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 =
|
|
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
|
+
};
|
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 = {
|
|
@@ -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
|
|
184
|
-
const seqKey =
|
|
185
|
-
const packet = packPacket(
|
|
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
|
|
216
|
-
|
|
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
|
}
|