@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 +1 -1
- 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 +94 -29
- package/dist/qr-login-cli.js +45 -9
- package/dist/telegram-client.d.ts +7 -3
- package/dist/telegram-client.js +78 -18
- package/dist/tools/auth.js +26 -1
- package/package.json +1 -1
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
|
-
|
|
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,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
|
-
|
|
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
|
+
// 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)");
|
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);
|
|
@@ -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
|
-
*
|
|
37
|
-
*
|
|
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
|
}>;
|
package/dist/telegram-client.js
CHANGED
|
@@ -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
|
-
*
|
|
199
|
-
*
|
|
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
|
-
|
|
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
|
|
206
|
-
|
|
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]
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
286
|
-
//
|
|
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
|
-
|
|
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();
|
package/dist/tools/auth.js
CHANGED
|
@@ -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