@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/src/state.js ADDED
@@ -0,0 +1,243 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { filterDialogsByTab, searchDialogs } from "./telegram/dialogs.js";
3
+
4
+ /**
5
+ * Централизованное реактивное состояние TuiGram.
6
+ */
7
+ class AppState extends EventEmitter {
8
+ constructor() {
9
+ super();
10
+ this.me = null;
11
+ this.connectionStatus = "connecting"; // "connecting" | "connected" | "disconnected"
12
+
13
+ /** @type {Array<object>} */
14
+ this.dialogs = [];
15
+ this.currentFilterTab = "all"; // "all" | "users" | "groups" | "channels" | "bots" | "unread" | "archived"
16
+ this.searchQuery = "";
17
+ this.selectedDialogIndex = 0;
18
+
19
+ /** @type {object|null} */
20
+ this.activeChat = null;
21
+
22
+ /** @type {Map<string, Array<object>>} */
23
+ this.messagesByChat = new Map();
24
+
25
+ /** @type {Map<string, boolean>} */
26
+ this.hasMoreHistory = new Map();
27
+
28
+ /** @type {Map<string, { name: string, expiresAt: number }>} */
29
+ this.typingByChat = new Map();
30
+
31
+ /** Режим ответа на сообщение */
32
+ this.replyTarget = null;
33
+ /** Режим редактирования своего сообщения */
34
+ this.editTarget = null;
35
+
36
+ /** Индекс выбранного сообщения в ленте (для контекстного меню) */
37
+ this.selectedMessageIndex = -1;
38
+
39
+ /** Черновики ввода по ID чата */
40
+ this.draftsByChat = new Map();
41
+ }
42
+
43
+ /**
44
+ * Возвращает отфильтрованный список диалогов с учётом активной вкладки и поискового запроса.
45
+ * @returns {Array<object>}
46
+ */
47
+ getVisibleDialogs() {
48
+ const tabFiltered = filterDialogsByTab(this.dialogs, this.currentFilterTab);
49
+ if (!this.searchQuery) return tabFiltered;
50
+ return searchDialogs(tabFiltered, this.searchQuery);
51
+ }
52
+
53
+ /**
54
+ * Устанавливает список всех диалогов.
55
+ * @param {Array<object>} dialogs
56
+ */
57
+ setDialogs(dialogs) {
58
+ this.dialogs = dialogs;
59
+ this.emit("dialogs_updated", this.getVisibleDialogs());
60
+ }
61
+
62
+ /**
63
+ * Устанавливает статус подключения.
64
+ * @param {"connecting"|"connected"|"disconnected"} status
65
+ */
66
+ setConnectionStatus(status) {
67
+ this.connectionStatus = status;
68
+ this.emit("status_changed", status);
69
+ }
70
+
71
+ /**
72
+ * Переключает вкладку фильтрации диалогов.
73
+ * @param {"all"|"users"|"groups"|"channels"|"bots"|"unread"|"archived"} tab
74
+ */
75
+ setFilterTab(tab) {
76
+ this.currentFilterTab = tab;
77
+ this.selectedDialogIndex = 0;
78
+ this.emit("filter_changed", { tab, dialogs: this.getVisibleDialogs() });
79
+ }
80
+
81
+ /**
82
+ * Устанавливает строку поиска.
83
+ * @param {string} query
84
+ */
85
+ setSearchQuery(query) {
86
+ this.searchQuery = query;
87
+ this.selectedDialogIndex = 0;
88
+ this.emit("search_changed", { query, dialogs: this.getVisibleDialogs() });
89
+ }
90
+
91
+ /**
92
+ * Устанавливает активный открытый чат.
93
+ * @param {object} dialog
94
+ */
95
+ setActiveChat(dialog) {
96
+ this.activeChat = dialog;
97
+ this.replyTarget = null;
98
+ this.editTarget = null;
99
+ this.selectedMessageIndex = -1;
100
+ if (dialog) {
101
+ dialog.unreadCount = 0;
102
+ dialog.unreadMentionsCount = 0;
103
+ }
104
+ this.emit("active_chat_changed", dialog);
105
+ }
106
+
107
+ /**
108
+ * Получает список сообщений для указанного чата.
109
+ * @param {string} chatId
110
+ * @returns {Array<object>}
111
+ */
112
+ getMessages(chatId) {
113
+ return this.messagesByChat.get(chatId) || [];
114
+ }
115
+
116
+ /**
117
+ * Устанавливает или дополняет список сообщений для чата.
118
+ * @param {string} chatId
119
+ * @param {Array<object>} newMessages
120
+ * @param {boolean} [prepend=false] Если true — добавляет старые сообщения в начало
121
+ */
122
+ setMessages(chatId, newMessages, prepend = false) {
123
+ const existing = this.messagesByChat.get(chatId) || [];
124
+ let combined = [];
125
+
126
+ if (prepend) {
127
+ // Добавление старой истории в начало
128
+ const existingIds = new Set(existing.map((m) => m.id));
129
+ const uniqueOlder = newMessages.filter((m) => !existingIds.has(m.id));
130
+ combined = [...uniqueOlder, ...existing];
131
+ } else {
132
+ // Слияние новых сообщений
133
+ const map = new Map();
134
+ for (const m of existing) map.set(m.id, m);
135
+ for (const m of newMessages) map.set(m.id, m);
136
+ combined = Array.from(map.values());
137
+ }
138
+
139
+ // Сортировка сообщений по дате / ID (хронологически снизу вверх)
140
+ combined.sort((a, b) => (a.date || 0) - (b.date || 0) || (a.id - b.id));
141
+
142
+ this.messagesByChat.set(chatId, combined);
143
+ this.emit("messages_updated", { chatId, messages: combined, isPrepend: prepend });
144
+ }
145
+
146
+ /**
147
+ * Добавляет одно входящее или отправленное сообщение.
148
+ * @param {string} chatId
149
+ * @param {object} message
150
+ */
151
+ addMessage(chatId, message) {
152
+ const list = this.getMessages(chatId);
153
+ const exists = list.some((m) => m.id === message.id);
154
+ if (!exists) {
155
+ list.push(message);
156
+ list.sort((a, b) => (a.date || 0) - (b.date || 0) || (a.id - b.id));
157
+ this.messagesByChat.set(chatId, list);
158
+ }
159
+
160
+ // Обновляем последнее сообщение диалога в списке чатов
161
+ const dialog = this.dialogs.find((d) => d.id === chatId);
162
+ if (dialog) {
163
+ dialog.lastMessage = {
164
+ id: message.id,
165
+ date: message.date,
166
+ text: message.text,
167
+ out: message.out,
168
+ fromId: message.fromId,
169
+ mediaType: message.media?.className || null,
170
+ };
171
+ dialog.date = message.date;
172
+ if (this.activeChat?.id !== chatId && !message.out) {
173
+ dialog.unreadCount = (dialog.unreadCount || 0) + 1;
174
+ }
175
+ // Перемещаем чат наверх
176
+ this.dialogs.sort((a, b) => {
177
+ if (a.pinned && !b.pinned) return -1;
178
+ if (!a.pinned && b.pinned) return 1;
179
+ return (b.date || 0) - (a.date || 0);
180
+ });
181
+ this.emit("dialogs_updated", this.getVisibleDialogs());
182
+ }
183
+
184
+ this.emit("messages_updated", { chatId, messages: list, isPrepend: false });
185
+ }
186
+
187
+ /**
188
+ * Обновляет изменённое сообщение.
189
+ * @param {string} chatId
190
+ * @param {object} updatedMessage
191
+ */
192
+ updateMessage(chatId, updatedMessage) {
193
+ const list = this.getMessages(chatId);
194
+ const index = list.findIndex((m) => m.id === updatedMessage.id);
195
+ if (index !== -1) {
196
+ list[index] = { ...list[index], ...updatedMessage };
197
+ this.emit("messages_updated", { chatId, messages: list, isPrepend: false });
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Удаляет сообщения по их ID.
203
+ * @param {string} chatId
204
+ * @param {Array<number>} deletedIds
205
+ */
206
+ removeMessages(chatId, deletedIds) {
207
+ const list = this.getMessages(chatId);
208
+ const idSet = new Set(deletedIds);
209
+ const filtered = list.filter((m) => !idSet.has(m.id));
210
+ this.messagesByChat.set(chatId, filtered);
211
+ this.emit("messages_updated", { chatId, messages: filtered, isPrepend: false });
212
+ }
213
+
214
+ /**
215
+ * Устанавливает статус набора текста в чате.
216
+ * @param {string} chatId
217
+ * @param {string} userName
218
+ */
219
+ setTyping(chatId, userName) {
220
+ this.typingByChat.set(chatId, {
221
+ name: userName,
222
+ expiresAt: Date.now() + 5000,
223
+ });
224
+ this.emit("typing_changed", { chatId, userName });
225
+ }
226
+
227
+ /**
228
+ * Возвращает имя пользователя, который сейчас печатает в чате.
229
+ * @param {string} chatId
230
+ * @returns {string|null}
231
+ */
232
+ getTypingUser(chatId) {
233
+ const info = this.typingByChat.get(chatId);
234
+ if (!info) return null;
235
+ if (Date.now() > info.expiresAt) {
236
+ this.typingByChat.delete(chatId);
237
+ return null;
238
+ }
239
+ return info.name;
240
+ }
241
+ }
242
+
243
+ export const state = new AppState();
@@ -0,0 +1,146 @@
1
+ import input from "input";
2
+ import { buildClient, readSession, saveSession, clearSession } from "./client.js";
3
+ import { config } from "../config.js";
4
+ import { entityCache } from "./entities.js";
5
+
6
+ /**
7
+ * Проверяет текущий статус авторизации.
8
+ * @returns {Promise<{ authorized: boolean, me: object|null, client: any|null }>}
9
+ */
10
+ export async function checkAuthStatus() {
11
+ const session = readSession();
12
+ if (!session) {
13
+ return { authorized: false, me: null, client: null };
14
+ }
15
+
16
+ try {
17
+ const client = buildClient(session);
18
+ await client.connect();
19
+ const isAuth = await client.isUserAuthorized();
20
+ if (isAuth) {
21
+ const me = await client.getMe();
22
+ if (me) {
23
+ entityCache.set(me.id, me);
24
+ }
25
+ return { authorized: true, me, client };
26
+ }
27
+ await client.disconnect().catch(() => {});
28
+ return { authorized: false, me: null, client: null };
29
+ } catch {
30
+ return { authorized: false, me: null, client: null };
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Проверяет, что интерактивный ввод вообще возможен.
36
+ *
37
+ * Без TTY промис `input.text` не резолвится никогда: teleproto молча получает
38
+ * пустой код (см. свой же try/catch вокруг `authParams.phoneCode`), а процесс
39
+ * выходит с кодом 0, так и не авторизовавшись. Отсекаем это заранее — до
40
+ * `client.start`, чтобы ошибка не превратилась внутри библиотеки
41
+ * в невнятный AUTH_USER_CANCEL.
42
+ *
43
+ * @param {object} [callbacks] пользовательские промпты — с ними терминал не нужен
44
+ * @param {boolean} [isTty] состояние stdin (параметром — ради тестируемости)
45
+ */
46
+ export function assertInteractiveInput(callbacks = {}, isTty = process.stdin.isTTY) {
47
+ const covered =
48
+ Boolean(callbacks.getPhoneNumber) &&
49
+ Boolean(callbacks.getPhoneCode) &&
50
+ Boolean(callbacks.getPassword);
51
+
52
+ if (covered || isTty) return;
53
+
54
+ throw new Error(
55
+ "Авторизация требует интерактивного терминала (stdin не подключён к TTY).\n" +
56
+ " • Запустите tuigram login вручную в терминале.\n" +
57
+ " • Либо перенесите готовый session.txt в директорию данных\n" +
58
+ ` (${config.sessionPath}) — путь показывает команда tuigram paths.`
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Интерактивный CLI-процесс авторизации.
64
+ * @param {object} [callbacks]
65
+ * @param {() => Promise<string>} [callbacks.getPhoneNumber]
66
+ * @param {() => Promise<string>} [callbacks.getPassword]
67
+ * @param {() => Promise<string>} [callbacks.getPhoneCode]
68
+ * @param {(err: Error) => boolean} [callbacks.onError]
69
+ * @returns {Promise<object>} возвращает объект авторизованного пользователя
70
+ */
71
+ export async function loginInteractive(callbacks = {}) {
72
+ config.assertCredentials();
73
+
74
+ const existing = readSession();
75
+ if (existing) {
76
+ try {
77
+ const client = buildClient(existing);
78
+ await client.connect();
79
+ if (await client.isUserAuthorized()) {
80
+ const me = await client.getMe();
81
+ console.log(`\nВы уже авторизованы как: ${me.firstName || ""} ${me.lastName || ""} (@${me.username || "без username"}), id=${me.id}`);
82
+ // Без терминала спрашивать некого: действующая сессия — рабочий
83
+ // результат, менять аккаунт молча мы не вправе.
84
+ const reLogin = process.stdin.isTTY
85
+ ? await input.confirm("Хотите войти под другим аккаунтом?", { default: false })
86
+ : false;
87
+ if (!reLogin) {
88
+ await client.disconnect().catch(() => {});
89
+ return me;
90
+ }
91
+ }
92
+ await client.disconnect().catch(() => {});
93
+ } catch {
94
+ // Сессия недействительна — продолжаем логин
95
+ }
96
+ }
97
+
98
+ // Дальше без диалога не обойтись: телефон, код из Telegram и, возможно, 2FA.
99
+ assertInteractiveInput(callbacks);
100
+
101
+ const client = buildClient("");
102
+
103
+ const phonePrompt = callbacks.getPhoneNumber || (async () => await input.text("Введите номер телефона (+79991234567): "));
104
+ const passPrompt = callbacks.getPassword || (async () => await input.password("Введите пароль двухфакторной аутентификации (2FA): "));
105
+ const codePrompt = callbacks.getPhoneCode || (async () => await input.text("Введите код подтверждения из Telegram: "));
106
+
107
+ await client.start({
108
+ phoneNumber: phonePrompt,
109
+ password: passPrompt,
110
+ phoneCode: codePrompt,
111
+ onError: callbacks.onError || ((err) => {
112
+ console.error("Ошибка входа:", err?.message || err);
113
+ return true;
114
+ }),
115
+ });
116
+
117
+ const sessionString = client.session.save();
118
+ saveSession(sessionString);
119
+
120
+ const me = await client.getMe();
121
+ if (me) {
122
+ entityCache.set(me.id, me);
123
+ }
124
+
125
+ console.log(`\nУспешный вход! ${me.firstName || ""} ${me.lastName || ""} (@${me.username || "нет"}), ID: ${me.id}`);
126
+ console.log(`Сессия сохранена в ${config.sessionPath}`);
127
+
128
+ await client.disconnect().catch(() => {});
129
+ return me;
130
+ }
131
+
132
+ /**
133
+ * Завершает сеанс и удаляет локальную сессию.
134
+ * @param {import("teleproto").TelegramClient} [client]
135
+ */
136
+ export async function logout(client) {
137
+ if (client) {
138
+ try {
139
+ await client.logOut();
140
+ } catch {
141
+ // Игнорируем сетевые ошибки логаута
142
+ }
143
+ await client.disconnect().catch(() => {});
144
+ }
145
+ clearSession();
146
+ }
@@ -0,0 +1,96 @@
1
+ import fs from "node:fs";
2
+ import { TelegramClient } from "teleproto";
3
+ import { StringSession } from "teleproto/sessions/index.js";
4
+ import { Logger, LogLevel } from "teleproto/extensions/Logger.js";
5
+ import { config } from "../config.js";
6
+ import { saveSessionFile, readFileSafe } from "../utils/storage.js";
7
+
8
+ /**
9
+ * Читает сохранённую строку сессии.
10
+ * @returns {string}
11
+ */
12
+ export function readSession() {
13
+ return readFileSafe(config.sessionPath, "").trim();
14
+ }
15
+
16
+ /**
17
+ * Сохраняет строку сессии.
18
+ * @param {string} sessionString
19
+ */
20
+ export function saveSession(sessionString) {
21
+ saveSessionFile(config.sessionPath, sessionString);
22
+ }
23
+
24
+ /**
25
+ * Удаляет файл локальной сессии.
26
+ */
27
+ export function clearSession() {
28
+ if (fs.existsSync(config.sessionPath)) {
29
+ try {
30
+ fs.unlinkSync(config.sessionPath);
31
+ } catch {
32
+ // Игнорируем ошибку удаления
33
+ }
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Создаёт экземпляр клиента MTProto.
39
+ * @param {string} [sessionString]
40
+ * @returns {TelegramClient}
41
+ */
42
+ export function buildClient(sessionString = readSession()) {
43
+ config.assertCredentials();
44
+
45
+ const client = new TelegramClient(
46
+ new StringSession(sessionString),
47
+ config.apiId,
48
+ config.apiHash,
49
+ {
50
+ connectionRetries: 10,
51
+ autoReconnect: true,
52
+ retryDelay: 1500,
53
+ baseLogger: new Logger(LogLevel.ERROR),
54
+ useWSS: false,
55
+ }
56
+ );
57
+
58
+ return client;
59
+ }
60
+
61
+ /**
62
+ * Подключает и проверяет авторизацию клиента.
63
+ * @returns {Promise<TelegramClient>}
64
+ */
65
+ export async function connectClient() {
66
+ const session = readSession();
67
+ if (!session) {
68
+ throw new Error("Сессия не найдена. Требуется авторизация: запустите логин.");
69
+ }
70
+
71
+ const client = buildClient(session);
72
+ await client.connect();
73
+
74
+ const isAuth = await client.isUserAuthorized();
75
+ if (!isAuth) {
76
+ throw new Error("Сессия недействительна или была отозвана. Требуется повторный логин.");
77
+ }
78
+
79
+ return client;
80
+ }
81
+
82
+ /**
83
+ * Выполняет действие с клиентом и корректно закрывает соединение после.
84
+ * @template T
85
+ * @param {(client: TelegramClient) => Promise<T>} fn
86
+ * @returns {Promise<T>}
87
+ */
88
+ export async function withClient(fn) {
89
+ const client = await connectClient();
90
+ try {
91
+ return await fn(client);
92
+ } finally {
93
+ await client.disconnect().catch(() => {});
94
+ await client.destroy?.().catch(() => {});
95
+ }
96
+ }
@@ -0,0 +1,130 @@
1
+ import { idToString, toMarkedId, detectChatType, getEntityDisplayName, entityCache } from "./entities.js";
2
+ import { describeMedia } from "./formatter.js";
3
+
4
+ /**
5
+ * Преобразует объект Dialog из библиотеки в плоскую структуру для UI.
6
+ * @param {object} dialog
7
+ * @returns {object}
8
+ */
9
+ export function normalizeDialog(dialog) {
10
+ const entity = dialog.entity || {};
11
+ const message = dialog.message;
12
+ const type = detectChatType(dialog);
13
+ const title = getEntityDisplayName(entity) || dialog.title || dialog.name || "Чат";
14
+
15
+ if (entity.id) {
16
+ entityCache.set(entity.id, entity);
17
+ }
18
+
19
+ let lastMessageText = "";
20
+ if (message) {
21
+ if (message.message) {
22
+ lastMessageText = message.message;
23
+ } else if (message.media) {
24
+ // Срезаем разметку blessed: цвета теперь hex, поэтому "#" обязателен
25
+ // в классе символов, иначе превью показывало литеральное "{#e0af68-fg}"
26
+ lastMessageText = describeMedia(message.media).replace(/\{\/?[#\w-]+\}/g, "");
27
+ }
28
+ }
29
+
30
+ return {
31
+ id: toMarkedId(dialog.id),
32
+ peerId: dialog.id,
33
+ type,
34
+ title,
35
+ username: entity.username || null,
36
+ pinned: Boolean(dialog.pinned),
37
+ archived: Boolean(dialog.archived),
38
+ unreadCount: dialog.unreadCount || 0,
39
+ unreadMentionsCount: dialog.unreadMentionsCount || 0,
40
+ folderId: dialog.folderId || 0,
41
+ date: dialog.date ? dialog.date * 1000 : (message?.date ? message.date * 1000 : Date.now()),
42
+ isMuted: Boolean(dialog.dialog?.notifySettings?.muteUntil > 0),
43
+ lastMessage: message ? {
44
+ id: message.id,
45
+ date: message.date ? message.date * 1000 : Date.now(),
46
+ text: lastMessageText,
47
+ out: Boolean(message.out),
48
+ fromId: idToString(message.fromId?.userId || message.fromId?.channelId || message.fromId?.chatId),
49
+ mediaType: message.media?.className || null,
50
+ } : null,
51
+ entity,
52
+ rawDialog: dialog,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Загружает список всех диалогов пользователя.
58
+ * @param {import("teleproto").TelegramClient} client
59
+ * @param {object} [options]
60
+ * @param {number} [options.limit=100]
61
+ * @param {boolean} [options.archived]
62
+ * @returns {Promise<Array<object>>}
63
+ */
64
+ export async function fetchDialogs(client, { limit = 100, archived } = {}) {
65
+ const params = { limit };
66
+ if (typeof archived === "boolean") {
67
+ params.archived = archived;
68
+ }
69
+
70
+ const dialogs = [];
71
+ for await (const dialog of client.iterDialogs(params)) {
72
+ dialogs.push(normalizeDialog(dialog));
73
+ }
74
+
75
+ // Сортировка: сначала закреплённые, затем по дате последнего сообщения
76
+ dialogs.sort((a, b) => {
77
+ if (a.pinned && !b.pinned) return -1;
78
+ if (!a.pinned && b.pinned) return 1;
79
+ return (b.date || 0) - (a.date || 0);
80
+ });
81
+
82
+ return dialogs;
83
+ }
84
+
85
+ /**
86
+ * Фильтрует список диалогов по выбранной категории.
87
+ * @param {Array<object>} dialogs
88
+ * @param {"all"|"users"|"groups"|"channels"|"bots"|"unread"|"archived"} filterTab
89
+ * @returns {Array<object>}
90
+ */
91
+ export function filterDialogsByTab(dialogs, filterTab = "all") {
92
+ if (!dialogs) return [];
93
+
94
+ switch (filterTab) {
95
+ case "users":
96
+ return dialogs.filter((d) => !d.archived && (d.type === "user" || d.type === "saved"));
97
+ case "groups":
98
+ return dialogs.filter((d) => !d.archived && (d.type === "group" || d.type === "supergroup"));
99
+ case "channels":
100
+ return dialogs.filter((d) => !d.archived && d.type === "channel");
101
+ case "bots":
102
+ return dialogs.filter((d) => !d.archived && d.type === "bot");
103
+ case "unread":
104
+ return dialogs.filter((d) => !d.archived && (d.unreadCount > 0 || d.unreadMentionsCount > 0));
105
+ case "archived":
106
+ return dialogs.filter((d) => d.archived);
107
+ case "all":
108
+ default:
109
+ return dialogs.filter((d) => !d.archived);
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Выполняет поиск по списку диалогов по строке запроса.
115
+ * @param {Array<object>} dialogs
116
+ * @param {string} query
117
+ * @returns {Array<object>}
118
+ */
119
+ export function searchDialogs(dialogs, query) {
120
+ if (!query || !query.trim()) return dialogs;
121
+ const q = query.trim().toLowerCase();
122
+
123
+ return dialogs.filter((d) => {
124
+ const titleMatch = d.title && d.title.toLowerCase().includes(q);
125
+ const usernameMatch = d.username && d.username.toLowerCase().includes(q);
126
+ const idMatch = d.id && d.id.includes(q);
127
+ const lastMsgMatch = d.lastMessage?.text && d.lastMessage.text.toLowerCase().includes(q);
128
+ return titleMatch || usernameMatch || idMatch || lastMsgMatch;
129
+ });
130
+ }