@overpod/mcp-telegram 1.27.1 → 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,22 +1,36 @@
1
1
  import { type Socket } from "node:net";
2
+ import { type IpcLoginDone } from "./ipc-protocol.js";
2
3
  export interface IpcClientOptions {
3
4
  connectTimeoutMs?: number;
4
5
  callTimeoutMs?: number;
6
+ loginTimeoutMs?: number;
5
7
  connectFn?: (path: string) => Socket;
6
8
  }
7
9
  /** Thin IPC proxy: forwards tool calls to the master process over Unix socket */
8
10
  export declare class IpcClient {
9
11
  private socket;
10
12
  private pending;
13
+ private pendingLogins;
11
14
  private buf;
12
15
  private connected;
16
+ private destroyed;
17
+ private onDisconnect?;
13
18
  private readonly connectTimeoutMs;
14
19
  private readonly callTimeoutMs;
20
+ private readonly loginTimeoutMs;
15
21
  private readonly connectFn;
16
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;
17
26
  connect(): Promise<boolean>;
27
+ private routeMessage;
18
28
  isConnected(): boolean;
19
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>;
20
34
  destroy(): void;
21
35
  }
22
36
  export declare function runClient(apiId: number, apiHash: string, version: string): Promise<void>;
package/dist/client.js CHANGED
@@ -2,27 +2,38 @@ 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;
18
22
  connectTimeoutMs;
19
23
  callTimeoutMs;
24
+ loginTimeoutMs;
20
25
  connectFn;
21
26
  constructor(opts = {}) {
22
27
  this.connectTimeoutMs = opts.connectTimeoutMs ?? CONNECT_TIMEOUT_MS;
23
28
  this.callTimeoutMs = opts.callTimeoutMs ?? IPC_CALL_TIMEOUT_MS;
29
+ this.loginTimeoutMs = opts.loginTimeoutMs ?? LOGIN_FLOW_TIMEOUT_MS;
24
30
  this.connectFn = opts.connectFn ?? connect;
25
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
+ }
26
37
  async connect() {
27
38
  return new Promise((resolve) => {
28
39
  const sock = socketPath();
@@ -41,20 +52,8 @@ export class IpcClient {
41
52
  this.buf += chunk.toString("utf-8");
42
53
  const { messages, remaining } = parseMessages(this.buf);
43
54
  this.buf = remaining;
44
- for (const msg of messages) {
45
- const res = msg;
46
- const pending = this.pending.get(res.id);
47
- if (!pending)
48
- continue;
49
- clearTimeout(pending.timer);
50
- this.pending.delete(res.id);
51
- if (res.error) {
52
- pending.reject(new Error(res.error));
53
- }
54
- else {
55
- pending.resolve(res.result);
56
- }
57
- }
55
+ for (const msg of messages)
56
+ this.routeMessage(msg);
58
57
  });
59
58
  s.on("close", () => {
60
59
  this.connected = false;
@@ -63,7 +62,17 @@ export class IpcClient {
63
62
  p.reject(new Error("IPC connection closed"));
64
63
  }
65
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?.();
66
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.
67
76
  s.on("error", () => { });
68
77
  resolve(true);
69
78
  };
@@ -75,6 +84,35 @@ export class IpcClient {
75
84
  s.once("error", onError);
76
85
  });
77
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
+ }
78
116
  isConnected() {
79
117
  return this.connected;
80
118
  }
@@ -90,15 +128,39 @@ export class IpcClient {
90
128
  reject(new Error(`IPC call timeout: ${tool}`));
91
129
  }, this.callTimeoutMs);
92
130
  this.pending.set(id, { resolve, reject, timer });
93
- 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 }));
94
150
  });
95
151
  }
96
152
  destroy() {
153
+ this.destroyed = true;
97
154
  for (const [, p] of this.pending) {
98
155
  clearTimeout(p.timer);
99
156
  p.reject(new Error("IPC client destroyed"));
100
157
  }
101
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();
102
164
  this.socket?.destroy();
103
165
  this.socket = null;
104
166
  this.connected = false;
@@ -132,12 +194,20 @@ export async function runClient(apiId, apiHash, version) {
132
194
  process.exit(1);
133
195
  }
134
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
+ });
135
203
  // Register all tools for MCP schema; dummy telegram instance is never used for actual calls
136
204
  const telegram = new TelegramService(apiId, apiHash);
137
205
  const server = new McpServer({ name: "mcp-telegram", version });
138
206
  registerTools(server, telegram);
139
207
  // Replace all handlers with IPC-forwarding versions
140
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));
141
211
  const transport = new StdioServerTransport();
142
212
  await server.connect(transport);
143
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
+ }
@@ -5,22 +5,44 @@ export type McpRegisteredTool = {
5
5
  export interface McpServerInternal {
6
6
  _registeredTools: Record<string, McpRegisteredTool>;
7
7
  }
8
- /** Request from Client → Master */
9
- export interface IpcRequest {
8
+ /** Client → Master: invoke MCP tool */
9
+ export interface IpcToolRequest {
10
+ type: "tool";
10
11
  id: string;
11
12
  tool: string;
12
13
  args: Record<string, unknown>;
13
14
  }
14
- /** Response from Master → Client */
15
- export interface IpcResponse {
15
+ /** Master → Client: tool result */
16
+ export interface IpcToolResponse {
17
+ type: "tool_response";
16
18
  id: string;
17
19
  result?: unknown;
18
20
  error?: string;
19
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;
20
42
  /** Encode a message as newline-delimited JSON */
21
- export declare function encodeMessage(msg: IpcRequest | IpcResponse): string;
43
+ export declare function encodeMessage(msg: IpcMessage): string;
22
44
  /** Parse newline-delimited JSON messages from a buffer, returns parsed messages + leftover */
23
45
  export declare function parseMessages(buf: string): {
24
- messages: (IpcRequest | IpcResponse)[];
46
+ messages: IpcMessage[];
25
47
  remaining: string;
26
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,4 +1,5 @@
1
1
  import { type Socket } from "node:net";
2
2
  import { type McpServerInternal } from "./ipc-protocol.js";
3
- export declare function handleClient(socket: Socket, mcpServer: McpServerInternal): void;
3
+ import { TelegramService } from "./telegram-client.js";
4
+ export declare function handleClient(socket: Socket, mcpServer: McpServerInternal, telegram: TelegramService): void;
4
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 { GlobalLock } from "./global-lock.js";
4
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
- export 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 @@ export 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
  }>;
@@ -218,7 +218,10 @@ export class TelegramService {
218
218
  isConnected() {
219
219
  return this.connected;
220
220
  }
221
- 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" };
222
225
  const session = new StringSession("");
223
226
  const proxy = resolveProxy();
224
227
  const client = new TelegramClient(session, this.apiId, this.apiHash, {
@@ -227,6 +230,13 @@ export class TelegramService {
227
230
  });
228
231
  try {
229
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
+ }
230
240
  let loginAccepted = false;
231
241
  let resolved = false;
232
242
  let lastQrUrl = "";
@@ -237,6 +247,8 @@ export class TelegramService {
237
247
  });
238
248
  const maxAttempts = 30; // 5 minutes
239
249
  for (let i = 0; i < maxAttempts && !resolved; i++) {
250
+ if (signal?.aborted)
251
+ break;
240
252
  try {
241
253
  const result = await client.invoke(new Api.auth.ExportLoginToken({
242
254
  apiId: this.apiId,
@@ -277,17 +289,45 @@ export class TelegramService {
277
289
  }
278
290
  }
279
291
  if (!resolved) {
280
- 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
+ });
281
308
  }
282
309
  }
310
+ if (signal?.aborted && !resolved) {
311
+ try {
312
+ await client.destroy();
313
+ }
314
+ catch { }
315
+ return { success: false, message: "QR login aborted" };
316
+ }
283
317
  if (resolved) {
284
318
  const newSession = client.session.save();
285
- // Adopt the QR login client directly instead of destroy+reconnect
286
- // 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;
287
325
  this.client = client;
288
- this.sessionString = newSession;
289
326
  this.connected = true;
290
- await this.saveSession(newSession);
327
+ this.entityCache.clear();
328
+ if (oldClient) {
329
+ oldClient.destroy().catch(() => { });
330
+ }
291
331
  return { success: true, message: "Telegram login successful" };
292
332
  }
293
333
  await client.destroy();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@overpod/mcp-telegram",
3
- "version": "1.27.1",
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",