@emaxe/tuigram 1.3.0 → 1.5.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,187 @@
1
+ /**
2
+ * Консольная команда установки и настройки зависимостей для воспроизведения видео.
3
+ * Проверяет наличие ffmpeg, скачивает статическую сборку при необходимости
4
+ * и активирует функционал ENABLE_VIDEO=true в файле настроек .env.
5
+ */
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import https from "node:https";
10
+ import { spawnSync } from "node:child_process";
11
+ import { bold, cyan, green, yellow, dim, red } from "colorette";
12
+ import { config } from "../config.js";
13
+ import { upsertEnv } from "./init.js";
14
+ import { findFfmpegPath, findFfplayPath, extractFirstFileFromZip } from "../utils/video.js";
15
+
16
+ /** Ссылки на стабильные статические сборки ffmpeg по платформам. */
17
+ const FFMPEG_URLS = {
18
+ "darwin:x64": "https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v4.4.1/ffmpeg-4.4.1-osx-64.zip",
19
+ "darwin:arm64": "https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v4.4.1/ffmpeg-4.4.1-osx-64.zip",
20
+ "linux:x64": "https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v4.4.1/ffmpeg-4.4.1-linux-64.zip",
21
+ "linux:arm64": "https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v4.4.1/ffmpeg-4.4.1-linux-arm-64.zip",
22
+ "linux:arm": "https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v4.4.1/ffmpeg-4.4.1-linux-armel-32.zip",
23
+ "win32:x64": "https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v4.4.1/ffmpeg-4.4.1-win-64.zip",
24
+ "win32:ia32": "https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v4.4.1/ffmpeg-4.4.1-win-32.zip",
25
+ };
26
+
27
+ /**
28
+ * Скачивает буфер по URL с поддержкой HTTP(S) редиректов.
29
+ * @param {string} targetUrl
30
+ * @param {number} [redirects=0]
31
+ * @returns {Promise<Buffer>}
32
+ */
33
+ function fetchBuffer(targetUrl, redirects = 0) {
34
+ return new Promise((resolve, reject) => {
35
+ if (redirects > 5) {
36
+ return reject(new Error("Слишком много редиректов при загрузке"));
37
+ }
38
+ https.get(targetUrl, (res) => {
39
+ if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
40
+ return resolve(fetchBuffer(res.headers.location, redirects + 1));
41
+ }
42
+ if (res.statusCode !== 200) {
43
+ return reject(new Error(`Ошибка HTTP ${res.statusCode}: ${res.statusMessage}`));
44
+ }
45
+ const chunks = [];
46
+ res.on("data", (chunk) => chunks.push(chunk));
47
+ res.on("end", () => resolve(Buffer.concat(chunks)));
48
+ res.on("error", reject);
49
+ }).on("error", reject);
50
+ });
51
+ }
52
+
53
+ /**
54
+ * Активирует флаг ENABLE_VIDEO=true в .env файлах.
55
+ * @returns {string[]} список обновлённых файлов
56
+ */
57
+ export function enableVideoInEnv() {
58
+ const updated = [];
59
+ const targets = [config.configEnvPath];
60
+
61
+ const localEnv = path.join(config.packageRoot, ".env");
62
+ if (fs.existsSync(localEnv) && localEnv !== config.configEnvPath) {
63
+ targets.push(localEnv);
64
+ }
65
+
66
+ for (const target of targets) {
67
+ try {
68
+ fs.mkdirSync(path.dirname(target), { recursive: true });
69
+ let text = "";
70
+ try {
71
+ text = fs.readFileSync(target, "utf8");
72
+ } catch {
73
+ text = "# TuiGram настройки\n";
74
+ }
75
+ const newText = upsertEnv(text, { ENABLE_VIDEO: "true" });
76
+ fs.writeFileSync(target, newText, { encoding: "utf8", mode: 0o600 });
77
+ updated.push(target);
78
+ } catch {
79
+ // Игнорируем ошибку записи отдельного файла
80
+ }
81
+ }
82
+
83
+ return updated;
84
+ }
85
+
86
+ /**
87
+ * Консольная команда установки зависимостей для видео (`tuigram install-video`).
88
+ * @param {Record<string, string|boolean>} [flags]
89
+ * @returns {Promise<void>}
90
+ */
91
+ export function cmdInstallVideo(flags = {}) {
92
+ return (async () => {
93
+ console.log(bold("\n🎬 TuiGram — Установка поддержки воспроизведения видео\n"));
94
+
95
+ const existingFfmpeg = findFfmpegPath();
96
+ const force = Boolean(flags.force);
97
+
98
+ if (existingFfmpeg && !force) {
99
+ console.log(green(`✓ ffmpeg обнаружен: ${existingFfmpeg}`));
100
+ try {
101
+ const ver = spawnSync(existingFfmpeg, ["-version"], { encoding: "utf8", timeout: 2000 });
102
+ if (ver.stdout) {
103
+ const firstLine = ver.stdout.trim().split(/\r?\n/)[0];
104
+ console.log(dim(` Версия: ${firstLine}`));
105
+ }
106
+ } catch {
107
+ // Игнорируем
108
+ }
109
+
110
+ const updated = enableVideoInEnv();
111
+ console.log(green("\n✓ Опция ENABLE_VIDEO=true активирована в .env!"));
112
+ for (const file of updated) {
113
+ console.log(dim(` Файл: ${file}`));
114
+ }
115
+
116
+ printUsageInstructions();
117
+ return;
118
+ }
119
+
120
+ const platformKey = `${process.platform}:${process.arch}`;
121
+ const downloadUrl = FFMPEG_URLS[platformKey] || FFMPEG_URLS[`${process.platform}:x64`];
122
+
123
+ if (!downloadUrl) {
124
+ console.log(yellow(`Платформа ${platformKey} не поддерживает автоматическую загрузку бинарника.`));
125
+ printManualInstallInstructions();
126
+ return;
127
+ }
128
+
129
+ console.log(`Платформа: ${cyan(platformKey)}`);
130
+ console.log(`Загрузка статической сборки ffmpeg из GitHub Releases...`);
131
+ console.log(dim(`URL: ${downloadUrl}\n`));
132
+
133
+ try {
134
+ const zipBuffer = await fetchBuffer(downloadUrl);
135
+ console.log(`✓ Загружено ${Math.round(zipBuffer.length / 1024 / 1024 * 10) / 10} MB. Распаковка...`);
136
+
137
+ const { data } = extractFirstFileFromZip(zipBuffer);
138
+ const binDir = path.join(config.dataDir, "bin");
139
+ fs.mkdirSync(binDir, { recursive: true });
140
+
141
+ const isWin = process.platform === "win32";
142
+ const binaryName = isWin ? "ffmpeg.exe" : "ffmpeg";
143
+ const destPath = path.join(binDir, binaryName);
144
+
145
+ fs.writeFileSync(destPath, data, { mode: 0o755 });
146
+ fs.chmodSync(destPath, 0o755);
147
+
148
+ // Проверка запуска
149
+ const testRun = spawnSync(destPath, ["-version"], { encoding: "utf8", timeout: 3000 });
150
+ if (testRun.status !== 0) {
151
+ throw new Error(`Бинарник не запустился (код ошибки: ${testRun.status})`);
152
+ }
153
+
154
+ console.log(green(`✓ ffmpeg успешно установлен: ${destPath}`));
155
+
156
+ const updated = enableVideoInEnv();
157
+ console.log(green("✓ Опция ENABLE_VIDEO=true активирована в .env!"));
158
+ for (const file of updated) {
159
+ console.log(dim(` Файл: ${file}`));
160
+ }
161
+
162
+ printUsageInstructions();
163
+ } catch (err) {
164
+ console.error(red(`\nОшибка автоматической установки ffmpeg: ${err.message}`));
165
+ printManualInstallInstructions();
166
+ }
167
+ })();
168
+ }
169
+
170
+ function printUsageInstructions() {
171
+ console.log(bold("\n▶ Как воспроизводить видео в TuiGram:"));
172
+ console.log(" 1. Запустите TUI интерфейс: tuigram (или npm start)");
173
+ console.log(" 2. В ленте чата кликните мышью по превью видео");
174
+ console.log(" или выберите сообщение и нажмите Enter → «Воспроизвести видео»");
175
+ console.log(" 3. Управление в плеере: [Пробел] — пауза/воспроизведение, [r] — с начала, [Esc] или [q] — закрыть.\n");
176
+ }
177
+
178
+ function printManualInstallInstructions() {
179
+ console.log(bold("\n📦 Установка ffmpeg вручную через системный пакетный менеджер:"));
180
+ console.log(" • macOS (Homebrew): brew install ffmpeg");
181
+ console.log(" • Ubuntu / Debian: sudo apt update && sudo apt install -y ffmpeg");
182
+ console.log(" • Arch Linux: sudo pacman -S ffmpeg");
183
+ console.log(" • Fedora: sudo dnf install ffmpeg");
184
+ console.log(" • Windows (winget): winget install Gyan.FFmpeg\n");
185
+ console.log("После установки добавьте в ваш .env файл:");
186
+ console.log(cyan(" ENABLE_VIDEO=true\n"));
187
+ }
package/src/config.js CHANGED
@@ -255,6 +255,12 @@ export const config = {
255
255
  imageMaxWidth: parseInt(process.env.IMAGE_MAX_WIDTH || "36", 10) || 36,
256
256
  imageMaxHeight: parseInt(process.env.IMAGE_MAX_HEIGHT || "14", 10) || 14,
257
257
 
258
+ enableVideo: String(process.env.ENABLE_VIDEO || process.env.ENABLE_VIDEO_PLAYBACK || "false").toLowerCase() === "true",
259
+ videoFps: Math.min(30, Math.max(1, parseInt(process.env.VIDEO_FPS || "15", 10) || 15)),
260
+ videoAudio: String(process.env.VIDEO_AUDIO || "true").toLowerCase() !== "false",
261
+ ffmpegPath: process.env.FFMPEG_PATH || null,
262
+ ffplayPath: process.env.FFPLAY_PATH || null,
263
+
258
264
  proxy: parseProxyConfig(),
259
265
 
260
266
  /**
package/src/index.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  cmdListen
15
15
  } from "./cli/cliCommands.js";
16
16
  import { cmdInit, cmdPaths } from "./cli/init.js";
17
+ import { cmdInstallVideo } from "./cli/videoSetup.js";
17
18
  import { config } from "./config.js";
18
19
  import { red, bold } from "colorette";
19
20
 
@@ -57,6 +58,7 @@ ${bold("Настройка:")}
57
58
  tuigram init Ввести ключи Telegram API (api_id / api_hash)
58
59
  --api-id <ID> --api-hash <HASH> без диалога
59
60
  tuigram paths Показать пути к настройкам, сессии и загрузкам
61
+ tuigram install-video Установить ffmpeg и включить воспроизведение видео
60
62
 
61
63
  ${bold("Консольные команды (CLI):")}
62
64
  tuigram login Авторизация в аккаунте (телефон, код, 2FA)
@@ -104,6 +106,12 @@ async function main() {
104
106
  cmdPaths();
105
107
  process.exit(0);
106
108
  break;
109
+ case "install-video":
110
+ case "setup-video":
111
+ case "install-deps":
112
+ await cmdInstallVideo(flags);
113
+ process.exit(0);
114
+ break;
107
115
  case "login":
108
116
  await cmdLogin();
109
117
  process.exit(0);
package/src/state.js CHANGED
@@ -45,7 +45,7 @@ class AppState extends EventEmitter {
45
45
  * @returns {Array<object>}
46
46
  */
47
47
  getVisibleDialogs() {
48
- const tabFiltered = filterDialogsByTab(this.dialogs, this.currentFilterTab);
48
+ const tabFiltered = filterDialogsByTab(this.dialogs, this.currentFilterTab, this.activeChat?.id);
49
49
  if (!this.searchQuery) return tabFiltered;
50
50
  return searchDialogs(tabFiltered, this.searchQuery);
51
51
  }
@@ -97,13 +97,34 @@ class AppState extends EventEmitter {
97
97
  this.replyTarget = null;
98
98
  this.editTarget = null;
99
99
  this.selectedMessageIndex = -1;
100
- if (dialog) {
101
- dialog.unreadCount = 0;
102
- dialog.unreadMentionsCount = 0;
103
- }
104
100
  this.emit("active_chat_changed", dialog);
105
101
  }
106
102
 
103
+ /**
104
+ * Обновляет счётчик непрочитанных сообщений и максимальный прочитанный ID диалога.
105
+ * @param {string} chatId
106
+ * @param {number} unreadCount
107
+ * @param {number} [readInboxMaxId]
108
+ */
109
+ updateDialogUnread(chatId, unreadCount, readInboxMaxId) {
110
+ const dialog = this.dialogs.find((d) => d.id === chatId);
111
+ if (!dialog) return;
112
+
113
+ const countChanged = dialog.unreadCount !== unreadCount;
114
+ const idChanged = typeof readInboxMaxId === "number" && readInboxMaxId > (dialog.readInboxMaxId || 0);
115
+
116
+ if (countChanged || idChanged) {
117
+ dialog.unreadCount = Math.max(0, unreadCount);
118
+ if (dialog.unreadCount === 0) {
119
+ dialog.unreadMentionsCount = 0;
120
+ }
121
+ if (idChanged) {
122
+ dialog.readInboxMaxId = readInboxMaxId;
123
+ }
124
+ this.emit("dialogs_updated", this.getVisibleDialogs());
125
+ }
126
+ }
127
+
107
128
  /**
108
129
  * Получает список сообщений для указанного чата.
109
130
  * @param {string} chatId
@@ -117,9 +138,11 @@ class AppState extends EventEmitter {
117
138
  * Устанавливает или дополняет список сообщений для чата.
118
139
  * @param {string} chatId
119
140
  * @param {Array<object>} newMessages
120
- * @param {boolean} [prepend=false] Если true — добавляет старые сообщения в начало
141
+ * @param {boolean|object} [options=false] Если true — добавляет старые сообщения в начало
121
142
  */
122
- setMessages(chatId, newMessages, prepend = false) {
143
+ setMessages(chatId, newMessages, options = false) {
144
+ const prepend = typeof options === "boolean" ? options : Boolean(options?.prepend);
145
+ const firstUnreadId = typeof options === "object" ? (options?.firstUnreadId || null) : null;
123
146
  const existing = this.messagesByChat.get(chatId) || [];
124
147
  let combined = [];
125
148
 
@@ -140,7 +163,7 @@ class AppState extends EventEmitter {
140
163
  combined.sort((a, b) => (a.date || 0) - (b.date || 0) || (a.id - b.id));
141
164
 
142
165
  this.messagesByChat.set(chatId, combined);
143
- this.emit("messages_updated", { chatId, messages: combined, isPrepend: prepend, isUpdate: false });
166
+ this.emit("messages_updated", { chatId, messages: combined, isPrepend: prepend, isUpdate: false, firstUnreadId });
144
167
  }
145
168
 
146
169
  /**
@@ -37,6 +37,9 @@ export function normalizeDialog(dialog) {
37
37
  archived: Boolean(dialog.archived),
38
38
  unreadCount: dialog.unreadCount || 0,
39
39
  unreadMentionsCount: dialog.unreadMentionsCount || 0,
40
+ readInboxMaxId: dialog.dialog?.readInboxMaxId || dialog.readInboxMaxId || 0,
41
+ readOutboxMaxId: dialog.dialog?.readOutboxMaxId || dialog.readOutboxMaxId || 0,
42
+ topMessage: dialog.dialog?.topMessage || message?.id || 0,
40
43
  folderId: dialog.folderId || 0,
41
44
  date: dialog.date ? dialog.date * 1000 : (message?.date ? message.date * 1000 : Date.now()),
42
45
  isMuted: Boolean(dialog.dialog?.notifySettings?.muteUntil > 0),
@@ -86,9 +89,10 @@ export async function fetchDialogs(client, { limit = 100, archived } = {}) {
86
89
  * Фильтрует список диалогов по выбранной категории.
87
90
  * @param {Array<object>} dialogs
88
91
  * @param {"all"|"users"|"groups"|"channels"|"bots"|"unread"|"archived"} filterTab
92
+ * @param {string|null} [activeChatId=null] ID активного чата (не исключается из вкладки непрочитанных)
89
93
  * @returns {Array<object>}
90
94
  */
91
- export function filterDialogsByTab(dialogs, filterTab = "all") {
95
+ export function filterDialogsByTab(dialogs, filterTab = "all", activeChatId = null) {
92
96
  if (!dialogs) return [];
93
97
 
94
98
  switch (filterTab) {
@@ -101,7 +105,7 @@ export function filterDialogsByTab(dialogs, filterTab = "all") {
101
105
  case "bots":
102
106
  return dialogs.filter((d) => !d.archived && d.type === "bot");
103
107
  case "unread":
104
- return dialogs.filter((d) => !d.archived && (d.unreadCount > 0 || d.unreadMentionsCount > 0));
108
+ return dialogs.filter((d) => !d.archived && (d.unreadCount > 0 || d.unreadMentionsCount > 0 || (activeChatId && d.id === activeChatId)));
105
109
  case "archived":
106
110
  return dialogs.filter((d) => d.archived);
107
111
  case "all":
@@ -70,7 +70,7 @@ export function setMessagePalette(theme) {
70
70
  */
71
71
  export function escapeBlessed(text) {
72
72
  if (!text) return "";
73
- return String(text).replace(/\{/g, "\\{").replace(/\}/g, "\\}");
73
+ return String(text).replace(/[{}]/g, (ch) => (ch === "{" ? "{open}" : "{close}"));
74
74
  }
75
75
 
76
76
  /**
@@ -2,10 +2,43 @@ import { Api, errors } from "teleproto";
2
2
  import { idToString, toMarkedId, getEntityDisplayName, entityCache, resolveEntity } from "./entities.js";
3
3
  import { describeMedia } from "./formatter.js";
4
4
  import { config } from "../config.js";
5
- import { renderStrippedThumbnail, renderImageBuffer } from "../utils/image.js";
5
+ import { renderStrippedThumbnail, renderImageBuffer, renderMediaPreloader, getCachedImagePreview, isPreviewableMedia } from "../utils/image.js";
6
6
 
7
7
  const { FloodWaitError } = errors;
8
8
 
9
+ /**
10
+ * Синхронно рисует миниатюру, встроенную в само сообщение (PhotoStrippedSize).
11
+ * Сеть не нужна — байты уже пришли вместе с сообщением, поэтому картинку можно
12
+ * показать мгновенно, пока качается полноразмерная версия.
13
+ * @param {object} rawMessage
14
+ * @param {object} [options]
15
+ * @param {number} [options.maxWidth]
16
+ * @param {number} [options.maxHeight]
17
+ * @param {boolean} [options.useCache=true] класть результат в кэш псевдографики
18
+ * @returns {string} разметка blessed или пустая строка
19
+ */
20
+ export function renderMessageThumbnail(rawMessage, {
21
+ maxWidth = config.imageMaxWidth,
22
+ maxHeight = config.imageMaxHeight,
23
+ useCache = true,
24
+ } = {}) {
25
+ const media = rawMessage?.media;
26
+ if (!media) return "";
27
+
28
+ const photo = media.photo;
29
+ const doc = media.document;
30
+ const sizes = photo?.sizes || doc?.thumbs || [];
31
+ const stripped = sizes.find((s) => s?.className === "PhotoStrippedSize" || s?.type === "i" || (s?.bytes && s.bytes.length > 0));
32
+ if (!stripped?.bytes) return "";
33
+
34
+ // Полноэкранные рендеры не кэшируем: одна такая строка весит сотни килобайт
35
+ const cacheKey = useCache
36
+ ? (photo?.id ? `photo_${photo.id}` : (doc?.id ? `doc_${doc.id}` : `msg_${rawMessage.id}`))
37
+ : undefined;
38
+
39
+ return renderStrippedThumbnail(stripped.bytes, { maxWidth, maxHeight, cacheKey }) || "";
40
+ }
41
+
9
42
  /**
10
43
  * Преобразует объект Message из MTProto в нормализованный объект для TUI.
11
44
  * @param {object} message
@@ -40,24 +73,30 @@ export function normalizeMessage(message) {
40
73
  }
41
74
  }
42
75
 
43
- // Извлечение и рендеринг PhotoStrippedSize в псевдографику
76
+ // Извлечение и рендеринг PhotoStrippedSize в псевдографику или прелоадера
44
77
  let imagePreview = null;
45
- if (config.showImages && message.media) {
46
- const media = message.media;
47
- const photo = media.photo;
48
- const doc = media.document;
49
- const sizes = photo?.sizes || doc?.thumbs || [];
50
- const stripped = sizes.find((s) => s?.className === "PhotoStrippedSize" || s?.type === "i" || (s?.bytes && s.bytes.length > 0));
51
-
52
- if (stripped?.bytes) {
53
- const cacheKey = photo?.id
54
- ? `photo_${photo.id}`
55
- : (doc?.id ? `doc_${doc.id}` : `msg_${message.id}`);
56
- imagePreview = renderStrippedThumbnail(stripped.bytes, {
57
- maxWidth: config.imageMaxWidth,
58
- maxHeight: config.imageMaxHeight,
59
- cacheKey,
60
- }) || null;
78
+ let isPreviewLoading = false;
79
+
80
+ if (config.showImages && isPreviewableMedia(message)) {
81
+ const cached = getCachedImagePreview(message, {
82
+ maxWidth: config.imageMaxWidth,
83
+ maxHeight: config.imageMaxHeight,
84
+ });
85
+ if (cached) {
86
+ imagePreview = cached;
87
+ isPreviewLoading = false;
88
+ } else {
89
+ const strippedThumb = renderMessageThumbnail(message);
90
+ if (strippedThumb) {
91
+ imagePreview = strippedThumb;
92
+ isPreviewLoading = false;
93
+ } else {
94
+ imagePreview = renderMediaPreloader(message, {
95
+ maxWidth: config.imageMaxWidth,
96
+ maxHeight: config.imageMaxHeight,
97
+ });
98
+ isPreviewLoading = true;
99
+ }
61
100
  }
62
101
  }
63
102
 
@@ -77,6 +116,7 @@ export function normalizeMessage(message) {
77
116
  media: message.media || null,
78
117
  mediaDescription: describeMedia(message.media),
79
118
  imagePreview,
119
+ isPreviewLoading,
80
120
  entities: message.entities || [],
81
121
  reactions,
82
122
  rawMessage: message,
@@ -103,6 +143,9 @@ export async function fetchHistory(client, rawPeer, { limit = 40, offsetId = 0,
103
143
  messages.push(normalizeMessage(msg));
104
144
  }
105
145
  }
146
+ if (!reverse) {
147
+ messages.reverse();
148
+ }
106
149
  };
107
150
 
108
151
  try {
@@ -269,6 +312,61 @@ export async function sendReaction(client, rawPeer, messageId, emoji = "👍") {
269
312
  );
270
313
  }
271
314
 
315
+ /**
316
+ * Находит первое непрочитанное входящее сообщение в хронологическом списке сообщений.
317
+ * @param {Array<object>} messages
318
+ * @param {object} [options]
319
+ * @param {number} [options.readInboxMaxId=0]
320
+ * @param {number} [options.unreadCount=0]
321
+ * @returns {object|null}
322
+ */
323
+ export function findFirstUnreadMessage(messages, { readInboxMaxId = 0, unreadCount = 0 } = {}) {
324
+ if (!Array.isArray(messages) || messages.length === 0 || unreadCount === 0) {
325
+ return null;
326
+ }
327
+
328
+ // Всегда сортируем сообщения по возрастанию ID/даты перед поиском
329
+ const sorted = [...messages].sort((a, b) => (a.date || 0) - (b.date || 0) || (a.id - b.id));
330
+
331
+ if (readInboxMaxId > 0) {
332
+ const first = sorted.find((m) => !m.out && m.id > readInboxMaxId);
333
+ if (first) return first;
334
+ }
335
+
336
+ if (unreadCount > 0) {
337
+ const incoming = sorted.filter((m) => !m.out);
338
+ if (incoming.length > 0) {
339
+ const unreadIncoming = incoming.slice(-unreadCount);
340
+ return unreadIncoming[0] || null;
341
+ }
342
+ }
343
+
344
+ return null;
345
+ }
346
+
347
+ /**
348
+ * Вычисляет количество оставшихся непрочитанных сообщений в чате на основе прочитанного maxId.
349
+ * @param {Array<object>} messages
350
+ * @param {number} maxReadId
351
+ * @param {number} [totalUnreadCount]
352
+ * @returns {number}
353
+ */
354
+ export function calculateRemainingUnreadCount(messages, maxReadId = 0, totalUnreadCount) {
355
+ if (!Array.isArray(messages) || messages.length === 0) return 0;
356
+
357
+ const unreadInLoaded = messages.filter((m) => !m.out && m.id > maxReadId).length;
358
+
359
+ if (typeof totalUnreadCount === "number" && totalUnreadCount > 0) {
360
+ const incomingInLoaded = messages.filter((m) => !m.out).length;
361
+ if (totalUnreadCount > incomingInLoaded) {
362
+ const notLoadedCount = totalUnreadCount - incomingInLoaded;
363
+ return notLoadedCount + unreadInLoaded;
364
+ }
365
+ }
366
+
367
+ return unreadInLoaded;
368
+ }
369
+
272
370
  /**
273
371
  * Отмечает сообщения в чате прочитанными.
274
372
  * @param {import("teleproto").TelegramClient} client
@@ -279,7 +377,7 @@ export async function markAsRead(client, rawPeer, maxId = 0) {
279
377
  const entity = await resolveEntity(client, rawPeer);
280
378
  try {
281
379
  if (maxId > 0) {
282
- await client.sendReadAcknowledge(entity, { maxId });
380
+ await client.markAsRead(entity, maxId);
283
381
  } else {
284
382
  await client.markAsRead(entity);
285
383
  }
@@ -288,6 +386,68 @@ export async function markAsRead(client, rawPeer, maxId = 0) {
288
386
  }
289
387
  }
290
388
 
389
+ /**
390
+ * Выбирает самую крупную растровую миниатюру документа.
391
+ * PhotoPathSize (SVG-контур) и PhotoStrippedSize непригодны для полноэкранного показа.
392
+ * @param {Array<object>} thumbs
393
+ * @returns {object|null}
394
+ */
395
+ function pickLargestThumb(thumbs) {
396
+ if (!Array.isArray(thumbs) || thumbs.length === 0) return null;
397
+
398
+ const weight = (t) => {
399
+ if (typeof t?.size === "number") return t.size;
400
+ if (Array.isArray(t?.sizes) && t.sizes.length > 0) return Math.max(...t.sizes);
401
+ return 0;
402
+ };
403
+
404
+ const usable = thumbs.filter((t) => t?.className !== "PhotoPathSize" && weight(t) > 0);
405
+ if (usable.length === 0) return null;
406
+
407
+ return usable.reduce((best, t) => (weight(t) > weight(best) ? t : best), usable[0]);
408
+ }
409
+
410
+ /**
411
+ * Загружает изображение сообщения в максимальном доступном качестве — для просмотра
412
+ * на весь экран.
413
+ *
414
+ * Фото качается оригиналом. У документа (видео, gif, файл) качается только самая
415
+ * крупная миниатюра: сам файл может весить сотни мегабайт и всё равно не рисуется.
416
+ *
417
+ * @param {import("teleproto").TelegramClient} client
418
+ * @param {object} rawMessage
419
+ * @returns {Promise<{ buffer: Buffer, mimeType: string }>}
420
+ */
421
+ export async function downloadImageBuffer(client, rawMessage) {
422
+ const media = rawMessage?.media;
423
+ if (!media) {
424
+ throw new Error("У сообщения нет изображения.");
425
+ }
426
+
427
+ if (media.className === "MessageMediaPhoto") {
428
+ // Без thumb teleproto отдаёт самый большой размер фотографии
429
+ const buffer = await client.downloadMedia(media, {});
430
+ if (!buffer || buffer.length === 0) {
431
+ throw new Error("Не удалось загрузить изображение.");
432
+ }
433
+ return { buffer, mimeType: "image/jpeg" };
434
+ }
435
+
436
+ if (media.className === "MessageMediaDocument") {
437
+ const thumb = pickLargestThumb(media.document?.thumbs);
438
+ if (!thumb) {
439
+ throw new Error("У вложения нет пригодной для показа миниатюры.");
440
+ }
441
+ const buffer = await client.downloadMedia(media, { thumb });
442
+ if (!buffer || buffer.length === 0) {
443
+ throw new Error("Не удалось загрузить миниатюру вложения.");
444
+ }
445
+ return { buffer, mimeType: "image/jpeg" };
446
+ }
447
+
448
+ throw new Error("Этот тип вложения нельзя показать как изображение.");
449
+ }
450
+
291
451
  /**
292
452
  * Асинхронно загружает и декодирует превью изображения сообщения через MTProto.
293
453
  * @param {import("teleproto").TelegramClient} client
@@ -304,6 +464,9 @@ export async function loadMessageImagePreview(client, rawMessage, { maxWidth = c
304
464
  const isDoc = media.className === "MessageMediaDocument";
305
465
  if (!isPhoto && !isDoc) return "";
306
466
 
467
+ const cached = getCachedImagePreview(rawMessage, { maxWidth, maxHeight });
468
+ if (cached) return cached;
469
+
307
470
  const cacheKey = isPhoto
308
471
  ? `photo_full_${media.photo?.id || rawMessage.id}`
309
472
  : `doc_full_${media.document?.id || rawMessage.id}`;