@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,64 @@
|
|
|
1
|
+
import blessed from "neo-blessed";
|
|
2
|
+
import { fg } from "../theme.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Создаёт нижнюю строку состояния (Status Bar) с подсказками и временными тостами.
|
|
6
|
+
* @param {blessed.Widgets.Screen} screen
|
|
7
|
+
* @param {object} theme
|
|
8
|
+
* @returns {blessed.Widgets.BoxElement & { showMessage: (text: string, type?: string, duration?: number) => void }}
|
|
9
|
+
*/
|
|
10
|
+
export function createStatusBar(screen, theme) {
|
|
11
|
+
const statusBar = blessed.box({
|
|
12
|
+
parent: screen,
|
|
13
|
+
bottom: 0,
|
|
14
|
+
left: 0,
|
|
15
|
+
width: "100%",
|
|
16
|
+
height: 1,
|
|
17
|
+
tags: true,
|
|
18
|
+
style: {
|
|
19
|
+
bg: theme.status.bg,
|
|
20
|
+
fg: theme.status.fg,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
let currentTimeout = null;
|
|
25
|
+
|
|
26
|
+
const key = (label) => `{bold}${fg(theme.accent, label)}{/bold}`;
|
|
27
|
+
const defaultHints =
|
|
28
|
+
` ${key("[Tab]")} Панель ${fg(theme.dim, "│")} ${key("[Enter]")} Выбрать/Отправить ` +
|
|
29
|
+
`${fg(theme.dim, "│")} ${key("[1-6]")} Вкладки ${fg(theme.dim, "│")} ${key("[/]")} Поиск ` +
|
|
30
|
+
`${fg(theme.dim, "│")} ${key("[F1]")} Помощь ${fg(theme.dim, "│")} ${key("[Ctrl+A]")} Действия ` +
|
|
31
|
+
`${fg(theme.dim, "│")} ${key("[Ctrl+P]")} Инфо ${fg(theme.dim, "│")} ${key("[Ctrl+Q]")} Выход`;
|
|
32
|
+
|
|
33
|
+
statusBar.setContent(defaultHints);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Показывает временное статусное сообщение (тост).
|
|
37
|
+
* @param {string} text
|
|
38
|
+
* @param {"info"|"success"|"warning"|"error"} [type="info"]
|
|
39
|
+
* @param {number} [duration=4000]
|
|
40
|
+
*/
|
|
41
|
+
statusBar.showMessage = function (text, type = "info", duration = 4000) {
|
|
42
|
+
if (currentTimeout) {
|
|
43
|
+
clearTimeout(currentTimeout);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const palette = {
|
|
47
|
+
info: [theme.info, "ℹ"],
|
|
48
|
+
success: [theme.success, "✓"],
|
|
49
|
+
warning: [theme.warning, "⚠️"],
|
|
50
|
+
error: [theme.error, "✕"],
|
|
51
|
+
};
|
|
52
|
+
const [color, icon] = palette[type] || palette.info;
|
|
53
|
+
|
|
54
|
+
statusBar.setContent(` ${fg(color, `${icon} ${text}`)}`);
|
|
55
|
+
screen.render();
|
|
56
|
+
|
|
57
|
+
currentTimeout = setTimeout(() => {
|
|
58
|
+
statusBar.setContent(defaultHints);
|
|
59
|
+
screen.render();
|
|
60
|
+
}, duration);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return statusBar;
|
|
64
|
+
}
|
package/src/ui/screen.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import blessed from "neo-blessed";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Глобальные сочетания, которые должны работать даже когда поле ввода или строка
|
|
5
|
+
* поиска перехватили клавиатуру (blessed выставляет screen.grabKeys = true и
|
|
6
|
+
* перестаёт рассылать screen-события всему, чего нет в ignoreLocked).
|
|
7
|
+
*/
|
|
8
|
+
export const GLOBAL_KEYS = [
|
|
9
|
+
"C-c",
|
|
10
|
+
"C-q",
|
|
11
|
+
"tab",
|
|
12
|
+
"S-tab",
|
|
13
|
+
"f1",
|
|
14
|
+
"C-o",
|
|
15
|
+
"C-r",
|
|
16
|
+
"C-e",
|
|
17
|
+
"C-p",
|
|
18
|
+
// escape — чтобы можно было прервать отправку файла из любого места
|
|
19
|
+
"escape",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Создаёт и настраивает главный экран терминального интерфейса.
|
|
24
|
+
* @param {object} [options]
|
|
25
|
+
* @param {object} [options.theme] активная тема (для фона экрана)
|
|
26
|
+
* @param {() => void} [options.onExit] вызывается перед завершением процесса по Ctrl+C
|
|
27
|
+
* @returns {blessed.Widgets.Screen}
|
|
28
|
+
*/
|
|
29
|
+
export function createScreen({ theme, onExit } = {}) {
|
|
30
|
+
const screen = blessed.screen({
|
|
31
|
+
smartCSR: true,
|
|
32
|
+
title: "TuiGram - Telegram Terminal Client",
|
|
33
|
+
fullUnicode: true,
|
|
34
|
+
dockBorders: true,
|
|
35
|
+
cursor: {
|
|
36
|
+
synthetic: true,
|
|
37
|
+
blink: true,
|
|
38
|
+
shape: "line",
|
|
39
|
+
},
|
|
40
|
+
style: {
|
|
41
|
+
bg: theme?.bg ?? "black",
|
|
42
|
+
fg: theme?.fg ?? "white",
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
screen.ignoreLocked = [...GLOBAL_KEYS];
|
|
47
|
+
|
|
48
|
+
// Обработка закрытия терминала или аварийного прерывания
|
|
49
|
+
screen.key(["C-c"], () => {
|
|
50
|
+
try {
|
|
51
|
+
onExit?.();
|
|
52
|
+
} catch {
|
|
53
|
+
// Выходим в любом случае
|
|
54
|
+
}
|
|
55
|
+
screen.destroy();
|
|
56
|
+
process.exit(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return screen;
|
|
60
|
+
}
|
package/src/ui/theme.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Цветовые схемы и стили элементов интерфейса TuiGram.
|
|
3
|
+
*
|
|
4
|
+
* Все цвета заданы hex-значениями намеренно: именованные ("blue", "cyan")
|
|
5
|
+
* занимают индексы 0-15, которые тема терминала перекрашивает как хочет —
|
|
6
|
+
* из-за этого синий фон рисовался бирюзовым, а серый текст пропадал.
|
|
7
|
+
*
|
|
8
|
+
* Важно: blessed сам сводит hex к ближайшему цвету xterm-256 и иногда попадает
|
|
9
|
+
* в те же индексы 0-15. Поэтому значения подобраны так, чтобы после конверсии
|
|
10
|
+
* индекс был >= 16 — это проверяется тестом в test/unit.test.js.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Собирает тему из плоской палитры, чтобы контраст задавался в одном месте.
|
|
15
|
+
* @param {object} p палитра
|
|
16
|
+
* @returns {object} тема
|
|
17
|
+
*/
|
|
18
|
+
function buildTheme(p) {
|
|
19
|
+
return {
|
|
20
|
+
name: p.name,
|
|
21
|
+
bg: p.bg,
|
|
22
|
+
fg: p.fg,
|
|
23
|
+
surface: p.surface,
|
|
24
|
+
surfaceHigh: p.surfaceHigh,
|
|
25
|
+
|
|
26
|
+
// Семантика — для компонентов, которым нужен цвет «по смыслу»
|
|
27
|
+
accent: p.accent,
|
|
28
|
+
onAccent: p.onAccent,
|
|
29
|
+
muted: p.muted,
|
|
30
|
+
dim: p.dim,
|
|
31
|
+
success: p.green,
|
|
32
|
+
warning: p.yellow,
|
|
33
|
+
error: p.red,
|
|
34
|
+
info: p.cyan,
|
|
35
|
+
|
|
36
|
+
header: {
|
|
37
|
+
bg: p.surface,
|
|
38
|
+
fg: p.fgBright,
|
|
39
|
+
bold: true,
|
|
40
|
+
},
|
|
41
|
+
borders: {
|
|
42
|
+
fg: p.border,
|
|
43
|
+
focusFg: p.accent,
|
|
44
|
+
},
|
|
45
|
+
chatList: {
|
|
46
|
+
bg: p.bg,
|
|
47
|
+
fg: p.fg,
|
|
48
|
+
selectedBg: p.surfaceHigh,
|
|
49
|
+
selectedFg: p.fgBright,
|
|
50
|
+
itemHoverBg: p.surface,
|
|
51
|
+
itemMuted: p.dim,
|
|
52
|
+
itemUnreadBg: p.accent,
|
|
53
|
+
itemUnreadFg: p.onAccent,
|
|
54
|
+
pinnedFg: p.yellow,
|
|
55
|
+
previewFg: p.muted,
|
|
56
|
+
timeFg: p.dim,
|
|
57
|
+
},
|
|
58
|
+
tabs: {
|
|
59
|
+
bg: p.bg,
|
|
60
|
+
fg: p.muted,
|
|
61
|
+
activeBg: p.accent,
|
|
62
|
+
activeFg: p.onAccent,
|
|
63
|
+
},
|
|
64
|
+
chatView: {
|
|
65
|
+
bg: p.bg,
|
|
66
|
+
fg: p.fg,
|
|
67
|
+
incomingName: p.cyan,
|
|
68
|
+
outgoingName: p.green,
|
|
69
|
+
time: p.dim,
|
|
70
|
+
dateDivider: p.yellow,
|
|
71
|
+
replyBorder: p.muted,
|
|
72
|
+
systemMsg: p.yellow,
|
|
73
|
+
mediaFg: p.magenta,
|
|
74
|
+
reactionFg: p.yellow,
|
|
75
|
+
},
|
|
76
|
+
input: {
|
|
77
|
+
bg: p.bg,
|
|
78
|
+
fg: p.fgBright,
|
|
79
|
+
contextBg: p.surface,
|
|
80
|
+
contextFg: p.muted,
|
|
81
|
+
replyBg: p.cyan,
|
|
82
|
+
replyFg: p.onAccent,
|
|
83
|
+
editBg: p.yellow,
|
|
84
|
+
editFg: p.onAccent,
|
|
85
|
+
},
|
|
86
|
+
modal: {
|
|
87
|
+
bg: p.surface,
|
|
88
|
+
fg: p.fgBright,
|
|
89
|
+
borderFg: p.accent,
|
|
90
|
+
selectedBg: p.accent,
|
|
91
|
+
selectedFg: p.onAccent,
|
|
92
|
+
labelFg: p.fg,
|
|
93
|
+
hintFg: p.muted,
|
|
94
|
+
inputBg: p.bg,
|
|
95
|
+
inputFg: p.fgBright,
|
|
96
|
+
inputFocusBg: p.surfaceHigh,
|
|
97
|
+
inputFocusFg: p.fgBright,
|
|
98
|
+
buttonBg: p.green,
|
|
99
|
+
buttonFg: p.onAccent,
|
|
100
|
+
dangerBg: p.red,
|
|
101
|
+
dangerFg: p.onDanger,
|
|
102
|
+
neutralBg: p.surfaceHigh,
|
|
103
|
+
neutralFg: p.fg,
|
|
104
|
+
buttonFocusBg: p.accent,
|
|
105
|
+
buttonFocusFg: p.onAccent,
|
|
106
|
+
},
|
|
107
|
+
picker: {
|
|
108
|
+
// filemanager сам красит элементы в light-blue/light-cyan —
|
|
109
|
+
// эти значения подставляются вместо них (см. filePickerModal.js)
|
|
110
|
+
dirFg: p.accent,
|
|
111
|
+
fileFg: p.fgBright,
|
|
112
|
+
linkFg: p.cyan,
|
|
113
|
+
},
|
|
114
|
+
search: {
|
|
115
|
+
bg: p.bg,
|
|
116
|
+
fg: p.yellow,
|
|
117
|
+
placeholderFg: p.dim,
|
|
118
|
+
},
|
|
119
|
+
scrollbar: {
|
|
120
|
+
bg: p.border,
|
|
121
|
+
fg: p.accent,
|
|
122
|
+
},
|
|
123
|
+
status: {
|
|
124
|
+
bg: p.bg,
|
|
125
|
+
fg: p.muted,
|
|
126
|
+
online: p.green,
|
|
127
|
+
connecting: p.yellow,
|
|
128
|
+
offline: p.red,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const themes = {
|
|
134
|
+
default: buildTheme({
|
|
135
|
+
name: "Default Dark",
|
|
136
|
+
bg: "#16161e",
|
|
137
|
+
surface: "#1f2335",
|
|
138
|
+
surfaceHigh: "#2f3549",
|
|
139
|
+
border: "#3b4261",
|
|
140
|
+
fg: "#a9b1d6",
|
|
141
|
+
fgBright: "#c0caf5",
|
|
142
|
+
muted: "#8288a6",
|
|
143
|
+
dim: "#6874a0",
|
|
144
|
+
accent: "#7aa2f7",
|
|
145
|
+
onAccent: "#16161e",
|
|
146
|
+
onDanger: "#16161e",
|
|
147
|
+
cyan: "#7dcfff",
|
|
148
|
+
green: "#9ece6a",
|
|
149
|
+
yellow: "#e0af68",
|
|
150
|
+
red: "#f7768e",
|
|
151
|
+
magenta: "#bb9af7",
|
|
152
|
+
}),
|
|
153
|
+
|
|
154
|
+
nord: buildTheme({
|
|
155
|
+
name: "Nord",
|
|
156
|
+
bg: "#2e3440",
|
|
157
|
+
surface: "#3b4252",
|
|
158
|
+
surfaceHigh: "#434c5e",
|
|
159
|
+
border: "#4c566a",
|
|
160
|
+
fg: "#d8dee9",
|
|
161
|
+
fgBright: "#eceff4",
|
|
162
|
+
muted: "#8b98b0",
|
|
163
|
+
dim: "#7e879b",
|
|
164
|
+
accent: "#88c0d0",
|
|
165
|
+
onAccent: "#2e3440",
|
|
166
|
+
onDanger: "#2e3440",
|
|
167
|
+
cyan: "#8fbcbb",
|
|
168
|
+
green: "#a3be8c",
|
|
169
|
+
yellow: "#ebcb8b",
|
|
170
|
+
red: "#d07a83",
|
|
171
|
+
magenta: "#b48ead",
|
|
172
|
+
}),
|
|
173
|
+
|
|
174
|
+
light: buildTheme({
|
|
175
|
+
name: "Light",
|
|
176
|
+
bg: "#f5f6f8",
|
|
177
|
+
surface: "#eceff4",
|
|
178
|
+
surfaceHigh: "#d8dee9",
|
|
179
|
+
border: "#c0c5ce",
|
|
180
|
+
fg: "#3b4252",
|
|
181
|
+
fgBright: "#2e3440",
|
|
182
|
+
muted: "#5f6672",
|
|
183
|
+
dim: "#6f7788",
|
|
184
|
+
accent: "#2d6df6",
|
|
185
|
+
onAccent: "#f5f6f8",
|
|
186
|
+
onDanger: "#f5f6f8",
|
|
187
|
+
cyan: "#0b7285",
|
|
188
|
+
green: "#2f7d32",
|
|
189
|
+
yellow: "#a16207",
|
|
190
|
+
red: "#c02c38",
|
|
191
|
+
magenta: "#7048b6",
|
|
192
|
+
}),
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Оборачивает текст в цветовой тег blessed.
|
|
197
|
+
* @param {string} color hex или именованный цвет
|
|
198
|
+
* @param {string} text
|
|
199
|
+
* @returns {string}
|
|
200
|
+
*/
|
|
201
|
+
export function fg(color, text) {
|
|
202
|
+
return `{${color}-fg}${text}{/${color}-fg}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Оборачивает текст в тег фона с явным цветом текста.
|
|
207
|
+
* @param {string} bgColor
|
|
208
|
+
* @param {string} fgColor
|
|
209
|
+
* @param {string} text
|
|
210
|
+
* @returns {string}
|
|
211
|
+
*/
|
|
212
|
+
export function badge(bgColor, fgColor, text) {
|
|
213
|
+
return `{${bgColor}-bg}{${fgColor}-fg}${text}{/${fgColor}-fg}{/${bgColor}-bg}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Получает активную тему по её названию из конфигурации.
|
|
218
|
+
* @param {string} themeName
|
|
219
|
+
* @returns {typeof themes.default}
|
|
220
|
+
*/
|
|
221
|
+
export function getTheme(themeName = "default") {
|
|
222
|
+
return themes[themeName] || themes.default;
|
|
223
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Разбор аргументов слэш-команд TUI.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Разбирает аргументы `/sendfile`.
|
|
7
|
+
*
|
|
8
|
+
* Формат: `путь [| путь ...] [-- подпись]`
|
|
9
|
+
* Разделитель путей — `|`, подпись отделяется первым ` -- `.
|
|
10
|
+
* Всё после первого ` -- ` считается подписью целиком, поэтому подпись
|
|
11
|
+
* сама может содержать двойное тире.
|
|
12
|
+
*
|
|
13
|
+
* @param {string|string[]} rawArgs
|
|
14
|
+
* @returns {{ paths: string[], caption: string }}
|
|
15
|
+
*/
|
|
16
|
+
export function parseSendFileArgs(rawArgs) {
|
|
17
|
+
const raw = (Array.isArray(rawArgs) ? rawArgs.join(" ") : String(rawArgs ?? "")).trim();
|
|
18
|
+
if (!raw) return { paths: [], caption: "" };
|
|
19
|
+
|
|
20
|
+
let pathsPart = raw;
|
|
21
|
+
let caption = "";
|
|
22
|
+
|
|
23
|
+
const separator = raw.match(/(^|\s)--(\s|$)/);
|
|
24
|
+
if (separator) {
|
|
25
|
+
pathsPart = raw.slice(0, separator.index);
|
|
26
|
+
caption = raw.slice(separator.index + separator[0].length).trim();
|
|
27
|
+
// Разделитель в начале строки: путей нет вовсе
|
|
28
|
+
if (separator[1] === "") {
|
|
29
|
+
pathsPart = "";
|
|
30
|
+
caption = raw.slice(separator[0].length - (separator[2] === "" ? 0 : 1)).trim();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const paths = pathsPart
|
|
35
|
+
.split("|")
|
|
36
|
+
.map((part) => part.trim())
|
|
37
|
+
.filter(Boolean);
|
|
38
|
+
|
|
39
|
+
return { paths, caption };
|
|
40
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Создаёт директорию, если она ещё не существует.
|
|
7
|
+
* @param {string} dirPath
|
|
8
|
+
*/
|
|
9
|
+
export function ensureDir(dirPath) {
|
|
10
|
+
if (!fs.existsSync(dirPath)) {
|
|
11
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Безопасно читает текстовый файл.
|
|
17
|
+
* @param {string} filePath
|
|
18
|
+
* @param {string} [defaultValue=""]
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function readFileSafe(filePath, defaultValue = "") {
|
|
22
|
+
try {
|
|
23
|
+
if (!fs.existsSync(filePath)) return defaultValue;
|
|
24
|
+
return fs.readFileSync(filePath, "utf8");
|
|
25
|
+
} catch {
|
|
26
|
+
return defaultValue;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Записывает файл, создавая родительские папки при необходимости.
|
|
32
|
+
* @param {string} filePath
|
|
33
|
+
* @param {string} content
|
|
34
|
+
* @param {object} [options]
|
|
35
|
+
*/
|
|
36
|
+
export function writeFileSafe(filePath, content, options = {}) {
|
|
37
|
+
const dir = path.dirname(filePath);
|
|
38
|
+
ensureDir(dir);
|
|
39
|
+
fs.writeFileSync(filePath, content, { encoding: "utf8", ...options });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Сохраняет строку сессии Telegram с правами 0600 (только для владельца).
|
|
44
|
+
* @param {string} filePath
|
|
45
|
+
* @param {string} sessionString
|
|
46
|
+
*/
|
|
47
|
+
export function saveSessionFile(filePath, sessionString) {
|
|
48
|
+
const dir = path.dirname(filePath);
|
|
49
|
+
ensureDir(dir);
|
|
50
|
+
fs.writeFileSync(filePath, sessionString.trim(), { encoding: "utf8", mode: 0o600 });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Читает JSON-файл или возвращает значение по умолчанию при ошибке.
|
|
55
|
+
* @template T
|
|
56
|
+
* @param {string} filePath
|
|
57
|
+
* @param {T} [defaultValue=null]
|
|
58
|
+
* @returns {T}
|
|
59
|
+
*/
|
|
60
|
+
export function readJson(filePath, defaultValue = null) {
|
|
61
|
+
try {
|
|
62
|
+
if (!fs.existsSync(filePath)) return defaultValue;
|
|
63
|
+
const text = fs.readFileSync(filePath, "utf8");
|
|
64
|
+
return JSON.parse(text);
|
|
65
|
+
} catch {
|
|
66
|
+
return defaultValue;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Записывает объект в JSON-файл.
|
|
72
|
+
* @param {string} filePath
|
|
73
|
+
* @param {unknown} data
|
|
74
|
+
* @param {boolean} [pretty=true]
|
|
75
|
+
*/
|
|
76
|
+
export function writeJson(filePath, data, pretty = true) {
|
|
77
|
+
const dir = path.dirname(filePath);
|
|
78
|
+
ensureDir(dir);
|
|
79
|
+
const text = JSON.stringify(data, null, pretty ? 2 : 0);
|
|
80
|
+
fs.writeFileSync(filePath, text, "utf8");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Расширения, которые teleproto отправляет как сжатое фото (см. Utils.isImage). */
|
|
84
|
+
const PHOTO_EXTENSIONS = new Set([".png", ".jpg", ".jpeg"]);
|
|
85
|
+
/** Расширения, которые Telegram показывает как видео. */
|
|
86
|
+
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v", ".3gp"]);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Приводит введённый пользователем путь к абсолютному:
|
|
90
|
+
* снимает обрамляющие кавычки, shell-экранирование (перетаскивание файла
|
|
91
|
+
* в терминал) и раскрывает "~".
|
|
92
|
+
* @param {string} raw
|
|
93
|
+
* @returns {string} абсолютный путь или "" для пустого ввода
|
|
94
|
+
*/
|
|
95
|
+
export function resolveLocalPath(raw) {
|
|
96
|
+
let value = String(raw ?? "").trim();
|
|
97
|
+
if (!value) return "";
|
|
98
|
+
|
|
99
|
+
const quoted =
|
|
100
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
101
|
+
(value.startsWith("'") && value.endsWith("'"));
|
|
102
|
+
|
|
103
|
+
if (quoted && value.length >= 2) {
|
|
104
|
+
value = value.slice(1, -1);
|
|
105
|
+
} else {
|
|
106
|
+
// Снимаем экранирование только тех символов, которые экранирует shell,
|
|
107
|
+
// чтобы не покалечить windows-путь вида C:\Users\name
|
|
108
|
+
value = value.replace(/\\([ ()'"&!$`;|<>*?\[\]{}~#])/g, "$1");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
value = value.trim();
|
|
112
|
+
if (!value) return "";
|
|
113
|
+
|
|
114
|
+
if (value === "~") {
|
|
115
|
+
value = os.homedir();
|
|
116
|
+
} else if (value.startsWith("~/") || value.startsWith("~\\")) {
|
|
117
|
+
value = path.join(os.homedir(), value.slice(2));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return path.resolve(value);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Определяет, чем Telegram покажет файл: фото, видео или документом.
|
|
125
|
+
* @param {string} filePath
|
|
126
|
+
* @returns {"photo"|"video"|"document"}
|
|
127
|
+
*/
|
|
128
|
+
export function detectFileKind(filePath) {
|
|
129
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
130
|
+
if (PHOTO_EXTENSIONS.has(ext)) return "photo";
|
|
131
|
+
if (VIDEO_EXTENSIONS.has(ext)) return "video";
|
|
132
|
+
return "document";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Проверяет локальный файл перед отправкой и возвращает понятную ошибку
|
|
137
|
+
* вместо сетевой. Единая точка валидации для TUI, слэш-команд и CLI.
|
|
138
|
+
* @param {string} raw путь в любом виде (с "~", кавычками, экранированием)
|
|
139
|
+
* @returns {{ ok: boolean, filePath: string, name?: string, size?: number, kind?: string, error?: string }}
|
|
140
|
+
*/
|
|
141
|
+
export function inspectLocalFile(raw) {
|
|
142
|
+
const filePath = resolveLocalPath(raw);
|
|
143
|
+
if (!filePath) {
|
|
144
|
+
return { ok: false, filePath: "", error: "Не указан путь к файлу" };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let stat;
|
|
148
|
+
try {
|
|
149
|
+
stat = fs.statSync(filePath);
|
|
150
|
+
} catch {
|
|
151
|
+
return { ok: false, filePath, error: `Файл не найден: ${filePath}` };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (stat.isDirectory()) {
|
|
155
|
+
return { ok: false, filePath, error: `Это папка, а не файл: ${filePath}` };
|
|
156
|
+
}
|
|
157
|
+
if (!stat.isFile()) {
|
|
158
|
+
return { ok: false, filePath, error: `Не обычный файл: ${filePath}` };
|
|
159
|
+
}
|
|
160
|
+
if (stat.size === 0) {
|
|
161
|
+
return { ok: false, filePath, error: `Файл пустой: ${path.basename(filePath)}` };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
fs.accessSync(filePath, fs.constants.R_OK);
|
|
166
|
+
} catch {
|
|
167
|
+
return { ok: false, filePath, error: `Нет прав на чтение: ${filePath}` };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
ok: true,
|
|
172
|
+
filePath,
|
|
173
|
+
name: path.basename(filePath),
|
|
174
|
+
size: stat.size,
|
|
175
|
+
kind: detectFileKind(filePath),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Утилиты форматирования дат, времени и размеров данных.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Преобразует unix-timestamp (секунды или Date) в объект Date.
|
|
7
|
+
* @param {number|Date|string} time
|
|
8
|
+
* @returns {Date}
|
|
9
|
+
*/
|
|
10
|
+
export function toDate(time) {
|
|
11
|
+
if (!time) return new Date();
|
|
12
|
+
if (time instanceof Date) return time;
|
|
13
|
+
if (typeof time === "number") {
|
|
14
|
+
// Если timestamp в секундах (как в Telegram), умножаем на 1000
|
|
15
|
+
return new Date(time < 1e11 ? time * 1000 : time);
|
|
16
|
+
}
|
|
17
|
+
return new Date(time);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Форматирует время для отображения в списке чатов (например, "14:32" или "29 авг").
|
|
22
|
+
* @param {number|Date|string} time
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
export function formatChatTime(time) {
|
|
26
|
+
if (!time) return "";
|
|
27
|
+
const date = toDate(time);
|
|
28
|
+
const now = new Date();
|
|
29
|
+
|
|
30
|
+
const isToday =
|
|
31
|
+
date.getDate() === now.getDate() &&
|
|
32
|
+
date.getMonth() === now.getMonth() &&
|
|
33
|
+
date.getFullYear() === now.getFullYear();
|
|
34
|
+
|
|
35
|
+
if (isToday) {
|
|
36
|
+
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const yesterday = new Date(now);
|
|
40
|
+
yesterday.setDate(now.getDate() - 1);
|
|
41
|
+
const isYesterday =
|
|
42
|
+
date.getDate() === yesterday.getDate() &&
|
|
43
|
+
date.getMonth() === yesterday.getMonth() &&
|
|
44
|
+
date.getFullYear() === yesterday.getFullYear();
|
|
45
|
+
|
|
46
|
+
if (isYesterday) {
|
|
47
|
+
return "Вчера";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const isSameYear = date.getFullYear() === now.getFullYear();
|
|
51
|
+
if (isSameYear) {
|
|
52
|
+
return date.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return date.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "2-digit" });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Форматирует время для сообщения (HH:MM).
|
|
60
|
+
* @param {number|Date|string} time
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
export function formatMessageTime(time) {
|
|
64
|
+
if (!time) return "";
|
|
65
|
+
const date = toDate(time);
|
|
66
|
+
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Форматирует полную дату и время (DD.MM.YYYY HH:MM:SS).
|
|
71
|
+
* @param {number|Date|string} time
|
|
72
|
+
* @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
export function formatFullDateTime(time) {
|
|
75
|
+
if (!time) return "";
|
|
76
|
+
const date = toDate(time);
|
|
77
|
+
return date.toLocaleString("ru-RU", {
|
|
78
|
+
day: "2-digit",
|
|
79
|
+
month: "2-digit",
|
|
80
|
+
year: "numeric",
|
|
81
|
+
hour: "2-digit",
|
|
82
|
+
minute: "2-digit",
|
|
83
|
+
second: "2-digit",
|
|
84
|
+
hour12: false,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Возвращает строку разделителя даты для ленты сообщений (например "29 августа 2026").
|
|
90
|
+
* @param {number|Date|string} time
|
|
91
|
+
* @returns {string}
|
|
92
|
+
*/
|
|
93
|
+
export function formatDateDivider(time) {
|
|
94
|
+
if (!time) return "";
|
|
95
|
+
const date = toDate(time);
|
|
96
|
+
return date.toLocaleDateString("ru-RU", {
|
|
97
|
+
day: "numeric",
|
|
98
|
+
month: "long",
|
|
99
|
+
year: "numeric",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Форматирует размер файла в человекопонятный вид (KB, MB, GB).
|
|
105
|
+
* @param {number} bytes
|
|
106
|
+
* @returns {string}
|
|
107
|
+
*/
|
|
108
|
+
export function formatFileSize(bytes) {
|
|
109
|
+
if (!bytes || bytes <= 0) return "0 B";
|
|
110
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
111
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
112
|
+
const size = (bytes / Math.pow(1024, i)).toFixed(1);
|
|
113
|
+
return `${size} ${units[i]}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Форматирует длительность в секундах (MM:SS или HH:MM:SS).
|
|
118
|
+
* @param {number} seconds
|
|
119
|
+
* @returns {string}
|
|
120
|
+
*/
|
|
121
|
+
export function formatDuration(seconds) {
|
|
122
|
+
if (!seconds) return "0:00";
|
|
123
|
+
const sec = Math.floor(seconds);
|
|
124
|
+
const m = Math.floor(sec / 60);
|
|
125
|
+
const s = sec % 60;
|
|
126
|
+
if (m < 60) {
|
|
127
|
+
return `${m}:${s.toString().padStart(2, "0")}`;
|
|
128
|
+
}
|
|
129
|
+
const h = Math.floor(m / 60);
|
|
130
|
+
const remM = m % 60;
|
|
131
|
+
return `${h}:${remM.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
|
132
|
+
}
|