@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,265 @@
1
+ import { Api, errors } from "teleproto";
2
+ import { idToString, toMarkedId, getEntityDisplayName, entityCache, resolveEntity } from "./entities.js";
3
+ import { describeMedia } from "./formatter.js";
4
+
5
+ const { FloodWaitError } = errors;
6
+
7
+ /**
8
+ * Преобразует объект Message из MTProto в нормализованный объект для TUI.
9
+ * @param {object} message
10
+ * @returns {object}
11
+ */
12
+ export function normalizeMessage(message) {
13
+ if (!message) return null;
14
+
15
+ // fromId остаётся немаркированным: по нему ищется сущность в entityCache,
16
+ // куда объекты кладутся по entity.id (тоже без маркера).
17
+ const fromId = idToString(message.fromId?.userId || message.fromId?.channelId || message.fromId?.chatId);
18
+ // peerId маркируется, чтобы совпадать с dialog.id (см. toMarkedId).
19
+ const peerId = toMarkedId(message.peerId);
20
+
21
+ // Извлекаем имя отправителя из кэша сущностей, если доступно
22
+ let senderName = message.out ? "Вы" : "Собеседник";
23
+ const cachedSender = entityCache.get(fromId);
24
+ if (cachedSender) {
25
+ senderName = getEntityDisplayName(cachedSender);
26
+ }
27
+
28
+ // Реакции
29
+ const reactions = [];
30
+ if (message.reactions?.results) {
31
+ for (const r of message.reactions.results) {
32
+ const emoticon = r.reaction?.emoticon || (r.reaction?.className === "ReactionCustomEmoji" ? "✨" : "👍");
33
+ reactions.push({
34
+ emoticon,
35
+ count: r.count || 1,
36
+ chosen: Boolean(r.chosenOrder),
37
+ });
38
+ }
39
+ }
40
+
41
+ return {
42
+ id: message.id,
43
+ date: message.date ? message.date * 1000 : Date.now(),
44
+ editDate: message.editDate ? message.editDate * 1000 : null,
45
+ out: Boolean(message.out),
46
+ text: message.message || "",
47
+ fromId,
48
+ senderName,
49
+ peerId,
50
+ replyToMsgId: message.replyTo?.replyToMsgId || null,
51
+ pinned: Boolean(message.pinned),
52
+ views: message.views || null,
53
+ forwards: message.forwards || null,
54
+ media: message.media || null,
55
+ mediaDescription: describeMedia(message.media),
56
+ entities: message.entities || [],
57
+ reactions,
58
+ rawMessage: message,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Загружает историю сообщений чата с защитой от FloodWait.
64
+ * @param {import("teleproto").TelegramClient} client
65
+ * @param {string|number|bigint} rawPeer
66
+ * @param {object} [options]
67
+ * @param {number} [options.limit=40]
68
+ * @param {number} [options.offsetId=0]
69
+ * @param {boolean} [options.reverse=false]
70
+ * @returns {Promise<{ peer: object, messages: Array<object> }>}
71
+ */
72
+ export async function fetchHistory(client, rawPeer, { limit = 40, offsetId = 0, reverse = false } = {}) {
73
+ const entity = await resolveEntity(client, rawPeer);
74
+ const messages = [];
75
+
76
+ const load = async () => {
77
+ for await (const msg of client.iterMessages(entity, { limit, offsetId, reverse })) {
78
+ if (msg && msg.className !== "MessageEmpty") {
79
+ messages.push(normalizeMessage(msg));
80
+ }
81
+ }
82
+ };
83
+
84
+ try {
85
+ await load();
86
+ } catch (err) {
87
+ if (err instanceof FloodWaitError || typeof err?.seconds === "number") {
88
+ const wait = (err.seconds || 5) + 1;
89
+ await new Promise((resolve) => setTimeout(resolve, wait * 1000));
90
+ messages.length = 0;
91
+ await load();
92
+ } else {
93
+ throw err;
94
+ }
95
+ }
96
+
97
+ return {
98
+ peer: entity,
99
+ messages,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Отправляет текстовое сообщение в чат.
105
+ * @param {import("teleproto").TelegramClient} client
106
+ * @param {string|number|bigint} rawPeer
107
+ * @param {string} text
108
+ * @param {object} [options]
109
+ * @param {number} [options.replyTo] ID сообщения, на которое отвечаем
110
+ * @returns {Promise<object>}
111
+ */
112
+ export async function sendMessage(client, rawPeer, text, { replyTo } = {}) {
113
+ const entity = await resolveEntity(client, rawPeer);
114
+ const params = {
115
+ message: text,
116
+ };
117
+
118
+ if (replyTo) {
119
+ params.replyTo = replyTo;
120
+ }
121
+
122
+ const sent = await client.sendMessage(entity, params);
123
+ return normalizeMessage(sent);
124
+ }
125
+
126
+ /**
127
+ * Редактирует существующее текстовое сообщение.
128
+ * @param {import("teleproto").TelegramClient} client
129
+ * @param {string|number|bigint} rawPeer
130
+ * @param {number} messageId
131
+ * @param {string} newText
132
+ * @returns {Promise<object>}
133
+ */
134
+ export async function editMessage(client, rawPeer, messageId, newText) {
135
+ const entity = await resolveEntity(client, rawPeer);
136
+ const edited = await client.editMessage(entity, {
137
+ message: messageId,
138
+ text: newText,
139
+ });
140
+ return normalizeMessage(edited);
141
+ }
142
+
143
+ /**
144
+ * Удаляет сообщения в чате.
145
+ * @param {import("teleproto").TelegramClient} client
146
+ * @param {string|number|bigint} rawPeer
147
+ * @param {Array<number>} messageIds
148
+ * @param {object} [options]
149
+ * @param {boolean} [options.revoke=true] Удалить для всех участников
150
+ */
151
+ export async function deleteMessages(client, rawPeer, messageIds, { revoke = true } = {}) {
152
+ const entity = await resolveEntity(client, rawPeer);
153
+ return await client.deleteMessages(entity, messageIds, { revoke });
154
+ }
155
+
156
+ /** Максимум вложений в одном альбоме Telegram. */
157
+ export const ALBUM_LIMIT = 10;
158
+
159
+ /**
160
+ * Отправляет один или несколько файлов. Массив уходит альбомом (media group).
161
+ * @param {import("teleproto").TelegramClient} client
162
+ * @param {string|number|bigint} rawPeer
163
+ * @param {string|Buffer|Array<string|Buffer>} files
164
+ * @param {object} [options]
165
+ * @param {string} [options.caption=""]
166
+ * @param {number} [options.replyTo]
167
+ * @param {boolean} [options.forceDocument=false] отправить без сжатия, файлом
168
+ * @param {(progress: number) => void} [options.progressCallback] прогресс 0..1
169
+ * @returns {Promise<Array<object>>} нормализованные отправленные сообщения
170
+ */
171
+ export async function sendFiles(client, rawPeer, files, { caption = "", replyTo, forceDocument = false, progressCallback } = {}) {
172
+ const list = Array.isArray(files) ? files : [files];
173
+ if (list.length === 0) {
174
+ throw new Error("Не указан ни один файл для отправки");
175
+ }
176
+ if (list.length > ALBUM_LIMIT) {
177
+ throw new Error(`За раз можно отправить не больше ${ALBUM_LIMIT} файлов (передано ${list.length})`);
178
+ }
179
+
180
+ const entity = await resolveEntity(client, rawPeer);
181
+ const params = {
182
+ // Один файл передаём как есть: массив из одного элемента увёл бы teleproto
183
+ // в _sendAlbum и Telegram получил бы медиагруппу вместо обычного фото.
184
+ file: list.length === 1 ? list[0] : list,
185
+ caption,
186
+ forceDocument,
187
+ progressCallback,
188
+ };
189
+ if (replyTo) {
190
+ params.replyTo = replyTo;
191
+ }
192
+
193
+ // При массиве teleproto уходит в _sendAlbum и возвращает массив сообщений
194
+ const sent = await client.sendFile(entity, params);
195
+ return (Array.isArray(sent) ? sent : [sent]).filter(Boolean).map(normalizeMessage);
196
+ }
197
+
198
+ /**
199
+ * Отправляет один файл / медиа в чат.
200
+ * @param {import("teleproto").TelegramClient} client
201
+ * @param {string|number|bigint} rawPeer
202
+ * @param {string|Buffer} file
203
+ * @param {object} [options] см. {@link sendFiles}
204
+ * @returns {Promise<object>}
205
+ */
206
+ export async function sendFile(client, rawPeer, file, options = {}) {
207
+ const sent = await sendFiles(client, rawPeer, file, options);
208
+ return sent[0];
209
+ }
210
+
211
+ /**
212
+ * Скачивает медиа-вложение сообщения на локальный диск.
213
+ * @param {import("teleproto").TelegramClient} client
214
+ * @param {object} rawMessage
215
+ * @param {object} [options]
216
+ * @param {string} [options.outputFile]
217
+ * @param {(progress: number) => void} [options.progressCallback]
218
+ * @returns {Promise<string|Buffer>}
219
+ */
220
+ export async function downloadMedia(client, rawMessage, { outputFile, progressCallback } = {}) {
221
+ if (!rawMessage?.media) {
222
+ throw new Error("У сообщения нет медиа-вложения для скачивания.");
223
+ }
224
+ return await client.downloadMedia(rawMessage.media, {
225
+ outputFile,
226
+ progressCallback,
227
+ });
228
+ }
229
+
230
+ /**
231
+ * Отправляет реакцию (эмодзи) на сообщение.
232
+ * @param {import("teleproto").TelegramClient} client
233
+ * @param {string|number|bigint} rawPeer
234
+ * @param {number} messageId
235
+ * @param {string} [emoji="👍"]
236
+ */
237
+ export async function sendReaction(client, rawPeer, messageId, emoji = "👍") {
238
+ const entity = await resolveEntity(client, rawPeer);
239
+ return await client.invoke(
240
+ new Api.messages.SendReaction({
241
+ peer: entity,
242
+ msgId: messageId,
243
+ reaction: [new Api.ReactionEmoji({ emoticon: emoji })],
244
+ })
245
+ );
246
+ }
247
+
248
+ /**
249
+ * Отмечает сообщения в чате прочитанными.
250
+ * @param {import("teleproto").TelegramClient} client
251
+ * @param {string|number|bigint} rawPeer
252
+ * @param {number} [maxId=0]
253
+ */
254
+ export async function markAsRead(client, rawPeer, maxId = 0) {
255
+ const entity = await resolveEntity(client, rawPeer);
256
+ try {
257
+ if (maxId > 0) {
258
+ await client.sendReadAcknowledge(entity, { maxId });
259
+ } else {
260
+ await client.markAsRead(entity);
261
+ }
262
+ } catch {
263
+ // Игнорируем незначительные сетевые ошибки прочтения
264
+ }
265
+ }