@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.
- package/.env.example +15 -0
- package/CHANGELOG.md +63 -1
- package/CHANGELOG.ru.md +67 -1
- package/README.md +96 -15
- package/README.ru.md +97 -15
- package/package.json +2 -1
- package/src/cli/videoSetup.js +187 -0
- package/src/config.js +6 -0
- package/src/index.js +8 -0
- package/src/state.js +31 -8
- package/src/telegram/dialogs.js +6 -2
- package/src/telegram/formatter.js +1 -1
- package/src/telegram/messages.js +182 -19
- package/src/ui/app.js +293 -73
- package/src/ui/components/chatList.js +159 -101
- package/src/ui/components/chatView.js +454 -109
- package/src/ui/components/header.js +2 -1
- package/src/ui/components/inputBox.js +14 -5
- package/src/ui/components/modals/actionModal.js +11 -14
- package/src/ui/components/modals/chatInfoModal.js +3 -13
- package/src/ui/components/modals/confirmModal.js +3 -13
- package/src/ui/components/modals/fileModal.js +6 -13
- package/src/ui/components/modals/filePickerModal.js +3 -13
- package/src/ui/components/modals/helpModal.js +14 -18
- package/src/ui/components/modals/imageViewerModal.js +172 -0
- package/src/ui/components/modals/videoPlayerModal.js +274 -0
- package/src/ui/components/statusBar.js +2 -1
- package/src/ui/modalMouse.js +43 -0
- package/src/ui/screen.js +168 -20
- package/src/ui/theme.js +1 -0
- package/src/utils/image.js +318 -14
- package/src/utils/mouse.js +62 -75
- package/src/utils/video.js +495 -0
|
@@ -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} [
|
|
141
|
+
* @param {boolean|object} [options=false] Если true — добавляет старые сообщения в начало
|
|
121
142
|
*/
|
|
122
|
-
setMessages(chatId, newMessages,
|
|
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
|
/**
|
package/src/telegram/dialogs.js
CHANGED
|
@@ -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(
|
|
73
|
+
return String(text).replace(/[{}]/g, (ch) => (ch === "{" ? "{open}" : "{close}"));
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
/**
|
package/src/telegram/messages.js
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
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.
|
|
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}`;
|