max2tg 1.0.0

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.
Files changed (3) hide show
  1. package/README.md +78 -0
  2. package/index.js +547 -0
  3. package/package.json +18 -0
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # max2tg
2
+
3
+ Конвертер событий (updates) мессенджера **MAX** в события **Telegram Bot API**.
4
+
5
+ ---
6
+
7
+ ## Установка
8
+
9
+ ```bash
10
+ npm install max2tg
11
+ ```
12
+
13
+ ---
14
+
15
+ ## Быстрый старт
16
+
17
+ ```js
18
+ const { convertMaxToTelegram, convertBatch } = require('max2tg');
19
+
20
+ // Одно событие → массив Telegram updates
21
+ const updates = convertMaxToTelegram(maxUpdate);
22
+
23
+ // Массив событий → плоский массив Telegram updates
24
+ const updates = convertBatch(maxUpdates);
25
+ ```
26
+
27
+ ### Пример в webhook-обработчике
28
+
29
+ ```js
30
+ const express = require('express');
31
+ const { convertMaxToTelegram } = require('max2tg');
32
+
33
+ const app = express();
34
+ app.use(express.json());
35
+
36
+ // Webhook от MAX
37
+ app.post('/webhook/max', (req, res) => {
38
+ const tgUpdates = convertMaxToTelegram(req.body);
39
+
40
+ for (const update of tgUpdates) {
41
+ handleTelegramUpdate(update); // ваш общий обработчик
42
+ }
43
+
44
+ res.sendStatus(200);
45
+ });
46
+
47
+ // Webhook от Telegram
48
+ app.post('/webhook/telegram', (req, res) => {
49
+ handleTelegramUpdate(req.body);
50
+ res.sendStatus(200);
51
+ });
52
+ ```
53
+
54
+ ---
55
+
56
+ ## Поддерживаемые типы событий
57
+
58
+ ### `bot_started` → `message` с командой `/start`
59
+ Активация бота пользователем, в том числе с `start payload`.
60
+
61
+ ### `message_created` → `message`
62
+ Поддержка всех видов вложений, включая геопозицию, документы, стикеры и альбомы.
63
+ Ответы на сообщение, пересланные сообщения, сообщения с клавиатурой и форматированием.
64
+
65
+ ### `message_callback` → `callback_query`
66
+ Нажатие пользователя на инлайн-кнопку.
67
+
68
+ ---
69
+
70
+ ## Ограничения
71
+
72
+ Поле file_id всегда равен null, на замену приходит _max_url и _max_token.
73
+
74
+ ---
75
+
76
+ ## Лицензия
77
+
78
+ MIT
package/index.js ADDED
@@ -0,0 +1,547 @@
1
+ /**
2
+ * Конвертер событий (updates) из формата MAX в формат Telegram Bot API.
3
+ *
4
+ * Поддерживаемые типы событий MAX:
5
+ * - bot_started → /start (message с командой)
6
+ * - message_created → message (text / sticker / document / location /
7
+ * forwarded photo / forwarded album)
8
+ * - message_callback → callback_query
9
+ ** /
10
+
11
+ // ─── helpers ────────────────────────────────────────────────────────────────
12
+
13
+ /** Переводит миллисекундный Unix-timestamp в секундный (Telegram). */
14
+ const toSec = (ms) => Math.floor(ms / 1000);
15
+
16
+ /** update_id генерируем из timestamp (секунды). Принимает ms или sec. */
17
+ const toUpdateId = (ts) => ts > 1e12 ? Math.floor(ts / 1000) : ts;
18
+
19
+ /**
20
+ * Собирает минимальный объект Chat из данных MAX.
21
+ * MAX хранит chat_id и user_id в recipient; для личных диалогов
22
+ * Telegram использует тот же id что и у пользователя.
23
+ */
24
+ function buildChat(maxUser) {
25
+ return {
26
+ id: maxUser.user_id, // в диалоге chat.id = user.id
27
+ type: "private",
28
+ first_name: maxUser.first_name,
29
+ last_name: maxUser.last_name ?? undefined,
30
+ username: maxUser.username ?? undefined,
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Собирает объект From/User из данных MAX.
36
+ */
37
+ function buildFrom(maxUser) {
38
+ return {
39
+ id: maxUser.user_id,
40
+ is_bot: maxUser.is_bot ?? false,
41
+ first_name: maxUser.first_name,
42
+ last_name: maxUser.last_name ?? undefined,
43
+ username: maxUser.username ?? undefined,
44
+ language_code: undefined, // MAX не передаёт language_code
45
+ };
46
+ }
47
+
48
+ // ─── markup (MAX) → entities (Telegram) ─────────────────────────────────────
49
+
50
+ const MAX_MARKUP_TO_TG_ENTITY = {
51
+ strong: "bold",
52
+ em: "italic",
53
+ pre: "pre",
54
+ code: "code",
55
+ link: "url",
56
+ strikethrough: "strikethrough",
57
+ underline: "underline",
58
+ };
59
+
60
+ /**
61
+ * Конвертирует MAX markup-массив в массив Telegram MessageEntity.
62
+ * Дубликаты (MAX иногда присылает одну и ту же метку дважды) убираются.
63
+ */
64
+ function convertMarkup(markup = []) {
65
+ const seen = new Set();
66
+ const entities = [];
67
+
68
+ for (const m of markup) {
69
+ const type = MAX_MARKUP_TO_TG_ENTITY[m.type];
70
+ if (!type) continue;
71
+
72
+ const key = `${type}:${m.from}:${m.length}`;
73
+ if (seen.has(key)) continue;
74
+ seen.add(key);
75
+
76
+ entities.push({ type, offset: m.from, length: m.length });
77
+ }
78
+
79
+ return entities.length ? entities : undefined;
80
+ }
81
+
82
+ // ─── inline_keyboard (MAX) → reply_markup (Telegram) ────────────────────────
83
+
84
+ /**
85
+ * MAX button → Telegram InlineKeyboardButton
86
+ *
87
+ * Типы MAX: link | callback | (возможно request_geo, request_contact и др.)
88
+ */
89
+ function convertButton(maxBtn) {
90
+ const tgBtn = { text: maxBtn.text };
91
+
92
+ if (maxBtn.type === "link" || maxBtn.url) {
93
+ tgBtn.url = maxBtn.url;
94
+ } else if (maxBtn.type === "callback") {
95
+ tgBtn.callback_data = maxBtn.payload ?? "";
96
+ }
97
+
98
+ return tgBtn;
99
+ }
100
+
101
+ function convertInlineKeyboard(attachment) {
102
+ if (!attachment || attachment.type !== "inline_keyboard") return undefined;
103
+ const rows = attachment.payload?.buttons ?? [];
104
+ return {
105
+ inline_keyboard: rows.map((row) => row.map(convertButton)),
106
+ };
107
+ }
108
+
109
+ // ─── attachments ─────────────────────────────────────────────────────────────
110
+
111
+ /**
112
+ * Извлекает первый inline_keyboard из массива вложений MAX.
113
+ */
114
+ function extractKeyboard(attachments = []) {
115
+ return (
116
+ attachments.find((a) => a.type === "inline_keyboard") ?? null
117
+ );
118
+ }
119
+
120
+ /**
121
+ * Строит объект Telegram стикера из MAX-вложения типа sticker.
122
+ * В MAX нет file_id — передаём только то, что есть.
123
+ */
124
+ function buildSticker(att) {
125
+ return {
126
+ // file_id недоступен из MAX — ставим заглушку; реальный file_id
127
+ // получить нельзя без загрузки файла через Telegram API.
128
+ file_id: null,
129
+ file_unique_id: null,
130
+ width: att.width ?? 512,
131
+ height: att.height ?? 512,
132
+ is_animated: false,
133
+ is_video: false,
134
+ type: "regular",
135
+ thumbnail: att.payload?.url
136
+ ? {
137
+ file_id: null,
138
+ file_unique_id: null,
139
+ width: att.width ?? 128,
140
+ height: att.height ?? 128,
141
+ }
142
+ : undefined,
143
+ // Нативный URL стикера (MAX-специфично, не часть TG API)
144
+ _max_url: att.payload?.url,
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Строит объект Telegram Document из MAX-вложения типа file.
150
+ */
151
+ function buildDocument(att) {
152
+ return {
153
+ file_id: null, // недоступен без Telegram
154
+ file_unique_id: null,
155
+ file_name: att.filename ?? undefined,
156
+ file_size: att.size ?? undefined,
157
+ // Прямая ссылка из MAX (временная, требует токена)
158
+ _max_url: att.payload?.url,
159
+ _max_token: att.payload?.token,
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Строит массив PhotoSize (один размер) из MAX-вложения типа image.
165
+ */
166
+ function buildPhoto(att) {
167
+ return [
168
+ {
169
+ file_id: null,
170
+ file_unique_id: null,
171
+ width: 0,
172
+ height: 0,
173
+ _max_url: att.payload?.url,
174
+ _max_token: att.payload?.token,
175
+ },
176
+ ];
177
+ }
178
+
179
+ /**
180
+ * Строит Telegram Location из MAX-вложения типа location.
181
+ */
182
+ function buildLocation(att) {
183
+ return {
184
+ latitude: att.latitude,
185
+ longitude: att.longitude,
186
+ };
187
+ }
188
+
189
+ // ─── forward origin ──────────────────────────────────────────────────────────
190
+
191
+ /**
192
+ * Строит forward_origin из MAX link.type === 'forward'.
193
+ * MAX хранит sender пересланного сообщения; если это бот/канал — channel,
194
+ * иначе — user.
195
+ */
196
+ function buildForwardOrigin(maxLink, dateMs) {
197
+ if (!maxLink || maxLink.type !== "forward") return {};
198
+
199
+ const date = toSec(dateMs);
200
+ const sender = maxLink.sender ?? {};
201
+
202
+ if (sender.is_bot || !sender.user_id) {
203
+ // Предполагаем канал / публичный источник
204
+ return {
205
+ forward_origin: {
206
+ type: "channel",
207
+ date,
208
+ chat: {
209
+ id: maxLink.chat_id || 0,
210
+ type: "channel",
211
+ title: sender.first_name ?? "Unknown",
212
+ username: sender.username ?? undefined,
213
+ },
214
+ message_id: 0,
215
+ },
216
+ };
217
+ }
218
+
219
+ return {
220
+ forward_origin: {
221
+ type: "user",
222
+ date,
223
+ sender_user: buildFrom(sender),
224
+ },
225
+ };
226
+ }
227
+
228
+ // ─── reply_to_message ────────────────────────────────────────────────────────
229
+
230
+ /**
231
+ * Строит упрощённый reply_to_message из MAX link.type === 'reply'.
232
+ */
233
+ function buildReplyTo(maxLink, chat) {
234
+ if (!maxLink || maxLink.type !== "reply") return {};
235
+
236
+ const sender = maxLink.sender ?? {};
237
+ return {
238
+ reply_to_message: {
239
+ message_id: maxLink.message?.mid ?? null,
240
+ chat,
241
+ from: buildFrom(sender),
242
+ date: 0,
243
+ text: maxLink.message?.text ?? "",
244
+ },
245
+ };
246
+ }
247
+
248
+ // ─── конвертеры по update_type ───────────────────────────────────────────────
249
+
250
+ /**
251
+ * bot_started → Telegram update с /start
252
+ */
253
+ function convertBotStarted(maxUpdate) {
254
+ const user = maxUpdate.user;
255
+ const chat = buildChat(user);
256
+ const from = buildFrom(user);
257
+ const date = toSec(maxUpdate.timestamp);
258
+
259
+ const startText = maxUpdate.payload
260
+ ? `/start ${maxUpdate.payload}`
261
+ : "/start";
262
+
263
+ const entities = [{ type: "bot_command", offset: 0, length: 6 }];
264
+
265
+ return {
266
+ update_id: toUpdateId(maxUpdate.timestamp),
267
+ message: {
268
+ message_id: String(toSec(maxUpdate.timestamp)), // bot_started не имеет mid — используем timestamp
269
+ from,
270
+ chat,
271
+ date,
272
+ text: startText,
273
+ entities,
274
+ },
275
+ };
276
+ }
277
+
278
+ /**
279
+ * message_created → Telegram update (message / стикер / документ /
280
+ * геопозиция / forward / album-item)
281
+ *
282
+ * Возвращает МАССИВ updates — один или несколько (для альбомов).
283
+ */
284
+ function convertMessageCreated(maxUpdate) {
285
+ const msg = maxUpdate.message;
286
+ const body = msg.body;
287
+ const sender = msg.sender;
288
+ const ts = toSec(msg.timestamp);
289
+ const chat = buildChat(sender);
290
+ const from = buildFrom(sender);
291
+ const link = msg.link ?? null;
292
+
293
+ // Базовая обёртка Telegram message
294
+ const mid = body.mid ?? null;
295
+ const base = {
296
+ message_id: mid, // MAX mid передаётся как строка
297
+ from,
298
+ chat,
299
+ date: ts,
300
+ };
301
+
302
+ // Forward/reply мета
303
+ const forwardMeta = buildForwardOrigin(link, msg.timestamp);
304
+ const replyMeta = buildReplyTo(link, chat);
305
+
306
+ const attachments = body.attachments ?? [];
307
+
308
+ // ── Стикер ──────────────────────────────────────────────────────────────
309
+ const stickerAtt = attachments.find((a) => a.type === "sticker");
310
+ if (stickerAtt) {
311
+ return [
312
+ {
313
+ update_id: toUpdateId(msg.timestamp),
314
+ message: { ...base, sticker: buildSticker(stickerAtt) },
315
+ },
316
+ ];
317
+ }
318
+
319
+ // ── Геопозиция ───────────────────────────────────────────────────────────
320
+ const locationAtt = attachments.find((a) => a.type === "location");
321
+ if (locationAtt) {
322
+ return [
323
+ {
324
+ update_id: toUpdateId(msg.timestamp),
325
+ message: { ...base, location: buildLocation(locationAtt) },
326
+ },
327
+ ];
328
+ }
329
+
330
+ // ── Файл / документ ──────────────────────────────────────────────────────
331
+ const fileAtt = attachments.find((a) => a.type === "file");
332
+ if (fileAtt) {
333
+ return [
334
+ {
335
+ update_id: toUpdateId(msg.timestamp),
336
+ message: {
337
+ ...base,
338
+ document: buildDocument(fileAtt),
339
+ caption: body.text || undefined,
340
+ },
341
+ },
342
+ ];
343
+ }
344
+
345
+ // ── Видео ─────────────────────────────────────────────────────────────────
346
+ const videoAtt = attachments.find((a) => a.type === "video");
347
+
348
+ // ── Изображения ──────────────────────────────────────────────────────────
349
+ const imageAtts = attachments.filter((a) => a.type === "image");
350
+
351
+ // ── Альбом (видео + изображения или несколько изображений) ───────────────
352
+ // MAX присылает всё одним событием; Telegram шлёт каждый элемент отдельно
353
+ // с общим media_group_id. Генерируем несколько updates.
354
+ const mediaItems = [
355
+ ...(videoAtt ? [{ type: "video", att: videoAtt }] : []),
356
+ ...imageAtts.map((att) => ({ type: "image", att })),
357
+ ];
358
+
359
+ if (mediaItems.length > 1) {
360
+ const mediaGroupId = String(Date.now()); // псевдо media_group_id
361
+ const caption = body.text || undefined;
362
+ const captionEntities = convertMarkup(body.markup);
363
+
364
+ return mediaItems.map((item, idx) => {
365
+ const isFirst = idx === 0;
366
+ const tgMsg = {
367
+ ...base,
368
+ ...forwardMeta,
369
+ media_group_id: mediaGroupId,
370
+ };
371
+
372
+ if (item.type === "video") {
373
+ tgMsg.video = {
374
+ file_id: null,
375
+ file_unique_id: null,
376
+ width: 0,
377
+ height: 0,
378
+ duration: item.att.duration ?? 0,
379
+ _max_url: item.att.payload?.url,
380
+ };
381
+ } else {
382
+ tgMsg.photo = buildPhoto(item.att);
383
+ }
384
+
385
+ // Caption только к первому элементу (как в Telegram)
386
+ if (isFirst && caption) {
387
+ tgMsg.caption = caption;
388
+ if (captionEntities) tgMsg.caption_entities = captionEntities;
389
+ }
390
+
391
+ return { update_id: toUpdateId(msg.timestamp) + idx, message: tgMsg };
392
+ });
393
+ }
394
+
395
+ // ── Одиночное изображение ─────────────────────────────────────────────────
396
+ if (mediaItems.length === 1) {
397
+ const item = mediaItems[0];
398
+ const tgMsg = { ...base, ...forwardMeta, ...replyMeta };
399
+
400
+ if (item.type === "video") {
401
+ tgMsg.video = {
402
+ file_id: null,
403
+ file_unique_id: null,
404
+ width: 0,
405
+ height: 0,
406
+ duration: item.att.duration ?? 0,
407
+ _max_url: item.att.payload?.url,
408
+ };
409
+ } else {
410
+ tgMsg.photo = buildPhoto(item.att);
411
+ }
412
+
413
+ const caption = body.text || undefined;
414
+ if (caption) {
415
+ tgMsg.caption = caption;
416
+ const ents = convertMarkup(body.markup);
417
+ if (ents) tgMsg.caption_entities = ents;
418
+ }
419
+
420
+ return [{ update_id: toUpdateId(msg.timestamp), message: tgMsg }];
421
+ }
422
+
423
+ // ── Inline keyboard в теле сообщения (бот → пользователь) ────────────────
424
+ const kbAtt = extractKeyboard(attachments);
425
+ const replyMarkup = convertInlineKeyboard(kbAtt);
426
+
427
+ // ── Обычный текст ─────────────────────────────────────────────────────────
428
+ const text = body.text ?? "";
429
+ const entities = convertMarkup(body.markup);
430
+
431
+ const tgMsg = {
432
+ ...base,
433
+ ...forwardMeta,
434
+ ...replyMeta,
435
+ text,
436
+ };
437
+ if (entities) tgMsg.entities = entities;
438
+ if (replyMarkup) tgMsg.reply_markup = replyMarkup;
439
+
440
+ return [{ update_id: toUpdateId(msg.timestamp), message: tgMsg }];
441
+ }
442
+
443
+ /**
444
+ * message_callback → Telegram callback_query
445
+ */
446
+ function convertMessageCallback(maxUpdate) {
447
+ const cb = maxUpdate.callback;
448
+ const user = cb.user;
449
+ const from = buildFrom(user);
450
+ const chat = buildChat(user);
451
+
452
+ return {
453
+ update_id: toUpdateId(cb.timestamp),
454
+ callback_query: {
455
+ id: String(cb.timestamp),
456
+ from,
457
+ message: {
458
+ message_id: maxUpdate.message?.body?.mid ?? String(toSec(cb.timestamp)),
459
+ from: {
460
+ id: 0,
461
+ is_bot: true,
462
+ first_name: "Bot",
463
+ },
464
+ chat,
465
+ date: toSec(cb.timestamp),
466
+ text: maxUpdate.message?.body?.text ?? "",
467
+ reply_markup: (() => {
468
+ const kbAtt = extractKeyboard(maxUpdate.message?.body?.attachments ?? []);
469
+ return convertInlineKeyboard(kbAtt);
470
+ })(),
471
+ },
472
+ chat_instance: String(chat.id),
473
+ data: cb.payload ?? "",
474
+ },
475
+ };
476
+ }
477
+
478
+ // ─── главная функция ─────────────────────────────────────────────────────────
479
+
480
+ /**
481
+ * Конвертирует одно MAX-событие в один или несколько Telegram-updates.
482
+ *
483
+ * @param {object} maxUpdate — тело события из MAX (поле `max` в тестовом JSON)
484
+ * @returns {object[]} — массив Telegram update-объектов
485
+ */
486
+ function convertMaxToTelegram(maxUpdate) {
487
+ const type = maxUpdate.update_type;
488
+
489
+ switch (type) {
490
+ case "bot_started":
491
+ return [convertBotStarted(maxUpdate)];
492
+
493
+ case "message_created":
494
+ return convertMessageCreated(maxUpdate);
495
+
496
+ case "message_callback":
497
+ return [convertMessageCallback(maxUpdate)];
498
+
499
+ default:
500
+ console.warn(`[max-to-telegram] Неизвестный update_type: "${type}"`);
501
+ return [];
502
+ }
503
+ }
504
+
505
+ /**
506
+ * Удобная обёртка: принимает массив MAX-событий, возвращает плоский
507
+ * массив Telegram updates.
508
+ *
509
+ * @param {object[]} maxUpdates
510
+ * @returns {object[]}
511
+ */
512
+ function convertBatch(maxUpdates) {
513
+ return maxUpdates.flatMap(convertMaxToTelegram);
514
+ }
515
+
516
+ module.exports = { convertMaxToTelegram, convertBatch };
517
+
518
+ // ════════════════════════════════════════════════════════════════════════════
519
+ // NOTES — Нюансы и ограничения
520
+ // ════════════════════════════════════════════════════════════════════════════
521
+ //
522
+ // 1. file_id / file_unique_id поля выставляются в null.
523
+ // Добавляются (_max_url + _max_token)
524
+ //
525
+ // 2. message_id
526
+ // В MAX строка вида "mid.xxxxx" передаётся в поле message_id.
527
+ //
528
+ // 3. Альбомы (media group)
529
+ // MAX присылает весь альбом одним событием message_created с несколькими
530
+ // вложениями. Telegram рассылает каждый элемент отдельным update с единым
531
+ // media_group_id. convertMessageCreated возвращает массив — разошлите каждый
532
+ // элемент отдельно или обработайте весь массив как альбом.
533
+ //
534
+ // 4. callback_data
535
+ // MAX payload передаётся в callback_data без изменений. Если payload
536
+ // длиннее 64 байт — Telegram Bot API вернёт ошибку при отправке.
537
+ // Рекомендуется хранить длинные payload на стороне бэкенда и передавать
538
+ // лишь короткий ключ уже на уровне формирования кнопок в MAX.
539
+ //
540
+ // 5. Forward origin из приватных источников
541
+ // Если оригинал переслан из закрытого чата, MAX может не передать
542
+ // полные данные отправителя. forward_origin.chat.id будет 0.
543
+ //
544
+ // 6. update_id
545
+ // Генерируется из Unix-timestamp события (в секундах). При нескольких
546
+ // событиях в одну секунду возможны коллизии — для альбомов используется
547
+ // timestamp + порядковый индекс элемента.
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "max2tg",
3
+ "version": "1.0.0",
4
+ "description": "MAX to Telegram event style converter",
5
+ "main": "index.js",
6
+ "keywords": [
7
+ "max",
8
+ "telegram",
9
+ "bot",
10
+ "converter",
11
+ "adapter"
12
+ ],
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/azizbots/max2tg.git"
17
+ }
18
+ }