@emaxe/tuigram 1.0.0 → 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emaxe/tuigram",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Полнофункциональный TUI & CLI клиент Telegram на Node.js на базе MTProto (teleproto)",
5
5
  "type": "module",
6
6
  "exports": {
@@ -13,6 +13,9 @@
13
13
  "bin/",
14
14
  "src/",
15
15
  "README.md",
16
+ "README.ru.md",
17
+ "CHANGELOG.md",
18
+ "CHANGELOG.ru.md",
16
19
  "LICENSE",
17
20
  ".env.example"
18
21
  ],
package/src/cli/init.js CHANGED
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import input from "input";
4
4
  import { bold, cyan, green, yellow, dim } from "colorette";
5
- import { config } from "../config.js";
5
+ import { config, formatProxyUrl } from "../config.js";
6
6
 
7
7
  /** Сколько раз переспрашивать значение, прежде чем сдаться. */
8
8
  const MAX_ATTEMPTS = 3;
@@ -193,5 +193,6 @@ export function cmdPaths() {
193
193
  console.log(` Сессия: ${config.sessionPath} ${sessionExists ? green("(есть)") : dim("(нет)")}`);
194
194
  console.log(` Загрузки: ${config.downloadsDir}`);
195
195
  console.log(` Ключи API: ${config.apiId && config.apiHash ? green("заданы") : yellow("не заданы — запустите tuigram init")}`);
196
+ console.log(` Прокси: ${config.proxy ? green(formatProxyUrl(config.proxy)) : dim("не используется")}`);
196
197
  console.log("");
197
198
  }
package/src/config.js CHANGED
@@ -110,6 +110,129 @@ function readVersion() {
110
110
  }
111
111
  }
112
112
 
113
+ /**
114
+ * @typedef {Object} ProxyConfig
115
+ * @property {"http"|"https"|"socks5"|"socks4"} type - Протокол прокси
116
+ * @property {string} host - Адрес сервера прокси
117
+ * @property {number} port - Порт сервера прокси
118
+ * @property {string} [username] - Логин пользователя
119
+ * @property {string} [password] - Пароль пользователя
120
+ * @property {number} [timeout] - Таймаут в секундах
121
+ * @property {string} ip - Хост прокси (для совместимости с MTProto)
122
+ * @property {4|5} [socksType] - Тип SOCKS-прокси
123
+ * @property {boolean} [http] - Флаг HTTP-прокси
124
+ */
125
+
126
+ /**
127
+ * Нормализует тип прокси из строки протокола.
128
+ * @param {string} [raw]
129
+ * @returns {"http"|"https"|"socks5"|"socks4"}
130
+ */
131
+ function normalizeProxyType(raw) {
132
+ const clean = String(raw || "").toLowerCase().replace(/:$/, "").trim();
133
+ if (clean === "https") return "https";
134
+ if (clean === "socks4" || clean === "socks4a") return "socks4";
135
+ if (clean === "socks5" || clean === "socks5h" || clean === "socks") return "socks5";
136
+ return "http";
137
+ }
138
+
139
+ /**
140
+ * Разбирает параметры прокси из переменных окружения или переданного объекта.
141
+ * Поддерживает URL-формат (PROXY_URL, HTTPS_PROXY, HTTP_PROXY, ALL_PROXY)
142
+ * и отдельные переменные (PROXY_TYPE, PROXY_HOST, PROXY_PORT, PROXY_USERNAME, PROXY_PASSWORD).
143
+ * @param {Record<string, string|undefined>} [env=process.env]
144
+ * @returns {ProxyConfig|null}
145
+ */
146
+ export function parseProxyConfig(env = process.env) {
147
+ const rawUrl = env.PROXY_URL || env.proxy_url || (!env.PROXY_HOST && !env.PROXY_IP && !env.proxy_host && !env.proxy_ip
148
+ ? (env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy || env.ALL_PROXY || env.all_proxy)
149
+ : undefined);
150
+
151
+ let type = "http";
152
+ let host = "";
153
+ let port = 0;
154
+ let username = "";
155
+ let password = "";
156
+
157
+ if (rawUrl && typeof rawUrl === "string" && rawUrl.trim()) {
158
+ let urlStr = rawUrl.trim();
159
+ // Добавляем схему по умолчанию, если передан host:port без префикса
160
+ if (!/^[a-zA-Z0-9+-.]+:\/\//.test(urlStr)) {
161
+ const defaultScheme = (env.PROXY_TYPE || "http").toLowerCase().replace(/:$/, "");
162
+ urlStr = `${defaultScheme}://${urlStr}`;
163
+ }
164
+
165
+ try {
166
+ const parsed = new URL(urlStr);
167
+ type = normalizeProxyType(parsed.protocol);
168
+ host = parsed.hostname.replace(/^\[(.*)\]$/, "$1");
169
+ port = parsed.port ? parseInt(parsed.port, 10) : (type === "https" ? 443 : (type.startsWith("socks") ? 1080 : 8080));
170
+ if (parsed.username) username = decodeURIComponent(parsed.username);
171
+ if (parsed.password) password = decodeURIComponent(parsed.password);
172
+ } catch {
173
+ return null;
174
+ }
175
+ } else if (env.PROXY_HOST || env.PROXY_IP) {
176
+ type = normalizeProxyType(env.PROXY_TYPE || "http");
177
+ host = String(env.PROXY_HOST || env.PROXY_IP).trim();
178
+ const defaultPort = type === "https" ? 443 : (type.startsWith("socks") ? 1080 : 8080);
179
+ port = env.PROXY_PORT ? parseInt(String(env.PROXY_PORT).trim(), 10) : defaultPort;
180
+ username = String(env.PROXY_USERNAME || env.PROXY_USER || "").trim();
181
+ password = String(env.PROXY_PASSWORD || env.PROXY_PASS || "").trim();
182
+ } else {
183
+ return null;
184
+ }
185
+
186
+ if (!host || !port || isNaN(port) || port <= 0 || port > 65535) {
187
+ return null;
188
+ }
189
+
190
+ const rawTimeout = env.PROXY_TIMEOUT ? parseInt(String(env.PROXY_TIMEOUT).trim(), 10) : 10;
191
+ const timeout = isNaN(rawTimeout) || rawTimeout <= 0 ? 10 : rawTimeout;
192
+
193
+ /** @type {ProxyConfig} */
194
+ const result = {
195
+ type,
196
+ host,
197
+ port,
198
+ ip: host,
199
+ timeout,
200
+ };
201
+
202
+ if (username) result.username = username;
203
+ if (password) result.password = password;
204
+
205
+ if (type === "socks5") {
206
+ result.socksType = 5;
207
+ } else if (type === "socks4") {
208
+ result.socksType = 4;
209
+ } else {
210
+ result.http = true;
211
+ }
212
+
213
+ return result;
214
+ }
215
+
216
+ /**
217
+ * Форматирует конфигурацию прокси в строку для вывода в терминал.
218
+ * Пароль маскируется звездочками в целях безопасности.
219
+ * @param {ProxyConfig|null} proxy
220
+ * @returns {string}
221
+ */
222
+ export function formatProxyUrl(proxy) {
223
+ if (!proxy) return "не используется";
224
+
225
+ let auth = "";
226
+ if (proxy.username) {
227
+ auth = proxy.password
228
+ ? `${encodeURIComponent(proxy.username)}:***@`
229
+ : `${encodeURIComponent(proxy.username)}@`;
230
+ }
231
+
232
+ const host = proxy.host.includes(":") ? `[${proxy.host}]` : proxy.host;
233
+ return `${proxy.type}://${auth}${host}:${proxy.port}`;
234
+ }
235
+
113
236
  export const config = {
114
237
  packageRoot,
115
238
  /** @deprecated оставлено для обратной совместимости, равно packageRoot */
@@ -129,6 +252,8 @@ export const config = {
129
252
  autoScroll: String(process.env.AUTO_SCROLL || "true").toLowerCase() === "true",
130
253
  showTyping: String(process.env.SHOW_TYPING || "true").toLowerCase() === "true",
131
254
 
255
+ proxy: parseProxyConfig(),
256
+
132
257
  /**
133
258
  * Путь к файлу сессии.
134
259
  * @returns {string}
@@ -4,6 +4,7 @@ import { StringSession } from "teleproto/sessions/index.js";
4
4
  import { Logger, LogLevel } from "teleproto/extensions/Logger.js";
5
5
  import { config } from "../config.js";
6
6
  import { saveSessionFile, readFileSafe } from "../utils/storage.js";
7
+ import { TuiGramNetSockets } from "./socket.js";
7
8
 
8
9
  /**
9
10
  * Читает сохранённую строку сессии.
@@ -42,17 +43,24 @@ export function clearSession() {
42
43
  export function buildClient(sessionString = readSession()) {
43
44
  config.assertCredentials();
44
45
 
46
+ const clientParams = {
47
+ connectionRetries: 10,
48
+ autoReconnect: true,
49
+ retryDelay: 1500,
50
+ baseLogger: new Logger(LogLevel.ERROR),
51
+ useWSS: false,
52
+ };
53
+
54
+ if (config.proxy) {
55
+ clientParams.proxy = config.proxy;
56
+ clientParams.networkSocket = TuiGramNetSockets;
57
+ }
58
+
45
59
  const client = new TelegramClient(
46
60
  new StringSession(sessionString),
47
61
  config.apiId,
48
62
  config.apiHash,
49
- {
50
- connectionRetries: 10,
51
- autoReconnect: true,
52
- retryDelay: 1500,
53
- baseLogger: new Logger(LogLevel.ERROR),
54
- useWSS: false,
55
- }
63
+ clientParams
56
64
  );
57
65
 
58
66
  return client;
@@ -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
+ }