@emaxe/tuigram 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.
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Резолвинг пиров, типов сущностей и безопасная работа с ID Telegram.
3
+ */
4
+ import { getPeerId } from "teleproto/Utils.js";
5
+
6
+ /**
7
+ * Приводит идентификатор Telegram к строковому представлению.
8
+ * @param {unknown} value
9
+ * @returns {string}
10
+ */
11
+ export function idToString(value) {
12
+ if (value === null || value === undefined) return "";
13
+ if (typeof value === "bigint" || typeof value === "number") return value.toString();
14
+ if (typeof value === "string") return value;
15
+ if (value && typeof value === "object") {
16
+ if (typeof value.value !== "undefined") return idToString(value.value);
17
+ if (typeof value.userId !== "undefined") return idToString(value.userId);
18
+ if (typeof value.channelId !== "undefined") return idToString(value.channelId);
19
+ if (typeof value.chatId !== "undefined") return idToString(value.chatId);
20
+ if (typeof value.toString === "function") return value.toString();
21
+ }
22
+ return String(value);
23
+ }
24
+
25
+ /**
26
+ * Приводит пир (Peer* из MTProto, сущность или готовый ID) к "маркированному"
27
+ * строковому ID в том же формате, в каком его отдаёт teleproto для диалогов:
28
+ * пользователь -> "123", группа -> "-456", канал/супергруппа -> "-100789".
29
+ *
30
+ * Без этого ID сообщения (немаркированный channelId) не совпадает с ID диалога
31
+ * и входящие сообщения групп/каналов не попадают в открытый чат.
32
+ * @param {unknown} peer
33
+ * @returns {string}
34
+ */
35
+ export function toMarkedId(peer) {
36
+ if (peer === null || peer === undefined) return "";
37
+ try {
38
+ const id = getPeerId(peer);
39
+ if (id !== null && id !== undefined && id !== "") return String(id);
40
+ } catch {
41
+ // Не Peer-объект — падаем на обычное строковое представление
42
+ }
43
+ return idToString(peer);
44
+ }
45
+
46
+ /**
47
+ * Преобразует пользовательский ввод в формат, понятный MTProto.
48
+ * @param {string|number|bigint} raw "@username" | "username" | "-1001234567890" | "me"
49
+ * @returns {string|bigint}
50
+ */
51
+ export function parsePeer(raw) {
52
+ const value = String(raw || "").trim();
53
+ if (!value) throw new Error("Не указан идентификатор чата (peer)");
54
+ if (value === "me" || value === "self") return "me";
55
+ if (/^-?\d+$/.test(value)) {
56
+ return BigInt(value);
57
+ }
58
+ return value.startsWith("@") ? value : `@${value}`;
59
+ }
60
+
61
+ /**
62
+ * Определяет тип диалога.
63
+ * @param {object} dialog
64
+ * @returns {"user"|"bot"|"group"|"supergroup"|"channel"|"saved"|"unknown"}
65
+ */
66
+ export function detectChatType(dialog) {
67
+ if (!dialog) return "unknown";
68
+ if (dialog.id === "me" || dialog.isSelf) return "saved";
69
+ const entity = dialog.entity || {};
70
+ if (dialog.isUser || entity.className === "User") {
71
+ if (entity.isSelf) return "saved";
72
+ return entity.bot ? "bot" : "user";
73
+ }
74
+ if (dialog.isChannel || entity.className === "Channel") {
75
+ return entity.broadcast ? "channel" : "supergroup";
76
+ }
77
+ if (dialog.isGroup || entity.className === "Chat") {
78
+ return entity.megagroup ? "supergroup" : "group";
79
+ }
80
+ return "unknown";
81
+ }
82
+
83
+ /**
84
+ * Возвращает отображаемое имя для сущности (пользователя, канала или чата).
85
+ * @param {object} entity
86
+ * @returns {string}
87
+ */
88
+ export function getEntityDisplayName(entity) {
89
+ if (!entity) return "Неизвестный чат";
90
+ if (entity.isSelf) return "Избранное (Saved Messages)";
91
+ if (entity.title) return entity.title;
92
+ const parts = [entity.firstName, entity.lastName].filter(Boolean);
93
+ if (parts.length > 0) return parts.join(" ");
94
+ if (entity.username) return `@${entity.username}`;
95
+ if (entity.id) return `Чат ${idToString(entity.id)}`;
96
+ return "Без названия";
97
+ }
98
+
99
+ /**
100
+ * Кэш сущностей (пользователи, каналы, чаты) для быстрого доступа.
101
+ */
102
+ class EntityCache {
103
+ constructor() {
104
+ /** @type {Map<string, object>} */
105
+ this.cache = new Map();
106
+ }
107
+
108
+ set(id, entity) {
109
+ if (!id || !entity) return;
110
+ const key = idToString(id);
111
+ this.cache.set(key, entity);
112
+ if (entity.username) {
113
+ this.cache.set(`@${entity.username.toLowerCase()}`, entity);
114
+ }
115
+ }
116
+
117
+ get(idOrUsername) {
118
+ if (!idOrUsername) return null;
119
+ const key = String(idOrUsername).toLowerCase();
120
+ return this.cache.get(key) || this.cache.get(idToString(idOrUsername)) || null;
121
+ }
122
+
123
+ has(idOrUsername) {
124
+ return this.get(idOrUsername) !== null;
125
+ }
126
+
127
+ clear() {
128
+ this.cache.clear();
129
+ }
130
+ }
131
+
132
+ export const entityCache = new EntityCache();
133
+
134
+ /**
135
+ * Разрешает сущность чата по строковому представлению с обработкой частых ошибок.
136
+ * @param {import("teleproto").TelegramClient} client
137
+ * @param {string|number|bigint} rawPeer
138
+ * @returns {Promise<object>}
139
+ */
140
+ export async function resolveEntity(client, rawPeer) {
141
+ const peer = parsePeer(rawPeer);
142
+ try {
143
+ const entity = await client.getEntity(peer);
144
+ if (entity) {
145
+ entityCache.set(entity.id, entity);
146
+ }
147
+ return entity;
148
+ } catch (err) {
149
+ const msg = err?.message || String(err);
150
+ if (/CHANNEL_PRIVATE/i.test(msg)) {
151
+ throw new Error(`Нет доступа к ${rawPeer}: канал приватный или аккаунт в нём не состоит.`);
152
+ }
153
+ if (/USERNAME_NOT_OCCUPIED|USERNAME_INVALID|Cannot find any entity/i.test(msg)) {
154
+ throw new Error(`Чат ${rawPeer} не найден. Проверьте правильность username или ID.`);
155
+ }
156
+ throw err;
157
+ }
158
+ }
@@ -0,0 +1,248 @@
1
+ import { formatFileSize, formatDuration } from "../utils/time.js";
2
+
3
+ /**
4
+ * Палитра для разметки сообщений и медиа-плашек.
5
+ *
6
+ * Значения по умолчанию рассчитаны на тёмный фон и заданы hex-ом: именованные
7
+ * цвета терминала (особенно "blue" и "gray") на тёмном фоне почти не читаются.
8
+ * TUI подменяет палитру активной темой через setMessagePalette().
9
+ */
10
+ const colors = {
11
+ code: "#e0af68",
12
+ codeBg: "#1f2335",
13
+ pre: "#e0af68",
14
+ link: "#7dcfff",
15
+ url: "#7aa2f7",
16
+ mention: "#bb9af7",
17
+ hashtag: "#7aa2f7",
18
+ spoiler: "#565f89",
19
+ photo: "#e0af68",
20
+ voice: "#7dcfff",
21
+ audio: "#9ece6a",
22
+ video: "#bb9af7",
23
+ sticker: "#e0af68",
24
+ document: "#7aa2f7",
25
+ poll: "#bb9af7",
26
+ geo: "#9ece6a",
27
+ contact: "#7dcfff",
28
+ venue: "#9ece6a",
29
+ dice: "#e0af68",
30
+ webpage: "#7dcfff",
31
+ unknown: "#565f89",
32
+ };
33
+
34
+ /**
35
+ * Подменяет палитру разметки цветами активной темы.
36
+ * @param {object} theme
37
+ */
38
+ export function setMessagePalette(theme) {
39
+ if (!theme) return;
40
+ Object.assign(colors, {
41
+ code: theme.warning,
42
+ codeBg: theme.surfaceHigh,
43
+ pre: theme.warning,
44
+ link: theme.info,
45
+ url: theme.accent,
46
+ mention: theme.chatView.mediaFg,
47
+ hashtag: theme.accent,
48
+ spoiler: theme.dim,
49
+ photo: theme.warning,
50
+ voice: theme.info,
51
+ audio: theme.success,
52
+ video: theme.chatView.mediaFg,
53
+ sticker: theme.warning,
54
+ document: theme.accent,
55
+ poll: theme.chatView.mediaFg,
56
+ geo: theme.success,
57
+ contact: theme.info,
58
+ venue: theme.success,
59
+ dice: theme.warning,
60
+ webpage: theme.info,
61
+ unknown: theme.dim,
62
+ });
63
+ }
64
+
65
+ /**
66
+ * Экранирует спецсимволы тегов blessed в обычном тексте сообщений,
67
+ * чтобы фигурные скобки { ... } в коде или тексте пользователя не ломали рендер.
68
+ * @param {string} text
69
+ * @returns {string}
70
+ */
71
+ export function escapeBlessed(text) {
72
+ if (!text) return "";
73
+ return String(text).replace(/\{/g, "\\{").replace(/\}/g, "\\}");
74
+ }
75
+
76
+ /**
77
+ * Преобразует разметку сообщения Telegram (entities) в форматированный Blessed-текст.
78
+ * @param {string} rawText
79
+ * @param {Array<object>} [entities=[]]
80
+ * @returns {string}
81
+ */
82
+ export function formatMessageText(rawText, entities = []) {
83
+ if (!rawText) return "";
84
+ if (!entities || entities.length === 0) {
85
+ return escapeBlessed(rawText);
86
+ }
87
+
88
+ // Сортируем entities по смещению от начала к концу
89
+ const sorted = [...entities].sort((a, b) => (a.offset || 0) - (b.offset || 0));
90
+
91
+ let result = "";
92
+ let currentIndex = 0;
93
+
94
+ for (const entity of sorted) {
95
+ const offset = entity.offset || 0;
96
+ const length = entity.length || 0;
97
+ if (offset < currentIndex || offset > rawText.length) continue;
98
+
99
+ // Неформатированный фрагмент до текущего entity
100
+ if (offset > currentIndex) {
101
+ result += escapeBlessed(rawText.slice(currentIndex, offset));
102
+ }
103
+
104
+ const fragment = rawText.slice(offset, offset + length);
105
+ const escaped = escapeBlessed(fragment);
106
+ const className = entity.className || "";
107
+
108
+ switch (className) {
109
+ case "MessageEntityBold":
110
+ result += `{bold}${escaped}{/bold}`;
111
+ break;
112
+ case "MessageEntityItalic":
113
+ result += `{|}${escaped}{/|}`;
114
+ break;
115
+ case "MessageEntityCode":
116
+ result += `{${colors.code}-fg}{${colors.codeBg}-bg}${escaped}{/${colors.codeBg}-bg}{/${colors.code}-fg}`;
117
+ break;
118
+ case "MessageEntityPre":
119
+ result += `{${colors.pre}-fg}\n${escaped}\n{/${colors.pre}-fg}`;
120
+ break;
121
+ case "MessageEntityTextUrl":
122
+ result += `{${colors.link}-fg}{underline}${escaped}{/underline}{/${colors.link}-fg} ({${colors.url}-fg}${escapeBlessed(entity.url || "")}{/${colors.url}-fg})`;
123
+ break;
124
+ case "MessageEntityUrl":
125
+ result += `{${colors.link}-fg}{underline}${escaped}{/underline}{/${colors.link}-fg}`;
126
+ break;
127
+ case "MessageEntityMention":
128
+ case "MessageEntityMentionName":
129
+ result += `{${colors.mention}-fg}${escaped}{/${colors.mention}-fg}`;
130
+ break;
131
+ case "MessageEntityHashtag":
132
+ result += `{${colors.hashtag}-fg}${escaped}{/${colors.hashtag}-fg}`;
133
+ break;
134
+ case "MessageEntityStrike":
135
+ result += `{${colors.spoiler}-fg}${escaped}{/${colors.spoiler}-fg}`;
136
+ break;
137
+ case "MessageEntityUnderline":
138
+ result += `{underline}${escaped}{/underline}`;
139
+ break;
140
+ case "MessageEntitySpoiler":
141
+ result += `{inverse}${escaped}{/inverse}`;
142
+ break;
143
+ default:
144
+ result += escaped;
145
+ }
146
+
147
+ currentIndex = offset + length;
148
+ }
149
+
150
+ if (currentIndex < rawText.length) {
151
+ result += escapeBlessed(rawText.slice(currentIndex));
152
+ }
153
+
154
+ return result;
155
+ }
156
+
157
+ /**
158
+ * Возвращает краткое описание медиа-вложения для сообщения.
159
+ * @param {object} media
160
+ * @returns {string}
161
+ */
162
+ export function describeMedia(media) {
163
+ if (!media) return "";
164
+ const type = media.className || "";
165
+
166
+ switch (type) {
167
+ case "MessageMediaPhoto": {
168
+ return `{${colors.photo}-fg}[📷 Фотография]{/${colors.photo}-fg}`;
169
+ }
170
+ case "MessageMediaDocument": {
171
+ const doc = media.document || {};
172
+ const attributes = doc.attributes || [];
173
+ let fileName = "файл";
174
+ let isVoice = false;
175
+ let isAudio = false;
176
+ let isVideo = false;
177
+ let isSticker = false;
178
+ let duration = 0;
179
+ let performer = "";
180
+ let title = "";
181
+
182
+ for (const attr of attributes) {
183
+ if (attr.className === "DocumentAttributeFilename") {
184
+ fileName = attr.fileName || fileName;
185
+ }
186
+ if (attr.className === "DocumentAttributeAudio") {
187
+ if (attr.voice) isVoice = true;
188
+ else isAudio = true;
189
+ duration = attr.duration || 0;
190
+ performer = attr.performer || "";
191
+ title = attr.title || "";
192
+ }
193
+ if (attr.className === "DocumentAttributeVideo") {
194
+ isVideo = true;
195
+ duration = attr.duration || 0;
196
+ }
197
+ if (attr.className === "DocumentAttributeSticker") {
198
+ isSticker = true;
199
+ if (attr.alt) fileName = `Стикер ${attr.alt}`;
200
+ }
201
+ }
202
+
203
+ const size = formatFileSize(doc.size || 0);
204
+
205
+ if (isVoice) {
206
+ return `{${colors.voice}-fg}[🎤 Голосовое сообщение (${formatDuration(duration)})]{/${colors.voice}-fg}`;
207
+ }
208
+ if (isAudio) {
209
+ const track = [performer, title].filter(Boolean).join(" - ") || fileName;
210
+ return `{${colors.audio}-fg}[🎵 Аудио: ${escapeBlessed(track)} (${formatDuration(duration)}, ${size})]{/${colors.audio}-fg}`;
211
+ }
212
+ if (isVideo) {
213
+ return `{${colors.video}-fg}[📹 Видео (${formatDuration(duration)}, ${size})]{/${colors.video}-fg}`;
214
+ }
215
+ if (isSticker) {
216
+ return `{${colors.sticker}-fg}[🖼 ${escapeBlessed(fileName)}]{/${colors.sticker}-fg}`;
217
+ }
218
+ return `{${colors.document}-fg}[📄 Документ: ${escapeBlessed(fileName)} (${size})]{/${colors.document}-fg}`;
219
+ }
220
+ case "MessageMediaPoll": {
221
+ const poll = media.poll || {};
222
+ const question = poll.question?.text || poll.question || "Опрос";
223
+ return `{${colors.poll}-fg}[📊 Опрос: "${escapeBlessed(question)}"]{/${colors.poll}-fg}`;
224
+ }
225
+ case "MessageMediaGeo":
226
+ case "MessageMediaGeoLive": {
227
+ return `{${colors.geo}-fg}[📍 Геолокация]{/${colors.geo}-fg}`;
228
+ }
229
+ case "MessageMediaContact": {
230
+ const contact = media;
231
+ const name = [contact.firstName, contact.lastName].filter(Boolean).join(" ");
232
+ return `{${colors.contact}-fg}[👤 Контакт: ${escapeBlessed(name)} (${escapeBlessed(contact.phoneNumber || "")})]{/${colors.contact}-fg}`;
233
+ }
234
+ case "MessageMediaVenue": {
235
+ return `{${colors.venue}-fg}[🏢 Место: ${escapeBlessed(media.title || "")}]{/${colors.venue}-fg}`;
236
+ }
237
+ case "MessageMediaDice": {
238
+ return `{${colors.dice}-fg}[🎲 Игральная кость: ${media.value || ""} (${escapeBlessed(media.emoticon || "")})]{/${colors.dice}-fg}`;
239
+ }
240
+ case "MessageMediaWebPage": {
241
+ const page = media.webpage || {};
242
+ const title = page.title || page.displayUrl || page.url || "";
243
+ return title ? `{${colors.webpage}-fg}[🔗 Ссылка: ${escapeBlessed(title)}]{/${colors.webpage}-fg}` : "";
244
+ }
245
+ default:
246
+ return `{${colors.unknown}-fg}[Вложение: ${escapeBlessed(type)}]{/${colors.unknown}-fg}`;
247
+ }
248
+ }
@@ -0,0 +1,141 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { Api } from "teleproto";
3
+ import { NewMessage, EditedMessage, DeletedMessage, Raw } from "teleproto/events/index.js";
4
+ import { normalizeMessage } from "./messages.js";
5
+ import { idToString, toMarkedId, entityCache } from "./entities.js";
6
+
7
+ /**
8
+ * Приводит идентификатор чата из сырого апдейта к маркированному виду (как dialog.id).
9
+ * @param {object} update
10
+ * @returns {string}
11
+ */
12
+ function updateChatId(update) {
13
+ if (update.peer) return toMarkedId(update.peer);
14
+ if (update.channelId) return toMarkedId(new Api.PeerChannel({ channelId: update.channelId }));
15
+ if (update.chatId) return toMarkedId(new Api.PeerChat({ chatId: update.chatId }));
16
+ return "";
17
+ }
18
+
19
+ /**
20
+ * Запускает фоновое прослушивание живых событий Telegram и транслирует их в шину событий.
21
+ * @param {import("teleproto").TelegramClient} client
22
+ * @returns {EventEmitter & { stop: () => void }}
23
+ */
24
+ export function startTelegramListener(client) {
25
+ const bus = new EventEmitter();
26
+ bus.setMaxListeners(0);
27
+
28
+ const handlers = [];
29
+ const on = (handler, event) => {
30
+ client.addEventHandler(handler, event);
31
+ handlers.push(handler);
32
+ };
33
+
34
+ // 1. Новые сообщения
35
+ on(async (event) => {
36
+ try {
37
+ const msg = event.message;
38
+ if (!msg) return;
39
+ const normalized = normalizeMessage(msg);
40
+ const peerId = normalized.peerId;
41
+ const fromId = normalized.fromId;
42
+
43
+ // Кэшируем информацию об авторе сообщения, если доступна
44
+ try {
45
+ const sender = await event.getSender?.();
46
+ if (sender) {
47
+ entityCache.set(sender.id, sender);
48
+ normalized.senderName = sender.title || [sender.firstName, sender.lastName].filter(Boolean).join(" ") || normalized.senderName;
49
+ }
50
+ } catch {
51
+ // Игнорируем
52
+ }
53
+
54
+ bus.emit("new_message", {
55
+ peerId,
56
+ fromId,
57
+ message: normalized,
58
+ rawEvent: event,
59
+ });
60
+ } catch (err) {
61
+ bus.emit("error", err);
62
+ }
63
+ }, new NewMessage({}));
64
+
65
+ // 2. Изменённые сообщения
66
+ on(async (event) => {
67
+ try {
68
+ const msg = event.message;
69
+ if (!msg) return;
70
+ const normalized = normalizeMessage(msg);
71
+ bus.emit("edited_message", {
72
+ peerId: normalized.peerId,
73
+ message: normalized,
74
+ rawEvent: event,
75
+ });
76
+ } catch (err) {
77
+ bus.emit("error", err);
78
+ }
79
+ }, new EditedMessage({}));
80
+
81
+ // 3. Удалённые сообщения
82
+ on(async (event) => {
83
+ try {
84
+ bus.emit("deleted_messages", {
85
+ peerId: toMarkedId(event.chatId),
86
+ deletedIds: event.deletedIds || [],
87
+ rawEvent: event,
88
+ });
89
+ } catch (err) {
90
+ bus.emit("error", err);
91
+ }
92
+ }, new DeletedMessage({}));
93
+
94
+ // 4. Статус набора текста («печатает...») и другие апдейты
95
+ on(async (update) => {
96
+ try {
97
+ const className = update?.className;
98
+ if (!className) return;
99
+
100
+ if (className === "UpdateUserTyping" || className === "UpdateChatUserTyping" || className === "UpdateChannelUserTyping") {
101
+ const userId = idToString(update.userId || update.fromId?.userId);
102
+ // channelId/chatId приходят немаркированными — приводим к формату dialog.id
103
+ const chatId = updateChatId(update) || userId;
104
+ const action = update.action?.className || "SendMessageTypingAction";
105
+
106
+ bus.emit("typing", {
107
+ chatId,
108
+ userId,
109
+ action,
110
+ });
111
+ } else if (className === "UpdateReadHistoryInbox" || className === "UpdateReadChannelInbox") {
112
+ bus.emit("read_inbox", {
113
+ peerId: updateChatId(update),
114
+ maxId: update.maxId,
115
+ stillUnreadCount: update.stillUnreadCount || 0,
116
+ });
117
+ } else if (className === "UpdateReadHistoryOutbox" || className === "UpdateReadChannelOutbox") {
118
+ bus.emit("read_outbox", {
119
+ peerId: updateChatId(update),
120
+ maxId: update.maxId,
121
+ });
122
+ }
123
+ } catch (err) {
124
+ // Игнорируем ошибки парсинга сырых апдейтов
125
+ }
126
+ }, new Raw({}));
127
+
128
+ /** Останавливает все обработчики */
129
+ bus.stop = () => {
130
+ for (const handler of handlers) {
131
+ try {
132
+ client.removeEventHandler(handler);
133
+ } catch {
134
+ // Игнорируем
135
+ }
136
+ }
137
+ bus.removeAllListeners();
138
+ };
139
+
140
+ return bus;
141
+ }