@overpod/mcp-telegram 1.27.0 → 1.28.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/dist/client.d.ts CHANGED
@@ -1,12 +1,36 @@
1
+ import { type Socket } from "node:net";
2
+ import { type IpcLoginDone } from "./ipc-protocol.js";
3
+ export interface IpcClientOptions {
4
+ connectTimeoutMs?: number;
5
+ callTimeoutMs?: number;
6
+ loginTimeoutMs?: number;
7
+ connectFn?: (path: string) => Socket;
8
+ }
1
9
  /** Thin IPC proxy: forwards tool calls to the master process over Unix socket */
2
10
  export declare class IpcClient {
3
11
  private socket;
4
12
  private pending;
13
+ private pendingLogins;
5
14
  private buf;
6
15
  private connected;
16
+ private destroyed;
17
+ private onDisconnect?;
18
+ private readonly connectTimeoutMs;
19
+ private readonly callTimeoutMs;
20
+ private readonly loginTimeoutMs;
21
+ private readonly connectFn;
22
+ constructor(opts?: IpcClientOptions);
23
+ /** Register a callback fired when the peer socket closes unexpectedly.
24
+ * Call this AFTER a successful connect() so aborted connection attempts don't fire it. */
25
+ setOnDisconnect(cb: () => void): void;
7
26
  connect(): Promise<boolean>;
27
+ private routeMessage;
8
28
  isConnected(): boolean;
9
29
  call(tool: string, args: Record<string, unknown>): Promise<unknown>;
30
+ /** Request QR login flow from master. `onQr` fires for each QR URL frame (refreshes ~every 10s).
31
+ * Only one login can run on the master side at a time — a concurrent call gets an immediate
32
+ * `login_done {success:false}` with "Another QR login is already in progress". */
33
+ loginFlow(onQr: (url: string) => void): Promise<IpcLoginDone>;
10
34
  destroy(): void;
11
35
  }
12
36
  export declare function runClient(apiId: number, apiHash: string, version: string): Promise<void>;
package/dist/client.js CHANGED
@@ -2,28 +2,47 @@ import { randomUUID } from "node:crypto";
2
2
  import { connect } from "node:net";
3
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
- import { encodeMessage, parseMessages } from "./ipc-protocol.js";
5
+ import { encodeMessage, parseMessages, } from "./ipc-protocol.js";
6
6
  import { socketPath } from "./lock.js";
7
7
  import { TelegramService } from "./telegram-client.js";
8
8
  import { registerTools } from "./tools/index.js";
9
9
  const CONNECT_TIMEOUT_MS = 5_000;
10
10
  const IPC_CALL_TIMEOUT_MS = 30_000;
11
+ const LOGIN_FLOW_TIMEOUT_MS = 360_000; // 6 min — QR has ~5 min server-side window
11
12
  const MAX_RECONNECT_ATTEMPTS = 5;
12
13
  /** Thin IPC proxy: forwards tool calls to the master process over Unix socket */
13
14
  export class IpcClient {
14
15
  socket = null;
15
16
  pending = new Map();
17
+ pendingLogins = new Map();
16
18
  buf = "";
17
19
  connected = false;
20
+ destroyed = false;
21
+ onDisconnect;
22
+ connectTimeoutMs;
23
+ callTimeoutMs;
24
+ loginTimeoutMs;
25
+ connectFn;
26
+ constructor(opts = {}) {
27
+ this.connectTimeoutMs = opts.connectTimeoutMs ?? CONNECT_TIMEOUT_MS;
28
+ this.callTimeoutMs = opts.callTimeoutMs ?? IPC_CALL_TIMEOUT_MS;
29
+ this.loginTimeoutMs = opts.loginTimeoutMs ?? LOGIN_FLOW_TIMEOUT_MS;
30
+ this.connectFn = opts.connectFn ?? connect;
31
+ }
32
+ /** Register a callback fired when the peer socket closes unexpectedly.
33
+ * Call this AFTER a successful connect() so aborted connection attempts don't fire it. */
34
+ setOnDisconnect(cb) {
35
+ this.onDisconnect = cb;
36
+ }
18
37
  async connect() {
19
38
  return new Promise((resolve) => {
20
39
  const sock = socketPath();
21
- const s = connect(sock);
40
+ const s = this.connectFn(sock);
22
41
  // One-shot connect timeout — cleared immediately on connect (HIGH-3)
23
42
  const connectTimer = setTimeout(() => {
24
43
  s.destroy();
25
44
  resolve(false);
26
- }, CONNECT_TIMEOUT_MS);
45
+ }, this.connectTimeoutMs);
27
46
  const onConnect = () => {
28
47
  clearTimeout(connectTimer);
29
48
  this.socket = s;
@@ -33,20 +52,8 @@ export class IpcClient {
33
52
  this.buf += chunk.toString("utf-8");
34
53
  const { messages, remaining } = parseMessages(this.buf);
35
54
  this.buf = remaining;
36
- for (const msg of messages) {
37
- const res = msg;
38
- const pending = this.pending.get(res.id);
39
- if (!pending)
40
- continue;
41
- clearTimeout(pending.timer);
42
- this.pending.delete(res.id);
43
- if (res.error) {
44
- pending.reject(new Error(res.error));
45
- }
46
- else {
47
- pending.resolve(res.result);
48
- }
49
- }
55
+ for (const msg of messages)
56
+ this.routeMessage(msg);
50
57
  });
51
58
  s.on("close", () => {
52
59
  this.connected = false;
@@ -55,7 +62,17 @@ export class IpcClient {
55
62
  p.reject(new Error("IPC connection closed"));
56
63
  }
57
64
  this.pending.clear();
65
+ for (const [, l] of this.pendingLogins) {
66
+ clearTimeout(l.timer);
67
+ l.reject(new Error("IPC connection closed"));
68
+ }
69
+ this.pendingLogins.clear();
70
+ if (!this.destroyed)
71
+ this.onDisconnect?.();
58
72
  });
73
+ // Post-connect errors (EPIPE, ECONNRESET) land here. Silent drop keeps the
74
+ // process alive for the "close" handler above to clean up pending calls.
75
+ // Node requires an error listener on sockets — absence crashes the process.
59
76
  s.on("error", () => { });
60
77
  resolve(true);
61
78
  };
@@ -67,6 +84,35 @@ export class IpcClient {
67
84
  s.once("error", onError);
68
85
  });
69
86
  }
87
+ routeMessage(msg) {
88
+ if (msg.type === "tool_response") {
89
+ const pending = this.pending.get(msg.id);
90
+ if (!pending)
91
+ return;
92
+ clearTimeout(pending.timer);
93
+ this.pending.delete(msg.id);
94
+ if (msg.error)
95
+ pending.reject(new Error(msg.error));
96
+ else
97
+ pending.resolve(msg.result);
98
+ return;
99
+ }
100
+ if (msg.type === "login_qr") {
101
+ const login = this.pendingLogins.get(msg.id);
102
+ login?.onQr(msg.url);
103
+ return;
104
+ }
105
+ if (msg.type === "login_done") {
106
+ const login = this.pendingLogins.get(msg.id);
107
+ if (!login)
108
+ return;
109
+ clearTimeout(login.timer);
110
+ this.pendingLogins.delete(msg.id);
111
+ login.resolve(msg);
112
+ return;
113
+ }
114
+ // tool / login_start are client→master only; ignored if received
115
+ }
70
116
  isConnected() {
71
117
  return this.connected;
72
118
  }
@@ -80,17 +126,41 @@ export class IpcClient {
80
126
  const timer = setTimeout(() => {
81
127
  this.pending.delete(id);
82
128
  reject(new Error(`IPC call timeout: ${tool}`));
83
- }, IPC_CALL_TIMEOUT_MS);
129
+ }, this.callTimeoutMs);
84
130
  this.pending.set(id, { resolve, reject, timer });
85
- socket.write(encodeMessage({ id, tool, args }));
131
+ socket.write(encodeMessage({ type: "tool", id, tool, args }));
132
+ });
133
+ }
134
+ /** Request QR login flow from master. `onQr` fires for each QR URL frame (refreshes ~every 10s).
135
+ * Only one login can run on the master side at a time — a concurrent call gets an immediate
136
+ * `login_done {success:false}` with "Another QR login is already in progress". */
137
+ async loginFlow(onQr) {
138
+ if (!this.socket || !this.connected) {
139
+ throw new Error("IPC client not connected");
140
+ }
141
+ const id = randomUUID();
142
+ const socket = this.socket;
143
+ return new Promise((resolve, reject) => {
144
+ const timer = setTimeout(() => {
145
+ this.pendingLogins.delete(id);
146
+ reject(new Error("Login flow timeout"));
147
+ }, this.loginTimeoutMs);
148
+ this.pendingLogins.set(id, { onQr, resolve, reject, timer });
149
+ socket.write(encodeMessage({ type: "login_start", id }));
86
150
  });
87
151
  }
88
152
  destroy() {
153
+ this.destroyed = true;
89
154
  for (const [, p] of this.pending) {
90
155
  clearTimeout(p.timer);
91
156
  p.reject(new Error("IPC client destroyed"));
92
157
  }
93
158
  this.pending.clear();
159
+ for (const [, l] of this.pendingLogins) {
160
+ clearTimeout(l.timer);
161
+ l.reject(new Error("IPC client destroyed"));
162
+ }
163
+ this.pendingLogins.clear();
94
164
  this.socket?.destroy();
95
165
  this.socket = null;
96
166
  this.connected = false;
@@ -124,12 +194,20 @@ export async function runClient(apiId, apiHash, version) {
124
194
  process.exit(1);
125
195
  }
126
196
  console.error(`[mcp-telegram] Client mode — proxying to master via ${socketPath()}`);
197
+ // Master died → socket closes → nothing to proxy. Exit so parent respawns us against a fresh master.
198
+ // Wire only AFTER successful connect, so retry attempts inside the loop above can't trip it.
199
+ ipc.setOnDisconnect(() => {
200
+ console.error("[mcp-telegram] IPC connection to master closed, exiting");
201
+ process.exit(1);
202
+ });
127
203
  // Register all tools for MCP schema; dummy telegram instance is never used for actual calls
128
204
  const telegram = new TelegramService(apiId, apiHash);
129
205
  const server = new McpServer({ name: "mcp-telegram", version });
130
206
  registerTools(server, telegram);
131
207
  // Replace all handlers with IPC-forwarding versions
132
208
  wireIpcProxies(server, ipc);
209
+ // Parent closed stdio → exit so parent can spawn a fresh instance cleanly
210
+ process.stdin.on("end", () => process.exit(0));
133
211
  const transport = new StdioServerTransport();
134
212
  await server.connect(transport);
135
213
  console.error("[mcp-telegram] MCP server running on stdio (client)");
@@ -0,0 +1,10 @@
1
+ /** FIFO mutex — ensures only one critical section runs at a time.
2
+ * Used in master to serialize tool calls with QR login flow:
3
+ * login holds the lock for up to minutes; tool calls queue behind it. */
4
+ export declare class GlobalLock {
5
+ private locked;
6
+ private waiters;
7
+ acquire(): Promise<() => void>;
8
+ isLocked(): boolean;
9
+ waitingCount(): number;
10
+ }
@@ -0,0 +1,29 @@
1
+ /** FIFO mutex — ensures only one critical section runs at a time.
2
+ * Used in master to serialize tool calls with QR login flow:
3
+ * login holds the lock for up to minutes; tool calls queue behind it. */
4
+ export class GlobalLock {
5
+ locked = false;
6
+ waiters = [];
7
+ async acquire() {
8
+ if (this.locked) {
9
+ await new Promise((resolve) => this.waiters.push(resolve));
10
+ }
11
+ this.locked = true;
12
+ let released = false;
13
+ return () => {
14
+ if (released)
15
+ return;
16
+ released = true;
17
+ this.locked = false;
18
+ const next = this.waiters.shift();
19
+ if (next)
20
+ next();
21
+ };
22
+ }
23
+ isLocked() {
24
+ return this.locked;
25
+ }
26
+ waitingCount() {
27
+ return this.waiters.length;
28
+ }
29
+ }
@@ -1,19 +1,48 @@
1
- /** Request from Client Master */
2
- export interface IpcRequest {
1
+ /** MCP SDK internal tool registry — field name "handler" confirmed in SDK v1.29.0 */
2
+ export type McpRegisteredTool = {
3
+ handler: (args: Record<string, unknown>, extra: Record<string, unknown>) => Promise<unknown>;
4
+ };
5
+ export interface McpServerInternal {
6
+ _registeredTools: Record<string, McpRegisteredTool>;
7
+ }
8
+ /** Client → Master: invoke MCP tool */
9
+ export interface IpcToolRequest {
10
+ type: "tool";
3
11
  id: string;
4
12
  tool: string;
5
13
  args: Record<string, unknown>;
6
14
  }
7
- /** Response from Master → Client */
8
- export interface IpcResponse {
15
+ /** Master → Client: tool result */
16
+ export interface IpcToolResponse {
17
+ type: "tool_response";
9
18
  id: string;
10
19
  result?: unknown;
11
20
  error?: string;
12
21
  }
22
+ /** Client → Master: begin QR login flow */
23
+ export interface IpcLoginStart {
24
+ type: "login_start";
25
+ id: string;
26
+ }
27
+ /** Master → Client: QR code URL to display (may fire multiple times as URL refreshes) */
28
+ export interface IpcLoginQr {
29
+ type: "login_qr";
30
+ id: string;
31
+ url: string;
32
+ }
33
+ /** Master → Client: QR login finished */
34
+ export interface IpcLoginDone {
35
+ type: "login_done";
36
+ id: string;
37
+ success: boolean;
38
+ username?: string;
39
+ error?: string;
40
+ }
41
+ export type IpcMessage = IpcToolRequest | IpcToolResponse | IpcLoginStart | IpcLoginQr | IpcLoginDone;
13
42
  /** Encode a message as newline-delimited JSON */
14
- export declare function encodeMessage(msg: IpcRequest | IpcResponse): string;
43
+ export declare function encodeMessage(msg: IpcMessage): string;
15
44
  /** Parse newline-delimited JSON messages from a buffer, returns parsed messages + leftover */
16
45
  export declare function parseMessages(buf: string): {
17
- messages: (IpcRequest | IpcResponse)[];
46
+ messages: IpcMessage[];
18
47
  remaining: string;
19
48
  };
@@ -12,7 +12,9 @@ export function parseMessages(buf) {
12
12
  if (!trimmed)
13
13
  continue;
14
14
  try {
15
- messages.push(JSON.parse(trimmed));
15
+ const parsed = JSON.parse(trimmed);
16
+ if (isIpcMessage(parsed))
17
+ messages.push(parsed);
16
18
  }
17
19
  catch {
18
20
  // Skip malformed lines
@@ -20,3 +22,12 @@ export function parseMessages(buf) {
20
22
  }
21
23
  return { messages, remaining };
22
24
  }
25
+ function isIpcMessage(m) {
26
+ if (!m || typeof m !== "object" || typeof m.type !== "string" || typeof m.id !== "string")
27
+ return false;
28
+ return (m.type === "tool" ||
29
+ m.type === "tool_response" ||
30
+ m.type === "login_start" ||
31
+ m.type === "login_qr" ||
32
+ m.type === "login_done");
33
+ }
package/dist/master.d.ts CHANGED
@@ -1 +1,5 @@
1
+ import { type Socket } from "node:net";
2
+ import { type McpServerInternal } from "./ipc-protocol.js";
3
+ import { TelegramService } from "./telegram-client.js";
4
+ export declare function handleClient(socket: Socket, mcpServer: McpServerInternal, telegram: TelegramService): void;
1
5
  export declare function runMaster(apiId: number, apiHash: string, version: string): Promise<void>;
package/dist/master.js CHANGED
@@ -1,52 +1,51 @@
1
1
  import { createServer } from "node:net";
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { encodeMessage, parseMessages } from "./ipc-protocol.js";
4
+ import { GlobalLock } from "./global-lock.js";
5
+ import { encodeMessage, parseMessages, } from "./ipc-protocol.js";
5
6
  import { releaseLock, releaseSocket, socketPath } from "./lock.js";
6
7
  import { TelegramService } from "./telegram-client.js";
7
8
  import { registerTools } from "./tools/index.js";
8
- let socketServer = null;
9
9
  let cleanedUp = false;
10
10
  function cleanup() {
11
11
  if (cleanedUp)
12
12
  return;
13
13
  cleanedUp = true;
14
+ // Sync unlink only — process.exit handlers cannot await async server.close(),
15
+ // and unlinking the socket file is sufficient to release the listening address.
14
16
  releaseLock();
15
17
  releaseSocket();
16
- socketServer?.close();
17
18
  }
18
19
  process.on("exit", cleanup);
19
20
  process.on("SIGINT", () => process.exit(0));
20
21
  process.on("SIGTERM", () => process.exit(0));
21
- function handleClient(socket, mcpServer) {
22
+ // Serializes tool calls with QR login — login holds the lock for up to minutes,
23
+ // tool calls queue behind it. Prevents tool calls from running against a stale
24
+ // Telegram client mid-relogin.
25
+ const globalLock = new GlobalLock();
26
+ let activeLogin = null;
27
+ export function handleClient(socket, mcpServer, telegram) {
22
28
  let buf = "";
23
- // Processing queue — ensures sequential handling even when handler awaits
24
29
  let processing = false;
25
30
  const queue = [];
31
+ // Per-socket FIFO: messages from one client execute in arrival order.
32
+ // Parallelism across DIFFERENT clients is enforced by globalLock (acquired per handler),
33
+ // not here — a tool call from client A can proceed while client B is in login flow.
26
34
  async function drainQueue() {
27
35
  if (processing)
28
36
  return;
29
37
  processing = true;
30
38
  while (queue.length > 0) {
31
- const req = queue.shift();
32
- if (!req)
39
+ const msg = queue.shift();
40
+ if (!msg)
33
41
  break;
34
- const tool = mcpServer._registeredTools[req.tool];
35
- const response = { id: req.id };
36
- if (!tool) {
37
- response.error = `Unknown tool: ${req.tool}`;
42
+ if (msg.type === "tool") {
43
+ await handleToolRequest(socket, msg, mcpServer);
38
44
  }
39
- else {
40
- try {
41
- response.result = await tool.handler(req.args ?? {}, {});
42
- }
43
- catch (err) {
44
- response.error = err instanceof Error ? err.message : String(err);
45
- }
46
- }
47
- if (!socket.destroyed) {
48
- socket.write(encodeMessage(response));
45
+ else if (msg.type === "login_start") {
46
+ await handleLoginStart(socket, msg, telegram);
49
47
  }
48
+ // Responses and QR frames are master→client only — client-side messages ignored here
50
49
  }
51
50
  processing = false;
52
51
  }
@@ -54,15 +53,73 @@ function handleClient(socket, mcpServer) {
54
53
  buf += chunk.toString("utf-8");
55
54
  const { messages, remaining } = parseMessages(buf);
56
55
  buf = remaining;
57
- for (const msg of messages) {
58
- const req = msg;
59
- if (!req.id || !req.tool)
60
- continue;
61
- queue.push(req);
62
- }
56
+ for (const msg of messages)
57
+ queue.push(msg);
63
58
  drainQueue();
64
59
  });
65
- socket.on("error", () => { });
60
+ // If this socket owned an in-progress QR login, abort it so globalLock
61
+ // releases and tool calls from other clients aren't blocked for minutes.
62
+ socket.on("close", () => {
63
+ if (activeLogin && activeLogin.socket === socket) {
64
+ activeLogin.abort.abort();
65
+ }
66
+ });
67
+ // Node requires an error listener on sockets. EPIPE/ECONNRESET happen when the peer
68
+ // disappears mid-write; log for diagnostics but don't crash the master.
69
+ socket.on("error", (err) => {
70
+ console.error("[mcp-telegram] IPC socket error:", err.message);
71
+ });
72
+ }
73
+ function send(socket, msg) {
74
+ if (!socket.destroyed)
75
+ socket.write(encodeMessage(msg));
76
+ }
77
+ async function handleToolRequest(socket, req, mcpServer) {
78
+ const tool = mcpServer._registeredTools[req.tool];
79
+ const response = { type: "tool_response", id: req.id };
80
+ if (!tool) {
81
+ response.error = `Unknown tool: ${req.tool}`;
82
+ }
83
+ else {
84
+ const unlock = await globalLock.acquire();
85
+ try {
86
+ response.result = await tool.handler(req.args ?? {}, {});
87
+ }
88
+ catch (err) {
89
+ response.error = err instanceof Error ? err.message : String(err);
90
+ }
91
+ finally {
92
+ unlock();
93
+ }
94
+ }
95
+ send(socket, response);
96
+ }
97
+ async function handleLoginStart(socket, req, telegram) {
98
+ const fail = (error) => send(socket, { type: "login_done", id: req.id, success: false, error });
99
+ if (activeLogin) {
100
+ fail("Another QR login is already in progress");
101
+ return;
102
+ }
103
+ const abort = new AbortController();
104
+ activeLogin = { socket, abort };
105
+ const unlock = await globalLock.acquire();
106
+ try {
107
+ const result = await telegram.startQrLogin(() => { }, (url) => send(socket, { type: "login_qr", id: req.id, url }), abort.signal);
108
+ if (result.success) {
109
+ const me = await telegram.getMe();
110
+ send(socket, { type: "login_done", id: req.id, success: true, username: me.username ?? undefined });
111
+ }
112
+ else {
113
+ fail(result.message);
114
+ }
115
+ }
116
+ catch (err) {
117
+ fail(err instanceof Error ? err.message : String(err));
118
+ }
119
+ finally {
120
+ activeLogin = null;
121
+ unlock();
122
+ }
66
123
  }
67
124
  export async function runMaster(apiId, apiHash, version) {
68
125
  const telegram = new TelegramService(apiId, apiHash);
@@ -72,8 +129,7 @@ export async function runMaster(apiId, apiHash, version) {
72
129
  // Remove stale socket file from previous crash before attempting to listen (HIGH-2)
73
130
  releaseSocket();
74
131
  const sock = socketPath();
75
- const srv = createServer((socket) => handleClient(socket, mcpServer));
76
- socketServer = srv;
132
+ const srv = createServer((socket) => handleClient(socket, mcpServer, telegram));
77
133
  await new Promise((resolve, reject) => {
78
134
  srv.listen(sock, resolve);
79
135
  srv.once("error", reject);
@@ -84,6 +140,10 @@ export async function runMaster(apiId, apiHash, version) {
84
140
  }
85
141
  catch { }
86
142
  console.error(`[mcp-telegram] Master mode — IPC socket ready: ${sock}`);
143
+ // Parent (Claude Code / MCP client) can close stdio without sending a signal.
144
+ // Without this, the process keeps running as an orphan with a live Telegram connection,
145
+ // blocking auth_key from being reused — causes AUTH_KEY_DUPLICATED on next start.
146
+ process.stdin.on("end", () => process.exit(0));
87
147
  const transport = new StdioServerTransport();
88
148
  await server.connect(transport);
89
149
  console.error("[mcp-telegram] MCP server running on stdio (master)");
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import "dotenv/config";
3
3
  import QRCode from "qrcode";
4
+ import { IpcClient } from "./client.js";
4
5
  import { TelegramService } from "./telegram-client.js";
5
6
  const API_ID = Number(process.env.TELEGRAM_API_ID);
6
7
  const API_HASH = process.env.TELEGRAM_API_HASH;
@@ -8,9 +9,43 @@ if (!API_ID || !API_HASH) {
8
9
  console.error("Set TELEGRAM_API_ID and TELEGRAM_API_HASH in .env file");
9
10
  process.exit(1);
10
11
  }
11
- const telegram = new TelegramService(API_ID, API_HASH);
12
- async function main() {
13
- // Check if already connected
12
+ async function printQr(url) {
13
+ const terminalQr = await QRCode.toString(url, { type: "terminal", small: true });
14
+ console.log(terminalQr);
15
+ console.log("Waiting for scan...\n");
16
+ }
17
+ function printLoginHeader(viaDaemon) {
18
+ console.log(`\nStarting Telegram QR login${viaDaemon ? " (via running daemon)" : ""}...\n`);
19
+ console.log("Scan the QR code in Telegram app:");
20
+ console.log(" Settings > Devices > Link Desktop Device\n");
21
+ }
22
+ async function ipcLogin() {
23
+ const ipc = new IpcClient();
24
+ const connected = await ipc.connect();
25
+ if (!connected)
26
+ return false;
27
+ printLoginHeader(true);
28
+ try {
29
+ const result = await ipc.loginFlow((url) => {
30
+ printQr(url).catch((err) => {
31
+ console.error(`[mcp-telegram] Failed to render QR: ${err instanceof Error ? err.message : String(err)}`);
32
+ });
33
+ });
34
+ if (result.success) {
35
+ console.log(`Login successful!`);
36
+ console.log(` Account: @${result.username ?? "unknown"}\n`);
37
+ }
38
+ else {
39
+ console.log(`Error: ${result.error ?? "unknown"}\n`);
40
+ }
41
+ }
42
+ finally {
43
+ ipc.destroy();
44
+ }
45
+ return true;
46
+ }
47
+ async function standaloneLogin() {
48
+ const telegram = new TelegramService(API_ID, API_HASH);
14
49
  await telegram.loadSession();
15
50
  if (await telegram.connect()) {
16
51
  const me = await telegram.getMe();
@@ -18,13 +53,9 @@ async function main() {
18
53
  await telegram.disconnect();
19
54
  return;
20
55
  }
21
- console.log("\nStarting Telegram QR login...\n");
22
- console.log("Scan the QR code in Telegram app:");
23
- console.log(" Settings > Devices > Link Desktop Device\n");
56
+ printLoginHeader(false);
24
57
  const result = await telegram.startQrLogin(() => { }, async (url) => {
25
- const terminalQr = await QRCode.toString(url, { type: "terminal", small: true });
26
- console.log(terminalQr);
27
- console.log("Waiting for scan...\n");
58
+ await printQr(url);
28
59
  });
29
60
  if (result.success) {
30
61
  console.log("Login successful!");
@@ -36,4 +67,9 @@ async function main() {
36
67
  }
37
68
  await telegram.disconnect();
38
69
  }
70
+ async function main() {
71
+ const viaIpc = await ipcLogin();
72
+ if (!viaIpc)
73
+ await standaloneLogin();
74
+ }
39
75
  main().catch(console.error);
@@ -38,7 +38,7 @@ export declare class TelegramService {
38
38
  */
39
39
  logOut(): Promise<boolean>;
40
40
  isConnected(): boolean;
41
- startQrLogin(onQrDataUrl: (dataUrl: string) => void, onQrUrl?: (url: string) => void): Promise<{
41
+ startQrLogin(onQrDataUrl: (dataUrl: string) => void, onQrUrl?: (url: string) => void, signal?: AbortSignal): Promise<{
42
42
  success: boolean;
43
43
  message: string;
44
44
  }>;
@@ -60,6 +60,7 @@ export class TelegramService {
60
60
  get sessionDir() {
61
61
  return dirname(this.sessionPath);
62
62
  }
63
+ // ─── Session & Auth ────────────────────────────────────────────────────────
63
64
  getClient() {
64
65
  return this.client;
65
66
  }
@@ -217,7 +218,10 @@ export class TelegramService {
217
218
  isConnected() {
218
219
  return this.connected;
219
220
  }
220
- async startQrLogin(onQrDataUrl, onQrUrl) {
221
+ async startQrLogin(onQrDataUrl, onQrUrl, signal) {
222
+ // Early exit if already aborted — avoids creating a Telegram connection we'd immediately tear down.
223
+ if (signal?.aborted)
224
+ return { success: false, message: "QR login aborted" };
221
225
  const session = new StringSession("");
222
226
  const proxy = resolveProxy();
223
227
  const client = new TelegramClient(session, this.apiId, this.apiHash, {
@@ -226,6 +230,13 @@ export class TelegramService {
226
230
  });
227
231
  try {
228
232
  await client.connect();
233
+ if (signal?.aborted) {
234
+ try {
235
+ await client.destroy();
236
+ }
237
+ catch { }
238
+ return { success: false, message: "QR login aborted" };
239
+ }
229
240
  let loginAccepted = false;
230
241
  let resolved = false;
231
242
  let lastQrUrl = "";
@@ -236,6 +247,8 @@ export class TelegramService {
236
247
  });
237
248
  const maxAttempts = 30; // 5 minutes
238
249
  for (let i = 0; i < maxAttempts && !resolved; i++) {
250
+ if (signal?.aborted)
251
+ break;
239
252
  try {
240
253
  const result = await client.invoke(new Api.auth.ExportLoginToken({
241
254
  apiId: this.apiId,
@@ -276,17 +289,45 @@ export class TelegramService {
276
289
  }
277
290
  }
278
291
  if (!resolved) {
279
- await new Promise((r) => setTimeout(r, loginAccepted ? 1500 : 10000));
292
+ // Abortable sleep wakes immediately when caller cancels
293
+ const waitMs = loginAccepted ? 1500 : 10000;
294
+ await new Promise((resolve) => {
295
+ const timer = setTimeout(() => {
296
+ signal?.removeEventListener("abort", onAbort);
297
+ resolve();
298
+ }, waitMs);
299
+ const onAbort = () => {
300
+ clearTimeout(timer);
301
+ resolve();
302
+ };
303
+ if (signal?.aborted)
304
+ onAbort();
305
+ else
306
+ signal?.addEventListener("abort", onAbort, { once: true });
307
+ });
280
308
  }
281
309
  }
310
+ if (signal?.aborted && !resolved) {
311
+ try {
312
+ await client.destroy();
313
+ }
314
+ catch { }
315
+ return { success: false, message: "QR login aborted" };
316
+ }
282
317
  if (resolved) {
283
318
  const newSession = client.session.save();
284
- // Adopt the QR login client directly instead of destroy+reconnect
285
- // This avoids creating a second Telegram session from DC migration auth keys
319
+ // Persist FIRST, adopt SECOND so if file write fails, in-memory state still
320
+ // matches whatever's on disk; saveSession is try/catch-safe for Docker etc.
321
+ await this.saveSession(newSession);
322
+ // Destroy the previous in-memory client to free its auth_key / socket.
323
+ // Previously left dangling → accumulated orphan Telegram connections per relogin.
324
+ const oldClient = this.client;
286
325
  this.client = client;
287
- this.sessionString = newSession;
288
326
  this.connected = true;
289
- await this.saveSession(newSession);
327
+ this.entityCache.clear();
328
+ if (oldClient) {
329
+ oldClient.destroy().catch(() => { });
330
+ }
290
331
  return { success: true, message: "Telegram login successful" };
291
332
  }
292
333
  await client.destroy();
@@ -300,6 +341,7 @@ export class TelegramService {
300
341
  return { success: false, message: `Login failed: ${err.message}` };
301
342
  }
302
343
  }
344
+ // ─── Messages ──────────────────────────────────────────────────────────────
303
345
  async getMe() {
304
346
  if (!this.client || !this.connected)
305
347
  throw new Error(NOT_CONNECTED_ERROR);
@@ -410,6 +452,7 @@ export class TelegramService {
410
452
  const resolved = await this.resolvePeer(chatId);
411
453
  await this.client.unpinMessage(resolved, messageId);
412
454
  }
455
+ // ─── Dialogs ───────────────────────────────────────────────────────────────
413
456
  async getDialogs(limit = 20, offsetDate, filterType) {
414
457
  if (!this.client || !this.connected)
415
458
  throw new Error(NOT_CONNECTED_ERROR);
@@ -499,6 +542,7 @@ export class TelegramService {
499
542
  };
500
543
  });
501
544
  }
545
+ // ─── Contacts ──────────────────────────────────────────────────────────────
502
546
  async addContact(userId, firstName, lastName, phone) {
503
547
  if (!this.client || !this.connected)
504
548
  throw new Error(NOT_CONNECTED_ERROR);
@@ -522,6 +566,7 @@ export class TelegramService {
522
566
  const peer = await this.client.getInputEntity(chatId);
523
567
  await this.client.invoke(new Api.messages.ReportSpam({ peer }));
524
568
  }
569
+ // ─── Read state ────────────────────────────────────────────────────────────
525
570
  async markAsRead(chatId) {
526
571
  if (!this.client || !this.connected)
527
572
  throw new Error(NOT_CONNECTED_ERROR);
@@ -786,6 +831,7 @@ export class TelegramService {
786
831
  }
787
832
  }, `sendTyping in ${chatId}`);
788
833
  }
834
+ // ─── Chat lookup & info ────────────────────────────────────────────────────
789
835
  /**
790
836
  * Resolve a chat by ID, username, or display name.
791
837
  * Falls back to searching user's dialogs if getEntity() fails.
@@ -1125,6 +1171,7 @@ export class TelegramService {
1125
1171
  })));
1126
1172
  return results;
1127
1173
  }
1174
+ // ─── Search ────────────────────────────────────────────────────────────────
1128
1175
  async getContacts(limit = 50) {
1129
1176
  if (!this.client || !this.connected)
1130
1177
  throw new Error(NOT_CONNECTED_ERROR);
@@ -1302,6 +1349,7 @@ export class TelegramService {
1302
1349
  businessLocation,
1303
1350
  };
1304
1351
  }
1352
+ // ─── Profiles & Media ──────────────────────────────────────────────────────
1305
1353
  async downloadProfilePhoto(entityId, options) {
1306
1354
  if (!this.client || !this.connected)
1307
1355
  throw new Error(NOT_CONNECTED_ERROR);
@@ -1360,6 +1408,7 @@ export class TelegramService {
1360
1408
  }
1361
1409
  return items.length > 0 ? items : undefined;
1362
1410
  }
1411
+ // ─── Reactions ─────────────────────────────────────────────────────────────
1363
1412
  async sendReaction(chatId, messageId, emoji, addToExisting = false) {
1364
1413
  if (!this.client || !this.connected)
1365
1414
  throw new Error(NOT_CONNECTED_ERROR);
@@ -1496,6 +1545,7 @@ export class TelegramService {
1496
1545
  return out;
1497
1546
  }, "getRecentReactions");
1498
1547
  }
1548
+ // ─── Scheduled & Polls ─────────────────────────────────────────────────────
1499
1549
  async sendScheduledMessage(chatId, text, scheduleDate, replyTo, parseMode) {
1500
1550
  if (!this.client || !this.connected)
1501
1551
  throw new Error(NOT_CONNECTED_ERROR);
@@ -1614,6 +1664,7 @@ export class TelegramService {
1614
1664
  catch { }
1615
1665
  return false;
1616
1666
  }
1667
+ // ─── Chat membership & management ──────────────────────────────────────────
1617
1668
  async joinChat(target) {
1618
1669
  if (!this.client)
1619
1670
  throw new Error(NOT_CONNECTED_ERROR);
@@ -1895,7 +1946,7 @@ export class TelegramService {
1895
1946
  rank: "",
1896
1947
  }));
1897
1948
  }
1898
- // ── New tools: feature parity ──────────────────────────────────────
1949
+ // ─── Chat settings & moderation ────────────────────────────────────────────
1899
1950
  async unblockUser(userId) {
1900
1951
  if (!this.client || !this.connected)
1901
1952
  throw new Error(NOT_CONNECTED_ERROR);
@@ -2133,6 +2184,7 @@ export class TelegramService {
2133
2184
  await this.client?.invoke(new Api.messages.HideChatJoinRequest({ peer: entity, userId: inputUser, approved }));
2134
2185
  }, `approveChatJoinRequest ${chatId}/${userId}`);
2135
2186
  }
2187
+ // ─── Inline bots & buttons ─────────────────────────────────────────────────
2136
2188
  async getInlineBotResults(bot, chatId, query, offset) {
2137
2189
  if (!this.client || !this.connected)
2138
2190
  throw new Error(NOT_CONNECTED_ERROR);
@@ -2305,7 +2357,7 @@ export class TelegramService {
2305
2357
  result = response;
2306
2358
  }
2307
2359
  catch (e) {
2308
- const msg = e.message ?? String(e);
2360
+ const msg = e instanceof Error ? e.message : String(e);
2309
2361
  if (/CHAT_ADMIN_REQUIRED|ADMIN_RANK_INVALID/i.test(msg)) {
2310
2362
  throw new Error("Access denied: channel stats require admin rights (and may require Telegram Premium)");
2311
2363
  }
@@ -2337,7 +2389,7 @@ export class TelegramService {
2337
2389
  result = response;
2338
2390
  }
2339
2391
  catch (e) {
2340
- const msg = e.message ?? String(e);
2392
+ const msg = e instanceof Error ? e.message : String(e);
2341
2393
  if (/CHAT_ADMIN_REQUIRED|ADMIN_RANK_INVALID/i.test(msg)) {
2342
2394
  throw new Error("Access denied: supergroup stats require admin rights");
2343
2395
  }
@@ -2349,6 +2401,7 @@ export class TelegramService {
2349
2401
  return summarizeMegagroupStats(result, options?.includeGraphs === true);
2350
2402
  }, `getMegagroupStats ${chatId}`, { throwOnFloodWait: true });
2351
2403
  }
2404
+ // ─── Stats & updates ───────────────────────────────────────────────────────
2352
2405
  async getUpdatesState() {
2353
2406
  if (!this.client || !this.connected)
2354
2407
  throw new Error(NOT_CONNECTED_ERROR);
@@ -2404,6 +2457,7 @@ export class TelegramService {
2404
2457
  return summarizeChannelDifference(diff, entity.id.toString(), cursor.pts);
2405
2458
  }, `getChannelUpdates ${chatId}`);
2406
2459
  }
2460
+ // ─── Forum topics ──────────────────────────────────────────────────────────
2407
2461
  async createForumTopic(chatId, title, iconColor, iconEmojiId) {
2408
2462
  if (!this.client || !this.connected)
2409
2463
  throw new Error(NOT_CONNECTED_ERROR);
@@ -2477,6 +2531,7 @@ export class TelegramService {
2477
2531
  }));
2478
2532
  }, `deleteForumTopic ${chatId}/${topicId}`);
2479
2533
  }
2534
+ // ─── Invite links & folders ────────────────────────────────────────────────
2480
2535
  async exportInviteLink(chatId, options) {
2481
2536
  if (!this.client || !this.connected)
2482
2537
  throw new Error(NOT_CONNECTED_ERROR);
@@ -2552,6 +2607,7 @@ export class TelegramService {
2552
2607
  const peer = await this.client.getInputEntity(resolved);
2553
2608
  await this.client.invoke(new Api.messages.SetHistoryTTL({ peer, period }));
2554
2609
  }
2610
+ // ─── Account & privacy ─────────────────────────────────────────────────────
2555
2611
  async getActiveSessions() {
2556
2612
  if (!this.client || !this.connected)
2557
2613
  throw new Error(NOT_CONNECTED_ERROR);
@@ -2655,7 +2711,7 @@ export class TelegramService {
2655
2711
  throw new Error(NOT_CONNECTED_ERROR);
2656
2712
  await this.client.invoke(new Api.account.UpdateUsername({ username }));
2657
2713
  }
2658
- // ─── Stickers ──────────────────────────────────────────────
2714
+ // ─── Stickers ──────────────────────────────────────────────────────────────
2659
2715
  async getStickerSet(shortName) {
2660
2716
  if (!this.client || !this.connected)
2661
2717
  throw new Error(NOT_CONNECTED_ERROR);
@@ -2748,6 +2804,7 @@ export class TelegramService {
2748
2804
  });
2749
2805
  }, `sendSticker to ${chatId}`);
2750
2806
  }
2807
+ // ─── Drafts & saved dialogs ────────────────────────────────────────────────
2751
2808
  async saveDraft(chatId, text, replyTo) {
2752
2809
  if (!this.client || !this.connected)
2753
2810
  throw new Error(NOT_CONNECTED_ERROR);
@@ -2943,6 +3000,7 @@ export class TelegramService {
2943
3000
  emoji: emojiMap.get(doc.id.toString()) || "",
2944
3001
  }));
2945
3002
  }
3003
+ // ─── Stories ───────────────────────────────────────────────────────────────
2946
3004
  async getAllStories(options) {
2947
3005
  if (!this.client || !this.connected)
2948
3006
  throw new Error(NOT_CONNECTED_ERROR);
@@ -2999,6 +3057,7 @@ export class TelegramService {
2999
3057
  return summarizeStoryViewsList(response);
3000
3058
  }, `getStoryViewsList ${chatId}/${options.id}`);
3001
3059
  }
3060
+ // ─── Boosts ────────────────────────────────────────────────────────────────
3002
3061
  async getMyBoosts() {
3003
3062
  if (!this.client || !this.connected)
3004
3063
  throw new Error(NOT_CONNECTED_ERROR);
@@ -3046,6 +3105,7 @@ export class TelegramService {
3046
3105
  return summarizeBusinessChatLinks(response);
3047
3106
  }, "getBusinessChatLinks");
3048
3107
  }
3108
+ // ─── Group calls ───────────────────────────────────────────────────────────
3049
3109
  async getGroupCall(chatId, options = {}) {
3050
3110
  if (!this.client || !this.connected)
3051
3111
  throw new Error(NOT_CONNECTED_ERROR);
@@ -3078,6 +3138,7 @@ export class TelegramService {
3078
3138
  return summarizeGroupCallParticipants(response);
3079
3139
  }, `getGroupCallParticipants ${chatId}`);
3080
3140
  }
3141
+ // ─── Stars ─────────────────────────────────────────────────────────────────
3081
3142
  async getStarsStatus(chatId) {
3082
3143
  if (!this.client || !this.connected)
3083
3144
  throw new Error(NOT_CONNECTED_ERROR);
@@ -3108,6 +3169,7 @@ export class TelegramService {
3108
3169
  return summarizeStarsStatus(response);
3109
3170
  }, `getStarsTransactions ${chatId}`);
3110
3171
  }
3172
+ // ─── Quick replies ─────────────────────────────────────────────────────────
3111
3173
  async getQuickReplies(hash) {
3112
3174
  if (!this.client || !this.connected)
3113
3175
  throw new Error(NOT_CONNECTED_ERROR);
@@ -109,12 +109,12 @@ function compactGraph(g) {
109
109
  }
110
110
  return { type: "data", data: parsed, zoomToken: g.zoomToken };
111
111
  }
112
- const any = g;
113
- if (typeof any.token === "string")
114
- return { type: "async", token: any.token };
115
- if (typeof any.error === "string")
116
- return { type: "error", error: any.error };
117
- return { type: "data", data: any.json?.data, zoomToken: any.zoomToken };
112
+ const graph = g;
113
+ if (typeof graph.token === "string")
114
+ return { type: "async", token: graph.token };
115
+ if (typeof graph.error === "string")
116
+ return { type: "error", error: graph.error };
117
+ return { type: "data", data: graph.json?.data, zoomToken: graph.zoomToken };
118
118
  }
119
119
  export function summarizeMegagroupStats(stats, includeGraphs) {
120
120
  const summary = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@overpod/mcp-telegram",
3
- "version": "1.27.0",
3
+ "version": "1.28.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",
@@ -27,8 +27,9 @@
27
27
  "lint": "biome check src/",
28
28
  "lint:fix": "biome check --fix src/",
29
29
  "format": "biome format --write src/",
30
- "test": "tsx --test src/**/*.test.ts",
31
- "test:watch": "tsx --test --watch src/**/*.test.ts",
30
+ "test": "tsx --test 'src/**/*.test.ts'",
31
+ "test:watch": "tsx --test --watch 'src/**/*.test.ts'",
32
+ "test:coverage": "c8 --all --src src --exclude 'src/**/*.test.ts' --reporter=text tsx --test 'src/**/*.test.ts'",
32
33
  "docs:dev": "vitepress dev docs",
33
34
  "docs:build": "vitepress build docs",
34
35
  "docs:preview": "vitepress preview docs"
@@ -65,6 +66,7 @@
65
66
  "@biomejs/biome": "^2.4.12",
66
67
  "@types/node": "^25.6.0",
67
68
  "@types/qrcode": "^1.5.6",
69
+ "c8": "^11.0.0",
68
70
  "tsx": "^4.21.0",
69
71
  "typescript": "^6.0.3",
70
72
  "vitepress": "^1.6.4"