@emaxe/tuigram 1.0.1 → 1.2.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,224 @@
1
+ /**
2
+ * Сетевой транспорт для MTProto с поддержкой прокси (HTTP CONNECT, SOCKS5, SOCKS4).
3
+ */
4
+ import http from "node:http";
5
+ import https from "node:https";
6
+ import net from "node:net";
7
+ import { PromisedNetSockets } from "teleproto/extensions/PromisedNetSockets.js";
8
+ import { SocksClient } from "socks";
9
+
10
+ /**
11
+ * Создаёт TCP-туннель через HTTP/HTTPS прокси методом HTTP CONNECT.
12
+ * Поддерживает как прокси с Basic-авторизацией, так и без неё.
13
+ * @param {object} params
14
+ * @param {boolean} [params.isHttps=false] подключение к прокси-серверу по TLS
15
+ * @param {string} params.proxyHost адрес прокси-сервера
16
+ * @param {number} params.proxyPort порт прокси-сервера
17
+ * @param {string} params.targetHost адрес целевого сервера Telegram MTProto
18
+ * @param {number} params.targetPort порт целевого сервера Telegram MTProto
19
+ * @param {string} [params.username] логин для авторизации на прокси
20
+ * @param {string} [params.password] пароль для авторизации на прокси
21
+ * @param {number} [params.timeout=10000] таймаут установки соединения в миллисекундах
22
+ * @returns {Promise<import("node:net").Socket>}
23
+ */
24
+ export function createHttpConnectSocket({
25
+ isHttps = false,
26
+ proxyHost,
27
+ proxyPort,
28
+ targetHost,
29
+ targetPort,
30
+ username,
31
+ password,
32
+ timeout = 10000,
33
+ }) {
34
+ return new Promise((resolve, reject) => {
35
+ const clientModule = isHttps ? https : http;
36
+
37
+ const headers = {
38
+ Host: `${targetHost}:${targetPort}`,
39
+ "User-Agent": "TuiGram",
40
+ "Proxy-Connection": "Keep-Alive",
41
+ };
42
+
43
+ if (username || password) {
44
+ const auth = Buffer.from(`${username || ""}:${password || ""}`).toString("base64");
45
+ headers["Proxy-Authorization"] = `Basic ${auth}`;
46
+ }
47
+
48
+ const req = clientModule.request({
49
+ host: proxyHost,
50
+ port: proxyPort,
51
+ method: "CONNECT",
52
+ path: `${targetHost}:${targetPort}`,
53
+ headers,
54
+ timeout,
55
+ });
56
+
57
+ let finished = false;
58
+
59
+ const cleanup = () => {
60
+ req.removeAllListeners();
61
+ };
62
+
63
+ req.on("connect", (res, socket, head) => {
64
+ if (finished) return;
65
+ finished = true;
66
+ cleanup();
67
+
68
+ if (res.statusCode >= 200 && res.statusCode < 300) {
69
+ // Если в заголовке ответа уже были данные следующего протокола, возвращаем их в сокет
70
+ if (head && head.length > 0) {
71
+ socket.unshift(head);
72
+ }
73
+ resolve(socket);
74
+ } else if (res.statusCode === 407) {
75
+ socket.destroy();
76
+ reject(new Error("Ошибка авторизации на HTTP-прокси (407 Proxy Authentication Required)"));
77
+ } else {
78
+ socket.destroy();
79
+ reject(new Error(`HTTP-прокси вернул код ошибки: ${res.statusCode} ${res.statusMessage || ""}`.trim()));
80
+ }
81
+ });
82
+
83
+ req.on("response", (res) => {
84
+ if (finished) return;
85
+ finished = true;
86
+ cleanup();
87
+ if (res.statusCode === 407) {
88
+ reject(new Error("Ошибка авторизации на HTTP-прокси (407 Proxy Authentication Required)"));
89
+ } else {
90
+ reject(new Error(`HTTP-прокси вернул код ошибки: ${res.statusCode} ${res.statusMessage || ""}`.trim()));
91
+ }
92
+ });
93
+
94
+ req.on("timeout", () => {
95
+ if (finished) return;
96
+ finished = true;
97
+ cleanup();
98
+ req.destroy(new Error(`Таймаут подключения к HTTP-прокси (${proxyHost}:${proxyPort})`));
99
+ });
100
+
101
+ req.on("error", (err) => {
102
+ if (finished) return;
103
+ finished = true;
104
+ cleanup();
105
+ reject(new Error(`Не удалось подключиться к HTTP-прокси (${proxyHost}:${proxyPort}): ${err.message}`));
106
+ });
107
+
108
+ req.end();
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Расширенный сокет для teleproto с поддержкой HTTP CONNECT и SOCKS5/4 туннелирования.
114
+ */
115
+ export class TuiGramNetSockets extends PromisedNetSockets {
116
+ /**
117
+ * @param {object} [proxy]
118
+ * @param {number} [keepAliveInterval]
119
+ */
120
+ constructor(proxy, keepAliveInterval) {
121
+ // Передаём undefined в базовый класс, чтобы обойти валидацию socksType для HTTP-прокси
122
+ super(undefined, keepAliveInterval);
123
+ this.proxy = proxy;
124
+ }
125
+
126
+ /**
127
+ * Устанавливает сетевое соединение с дата-центром Telegram.
128
+ * @param {number} port
129
+ * @param {string} ip
130
+ * @returns {Promise<this>}
131
+ */
132
+ async connect(port, ip) {
133
+ this.chunks = [];
134
+ this.headOffset = 0;
135
+ this.available = 0;
136
+ let connected = false;
137
+
138
+ if (this.proxy) {
139
+ const proxyType = (this.proxy.type || "").toLowerCase();
140
+ const host = this.proxy.host || this.proxy.ip;
141
+ const proxyPort = Number(this.proxy.port);
142
+ const timeout = (this.proxy.timeout || 10) * 1000;
143
+
144
+ if (proxyType === "socks5" || proxyType === "socks4" || this.proxy.socksType) {
145
+ const socksType = this.proxy.socksType || (proxyType === "socks4" ? 4 : 5);
146
+ const info = await SocksClient.createConnection({
147
+ proxy: {
148
+ host,
149
+ port: proxyPort,
150
+ type: socksType,
151
+ userId: this.proxy.username,
152
+ password: this.proxy.password,
153
+ },
154
+ command: "connect",
155
+ timeout,
156
+ destination: {
157
+ host: ip,
158
+ port: port,
159
+ },
160
+ });
161
+ this.client = info.socket;
162
+ connected = true;
163
+ } else if (proxyType === "http" || proxyType === "https" || this.proxy.http) {
164
+ this.client = await createHttpConnectSocket({
165
+ isHttps: proxyType === "https",
166
+ proxyHost: host,
167
+ proxyPort: proxyPort,
168
+ targetHost: ip,
169
+ targetPort: port,
170
+ username: this.proxy.username,
171
+ password: this.proxy.password,
172
+ timeout,
173
+ });
174
+ connected = true;
175
+ } else {
176
+ throw new Error(`Неподдерживаемый тип прокси: ${this.proxy.type}`);
177
+ }
178
+ } else {
179
+ this.client = new net.Socket();
180
+ }
181
+
182
+ this.canRead = new Promise((resolve) => {
183
+ this.resolveRead = resolve;
184
+ });
185
+ this.closed = false;
186
+
187
+ return new Promise((resolve, reject) => {
188
+ if (!this.client) {
189
+ return reject(new Error("Сетевой сокет не инициализирован"));
190
+ }
191
+
192
+ const tune = (socket) => {
193
+ socket.setNoDelay(true);
194
+ socket.setKeepAlive(this.keepAliveInterval > 0, Math.max(0, this.keepAliveInterval));
195
+ };
196
+
197
+ if (connected) {
198
+ tune(this.client);
199
+ this.receive();
200
+ resolve(this);
201
+ } else {
202
+ this.client.connect(port, ip, () => {
203
+ tune(this.client);
204
+ this.receive();
205
+ resolve(this);
206
+ });
207
+ }
208
+
209
+ this.client.on("error", reject);
210
+ this.client.on("close", () => {
211
+ if (this.client && this.client.destroyed) {
212
+ if (this.resolveRead) {
213
+ this.resolveRead(false);
214
+ }
215
+ this.closed = true;
216
+ }
217
+ });
218
+ });
219
+ }
220
+
221
+ toString() {
222
+ return "TuiGramNetSocket";
223
+ }
224
+ }
package/src/ui/app.js CHANGED
@@ -16,7 +16,7 @@ import { state } from "../state.js";
16
16
  import { config } from "../config.js";
17
17
  import { fetchDialogs } from "../telegram/dialogs.js";
18
18
  import { setMessagePalette } from "../telegram/formatter.js";
19
- import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead } from "../telegram/messages.js";
19
+ import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead, loadMessageImagePreview } from "../telegram/messages.js";
20
20
  import { startTelegramListener } from "../telegram/listener.js";
21
21
  import { logout } from "../telegram/auth.js";
22
22
  import { ensureDir, inspectLocalFile } from "../utils/storage.js";
@@ -128,6 +128,7 @@ export async function startTui(client, me) {
128
128
  state.setMessages(dialog.id, history.messages);
129
129
  markAsRead(client, dialog.peerId).catch(() => {});
130
130
  statusBar.showMessage(`Чат: ${dialog.title}`, "info");
131
+ fetchMissingImagePreviews(history.messages, dialog.id);
131
132
  } catch (err) {
132
133
  statusBar.showMessage(`Ошибка загрузки: ${err.message}`, "error");
133
134
  }
@@ -157,6 +158,7 @@ export async function startTui(client, me) {
157
158
  if (older.messages.length > 0) {
158
159
  state.setMessages(state.activeChat.id, older.messages, true);
159
160
  statusBar.showMessage(`Загружено ${older.messages.length} предыдущих сообщений`, "info");
161
+ fetchMissingImagePreviews(older.messages, state.activeChat.id);
160
162
  }
161
163
  } catch (err) {
162
164
  statusBar.showMessage(`Ошибка пагинации: ${err.message}`, "error");
@@ -291,9 +293,13 @@ export async function startTui(client, me) {
291
293
  chatView.setMessages(msgs);
292
294
  });
293
295
 
294
- state.on("messages_updated", ({ chatId, messages, isPrepend }) => {
296
+ state.on("messages_updated", ({ chatId, messages, isPrepend, isUpdate, isNewMessage }) => {
295
297
  if (state.activeChat?.id === chatId) {
296
- chatView.setMessages(messages, !isPrepend);
298
+ // Скроллим в самый низ только если это новое сообщение или первая загрузка чата.
299
+ // При подгрузке старых сообщений (isPrepend) или фоновых обновлениях (isUpdate)
300
+ // позиция скролла сохраняется!
301
+ const shouldScrollToBottom = isNewMessage ? config.autoScroll : (!isPrepend && !isUpdate);
302
+ chatView.setMessages(messages, shouldScrollToBottom);
297
303
  }
298
304
  });
299
305
 
@@ -316,11 +322,41 @@ export async function startTui(client, me) {
316
322
  }
317
323
  });
318
324
 
325
+ /**
326
+ * Фоново догружает превью изображений для сообщений, не имевших встроенного PhotoStrippedSize.
327
+ * @param {Array<object>} messages
328
+ * @param {string} chatId
329
+ */
330
+ async function fetchMissingImagePreviews(messages, chatId) {
331
+ if (!config.showImages || !messages || messages.length === 0) return;
332
+ for (const msg of messages) {
333
+ if (state.activeChat?.id !== chatId) break;
334
+ if (msg.media && !msg.imagePreview && msg.rawMessage) {
335
+ const isPhoto = msg.media.className === "MessageMediaPhoto";
336
+ const isDoc = msg.media.className === "MessageMediaDocument";
337
+ if (isPhoto || isDoc) {
338
+ try {
339
+ const preview = await loadMessageImagePreview(client, msg.rawMessage);
340
+ if (preview && state.activeChat?.id === chatId) {
341
+ msg.imagePreview = preview;
342
+ state.updateMessage(chatId, msg);
343
+ }
344
+ } catch {
345
+ // Игнорируем сетевые ошибки фоновой загрузки превью
346
+ }
347
+ }
348
+ }
349
+ }
350
+ }
351
+
319
352
  // 6. Подключение фонового слушателя MTProto событий
320
353
  listener = startTelegramListener(client);
321
354
 
322
355
  listener.on("new_message", ({ peerId, message }) => {
323
356
  state.addMessage(peerId, message);
357
+ if (!message.imagePreview && (message.media?.className === "MessageMediaPhoto" || message.media?.className === "MessageMediaDocument")) {
358
+ fetchMissingImagePreviews([message], peerId);
359
+ }
324
360
 
325
361
  if (state.activeChat?.id === peerId) {
326
362
  markAsRead(client, state.activeChat.peerId).catch(() => {});
@@ -465,6 +501,21 @@ export async function startTui(client, me) {
465
501
  screen.key(["tab"], () => moveFocus(1));
466
502
  screen.key(["S-tab"], () => moveFocus(-1));
467
503
 
504
+ screen.key(["pageup", "C-u"], () => {
505
+ if (!state.activeChat) return;
506
+ chatView.scrollBox.scroll(-10);
507
+ if (chatView.scrollBox.getScroll() <= 0) {
508
+ chatView.loadMore?.();
509
+ }
510
+ screen.render();
511
+ });
512
+
513
+ screen.key(["pagedown", "C-d"], () => {
514
+ if (!state.activeChat) return;
515
+ chatView.scrollBox.scroll(10);
516
+ screen.render();
517
+ });
518
+
468
519
  screen.key(["f1", "?"], () => {
469
520
  releaseInputs();
470
521
  helpModal.show();
@@ -38,7 +38,6 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
38
38
  bottom: 0,
39
39
  tags: true,
40
40
  scrollable: true,
41
- alwaysScroll: true,
42
41
  mouse: true,
43
42
  keys: true,
44
43
  vi: true,
@@ -99,8 +98,19 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
99
98
 
100
99
  // Текст сообщения и entities
101
100
  let bodyText = formatMessageText(msg.text, msg.entities);
102
- if (msg.mediaDescription) {
103
- bodyText = bodyText ? `${msg.mediaDescription}\n ${bodyText}` : msg.mediaDescription;
101
+
102
+ // Блок медиа-вложения и превью изображения
103
+ let mediaBlock = "";
104
+ if (msg.imagePreview) {
105
+ mediaBlock = msg.mediaDescription
106
+ ? `${msg.mediaDescription}\n${msg.imagePreview}`
107
+ : msg.imagePreview;
108
+ } else if (msg.mediaDescription) {
109
+ mediaBlock = msg.mediaDescription;
110
+ }
111
+
112
+ if (mediaBlock) {
113
+ bodyText = bodyText ? `${mediaBlock}\n${bodyText}` : mediaBlock;
104
114
  }
105
115
 
106
116
  // Отступ строк текста сообщения
@@ -135,26 +145,60 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
135
145
  */
136
146
  function setMessages(messages, autoScrollToBottom = true) {
137
147
  currentMessages = messages;
148
+ const prevScroll = scrollBox.getScroll();
149
+ const prevHeight = scrollBox.getScrollHeight();
150
+
138
151
  scrollBox.setContent(renderMessages(messages));
152
+
139
153
  if (autoScrollToBottom) {
140
154
  scrollBox.setScrollPerc(100);
155
+ } else {
156
+ // Сохраняем относительную позицию скролла: если добавились старые сообщения сверху,
157
+ // компенсируем сдвиг высоты ленты
158
+ const newHeight = scrollBox.getScrollHeight();
159
+ const addedLines = newHeight - prevHeight;
160
+ if (addedLines > 0 && prevScroll > 0) {
161
+ scrollBox.scrollTo(prevScroll + addedLines);
162
+ } else if (prevScroll > 0) {
163
+ scrollBox.scrollTo(prevScroll);
164
+ }
141
165
  }
142
166
  screen.render();
143
167
  }
144
168
 
145
169
  // Обработка прокрутки вверх для подгрузки истории
146
- scrollBox.key(["pageup", "C-u"], () => {
147
- scrollBox.scroll(-10);
170
+ function handleScrollUp(step = 10) {
171
+ scrollBox.scroll(-step);
148
172
  if (scrollBox.getScroll() <= 0) {
149
173
  onLoadMoreHistory?.();
150
174
  }
151
175
  screen.render();
152
- });
176
+ }
177
+
178
+ function handleScrollDown(step = 10) {
179
+ scrollBox.scroll(step);
180
+ screen.render();
181
+ }
153
182
 
154
- scrollBox.key(["pagedown", "C-d"], () => {
155
- scrollBox.scroll(10);
183
+ scrollBox.key(["pageup", "C-u"], () => handleScrollUp(10));
184
+ scrollBox.key(["pagedown", "C-d"], () => handleScrollDown(10));
185
+ scrollBox.key(["up", "k"], () => handleScrollUp(2));
186
+ scrollBox.key(["down", "j"], () => handleScrollDown(2));
187
+ scrollBox.key(["home"], () => {
188
+ scrollBox.scrollTo(0);
189
+ onLoadMoreHistory?.();
156
190
  screen.render();
157
191
  });
192
+ scrollBox.key(["end"], () => {
193
+ scrollBox.setScrollPerc(100);
194
+ screen.render();
195
+ });
196
+
197
+ scrollBox.on("wheelup", () => {
198
+ if (scrollBox.getScroll() <= 0) {
199
+ onLoadMoreHistory?.();
200
+ }
201
+ });
158
202
 
159
203
  // Ctrl+M терминал шлёт как "\r" (имя клавиши "return"), поэтому меню действий
160
204
  // висит на Ctrl+A — иначе оно недостижимо.
@@ -169,6 +213,7 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
169
213
  container,
170
214
  scrollBox,
171
215
  setMessages,
216
+ loadMore: () => onLoadMoreHistory?.(),
172
217
  scrollToBottom: () => {
173
218
  scrollBox.setScrollPerc(100);
174
219
  screen.render();