@dikacode/telegram-mcp 1.0.1 → 1.0.2
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/README.md +33 -9
- package/dist/cli/doctor.js +69 -19
- package/dist/cli/login.js +46 -14
- package/dist/cli/update.js +293 -0
- package/dist/index.js +92 -34
- package/dist/ui.js +237 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -150,7 +150,7 @@ telegram-mcp
|
|
|
150
150
|
}
|
|
151
151
|
```
|
|
152
152
|
|
|
153
|
-
Pin a version if you want reproducible installs: `npx -y @dikacode/telegram-mcp@1.0.
|
|
153
|
+
Pin a version if you want reproducible installs: `npx -y @dikacode/telegram-mcp@1.0.2`.
|
|
154
154
|
|
|
155
155
|
### 2. Clone and link
|
|
156
156
|
|
|
@@ -170,7 +170,7 @@ npm link # optional: makes `telegram-mcp` available on PATH
|
|
|
170
170
|
Download the tarball attached to the latest release and install it globally:
|
|
171
171
|
|
|
172
172
|
```bash
|
|
173
|
-
npm install -g https://github.com/dikaglorya/telegram-mcp/releases/download/v1.0.
|
|
173
|
+
npm install -g https://github.com/dikaglorya/telegram-mcp/releases/download/v1.0.2/dikacode-telegram-mcp-1.0.2.tgz
|
|
174
174
|
telegram-mcp
|
|
175
175
|
```
|
|
176
176
|
|
|
@@ -188,13 +188,33 @@ npm fetches the repository, installs dependencies, runs the `prepare` build, and
|
|
|
188
188
|
|
|
189
189
|
## Updating
|
|
190
190
|
|
|
191
|
-
|
|
191
|
+
One command, whichever way it was installed:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
telegram-mcp update
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
It works out how *this* copy got here and runs the right thing — `npm install
|
|
198
|
+
-g @latest` for a global install (reusing a custom `--prefix`), `git pull
|
|
199
|
+
--ff-only` plus a rebuild for a checkout, and a plain restart instruction for an
|
|
200
|
+
npx run, where nothing on disk needs changing. `--check` reports and installs
|
|
201
|
+
nothing.
|
|
202
|
+
|
|
203
|
+
```text
|
|
204
|
+
telegram-mcp 1.0.2 · update
|
|
205
|
+
|
|
206
|
+
installed 1.0.1 /home/you/.local/lib/node_modules/@dikacode/telegram-mcp
|
|
207
|
+
latest 1.0.2
|
|
208
|
+
install global npm package
|
|
209
|
+
|
|
210
|
+
▲ 1.0.1 -> 1.0.2 available
|
|
211
|
+
```
|
|
192
212
|
|
|
193
213
|
| Installed with | Behaviour |
|
|
194
214
|
| --- | --- |
|
|
195
215
|
| `npx -y @dikacode/telegram-mcp` (unpinned) | **Automatic.** npm resolves `latest` on every start, so the next client restart picks up a new release. |
|
|
196
|
-
| `npx -y @dikacode/telegram-mcp@1.0.
|
|
197
|
-
| `npm install -g @dikacode/telegram-mcp` |
|
|
216
|
+
| `npx -y @dikacode/telegram-mcp@1.0.2` (pinned) | **Never.** That is the point of pinning. |
|
|
217
|
+
| `npm install -g @dikacode/telegram-mcp` | **`telegram-mcp update`**, or `npm update -g @dikacode/telegram-mcp`. |
|
|
198
218
|
| Release tarball | **Manual.** Reinstall from the newer tarball. |
|
|
199
219
|
| `npx -y github:dikaglorya/telegram-mcp` | **Not automatic.** npm caches the fetched repo; clear the cache or pin a ref (`#main`) to move forward. |
|
|
200
220
|
|
|
@@ -202,7 +222,8 @@ Check what you are running:
|
|
|
202
222
|
|
|
203
223
|
```bash
|
|
204
224
|
telegram-mcp --version
|
|
205
|
-
|
|
225
|
+
telegram-mcp update --check # newer release available? installs nothing
|
|
226
|
+
npm outdated -g @dikacode/telegram-mcp # global installs only
|
|
206
227
|
```
|
|
207
228
|
|
|
208
229
|
The server also tells you. When a newer release exists it prints one line to
|
|
@@ -528,7 +549,8 @@ All settings are environment variables. The server also loads a `.env` next to t
|
|
|
528
549
|
| `Cannot find module .../dist/index.js` | The package was installed without building. Run `npm run build`, or use the release tarball. |
|
|
529
550
|
| Login works, then the next run asks again | `TELEGRAM_SESSION_PATH` changed between runs. Keep it stable, or leave it unset. |
|
|
530
551
|
| Window/terminal closes when started by hand | Expected: the server speaks MCP on stdio. Start it through a client, or use `npm run login` for the standalone CLI. |
|
|
531
|
-
| `update available: …` on every start | A newer release is on npm. Run
|
|
552
|
+
| `update available: …` on every start | A newer release is on npm. Run `telegram-mcp update`, or set `TELEGRAM_UPDATE_CHECK=0` if you pin versions on purpose. |
|
|
553
|
+
| `telegram-mcp update` finds no upgrade command | The copy lives in a `node_modules` tree npm does not manage as a global prefix (npx cache, or a project dependency). Restart it as `npx -y @dikacode/telegram-mcp@latest`, or upgrade the project dependency. |
|
|
532
554
|
| `telegram-mcp --version` prints nothing | You are on `1.0.0`, which predates the flag. Upgrade to `1.0.1` or newer. |
|
|
533
555
|
|
|
534
556
|
## Development
|
|
@@ -565,7 +587,7 @@ The harness also supports `--json`, `--only=tool_a,tool_b`, `--cleanup`, `--recl
|
|
|
565
587
|
```
|
|
566
588
|
telegram-mcp/
|
|
567
589
|
├── bin/telegram-mcp.js portable launcher (finds Node, then dist/index.js)
|
|
568
|
-
├── cli/ standalone login and
|
|
590
|
+
├── cli/ standalone login, doctor and update commands
|
|
569
591
|
├── assets/ animated README art (SVG, no scripts)
|
|
570
592
|
├── harness/ unit checks + end-to-end MCP harness
|
|
571
593
|
├── index.ts stdio entrypoint
|
|
@@ -592,7 +614,7 @@ The TypeScript is built with `tsc` to `dist/`. There is no bundler; the publishe
|
|
|
592
614
|
|
|
593
615
|
## Release
|
|
594
616
|
|
|
595
|
-
**v1.0.
|
|
617
|
+
**v1.0.2** is the current release, published to npm as
|
|
596
618
|
[`@dikacode/telegram-mcp`](https://www.npmjs.com/package/@dikacode/telegram-mcp).
|
|
597
619
|
|
|
598
620
|
- 69 MCP tools, all registered with strict input schemas.
|
|
@@ -602,6 +624,8 @@ The TypeScript is built with `tsc` to `dist/`. There is no bundler; the publishe
|
|
|
602
624
|
- Optional allowlist, audited calls, redacted secrets.
|
|
603
625
|
- Cross-platform launcher; no hardcoded paths.
|
|
604
626
|
- `--version` / `--help`, and a startup notice when a newer release exists.
|
|
627
|
+
- `telegram-mcp update` upgrades whichever layout is installed; `--check`
|
|
628
|
+
reports without touching anything.
|
|
605
629
|
- End-to-end harness with paging, policy probes and artifact cleanup.
|
|
606
630
|
|
|
607
631
|
See [CHANGELOG.md](https://github.com/dikaglorya/telegram-mcp/blob/main/CHANGELOG.md) for the full release notes. Release artifacts are on the [releases page](https://github.com/dikaglorya/telegram-mcp/releases).
|
package/dist/cli/doctor.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* Doctor: validate config, session presence, permissions, live connectivity.
|
|
4
|
-
*
|
|
4
|
+
* Never prints secrets.
|
|
5
|
+
*
|
|
6
|
+
* Output is a framed report on stdout when run as a CLI — this is a human
|
|
7
|
+
* command, not an MCP server, so stdout is free. Under `--quiet` (or when
|
|
8
|
+
* piped) it prints one line per check with no colour, which is what CI wants.
|
|
5
9
|
*/
|
|
6
10
|
import "../env.js";
|
|
7
11
|
import fs from "node:fs";
|
|
@@ -9,8 +13,14 @@ import path from "node:path";
|
|
|
9
13
|
import { ensureDirs, getConfig, hasSession, readSessionString, redact } from "../config.js";
|
|
10
14
|
import { lockFile } from "../perms.js";
|
|
11
15
|
import { getClient, disconnect } from "../telegram.js";
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
import { banner, colorEnabled, gap, paint, sym, theme } from "../ui.js";
|
|
17
|
+
const out = (text) => void process.stdout.write(text);
|
|
18
|
+
const rows = [];
|
|
19
|
+
function record(pass, label, detail = "") {
|
|
20
|
+
rows.push({ pass, label, detail });
|
|
21
|
+
}
|
|
22
|
+
function icon(pass) {
|
|
23
|
+
return pass ? theme.success(sym.ok) : theme.fail(sym.fail);
|
|
14
24
|
}
|
|
15
25
|
async function main() {
|
|
16
26
|
let failures = 0;
|
|
@@ -19,49 +29,55 @@ async function main() {
|
|
|
19
29
|
config = getConfig();
|
|
20
30
|
}
|
|
21
31
|
catch (err) {
|
|
22
|
-
|
|
32
|
+
record(false, "config", err instanceof Error ? err.message : String(err));
|
|
33
|
+
report();
|
|
23
34
|
process.exit(1);
|
|
24
35
|
}
|
|
25
36
|
ensureDirs(config);
|
|
26
|
-
|
|
27
|
-
|
|
37
|
+
record(true, "config", `api_id=${config.apiId}, allowAll=${config.allowAllChats}, allowlist=${config.allowlist.length}`);
|
|
38
|
+
record(fs.existsSync(path.dirname(config.sessionPath)), "state dir", path.dirname(config.sessionPath));
|
|
28
39
|
const has = hasSession(config);
|
|
29
|
-
|
|
40
|
+
record(has, "session present", config.sessionPath);
|
|
30
41
|
if (!has)
|
|
31
42
|
failures++;
|
|
32
43
|
if (has) {
|
|
33
44
|
if (process.platform === "win32") {
|
|
34
|
-
|
|
45
|
+
record(true, "session permissions", "skipped on Windows (inherits user profile ACLs)");
|
|
35
46
|
}
|
|
36
47
|
else {
|
|
37
48
|
try {
|
|
38
49
|
const mode = fs.statSync(config.sessionPath).mode & 0o777;
|
|
39
50
|
const good = (mode & 0o077) === 0;
|
|
40
|
-
|
|
41
|
-
|
|
51
|
+
if (good) {
|
|
52
|
+
record(true, "session permissions", `mode=${mode.toString(8)}`);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
42
55
|
lockFile(config.sessionPath);
|
|
43
|
-
|
|
56
|
+
const fixed = (fs.statSync(config.sessionPath).mode & 0o777 & 0o077) === 0;
|
|
57
|
+
record(fixed, "session permissions", fixed ? "repaired to 600" : `mode=${mode.toString(8)}`);
|
|
58
|
+
if (!fixed)
|
|
59
|
+
failures++;
|
|
44
60
|
}
|
|
45
61
|
}
|
|
46
62
|
catch (err) {
|
|
47
|
-
|
|
63
|
+
record(false, "session permissions", err instanceof Error ? err.message : String(err));
|
|
48
64
|
failures++;
|
|
49
65
|
}
|
|
50
66
|
}
|
|
51
67
|
try {
|
|
52
|
-
|
|
68
|
+
record(readSessionString(config).length > 0, "session readable");
|
|
53
69
|
}
|
|
54
70
|
catch (err) {
|
|
55
|
-
|
|
71
|
+
record(false, "session readable", err instanceof Error ? err.message : String(err));
|
|
56
72
|
failures++;
|
|
57
73
|
}
|
|
58
74
|
try {
|
|
59
75
|
const client = await getClient();
|
|
60
76
|
const authorized = await client.isUserAuthorized();
|
|
61
|
-
|
|
77
|
+
record(authorized, "telegram authorized");
|
|
62
78
|
if (authorized) {
|
|
63
79
|
const me = (await client.getMe());
|
|
64
|
-
|
|
80
|
+
record(true, "account", `id=${String(me.id ?? "")} username=${me.username ?? "none"}`);
|
|
65
81
|
}
|
|
66
82
|
else {
|
|
67
83
|
failures++;
|
|
@@ -69,14 +85,48 @@ async function main() {
|
|
|
69
85
|
await disconnect();
|
|
70
86
|
}
|
|
71
87
|
catch (err) {
|
|
72
|
-
|
|
88
|
+
record(false, "telegram connectivity", redact(err instanceof Error ? err.message : String(err)));
|
|
73
89
|
failures++;
|
|
74
90
|
}
|
|
75
91
|
}
|
|
76
|
-
|
|
92
|
+
report(failures);
|
|
77
93
|
process.exit(failures === 0 ? 0 : 1);
|
|
78
94
|
}
|
|
95
|
+
function report(failures) {
|
|
96
|
+
const plain = !colorEnabled() || process.argv.includes("--quiet");
|
|
97
|
+
if (!plain) {
|
|
98
|
+
banner({ subtitle: "health check", compact: true });
|
|
99
|
+
gap();
|
|
100
|
+
}
|
|
101
|
+
const width = Math.max(...rows.map((r) => r.label.length));
|
|
102
|
+
for (const row of rows) {
|
|
103
|
+
if (plain) {
|
|
104
|
+
out(`[${row.pass ? "PASS" : "FAIL"}] ${row.label}${row.detail ? ` - ${row.detail}` : ""}\n`);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
const detail = row.detail ? ` ${theme.muted(row.detail)}` : "";
|
|
108
|
+
// padEnd measures code units; the icon is one glyph, so this aligns.
|
|
109
|
+
out(` ${icon(row.pass)} ${theme.label(row.label.padEnd(width))}${detail}\n`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (failures === undefined)
|
|
113
|
+
return;
|
|
114
|
+
if (plain) {
|
|
115
|
+
out(failures === 0 ? "\nall checks passed.\n" : `\n${failures} check(s) failed.\n`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
gap();
|
|
119
|
+
out(failures === 0
|
|
120
|
+
? ` ${theme.success(`${sym.ok} ${paint.bold("all checks passed")}`)}\n`
|
|
121
|
+
: ` ${theme.fail(`${sym.fail} ${failures} check(s) failed`)}\n`);
|
|
122
|
+
if (failures !== 0 && hasHint()) {
|
|
123
|
+
out(` ${theme.muted(`${sym.bullet} run ${paint.bold("telegram-mcp login")} to issue a fresh session`)}\n`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function hasHint() {
|
|
127
|
+
return rows.some((r) => !r.pass && /session|authorized/i.test(r.label));
|
|
128
|
+
}
|
|
79
129
|
main().catch((err) => {
|
|
80
|
-
process.stderr.write(`[doctor] fatal: ${redact(err instanceof Error ? err.message : String(err))}\n`);
|
|
130
|
+
process.stderr.write(`${theme.fail(`[doctor] fatal: ${redact(err instanceof Error ? err.message : String(err))}`)}\n`);
|
|
81
131
|
process.exit(1);
|
|
82
132
|
});
|
package/dist/cli/login.js
CHANGED
|
@@ -15,6 +15,7 @@ import { TelegramClient } from "telegram";
|
|
|
15
15
|
import { StringSession } from "telegram/sessions/index.js";
|
|
16
16
|
import { ensureDirs, getConfig, hasSession, readSessionString, writeSessionString, redact, } from "../config.js";
|
|
17
17
|
import { createLogger } from "../logger.js";
|
|
18
|
+
import { banner, gap, hint, nextSteps, paint, spinner, step, success, sym, theme, warn } from "../ui.js";
|
|
18
19
|
function ask(question) {
|
|
19
20
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
20
21
|
return new Promise((resolve) => rl.question(question, (answer) => {
|
|
@@ -22,6 +23,9 @@ function ask(question) {
|
|
|
22
23
|
resolve(answer.trim());
|
|
23
24
|
}));
|
|
24
25
|
}
|
|
26
|
+
function askPlain(question) {
|
|
27
|
+
return ask(`${theme.accent(`${sym.arrow} `)}${theme.label(question)} `);
|
|
28
|
+
}
|
|
25
29
|
/**
|
|
26
30
|
* Ask a question without echoing the answer. readline's `terminal` mode masks
|
|
27
31
|
* the prompt on POSIX and Windows alike; it falls back to a plain read when
|
|
@@ -57,6 +61,9 @@ function askSecret(question) {
|
|
|
57
61
|
async function main() {
|
|
58
62
|
const config = getConfig();
|
|
59
63
|
ensureDirs(config);
|
|
64
|
+
banner({ subtitle: "interactive login", compact: true });
|
|
65
|
+
gap();
|
|
66
|
+
step(1, 3, "Checking the existing session");
|
|
60
67
|
// Re-running login silently mints a NEW auth key for the same account. Telegram
|
|
61
68
|
// then treats the previous key as AUTH_KEY_DUPLICATED and every client holding
|
|
62
69
|
// it stops working. Refuse to overwrite a session that still works.
|
|
@@ -65,25 +72,35 @@ async function main() {
|
|
|
65
72
|
connectionRetries: 2,
|
|
66
73
|
baseLogger: createLogger(),
|
|
67
74
|
});
|
|
75
|
+
const check = spinner("verifying the saved session");
|
|
68
76
|
try {
|
|
69
77
|
await probe.connect();
|
|
70
78
|
const ok = await probe.isUserAuthorized();
|
|
71
79
|
if (ok) {
|
|
72
80
|
const me = (await probe.getMe());
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
81
|
+
check.stop(`${theme.success(sym.ok)} already authorized as ${theme.label(String(me.username ?? me.id ?? ""))}`);
|
|
82
|
+
gap();
|
|
83
|
+
hint(`session kept at ${config.sessionPath}`);
|
|
84
|
+
hint(`pass ${theme.label("--force")} to overwrite it with a fresh auth key`);
|
|
76
85
|
await probe.disconnect().catch(() => undefined);
|
|
77
86
|
return;
|
|
78
87
|
}
|
|
88
|
+
check.stop(`${theme.warn(`${sym.warn} saved session is no longer authorized`)}`);
|
|
79
89
|
}
|
|
80
90
|
catch {
|
|
91
|
+
check.stop(`${theme.warn(`${sym.warn} saved session is unusable — issuing a fresh one`)}`);
|
|
81
92
|
/* dead or duplicated session — fall through and replace it */
|
|
82
93
|
}
|
|
83
94
|
await probe.disconnect().catch(() => undefined);
|
|
84
|
-
process.stderr.write(`[login] existing session is unusable, issuing a fresh one\n`);
|
|
85
95
|
}
|
|
86
|
-
|
|
96
|
+
else {
|
|
97
|
+
hint(hasSession(config) ? `overwriting the session because of ${theme.label("--force")}` : "no session on disk yet");
|
|
98
|
+
}
|
|
99
|
+
gap();
|
|
100
|
+
step(2, 3, "Signing in to Telegram");
|
|
101
|
+
hint(`the session will be stored at ${config.sessionPath}`);
|
|
102
|
+
if (!process.stdin.isTTY)
|
|
103
|
+
hint(`stdin is not a terminal — prompts may not echo correctly`);
|
|
87
104
|
const existing = hasSession(config) ? readSessionString(config) : "";
|
|
88
105
|
const session = new StringSession(existing);
|
|
89
106
|
const client = new TelegramClient(session, config.apiId, config.apiHash, {
|
|
@@ -97,28 +114,40 @@ async function main() {
|
|
|
97
114
|
phoneNumber: async () => {
|
|
98
115
|
const stored = process.env.TELEGRAM_PHONE?.trim();
|
|
99
116
|
if (stored) {
|
|
100
|
-
|
|
117
|
+
hint(`using ${theme.label("TELEGRAM_PHONE")} from .env`);
|
|
101
118
|
return stored;
|
|
102
119
|
}
|
|
103
|
-
return
|
|
120
|
+
return askPlain("Phone number (international, e.g. +15551234567)");
|
|
121
|
+
},
|
|
122
|
+
phoneCode: async () => {
|
|
123
|
+
hint("Telegram is sending a code to your other devices");
|
|
124
|
+
return askPlain("Login code");
|
|
125
|
+
},
|
|
126
|
+
password: async () => {
|
|
127
|
+
hint("two-step verification is on for this account");
|
|
128
|
+
return askSecret("2FA password (leave blank if none)");
|
|
104
129
|
},
|
|
105
|
-
phoneCode: async () => ask("Login code from Telegram: "),
|
|
106
|
-
password: async () => askSecret("2FA password (leave blank if none)"),
|
|
107
130
|
onError: (err) => {
|
|
108
131
|
// The update loop keeps pinging after we disconnect; those late
|
|
109
132
|
// rejections are noise, not auth failures.
|
|
110
133
|
const msg = err instanceof Error ? err.message : String(err);
|
|
111
134
|
if (msg === "TIMEOUT" || msg.includes("TIMEOUT"))
|
|
112
135
|
return;
|
|
113
|
-
|
|
136
|
+
warn(`auth error: ${redact(msg)}`);
|
|
114
137
|
},
|
|
115
138
|
});
|
|
116
139
|
const me = (await client.getMe());
|
|
117
140
|
const serialized = client.session.save();
|
|
118
141
|
writeSessionString(config, serialized);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
142
|
+
gap();
|
|
143
|
+
step(3, 3, "Saving");
|
|
144
|
+
success(`authorized as ${paint.bold(String(me.username ?? me.id ?? ""))}`);
|
|
145
|
+
hint(`session saved to ${config.sessionPath} ${theme.muted("(0600)")}`);
|
|
146
|
+
nextSteps([
|
|
147
|
+
`start your MCP client — it spawns ${theme.label("telegram-mcp")} from the config you already added`,
|
|
148
|
+
`or run ${theme.label("telegram-mcp doctor")} to confirm everything is healthy`,
|
|
149
|
+
]);
|
|
150
|
+
gap();
|
|
122
151
|
}
|
|
123
152
|
finally {
|
|
124
153
|
// gramJS fires the update-ping loop the moment we connect. Disconnecting
|
|
@@ -136,6 +165,9 @@ async function main() {
|
|
|
136
165
|
}
|
|
137
166
|
main().catch((err) => {
|
|
138
167
|
const msg = err instanceof Error ? err.message : String(err);
|
|
139
|
-
process.stderr.write(
|
|
168
|
+
process.stderr.write(`${theme.fail(`${sym.fail} login failed: ${redact(msg)}`)}\n`);
|
|
169
|
+
if (/not set|invalid/i.test(msg)) {
|
|
170
|
+
process.stderr.write(` ${theme.muted(`${sym.bullet} get api_id/api_hash from `)}${theme.underline("https://my.telegram.org/apps")}\n`);
|
|
171
|
+
}
|
|
140
172
|
process.exit(1);
|
|
141
173
|
});
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `telegram-mcp update` — bring the installed copy up to date.
|
|
4
|
+
*
|
|
5
|
+
* The hard part is not the upgrade, it is knowing *how* this copy was
|
|
6
|
+
* installed. A global npm install is upgraded with `npm i -g`, an npx run is
|
|
7
|
+
* replaced by asking npx for @latest, and a git checkout is updated with
|
|
8
|
+
* `git pull` plus a rebuild. Guessing wrong either does nothing (npm refuses
|
|
9
|
+
* to touch a package it does not own) or silently reinstalls the same version.
|
|
10
|
+
*
|
|
11
|
+
* So detection is made from the filesystem, not from a flag the user has to
|
|
12
|
+
* remember:
|
|
13
|
+
* - the real path of this file lives under node_modules -> npm package
|
|
14
|
+
* - under a global prefix (`npm root -g`) -> global install
|
|
15
|
+
* - anywhere else (npx cache, local dep) -> npx
|
|
16
|
+
* - a sibling `.git` directory -> git checkout
|
|
17
|
+
*
|
|
18
|
+
* With `--check` nothing is installed; it only reports. That is what
|
|
19
|
+
* `telegram-mcp update --check` and the doctor use, and it never writes.
|
|
20
|
+
*/
|
|
21
|
+
import "../env.js";
|
|
22
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import { PACKAGE_NAME, SERVER_VERSION, compareVersions } from "../version.js";
|
|
27
|
+
import { getUpdateInfo } from "../update-check.js";
|
|
28
|
+
import { banner, gap, hint, info, sym, theme } from "../ui.js";
|
|
29
|
+
const out = (text) => void process.stdout.write(text);
|
|
30
|
+
/**
|
|
31
|
+
* Where npm puts globally installed packages. Asking npm is more reliable than
|
|
32
|
+
* reconstructing `process.execPath`, which differs between Homebrew, nvm,
|
|
33
|
+
* Volta and the Windows installer. `NPM_CONFIG_PREFIX` still applies, so a
|
|
34
|
+
* custom prefix is detected too.
|
|
35
|
+
*/
|
|
36
|
+
function npmGlobalRoot() {
|
|
37
|
+
try {
|
|
38
|
+
const root = execFileSync("npm", ["root", "-g"], {
|
|
39
|
+
encoding: "utf8",
|
|
40
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
41
|
+
timeout: 10_000,
|
|
42
|
+
shell: process.platform === "win32",
|
|
43
|
+
}).trim();
|
|
44
|
+
return root || null;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Candidates for the global prefix. `npm root -g` is authoritative, but it is
|
|
52
|
+
* skipped when npm is not on PATH (clients do strip it), and an explicit
|
|
53
|
+
* `npm i -g --prefix <dir>` is easy to miss. The real path of this file gives
|
|
54
|
+
* the answer either way: it ends in
|
|
55
|
+
* <prefix>/lib/node_modules/<name>/dist/cli/update.js (POSIX)
|
|
56
|
+
* <prefix>/node_modules/<name>/dist/cli/update.js (Windows)
|
|
57
|
+
*/
|
|
58
|
+
function globalRoots() {
|
|
59
|
+
const candidates = new Set();
|
|
60
|
+
const fromNpm = npmGlobalRoot();
|
|
61
|
+
if (fromNpm)
|
|
62
|
+
candidates.add(fromNpm);
|
|
63
|
+
const marker = process.platform === "win32" ? "/node_modules/" : "/lib/node_modules/";
|
|
64
|
+
const here = fileURLToPath(import.meta.url).replace(/\\/g, "/");
|
|
65
|
+
const at = here.lastIndexOf(marker);
|
|
66
|
+
if (at !== -1)
|
|
67
|
+
candidates.add(here.slice(0, at + marker.length - 1));
|
|
68
|
+
return [...candidates].map((dir) => path.resolve(dir)).filter((dir) => fs.existsSync(dir));
|
|
69
|
+
}
|
|
70
|
+
/** Walk up looking for a package root (a directory with package.json + .git or dist). */
|
|
71
|
+
function findPackageRoot(start) {
|
|
72
|
+
let dir = start;
|
|
73
|
+
for (let i = 0; i < 6; i++) {
|
|
74
|
+
if (fs.existsSync(path.join(dir, "package.json")))
|
|
75
|
+
return dir;
|
|
76
|
+
const parent = path.dirname(dir);
|
|
77
|
+
if (parent === dir)
|
|
78
|
+
return null;
|
|
79
|
+
dir = parent;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
export function detectInstall() {
|
|
84
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
85
|
+
const root = findPackageRoot(here) ?? here;
|
|
86
|
+
const normalized = root.replace(/\\/g, "/");
|
|
87
|
+
// A git checkout is the only layout that carries its own .git.
|
|
88
|
+
if (fs.existsSync(path.join(root, ".git"))) {
|
|
89
|
+
return { kind: "source", location: root };
|
|
90
|
+
}
|
|
91
|
+
if (/\/node_modules\//.test(`${normalized}/`)) {
|
|
92
|
+
const global = globalRoots().map((p) => p.replace(/\\/g, "/").replace(/\/+$/, ""));
|
|
93
|
+
if (global.some((g) => normalized === g || normalized.startsWith(`${g}/`))) {
|
|
94
|
+
return { kind: "global", location: root };
|
|
95
|
+
}
|
|
96
|
+
// Anything else under node_modules was put there by npx, or by a project
|
|
97
|
+
// that lists this package as a dependency, and both are refreshed by
|
|
98
|
+
// asking npm for @latest rather than by a -g install.
|
|
99
|
+
return { kind: "npx", location: root };
|
|
100
|
+
}
|
|
101
|
+
return { kind: "unknown", location: root };
|
|
102
|
+
}
|
|
103
|
+
export async function plan(allowNetwork = true) {
|
|
104
|
+
const { kind, location } = detectInstall();
|
|
105
|
+
const info = await getUpdateInfo({ allowNetwork });
|
|
106
|
+
const latest = info.source === "disabled" ? null : info.latest;
|
|
107
|
+
const outdated = latest !== null ? compareVersions(latest, SERVER_VERSION) > 0 : false;
|
|
108
|
+
const base = {
|
|
109
|
+
kind,
|
|
110
|
+
location,
|
|
111
|
+
current: SERVER_VERSION,
|
|
112
|
+
latest: latest ?? null,
|
|
113
|
+
outdated,
|
|
114
|
+
};
|
|
115
|
+
if (kind === "source") {
|
|
116
|
+
// A detached HEAD, a shallow clone or a checkout without an upstream all
|
|
117
|
+
// fail `pull --ff-only`. Ask git about the tracked upstream instead of
|
|
118
|
+
// assuming `origin/main` exists.
|
|
119
|
+
const upstream = git(location, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]);
|
|
120
|
+
if (!upstream) {
|
|
121
|
+
return {
|
|
122
|
+
...base,
|
|
123
|
+
command: null,
|
|
124
|
+
note: "this checkout has no upstream branch; update it with `git pull` yourself, then npm install && npm run build",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
...base,
|
|
129
|
+
command: ["git", "-C", location, "pull", "--ff-only"],
|
|
130
|
+
note: "then rebuild: npm install && npm run build",
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (kind === "global") {
|
|
134
|
+
// Respect a custom prefix: `npm i -g --prefix /opt/x` is a global install
|
|
135
|
+
// too, and upgrading it with a plain `npm i -g` would land in a different
|
|
136
|
+
// tree and leave the old copy in place.
|
|
137
|
+
const prefix = globalPrefix();
|
|
138
|
+
return {
|
|
139
|
+
...base,
|
|
140
|
+
command: prefix
|
|
141
|
+
? ["npm", "install", "-g", "--prefix", prefix, `${PACKAGE_NAME}@latest`]
|
|
142
|
+
: ["npm", "install", "-g", `${PACKAGE_NAME}@latest`],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (kind === "npx") {
|
|
146
|
+
return {
|
|
147
|
+
...base,
|
|
148
|
+
command: null,
|
|
149
|
+
note: `this copy was loaded from a node_modules folder; restart with \`npx -y ${PACKAGE_NAME}@latest\` and npx will fetch the newest release`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return { ...base, command: null, note: "cannot tell how this copy was installed; use npm install -g @latest" };
|
|
153
|
+
}
|
|
154
|
+
/** Run git and return trimmed stdout, or null when it fails. Never throws. */
|
|
155
|
+
function git(cwd, args) {
|
|
156
|
+
try {
|
|
157
|
+
const result = execFileSync("git", ["-C", cwd, ...args], {
|
|
158
|
+
encoding: "utf8",
|
|
159
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
160
|
+
timeout: 5_000,
|
|
161
|
+
}).trim();
|
|
162
|
+
return result || null;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The prefix a global install lives under, or null when it is the default one.
|
|
170
|
+
* Read from the resolved global root (`<prefix>/lib/node_modules` on POSIX,
|
|
171
|
+
* `<prefix>/node_modules` on Windows) so no extra npm call is needed.
|
|
172
|
+
*/
|
|
173
|
+
function globalPrefix() {
|
|
174
|
+
// Applies to the global root that contains the running copy: a custom
|
|
175
|
+
// prefix must be reused, or the upgrade would install next to it instead of
|
|
176
|
+
// over it.
|
|
177
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
178
|
+
const normalized = here.replace(/\\/g, "/");
|
|
179
|
+
for (const root of globalRoots()) {
|
|
180
|
+
const g = root.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
181
|
+
if (normalized.startsWith(`${g}/`)) {
|
|
182
|
+
return process.platform === "win32" ? path.dirname(root) : path.dirname(path.dirname(root));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
function run(command) {
|
|
188
|
+
return new Promise((resolve) => {
|
|
189
|
+
const child = spawn(command[0], command.slice(1), {
|
|
190
|
+
stdio: "inherit",
|
|
191
|
+
shell: process.platform === "win32",
|
|
192
|
+
});
|
|
193
|
+
child.on("error", () => resolve(1));
|
|
194
|
+
child.on("exit", (code) => resolve(code ?? 1));
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
function printPlan(p, checkOnly) {
|
|
198
|
+
const label = p.kind === "global"
|
|
199
|
+
? "global npm package"
|
|
200
|
+
: p.kind === "npx"
|
|
201
|
+
? "npx or project dependency"
|
|
202
|
+
: p.kind === "source"
|
|
203
|
+
? "git checkout"
|
|
204
|
+
: "unknown";
|
|
205
|
+
out(`\n ${theme.label("installed")} ${theme.bold(p.current)} ${theme.muted(p.location)}\n`);
|
|
206
|
+
out(` ${theme.label("latest")} ${p.latest ? theme.bold(p.latest) : theme.muted("unknown")}\n`);
|
|
207
|
+
out(` ${theme.label("install")} ${label}\n`);
|
|
208
|
+
if (p.latest === null) {
|
|
209
|
+
hint("could not reach the npm registry; check the network or TELEGRAM_UPDATE_CHECK");
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (!p.outdated) {
|
|
213
|
+
out(`\n ${theme.success(`${sym.ok} already up to date`)}\n\n`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
out(`\n ${theme.warn(`${sym.warn} ${theme.bold(`${p.current} -> ${p.latest}`)} available`)}\n`);
|
|
217
|
+
if (p.note)
|
|
218
|
+
hint(p.note);
|
|
219
|
+
if (checkOnly) {
|
|
220
|
+
if (p.command)
|
|
221
|
+
hint(`to upgrade, run ${theme.label(p.command.join(" "))}`);
|
|
222
|
+
hint("run without --check to apply it");
|
|
223
|
+
out("\n");
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async function main() {
|
|
228
|
+
const argv = process.argv.slice(2);
|
|
229
|
+
const checkOnly = argv.includes("--check") || argv.includes("--dry-run");
|
|
230
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
231
|
+
out([
|
|
232
|
+
"",
|
|
233
|
+
` ${theme.bold("telegram-mcp update")} upgrade this install to the latest release`,
|
|
234
|
+
` ${theme.bold("telegram-mcp update --check")} report only; install nothing`,
|
|
235
|
+
"",
|
|
236
|
+
" The command is chosen from how this copy was installed:",
|
|
237
|
+
` ${theme.accent("global install")} npm install -g ${PACKAGE_NAME}@latest`,
|
|
238
|
+
` ${theme.accent("npx run")} restart with npx -y ${PACKAGE_NAME}@latest`,
|
|
239
|
+
` ${theme.accent("git checkout")} git pull --ff-only, then npm install && npm run build`,
|
|
240
|
+
"",
|
|
241
|
+
].join("\n"));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
banner({ subtitle: "update", compact: true });
|
|
245
|
+
const p = await plan(true);
|
|
246
|
+
printPlan(p, checkOnly);
|
|
247
|
+
if (checkOnly || !p.outdated || !p.command) {
|
|
248
|
+
if (!checkOnly && p.outdated && !p.command && p.note) {
|
|
249
|
+
gap();
|
|
250
|
+
hint("nothing was installed; this copy is refreshed by the command above");
|
|
251
|
+
out("\n");
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
info(`${theme.label("running")} ${p.command.join(" ")}`);
|
|
256
|
+
const code = await run(p.command);
|
|
257
|
+
if (code !== 0) {
|
|
258
|
+
out(`\n ${theme.fail(`${sym.fail} update command exited with ${code}`)}\n\n`);
|
|
259
|
+
process.exitCode = code;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (p.kind === "source") {
|
|
263
|
+
info(`${theme.label("rebuilding")} npm install && npm run build`);
|
|
264
|
+
const install = await run(["npm", "install"]);
|
|
265
|
+
if (install === 0)
|
|
266
|
+
await run(["npm", "run", "build"]);
|
|
267
|
+
}
|
|
268
|
+
gap();
|
|
269
|
+
out(` ${theme.success(`${sym.ok} updated to ${p.latest}`)}\n`);
|
|
270
|
+
hint("restart your MCP client so it spawns the new copy");
|
|
271
|
+
out("\n");
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Only run when this file is the entrypoint. `detectInstall` is imported by the
|
|
275
|
+
* unit checks, and importing a module must not print a report or spawn npm.
|
|
276
|
+
*/
|
|
277
|
+
function invokedDirectly() {
|
|
278
|
+
const entry = process.argv[1];
|
|
279
|
+
if (!entry)
|
|
280
|
+
return false;
|
|
281
|
+
try {
|
|
282
|
+
return path.resolve(entry) === fileURLToPath(import.meta.url);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (invokedDirectly()) {
|
|
289
|
+
void main().catch((err) => {
|
|
290
|
+
process.stderr.write(`${theme.fail(`${sym.fail} update failed: ${err instanceof Error ? err.message : String(err)}`)}\n`);
|
|
291
|
+
process.exit(1);
|
|
292
|
+
});
|
|
293
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -12,65 +12,123 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
12
12
|
import { buildServer } from "./server.js";
|
|
13
13
|
import { ensureDirs, getConfig, redact } from "./config.js";
|
|
14
14
|
import { shutdownLogin } from "./login-tools.js";
|
|
15
|
-
import { PACKAGE_NAME,
|
|
15
|
+
import { PACKAGE_NAME, SERVER_VERSION } from "./version.js";
|
|
16
16
|
import { warnIfOutdated } from "./update-check.js";
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
17
|
+
import { banner, gap, hint, info, muted, sym, theme } from "./ui.js";
|
|
18
|
+
function help() {
|
|
19
|
+
const rows = [
|
|
20
|
+
["telegram-mcp", "serve MCP on stdio (what an MCP client runs)"],
|
|
21
|
+
["telegram-mcp --version", "print the version and exit"],
|
|
22
|
+
["telegram-mcp --help", "print this help and exit"],
|
|
23
|
+
["telegram-mcp login", "log in from this terminal instead of chat"],
|
|
24
|
+
["telegram-mcp doctor", "check config, session and connectivity"],
|
|
25
|
+
["telegram-mcp update", "upgrade this install to the latest release"],
|
|
26
|
+
["telegram-mcp update --check", "report the available version, install nothing"],
|
|
27
|
+
];
|
|
28
|
+
const width = Math.max(...rows.map(([cmd]) => cmd.length));
|
|
29
|
+
const usage = rows
|
|
30
|
+
.map(([cmd, desc]) => ` ${theme.accent(cmd.padEnd(width))} ${theme.muted(desc)}`)
|
|
31
|
+
.join("\n");
|
|
32
|
+
return [
|
|
33
|
+
`${theme.title("▀█▀ █▀▀ █░░ █▀▀ █▀▀ █▀█ ▄▀█ █▀▄▀█")}`,
|
|
34
|
+
`${theme.title("░█░ █▀▀ █░░ ██▄ █▄█ █▀▄ █▀█ █░▀░█")} ${theme.muted(SERVER_VERSION)}`,
|
|
35
|
+
"",
|
|
36
|
+
theme.label("An MCP server for a personal Telegram account, over MTProto."),
|
|
37
|
+
"",
|
|
38
|
+
theme.bold("Usage"),
|
|
39
|
+
usage,
|
|
40
|
+
"",
|
|
41
|
+
theme.bold("Setup"),
|
|
42
|
+
` ${theme.accent("1.")} Get api_id and api_hash from ${theme.underline("https://my.telegram.org/apps")}`,
|
|
43
|
+
` ${theme.accent("2.")} Provide them as ${theme.label("TELEGRAM_API_ID")} / ${theme.label("TELEGRAM_API_HASH")},`,
|
|
44
|
+
` or let the agent call ${theme.label("telegram_setup")}`,
|
|
45
|
+
` ${theme.accent("3.")} Log in with ${theme.label("telegram_login_start")} (the agent can do it),`,
|
|
46
|
+
` or run ${theme.label("telegram-mcp login")} in a terminal`,
|
|
47
|
+
"",
|
|
48
|
+
theme.bold("Updating"),
|
|
49
|
+
` ${theme.accent("telegram-mcp update".padEnd(42))} ${theme.muted("detects how it was installed")}`,
|
|
50
|
+
` ${theme.accent("telegram-mcp update --check".padEnd(42))} ${theme.muted("report only")}`,
|
|
51
|
+
` ${theme.accent(`npx -y ${PACKAGE_NAME}@latest`.padEnd(42))} ${theme.muted("npx, always current")}`,
|
|
52
|
+
` ${theme.accent(`npm install -g ${PACKAGE_NAME}@latest`.padEnd(42))} ${theme.muted("global install")}`,
|
|
53
|
+
"",
|
|
54
|
+
`${theme.muted("Docs")} ${theme.underline("https://github.com/dikaglorya/telegram-mcp")}`,
|
|
55
|
+
"",
|
|
56
|
+
].join("\n");
|
|
57
|
+
}
|
|
58
|
+
/** Subcommands that act like a CLI instead of serving stdio. */
|
|
59
|
+
function dispatchSubcommand(argv) {
|
|
60
|
+
const command = argv[0];
|
|
61
|
+
if (command !== "login" && command !== "doctor" && command !== "update")
|
|
62
|
+
return false;
|
|
63
|
+
// Each module runs its own main() on import; they keep the same behaviour as
|
|
64
|
+
// `npm run login` and `npm run doctor`, just reachable from one binary.
|
|
65
|
+
const targets = {
|
|
66
|
+
login: "./cli/login.js",
|
|
67
|
+
doctor: "./cli/doctor.js",
|
|
68
|
+
update: "./cli/update.js",
|
|
69
|
+
};
|
|
70
|
+
const target = targets[command];
|
|
71
|
+
void import(target).catch((err) => {
|
|
72
|
+
process.stderr.write(`${theme.fail(`${sym.fail} cannot start ${command}: ${err instanceof Error ? err.message : String(err)}`)}\n`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
});
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
/** Handled before the transport is created, so stdout stays parseable. */
|
|
38
78
|
function handleCliFlags(argv) {
|
|
39
|
-
|
|
40
|
-
|
|
79
|
+
// Claude Desktop (and a few other clients) launch the server with
|
|
80
|
+
// `mcp-server-<name>` as an argument. That is not a subcommand, so anything
|
|
81
|
+
// unrecognised must fall through to serving rather than be interpreted.
|
|
82
|
+
const args = argv.slice(2).filter((a) => !/^mcp-server-/.test(a));
|
|
83
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
41
84
|
process.stdout.write(`${SERVER_VERSION}\n`);
|
|
42
85
|
return true;
|
|
43
86
|
}
|
|
44
|
-
if (
|
|
45
|
-
process.stdout.write(
|
|
87
|
+
if (args.includes("--help") || args.includes("-h") || args[0] === "help") {
|
|
88
|
+
process.stdout.write(help());
|
|
46
89
|
return true;
|
|
47
90
|
}
|
|
48
|
-
return
|
|
91
|
+
return dispatchSubcommand(args);
|
|
92
|
+
}
|
|
93
|
+
function startupBanner(configured, allowAll, allowlist) {
|
|
94
|
+
if (configured) {
|
|
95
|
+
banner({ subtitle: "your Telegram account, as an MCP server", compact: true });
|
|
96
|
+
info(`${theme.success(sym.ok)} serving over stdio ${theme.muted(`(allowAll=${allowAll}, allowlist=${allowlist})`)}`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
banner({ subtitle: "not configured yet", compact: true });
|
|
100
|
+
info(`${theme.warn(`${sym.warn} ${theme.bold("not configured")} — no api_id/api_hash yet`)}`);
|
|
101
|
+
hint(`get them from ${theme.underline("https://my.telegram.org/apps")}`);
|
|
102
|
+
hint(`then call ${theme.label("telegram_setup")} from chat, or run ${theme.label("telegram-mcp login")}`);
|
|
103
|
+
gap();
|
|
104
|
+
muted(`serving anyway — waiting for the agent`);
|
|
49
105
|
}
|
|
50
106
|
async function main() {
|
|
51
107
|
// A missing .env must NOT be fatal: the user needs telegram_setup (and the
|
|
52
108
|
// doctor) to be reachable so they can fix it from chat. Only a genuinely
|
|
53
109
|
// broken server would exit here.
|
|
54
110
|
let config = null;
|
|
111
|
+
let reason = "";
|
|
55
112
|
try {
|
|
56
113
|
config = getConfig();
|
|
57
114
|
ensureDirs(config);
|
|
58
115
|
}
|
|
59
116
|
catch (err) {
|
|
60
|
-
|
|
61
|
-
`[telegram-mcp] serving anyway — call telegram_setup to create .env, then telegram_login_start.\n`);
|
|
117
|
+
reason = redact(err instanceof Error ? err.message : String(err));
|
|
62
118
|
}
|
|
63
119
|
const server = buildServer();
|
|
64
120
|
const transport = new StdioServerTransport();
|
|
65
121
|
await server.connect(transport);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
122
|
+
startupBanner(Boolean(config), config?.allowAllChats ?? false, config?.allowlist.length ?? 0);
|
|
123
|
+
if (!config && reason) {
|
|
124
|
+
hint(theme.muted(reason.split(".")[0]));
|
|
125
|
+
hint(theme.muted("this server keeps running so the agent can call telegram_setup"));
|
|
126
|
+
}
|
|
69
127
|
// After the transport is live, so a slow or unreachable registry can never
|
|
70
128
|
// delay the first tool call.
|
|
71
129
|
warnIfOutdated();
|
|
72
130
|
const shutdown = async (signal) => {
|
|
73
|
-
|
|
131
|
+
muted(`${signal}, shutting down`);
|
|
74
132
|
try {
|
|
75
133
|
// Drops any half-finished login socket too, not just the main client.
|
|
76
134
|
await shutdownLogin();
|
|
@@ -85,7 +143,7 @@ async function main() {
|
|
|
85
143
|
}
|
|
86
144
|
if (!handleCliFlags(process.argv)) {
|
|
87
145
|
main().catch((err) => {
|
|
88
|
-
process.stderr.write(
|
|
146
|
+
process.stderr.write(`${theme.fail(`${sym.fail} fatal: ${redact(err instanceof Error ? err.stack ?? err.message : String(err))}`)}\n`);
|
|
89
147
|
process.exit(1);
|
|
90
148
|
});
|
|
91
149
|
}
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal presentation: colour, symbols, a banner and small animations.
|
|
3
|
+
*
|
|
4
|
+
* Rules this module enforces, in order of importance:
|
|
5
|
+
*
|
|
6
|
+
* 1. **stdout is never touched.** Only stderr goes through here. stdout is the
|
|
7
|
+
* MCP JSON-RPC channel, and a single ANSI escape sequence written there
|
|
8
|
+
* corrupts the stream and gets the server dropped by the client.
|
|
9
|
+
* 2. **Degrade silently.** Colour needs a TTY, a terminal that is not dumb,
|
|
10
|
+
* and no NO_COLOR. Piped output, CI logs and `npx ... > file` get plain
|
|
11
|
+
* text with no escape codes at all.
|
|
12
|
+
* 3. **Animation is optional and cannot hang.** Every animation has a hard
|
|
13
|
+
* time cap and is skipped when `TERM=dumb`, when stderr is not a TTY, when
|
|
14
|
+
* the terminal is narrow, or when TELEGRAM_NO_ANIMATION is set.
|
|
15
|
+
*
|
|
16
|
+
* The exported surface is small on purpose: the rest of the codebase should not
|
|
17
|
+
* be threading `isTTY` checks through every message.
|
|
18
|
+
*/
|
|
19
|
+
import { SERVER_NAME, SERVER_VERSION } from "./version.js";
|
|
20
|
+
/* ------------------------------------------------------------------ */
|
|
21
|
+
/* Capability detection */
|
|
22
|
+
/* ------------------------------------------------------------------ */
|
|
23
|
+
const isWindows = process.platform === "win32";
|
|
24
|
+
function stderrIsTty() {
|
|
25
|
+
return Boolean(process.stderr.isTTY);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Colour support. `NO_COLOR` is the de-facto standard opt-out and takes
|
|
29
|
+
* priority over everything (https://no-color.org). `FORCE_COLOR` exists for the
|
|
30
|
+
* case where output is piped into a colour-aware pager.
|
|
31
|
+
*/
|
|
32
|
+
export function colorEnabled() {
|
|
33
|
+
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "")
|
|
34
|
+
return false;
|
|
35
|
+
const forced = process.env.FORCE_COLOR;
|
|
36
|
+
if (forced !== undefined && forced !== "" && forced !== "0")
|
|
37
|
+
return true;
|
|
38
|
+
if (forced === "0")
|
|
39
|
+
return false;
|
|
40
|
+
if (!stderrIsTty())
|
|
41
|
+
return false;
|
|
42
|
+
if (process.env.TERM === "dumb")
|
|
43
|
+
return false;
|
|
44
|
+
// Windows Terminal and VS Code advertise themselves; plain conhost does not
|
|
45
|
+
// reliably handle SGR, so it stays plain unless FORCE_COLOR says otherwise.
|
|
46
|
+
if (isWindows && !process.env.WT_SESSION && !process.env.TERM_PROGRAM)
|
|
47
|
+
return false;
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
/** Whether the fancy stuff (banner frame, spinner) should run at all. */
|
|
51
|
+
export function animationEnabled() {
|
|
52
|
+
if (process.env.TELEGRAM_NO_ANIMATION && process.env.TELEGRAM_NO_ANIMATION !== "0")
|
|
53
|
+
return false;
|
|
54
|
+
if (!stderrIsTty())
|
|
55
|
+
return false;
|
|
56
|
+
if (process.env.TERM === "dumb")
|
|
57
|
+
return false;
|
|
58
|
+
const columns = process.stderr.columns ?? 0;
|
|
59
|
+
if (columns > 0 && columns < 60)
|
|
60
|
+
return false;
|
|
61
|
+
return colorEnabled();
|
|
62
|
+
}
|
|
63
|
+
function sgr(open, close = 0) {
|
|
64
|
+
return (text) => (colorEnabled() ? `\u001b[${open}m${text}\u001b[${close}m` : text);
|
|
65
|
+
}
|
|
66
|
+
/** 256-colour foreground, falling back to cyan when colour is off. */
|
|
67
|
+
function c256(code, fallback) {
|
|
68
|
+
return (text) => colorEnabled() ? `\u001b[38;5;${code}m${text}\u001b[0m` : `\u001b[${fallback}m${text}\u001b[0m`;
|
|
69
|
+
}
|
|
70
|
+
const basic = !process.env.TELEGRAM_256_COLORS;
|
|
71
|
+
/**
|
|
72
|
+
* Low-level painters, for the rare call that needs one word emphasised inside a
|
|
73
|
+
* sentence. Prefer `theme` in new code; these are aliases kept small on purpose.
|
|
74
|
+
*/
|
|
75
|
+
export const paint = {
|
|
76
|
+
bold: sgr(1, 22),
|
|
77
|
+
dim: sgr(2, 22),
|
|
78
|
+
underline: sgr(4, 24),
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* The palette: cool cyan/teal for structure, warm accents for emphasis.
|
|
82
|
+
* Names are semantic (`success`, `fail`) so call sites read as intent.
|
|
83
|
+
*/
|
|
84
|
+
export const theme = {
|
|
85
|
+
bold: paint.bold,
|
|
86
|
+
dim: paint.dim,
|
|
87
|
+
underline: paint.underline,
|
|
88
|
+
border: basic ? sgr(90) : c256(244, 90),
|
|
89
|
+
title: basic ? sgr(96) : c256(51, 96),
|
|
90
|
+
label: basic ? sgr(97) : c256(252, 97),
|
|
91
|
+
accent: basic ? sgr(96) : c256(45, 96),
|
|
92
|
+
success: basic ? sgr(92) : c256(48, 92),
|
|
93
|
+
fail: basic ? sgr(91) : c256(203, 91),
|
|
94
|
+
warn: basic ? sgr(93) : c256(214, 93),
|
|
95
|
+
muted: basic ? sgr(90) : c256(242, 90),
|
|
96
|
+
};
|
|
97
|
+
/* ------------------------------------------------------------------ */
|
|
98
|
+
/* Symbols */
|
|
99
|
+
/* ------------------------------------------------------------------ */
|
|
100
|
+
// Unicode box drawing and symbols are fine on every modern terminal, but
|
|
101
|
+
// Windows conhost with a legacy code page mangles them, so ASCII is used there.
|
|
102
|
+
const unicodeSymbols = !isWindows || Boolean(process.env.WT_SESSION);
|
|
103
|
+
export const sym = {
|
|
104
|
+
ok: unicodeSymbols ? "✔" : "v",
|
|
105
|
+
fail: unicodeSymbols ? "✖" : "x",
|
|
106
|
+
warn: unicodeSymbols ? "▲" : "!",
|
|
107
|
+
arrow: unicodeSymbols ? "→" : "->",
|
|
108
|
+
bullet: unicodeSymbols ? "·" : "-",
|
|
109
|
+
bar: unicodeSymbols ? "│" : "|",
|
|
110
|
+
tl: unicodeSymbols ? "╭" : "+",
|
|
111
|
+
tr: unicodeSymbols ? "╮" : "+",
|
|
112
|
+
bl: unicodeSymbols ? "╰" : "+",
|
|
113
|
+
br: unicodeSymbols ? "╯" : "+",
|
|
114
|
+
h: unicodeSymbols ? "─" : "-",
|
|
115
|
+
};
|
|
116
|
+
/* ------------------------------------------------------------------ */
|
|
117
|
+
/* Message helpers — stderr only */
|
|
118
|
+
/* ------------------------------------------------------------------ */
|
|
119
|
+
function write(text) {
|
|
120
|
+
process.stderr.write(text);
|
|
121
|
+
}
|
|
122
|
+
/** The canonical diagnostic line. `[telegram-mcp] ...` stays greppable. */
|
|
123
|
+
export function info(message) {
|
|
124
|
+
write(`${theme.title(`[${SERVER_NAME}]`)} ${message}\n`);
|
|
125
|
+
}
|
|
126
|
+
export function muted(message) {
|
|
127
|
+
write(`${theme.muted(`[${SERVER_NAME}]`)} ${message}\n`);
|
|
128
|
+
}
|
|
129
|
+
export function success(message) {
|
|
130
|
+
write(`${theme.success(sym.ok)} ${message}\n`);
|
|
131
|
+
}
|
|
132
|
+
export function warn(message) {
|
|
133
|
+
write(`${theme.warn(`${sym.warn} ${message}`)}\n`);
|
|
134
|
+
}
|
|
135
|
+
export function error(message) {
|
|
136
|
+
write(`${theme.fail(`${sym.fail} ${message}`)}\n`);
|
|
137
|
+
}
|
|
138
|
+
export function hint(message) {
|
|
139
|
+
write(` ${theme.muted(`${sym.bullet} ${message}`)}\n`);
|
|
140
|
+
}
|
|
141
|
+
/** A blank line, but only when the terminal can afford it. */
|
|
142
|
+
export function gap() {
|
|
143
|
+
if (animationEnabled())
|
|
144
|
+
write("\n");
|
|
145
|
+
}
|
|
146
|
+
/* ------------------------------------------------------------------ */
|
|
147
|
+
/* Banner */
|
|
148
|
+
/* ------------------------------------------------------------------ */
|
|
149
|
+
/** Two-row block wordmark: readable at small widths, unlike a huge figlet. */
|
|
150
|
+
const WORDMARK = [
|
|
151
|
+
"▀█▀ █▀▀ █░░ █▀▀ █▀▀ █▀█ ▄▀█ █▀▄▀█",
|
|
152
|
+
"░█░ █▀▀ █░░ ██▄ █▄█ █▀▄ █▀█ █░▀░█",
|
|
153
|
+
];
|
|
154
|
+
/**
|
|
155
|
+
* Print the banner. Deliberately quiet on repeated runs: the full frame is for
|
|
156
|
+
* CLI commands (login, doctor, --help), while a server start uses the compact
|
|
157
|
+
* form so an agent's log does not fill up with art.
|
|
158
|
+
*/
|
|
159
|
+
export function banner(opts = {}) {
|
|
160
|
+
const version = opts.version ?? SERVER_VERSION;
|
|
161
|
+
const subtitle = opts.subtitle ?? "your Telegram account, as an MCP server";
|
|
162
|
+
if (opts.compact || !animationEnabled()) {
|
|
163
|
+
write(`${theme.title(SERVER_NAME)} ${theme.muted(version)} ${theme.muted(sym.bullet)} ${theme.label(subtitle)}\n`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const width = Math.max(...WORDMARK.map((row) => [...row].length)) + 4;
|
|
167
|
+
write(`${theme.border(`${sym.tl}${sym.h.repeat(width)}${sym.tr}`)}\n`);
|
|
168
|
+
for (const row of WORDMARK)
|
|
169
|
+
write(`${theme.border(sym.bar)} ${theme.title(row)}\n`);
|
|
170
|
+
write(`${theme.border(sym.bar)} ${theme.muted(version)} ${theme.muted(sym.bullet)} ${theme.label(subtitle)}\n`);
|
|
171
|
+
write(`${theme.border(`${sym.bl}${sym.h.repeat(width)}${sym.br}`)}\n`);
|
|
172
|
+
}
|
|
173
|
+
/* ------------------------------------------------------------------ */
|
|
174
|
+
/* Spinner */
|
|
175
|
+
/* ------------------------------------------------------------------ */
|
|
176
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
177
|
+
/** Redraw the current line in place. Only valid when animation is on. */
|
|
178
|
+
function redraw(text) {
|
|
179
|
+
write(`\u001b[2K\r${text}`);
|
|
180
|
+
}
|
|
181
|
+
function clearLine() {
|
|
182
|
+
write("\u001b[2K\r");
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* A one-line spinner with a hard time cap, so a stuck network call cannot leave
|
|
186
|
+
* something spinning in a log forever. `stop()` erases the line and optionally
|
|
187
|
+
* replaces it with a final message.
|
|
188
|
+
*/
|
|
189
|
+
export function spinner(text, opts = {}) {
|
|
190
|
+
const maxMs = opts.maxMs ?? 6000;
|
|
191
|
+
if (!animationEnabled()) {
|
|
192
|
+
write(`${theme.muted(`${sym.bullet} ${text}`)}\n`);
|
|
193
|
+
return { stop: () => undefined };
|
|
194
|
+
}
|
|
195
|
+
let frame = 0;
|
|
196
|
+
const started = Date.now();
|
|
197
|
+
let stopped = false;
|
|
198
|
+
const stop = (final) => {
|
|
199
|
+
if (stopped)
|
|
200
|
+
return;
|
|
201
|
+
stopped = true;
|
|
202
|
+
clearInterval(timer);
|
|
203
|
+
clearLine();
|
|
204
|
+
if (final)
|
|
205
|
+
write(`${final}\n`);
|
|
206
|
+
};
|
|
207
|
+
const draw = () => redraw(`${theme.accent(FRAMES[frame++ % FRAMES.length])} ${text}`);
|
|
208
|
+
draw();
|
|
209
|
+
const timer = setInterval(() => {
|
|
210
|
+
if (Date.now() - started > maxMs) {
|
|
211
|
+
// Give up quietly: the caller still prints its own outcome line.
|
|
212
|
+
stop();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
draw();
|
|
216
|
+
}, 90);
|
|
217
|
+
// Never let the animation be the reason the process stays alive.
|
|
218
|
+
timer.unref?.();
|
|
219
|
+
return { stop };
|
|
220
|
+
}
|
|
221
|
+
/** A numbered step header, e.g. `[1/3] Connecting to Telegram`. */
|
|
222
|
+
export function step(index, total, text) {
|
|
223
|
+
write(`${theme.accent(`[${theme.muted(`${index}/${total}`)}]`)} ${theme.bold(text)}\n`);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The three-step onboarding footer shown once credentials are written. It is
|
|
227
|
+
* the answer to "ok, and now what?" — the most common question after setup.
|
|
228
|
+
*/
|
|
229
|
+
export function nextSteps(steps) {
|
|
230
|
+
if (!steps.length)
|
|
231
|
+
return;
|
|
232
|
+
gap();
|
|
233
|
+
write(`${theme.label(theme.bold("Next"))}\n`);
|
|
234
|
+
steps.forEach((text, i) => {
|
|
235
|
+
write(` ${theme.accent(String(i + 1))}${theme.muted(".")} ${text}\n`);
|
|
236
|
+
});
|
|
237
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dikacode/telegram-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Local MCP server that connects CLI agents (OpenCode, Codex CLI, Claude Code, ...) to a personal Telegram account over MTProto.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"start": "node dist/index.js",
|
|
19
19
|
"login": "tsx cli/login.ts",
|
|
20
20
|
"doctor": "tsx cli/doctor.ts",
|
|
21
|
+
"preview": "node harness/preview.mjs",
|
|
21
22
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
22
23
|
"login:force": "tsx cli/login.ts --force",
|
|
23
24
|
"test": "npm run build && node harness/unit.mjs",
|