@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 +14 -0
- package/dist/client.js +86 -16
- package/dist/global-lock.d.ts +10 -0
- package/dist/global-lock.js +29 -0
- package/dist/ipc-protocol.d.ts +28 -6
- package/dist/ipc-protocol.js +12 -1
- package/dist/master.d.ts +2 -1
- package/dist/master.js +89 -29
- package/dist/qr-login-cli.js +45 -9
- package/dist/telegram-client.d.ts +1 -1
- package/dist/telegram-client.js +46 -6
- package/package.json +1 -1
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
|
-
|
|
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
|
+
}
|
package/dist/ipc-protocol.d.ts
CHANGED
|
@@ -5,22 +5,44 @@ export type McpRegisteredTool = {
|
|
|
5
5
|
export interface McpServerInternal {
|
|
6
6
|
_registeredTools: Record<string, McpRegisteredTool>;
|
|
7
7
|
}
|
|
8
|
-
/**
|
|
9
|
-
export interface
|
|
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
|
-
/**
|
|
15
|
-
export interface
|
|
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:
|
|
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:
|
|
46
|
+
messages: IpcMessage[];
|
|
25
47
|
remaining: string;
|
|
26
48
|
};
|
package/dist/ipc-protocol.js
CHANGED
|
@@ -12,7 +12,9 @@ export function parseMessages(buf) {
|
|
|
12
12
|
if (!trimmed)
|
|
13
13
|
continue;
|
|
14
14
|
try {
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
32
|
-
if (!
|
|
39
|
+
const msg = queue.shift();
|
|
40
|
+
if (!msg)
|
|
33
41
|
break;
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)");
|
package/dist/qr-login-cli.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}>;
|
package/dist/telegram-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
286
|
-
//
|
|
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
|
-
|
|
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