@overpod/mcp-telegram 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,508 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile, unlink, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import bigInt from "big-integer";
6
+ import QRCode from "qrcode";
7
+ import { TelegramClient } from "telegram";
8
+ import { StringSession } from "telegram/sessions/index.js";
9
+ import { Api } from "telegram/tl/index.js";
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const SESSION_FILE = join(__dirname, "..", ".telegram-session");
12
+ export class TelegramService {
13
+ client = null;
14
+ apiId;
15
+ apiHash;
16
+ sessionString = "";
17
+ connected = false;
18
+ lastError = "";
19
+ constructor(apiId, apiHash) {
20
+ this.apiId = apiId;
21
+ this.apiHash = apiHash;
22
+ }
23
+ async loadSession() {
24
+ if (existsSync(SESSION_FILE)) {
25
+ this.sessionString = (await readFile(SESSION_FILE, "utf-8")).trim();
26
+ return true;
27
+ }
28
+ return false;
29
+ }
30
+ async saveSession(session) {
31
+ this.sessionString = session;
32
+ await writeFile(SESSION_FILE, session, "utf-8");
33
+ }
34
+ async connect() {
35
+ if (this.connected && this.client)
36
+ return true;
37
+ if (!this.sessionString) {
38
+ const loaded = await this.loadSession();
39
+ if (!loaded)
40
+ return false;
41
+ }
42
+ const session = new StringSession(this.sessionString);
43
+ this.client = new TelegramClient(session, this.apiId, this.apiHash, {
44
+ connectionRetries: 5,
45
+ });
46
+ try {
47
+ await this.client.connect();
48
+ // Verify session is still valid
49
+ await this.client.getMe();
50
+ this.connected = true;
51
+ return true;
52
+ }
53
+ catch (err) {
54
+ const error = err;
55
+ const msg = error.errorMessage || error.message || "";
56
+ // Auth revoked — delete invalid session
57
+ if (msg === "AUTH_KEY_UNREGISTERED" || msg === "SESSION_REVOKED" || msg === "USER_DEACTIVATED") {
58
+ await this.clearSession();
59
+ this.lastError = "Session revoked. Re-login required.";
60
+ }
61
+ // Network error — keep session, just report
62
+ else if (msg.includes("TIMEOUT") ||
63
+ msg.includes("ECONNREFUSED") ||
64
+ msg.includes("ENETUNREACH") ||
65
+ msg.includes("ENOTFOUND") ||
66
+ msg.includes("network")) {
67
+ this.lastError = `Network error: ${msg}. Session preserved, will retry on next call.`;
68
+ }
69
+ // Unknown error
70
+ else {
71
+ this.lastError = `Connection error: ${msg}`;
72
+ }
73
+ try {
74
+ await this.client.disconnect();
75
+ }
76
+ catch { }
77
+ this.client = null;
78
+ return false;
79
+ }
80
+ }
81
+ async clearSession() {
82
+ this.connected = false;
83
+ this.sessionString = "";
84
+ this.client = null;
85
+ if (existsSync(SESSION_FILE)) {
86
+ await unlink(SESSION_FILE);
87
+ }
88
+ }
89
+ /** Ensure connection is active, auto-reconnect if session exists */
90
+ async ensureConnected() {
91
+ if (this.connected && this.client) {
92
+ return true;
93
+ }
94
+ // Try to reconnect with saved session
95
+ return this.connect();
96
+ }
97
+ async disconnect() {
98
+ if (this.client && this.connected) {
99
+ await this.client.disconnect();
100
+ this.connected = false;
101
+ this.client = null;
102
+ }
103
+ }
104
+ isConnected() {
105
+ return this.connected;
106
+ }
107
+ async startQrLogin(onQrDataUrl, onQrUrl) {
108
+ const session = new StringSession("");
109
+ const client = new TelegramClient(session, this.apiId, this.apiHash, {
110
+ connectionRetries: 5,
111
+ });
112
+ try {
113
+ await client.connect();
114
+ let loginAccepted = false;
115
+ let resolved = false;
116
+ let lastQrUrl = "";
117
+ client.addEventHandler((update) => {
118
+ if (update instanceof Api.UpdateLoginToken) {
119
+ loginAccepted = true;
120
+ }
121
+ });
122
+ const maxAttempts = 30; // 5 minutes
123
+ for (let i = 0; i < maxAttempts && !resolved; i++) {
124
+ try {
125
+ const result = await client.invoke(new Api.auth.ExportLoginToken({
126
+ apiId: this.apiId,
127
+ apiHash: this.apiHash,
128
+ exceptIds: [],
129
+ }));
130
+ if (result instanceof Api.auth.LoginToken) {
131
+ const base64url = Buffer.from(result.token).toString("base64url");
132
+ const url = `tg://login?token=${base64url}`;
133
+ if (url !== lastQrUrl) {
134
+ lastQrUrl = url;
135
+ const dataUrl = await QRCode.toDataURL(url, {
136
+ width: 256,
137
+ margin: 2,
138
+ });
139
+ onQrDataUrl(dataUrl);
140
+ onQrUrl?.(url);
141
+ }
142
+ }
143
+ else if (result instanceof Api.auth.LoginTokenMigrateTo) {
144
+ await client._switchDC(result.dcId);
145
+ const imported = await client.invoke(new Api.auth.ImportLoginToken({ token: result.token }));
146
+ if (imported instanceof Api.auth.LoginTokenSuccess) {
147
+ resolved = true;
148
+ break;
149
+ }
150
+ }
151
+ else if (result instanceof Api.auth.LoginTokenSuccess) {
152
+ resolved = true;
153
+ break;
154
+ }
155
+ }
156
+ catch (err) {
157
+ const error = err;
158
+ if (error.errorMessage === "SESSION_PASSWORD_NEEDED") {
159
+ await client.disconnect();
160
+ return { success: false, message: "2FA enabled — QR login not supported with 2FA" };
161
+ }
162
+ }
163
+ if (!resolved) {
164
+ await new Promise((r) => setTimeout(r, loginAccepted ? 1500 : 10000));
165
+ }
166
+ }
167
+ if (resolved) {
168
+ const newSession = client.session.save();
169
+ await client.disconnect();
170
+ await this.saveSession(newSession);
171
+ // Reconnect with new session
172
+ this.sessionString = newSession;
173
+ await this.connect();
174
+ return { success: true, message: "Telegram login successful" };
175
+ }
176
+ await client.disconnect();
177
+ return { success: false, message: "QR login timeout" };
178
+ }
179
+ catch (err) {
180
+ try {
181
+ await client.disconnect();
182
+ }
183
+ catch { }
184
+ return { success: false, message: `Login failed: ${err.message}` };
185
+ }
186
+ }
187
+ async getMe() {
188
+ if (!this.client || !this.connected)
189
+ throw new Error("Not connected");
190
+ const me = await this.client.getMe();
191
+ const user = me;
192
+ return {
193
+ id: user.id.toString(),
194
+ username: user.username ?? undefined,
195
+ firstName: user.firstName ?? undefined,
196
+ };
197
+ }
198
+ async sendMessage(chatId, text, replyTo, parseMode) {
199
+ if (!this.client || !this.connected)
200
+ throw new Error("Not connected");
201
+ await this.client.sendMessage(chatId, {
202
+ message: text,
203
+ ...(replyTo ? { replyTo } : {}),
204
+ ...(parseMode ? { parseMode: parseMode === "html" ? "html" : "md" } : {}),
205
+ });
206
+ }
207
+ async sendFile(chatId, filePath, caption) {
208
+ if (!this.client || !this.connected)
209
+ throw new Error("Not connected");
210
+ await this.client.sendFile(chatId, { file: filePath, caption });
211
+ }
212
+ async downloadMedia(chatId, messageId, downloadPath) {
213
+ if (!this.client || !this.connected)
214
+ throw new Error("Not connected");
215
+ const messages = await this.client.getMessages(chatId, { ids: [messageId] });
216
+ const message = messages[0];
217
+ if (!message)
218
+ throw new Error(`Message ${messageId} not found`);
219
+ if (!message.media)
220
+ throw new Error(`Message ${messageId} has no media`);
221
+ const buffer = await this.client.downloadMedia(message);
222
+ if (!buffer)
223
+ throw new Error("Failed to download media");
224
+ await writeFile(downloadPath, buffer);
225
+ return downloadPath;
226
+ }
227
+ async pinMessage(chatId, messageId, silent = false) {
228
+ if (!this.client || !this.connected)
229
+ throw new Error("Not connected");
230
+ await this.client.pinMessage(chatId, messageId, { notify: !silent });
231
+ }
232
+ async unpinMessage(chatId, messageId) {
233
+ if (!this.client || !this.connected)
234
+ throw new Error("Not connected");
235
+ await this.client.unpinMessage(chatId, messageId);
236
+ }
237
+ async getDialogs(limit = 20, offsetDate, filterType) {
238
+ if (!this.client || !this.connected)
239
+ throw new Error("Not connected");
240
+ const fetchLimit = filterType ? limit * 3 : limit;
241
+ const dialogs = await this.client.getDialogs({ limit: fetchLimit, ...(offsetDate ? { offsetDate } : {}) });
242
+ const mapped = dialogs.map((d) => ({
243
+ id: d.id?.toString() ?? "",
244
+ name: d.title ?? d.name ?? "Unknown",
245
+ type: d.isGroup ? "group" : d.isChannel ? "channel" : "private",
246
+ unreadCount: d.unreadCount,
247
+ }));
248
+ return filterType ? mapped.filter((d) => d.type === filterType).slice(0, limit) : mapped;
249
+ }
250
+ async getUnreadDialogs(limit = 20) {
251
+ if (!this.client || !this.connected)
252
+ throw new Error("Not connected");
253
+ const dialogs = await this.client.getDialogs({ limit: limit * 3 });
254
+ return dialogs
255
+ .filter((d) => d.unreadCount > 0)
256
+ .slice(0, limit)
257
+ .map((d) => ({
258
+ id: d.id?.toString() ?? "",
259
+ name: d.title ?? d.name ?? "Unknown",
260
+ type: d.isGroup ? "group" : d.isChannel ? "channel" : "private",
261
+ unreadCount: d.unreadCount,
262
+ }));
263
+ }
264
+ async markAsRead(chatId) {
265
+ if (!this.client || !this.connected)
266
+ throw new Error("Not connected");
267
+ await this.client.markAsRead(chatId);
268
+ }
269
+ async forwardMessage(fromChatId, toChatId, messageIds) {
270
+ if (!this.client || !this.connected)
271
+ throw new Error("Not connected");
272
+ await this.client.forwardMessages(toChatId, { messages: messageIds, fromPeer: fromChatId });
273
+ }
274
+ async editMessage(chatId, messageId, newText) {
275
+ if (!this.client || !this.connected)
276
+ throw new Error("Not connected");
277
+ await this.client.editMessage(chatId, { message: messageId, text: newText });
278
+ }
279
+ async deleteMessages(chatId, messageIds) {
280
+ if (!this.client || !this.connected)
281
+ throw new Error("Not connected");
282
+ await this.client.deleteMessages(chatId, messageIds, { revoke: true });
283
+ }
284
+ async getChatInfo(chatId) {
285
+ if (!this.client || !this.connected)
286
+ throw new Error("Not connected");
287
+ const entity = await this.client.getEntity(chatId);
288
+ if (entity instanceof Api.User) {
289
+ const parts = [entity.firstName, entity.lastName].filter(Boolean);
290
+ return {
291
+ id: entity.id.toString(),
292
+ name: parts.join(" ") || "Unknown",
293
+ type: "private",
294
+ username: entity.username ?? undefined,
295
+ };
296
+ }
297
+ if (entity instanceof Api.Channel) {
298
+ return {
299
+ id: entity.id.toString(),
300
+ name: entity.title,
301
+ type: entity.megagroup ? "group" : "channel",
302
+ username: entity.username ?? undefined,
303
+ membersCount: entity.participantsCount ?? undefined,
304
+ };
305
+ }
306
+ if (entity instanceof Api.Chat) {
307
+ return {
308
+ id: entity.id.toString(),
309
+ name: entity.title,
310
+ type: "group",
311
+ membersCount: entity.participantsCount ?? undefined,
312
+ };
313
+ }
314
+ return { id: chatId, name: "Unknown", type: "unknown" };
315
+ }
316
+ /** Extract media info from a message */
317
+ extractMediaInfo(media) {
318
+ if (!media)
319
+ return undefined;
320
+ if (media instanceof Api.MessageMediaPhoto) {
321
+ return { type: "photo" };
322
+ }
323
+ if (media instanceof Api.MessageMediaDocument && media.document instanceof Api.Document) {
324
+ const doc = media.document;
325
+ let type = "document";
326
+ let fileName;
327
+ for (const attr of doc.attributes) {
328
+ if (attr instanceof Api.DocumentAttributeVideo)
329
+ type = "video";
330
+ else if (attr instanceof Api.DocumentAttributeAudio)
331
+ type = "audio";
332
+ else if (attr instanceof Api.DocumentAttributeSticker)
333
+ type = "sticker";
334
+ else if (attr instanceof Api.DocumentAttributeFilename)
335
+ fileName = attr.fileName;
336
+ }
337
+ return { type, fileName, size: doc.size?.toJSNumber?.() ?? Number(doc.size) };
338
+ }
339
+ return undefined;
340
+ }
341
+ /** Resolve sender ID to a display name */
342
+ async resolveSenderName(senderId) {
343
+ if (!senderId || !this.client)
344
+ return "unknown";
345
+ try {
346
+ const entity = await this.client.getEntity(senderId);
347
+ if (entity instanceof Api.User) {
348
+ const parts = [entity.firstName, entity.lastName].filter(Boolean);
349
+ const name = parts.join(" ") || "Unknown";
350
+ return entity.username ? `${name} (@${entity.username})` : name;
351
+ }
352
+ if (entity instanceof Api.Channel || entity instanceof Api.Chat) {
353
+ return entity.title ?? "Group";
354
+ }
355
+ return senderId.toString();
356
+ }
357
+ catch {
358
+ return senderId.toString();
359
+ }
360
+ }
361
+ async getMessages(chatId, limit = 10, offsetId, minDate, maxDate) {
362
+ if (!this.client || !this.connected)
363
+ throw new Error("Not connected");
364
+ const opts = {
365
+ limit,
366
+ ...(offsetId ? { offsetId } : {}),
367
+ ...(maxDate ? { offsetDate: maxDate } : {}),
368
+ };
369
+ const messages = await this.client.getMessages(chatId, opts);
370
+ let filtered = messages;
371
+ if (minDate) {
372
+ filtered = filtered.filter((m) => (m.date ?? 0) >= minDate);
373
+ }
374
+ const results = await Promise.all(filtered.map(async (m) => ({
375
+ id: m.id,
376
+ text: m.message ?? "",
377
+ sender: await this.resolveSenderName(m.senderId),
378
+ date: new Date((m.date ?? 0) * 1000).toISOString(),
379
+ media: this.extractMediaInfo(m.media),
380
+ })));
381
+ return results;
382
+ }
383
+ async searchChats(query, limit = 10) {
384
+ if (!this.client || !this.connected)
385
+ throw new Error("Not connected");
386
+ const result = await this.client.invoke(new Api.contacts.Search({ q: query, limit }));
387
+ const chats = [];
388
+ for (const user of result.users) {
389
+ if (user instanceof Api.User) {
390
+ const parts = [user.firstName, user.lastName].filter(Boolean);
391
+ chats.push({
392
+ id: user.id.toString(),
393
+ name: parts.join(" ") || "Unknown",
394
+ type: "private",
395
+ username: user.username ?? undefined,
396
+ });
397
+ }
398
+ }
399
+ for (const chat of result.chats) {
400
+ if (chat instanceof Api.Chat) {
401
+ chats.push({ id: chat.id.toString(), name: chat.title, type: "group" });
402
+ }
403
+ else if (chat instanceof Api.Channel) {
404
+ chats.push({
405
+ id: chat.id.toString(),
406
+ name: chat.title,
407
+ type: chat.megagroup ? "group" : "channel",
408
+ username: chat.username ?? undefined,
409
+ });
410
+ }
411
+ }
412
+ return chats;
413
+ }
414
+ async searchMessages(chatId, query, limit = 20, minDate, maxDate) {
415
+ if (!this.client || !this.connected)
416
+ throw new Error("Not connected");
417
+ const messages = await this.client.getMessages(chatId, {
418
+ search: query,
419
+ limit,
420
+ ...(maxDate ? { offsetDate: maxDate } : {}),
421
+ });
422
+ let filtered = messages;
423
+ if (minDate) {
424
+ filtered = filtered.filter((m) => (m.date ?? 0) >= minDate);
425
+ }
426
+ const results = await Promise.all(filtered.map(async (m) => ({
427
+ id: m.id,
428
+ text: m.message ?? "",
429
+ sender: await this.resolveSenderName(m.senderId),
430
+ date: new Date((m.date ?? 0) * 1000).toISOString(),
431
+ media: this.extractMediaInfo(m.media),
432
+ })));
433
+ return results;
434
+ }
435
+ async getContacts(limit = 50) {
436
+ if (!this.client || !this.connected)
437
+ throw new Error("Not connected");
438
+ const result = await this.client.invoke(new Api.contacts.GetContacts({ hash: bigInt(0) }));
439
+ if (!(result instanceof Api.contacts.Contacts))
440
+ return [];
441
+ const contacts = [];
442
+ for (const user of result.users) {
443
+ if (user instanceof Api.User) {
444
+ const parts = [user.firstName, user.lastName].filter(Boolean);
445
+ contacts.push({
446
+ id: user.id.toString(),
447
+ name: parts.join(" ") || "Unknown",
448
+ username: user.username ?? undefined,
449
+ phone: user.phone ?? undefined,
450
+ });
451
+ }
452
+ }
453
+ return contacts.slice(0, limit);
454
+ }
455
+ async getChatMembers(chatId, limit = 50) {
456
+ if (!this.client || !this.connected)
457
+ throw new Error("Not connected");
458
+ const participants = await this.client.getParticipants(chatId, { limit });
459
+ const members = [];
460
+ for (const p of participants) {
461
+ if (p instanceof Api.User) {
462
+ const parts = [p.firstName, p.lastName].filter(Boolean);
463
+ members.push({
464
+ id: p.id.toString(),
465
+ name: parts.join(" ") || "Unknown",
466
+ username: p.username ?? undefined,
467
+ });
468
+ }
469
+ }
470
+ return members;
471
+ }
472
+ async getProfile(userId) {
473
+ if (!this.client || !this.connected)
474
+ throw new Error("Not connected");
475
+ const entity = await this.client.getEntity(userId);
476
+ if (!(entity instanceof Api.User))
477
+ throw new Error("Entity is not a user");
478
+ const inputEntity = await this.client.getInputEntity(userId);
479
+ const fullResult = await this.client.invoke(new Api.users.GetFullUser({ id: inputEntity }));
480
+ const bio = fullResult.fullUser.about ?? undefined;
481
+ const parts = [entity.firstName, entity.lastName].filter(Boolean);
482
+ let lastSeen;
483
+ if (entity.status instanceof Api.UserStatusOnline) {
484
+ lastSeen = "online";
485
+ }
486
+ else if (entity.status instanceof Api.UserStatusOffline) {
487
+ lastSeen = new Date(entity.status.wasOnline * 1000).toISOString();
488
+ }
489
+ else if (entity.status instanceof Api.UserStatusRecently) {
490
+ lastSeen = "recently";
491
+ }
492
+ else if (entity.status instanceof Api.UserStatusLastWeek) {
493
+ lastSeen = "last week";
494
+ }
495
+ else if (entity.status instanceof Api.UserStatusLastMonth) {
496
+ lastSeen = "last month";
497
+ }
498
+ return {
499
+ id: entity.id.toString(),
500
+ name: parts.join(" ") || "Unknown",
501
+ username: entity.username ?? undefined,
502
+ phone: entity.phone ?? undefined,
503
+ bio,
504
+ photo: !!entity.photo,
505
+ lastSeen,
506
+ };
507
+ }
508
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@overpod/mcp-telegram",
3
+ "version": "1.1.0",
4
+ "description": "MCP server for Telegram userbot — 20 tools for messages, media, contacts & more. Built on GramJS/MTProto.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "mcp-telegram": "dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "dev": "tsx watch src/index.ts",
17
+ "start": "node dist/index.js",
18
+ "login": "node dist/qr-login-cli.js",
19
+ "build": "tsc",
20
+ "prepublishOnly": "npm run build",
21
+ "lint": "biome check src/",
22
+ "lint:fix": "biome check --fix src/",
23
+ "format": "biome format --write src/"
24
+ },
25
+ "keywords": [
26
+ "mcp",
27
+ "mcp-server",
28
+ "model-context-protocol",
29
+ "telegram",
30
+ "gramjs",
31
+ "mtproto",
32
+ "userbot",
33
+ "claude",
34
+ "ai-tools"
35
+ ],
36
+ "author": "overpod",
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/overpod/mcp-telegram.git"
41
+ },
42
+ "homepage": "https://github.com/overpod/mcp-telegram#readme",
43
+ "bugs": {
44
+ "url": "https://github.com/overpod/mcp-telegram/issues"
45
+ },
46
+ "dependencies": {
47
+ "@modelcontextprotocol/sdk": "^1.27.1",
48
+ "dotenv": "^17.3.1",
49
+ "qrcode": "^1.5.4",
50
+ "telegram": "^2.26.22",
51
+ "zod": "^4.3.6"
52
+ },
53
+ "devDependencies": {
54
+ "@biomejs/biome": "^2.4.6",
55
+ "@types/node": "^25.4.0",
56
+ "@types/qrcode": "^1.5.6",
57
+ "tsx": "^4.21.0",
58
+ "typescript": "^5.9.3"
59
+ }
60
+ }