@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.
- package/.env.example +29 -0
- package/LICENSE +21 -0
- package/README.md +364 -0
- package/bin/tuigram.js +2 -0
- package/package.json +73 -0
- package/src/cli/cliCommands.js +164 -0
- package/src/cli/formatters.js +72 -0
- package/src/cli/init.js +197 -0
- package/src/config.js +158 -0
- package/src/index.js +172 -0
- package/src/state.js +243 -0
- package/src/telegram/auth.js +146 -0
- package/src/telegram/client.js +96 -0
- package/src/telegram/dialogs.js +130 -0
- package/src/telegram/entities.js +158 -0
- package/src/telegram/formatter.js +248 -0
- package/src/telegram/listener.js +141 -0
- package/src/telegram/messages.js +265 -0
- package/src/ui/app.js +534 -0
- package/src/ui/components/chatList.js +295 -0
- package/src/ui/components/chatView.js +178 -0
- package/src/ui/components/header.js +82 -0
- package/src/ui/components/inputBox.js +221 -0
- package/src/ui/components/modals/actionModal.js +136 -0
- package/src/ui/components/modals/chatInfoModal.js +125 -0
- package/src/ui/components/modals/confirmModal.js +141 -0
- package/src/ui/components/modals/fileModal.js +355 -0
- package/src/ui/components/modals/filePickerModal.js +214 -0
- package/src/ui/components/modals/helpModal.js +131 -0
- package/src/ui/components/statusBar.js +64 -0
- package/src/ui/screen.js +60 -0
- package/src/ui/theme.js +223 -0
- package/src/utils/commands.js +40 -0
- package/src/utils/storage.js +177 -0
- package/src/utils/time.js +132 -0
|
@@ -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
|
+
}
|