@overpod/mcp-telegram 1.7.0 → 1.8.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/README.md CHANGED
@@ -21,6 +21,7 @@ An MCP (Model Context Protocol) server that connects AI assistants like Claude t
21
21
  - **MTProto protocol** -- direct Telegram API access, not the limited Bot API
22
22
  - **Userbot** -- operates as your personal account, not a bot
23
23
  - **Full-featured** -- messaging, reactions, polls, scheduled messages, media, contacts, and more
24
+ - **Forum Topics** -- list topics, read per-topic messages, send to specific topics, per-topic unread counts
24
25
  - **QR code login** -- authenticate by scanning a QR code in the Telegram app
25
26
  - **Session persistence** -- login once, stay connected across restarts
26
27
  - **Human-readable output** -- sender names are resolved, not just numeric IDs
@@ -46,7 +47,9 @@ An MCP (Model Context Protocol) server that connects AI assistants like Claude t
46
47
  TELEGRAM_API_ID=YOUR_ID TELEGRAM_API_HASH=YOUR_HASH npx @overpod/mcp-telegram login
47
48
  ```
48
49
 
49
- A QR code will appear in the terminal. Open Telegram on your phone, go to **Settings > Devices > Link Desktop Device**, and scan the code. The session is saved to `~/.telegram-session` and reused automatically.
50
+ A QR code will appear in the terminal. Open Telegram on your phone, go to **Settings > Devices > Link Desktop Device**, and scan the code. The session is saved to `~/.mcp-telegram/session` and reused automatically.
51
+
52
+ > **Custom session path:** set `TELEGRAM_SESSION_PATH=/path/to/session` to store the session file elsewhere.
50
53
 
51
54
  ### 3. Add to Claude
52
55
 
@@ -59,6 +62,34 @@ claude mcp add telegram -s user \
59
62
 
60
63
  That's it! Ask Claude to run `telegram-status` to verify.
61
64
 
65
+ ### Multiple Accounts
66
+
67
+ Use `TELEGRAM_SESSION_PATH` to run separate Telegram accounts side by side:
68
+
69
+ ```bash
70
+ # Login each account with a unique session path
71
+ TELEGRAM_API_ID=ID1 TELEGRAM_API_HASH=HASH1 TELEGRAM_SESSION_PATH=~/.mcp-telegram/session-work npx @overpod/mcp-telegram login
72
+ TELEGRAM_API_ID=ID2 TELEGRAM_API_HASH=HASH2 TELEGRAM_SESSION_PATH=~/.mcp-telegram/session-personal npx @overpod/mcp-telegram login
73
+ ```
74
+
75
+ Then add each as a separate MCP server:
76
+
77
+ ```bash
78
+ claude mcp add telegram-work -s user \
79
+ -e TELEGRAM_API_ID=ID1 \
80
+ -e TELEGRAM_API_HASH=HASH1 \
81
+ -e TELEGRAM_SESSION_PATH=~/.mcp-telegram/session-work \
82
+ -- npx @overpod/mcp-telegram
83
+
84
+ claude mcp add telegram-personal -s user \
85
+ -e TELEGRAM_API_ID=ID2 \
86
+ -e TELEGRAM_API_HASH=HASH2 \
87
+ -e TELEGRAM_SESSION_PATH=~/.mcp-telegram/session-personal \
88
+ -- npx @overpod/mcp-telegram
89
+ ```
90
+
91
+ Each account gets its own session file — no conflicts.
92
+
62
93
  ## Installation Options
63
94
 
64
95
  ### npx (recommended, zero install)
@@ -177,9 +208,16 @@ const telegramMcp = new MCPClient({
177
208
  | `telegram-read-messages` | Read recent messages from a chat |
178
209
  | `telegram-search-chats` | Search for chats, users, or channels by name |
179
210
  | `telegram-search-messages` | Search messages in a chat by text |
180
- | `telegram-get-unread` | Get chats with unread messages, bot/contact markers |
211
+ | `telegram-get-unread` | Get chats with unread messages; forums show per-topic unread breakdown |
181
212
  | `telegram-get-contact-requests` | Get incoming messages from non-contacts with preview |
182
213
 
214
+ ### Forum Topics
215
+
216
+ | Tool | Description |
217
+ |------|-------------|
218
+ | `telegram-list-topics` | List forum topics in a group with unread counts and status |
219
+ | `telegram-read-topic-messages` | Read messages from a specific forum topic |
220
+
183
221
  ### Chat Management
184
222
 
185
223
  | Tool | Description |
@@ -226,6 +264,23 @@ Most tools accept `chatId` as a string -- either a numeric ID (e.g., `"-10012345
226
264
  | `text` | string | yes | Message text |
227
265
  | `replyTo` | number | no | Message ID to reply to |
228
266
  | `parseMode` | `"md"` / `"html"` | no | Message formatting mode |
267
+ | `topicId` | number | no | Forum topic ID to send into (for groups with Topics) |
268
+
269
+ ### telegram-list-topics
270
+
271
+ | Parameter | Type | Required | Description |
272
+ |-----------|------|----------|-------------|
273
+ | `chatId` | string | yes | Chat ID or @username (group with Topics enabled) |
274
+ | `limit` | number | no | Max topics to return (default: 100) |
275
+
276
+ ### telegram-read-topic-messages
277
+
278
+ | Parameter | Type | Required | Description |
279
+ |-----------|------|----------|-------------|
280
+ | `chatId` | string | yes | Chat ID or @username |
281
+ | `topicId` | number | yes | Topic ID (from `telegram-list-topics`) |
282
+ | `limit` | number | no | Number of messages (default: 20) |
283
+ | `offsetId` | number | no | Message ID for pagination |
229
284
 
230
285
  ### telegram-list-chats
231
286
 
@@ -426,7 +481,8 @@ src/
426
481
  ## Security
427
482
 
428
483
  - API credentials are stored in `.env` (gitignored)
429
- - Session is stored in `.telegram-session` (gitignored)
484
+ - Session is stored in `~/.mcp-telegram/session` with `0600` permissions (owner-only access)
485
+ - Session directory is created with `0700` permissions
430
486
  - Phone number is **not required** -- QR-only authentication
431
487
  - This is a **userbot** (personal account), not a bot -- respect the [Telegram Terms of Service](https://core.telegram.org/api/terms)
432
488
 
package/dist/index.js CHANGED
@@ -104,13 +104,15 @@ server.tool("telegram-send-message", "Send a message to a Telegram chat", {
104
104
  text: z.string().describe("Message text"),
105
105
  replyTo: z.number().optional().describe("Message ID to reply to"),
106
106
  parseMode: z.enum(["md", "html"]).optional().describe("Message format: md (Markdown) or html"),
107
- }, async ({ chatId, text, replyTo, parseMode }) => {
107
+ topicId: z.number().optional().describe("Forum topic ID to send message into (for groups with Topics enabled)"),
108
+ }, async ({ chatId, text, replyTo, parseMode, topicId }) => {
108
109
  const err = await requireConnection();
109
110
  if (err)
110
111
  return { content: [{ type: "text", text: err }] };
111
112
  try {
112
- await telegram.sendMessage(chatId, text, replyTo, parseMode);
113
- return { content: [{ type: "text", text: `Message sent to ${chatId}` }] };
113
+ await telegram.sendMessage(chatId, text, replyTo, parseMode, topicId);
114
+ const dest = topicId ? `topic ${topicId} in ${chatId}` : chatId;
115
+ return { content: [{ type: "text", text: `Message sent to ${dest}` }] };
114
116
  }
115
117
  catch (e) {
116
118
  return { content: [{ type: "text", text: `Send error: ${e.message}` }] };
@@ -217,7 +219,13 @@ server.tool("telegram-get-unread", "Get unread Telegram chats", {
217
219
  const prefix = d.type === "group" ? "G" : d.type === "channel" ? "C" : "P";
218
220
  const botTag = d.isBot ? " [bot]" : "";
219
221
  const contactTag = d.type === "private" && d.isContact === false ? " [not in contacts]" : "";
220
- return `${prefix} ${d.name} (${d.id})${botTag}${contactTag} [${d.unreadCount} unread]`;
222
+ const forumTag = d.forum ? " [forum]" : "";
223
+ let line = `${prefix} ${d.name} (${d.id})${botTag}${contactTag}${forumTag} [${d.unreadCount} unread]`;
224
+ if (d.topics && d.topics.length > 0) {
225
+ const topicLines = d.topics.map((t) => ` # ${t.title} [${t.unreadCount} unread]`);
226
+ line += `\n${topicLines.join("\n")}`;
227
+ }
228
+ return line;
221
229
  })
222
230
  .join("\n");
223
231
  return { content: [{ type: "text", text: text || "No unread chats" }] };
@@ -303,6 +311,7 @@ server.tool("telegram-get-chat-info", "Get detailed info about a Telegram chat",
303
311
  `Name: ${info.name}`,
304
312
  `ID: ${info.id}`,
305
313
  `Type: ${info.type}`,
314
+ ...(info.forum ? ["Forum: yes"] : []),
306
315
  ...(info.username ? [`Username: @${info.username}`] : []),
307
316
  ...(info.description ? [`Description: ${info.description}`] : []),
308
317
  ...(info.membersCount != null ? [`Members: ${info.membersCount}`] : []),
@@ -594,6 +603,49 @@ server.tool("telegram-report-spam", "Report a chat as spam to Telegram", {
594
603
  return { content: [{ type: "text", text: `Error: ${e.message}` }] };
595
604
  }
596
605
  });
606
+ server.tool("telegram-list-topics", "List forum topics in a Telegram group with Topics enabled. Shows topic names, unread counts, and status", {
607
+ chatId: z.string().describe("Chat ID or username of a group with Topics enabled"),
608
+ limit: z.number().default(100).describe("Max topics to return"),
609
+ }, async ({ chatId, limit }) => {
610
+ const err = await requireConnection();
611
+ if (err)
612
+ return { content: [{ type: "text", text: err }] };
613
+ try {
614
+ const topics = await telegram.getForumTopics(chatId, limit);
615
+ const text = topics
616
+ .map((t) => {
617
+ const flags = [t.pinned ? "pinned" : "", t.closed ? "closed" : ""].filter(Boolean).join(", ");
618
+ const flagStr = flags ? ` [${flags}]` : "";
619
+ const unread = t.unreadCount > 0 ? ` [${t.unreadCount} unread]` : "";
620
+ return `# ${t.title} (id: ${t.id})${flagStr}${unread}`;
621
+ })
622
+ .join("\n");
623
+ return { content: [{ type: "text", text: text || "No topics found" }] };
624
+ }
625
+ catch (e) {
626
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
627
+ }
628
+ });
629
+ server.tool("telegram-read-topic-messages", "Read messages from a specific forum topic in a Telegram group", {
630
+ chatId: z.string().describe("Chat ID or username"),
631
+ topicId: z.number().describe("Topic ID (get from telegram-list-topics)"),
632
+ limit: z.number().default(20).describe("Number of messages to return"),
633
+ offsetId: z.number().optional().describe("Message ID to start from (for pagination)"),
634
+ }, async ({ chatId, topicId, limit, offsetId }) => {
635
+ const err = await requireConnection();
636
+ if (err)
637
+ return { content: [{ type: "text", text: err }] };
638
+ try {
639
+ const messages = await telegram.getTopicMessages(chatId, topicId, limit, offsetId);
640
+ const text = messages
641
+ .map((m) => `[${m.date}] ${m.sender}: ${m.text}${m.media ? ` [${m.media.type}${m.media.fileName ? `: ${m.media.fileName}` : ""}]` : ""}`)
642
+ .join("\n\n");
643
+ return { content: [{ type: "text", text: text || "No messages in this topic" }] };
644
+ }
645
+ catch (e) {
646
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
647
+ }
648
+ });
597
649
  // --- Start ---
598
650
  async function main() {
599
651
  // Try to auto-connect with saved session
@@ -36,7 +36,7 @@ export declare class TelegramService {
36
36
  username?: string;
37
37
  firstName?: string;
38
38
  }>;
39
- sendMessage(chatId: string, text: string, replyTo?: number, parseMode?: "md" | "html"): Promise<void>;
39
+ sendMessage(chatId: string, text: string, replyTo?: number, parseMode?: "md" | "html", topicId?: number): Promise<void>;
40
40
  sendFile(chatId: string, filePath: string, caption?: string): Promise<void>;
41
41
  downloadMedia(chatId: string, messageId: number, downloadPath: string): Promise<string>;
42
42
  downloadMediaAsBuffer(chatId: string, messageId: number): Promise<{
@@ -62,6 +62,12 @@ export declare class TelegramService {
62
62
  unreadCount: number;
63
63
  isBot?: boolean;
64
64
  isContact?: boolean;
65
+ forum?: boolean;
66
+ topics?: Array<{
67
+ id: number;
68
+ title: string;
69
+ unreadCount: number;
70
+ }>;
65
71
  }>>;
66
72
  getContactRequests(limit?: number): Promise<Array<{
67
73
  id: string;
@@ -88,6 +94,7 @@ export declare class TelegramService {
88
94
  membersCount?: number;
89
95
  isBot?: boolean;
90
96
  isContact?: boolean;
97
+ forum?: boolean;
91
98
  }>;
92
99
  /** Extract media info from a message */
93
100
  private extractMediaInfo;
@@ -148,6 +155,28 @@ export declare class TelegramService {
148
155
  quiz?: boolean;
149
156
  correctAnswer?: number;
150
157
  }): Promise<number>;
158
+ getForumTopics(chatId: string, limit?: number): Promise<Array<{
159
+ id: number;
160
+ title: string;
161
+ unreadCount: number;
162
+ unreadMentions: number;
163
+ iconColor: number;
164
+ closed: boolean;
165
+ pinned: boolean;
166
+ }>>;
167
+ getTopicMessages(chatId: string, topicId: number, limit?: number, offsetId?: number): Promise<Array<{
168
+ id: number;
169
+ text: string;
170
+ sender: string;
171
+ date: string;
172
+ media?: {
173
+ type: string;
174
+ fileName?: string;
175
+ size?: number;
176
+ };
177
+ }>>;
178
+ /** Check if a chat entity is a forum (has topics enabled) */
179
+ isForum(chatId: string): Promise<boolean>;
151
180
  joinChat(target: string): Promise<{
152
181
  id: string;
153
182
  title: string;
@@ -273,14 +273,29 @@ export class TelegramService {
273
273
  firstName: user.firstName ?? undefined,
274
274
  };
275
275
  }
276
- async sendMessage(chatId, text, replyTo, parseMode) {
276
+ async sendMessage(chatId, text, replyTo, parseMode, topicId) {
277
277
  if (!this.client || !this.connected)
278
278
  throw new Error("Not connected");
279
- await this.client.sendMessage(chatId, {
280
- message: text,
281
- ...(replyTo ? { replyTo } : {}),
282
- ...(parseMode ? { parseMode: parseMode === "html" ? "html" : "md" } : {}),
283
- });
279
+ if (topicId) {
280
+ // Forum topics require raw API call with InputReplyToMessage
281
+ const peer = await this.client.getInputEntity(chatId);
282
+ await this.client.invoke(new Api.messages.SendMessage({
283
+ peer,
284
+ message: text,
285
+ randomId: bigInt(Math.floor(Math.random() * 1e15)),
286
+ replyTo: new Api.InputReplyToMessage({
287
+ replyToMsgId: replyTo ?? topicId,
288
+ topMsgId: topicId,
289
+ }),
290
+ }));
291
+ }
292
+ else {
293
+ await this.client.sendMessage(chatId, {
294
+ message: text,
295
+ ...(replyTo ? { replyTo } : {}),
296
+ ...(parseMode ? { parseMode: parseMode === "html" ? "html" : "md" } : {}),
297
+ });
298
+ }
284
299
  }
285
300
  async sendFile(chatId, filePath, caption) {
286
301
  if (!this.client || !this.connected)
@@ -374,12 +389,11 @@ export class TelegramService {
374
389
  if (!this.client || !this.connected)
375
390
  throw new Error("Not connected");
376
391
  const dialogs = await this.client.getDialogs({ limit: limit * 3 });
377
- return dialogs
378
- .filter((d) => d.unreadCount > 0)
379
- .slice(0, limit)
380
- .map((d) => {
392
+ const unread = dialogs.filter((d) => d.unreadCount > 0).slice(0, limit);
393
+ const results = await Promise.all(unread.map(async (d) => {
381
394
  const isUser = d.entity instanceof Api.User;
382
- return {
395
+ const isForum = d.entity instanceof Api.Channel && Boolean(d.entity.forum);
396
+ const base = {
383
397
  id: d.id?.toString() ?? "",
384
398
  name: d.title ?? d.name ?? "Unknown",
385
399
  type: d.isGroup ? "group" : d.isChannel ? "channel" : "private",
@@ -388,7 +402,29 @@ export class TelegramService {
388
402
  ? { isBot: Boolean(d.entity.bot), isContact: Boolean(d.entity.contact) }
389
403
  : {}),
390
404
  };
391
- });
405
+ if (isForum) {
406
+ try {
407
+ const forumTopics = await this.getForumTopics(d.id?.toString() ?? "");
408
+ const unreadTopics = forumTopics
409
+ .filter((t) => t.unreadCount > 0)
410
+ .map((t) => ({ id: t.id, title: t.title, unreadCount: t.unreadCount }));
411
+ const realUnread = unreadTopics.reduce((sum, t) => sum + t.unreadCount, 0);
412
+ if (realUnread === 0)
413
+ return null;
414
+ return {
415
+ ...base,
416
+ unreadCount: realUnread,
417
+ forum: true,
418
+ topics: unreadTopics.length > 0 ? unreadTopics : undefined,
419
+ };
420
+ }
421
+ catch {
422
+ return { ...base, forum: true };
423
+ }
424
+ }
425
+ return base;
426
+ }));
427
+ return results.filter((r) => r !== null);
392
428
  }
393
429
  async getContactRequests(limit = 20) {
394
430
  if (!this.client || !this.connected)
@@ -493,6 +529,7 @@ export class TelegramService {
493
529
  username: entity.username ?? undefined,
494
530
  description,
495
531
  membersCount,
532
+ forum: Boolean(entity.forum) || undefined,
496
533
  };
497
534
  }
498
535
  if (entity instanceof Api.Chat) {
@@ -769,6 +806,75 @@ export class TelegramService {
769
806
  }
770
807
  return 0;
771
808
  }
809
+ async getForumTopics(chatId, limit = 100) {
810
+ if (!this.client || !this.connected)
811
+ throw new Error("Not connected");
812
+ const entity = await this.client.getEntity(chatId);
813
+ if (!(entity instanceof Api.Channel))
814
+ throw new Error("Forum topics are only available in supergroups");
815
+ const result = await this.client.invoke(new Api.channels.GetForumTopics({
816
+ channel: entity,
817
+ limit,
818
+ offsetTopic: 0,
819
+ offsetDate: 0,
820
+ offsetId: 0,
821
+ }));
822
+ const topics = [];
823
+ for (const topic of result.topics) {
824
+ if (topic instanceof Api.ForumTopic) {
825
+ topics.push({
826
+ id: topic.id,
827
+ title: topic.title,
828
+ unreadCount: topic.unreadCount,
829
+ unreadMentions: topic.unreadMentionsCount,
830
+ iconColor: topic.iconColor,
831
+ closed: Boolean(topic.closed),
832
+ pinned: Boolean(topic.pinned),
833
+ });
834
+ }
835
+ }
836
+ return topics;
837
+ }
838
+ async getTopicMessages(chatId, topicId, limit = 20, offsetId) {
839
+ if (!this.client || !this.connected)
840
+ throw new Error("Not connected");
841
+ const peer = await this.client.getInputEntity(chatId);
842
+ const result = await this.client.invoke(new Api.messages.GetReplies({
843
+ peer,
844
+ msgId: topicId,
845
+ limit,
846
+ ...(offsetId ? { offsetId } : {}),
847
+ offsetDate: 0,
848
+ addOffset: 0,
849
+ maxId: 0,
850
+ minId: 0,
851
+ hash: bigInt(0),
852
+ }));
853
+ const messages = "messages" in result ? result.messages : [];
854
+ const results = await Promise.all(messages
855
+ .filter((m) => m instanceof Api.Message)
856
+ .map(async (m) => ({
857
+ id: m.id,
858
+ text: m.message ?? "",
859
+ sender: await this.resolveSenderName(m.senderId),
860
+ date: new Date((m.date ?? 0) * 1000).toISOString(),
861
+ media: this.extractMediaInfo(m.media),
862
+ })));
863
+ return results;
864
+ }
865
+ /** Check if a chat entity is a forum (has topics enabled) */
866
+ async isForum(chatId) {
867
+ if (!this.client || !this.connected)
868
+ throw new Error("Not connected");
869
+ try {
870
+ const entity = await this.client.getEntity(chatId);
871
+ if (entity instanceof Api.Channel) {
872
+ return Boolean(entity.forum);
873
+ }
874
+ }
875
+ catch { }
876
+ return false;
877
+ }
772
878
  async joinChat(target) {
773
879
  if (!this.client)
774
880
  throw new Error("Not connected");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@overpod/mcp-telegram",
3
- "version": "1.7.0",
3
+ "version": "1.8.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",