@overpod/mcp-telegram 1.18.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,273 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [1.20.0] - 2026-03-31
11
+
12
+ ### Added
13
+ - **Rate limiting & retry** — automatic FLOOD_WAIT handling, network error recovery with exponential backoff (`src/rate-limiter.ts`)
14
+ - `send-message` now returns `messageId` in the response (`Message sent to @user [#12345]`), enabling send → edit workflows (closes #16)
15
+ - Rate limiter unit tests (7 tests in `src/__tests__/rate-limiter.test.ts`)
16
+
17
+ ### Changed
18
+ - `sendMessage()` return type changed from `void` to `Api.Message | Api.UpdateShortSentMessage | undefined`
19
+ - Write methods (`sendMessage`, `sendFile`, `editMessage`, `deleteMessages`) are now rate-limited with automatic retry on transient errors
20
+
21
+ ## [1.19.0] - 2026-03-30
22
+
23
+ ### Added
24
+ - Docker support for containerized deployment
25
+ - Non-blocking startup behavior
26
+ - Local QR code fallback for authentication
27
+ - Automated test infrastructure with Node.js test runner
28
+ - CI workflow to publish Docker images to GitHub Container Registry
29
+
30
+ ### Changed
31
+ - Added pnpm-lock.yaml for better dependency management
32
+
33
+ ## [1.18.0] - 2026-03-28
34
+
35
+ ### Added
36
+ - New `telegram-get-my-role` tool to check user's role in a chat
37
+ - Role information in `telegram-get-chat-members` results
38
+
39
+ ## [1.17.0] - 2026-03-28
40
+
41
+ ### Added
42
+ - Chat resolution by display name (not just ID or username)
43
+
44
+ ### Changed
45
+ - Updated documentation to replace static tool list with auto-discovery note
46
+ - Improved project structure documentation
47
+
48
+ ## [1.16.0] - 2026-03-28
49
+
50
+ ### Added
51
+ - Group management tools: invite, kick, ban, edit, leave
52
+ - Admin management capabilities
53
+
54
+ ## [1.15.0] - 2026-03-28
55
+
56
+ ### Added
57
+ - `telegram-create-group` tool for creating new groups
58
+
59
+ ### Fixed
60
+ - Documented `AUTH_KEY_DUPLICATED` error handling
61
+
62
+ ## [1.14.0] - 2026-03-28
63
+
64
+ ### Added
65
+ - SOCKS5 proxy support for Telegram connections
66
+ - MTProxy support for Telegram connections
67
+
68
+ ### Changed
69
+ - Updated Biome to 2.4.9 with new config schema
70
+ - Sorted imports for Biome compliance
71
+ - Added proxy documentation to README
72
+
73
+ ## [1.13.0] - 2026-03-26
74
+
75
+ ### Changed
76
+ - Refactored tools into modular files organized by category
77
+
78
+ ## [1.12.0] - 2026-03-26
79
+
80
+ ### Changed
81
+ - Migrated to `registerTool()` API with tool annotations
82
+
83
+ ## [1.11.1] - 2026-03-25
84
+
85
+ ### Fixed
86
+ - Sanitized unpaired UTF-16 surrogates in tool responses
87
+
88
+ ### Changed
89
+ - Upgraded TypeScript to 6.0
90
+ - Updated README with missing tools
91
+
92
+ ## [1.11.0] - 2026-03-23
93
+
94
+ ### Added
95
+ - Full reactions support: read, send multiple reactions, get detailed info
96
+
97
+ ### Changed
98
+ - Included message ID in all message-reading tool outputs
99
+
100
+ ## [1.10.1] - 2026-03-22
101
+
102
+ ### Fixed
103
+ - Message ID now included in all message-reading tool outputs
104
+
105
+ ## [1.10.0] - 2026-03-20
106
+
107
+ ### Added
108
+ - Enhanced `telegram-get-profile` with birthday, business, and premium data
109
+ - New `telegram-get-profile-photo` tool
110
+ - Global message search capability
111
+ - Enriched chat search results
112
+
113
+ ## [1.9.0] - 2026-03-18
114
+
115
+ ### Added
116
+ - Forum Topics support
117
+ - Per-topic unread count for forum groups
118
+ - Secure session storage with configurable path
119
+ - Multiple accounts support
120
+
121
+ ### Fixed
122
+ - Per-topic unread sum calculation for forum groups
123
+
124
+ ### Changed
125
+ - Updated session path and security documentation
126
+ - Upgraded GitHub Actions to v6
127
+ - Replaced Node 20 with Node 24 in CI
128
+ - Updated Biome to 2.4.7 and @types/node to 25.5.0
129
+
130
+ ## [1.8.1] - 2026-03-19
131
+
132
+ ### Fixed
133
+ - Redirected console.log to stderr to prevent MCP JSON-RPC corruption
134
+
135
+ ### Changed
136
+ - Updated dependencies (Biome 2.4.8)
137
+
138
+ ## [1.8.0] - 2026-03-18
139
+
140
+ ### Added
141
+ - Secure session storage with configurable path via SESSION_PATH environment variable
142
+
143
+ ### Changed
144
+ - Updated session path and security information in README
145
+
146
+ ## [1.7.0] - 2026-03-16
147
+
148
+ ### Added
149
+ - CI workflow to publish to GitHub Packages alongside npm
150
+ - Manual workflow dispatch trigger for publishing
151
+
152
+ ## [1.6.0] - 2026-03-16
153
+
154
+ ### Added
155
+ - Contact request management
156
+ - Block/unblock users
157
+ - Report spam functionality
158
+ - Add contact tool
159
+ - ChatGPT to list of supported clients
160
+
161
+ ### Changed
162
+ - Removed hardcoded tool counts from README and package.json
163
+ - Updated Biome to 2.4.7 and @types/node to 25.5.0
164
+
165
+ ## [1.5.0] - 2026-03-16
166
+
167
+ ### Added
168
+ - Reactions support
169
+ - Scheduled messages
170
+ - Polls creation and management
171
+ - `telegram-join-chat` tool for joining groups and channels
172
+
173
+ ### Changed
174
+ - Updated README with new tool documentation
175
+ - Increased tool count to 24
176
+
177
+ ## [1.4.0] - 2026-03-15
178
+
179
+ ### Added
180
+ - Glama.ai MCP catalog verification (glama.json)
181
+ - Smithery MCP catalog listing (smithery.yaml)
182
+ - Demo GIF and badges to README
183
+ - Hosted version link
184
+
185
+ ### Fixed
186
+ - Removed PNG file save from CLI QR login
187
+
188
+ ### Changed
189
+ - Updated README with Glama MCP server badge
190
+
191
+ ## [1.3.1] - 2026-03-12
192
+
193
+ ### Fixed
194
+ - Use `destroy()` instead of `disconnect()` to stop GramJS update loop
195
+ - Adopt QR login client directly instead of destroy+reconnect flow
196
+ - Destroy GramJS client in `logOut()` and `startQrLogin()` to stop update loop
197
+
198
+ ## [1.3.0] - 2026-03-12
199
+
200
+ ### Added
201
+ - `logOut()` method for complete Telegram session termination
202
+
203
+ ## [1.2.0] - 2026-03-11
204
+
205
+ ### Added
206
+ - `downloadMediaAsBuffer` for serverless media download
207
+ - Library exports and declaration types
208
+ - Date filters for messages
209
+ - Comprehensive README for v1.0
210
+
211
+ ### Fixed
212
+ - MIME type detection from magic bytes in `downloadMediaAsBuffer`
213
+ - Made `saveSession` resilient to file write errors in Docker
214
+
215
+ ### Changed
216
+ - Use `GetFullChannel`/`GetFullChat` for complete chat information
217
+ - Improved `telegram-login` for Claude Desktop users
218
+ - Added npm publishing support and GitHub Actions CI/CD
219
+
220
+ ## [1.1.0] - 2026-03-11
221
+
222
+ ### Added
223
+ - Contact management tools
224
+ - Chat members listing
225
+ - User profile retrieval
226
+ - Chat type filter
227
+ - Media tools (send, download, get info)
228
+ - Pin/unpin messages
229
+ - Markdown support
230
+ - Media information in messages
231
+ - Unread counts
232
+ - Mark messages as read
233
+ - Forward messages
234
+ - Edit messages
235
+ - Delete messages
236
+ - Detailed chat information
237
+ - Pagination support
238
+
239
+ ## [1.0.0] - 2026-03-10
240
+
241
+ ### Added
242
+ - Initial release: MCP server for Telegram userbot
243
+ - Basic message reading and sending
244
+ - Chat listing
245
+ - Authentication via phone number and QR code
246
+ - Session persistence
247
+ - GramJS/MTProto integration
248
+
249
+ [Unreleased]: https://github.com/overpod/mcp-telegram/compare/v1.19.0...HEAD
250
+ [1.19.0]: https://github.com/overpod/mcp-telegram/compare/v1.18.0...v1.19.0
251
+ [1.18.0]: https://github.com/overpod/mcp-telegram/compare/v1.17.0...v1.18.0
252
+ [1.17.0]: https://github.com/overpod/mcp-telegram/compare/v1.16.0...v1.17.0
253
+ [1.16.0]: https://github.com/overpod/mcp-telegram/compare/v1.15.0...v1.16.0
254
+ [1.15.0]: https://github.com/overpod/mcp-telegram/compare/v1.14.0...v1.15.0
255
+ [1.14.0]: https://github.com/overpod/mcp-telegram/compare/v1.13.0...v1.14.0
256
+ [1.13.0]: https://github.com/overpod/mcp-telegram/compare/v1.12.0...v1.13.0
257
+ [1.12.0]: https://github.com/overpod/mcp-telegram/compare/v1.11.1...v1.12.0
258
+ [1.11.1]: https://github.com/overpod/mcp-telegram/compare/v1.11.0...v1.11.1
259
+ [1.11.0]: https://github.com/overpod/mcp-telegram/compare/v1.10.1...v1.11.0
260
+ [1.10.1]: https://github.com/overpod/mcp-telegram/compare/v1.10.0...v1.10.1
261
+ [1.10.0]: https://github.com/overpod/mcp-telegram/compare/v1.9.0...v1.10.0
262
+ [1.9.0]: https://github.com/overpod/mcp-telegram/compare/v1.8.1...v1.9.0
263
+ [1.8.1]: https://github.com/overpod/mcp-telegram/compare/v1.8.0...v1.8.1
264
+ [1.8.0]: https://github.com/overpod/mcp-telegram/compare/v1.7.0...v1.8.0
265
+ [1.7.0]: https://github.com/overpod/mcp-telegram/compare/v1.6.0...v1.7.0
266
+ [1.6.0]: https://github.com/overpod/mcp-telegram/compare/v1.5.0...v1.6.0
267
+ [1.5.0]: https://github.com/overpod/mcp-telegram/compare/v1.4.0...v1.5.0
268
+ [1.4.0]: https://github.com/overpod/mcp-telegram/compare/v1.3.1...v1.4.0
269
+ [1.3.1]: https://github.com/overpod/mcp-telegram/compare/v1.3.0...v1.3.1
270
+ [1.3.0]: https://github.com/overpod/mcp-telegram/compare/v1.2.0...v1.3.0
271
+ [1.2.0]: https://github.com/overpod/mcp-telegram/compare/v1.1.0...v1.2.0
272
+ [1.1.0]: https://github.com/overpod/mcp-telegram/compare/v1.0.0...v1.1.0
273
+ [1.0.0]: https://github.com/overpod/mcp-telegram/releases/tag/v1.0.0
package/README.md CHANGED
@@ -138,6 +138,34 @@ cd mcp-telegram
138
138
  npm install && npm run build
139
139
  ```
140
140
 
141
+ ### Docker
142
+
143
+ ```bash
144
+ docker build -t mcp-telegram https://github.com/overpod/mcp-telegram.git
145
+ ```
146
+
147
+ Login (interactive terminal required):
148
+
149
+ ```bash
150
+ docker run -it --rm \
151
+ -e TELEGRAM_API_ID=YOUR_ID \
152
+ -e TELEGRAM_API_HASH=YOUR_HASH \
153
+ -v ~/.mcp-telegram:/root/.mcp-telegram \
154
+ --entrypoint node mcp-telegram dist/qr-login-cli.js
155
+ ```
156
+
157
+ Run the MCP server:
158
+
159
+ ```bash
160
+ docker run -i --rm \
161
+ -e TELEGRAM_API_ID=YOUR_ID \
162
+ -e TELEGRAM_API_HASH=YOUR_HASH \
163
+ -v ~/.mcp-telegram:/root/.mcp-telegram \
164
+ mcp-telegram
165
+ ```
166
+
167
+ > **Note**: Login must be done once via terminal. After that, the session is persisted in `~/.mcp-telegram` and reused automatically.
168
+
141
169
  ## Usage with MCP Clients
142
170
 
143
171
  ### Claude Code (CLI)
@@ -174,12 +202,37 @@ claude mcp add telegram -s user \
174
202
 
175
203
  3. Restart Claude Desktop.
176
204
 
177
- 4. Ask Claude: **"Run telegram-login"** -- a QR code will appear. If the image is not visible, Claude will provide a browser link to view the QR code. Scan it in Telegram (**Settings > Devices > Link Desktop Device**).
205
+ 4. Ask Claude: **"Run telegram-login"** -- a QR code will appear. If the image is not visible, it's also saved to `~/.mcp-telegram/qr-login.png`. Scan it in Telegram (**Settings > Devices > Link Desktop Device**).
178
206
 
179
207
  5. Ask Claude: **"Run telegram-status"** to verify the connection.
180
208
 
181
209
  > **Note**: No terminal required! Login works entirely through Claude Desktop.
182
210
 
211
+ ### Claude Desktop (Docker)
212
+
213
+ 1. Login via terminal first (see [Docker](#docker) section above).
214
+
215
+ 2. Add to your config file:
216
+
217
+ ```json
218
+ {
219
+ "mcpServers": {
220
+ "telegram": {
221
+ "command": "docker",
222
+ "args": [
223
+ "run", "-i", "--rm",
224
+ "-e", "TELEGRAM_API_ID=YOUR_ID",
225
+ "-e", "TELEGRAM_API_HASH=YOUR_HASH",
226
+ "-v", "~/.mcp-telegram:/root/.mcp-telegram",
227
+ "mcp-telegram"
228
+ ]
229
+ }
230
+ }
231
+ }
232
+ ```
233
+
234
+ 3. Restart Claude Desktop. Ask Claude: **"Run telegram-status"** to verify.
235
+
183
236
  ### Cursor / VS Code
184
237
 
185
238
  Add the same JSON config above to your MCP settings (Cursor Settings > MCP, or VS Code MCP config).
@@ -276,6 +329,8 @@ Then set `TELEGRAM_SESSION_PATH` in each environment's MCP config accordingly.
276
329
  - Session is stored in `~/.mcp-telegram/session` with `0600` permissions (owner-only access)
277
330
  - Session directory is created with `0700` permissions
278
331
  - Phone number is **not required** -- QR-only authentication
332
+ - No data is sent to third-party services -- all communication goes directly to Telegram servers via MTProto
333
+ - QR login codes are generated locally and never leave your machine
279
334
  - **One session per process** -- using the same session in multiple processes simultaneously causes `AUTH_KEY_DUPLICATED` errors (see [Troubleshooting](#troubleshooting))
280
335
  - This is a **userbot** (personal account), not a bot -- respect the [Telegram Terms of Service](https://core.telegram.org/api/terms)
281
336
 
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,81 @@
1
+ import assert from "node:assert";
2
+ import { describe, it } from "node:test";
3
+ import { RateLimiter } from "../rate-limiter.js";
4
+ describe("RateLimiter", () => {
5
+ it("should execute a function successfully", async () => {
6
+ const limiter = new RateLimiter({ maxRequestsPerSecond: 100 });
7
+ const result = await limiter.execute(async () => "success");
8
+ assert.strictEqual(result, "success");
9
+ });
10
+ it("should enforce rate limiting between requests", async () => {
11
+ const limiter = new RateLimiter({ maxRequestsPerSecond: 10 }); // 10 req/s = 100ms between requests
12
+ const start = Date.now();
13
+ await limiter.execute(async () => "first");
14
+ await limiter.execute(async () => "second");
15
+ const elapsed = Date.now() - start;
16
+ assert.ok(elapsed >= 90, `Expected at least 90ms, got ${elapsed}ms`);
17
+ });
18
+ it("should retry on FLOOD_WAIT error", async () => {
19
+ const limiter = new RateLimiter({ maxRetries: 2, maxRequestsPerSecond: 100 });
20
+ let attempts = 0;
21
+ const result = await limiter.execute(async () => {
22
+ attempts++;
23
+ if (attempts < 2) {
24
+ throw new Error("FLOOD_WAIT_1");
25
+ }
26
+ return "success after retry";
27
+ });
28
+ assert.strictEqual(result, "success after retry");
29
+ assert.strictEqual(attempts, 2);
30
+ });
31
+ it("should throw after max retries on FLOOD_WAIT", async () => {
32
+ const limiter = new RateLimiter({ maxRetries: 1, maxRequestsPerSecond: 100 });
33
+ await assert.rejects(async () => {
34
+ await limiter.execute(async () => {
35
+ throw new Error("FLOOD_WAIT_2");
36
+ });
37
+ }, {
38
+ message: /Rate limit exceeded after 1 retries/,
39
+ });
40
+ });
41
+ it("should retry on network errors with exponential backoff", async () => {
42
+ const limiter = new RateLimiter({
43
+ maxRetries: 2,
44
+ initialRetryDelay: 100,
45
+ maxRequestsPerSecond: 100,
46
+ });
47
+ let attempts = 0;
48
+ const result = await limiter.execute(async () => {
49
+ attempts++;
50
+ if (attempts < 2) {
51
+ throw new Error("TIMEOUT");
52
+ }
53
+ return "recovered";
54
+ });
55
+ assert.strictEqual(result, "recovered");
56
+ assert.strictEqual(attempts, 2);
57
+ });
58
+ it("should not retry on non-retryable errors", async () => {
59
+ const limiter = new RateLimiter({ maxRetries: 3, maxRequestsPerSecond: 100 });
60
+ let attempts = 0;
61
+ await assert.rejects(async () => {
62
+ await limiter.execute(async () => {
63
+ attempts++;
64
+ throw new Error("AUTH_KEY_UNREGISTERED");
65
+ });
66
+ }, {
67
+ message: "AUTH_KEY_UNREGISTERED",
68
+ });
69
+ assert.strictEqual(attempts, 1, "Should not retry non-retryable errors");
70
+ });
71
+ it("should handle FLOOD_WAIT with seconds parsing", async () => {
72
+ const limiter = new RateLimiter({ maxRetries: 1, maxRequestsPerSecond: 100 });
73
+ await assert.rejects(async () => {
74
+ await limiter.execute(async () => {
75
+ throw new Error("FLOOD_WAIT_1");
76
+ });
77
+ }, {
78
+ message: /Telegram requires 1s wait/,
79
+ });
80
+ });
81
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,110 @@
1
+ import assert from "node:assert";
2
+ import { describe, it } from "node:test";
3
+ import { DESTRUCTIVE, fail, formatReactions, ok, READ_ONLY, sanitize, WRITE } from "../../tools/shared.js";
4
+ describe("shared utilities", () => {
5
+ describe("ok()", () => {
6
+ it("should return success response with text content", () => {
7
+ const result = ok("Operation successful");
8
+ assert.deepStrictEqual(result, {
9
+ content: [{ type: "text", text: "Operation successful" }],
10
+ });
11
+ });
12
+ it("should handle empty string", () => {
13
+ const result = ok("");
14
+ assert.deepStrictEqual(result, {
15
+ content: [{ type: "text", text: "" }],
16
+ });
17
+ });
18
+ });
19
+ describe("fail()", () => {
20
+ it("should return error response with isError flag", () => {
21
+ const error = new Error("Something went wrong");
22
+ const result = fail(error);
23
+ assert.deepStrictEqual(result, {
24
+ content: [{ type: "text", text: "Error: Something went wrong" }],
25
+ isError: true,
26
+ });
27
+ });
28
+ it("should handle non-Error objects", () => {
29
+ const result = fail({ message: "Custom error" });
30
+ assert.ok(result.content[0].text.includes("Error:"));
31
+ assert.strictEqual(result.isError, true);
32
+ });
33
+ });
34
+ describe("sanitize()", () => {
35
+ it("should remove unpaired high surrogates", () => {
36
+ const input = "Hello\uD800World";
37
+ const result = sanitize(input);
38
+ assert.strictEqual(result, "Hello\uFFFDWorld");
39
+ });
40
+ it("should remove unpaired low surrogates", () => {
41
+ const input = "Hello\uDC00World";
42
+ const result = sanitize(input);
43
+ assert.strictEqual(result, "Hello\uFFFDWorld");
44
+ });
45
+ it("should preserve valid surrogate pairs", () => {
46
+ const input = "Hello\uD83D\uDE00World"; // 😀 emoji
47
+ const result = sanitize(input);
48
+ assert.strictEqual(result, "Hello\uD83D\uDE00World");
49
+ });
50
+ it("should handle normal text without surrogates", () => {
51
+ const input = "Hello World";
52
+ const result = sanitize(input);
53
+ assert.strictEqual(result, "Hello World");
54
+ });
55
+ it("should handle empty string", () => {
56
+ const result = sanitize("");
57
+ assert.strictEqual(result, "");
58
+ });
59
+ });
60
+ describe("formatReactions()", () => {
61
+ it("should format reactions with counts", () => {
62
+ const reactions = [
63
+ { emoji: "👍", count: 5, me: false },
64
+ { emoji: "❤️", count: 3, me: true },
65
+ { emoji: "🔥", count: 1, me: false },
66
+ ];
67
+ const result = formatReactions(reactions);
68
+ assert.strictEqual(result, " [👍×5 ❤️×3(me) 🔥×1]");
69
+ });
70
+ it("should mark reactions from current user", () => {
71
+ const reactions = [{ emoji: "👍", count: 2, me: true }];
72
+ const result = formatReactions(reactions);
73
+ assert.strictEqual(result, " [👍×2(me)]");
74
+ });
75
+ it("should return empty string for undefined reactions", () => {
76
+ const result = formatReactions(undefined);
77
+ assert.strictEqual(result, "");
78
+ });
79
+ it("should return empty string for empty reactions array", () => {
80
+ const result = formatReactions([]);
81
+ assert.strictEqual(result, "");
82
+ });
83
+ it("should handle single reaction", () => {
84
+ const reactions = [{ emoji: "🎉", count: 1, me: false }];
85
+ const result = formatReactions(reactions);
86
+ assert.strictEqual(result, " [🎉×1]");
87
+ });
88
+ });
89
+ describe("MCP tool annotations", () => {
90
+ it("should define READ_ONLY preset", () => {
91
+ assert.deepStrictEqual(READ_ONLY, {
92
+ readOnlyHint: true,
93
+ openWorldHint: true,
94
+ });
95
+ });
96
+ it("should define WRITE preset", () => {
97
+ assert.deepStrictEqual(WRITE, {
98
+ readOnlyHint: false,
99
+ openWorldHint: true,
100
+ });
101
+ });
102
+ it("should define DESTRUCTIVE preset", () => {
103
+ assert.deepStrictEqual(DESTRUCTIVE, {
104
+ readOnlyHint: false,
105
+ destructiveHint: true,
106
+ openWorldHint: true,
107
+ });
108
+ });
109
+ });
110
+ });
package/dist/index.js CHANGED
@@ -14,7 +14,9 @@ import { registerTools } from "./tools/index.js";
14
14
  const API_ID = Number(process.env.TELEGRAM_API_ID);
15
15
  const API_HASH = process.env.TELEGRAM_API_HASH;
16
16
  if (!API_ID || !API_HASH) {
17
- console.error("[mcp-telegram] TELEGRAM_API_ID and TELEGRAM_API_HASH must be set");
17
+ console.error("[mcp-telegram] Missing TELEGRAM_API_ID and TELEGRAM_API_HASH");
18
+ console.error("Get your credentials at https://my.telegram.org/apps (API development tools)");
19
+ console.error("Set them in .env or export as environment variables");
18
20
  process.exit(1);
19
21
  }
20
22
  const telegram = new TelegramService(API_ID, API_HASH);
@@ -24,18 +26,19 @@ const server = new McpServer({
24
26
  });
25
27
  registerTools(server, telegram);
26
28
  async function main() {
27
- // Try to auto-connect with saved session
28
- await telegram.loadSession();
29
- if (await telegram.connect()) {
30
- const me = await telegram.getMe();
31
- console.error(`[mcp-telegram] Auto-connected as @${me.username}`);
32
- }
33
- else if (telegram.lastError) {
34
- console.error(`[mcp-telegram] ${telegram.lastError}`);
35
- }
36
29
  const transport = new StdioServerTransport();
37
30
  await server.connect(transport);
38
31
  console.error("[mcp-telegram] MCP server running on stdio");
32
+ // Auto-connect with saved session after MCP is ready (non-blocking)
33
+ telegram.loadSession().then(async () => {
34
+ if (await telegram.connect()) {
35
+ const me = await telegram.getMe();
36
+ console.error(`[mcp-telegram] Auto-connected as @${me.username}`);
37
+ }
38
+ else if (telegram.lastError) {
39
+ console.error(`[mcp-telegram] ${telegram.lastError}`);
40
+ }
41
+ });
39
42
  }
40
43
  main().catch((err) => {
41
44
  console.error("[mcp-telegram] Fatal:", err);
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Rate limiter and retry logic for Telegram API calls.
3
+ * Handles FLOOD_WAIT errors and implements exponential backoff.
4
+ */
5
+ export interface RateLimiterOptions {
6
+ /** Maximum number of requests per second (default: 20) */
7
+ maxRequestsPerSecond?: number;
8
+ /** Maximum number of retry attempts (default: 3) */
9
+ maxRetries?: number;
10
+ /** Initial retry delay in milliseconds (default: 1000) */
11
+ initialRetryDelay?: number;
12
+ /** Maximum retry delay in milliseconds (default: 60000) */
13
+ maxRetryDelay?: number;
14
+ }
15
+ export declare class RateLimiter {
16
+ private lastRequestTime;
17
+ private minInterval;
18
+ private maxRetries;
19
+ private initialRetryDelay;
20
+ private maxRetryDelay;
21
+ constructor(options?: RateLimiterOptions);
22
+ /** Execute a function with rate limiting and automatic retry */
23
+ execute<T>(fn: () => Promise<T>, context?: string): Promise<T>;
24
+ private executeWithRetry;
25
+ private waitForSlot;
26
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Rate limiter and retry logic for Telegram API calls.
3
+ * Handles FLOOD_WAIT errors and implements exponential backoff.
4
+ */
5
+ export class RateLimiter {
6
+ lastRequestTime = 0;
7
+ minInterval;
8
+ maxRetries;
9
+ initialRetryDelay;
10
+ maxRetryDelay;
11
+ constructor(options = {}) {
12
+ const maxRequestsPerSecond = options.maxRequestsPerSecond ?? 20;
13
+ this.minInterval = 1000 / maxRequestsPerSecond;
14
+ this.maxRetries = options.maxRetries ?? 3;
15
+ this.initialRetryDelay = options.initialRetryDelay ?? 1000;
16
+ this.maxRetryDelay = options.maxRetryDelay ?? 60000;
17
+ }
18
+ /** Execute a function with rate limiting and automatic retry */
19
+ async execute(fn, context = "API call") {
20
+ return this.executeWithRetry(fn, context, 0);
21
+ }
22
+ async executeWithRetry(fn, context, attempt) {
23
+ await this.waitForSlot();
24
+ try {
25
+ return await fn();
26
+ }
27
+ catch (error) {
28
+ const errorMessage = error.errorMessage || error.message || String(error);
29
+ // FLOOD_WAIT — wait the exact time Telegram requires
30
+ const floodMatch = errorMessage.match(/FLOOD_WAIT[_]?(\d+)/i);
31
+ if (floodMatch) {
32
+ const waitSeconds = Number.parseInt(floodMatch[1], 10);
33
+ if (attempt >= this.maxRetries) {
34
+ throw new Error(`Rate limit exceeded after ${this.maxRetries} retries. Telegram requires ${waitSeconds}s wait. Try again later.`);
35
+ }
36
+ console.error(`[rate-limiter] FLOOD_WAIT for ${context}. Waiting ${waitSeconds}s (attempt ${attempt + 1}/${this.maxRetries})`);
37
+ await sleep(waitSeconds * 1000);
38
+ return this.executeWithRetry(fn, context, attempt + 1);
39
+ }
40
+ // Network/timeout errors — exponential backoff
41
+ if (isNetworkError(errorMessage)) {
42
+ if (attempt >= this.maxRetries) {
43
+ throw new Error(`Network error after ${this.maxRetries} retries: ${errorMessage}. Check your connection.`);
44
+ }
45
+ const delay = Math.min(this.initialRetryDelay * 2 ** attempt, this.maxRetryDelay);
46
+ console.error(`[rate-limiter] Network error for ${context}. Retrying in ${delay}ms (attempt ${attempt + 1}/${this.maxRetries})`);
47
+ await sleep(delay);
48
+ return this.executeWithRetry(fn, context, attempt + 1);
49
+ }
50
+ // Temporary server errors (5xx) — exponential backoff
51
+ if (isTemporaryError(errorMessage)) {
52
+ if (attempt >= this.maxRetries) {
53
+ throw new Error(`Temporary error after ${this.maxRetries} retries: ${errorMessage}`);
54
+ }
55
+ const delay = Math.min(this.initialRetryDelay * 2 ** attempt, this.maxRetryDelay);
56
+ await sleep(delay);
57
+ return this.executeWithRetry(fn, context, attempt + 1);
58
+ }
59
+ // Non-retryable — throw immediately
60
+ throw error;
61
+ }
62
+ }
63
+ async waitForSlot() {
64
+ const now = Date.now();
65
+ const elapsed = now - this.lastRequestTime;
66
+ if (elapsed < this.minInterval) {
67
+ await sleep(this.minInterval - elapsed);
68
+ }
69
+ this.lastRequestTime = Date.now();
70
+ }
71
+ }
72
+ function isNetworkError(msg) {
73
+ return /TIMEOUT|ETIMEDOUT|ECONNREFUSED|ENETUNREACH|ENOTFOUND|EHOSTUNREACH|network|timed out/i.test(msg);
74
+ }
75
+ function isTemporaryError(msg) {
76
+ return /INTERNAL|^50[023]$|Internal Server Error|Service Unavailable|Bad Gateway/i.test(msg);
77
+ }
78
+ function sleep(ms) {
79
+ return new Promise((resolve) => setTimeout(resolve, ms));
80
+ }
@@ -1,3 +1,4 @@
1
+ import { Api } from "telegram/tl/index.js";
1
2
  export declare class TelegramService {
2
3
  private client;
3
4
  private apiId;
@@ -5,7 +6,9 @@ export declare class TelegramService {
5
6
  private sessionString;
6
7
  private connected;
7
8
  private sessionPath;
9
+ private rateLimiter;
8
10
  lastError: string;
11
+ get sessionDir(): string;
9
12
  constructor(apiId: number, apiHash: string, options?: {
10
13
  sessionPath?: string;
11
14
  });
@@ -36,7 +39,7 @@ export declare class TelegramService {
36
39
  username?: string;
37
40
  firstName?: string;
38
41
  }>;
39
- sendMessage(chatId: string, text: string, replyTo?: number, parseMode?: "md" | "html", topicId?: number): Promise<void>;
42
+ sendMessage(chatId: string, text: string, replyTo?: number, parseMode?: "md" | "html", topicId?: number): Promise<Api.Message | Api.UpdateShortSentMessage | undefined>;
40
43
  sendFile(chatId: string, filePath: string, caption?: string): Promise<void>;
41
44
  downloadMedia(chatId: string, messageId: number, downloadPath: string): Promise<string>;
42
45
  downloadMediaAsBuffer(chatId: string, messageId: number): Promise<{
@@ -9,12 +9,14 @@ import { TelegramClient } from "telegram";
9
9
  import { CustomFile } from "telegram/client/uploads.js";
10
10
  import { StringSession } from "telegram/sessions/index.js";
11
11
  import { Api } from "telegram/tl/index.js";
12
+ import { RateLimiter } from "./rate-limiter.js";
12
13
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
14
  const LEGACY_SESSION_FILE = join(__dirname, "..", ".telegram-session");
14
15
  const DEFAULT_SESSION_DIR = join(homedir(), ".mcp-telegram");
15
16
  const DEFAULT_SESSION_FILE = join(DEFAULT_SESSION_DIR, "session");
16
17
  const SESSION_STRING_RE = /^[A-Za-z0-9+/=]+$/;
17
18
  const MIN_SESSION_LENGTH = 100;
19
+ const NOT_CONNECTED_ERROR = "Not connected. Run telegram-status to check or telegram-login to authenticate.";
18
20
  function resolveSessionPath(sessionPath) {
19
21
  return sessionPath ?? process.env.TELEGRAM_SESSION_PATH ?? DEFAULT_SESSION_FILE;
20
22
  }
@@ -49,7 +51,11 @@ export class TelegramService {
49
51
  sessionString = "";
50
52
  connected = false;
51
53
  sessionPath;
54
+ rateLimiter = new RateLimiter();
52
55
  lastError = "";
56
+ get sessionDir() {
57
+ return dirname(this.sessionPath);
58
+ }
53
59
  constructor(apiId, apiHash, options) {
54
60
  this.apiId = apiId;
55
61
  this.apiHash = apiHash;
@@ -133,7 +139,7 @@ export class TelegramService {
133
139
  // Auth revoked — delete invalid session
134
140
  if (msg === "AUTH_KEY_UNREGISTERED" || msg === "SESSION_REVOKED" || msg === "USER_DEACTIVATED") {
135
141
  await this.clearSession();
136
- this.lastError = "Session revoked. Re-login required.";
142
+ this.lastError = "Session revoked. Run telegram-login to re-authenticate.";
137
143
  }
138
144
  // Network error — keep session, just report
139
145
  else if (msg.includes("TIMEOUT") ||
@@ -141,7 +147,7 @@ export class TelegramService {
141
147
  msg.includes("ENETUNREACH") ||
142
148
  msg.includes("ENOTFOUND") ||
143
149
  msg.includes("network")) {
144
- this.lastError = `Network error: ${msg}. Session preserved, will retry on next call.`;
150
+ this.lastError = `Network error: ${msg}. Run telegram-status to retry connection.`;
145
151
  }
146
152
  // Unknown error
147
153
  else {
@@ -287,7 +293,7 @@ export class TelegramService {
287
293
  }
288
294
  async getMe() {
289
295
  if (!this.client || !this.connected)
290
- throw new Error("Not connected");
296
+ throw new Error(NOT_CONNECTED_ERROR);
291
297
  const me = await this.client.getMe();
292
298
  const user = me;
293
299
  return {
@@ -298,38 +304,47 @@ export class TelegramService {
298
304
  }
299
305
  async sendMessage(chatId, text, replyTo, parseMode, topicId) {
300
306
  if (!this.client || !this.connected)
301
- throw new Error("Not connected");
302
- const resolved = await this.resolvePeer(chatId);
303
- if (topicId) {
304
- // Forum topics require raw API call with InputReplyToMessage
305
- const peer = await this.client.getInputEntity(resolved);
306
- await this.client.invoke(new Api.messages.SendMessage({
307
- peer,
308
- message: text,
309
- randomId: bigInt(Math.floor(Math.random() * 1e15)),
310
- replyTo: new Api.InputReplyToMessage({
311
- replyToMsgId: replyTo ?? topicId,
312
- topMsgId: topicId,
313
- }),
314
- }));
315
- }
316
- else {
317
- await this.client.sendMessage(resolved, {
307
+ throw new Error(NOT_CONNECTED_ERROR);
308
+ return this.rateLimiter.execute(async () => {
309
+ const resolved = await this.resolvePeer(chatId);
310
+ if (topicId) {
311
+ const peer = await this.client?.getInputEntity(resolved);
312
+ const result = await this.client?.invoke(new Api.messages.SendMessage({
313
+ peer,
314
+ message: text,
315
+ randomId: bigInt(Math.floor(Math.random() * 1e15)),
316
+ replyTo: new Api.InputReplyToMessage({
317
+ replyToMsgId: replyTo ?? topicId,
318
+ topMsgId: topicId,
319
+ }),
320
+ }));
321
+ if (result instanceof Api.UpdateShortSentMessage)
322
+ return result;
323
+ if (result instanceof Api.Updates || result instanceof Api.UpdatesCombined) {
324
+ const msgUpdate = result.updates.find((u) => u instanceof Api.UpdateNewMessage);
325
+ if (msgUpdate?.message instanceof Api.Message)
326
+ return msgUpdate.message;
327
+ }
328
+ return undefined;
329
+ }
330
+ return await this.client?.sendMessage(resolved, {
318
331
  message: text,
319
332
  ...(replyTo ? { replyTo } : {}),
320
333
  ...(parseMode ? { parseMode: parseMode === "html" ? "html" : "md" } : {}),
321
334
  });
322
- }
335
+ }, `sendMessage to ${chatId}`);
323
336
  }
324
337
  async sendFile(chatId, filePath, caption) {
325
338
  if (!this.client || !this.connected)
326
- throw new Error("Not connected");
327
- const resolved = await this.resolvePeer(chatId);
328
- await this.client.sendFile(resolved, { file: filePath, caption });
339
+ throw new Error(NOT_CONNECTED_ERROR);
340
+ await this.rateLimiter.execute(async () => {
341
+ const resolved = await this.resolvePeer(chatId);
342
+ await this.client?.sendFile(resolved, { file: filePath, caption });
343
+ }, `sendFile to ${chatId}`);
329
344
  }
330
345
  async downloadMedia(chatId, messageId, downloadPath) {
331
346
  if (!this.client || !this.connected)
332
- throw new Error("Not connected");
347
+ throw new Error(NOT_CONNECTED_ERROR);
333
348
  const resolved = await this.resolvePeer(chatId);
334
349
  const messages = await this.client.getMessages(resolved, { ids: [messageId] });
335
350
  const message = messages[0];
@@ -345,7 +360,7 @@ export class TelegramService {
345
360
  }
346
361
  async downloadMediaAsBuffer(chatId, messageId) {
347
362
  if (!this.client || !this.connected)
348
- throw new Error("Not connected");
363
+ throw new Error(NOT_CONNECTED_ERROR);
349
364
  const resolved = await this.resolvePeer(chatId);
350
365
  const messages = await this.client.getMessages(resolved, { ids: [messageId] });
351
366
  const message = messages[0];
@@ -381,19 +396,19 @@ export class TelegramService {
381
396
  }
382
397
  async pinMessage(chatId, messageId, silent = false) {
383
398
  if (!this.client || !this.connected)
384
- throw new Error("Not connected");
399
+ throw new Error(NOT_CONNECTED_ERROR);
385
400
  const resolved = await this.resolvePeer(chatId);
386
401
  await this.client.pinMessage(resolved, messageId, { notify: !silent });
387
402
  }
388
403
  async unpinMessage(chatId, messageId) {
389
404
  if (!this.client || !this.connected)
390
- throw new Error("Not connected");
405
+ throw new Error(NOT_CONNECTED_ERROR);
391
406
  const resolved = await this.resolvePeer(chatId);
392
407
  await this.client.unpinMessage(resolved, messageId);
393
408
  }
394
409
  async getDialogs(limit = 20, offsetDate, filterType) {
395
410
  if (!this.client || !this.connected)
396
- throw new Error("Not connected");
411
+ throw new Error(NOT_CONNECTED_ERROR);
397
412
  const fetchLimit = filterType ? limit * 3 : limit;
398
413
  const dialogs = await this.client.getDialogs({ limit: fetchLimit, ...(offsetDate ? { offsetDate } : {}) });
399
414
  const mapped = dialogs.map((d) => {
@@ -416,7 +431,7 @@ export class TelegramService {
416
431
  }
417
432
  async getUnreadDialogs(limit = 20) {
418
433
  if (!this.client || !this.connected)
419
- throw new Error("Not connected");
434
+ throw new Error(NOT_CONNECTED_ERROR);
420
435
  const dialogs = await this.client.getDialogs({ limit: limit * 3 });
421
436
  const unread = dialogs.filter((d) => d.unreadCount > 0).slice(0, limit);
422
437
  const results = await Promise.all(unread.map(async (d) => {
@@ -457,7 +472,7 @@ export class TelegramService {
457
472
  }
458
473
  async getContactRequests(limit = 20) {
459
474
  if (!this.client || !this.connected)
460
- throw new Error("Not connected");
475
+ throw new Error(NOT_CONNECTED_ERROR);
461
476
  const dialogs = await this.client.getDialogs({ limit: limit * 5 });
462
477
  return dialogs
463
478
  .filter((d) => {
@@ -482,7 +497,7 @@ export class TelegramService {
482
497
  }
483
498
  async addContact(userId, firstName, lastName, phone) {
484
499
  if (!this.client || !this.connected)
485
- throw new Error("Not connected");
500
+ throw new Error(NOT_CONNECTED_ERROR);
486
501
  const entity = await this.client.getInputEntity(userId);
487
502
  await this.client.invoke(new Api.contacts.AddContact({
488
503
  id: entity,
@@ -493,39 +508,43 @@ export class TelegramService {
493
508
  }
494
509
  async blockUser(userId) {
495
510
  if (!this.client || !this.connected)
496
- throw new Error("Not connected");
511
+ throw new Error(NOT_CONNECTED_ERROR);
497
512
  const entity = await this.client.getInputEntity(userId);
498
513
  await this.client.invoke(new Api.contacts.Block({ id: entity }));
499
514
  }
500
515
  async reportSpam(chatId) {
501
516
  if (!this.client || !this.connected)
502
- throw new Error("Not connected");
517
+ throw new Error(NOT_CONNECTED_ERROR);
503
518
  const peer = await this.client.getInputEntity(chatId);
504
519
  await this.client.invoke(new Api.messages.ReportSpam({ peer }));
505
520
  }
506
521
  async markAsRead(chatId) {
507
522
  if (!this.client || !this.connected)
508
- throw new Error("Not connected");
523
+ throw new Error(NOT_CONNECTED_ERROR);
509
524
  await this.client.markAsRead(chatId);
510
525
  }
511
526
  async forwardMessage(fromChatId, toChatId, messageIds) {
512
527
  if (!this.client || !this.connected)
513
- throw new Error("Not connected");
528
+ throw new Error(NOT_CONNECTED_ERROR);
514
529
  const resolvedFrom = await this.resolvePeer(fromChatId);
515
530
  const resolvedTo = await this.resolvePeer(toChatId);
516
531
  await this.client.forwardMessages(resolvedTo, { messages: messageIds, fromPeer: resolvedFrom });
517
532
  }
518
533
  async editMessage(chatId, messageId, newText) {
519
534
  if (!this.client || !this.connected)
520
- throw new Error("Not connected");
521
- const resolved = await this.resolvePeer(chatId);
522
- await this.client.editMessage(resolved, { message: messageId, text: newText });
535
+ throw new Error(NOT_CONNECTED_ERROR);
536
+ await this.rateLimiter.execute(async () => {
537
+ const resolved = await this.resolvePeer(chatId);
538
+ await this.client?.editMessage(resolved, { message: messageId, text: newText });
539
+ }, `editMessage ${messageId} in ${chatId}`);
523
540
  }
524
541
  async deleteMessages(chatId, messageIds) {
525
542
  if (!this.client || !this.connected)
526
- throw new Error("Not connected");
527
- const resolved = await this.resolvePeer(chatId);
528
- await this.client.deleteMessages(resolved, messageIds, { revoke: true });
543
+ throw new Error(NOT_CONNECTED_ERROR);
544
+ await this.rateLimiter.execute(async () => {
545
+ const resolved = await this.resolvePeer(chatId);
546
+ await this.client?.deleteMessages(resolved, messageIds, { revoke: true });
547
+ }, `deleteMessages in ${chatId}`);
529
548
  }
530
549
  /**
531
550
  * Resolve a chat by ID, username, or display name.
@@ -534,7 +553,7 @@ export class TelegramService {
534
553
  // biome-ignore lint: GramJS has no proper entity union type
535
554
  async resolveChat(chatId) {
536
555
  if (!this.client)
537
- throw new Error("Not connected");
556
+ throw new Error(NOT_CONNECTED_ERROR);
538
557
  // First try direct resolve (numeric ID, username, phone)
539
558
  try {
540
559
  return await this.client.getEntity(chatId);
@@ -573,7 +592,7 @@ export class TelegramService {
573
592
  }
574
593
  async getChatInfo(chatId) {
575
594
  if (!this.client || !this.connected)
576
- throw new Error("Not connected");
595
+ throw new Error(NOT_CONNECTED_ERROR);
577
596
  const entity = await this.resolveChat(chatId);
578
597
  if (entity instanceof Api.User) {
579
598
  const parts = [entity.firstName, entity.lastName].filter(Boolean);
@@ -681,7 +700,7 @@ export class TelegramService {
681
700
  }
682
701
  async getMessages(chatId, limit = 10, offsetId, minDate, maxDate) {
683
702
  if (!this.client || !this.connected)
684
- throw new Error("Not connected");
703
+ throw new Error(NOT_CONNECTED_ERROR);
685
704
  const resolved = await this.resolvePeer(chatId);
686
705
  const opts = {
687
706
  limit,
@@ -705,7 +724,7 @@ export class TelegramService {
705
724
  }
706
725
  async searchChats(query, limit = 10) {
707
726
  if (!this.client || !this.connected)
708
- throw new Error("Not connected");
727
+ throw new Error(NOT_CONNECTED_ERROR);
709
728
  const result = await this.client.invoke(new Api.contacts.Search({ q: query, limit }));
710
729
  const chats = [];
711
730
  for (const user of result.users) {
@@ -766,7 +785,7 @@ export class TelegramService {
766
785
  }
767
786
  async searchGlobal(query, limit = 20, minDate, maxDate) {
768
787
  if (!this.client || !this.connected)
769
- throw new Error("Not connected");
788
+ throw new Error(NOT_CONNECTED_ERROR);
770
789
  const result = await this.client.invoke(new Api.messages.SearchGlobal({
771
790
  q: query,
772
791
  filter: new Api.InputMessagesFilterEmpty(),
@@ -835,7 +854,7 @@ export class TelegramService {
835
854
  }
836
855
  async searchMessages(chatId, query, limit = 20, minDate, maxDate) {
837
856
  if (!this.client || !this.connected)
838
- throw new Error("Not connected");
857
+ throw new Error(NOT_CONNECTED_ERROR);
839
858
  const resolved = await this.resolvePeer(chatId);
840
859
  const messages = await this.client.getMessages(resolved, {
841
860
  search: query,
@@ -858,7 +877,7 @@ export class TelegramService {
858
877
  }
859
878
  async getContacts(limit = 50) {
860
879
  if (!this.client || !this.connected)
861
- throw new Error("Not connected");
880
+ throw new Error(NOT_CONNECTED_ERROR);
862
881
  const result = await this.client.invoke(new Api.contacts.GetContacts({ hash: bigInt(0) }));
863
882
  if (!(result instanceof Api.contacts.Contacts))
864
883
  return [];
@@ -878,7 +897,7 @@ export class TelegramService {
878
897
  }
879
898
  async getChatMembers(chatId, limit = 50) {
880
899
  if (!this.client || !this.connected)
881
- throw new Error("Not connected");
900
+ throw new Error(NOT_CONNECTED_ERROR);
882
901
  const entity = await this.resolveChat(chatId);
883
902
  if (entity instanceof Api.Channel) {
884
903
  const result = await this.client.invoke(new Api.channels.GetParticipants({
@@ -923,7 +942,7 @@ export class TelegramService {
923
942
  }
924
943
  async getMyRole(chatId) {
925
944
  if (!this.client || !this.connected)
926
- throw new Error("Not connected");
945
+ throw new Error(NOT_CONNECTED_ERROR);
927
946
  const entity = await this.resolveChat(chatId);
928
947
  const me = await this.getMe();
929
948
  if (entity instanceof Api.Channel) {
@@ -975,7 +994,7 @@ export class TelegramService {
975
994
  }
976
995
  async getProfile(userId) {
977
996
  if (!this.client || !this.connected)
978
- throw new Error("Not connected");
997
+ throw new Error(NOT_CONNECTED_ERROR);
979
998
  const entity = await this.client.getEntity(userId);
980
999
  if (!(entity instanceof Api.User))
981
1000
  throw new Error("Entity is not a user");
@@ -1035,7 +1054,7 @@ export class TelegramService {
1035
1054
  }
1036
1055
  async downloadProfilePhoto(entityId, options) {
1037
1056
  if (!this.client || !this.connected)
1038
- throw new Error("Not connected");
1057
+ throw new Error(NOT_CONNECTED_ERROR);
1039
1058
  const entity = await this.client.getEntity(entityId);
1040
1059
  const buffer = (await this.client.downloadProfilePhoto(entity, {
1041
1060
  isBig: options?.isBig !== false,
@@ -1086,7 +1105,7 @@ export class TelegramService {
1086
1105
  }
1087
1106
  async sendReaction(chatId, messageId, emoji, addToExisting = false) {
1088
1107
  if (!this.client || !this.connected)
1089
- throw new Error("Not connected");
1108
+ throw new Error(NOT_CONNECTED_ERROR);
1090
1109
  const resolved = await this.resolvePeer(chatId);
1091
1110
  const peer = await this.client.getInputEntity(resolved);
1092
1111
  const reactionList = [];
@@ -1126,7 +1145,7 @@ export class TelegramService {
1126
1145
  }
1127
1146
  async getMessageReactions(chatId, messageId) {
1128
1147
  if (!this.client || !this.connected)
1129
- throw new Error("Not connected");
1148
+ throw new Error(NOT_CONNECTED_ERROR);
1130
1149
  const resolved = await this.resolvePeer(chatId);
1131
1150
  const peer = await this.client.getInputEntity(resolved);
1132
1151
  // First get the message to know which reactions exist
@@ -1181,7 +1200,7 @@ export class TelegramService {
1181
1200
  }
1182
1201
  async sendScheduledMessage(chatId, text, scheduleDate, replyTo, parseMode) {
1183
1202
  if (!this.client || !this.connected)
1184
- throw new Error("Not connected");
1203
+ throw new Error(NOT_CONNECTED_ERROR);
1185
1204
  const resolved = await this.resolvePeer(chatId);
1186
1205
  await this.client.sendMessage(resolved, {
1187
1206
  message: text,
@@ -1192,7 +1211,7 @@ export class TelegramService {
1192
1211
  }
1193
1212
  async createPoll(chatId, question, answers, options) {
1194
1213
  if (!this.client || !this.connected)
1195
- throw new Error("Not connected");
1214
+ throw new Error(NOT_CONNECTED_ERROR);
1196
1215
  const peer = await this.client.getInputEntity(chatId);
1197
1216
  const pollAnswers = answers.map((text, i) => new Api.PollAnswer({
1198
1217
  text: new Api.TextWithEntities({ text, entities: [] }),
@@ -1228,7 +1247,7 @@ export class TelegramService {
1228
1247
  }
1229
1248
  async getForumTopics(chatId, limit = 100) {
1230
1249
  if (!this.client || !this.connected)
1231
- throw new Error("Not connected");
1250
+ throw new Error(NOT_CONNECTED_ERROR);
1232
1251
  const entity = await this.resolveChat(chatId);
1233
1252
  if (!(entity instanceof Api.Channel))
1234
1253
  throw new Error("Forum topics are only available in supergroups");
@@ -1257,7 +1276,7 @@ export class TelegramService {
1257
1276
  }
1258
1277
  async getTopicMessages(chatId, topicId, limit = 20, offsetId) {
1259
1278
  if (!this.client || !this.connected)
1260
- throw new Error("Not connected");
1279
+ throw new Error(NOT_CONNECTED_ERROR);
1261
1280
  const peer = await this.client.getInputEntity(chatId);
1262
1281
  const result = await this.client.invoke(new Api.messages.GetReplies({
1263
1282
  peer,
@@ -1286,7 +1305,7 @@ export class TelegramService {
1286
1305
  /** Check if a chat entity is a forum (has topics enabled) */
1287
1306
  async isForum(chatId) {
1288
1307
  if (!this.client || !this.connected)
1289
- throw new Error("Not connected");
1308
+ throw new Error(NOT_CONNECTED_ERROR);
1290
1309
  try {
1291
1310
  const entity = await this.resolveChat(chatId);
1292
1311
  if (entity instanceof Api.Channel) {
@@ -1298,7 +1317,7 @@ export class TelegramService {
1298
1317
  }
1299
1318
  async joinChat(target) {
1300
1319
  if (!this.client)
1301
- throw new Error("Not connected");
1320
+ throw new Error(NOT_CONNECTED_ERROR);
1302
1321
  // Extract invite hash from various link formats
1303
1322
  const inviteMatch = target.match(/(?:t\.me\/\+|t\.me\/joinchat\/|tg:\/\/join\?invite=)([a-zA-Z0-9_-]+)/);
1304
1323
  if (inviteMatch) {
@@ -1329,7 +1348,7 @@ export class TelegramService {
1329
1348
  }
1330
1349
  async createGroup(options) {
1331
1350
  if (!this.client)
1332
- throw new Error("Not connected");
1351
+ throw new Error(NOT_CONNECTED_ERROR);
1333
1352
  const { title, users, supergroup = false, forum = false, description } = options;
1334
1353
  if (supergroup || forum) {
1335
1354
  // Create supergroup/channel via channels.CreateChannel
@@ -1403,7 +1422,7 @@ export class TelegramService {
1403
1422
  }
1404
1423
  async inviteToGroup(chatId, users) {
1405
1424
  if (!this.client)
1406
- throw new Error("Not connected");
1425
+ throw new Error(NOT_CONNECTED_ERROR);
1407
1426
  const entity = await this.resolveChat(chatId);
1408
1427
  const invited = [];
1409
1428
  const failed = [];
@@ -1431,7 +1450,7 @@ export class TelegramService {
1431
1450
  }
1432
1451
  async kickUser(chatId, userId) {
1433
1452
  if (!this.client)
1434
- throw new Error("Not connected");
1453
+ throw new Error(NOT_CONNECTED_ERROR);
1435
1454
  const entity = await this.resolveChat(chatId);
1436
1455
  const user = await this.client.getEntity(userId);
1437
1456
  if (!(user instanceof Api.User))
@@ -1456,7 +1475,7 @@ export class TelegramService {
1456
1475
  }
1457
1476
  async banUser(chatId, userId) {
1458
1477
  if (!this.client)
1459
- throw new Error("Not connected");
1478
+ throw new Error(NOT_CONNECTED_ERROR);
1460
1479
  const entity = await this.resolveChat(chatId);
1461
1480
  const user = await this.client.getEntity(userId);
1462
1481
  if (!(user instanceof Api.User))
@@ -1472,7 +1491,7 @@ export class TelegramService {
1472
1491
  }
1473
1492
  async unbanUser(chatId, userId) {
1474
1493
  if (!this.client)
1475
- throw new Error("Not connected");
1494
+ throw new Error(NOT_CONNECTED_ERROR);
1476
1495
  const entity = await this.resolveChat(chatId);
1477
1496
  const user = await this.client.getEntity(userId);
1478
1497
  if (!(user instanceof Api.User))
@@ -1488,7 +1507,7 @@ export class TelegramService {
1488
1507
  }
1489
1508
  async editGroup(chatId, options) {
1490
1509
  if (!this.client)
1491
- throw new Error("Not connected");
1510
+ throw new Error(NOT_CONNECTED_ERROR);
1492
1511
  const entity = await this.resolveChat(chatId);
1493
1512
  if (options.title) {
1494
1513
  if (entity instanceof Api.Channel) {
@@ -1518,7 +1537,7 @@ export class TelegramService {
1518
1537
  }
1519
1538
  async leaveGroup(chatId) {
1520
1539
  if (!this.client)
1521
- throw new Error("Not connected");
1540
+ throw new Error(NOT_CONNECTED_ERROR);
1522
1541
  const entity = await this.resolveChat(chatId);
1523
1542
  if (entity instanceof Api.Channel) {
1524
1543
  await this.client.invoke(new Api.channels.LeaveChannel({ channel: entity }));
@@ -1535,7 +1554,7 @@ export class TelegramService {
1535
1554
  }
1536
1555
  async setAdmin(chatId, userId, options) {
1537
1556
  if (!this.client)
1538
- throw new Error("Not connected");
1557
+ throw new Error(NOT_CONNECTED_ERROR);
1539
1558
  const entity = await this.resolveChat(chatId);
1540
1559
  if (!(entity instanceof Api.Channel))
1541
1560
  throw new Error("Set admin is only supported for supergroups and channels");
@@ -1561,7 +1580,7 @@ export class TelegramService {
1561
1580
  }
1562
1581
  async removeAdmin(chatId, userId) {
1563
1582
  if (!this.client)
1564
- throw new Error("Not connected");
1583
+ throw new Error(NOT_CONNECTED_ERROR);
1565
1584
  const entity = await this.resolveChat(chatId);
1566
1585
  if (!(entity instanceof Api.Channel))
1567
1586
  throw new Error("Remove admin is only supported for supergroups and channels");
@@ -1,3 +1,5 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
1
3
  import { fail, ok, READ_ONLY, WRITE } from "./shared.js";
2
4
  export function registerAuthTools(server, telegram) {
3
5
  server.registerTool("telegram-status", { description: "Check Telegram connection status", annotations: READ_ONLY }, async () => {
@@ -18,11 +20,8 @@ export function registerAuthTools(server, telegram) {
18
20
  annotations: WRITE,
19
21
  }, async () => {
20
22
  let qrDataUrl = "";
21
- let qrRawUrl = "";
22
23
  const loginPromise = telegram.startQrLogin((dataUrl) => {
23
24
  qrDataUrl = dataUrl;
24
- }, (url) => {
25
- qrRawUrl = url;
26
25
  });
27
26
  // Wait for first QR to be generated
28
27
  const startTime = Date.now();
@@ -41,20 +40,17 @@ export function registerAuthTools(server, telegram) {
41
40
  console.error(`[mcp-telegram] Login failed: ${result.message}`);
42
41
  }
43
42
  });
44
- // Return as MCP image content + text with fallback options
43
+ // Save QR to file as fallback (no data sent to third-party services)
45
44
  const base64 = qrDataUrl.replace(/^data:image\/png;base64,/, "");
46
- const qrApiUrl = qrRawUrl
47
- ? `https://api.qrserver.com/v1/create-qr-code/?size=256x256&data=${encodeURIComponent(qrRawUrl)}`
48
- : "";
45
+ const qrFilePath = join(telegram.sessionDir, "qr-login.png");
46
+ await writeFile(qrFilePath, Buffer.from(base64, "base64")).catch(() => { });
49
47
  const instructions = [
50
48
  "Scan this QR code in Telegram: **Settings → Devices → Link Desktop Device**.",
51
49
  "",
52
- qrApiUrl ? `If the QR image is not visible, open this link in your browser:\n${qrApiUrl}` : "",
50
+ `If the QR image is not visible, it's also saved to: ${qrFilePath}`,
53
51
  "",
54
52
  "After scanning, run **telegram-status** to verify the connection.",
55
- ]
56
- .filter(Boolean)
57
- .join("\n");
53
+ ].join("\n");
58
54
  return {
59
55
  content: [
60
56
  {
@@ -16,9 +16,11 @@ export function registerMessageTools(server, telegram) {
16
16
  if (err)
17
17
  return fail(new Error(err));
18
18
  try {
19
- await telegram.sendMessage(chatId, text, replyTo, parseMode, topicId);
19
+ const result = await telegram.sendMessage(chatId, text, replyTo, parseMode, topicId);
20
20
  const dest = topicId ? `topic ${topicId} in ${chatId}` : chatId;
21
- return ok(`Message sent to ${dest}`);
21
+ const messageId = result?.id;
22
+ const idInfo = messageId ? ` [#${messageId}]` : "";
23
+ return ok(`Message sent to ${dest}${idInfo}`);
22
24
  }
23
25
  catch (e) {
24
26
  return fail(e);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@overpod/mcp-telegram",
3
- "version": "1.18.0",
3
+ "version": "1.20.0",
4
4
  "description": "MCP server for Telegram userbot — messages, media, reactions, polls & more. Built on GramJS/MTProto.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,7 +14,8 @@
14
14
  "files": [
15
15
  "dist",
16
16
  "README.md",
17
- "LICENSE"
17
+ "LICENSE",
18
+ "CHANGELOG.md"
18
19
  ],
19
20
  "scripts": {
20
21
  "dev": "tsx watch src/index.ts",
@@ -25,7 +26,9 @@
25
26
  "prepublishOnly": "npm run build",
26
27
  "lint": "biome check src/",
27
28
  "lint:fix": "biome check --fix src/",
28
- "format": "biome format --write src/"
29
+ "format": "biome format --write src/",
30
+ "test": "tsx --test src/**/*.test.ts",
31
+ "test:watch": "tsx --test --watch src/**/*.test.ts"
29
32
  },
30
33
  "keywords": [
31
34
  "mcp",