@overpod/mcp-telegram 1.27.1 → 1.28.1

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
@@ -316,7 +316,7 @@ All tools are auto-discoverable via MCP — your AI client will see the full lis
316
316
 
317
317
  | Category | Tools |
318
318
  |----------|-------|
319
- | **Auth** | `telegram-status`, `telegram-login` |
319
+ | **Auth** | `telegram-status`, `telegram-login`, `telegram-logout` |
320
320
  | **Messaging** | `telegram-send-message`, `telegram-edit-message`, `telegram-delete-message`, `telegram-forward-message`, `telegram-send-scheduled`, `telegram-send-typing`, `telegram-translate-message`, `telegram-get-message-link` |
321
321
  | **Scheduled** | `telegram-get-scheduled`, `telegram-delete-scheduled` |
322
322
  | **Reading** | `telegram-list-chats`, `telegram-read-messages`, `telegram-search-messages`, `telegram-search-global`, `telegram-search-chats`, `telegram-get-unread`, `telegram-mark-as-read`, `telegram-get-replies`, `telegram-get-unread-mentions`, `telegram-get-unread-reactions`, `telegram-get-saved-dialogs` |
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,78 @@ 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
+ // telegram-logout must cancel an in-progress QR login instead of queueing behind it
85
+ // for up to 5 minutes. Aborting releases the globalLock held by handleLoginStart.
86
+ if (req.tool === "telegram-logout" && activeLogin) {
87
+ activeLogin.abort.abort();
88
+ }
89
+ const unlock = await globalLock.acquire();
90
+ try {
91
+ response.result = await tool.handler(req.args ?? {}, {});
92
+ }
93
+ catch (err) {
94
+ response.error = err instanceof Error ? err.message : String(err);
95
+ }
96
+ finally {
97
+ unlock();
98
+ }
99
+ }
100
+ send(socket, response);
101
+ }
102
+ async function handleLoginStart(socket, req, telegram) {
103
+ const fail = (error) => send(socket, { type: "login_done", id: req.id, success: false, error });
104
+ if (activeLogin) {
105
+ fail("Another QR login is already in progress");
106
+ return;
107
+ }
108
+ const abort = new AbortController();
109
+ activeLogin = { socket, abort };
110
+ const unlock = await globalLock.acquire();
111
+ try {
112
+ const result = await telegram.startQrLogin(() => { }, (url) => send(socket, { type: "login_qr", id: req.id, url }), abort.signal);
113
+ if (result.success) {
114
+ const me = await telegram.getMe();
115
+ send(socket, { type: "login_done", id: req.id, success: true, username: me.username ?? undefined });
116
+ }
117
+ else {
118
+ fail(result.message);
119
+ }
120
+ }
121
+ catch (err) {
122
+ fail(err instanceof Error ? err.message : String(err));
123
+ }
124
+ finally {
125
+ activeLogin = null;
126
+ unlock();
127
+ }
66
128
  }
67
129
  export async function runMaster(apiId, apiHash, version) {
68
130
  const telegram = new TelegramService(apiId, apiHash);
@@ -72,8 +134,7 @@ export async function runMaster(apiId, apiHash, version) {
72
134
  // Remove stale socket file from previous crash before attempting to listen (HIGH-2)
73
135
  releaseSocket();
74
136
  const sock = socketPath();
75
- const srv = createServer((socket) => handleClient(socket, mcpServer));
76
- socketServer = srv;
137
+ const srv = createServer((socket) => handleClient(socket, mcpServer, telegram));
77
138
  await new Promise((resolve, reject) => {
78
139
  srv.listen(sock, resolve);
79
140
  srv.once("error", reject);
@@ -84,6 +145,10 @@ export async function runMaster(apiId, apiHash, version) {
84
145
  }
85
146
  catch { }
86
147
  console.error(`[mcp-telegram] Master mode — IPC socket ready: ${sock}`);
148
+ // Parent (Claude Code / MCP client) can close stdio without sending a signal.
149
+ // Without this, the process keeps running as an orphan with a live Telegram connection,
150
+ // blocking auth_key from being reused — causes AUTH_KEY_DUPLICATED on next start.
151
+ process.stdin.on("end", () => process.exit(0));
87
152
  const transport = new StdioServerTransport();
88
153
  await server.connect(transport);
89
154
  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);
@@ -16,6 +16,7 @@ export declare class TelegramService {
16
16
  private entityCache;
17
17
  lastError: string;
18
18
  get sessionDir(): string;
19
+ hasLocalSession(): boolean;
19
20
  getClient(): TelegramClient | null;
20
21
  constructor(apiId: number, apiHash: string, options?: {
21
22
  sessionPath?: string;
@@ -33,12 +34,15 @@ export declare class TelegramService {
33
34
  ensureConnected(): Promise<boolean>;
34
35
  disconnect(): Promise<void>;
35
36
  /**
36
- * Log out from Telegram completely terminates the session on Telegram servers.
37
- * After this, the session string becomes invalid and won't appear in "Active Sessions".
37
+ * Terminates the session on Telegram servers, destroys the client, and clears
38
+ * local session (in-memory + file). Returns true only when server-side revoke
39
+ * confirmed. False means server revoke could not be confirmed — local wipe
40
+ * was still attempted. Throws if local file removal failed so callers can
41
+ * surface the partial state instead of silently misreporting success.
38
42
  */
39
43
  logOut(): Promise<boolean>;
40
44
  isConnected(): boolean;
41
- startQrLogin(onQrDataUrl: (dataUrl: string) => void, onQrUrl?: (url: string) => void): Promise<{
45
+ startQrLogin(onQrDataUrl: (dataUrl: string) => void, onQrUrl?: (url: string) => void, signal?: AbortSignal): Promise<{
42
46
  success: boolean;
43
47
  message: string;
44
48
  }>;
@@ -60,6 +60,9 @@ export class TelegramService {
60
60
  get sessionDir() {
61
61
  return dirname(this.sessionPath);
62
62
  }
63
+ hasLocalSession() {
64
+ return existsSync(this.sessionPath);
65
+ }
63
66
  // ─── Session & Auth ────────────────────────────────────────────────────────
64
67
  getClient() {
65
68
  return this.client;
@@ -195,30 +198,50 @@ export class TelegramService {
195
198
  }
196
199
  }
197
200
  /**
198
- * Log out from Telegram completely terminates the session on Telegram servers.
199
- * After this, the session string becomes invalid and won't appear in "Active Sessions".
201
+ * Terminates the session on Telegram servers, destroys the client, and clears
202
+ * local session (in-memory + file). Returns true only when server-side revoke
203
+ * confirmed. False means server revoke could not be confirmed — local wipe
204
+ * was still attempted. Throws if local file removal failed so callers can
205
+ * surface the partial state instead of silently misreporting success.
200
206
  */
201
207
  async logOut() {
202
- if (!this.client || !this.connected)
208
+ const wipeLocalOrThrow = async () => {
209
+ await this.clearSession();
210
+ if (existsSync(this.sessionPath)) {
211
+ throw new Error(`Local session file still present after clearSession: ${this.sessionPath}`);
212
+ }
213
+ };
214
+ if (!this.client || !this.connected) {
215
+ if (existsSync(this.sessionPath))
216
+ await wipeLocalOrThrow();
203
217
  return false;
218
+ }
219
+ const client = this.client;
220
+ let revoked = false;
204
221
  try {
205
- await this.client.invoke(new Api.auth.LogOut());
206
- await this.client.destroy();
207
- this.connected = false;
208
- this.sessionString = "";
209
- this.client = null;
210
- return true;
222
+ await client.invoke(new Api.auth.LogOut());
223
+ revoked = true;
211
224
  }
212
225
  catch (error) {
213
- console.error("[telegram] logOut error:", error);
214
- await this.disconnect();
215
- return false;
226
+ console.error("[telegram] auth.LogOut failed:", error);
227
+ }
228
+ // destroy() failure must NOT mask a successful server revoke — log and continue.
229
+ try {
230
+ await client.destroy();
231
+ }
232
+ catch (err) {
233
+ console.error("[telegram] client.destroy failed during logOut:", err);
216
234
  }
235
+ await wipeLocalOrThrow();
236
+ return revoked;
217
237
  }
218
238
  isConnected() {
219
239
  return this.connected;
220
240
  }
221
- async startQrLogin(onQrDataUrl, onQrUrl) {
241
+ async startQrLogin(onQrDataUrl, onQrUrl, signal) {
242
+ // Early exit if already aborted — avoids creating a Telegram connection we'd immediately tear down.
243
+ if (signal?.aborted)
244
+ return { success: false, message: "QR login aborted" };
222
245
  const session = new StringSession("");
223
246
  const proxy = resolveProxy();
224
247
  const client = new TelegramClient(session, this.apiId, this.apiHash, {
@@ -227,6 +250,13 @@ export class TelegramService {
227
250
  });
228
251
  try {
229
252
  await client.connect();
253
+ if (signal?.aborted) {
254
+ try {
255
+ await client.destroy();
256
+ }
257
+ catch { }
258
+ return { success: false, message: "QR login aborted" };
259
+ }
230
260
  let loginAccepted = false;
231
261
  let resolved = false;
232
262
  let lastQrUrl = "";
@@ -237,6 +267,8 @@ export class TelegramService {
237
267
  });
238
268
  const maxAttempts = 30; // 5 minutes
239
269
  for (let i = 0; i < maxAttempts && !resolved; i++) {
270
+ if (signal?.aborted)
271
+ break;
240
272
  try {
241
273
  const result = await client.invoke(new Api.auth.ExportLoginToken({
242
274
  apiId: this.apiId,
@@ -277,17 +309,45 @@ export class TelegramService {
277
309
  }
278
310
  }
279
311
  if (!resolved) {
280
- await new Promise((r) => setTimeout(r, loginAccepted ? 1500 : 10000));
312
+ // Abortable sleep wakes immediately when caller cancels
313
+ const waitMs = loginAccepted ? 1500 : 10000;
314
+ await new Promise((resolve) => {
315
+ const timer = setTimeout(() => {
316
+ signal?.removeEventListener("abort", onAbort);
317
+ resolve();
318
+ }, waitMs);
319
+ const onAbort = () => {
320
+ clearTimeout(timer);
321
+ resolve();
322
+ };
323
+ if (signal?.aborted)
324
+ onAbort();
325
+ else
326
+ signal?.addEventListener("abort", onAbort, { once: true });
327
+ });
281
328
  }
282
329
  }
330
+ if (signal?.aborted && !resolved) {
331
+ try {
332
+ await client.destroy();
333
+ }
334
+ catch { }
335
+ return { success: false, message: "QR login aborted" };
336
+ }
283
337
  if (resolved) {
284
338
  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
339
+ // Persist FIRST, adopt SECOND so if file write fails, in-memory state still
340
+ // matches whatever's on disk; saveSession is try/catch-safe for Docker etc.
341
+ await this.saveSession(newSession);
342
+ // Destroy the previous in-memory client to free its auth_key / socket.
343
+ // Previously left dangling → accumulated orphan Telegram connections per relogin.
344
+ const oldClient = this.client;
287
345
  this.client = client;
288
- this.sessionString = newSession;
289
346
  this.connected = true;
290
- await this.saveSession(newSession);
347
+ this.entityCache.clear();
348
+ if (oldClient) {
349
+ oldClient.destroy().catch(() => { });
350
+ }
291
351
  return { success: true, message: "Telegram login successful" };
292
352
  }
293
353
  await client.destroy();
@@ -1,6 +1,6 @@
1
1
  import { writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { fail, ok, READ_ONLY, WRITE } from "./shared.js";
3
+ import { DESTRUCTIVE, fail, ok, READ_ONLY, WRITE } from "./shared.js";
4
4
  export function registerAuthTools(server, telegram) {
5
5
  server.registerTool("telegram-status", { description: "Check Telegram connection status", annotations: READ_ONLY }, async () => {
6
6
  if (await telegram.ensureConnected()) {
@@ -65,4 +65,29 @@ export function registerAuthTools(server, telegram) {
65
65
  ],
66
66
  };
67
67
  });
68
+ server.registerTool("telegram-logout", {
69
+ description: "Log out from Telegram completely. Revokes the session on Telegram servers (removes it from Settings → Devices), deletes the local session file, and disconnects. After this you must run telegram-login to re-authenticate.",
70
+ annotations: DESTRUCTIVE,
71
+ }, async () => {
72
+ const wasConnected = await telegram.ensureConnected();
73
+ if (!wasConnected && !telegram.hasLocalSession()) {
74
+ return fail(new Error("Not logged in. Nothing to log out from."));
75
+ }
76
+ try {
77
+ const revoked = await telegram.logOut();
78
+ if (!wasConnected) {
79
+ return ok("Local session removed (was already disconnected). Server-side revoke was not performed.");
80
+ }
81
+ if (revoked) {
82
+ return ok("Logged out. Session revoked on Telegram servers and removed locally.");
83
+ }
84
+ return fail(new Error("Local session removed, but server-side revoke could not be confirmed. Open 'Settings → Devices' in Telegram and terminate the session manually if it is still listed."));
85
+ }
86
+ catch (err) {
87
+ // Local file removal failed (read-only FS, permission denied, etc.).
88
+ // Never claim local cleanup succeeded when the file may still be on disk.
89
+ return fail(new Error(`Failed to remove local session file: ${err instanceof Error ? err.message : String(err)}. ` +
90
+ `Delete it manually (check telegram-status for the path).`));
91
+ }
92
+ });
68
93
  }
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.1",
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",