tg-mtproto-cli 0.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -0
  3. package/dist/main.mjs +1142 -0
  4. package/package.json +61 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cyberash
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # tg-mtproto-cli
2
+
3
+ CLI for Telegram via MTProto. Direct access to chats, messages and media — no Bot API, no limits.
4
+
5
+ ## Features
6
+
7
+ - **Multi-account** — manage multiple Telegram accounts with aliases
8
+ - **Chats** — list dialogs with unread counters
9
+ - **Messages** — fetch messages from any chat, group or supergroup
10
+ - **Forum topics** — list and read topics in supergroup forums
11
+ - **Media download** — download photos, videos, documents
12
+ - **Time filter** — `--after` to show messages since a specific time
13
+ - **JSON output** — `--json` flag for scripting and piping
14
+ - **Secure storage** — credentials in system keychain (macOS Keychain, Windows Credential Vault, Linux Secret Service)
15
+
16
+ ## Prerequisites
17
+
18
+ You need Telegram API credentials (`api_id` and `api_hash`).
19
+ Get them at [my.telegram.org](https://my.telegram.org/apps).
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ npm install -g tg-mtproto-cli
25
+ ```
26
+
27
+ ## Quick start
28
+
29
+ ```bash
30
+ # Authenticate (will prompt for API credentials on first run)
31
+ tg auth
32
+
33
+ # List your chats
34
+ tg chats
35
+
36
+ # Last 20 messages from a chat
37
+ tg messages @username -n 20
38
+
39
+ # Messages since 9am today
40
+ tg messages @username --after 09:00
41
+
42
+ # Download a photo
43
+ tg download @username 42
44
+ ```
45
+
46
+ ## Commands
47
+
48
+ | Command | Description |
49
+ |---|---|
50
+ | `tg auth` | Authenticate a Telegram account |
51
+ | `tg logout <alias>` | Log out and remove session |
52
+ | `tg accounts` | List all accounts |
53
+ | `tg accounts rename <old> <new>` | Rename an account alias |
54
+ | `tg default <alias>` | Set the default account |
55
+ | `tg chats` | List chats |
56
+ | `tg messages <chat>` | List messages from a chat |
57
+ | `tg topics <chat>` | List forum topics in a supergroup |
58
+ | `tg download <chat> <messageId>` | Download media from a message |
59
+
60
+ ## Messages options
61
+
62
+ ```bash
63
+ tg messages <chat> [options]
64
+ ```
65
+
66
+ | Option | Description |
67
+ |---|---|
68
+ | `-n <count>` | Number of messages (default: 100) |
69
+ | `--all` | Load entire chat history |
70
+ | `--topic <id>` | Messages from a specific forum topic |
71
+ | `--after <datetime>` | Messages after a given time |
72
+
73
+ `--after` accepts: `2026-02-22`, `2026-02-22T10:00`, `10:00` (today).
74
+
75
+ ## Global flags
76
+
77
+ | Flag | Description |
78
+ |---|---|
79
+ | `--account <alias>` | Use a specific account |
80
+ | `--json` | Output in JSON format |
81
+
82
+ ## How it works
83
+
84
+ Uses [mtcute](https://github.com/mtcute/mtcute) for MTProto protocol.
85
+ Sessions are stored in `~/.tg-mtproto-cli/sessions/`.
86
+ Credentials are stored in the system keychain via [cross-keychain](https://github.com/nicolo-ribaudo/cross-keychain).
87
+
88
+ ## License
89
+
90
+ MIT
package/dist/main.mjs ADDED
@@ -0,0 +1,1142 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import chalk from "chalk";
4
+ import Table from "cli-table3";
5
+ import { createInterface } from "node:readline";
6
+ import { Presets, SingleBar } from "cli-progress";
7
+ import { deletePassword, getPassword, setPassword } from "cross-keychain";
8
+ import { existsSync, mkdirSync, unlinkSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { TelegramClient } from "@mtcute/node";
12
+ import { encodeQR } from "qr";
13
+
14
+ //#region src/cli/formatters/account-formatter.ts
15
+ function formatAccountsTable(accounts) {
16
+ if (accounts.length === 0) return chalk.yellow("No accounts found. Use 'tg auth' to log in.");
17
+ const table = new Table({
18
+ head: [
19
+ "#",
20
+ "Alias",
21
+ "Phone",
22
+ "Name",
23
+ "Default"
24
+ ],
25
+ style: { head: ["cyan"] }
26
+ });
27
+ accounts.forEach((acc, i) => {
28
+ const name = acc.lastName ? `${acc.firstName} ${acc.lastName}` : acc.firstName;
29
+ table.push([
30
+ i + 1,
31
+ acc.alias,
32
+ acc.phone,
33
+ name,
34
+ acc.isDefault ? chalk.green("*") : ""
35
+ ]);
36
+ });
37
+ return table.toString();
38
+ }
39
+ function formatAccountsJson(accounts) {
40
+ return JSON.stringify(accounts, null, 2);
41
+ }
42
+
43
+ //#endregion
44
+ //#region src/cli/commands/accounts.ts
45
+ function registerAccountsCommand(program, accountsListUseCase, accountsRenameUseCase) {
46
+ const accountsCmd = program.command("accounts").description("Manage accounts");
47
+ accountsCmd.command("list", { isDefault: true }).description("List all logged-in accounts").action(async () => {
48
+ try {
49
+ const accounts = await accountsListUseCase.execute();
50
+ const json = program.opts().json;
51
+ console.log(json ? formatAccountsJson(accounts) : formatAccountsTable(accounts));
52
+ } catch (err) {
53
+ const message = err instanceof Error ? err.message : String(err);
54
+ console.error(chalk.red(message));
55
+ process.exit(1);
56
+ }
57
+ });
58
+ accountsCmd.command("rename <old> <new>").description("Rename an account alias").action(async (oldAlias, newAlias) => {
59
+ try {
60
+ await accountsRenameUseCase.execute(oldAlias, newAlias);
61
+ console.log(chalk.green(`Account alias renamed: "${oldAlias}" -> "${newAlias}"`));
62
+ } catch (err) {
63
+ const message = err instanceof Error ? err.message : String(err);
64
+ console.error(chalk.red(message));
65
+ process.exit(1);
66
+ }
67
+ });
68
+ }
69
+
70
+ //#endregion
71
+ //#region src/cli/commands/auth.ts
72
+ function prompt(rl, question, isPassword = false) {
73
+ return new Promise((resolve) => rl.question(question, (answer) => {
74
+ if (isPassword) resolve(answer);
75
+ else resolve(answer.trim());
76
+ }));
77
+ }
78
+ function registerAuthCommand(program, authUseCase) {
79
+ program.command("auth").description("Authorize a Telegram account").action(async () => {
80
+ const rl = createInterface({
81
+ input: process.stdin,
82
+ output: process.stdout
83
+ });
84
+ try {
85
+ const account = await authUseCase.execute({
86
+ promptApiId: async () => {
87
+ const raw = await prompt(rl, "API ID (from https://my.telegram.org): ");
88
+ const id = parseInt(raw, 10);
89
+ if (Number.isNaN(id)) throw new Error("Invalid API ID");
90
+ return id;
91
+ },
92
+ promptApiHash: async () => {
93
+ return prompt(rl, "API Hash: ");
94
+ },
95
+ promptPhone: async () => {
96
+ return prompt(rl, "Phone number: ");
97
+ },
98
+ promptCode: async () => {
99
+ return prompt(rl, "Confirmation code: ");
100
+ },
101
+ promptPassword: async () => {
102
+ return prompt(rl, "2FA password: ", true);
103
+ },
104
+ promptAlias: async (defaultAlias) => {
105
+ return (await prompt(rl, `Alias (default: ${defaultAlias}): `)).trim() || defaultAlias;
106
+ },
107
+ onAliasTaken: (alias) => {
108
+ console.error(chalk.red(`Alias "${alias}" is already taken. Choose another.`));
109
+ },
110
+ onAlreadyLoggedIn: async (existing) => {
111
+ return (await prompt(rl, chalk.yellow(`Account "${existing.alias}" (${existing.phone}) is already logged in. Re-authenticate? (y/n): `))).toLowerCase() === "y";
112
+ }
113
+ });
114
+ const name = account.lastName ? `${account.firstName} ${account.lastName}` : account.firstName;
115
+ const username = account.username ? ` (@${account.username})` : "";
116
+ console.log(chalk.green(`Logged in as ${name}${username}, alias: ${account.alias}`));
117
+ } catch (err) {
118
+ const message = err instanceof Error ? err.message : String(err);
119
+ console.error(chalk.red(message));
120
+ process.exit(1);
121
+ } finally {
122
+ rl.close();
123
+ }
124
+ });
125
+ }
126
+
127
+ //#endregion
128
+ //#region src/cli/formatters/chat-formatter.ts
129
+ function formatTimeAgo(date) {
130
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1e3);
131
+ if (seconds < 60) return `${seconds}s ago`;
132
+ const minutes = Math.floor(seconds / 60);
133
+ if (minutes < 60) return `${minutes} min ago`;
134
+ const hours = Math.floor(minutes / 60);
135
+ if (hours < 24) return `${hours}h ago`;
136
+ return `${Math.floor(hours / 24)}d ago`;
137
+ }
138
+ function formatChatsTable(chats) {
139
+ if (chats.length === 0) return chalk.yellow("No chats found.");
140
+ const table = new Table({
141
+ head: [
142
+ "#",
143
+ "Chat ID",
144
+ "Type",
145
+ "Title",
146
+ "Unread",
147
+ "Last Activity"
148
+ ],
149
+ style: { head: ["cyan"] }
150
+ });
151
+ chats.forEach((chat, i) => {
152
+ table.push([
153
+ i + 1,
154
+ chat.id,
155
+ chat.type,
156
+ chat.title,
157
+ chat.unreadCount > 0 ? chalk.bold(String(chat.unreadCount)) : "—",
158
+ formatTimeAgo(chat.lastActivity)
159
+ ]);
160
+ });
161
+ return table.toString();
162
+ }
163
+ function formatChatsJson(chats) {
164
+ return JSON.stringify(chats, null, 2);
165
+ }
166
+
167
+ //#endregion
168
+ //#region src/cli/commands/chats.ts
169
+ const VALID_TYPES = [
170
+ "private",
171
+ "group",
172
+ "supergroup",
173
+ "channel"
174
+ ];
175
+ function registerChatsCommand(program, chatsListUseCase) {
176
+ program.command("chats").description("List chats").option("--type <type>", "Filter by chat type: private, group, supergroup, channel").option("--limit <n>", "Number of chats to show", "50").option("--offset <n>", "Offset for pagination", "0").action(async (opts) => {
177
+ try {
178
+ if (opts.type && !VALID_TYPES.includes(opts.type)) {
179
+ console.error(chalk.red(`Invalid chat type. Use: ${VALID_TYPES.join(", ")}`));
180
+ process.exit(1);
181
+ }
182
+ const chats = await chatsListUseCase.execute({
183
+ alias: program.opts().account,
184
+ limit: parseInt(opts.limit, 10),
185
+ offset: parseInt(opts.offset, 10),
186
+ type: opts.type
187
+ });
188
+ const json = program.opts().json;
189
+ console.log(json ? formatChatsJson(chats) : formatChatsTable(chats));
190
+ } catch (err) {
191
+ const message = err instanceof Error ? err.message : String(err);
192
+ console.error(chalk.red(message));
193
+ process.exit(message.includes("No default") || message.includes("expired") ? 2 : 1);
194
+ }
195
+ });
196
+ }
197
+
198
+ //#endregion
199
+ //#region src/cli/commands/default.ts
200
+ function registerDefaultCommand(program, setDefaultUseCase) {
201
+ program.command("default <alias>").description("Set the default account").action(async (alias) => {
202
+ try {
203
+ const account = await setDefaultUseCase.execute(alias);
204
+ const name = account.lastName ? `${account.firstName} ${account.lastName}` : account.firstName;
205
+ console.log(chalk.green(`Default account set to "${account.alias}" (${account.phone}, ${name})`));
206
+ } catch (err) {
207
+ const message = err instanceof Error ? err.message : String(err);
208
+ if (message.includes("already the default")) {
209
+ console.log(message);
210
+ process.exit(0);
211
+ }
212
+ console.error(chalk.red(message));
213
+ process.exit(message.includes("not found") ? 1 : 2);
214
+ }
215
+ });
216
+ }
217
+
218
+ //#endregion
219
+ //#region src/cli/commands/download.ts
220
+ function registerDownloadCommand(program, downloadMediaUseCase) {
221
+ program.command("download <chat> <messageId>").description("Download media from a message").option("--out <dir>", "Output directory", ".").action(async (chat, messageId, opts) => {
222
+ try {
223
+ const json = program.opts().json;
224
+ let progressBar = null;
225
+ if (!json) progressBar = new SingleBar({ format: "{bar} {percentage}% | {value}/{total} bytes" }, Presets.shades_classic);
226
+ const result = await downloadMediaUseCase.execute({
227
+ alias: program.opts().account,
228
+ chat,
229
+ messageId: parseInt(messageId, 10),
230
+ outputDir: opts.out,
231
+ onProgress: json ? void 0 : (downloaded, total) => {
232
+ if (progressBar) {
233
+ if (total > 0 && !progressBar.isActive) progressBar.start(total, 0);
234
+ progressBar.update(downloaded);
235
+ }
236
+ }
237
+ });
238
+ progressBar?.stop();
239
+ if (json) console.log(JSON.stringify(result, null, 2));
240
+ else console.log(chalk.green(`\nSaved to ${result.path}`));
241
+ } catch (err) {
242
+ const message = err instanceof Error ? err.message : String(err);
243
+ console.error(chalk.red(message));
244
+ process.exit(message.includes("No default") || message.includes("expired") ? 2 : 1);
245
+ }
246
+ });
247
+ }
248
+
249
+ //#endregion
250
+ //#region src/cli/commands/logout.ts
251
+ function registerLogoutCommand(program, logoutUseCase) {
252
+ program.command("logout [alias]").description("Log out from a Telegram account").action(async (alias) => {
253
+ try {
254
+ const { account, othersExist } = await logoutUseCase.execute(alias);
255
+ const name = account.lastName ? `${account.firstName} ${account.lastName}` : account.firstName;
256
+ console.log(chalk.green(`Logged out from "${account.alias}" (${account.phone}, ${name})`));
257
+ if (account.isDefault && othersExist) console.log(chalk.yellow(`No default account set. Use 'tg default <alias>' to set one.`));
258
+ } catch (err) {
259
+ const message = err instanceof Error ? err.message : String(err);
260
+ console.error(chalk.red(message));
261
+ process.exit(message.includes("No accounts") ? 2 : 1);
262
+ }
263
+ });
264
+ }
265
+
266
+ //#endregion
267
+ //#region src/cli/formatters/message-formatter.ts
268
+ function formatDate(date) {
269
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
270
+ }
271
+ function formatMediaPlaceholder(media) {
272
+ switch (media.type) {
273
+ case "photo": return "[Photo]";
274
+ case "video": return media.fileName ? `[Video: ${media.fileName}${media.size ? `, ${formatSize(media.size)}` : ""}]` : "[Video]";
275
+ case "document": return media.fileName ? `[Document: ${media.fileName}${media.size ? `, ${formatSize(media.size)}` : ""}]` : "[Document]";
276
+ case "audio": return media.fileName ? `[Audio: ${media.fileName}]` : "[Audio]";
277
+ case "voice": return "[Voice]";
278
+ case "video_note": return "[Video message]";
279
+ case "sticker": return "[Sticker]";
280
+ case "animation": return "[GIF]";
281
+ default: return "[Media]";
282
+ }
283
+ }
284
+ function formatSize(bytes) {
285
+ if (bytes < 1024) return `${bytes} B`;
286
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
287
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
288
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
289
+ }
290
+ function formatMessagesText(messages) {
291
+ if (messages.length === 0) return "";
292
+ return messages.map((msg) => {
293
+ const lines = [`${chalk.dim(`[${formatDate(msg.date)}]`)} ${chalk.cyan(`#${msg.id}`)} ${chalk.bold(msg.senderName)}:`];
294
+ if (msg.text) lines.push(` ${msg.text}`);
295
+ if (msg.media) lines.push(` ${chalk.italic(formatMediaPlaceholder(msg.media))}`);
296
+ if (!msg.text && !msg.media) lines.push(` ${chalk.dim("[empty message]")}`);
297
+ return lines.join("\n");
298
+ }).join("\n\n");
299
+ }
300
+ function formatMessagesJson(messages) {
301
+ return JSON.stringify(messages, null, 2);
302
+ }
303
+
304
+ //#endregion
305
+ //#region src/cli/commands/messages.ts
306
+ function parseAfterDate(value) {
307
+ if (/^\d{1,2}:\d{2}$/.test(value)) {
308
+ const today = /* @__PURE__ */ new Date();
309
+ const [h, m] = value.split(":").map(Number);
310
+ today.setHours(h, m, 0, 0);
311
+ return today;
312
+ }
313
+ const parsed = new Date(value);
314
+ if (Number.isNaN(parsed.getTime())) throw new Error("Invalid date format. Use: YYYY-MM-DD, YYYY-MM-DDTHH:mm, or HH:mm");
315
+ return parsed;
316
+ }
317
+ function registerMessagesCommand(program, messagesListUseCase) {
318
+ program.command("messages <chat>").description("List messages from a chat").option("-n <count>", "Number of messages to load", "100").option("--all", "Load all messages", false).option("--topic <id>", "Load messages from a specific forum topic").option("--after <datetime>", "Show messages after given time (YYYY-MM-DD, YYYY-MM-DDTHH:mm, HH:mm)").action(async (chat, opts) => {
319
+ try {
320
+ const json = program.opts().json;
321
+ const after = opts.after ? parseAfterDate(opts.after) : void 0;
322
+ const messages = await messagesListUseCase.execute({
323
+ alias: program.opts().account,
324
+ chat,
325
+ limit: parseInt(opts.n, 10),
326
+ all: opts.all,
327
+ threadId: opts.topic ? parseInt(opts.topic, 10) : void 0,
328
+ after,
329
+ onProgress: json ? void 0 : (loaded) => {
330
+ process.stderr.write(`\rLoading messages... ${loaded} loaded`);
331
+ }
332
+ });
333
+ if (!json && (opts.all || after)) process.stderr.write(`\r${" ".repeat(50)}\r`);
334
+ console.log(json ? formatMessagesJson(messages) : formatMessagesText(messages));
335
+ } catch (err) {
336
+ const message = err instanceof Error ? err.message : String(err);
337
+ console.error(chalk.red(message));
338
+ process.exit(message.includes("No default") || message.includes("expired") ? 2 : 1);
339
+ }
340
+ });
341
+ }
342
+
343
+ //#endregion
344
+ //#region src/cli/formatters/topic-formatter.ts
345
+ function formatTopicsTable(topics) {
346
+ if (topics.length === 0) return chalk.yellow("No topics found. This chat may not be a forum.");
347
+ const table = new Table({
348
+ head: [
349
+ "#",
350
+ "Topic ID",
351
+ "Title",
352
+ "Unread",
353
+ "Status"
354
+ ],
355
+ style: { head: ["cyan"] }
356
+ });
357
+ topics.forEach((topic, i) => {
358
+ const status = topic.isClosed ? chalk.dim("closed") : chalk.green("open");
359
+ const pin = topic.isPinned ? ` ${chalk.yellow("pinned")}` : "";
360
+ table.push([
361
+ i + 1,
362
+ topic.id,
363
+ topic.title,
364
+ topic.unreadCount > 0 ? chalk.bold(String(topic.unreadCount)) : "—",
365
+ status + pin
366
+ ]);
367
+ });
368
+ return table.toString();
369
+ }
370
+ function formatTopicsJson(topics) {
371
+ return JSON.stringify(topics, null, 2);
372
+ }
373
+
374
+ //#endregion
375
+ //#region src/cli/commands/topics.ts
376
+ function registerTopicsCommand(program, topicsListUseCase) {
377
+ program.command("topics <chat>").description("List forum topics in a supergroup").option("--limit <n>", "Number of topics to show", "100").action(async (chat, opts) => {
378
+ try {
379
+ const topics = await topicsListUseCase.execute({
380
+ alias: program.opts().account,
381
+ chat,
382
+ limit: parseInt(opts.limit, 10)
383
+ });
384
+ const json = program.opts().json;
385
+ console.log(json ? formatTopicsJson(topics) : formatTopicsTable(topics));
386
+ } catch (err) {
387
+ const message = err instanceof Error ? err.message : String(err);
388
+ console.error(chalk.red(message));
389
+ process.exit(message.includes("No default") ? 2 : 1);
390
+ }
391
+ });
392
+ }
393
+
394
+ //#endregion
395
+ //#region src/cli/app.ts
396
+ function createApp(useCases) {
397
+ const program = new Command();
398
+ program.name("tg").description("CLI for Telegram via MTProto").version("0.1.0").option("--account <alias>", "Use a specific account instead of default").option("--json", "Output in JSON format");
399
+ registerAuthCommand(program, useCases.auth);
400
+ registerLogoutCommand(program, useCases.logout);
401
+ registerAccountsCommand(program, useCases.accountsList, useCases.accountsRename);
402
+ registerDefaultCommand(program, useCases.setDefault);
403
+ registerChatsCommand(program, useCases.chatsList);
404
+ registerMessagesCommand(program, useCases.messagesList);
405
+ registerDownloadCommand(program, useCases.downloadMedia);
406
+ registerTopicsCommand(program, useCases.topicsList);
407
+ return program;
408
+ }
409
+
410
+ //#endregion
411
+ //#region src/infrastructure/keychain-credential-storage.ts
412
+ const SERVICE = "tg-mtproto-cli";
413
+ const KEY_API_CREDENTIALS = "api_credentials";
414
+ const KEY_ACCOUNTS_INDEX = "accounts_index";
415
+ const KEY_DEFAULT_ACCOUNT = "default_account";
416
+ const accountKey = (alias) => `account.${alias}`;
417
+ async function readJson(account) {
418
+ const raw = await getPassword(SERVICE, account);
419
+ if (!raw) return null;
420
+ return JSON.parse(raw);
421
+ }
422
+ async function writeJson(account, data) {
423
+ await setPassword(SERVICE, account, JSON.stringify(data));
424
+ }
425
+ var KeychainCredentialStorage = class {
426
+ async getAccounts() {
427
+ const aliases = await readJson(KEY_ACCOUNTS_INDEX);
428
+ if (!aliases || aliases.length === 0) return [];
429
+ const accounts = [];
430
+ for (const alias of aliases) {
431
+ const acc = await readJson(accountKey(alias));
432
+ if (acc) accounts.push(acc);
433
+ }
434
+ return accounts;
435
+ }
436
+ async getAccountByAlias(alias) {
437
+ return readJson(accountKey(alias));
438
+ }
439
+ async getDefaultAccount() {
440
+ const defaultAlias = await getPassword(SERVICE, KEY_DEFAULT_ACCOUNT);
441
+ if (!defaultAlias) return null;
442
+ const alias = JSON.parse(defaultAlias);
443
+ return this.getAccountByAlias(alias);
444
+ }
445
+ async saveAccount(account) {
446
+ await writeJson(accountKey(account.alias), account);
447
+ const aliases = await readJson(KEY_ACCOUNTS_INDEX) ?? [];
448
+ if (!aliases.includes(account.alias)) {
449
+ aliases.push(account.alias);
450
+ await writeJson(KEY_ACCOUNTS_INDEX, aliases);
451
+ }
452
+ }
453
+ async removeAccount(alias) {
454
+ await deletePassword(SERVICE, accountKey(alias));
455
+ await writeJson(KEY_ACCOUNTS_INDEX, (await readJson(KEY_ACCOUNTS_INDEX) ?? []).filter((a) => a !== alias));
456
+ const defaultAlias = await getPassword(SERVICE, KEY_DEFAULT_ACCOUNT);
457
+ if (defaultAlias && JSON.parse(defaultAlias) === alias) await deletePassword(SERVICE, KEY_DEFAULT_ACCOUNT);
458
+ }
459
+ async setDefault(alias) {
460
+ const accounts = await this.getAccounts();
461
+ for (const acc of accounts) {
462
+ const updated = {
463
+ ...acc,
464
+ isDefault: acc.alias === alias
465
+ };
466
+ await writeJson(accountKey(acc.alias), updated);
467
+ }
468
+ await writeJson(KEY_DEFAULT_ACCOUNT, alias);
469
+ }
470
+ async isAliasAvailable(alias) {
471
+ return await readJson(accountKey(alias)) === null;
472
+ }
473
+ async renameAlias(oldAlias, newAlias) {
474
+ const account = await readJson(accountKey(oldAlias));
475
+ if (!account) throw new Error(`Account "${oldAlias}" not found`);
476
+ const updated = {
477
+ ...account,
478
+ alias: newAlias
479
+ };
480
+ await writeJson(accountKey(newAlias), updated);
481
+ await deletePassword(SERVICE, accountKey(oldAlias));
482
+ const aliases = await readJson(KEY_ACCOUNTS_INDEX) ?? [];
483
+ const idx = aliases.indexOf(oldAlias);
484
+ if (idx !== -1) aliases[idx] = newAlias;
485
+ await writeJson(KEY_ACCOUNTS_INDEX, aliases);
486
+ const defaultAlias = await getPassword(SERVICE, KEY_DEFAULT_ACCOUNT);
487
+ if (defaultAlias && JSON.parse(defaultAlias) === oldAlias) await writeJson(KEY_DEFAULT_ACCOUNT, newAlias);
488
+ }
489
+ async getApiCredentials() {
490
+ return readJson(KEY_API_CREDENTIALS);
491
+ }
492
+ async saveApiCredentials(apiId, apiHash) {
493
+ await writeJson(KEY_API_CREDENTIALS, {
494
+ apiId,
495
+ apiHash
496
+ });
497
+ }
498
+ };
499
+
500
+ //#endregion
501
+ //#region src/infrastructure/sqlite-session-storage.ts
502
+ var SqliteSessionStorage = class {
503
+ sessionsDir;
504
+ constructor() {
505
+ this.sessionsDir = join(homedir(), ".tg-mtproto-cli", "sessions");
506
+ mkdirSync(this.sessionsDir, { recursive: true });
507
+ }
508
+ getSessionPath(phone) {
509
+ return join(this.sessionsDir, `${phone}.session`);
510
+ }
511
+ sessionExists(phone) {
512
+ return existsSync(this.getSessionPath(phone));
513
+ }
514
+ async removeSession(phone) {
515
+ const path = this.getSessionPath(phone);
516
+ if (existsSync(path)) unlinkSync(path);
517
+ }
518
+ };
519
+
520
+ //#endregion
521
+ //#region src/infrastructure/mtcute-telegram-gateway.ts
522
+ function mapMediaTypeFromMtcute(media) {
523
+ switch (media.type) {
524
+ case "photo": return "photo";
525
+ case "video": return "video";
526
+ case "document": return "document";
527
+ case "audio": return "audio";
528
+ case "voice": return "voice";
529
+ case "sticker": return "sticker";
530
+ default: return null;
531
+ }
532
+ }
533
+ function mapMedia(raw) {
534
+ if (!raw) return null;
535
+ const mediaType = mapMediaTypeFromMtcute(raw);
536
+ if (!mediaType) return null;
537
+ let fileName = null;
538
+ let mimeType = null;
539
+ let size = null;
540
+ if ("fileName" in raw && typeof raw.fileName === "string") fileName = raw.fileName;
541
+ if ("mimeType" in raw && typeof raw.mimeType === "string") mimeType = raw.mimeType;
542
+ if ("fileSize" in raw && typeof raw.fileSize === "number") size = raw.fileSize;
543
+ if (!mimeType) mimeType = {
544
+ photo: "image/jpeg",
545
+ sticker: "image/webp",
546
+ voice: "audio/ogg"
547
+ }[mediaType] ?? null;
548
+ return {
549
+ type: mediaType,
550
+ fileName,
551
+ mimeType,
552
+ size
553
+ };
554
+ }
555
+ const MIME_EXT = {
556
+ "image/jpeg": ".jpg",
557
+ "image/png": ".png",
558
+ "image/webp": ".webp",
559
+ "image/gif": ".gif",
560
+ "video/mp4": ".mp4",
561
+ "video/quicktime": ".mov",
562
+ "audio/mpeg": ".mp3",
563
+ "audio/ogg": ".ogg",
564
+ "application/pdf": ".pdf"
565
+ };
566
+ function extFromMime(mime) {
567
+ if (!mime) return "";
568
+ return MIME_EXT[mime] ?? "";
569
+ }
570
+ function parseChatId(chatId) {
571
+ if (typeof chatId === "number") return chatId;
572
+ const num = Number(chatId);
573
+ return Number.isFinite(num) ? num : chatId;
574
+ }
575
+ function mapPeerChatType(peer) {
576
+ switch (peer.type) {
577
+ case "user":
578
+ case "bot": return "private";
579
+ case "group": return "group";
580
+ case "supergroup": return "supergroup";
581
+ case "channel": return "channel";
582
+ default: return "private";
583
+ }
584
+ }
585
+ var MtcuteTelegramGateway = class {
586
+ client;
587
+ constructor(params) {
588
+ this.client = new TelegramClient({
589
+ apiId: params.apiId,
590
+ apiHash: params.apiHash,
591
+ storage: params.sessionPath
592
+ });
593
+ }
594
+ async connect() {
595
+ await this.client.connect();
596
+ }
597
+ async disconnect() {
598
+ await this.client.destroy();
599
+ }
600
+ async auth(params) {
601
+ const user = await this.client.start({
602
+ phone: params.phone,
603
+ password: params.password,
604
+ qrCodeHandler: (url) => {
605
+ console.log(encodeQR(url, "ascii"));
606
+ }
607
+ });
608
+ return {
609
+ id: user.id,
610
+ phone: user.phoneNumber ?? "",
611
+ firstName: user.firstName ?? "",
612
+ lastName: user.lastName ?? null,
613
+ username: user.username ?? null,
614
+ alias: "",
615
+ isDefault: false
616
+ };
617
+ }
618
+ async logout() {
619
+ await this.client.logOut();
620
+ }
621
+ async getChats(params) {
622
+ const chats = [];
623
+ let count = 0;
624
+ for await (const dialog of this.client.iterDialogs({ limit: params.limit + params.offset })) {
625
+ if (count < params.offset) {
626
+ count++;
627
+ continue;
628
+ }
629
+ const peer = dialog.peer;
630
+ const chatType = mapPeerChatType(peer);
631
+ if (params.type && chatType !== params.type) continue;
632
+ const lastMsg = dialog.lastMessage;
633
+ let lastMessageText = null;
634
+ if (lastMsg) {
635
+ const text = lastMsg.text;
636
+ if (text) lastMessageText = text.length > 100 ? `${text.slice(0, 100)}...` : text;
637
+ else if (lastMsg.media) lastMessageText = `[${lastMsg.media.type}]`;
638
+ }
639
+ chats.push({
640
+ id: peer.id,
641
+ type: chatType,
642
+ title: peer.displayName,
643
+ username: "username" in peer ? peer.username ?? null : null,
644
+ unreadCount: dialog.unreadCount,
645
+ lastMessage: lastMessageText,
646
+ lastActivity: lastMsg?.date ?? /* @__PURE__ */ new Date()
647
+ });
648
+ if (chats.length >= params.limit) break;
649
+ }
650
+ return chats;
651
+ }
652
+ async getMessages(params) {
653
+ const resolvedChatId = parseChatId(params.chatId);
654
+ const scanLimit = params.after ? Infinity : params.all ? Infinity : params.limit;
655
+ let messages;
656
+ if (params.threadId) messages = await this.getMessagesByThread(resolvedChatId, params.threadId, scanLimit, params.after, params.onProgress);
657
+ else messages = await this.getMessagesByHistory(resolvedChatId, scanLimit, params.after, params.onProgress);
658
+ if (!params.all && params.limit < messages.length) return messages.slice(-params.limit);
659
+ return messages;
660
+ }
661
+ async getMessagesByHistory(chatId, limit, after, onProgress) {
662
+ if (after) return this.getHistoryForward(chatId, after, onProgress);
663
+ return this.getHistoryBackward(chatId, limit, onProgress);
664
+ }
665
+ async getHistoryForward(chatId, after, onProgress) {
666
+ const messages = [];
667
+ let offset = {
668
+ id: 0,
669
+ date: Math.floor(after.getTime() / 1e3)
670
+ };
671
+ let loaded = 0;
672
+ while (true) {
673
+ const batch = await this.client.getHistory(chatId, {
674
+ limit: 100,
675
+ offset,
676
+ reverse: true
677
+ });
678
+ if (batch.length === 0) break;
679
+ for (const msg of batch) messages.push({
680
+ id: msg.id,
681
+ chatId: msg.chat.id,
682
+ senderName: msg.sender.displayName,
683
+ text: msg.text || null,
684
+ date: msg.date,
685
+ media: mapMedia(msg.media),
686
+ replyToId: msg.replyToMessage?.id ?? null,
687
+ isOutgoing: msg.isOutgoing
688
+ });
689
+ loaded += batch.length;
690
+ onProgress?.(loaded);
691
+ const lastMsg = batch[batch.length - 1];
692
+ offset = {
693
+ id: lastMsg.id,
694
+ date: Math.floor(lastMsg.date.getTime() / 1e3)
695
+ };
696
+ if (batch.length < 100) break;
697
+ }
698
+ return messages;
699
+ }
700
+ async getHistoryBackward(chatId, limit, onProgress) {
701
+ const messages = [];
702
+ let offset;
703
+ let loaded = 0;
704
+ while (loaded < limit) {
705
+ const batchSize = Math.min(limit === Infinity ? 100 : limit - loaded, 100);
706
+ const batch = await this.client.getHistory(chatId, {
707
+ limit: batchSize,
708
+ offset
709
+ });
710
+ if (batch.length === 0) break;
711
+ for (const msg of batch) messages.push({
712
+ id: msg.id,
713
+ chatId: msg.chat.id,
714
+ senderName: msg.sender.displayName,
715
+ text: msg.text || null,
716
+ date: msg.date,
717
+ media: mapMedia(msg.media),
718
+ replyToId: msg.replyToMessage?.id ?? null,
719
+ isOutgoing: msg.isOutgoing
720
+ });
721
+ loaded += batch.length;
722
+ onProgress?.(loaded);
723
+ const lastMsg = batch[batch.length - 1];
724
+ if (lastMsg) offset = {
725
+ id: lastMsg.id,
726
+ date: Math.floor(lastMsg.date.getTime() / 1e3)
727
+ };
728
+ if (batch.length < batchSize) break;
729
+ }
730
+ messages.reverse();
731
+ return messages;
732
+ }
733
+ async getMessagesByThread(chatId, threadId, limit, after, onProgress) {
734
+ const messages = [];
735
+ let searchOffset = 0;
736
+ let loaded = 0;
737
+ let hitDateBoundary = false;
738
+ while (loaded < limit && !hitDateBoundary) {
739
+ const batchSize = Math.min(limit === Infinity ? 100 : limit - loaded, 100);
740
+ const batch = await this.client.searchMessages({
741
+ chatId,
742
+ threadId,
743
+ limit: batchSize,
744
+ offset: searchOffset
745
+ });
746
+ if (batch.length === 0) break;
747
+ for (const msg of batch) {
748
+ if (after && msg.date < after) {
749
+ hitDateBoundary = true;
750
+ break;
751
+ }
752
+ messages.push({
753
+ id: msg.id,
754
+ chatId: msg.chat.id,
755
+ senderName: msg.sender.displayName,
756
+ text: msg.text || null,
757
+ date: msg.date,
758
+ media: mapMedia(msg.media),
759
+ replyToId: msg.replyToMessage?.id ?? null,
760
+ isOutgoing: msg.isOutgoing
761
+ });
762
+ }
763
+ loaded += batch.length;
764
+ onProgress?.(loaded);
765
+ searchOffset = batch[batch.length - 1].id;
766
+ if (batch.length < batchSize) break;
767
+ }
768
+ messages.reverse();
769
+ return messages;
770
+ }
771
+ async downloadMedia(params) {
772
+ const [msg] = await this.client.getMessages(parseChatId(params.chatId), params.messageId);
773
+ if (!msg) throw new Error(`Message #${params.messageId} not found`);
774
+ if (!msg.media) throw new Error(`Message #${params.messageId} has no media attachment`);
775
+ const media = mapMedia(msg.media);
776
+ const fileName = media?.fileName ?? `${media?.type ?? "file"}_${params.messageId}${extFromMime(media?.mimeType)}`;
777
+ const fullPath = params.outputPath.endsWith("/") ? `${params.outputPath}${fileName}` : `${params.outputPath}/${fileName}`;
778
+ let totalSize = media?.size ?? 0;
779
+ let downloaded = 0;
780
+ const downloadable = msg.media;
781
+ await this.client.downloadToFile(fullPath, downloadable, { progressCallback: (current, total) => {
782
+ downloaded = Number(current);
783
+ totalSize = Number(total);
784
+ params.onProgress?.(downloaded, totalSize);
785
+ } });
786
+ return {
787
+ path: fullPath,
788
+ fileName,
789
+ size: totalSize,
790
+ mimeType: media?.mimeType ?? "application/octet-stream"
791
+ };
792
+ }
793
+ async getTopics(params) {
794
+ const resolvedChatId = parseChatId(params.chatId);
795
+ const topics = [];
796
+ for await (const topic of this.client.iterForumTopics(resolvedChatId, { limit: params.limit })) {
797
+ topics.push({
798
+ id: topic.id,
799
+ title: topic.title,
800
+ unreadCount: topic.unreadCount,
801
+ isClosed: topic.isClosed,
802
+ isPinned: topic.isPinned
803
+ });
804
+ if (topics.length >= params.limit) break;
805
+ }
806
+ return topics;
807
+ }
808
+ async resolveChat(identifier) {
809
+ const peer = await this.client.resolvePeer(parseChatId(identifier));
810
+ let type = "private";
811
+ let id = 0;
812
+ switch (peer._) {
813
+ case "inputPeerUser":
814
+ type = "private";
815
+ id = Number(peer.userId);
816
+ break;
817
+ case "inputPeerChat":
818
+ type = "group";
819
+ id = Number(peer.chatId);
820
+ break;
821
+ case "inputPeerChannel":
822
+ type = "supergroup";
823
+ id = Number(peer.channelId);
824
+ break;
825
+ }
826
+ return {
827
+ id,
828
+ type
829
+ };
830
+ }
831
+ };
832
+
833
+ //#endregion
834
+ //#region src/infrastructure/telegram-gateway-factory.ts
835
+ const createTelegramGateway = (params) => {
836
+ return new MtcuteTelegramGateway(params);
837
+ };
838
+
839
+ //#endregion
840
+ //#region src/use-cases/accounts-list.ts
841
+ var AccountsListUseCase = class {
842
+ constructor(credentialStorage) {
843
+ this.credentialStorage = credentialStorage;
844
+ }
845
+ async execute() {
846
+ return this.credentialStorage.getAccounts();
847
+ }
848
+ };
849
+
850
+ //#endregion
851
+ //#region src/use-cases/accounts-rename.ts
852
+ const ALIAS_REGEX = /^[a-zA-Z0-9_-]+$/;
853
+ var AccountsRenameUseCase = class {
854
+ constructor(credentialStorage) {
855
+ this.credentialStorage = credentialStorage;
856
+ }
857
+ async execute(oldAlias, newAlias) {
858
+ if (oldAlias === newAlias) throw new Error(`Alias is already "${oldAlias}"`);
859
+ if (!ALIAS_REGEX.test(newAlias)) throw new Error("Invalid alias format. Use: letters, digits, hyphens, underscores");
860
+ if (!await this.credentialStorage.getAccountByAlias(oldAlias)) throw new Error(`Account "${oldAlias}" not found`);
861
+ if (!await this.credentialStorage.isAliasAvailable(newAlias)) throw new Error(`Alias "${newAlias}" is already taken`);
862
+ await this.credentialStorage.renameAlias(oldAlias, newAlias);
863
+ }
864
+ };
865
+
866
+ //#endregion
867
+ //#region src/use-cases/auth.ts
868
+ var AuthUseCase = class {
869
+ constructor(credentialStorage, sessionStorage, createGateway) {
870
+ this.credentialStorage = credentialStorage;
871
+ this.sessionStorage = sessionStorage;
872
+ this.createGateway = createGateway;
873
+ }
874
+ async execute(callbacks) {
875
+ let apiCreds = await this.credentialStorage.getApiCredentials();
876
+ if (!apiCreds) {
877
+ const apiId = await callbacks.promptApiId();
878
+ const apiHash = await callbacks.promptApiHash();
879
+ await this.credentialStorage.saveApiCredentials(apiId, apiHash);
880
+ apiCreds = {
881
+ apiId,
882
+ apiHash
883
+ };
884
+ }
885
+ const phone = (await callbacks.promptPhone()).replace(/[\s()-]/g, "");
886
+ const accounts = await this.credentialStorage.getAccounts();
887
+ const normalizedDigits = phone.replace(/\D/g, "");
888
+ const existing = accounts.find((a) => a.phone.replace(/\D/g, "") === normalizedDigits);
889
+ if (existing) {
890
+ if (!await callbacks.onAlreadyLoggedIn(existing)) throw new Error("Authentication cancelled");
891
+ await this.sessionStorage.removeSession(existing.phone);
892
+ await this.credentialStorage.removeAccount(existing.alias);
893
+ }
894
+ const sessionPath = this.sessionStorage.getSessionPath(phone);
895
+ const gateway = this.createGateway({
896
+ apiId: apiCreds.apiId,
897
+ apiHash: apiCreds.apiHash,
898
+ sessionPath
899
+ });
900
+ try {
901
+ await gateway.connect();
902
+ const rawAccount = await gateway.auth({
903
+ phone: async () => phone,
904
+ code: callbacks.promptCode,
905
+ password: callbacks.promptPassword
906
+ });
907
+ const defaultAlias = rawAccount.username ?? phone;
908
+ let alias = await callbacks.promptAlias(defaultAlias);
909
+ if (!alias) alias = defaultAlias;
910
+ while (!await this.credentialStorage.isAliasAvailable(alias)) {
911
+ callbacks.onAliasTaken(alias);
912
+ alias = await callbacks.promptAlias(defaultAlias);
913
+ if (!alias) alias = defaultAlias;
914
+ }
915
+ const isFirst = accounts.length === 0 || existing && accounts.length === 1;
916
+ const account = {
917
+ ...rawAccount,
918
+ phone,
919
+ alias,
920
+ isDefault: !!isFirst
921
+ };
922
+ await this.credentialStorage.saveAccount(account);
923
+ if (isFirst) await this.credentialStorage.setDefault(alias);
924
+ return account;
925
+ } finally {
926
+ await gateway.disconnect();
927
+ }
928
+ }
929
+ };
930
+
931
+ //#endregion
932
+ //#region src/use-cases/chats-list.ts
933
+ var ChatsListUseCase = class {
934
+ constructor(credentialStorage, sessionStorage, createGateway) {
935
+ this.credentialStorage = credentialStorage;
936
+ this.sessionStorage = sessionStorage;
937
+ this.createGateway = createGateway;
938
+ }
939
+ async execute(params) {
940
+ const account = params.alias ? await this.credentialStorage.getAccountByAlias(params.alias) : await this.credentialStorage.getDefaultAccount();
941
+ if (!account) throw new Error("No default account. Use 'tg default <alias>' or --account flag.");
942
+ const apiCreds = await this.credentialStorage.getApiCredentials();
943
+ if (!apiCreds) throw new Error("No API credentials. Run 'tg auth' first.");
944
+ const sessionPath = this.sessionStorage.getSessionPath(account.phone);
945
+ const gateway = this.createGateway({
946
+ apiId: apiCreds.apiId,
947
+ apiHash: apiCreds.apiHash,
948
+ sessionPath
949
+ });
950
+ try {
951
+ await gateway.connect();
952
+ return await gateway.getChats({
953
+ limit: params.limit,
954
+ offset: params.offset,
955
+ type: params.type
956
+ });
957
+ } finally {
958
+ await gateway.disconnect();
959
+ }
960
+ }
961
+ };
962
+
963
+ //#endregion
964
+ //#region src/use-cases/download-media.ts
965
+ var DownloadMediaUseCase = class {
966
+ constructor(credentialStorage, sessionStorage, createGateway) {
967
+ this.credentialStorage = credentialStorage;
968
+ this.sessionStorage = sessionStorage;
969
+ this.createGateway = createGateway;
970
+ }
971
+ async execute(params) {
972
+ const account = params.alias ? await this.credentialStorage.getAccountByAlias(params.alias) : await this.credentialStorage.getDefaultAccount();
973
+ if (!account) throw new Error("No default account. Use 'tg default <alias>' or --account flag.");
974
+ const apiCreds = await this.credentialStorage.getApiCredentials();
975
+ if (!apiCreds) throw new Error("No API credentials. Run 'tg auth' first.");
976
+ const sessionPath = this.sessionStorage.getSessionPath(account.phone);
977
+ const gateway = this.createGateway({
978
+ apiId: apiCreds.apiId,
979
+ apiHash: apiCreds.apiHash,
980
+ sessionPath
981
+ });
982
+ try {
983
+ await gateway.connect();
984
+ return await gateway.downloadMedia({
985
+ chatId: params.chat,
986
+ messageId: params.messageId,
987
+ outputPath: params.outputDir,
988
+ onProgress: params.onProgress
989
+ });
990
+ } finally {
991
+ await gateway.disconnect();
992
+ }
993
+ }
994
+ };
995
+
996
+ //#endregion
997
+ //#region src/use-cases/logout.ts
998
+ var LogoutUseCase = class {
999
+ constructor(credentialStorage, sessionStorage, createGateway) {
1000
+ this.credentialStorage = credentialStorage;
1001
+ this.sessionStorage = sessionStorage;
1002
+ this.createGateway = createGateway;
1003
+ }
1004
+ async execute(alias) {
1005
+ let account;
1006
+ if (alias) {
1007
+ account = await this.credentialStorage.getAccountByAlias(alias);
1008
+ if (!account) throw new Error(`Account "${alias}" not found`);
1009
+ } else {
1010
+ account = await this.credentialStorage.getDefaultAccount();
1011
+ if (!account) {
1012
+ if ((await this.credentialStorage.getAccounts()).length === 0) throw new Error("No accounts found. Use 'tg auth' to log in.");
1013
+ throw new Error("No default account set. Specify an alias or use 'tg default <alias>'.");
1014
+ }
1015
+ }
1016
+ const apiCreds = await this.credentialStorage.getApiCredentials();
1017
+ if (apiCreds) {
1018
+ const sessionPath = this.sessionStorage.getSessionPath(account.phone);
1019
+ const gateway = this.createGateway({
1020
+ apiId: apiCreds.apiId,
1021
+ apiHash: apiCreds.apiHash,
1022
+ sessionPath
1023
+ });
1024
+ try {
1025
+ await gateway.connect();
1026
+ await gateway.logout();
1027
+ } catch {} finally {
1028
+ await gateway.disconnect();
1029
+ }
1030
+ }
1031
+ await this.sessionStorage.removeSession(account.phone);
1032
+ await this.credentialStorage.removeAccount(account.alias);
1033
+ const remaining = await this.credentialStorage.getAccounts();
1034
+ return {
1035
+ account,
1036
+ othersExist: remaining.length > 0
1037
+ };
1038
+ }
1039
+ };
1040
+
1041
+ //#endregion
1042
+ //#region src/use-cases/messages-list.ts
1043
+ var MessagesListUseCase = class {
1044
+ constructor(credentialStorage, sessionStorage, createGateway) {
1045
+ this.credentialStorage = credentialStorage;
1046
+ this.sessionStorage = sessionStorage;
1047
+ this.createGateway = createGateway;
1048
+ }
1049
+ async execute(params) {
1050
+ const account = params.alias ? await this.credentialStorage.getAccountByAlias(params.alias) : await this.credentialStorage.getDefaultAccount();
1051
+ if (!account) throw new Error("No default account. Use 'tg default <alias>' or --account flag.");
1052
+ const apiCreds = await this.credentialStorage.getApiCredentials();
1053
+ if (!apiCreds) throw new Error("No API credentials. Run 'tg auth' first.");
1054
+ const sessionPath = this.sessionStorage.getSessionPath(account.phone);
1055
+ const gateway = this.createGateway({
1056
+ apiId: apiCreds.apiId,
1057
+ apiHash: apiCreds.apiHash,
1058
+ sessionPath
1059
+ });
1060
+ try {
1061
+ await gateway.connect();
1062
+ return await gateway.getMessages({
1063
+ chatId: params.chat,
1064
+ limit: params.limit,
1065
+ all: params.all,
1066
+ threadId: params.threadId,
1067
+ after: params.after,
1068
+ onProgress: params.onProgress
1069
+ });
1070
+ } finally {
1071
+ await gateway.disconnect();
1072
+ }
1073
+ }
1074
+ };
1075
+
1076
+ //#endregion
1077
+ //#region src/use-cases/set-default-account.ts
1078
+ var SetDefaultAccountUseCase = class {
1079
+ constructor(credentialStorage) {
1080
+ this.credentialStorage = credentialStorage;
1081
+ }
1082
+ async execute(alias) {
1083
+ const account = await this.credentialStorage.getAccountByAlias(alias);
1084
+ if (!account) throw new Error(`Account "${alias}" not found`);
1085
+ if (account.isDefault) throw new Error(`Account "${alias}" is already the default`);
1086
+ await this.credentialStorage.setDefault(alias);
1087
+ return {
1088
+ ...account,
1089
+ isDefault: true
1090
+ };
1091
+ }
1092
+ };
1093
+
1094
+ //#endregion
1095
+ //#region src/use-cases/topics-list.ts
1096
+ var TopicsListUseCase = class {
1097
+ constructor(credentialStorage, sessionStorage, createGateway) {
1098
+ this.credentialStorage = credentialStorage;
1099
+ this.sessionStorage = sessionStorage;
1100
+ this.createGateway = createGateway;
1101
+ }
1102
+ async execute(params) {
1103
+ const account = params.alias ? await this.credentialStorage.getAccountByAlias(params.alias) : await this.credentialStorage.getDefaultAccount();
1104
+ if (!account) throw new Error("No default account. Use 'tg default <alias>' or --account flag.");
1105
+ const apiCreds = await this.credentialStorage.getApiCredentials();
1106
+ if (!apiCreds) throw new Error("No API credentials. Run 'tg auth' first.");
1107
+ const sessionPath = this.sessionStorage.getSessionPath(account.phone);
1108
+ const gateway = this.createGateway({
1109
+ apiId: apiCreds.apiId,
1110
+ apiHash: apiCreds.apiHash,
1111
+ sessionPath
1112
+ });
1113
+ try {
1114
+ await gateway.connect();
1115
+ return await gateway.getTopics({
1116
+ chatId: params.chat,
1117
+ limit: params.limit
1118
+ });
1119
+ } finally {
1120
+ await gateway.disconnect();
1121
+ }
1122
+ }
1123
+ };
1124
+
1125
+ //#endregion
1126
+ //#region src/main.ts
1127
+ const credentialStorage = new KeychainCredentialStorage();
1128
+ const sessionStorage = new SqliteSessionStorage();
1129
+ createApp({
1130
+ auth: new AuthUseCase(credentialStorage, sessionStorage, createTelegramGateway),
1131
+ logout: new LogoutUseCase(credentialStorage, sessionStorage, createTelegramGateway),
1132
+ accountsList: new AccountsListUseCase(credentialStorage),
1133
+ accountsRename: new AccountsRenameUseCase(credentialStorage),
1134
+ setDefault: new SetDefaultAccountUseCase(credentialStorage),
1135
+ chatsList: new ChatsListUseCase(credentialStorage, sessionStorage, createTelegramGateway),
1136
+ messagesList: new MessagesListUseCase(credentialStorage, sessionStorage, createTelegramGateway),
1137
+ downloadMedia: new DownloadMediaUseCase(credentialStorage, sessionStorage, createTelegramGateway),
1138
+ topicsList: new TopicsListUseCase(credentialStorage, sessionStorage, createTelegramGateway)
1139
+ }).parseAsync(process.argv);
1140
+
1141
+ //#endregion
1142
+ export { };
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "tg-mtproto-cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for Telegram via MTProto — chats, messages, media download, multi-account",
5
+ "type": "module",
6
+ "bin": {
7
+ "tg": "./dist/main.mjs"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsdown",
14
+ "dev": "tsdown --watch",
15
+ "start": "node dist/main.mjs",
16
+ "typecheck": "tsc --noEmit",
17
+ "lint": "biome check src",
18
+ "lint:fix": "biome check --write src",
19
+ "format": "biome format --write src",
20
+ "prepublishOnly": "npm run lint && npm run build"
21
+ },
22
+ "keywords": [
23
+ "telegram",
24
+ "mtproto",
25
+ "cli",
26
+ "tg",
27
+ "mtcute",
28
+ "chat",
29
+ "messages",
30
+ "telegram-client"
31
+ ],
32
+ "author": "cyberash",
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/cyberash-dev/tg-mtproto-cli.git"
37
+ },
38
+ "homepage": "https://github.com/cyberash-dev/tg-mtproto-cli#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/cyberash-dev/tg-mtproto-cli/issues"
41
+ },
42
+ "engines": {
43
+ "node": ">=18"
44
+ },
45
+ "dependencies": {
46
+ "@mtcute/node": "0.28.1",
47
+ "chalk": "5.6.2",
48
+ "cli-progress": "3.12.0",
49
+ "cli-table3": "0.6.5",
50
+ "commander": "14.0.3",
51
+ "cross-keychain": "1.1.0",
52
+ "qr": "0.5.4"
53
+ },
54
+ "devDependencies": {
55
+ "@biomejs/biome": "2.4.4",
56
+ "@types/cli-progress": "3.11.6",
57
+ "@types/node": "25.3.0",
58
+ "tsdown": "0.20.3",
59
+ "typescript": "5.9.3"
60
+ }
61
+ }