telegram-claude-mcp 1.6.0 → 2.0.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.
@@ -0,0 +1,415 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * Telegram Claude Daemon
5
+ *
6
+ * Singleton daemon that handles all Telegram communication for multiple
7
+ * Claude Code sessions. Proxies connect via Unix socket.
8
+ */
9
+
10
+ import * as net from 'net';
11
+ import { createServer, type IncomingMessage, type ServerResponse } from 'http';
12
+ import { existsSync, unlinkSync, writeFileSync, mkdirSync } from 'fs';
13
+ import { dirname } from 'path';
14
+ import { SessionManager } from './session-manager.js';
15
+ import { MultiTelegramManager, type HookEvent } from './telegram-multi.js';
16
+ import {
17
+ DAEMON_SOCKET_PATH,
18
+ DAEMON_PID_FILE,
19
+ DAEMON_HTTP_PORT,
20
+ serializeMessage,
21
+ parseMessages,
22
+ type ProxyMessage,
23
+ type DaemonMessage,
24
+ type DaemonConnectedMessage,
25
+ type DaemonToolResultMessage,
26
+ type DaemonErrorMessage,
27
+ type DaemonPongMessage,
28
+ } from '../shared/protocol.js';
29
+
30
+ const VERSION = '2.0.0';
31
+
32
+ interface DaemonConfig {
33
+ telegramBotToken: string;
34
+ telegramChatId: number;
35
+ responseTimeoutMs: number;
36
+ permissionTimeoutMs: number;
37
+ }
38
+
39
+ function loadConfig(): DaemonConfig {
40
+ const botToken = process.env.TELEGRAM_BOT_TOKEN;
41
+ const chatId = process.env.TELEGRAM_CHAT_ID;
42
+
43
+ if (!botToken) {
44
+ console.error('Error: TELEGRAM_BOT_TOKEN is required');
45
+ process.exit(1);
46
+ }
47
+
48
+ if (!chatId) {
49
+ console.error('Error: TELEGRAM_CHAT_ID is required');
50
+ process.exit(1);
51
+ }
52
+
53
+ return {
54
+ telegramBotToken: botToken,
55
+ telegramChatId: parseInt(chatId, 10),
56
+ responseTimeoutMs: parseInt(process.env.CHAT_RESPONSE_TIMEOUT_MS || '600000', 10),
57
+ permissionTimeoutMs: parseInt(process.env.PERMISSION_TIMEOUT_MS || '600000', 10),
58
+ };
59
+ }
60
+
61
+ async function main() {
62
+ const config = loadConfig();
63
+
64
+ console.error('');
65
+ console.error('Telegram Claude Daemon v' + VERSION);
66
+ console.error('================================');
67
+
68
+ // Clean up stale socket
69
+ if (existsSync(DAEMON_SOCKET_PATH)) {
70
+ console.error('[Daemon] Removing stale socket');
71
+ unlinkSync(DAEMON_SOCKET_PATH);
72
+ }
73
+
74
+ // Ensure socket directory exists
75
+ const socketDir = dirname(DAEMON_SOCKET_PATH);
76
+ if (!existsSync(socketDir)) {
77
+ mkdirSync(socketDir, { recursive: true });
78
+ }
79
+
80
+ // Write PID file
81
+ writeFileSync(DAEMON_PID_FILE, process.pid.toString());
82
+
83
+ // Create session manager
84
+ const sessionManager = new SessionManager();
85
+
86
+ // Create multi-session Telegram manager
87
+ const telegram = new MultiTelegramManager(
88
+ {
89
+ botToken: config.telegramBotToken,
90
+ chatId: config.telegramChatId,
91
+ responseTimeoutMs: config.responseTimeoutMs,
92
+ permissionTimeoutMs: config.permissionTimeoutMs,
93
+ },
94
+ sessionManager
95
+ );
96
+
97
+ telegram.start();
98
+
99
+ // Track socket buffers for each connection
100
+ const socketBuffers = new Map<net.Socket, string>();
101
+
102
+ // Create Unix socket server for proxy connections
103
+ const ipcServer = net.createServer((socket) => {
104
+ console.error('[Daemon] New proxy connection');
105
+ socketBuffers.set(socket, '');
106
+
107
+ socket.on('data', async (data) => {
108
+ let buffer = socketBuffers.get(socket) || '';
109
+ buffer += data.toString();
110
+
111
+ const { messages, remainder } = parseMessages<ProxyMessage>(buffer);
112
+ socketBuffers.set(socket, remainder);
113
+
114
+ for (const msg of messages) {
115
+ await handleProxyMessage(socket, msg, sessionManager, telegram);
116
+ }
117
+ });
118
+
119
+ socket.on('close', () => {
120
+ const session = sessionManager.findBySocket(socket);
121
+ if (session) {
122
+ console.error(`[Daemon] Proxy disconnected: ${session.sessionId}`);
123
+ sessionManager.unregister(session.sessionId);
124
+ }
125
+ socketBuffers.delete(socket);
126
+ });
127
+
128
+ socket.on('error', (err) => {
129
+ console.error('[Daemon] Socket error:', err.message);
130
+ });
131
+ });
132
+
133
+ ipcServer.listen(DAEMON_SOCKET_PATH, () => {
134
+ console.error(`[Daemon] IPC server listening on ${DAEMON_SOCKET_PATH}`);
135
+ });
136
+
137
+ // Create HTTP server for hooks
138
+ const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
139
+ res.setHeader('Access-Control-Allow-Origin', '*');
140
+ res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
141
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
142
+
143
+ if (req.method === 'OPTIONS') {
144
+ res.writeHead(200);
145
+ res.end();
146
+ return;
147
+ }
148
+
149
+ if (req.method !== 'POST') {
150
+ res.writeHead(405, { 'Content-Type': 'application/json' });
151
+ res.end(JSON.stringify({ error: 'Method not allowed' }));
152
+ return;
153
+ }
154
+
155
+ let body = '';
156
+ for await (const chunk of req) {
157
+ body += chunk;
158
+ }
159
+
160
+ try {
161
+ const data = JSON.parse(body);
162
+ const url = req.url || '/';
163
+
164
+ // Get session name from header or data
165
+ const sessionName = req.headers['x-session-name'] as string || data.session_name || 'default';
166
+
167
+ if (url === '/permission' || url === '/hooks/permission') {
168
+ const { tool_name, tool_input } = data;
169
+
170
+ if (!tool_name) {
171
+ res.writeHead(400, { 'Content-Type': 'application/json' });
172
+ res.end(JSON.stringify({ error: 'Missing tool_name' }));
173
+ return;
174
+ }
175
+
176
+ console.error(`[HTTP] Permission request: ${tool_name} (session: ${sessionName})`);
177
+
178
+ const decision = await telegram.handlePermissionRequest(
179
+ sessionName,
180
+ tool_name,
181
+ tool_input || {}
182
+ );
183
+
184
+ res.writeHead(200, { 'Content-Type': 'application/json' });
185
+ res.end(JSON.stringify({
186
+ hookSpecificOutput: {
187
+ hookEventName: 'PermissionRequest',
188
+ decision: {
189
+ behavior: decision.behavior,
190
+ ...(decision.message && { message: decision.message }),
191
+ },
192
+ },
193
+ }));
194
+ return;
195
+ }
196
+
197
+ if (url === '/notify' || url === '/hooks/notify') {
198
+ const event: HookEvent = {
199
+ type: data.type || 'notification',
200
+ message: data.message,
201
+ session_id: data.session_id,
202
+ tool_name: data.tool_name,
203
+ tool_input: data.tool_input,
204
+ timestamp: data.timestamp || new Date().toISOString(),
205
+ };
206
+
207
+ console.error(`[HTTP] Notification: ${event.type} (session: ${sessionName})`);
208
+ await telegram.sendHookNotification(sessionName, event);
209
+
210
+ res.writeHead(200, { 'Content-Type': 'application/json' });
211
+ res.end(JSON.stringify({ success: true }));
212
+ return;
213
+ }
214
+
215
+ if (url === '/stop' || url === '/hooks/stop') {
216
+ const { transcript_path } = data;
217
+
218
+ console.error(`[HTTP] Interactive stop (session: ${sessionName})`);
219
+ const result = await telegram.handleInteractiveStop(sessionName, transcript_path);
220
+
221
+ res.writeHead(200, { 'Content-Type': 'application/json' });
222
+ res.end(JSON.stringify(result));
223
+ return;
224
+ }
225
+
226
+ // Status endpoint
227
+ if (url === '/status') {
228
+ res.writeHead(200, { 'Content-Type': 'application/json' });
229
+ res.end(JSON.stringify({
230
+ version: VERSION,
231
+ activeSessions: sessionManager.count(),
232
+ sessions: sessionManager.getAll().map(s => ({
233
+ sessionId: s.sessionId,
234
+ sessionName: s.sessionName,
235
+ connectedAt: s.connectedAt,
236
+ lastActivity: s.lastActivity,
237
+ })),
238
+ }));
239
+ return;
240
+ }
241
+
242
+ res.writeHead(404, { 'Content-Type': 'application/json' });
243
+ res.end(JSON.stringify({ error: 'Not found' }));
244
+ } catch (error) {
245
+ console.error('[HTTP] Error:', error);
246
+ res.writeHead(500, { 'Content-Type': 'application/json' });
247
+ res.end(JSON.stringify({ error: 'Internal server error' }));
248
+ }
249
+ });
250
+
251
+ httpServer.listen(DAEMON_HTTP_PORT, () => {
252
+ console.error(`[Daemon] HTTP hook server on port ${DAEMON_HTTP_PORT}`);
253
+ });
254
+
255
+ console.error('');
256
+ console.error('Daemon ready!');
257
+ console.error(` IPC: ${DAEMON_SOCKET_PATH}`);
258
+ console.error(` HTTP: http://localhost:${DAEMON_HTTP_PORT}`);
259
+ console.error(` PID: ${process.pid}`);
260
+ console.error('');
261
+
262
+ // Graceful shutdown
263
+ const shutdown = async () => {
264
+ console.error('\n[Daemon] Shutting down...');
265
+
266
+ // Close all proxy connections
267
+ for (const session of sessionManager.getAll()) {
268
+ session.socket.end();
269
+ }
270
+
271
+ ipcServer.close();
272
+ httpServer.close();
273
+ telegram.stop();
274
+
275
+ // Clean up files
276
+ try {
277
+ if (existsSync(DAEMON_SOCKET_PATH)) unlinkSync(DAEMON_SOCKET_PATH);
278
+ if (existsSync(DAEMON_PID_FILE)) unlinkSync(DAEMON_PID_FILE);
279
+ } catch {}
280
+
281
+ process.exit(0);
282
+ };
283
+
284
+ process.on('SIGINT', shutdown);
285
+ process.on('SIGTERM', shutdown);
286
+
287
+ // Periodic cleanup of dead sessions
288
+ setInterval(() => {
289
+ sessionManager.cleanup();
290
+ }, 30000);
291
+ }
292
+
293
+ /**
294
+ * Handle a message from a proxy
295
+ */
296
+ async function handleProxyMessage(
297
+ socket: net.Socket,
298
+ msg: ProxyMessage,
299
+ sessionManager: SessionManager,
300
+ telegram: MultiTelegramManager
301
+ ): Promise<void> {
302
+ console.error(`[Daemon] Received: ${msg.type} from ${msg.sessionId}`);
303
+
304
+ switch (msg.type) {
305
+ case 'connect': {
306
+ sessionManager.register(msg.sessionId, msg.sessionName, socket, msg.projectPath);
307
+
308
+ const response: DaemonConnectedMessage = {
309
+ type: 'connected',
310
+ sessionId: msg.sessionId,
311
+ daemonVersion: VERSION,
312
+ };
313
+ socket.write(serializeMessage(response));
314
+ break;
315
+ }
316
+
317
+ case 'disconnect': {
318
+ sessionManager.unregister(msg.sessionId);
319
+ break;
320
+ }
321
+
322
+ case 'ping': {
323
+ const response: DaemonPongMessage = {
324
+ type: 'pong',
325
+ sessionId: msg.sessionId,
326
+ activeSessions: sessionManager.count(),
327
+ };
328
+ socket.write(serializeMessage(response));
329
+ break;
330
+ }
331
+
332
+ case 'tool_call': {
333
+ const { requestId, tool, arguments: args } = msg;
334
+ sessionManager.touch(msg.sessionId);
335
+
336
+ try {
337
+ let result: { content: Array<{ type: string; text: string }>; isError?: boolean };
338
+
339
+ switch (tool) {
340
+ case 'send_message': {
341
+ const { message } = args as { message: string };
342
+ const res = await telegram.sendMessageAndWait(msg.sessionId, message);
343
+ result = {
344
+ content: [
345
+ {
346
+ type: 'text',
347
+ text: `Message sent to Telegram.\n\nChat ID: ${res.chatId}\n\nUser's response:\n${res.response}\n\nUse continue_chat for follow-ups or end_chat to close.`,
348
+ },
349
+ ],
350
+ };
351
+ break;
352
+ }
353
+
354
+ case 'continue_chat': {
355
+ const { message } = args as { chat_id: string; message: string };
356
+ const response = await telegram.continueChat(msg.sessionId, message);
357
+ result = {
358
+ content: [{ type: 'text', text: `User's response:\n${response}` }],
359
+ };
360
+ break;
361
+ }
362
+
363
+ case 'notify_user': {
364
+ const { message } = args as { chat_id: string; message: string };
365
+ await telegram.notify(msg.sessionId, message);
366
+ result = {
367
+ content: [{ type: 'text', text: `Notification sent: "${message}"` }],
368
+ };
369
+ break;
370
+ }
371
+
372
+ case 'end_chat': {
373
+ const { message } = args as { chat_id: string; message?: string };
374
+ await telegram.endChat(msg.sessionId, message);
375
+ result = {
376
+ content: [{ type: 'text', text: 'Chat session ended.' }],
377
+ };
378
+ break;
379
+ }
380
+
381
+ default:
382
+ throw new Error(`Unknown tool: ${tool}`);
383
+ }
384
+
385
+ const response: DaemonToolResultMessage = {
386
+ type: 'tool_result',
387
+ sessionId: msg.sessionId,
388
+ requestId,
389
+ success: true,
390
+ result,
391
+ };
392
+ socket.write(serializeMessage(response));
393
+ } catch (error) {
394
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
395
+ const response: DaemonToolResultMessage = {
396
+ type: 'tool_result',
397
+ sessionId: msg.sessionId,
398
+ requestId,
399
+ success: false,
400
+ result: {
401
+ content: [{ type: 'text', text: `Error: ${errorMessage}` }],
402
+ isError: true,
403
+ },
404
+ };
405
+ socket.write(serializeMessage(response));
406
+ }
407
+ break;
408
+ }
409
+ }
410
+ }
411
+
412
+ main().catch((error) => {
413
+ console.error('Fatal error:', error);
414
+ process.exit(1);
415
+ });
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Session Manager for the Telegram Claude Daemon
3
+ *
4
+ * Tracks all connected Claude Code sessions and their state.
5
+ */
6
+
7
+ import type { Socket } from 'net';
8
+ import type { SessionInfo } from '../shared/protocol.js';
9
+
10
+ export interface ConnectedSession extends SessionInfo {
11
+ socket: Socket;
12
+ }
13
+
14
+ export class SessionManager {
15
+ private sessions: Map<string, ConnectedSession> = new Map();
16
+
17
+ /**
18
+ * Register a new session
19
+ */
20
+ register(
21
+ sessionId: string,
22
+ sessionName: string,
23
+ socket: Socket,
24
+ projectPath?: string
25
+ ): ConnectedSession {
26
+ const session: ConnectedSession = {
27
+ sessionId,
28
+ sessionName,
29
+ projectPath,
30
+ socket,
31
+ connectedAt: new Date(),
32
+ lastActivity: new Date(),
33
+ activeChats: new Set(),
34
+ };
35
+
36
+ this.sessions.set(sessionId, session);
37
+ console.error(`[SessionManager] Registered session: ${sessionId} (${sessionName})`);
38
+
39
+ return session;
40
+ }
41
+
42
+ /**
43
+ * Unregister a session
44
+ */
45
+ unregister(sessionId: string): boolean {
46
+ const session = this.sessions.get(sessionId);
47
+ if (session) {
48
+ this.sessions.delete(sessionId);
49
+ console.error(`[SessionManager] Unregistered session: ${sessionId}`);
50
+ return true;
51
+ }
52
+ return false;
53
+ }
54
+
55
+ /**
56
+ * Get session by ID
57
+ */
58
+ get(sessionId: string): ConnectedSession | undefined {
59
+ return this.sessions.get(sessionId);
60
+ }
61
+
62
+ /**
63
+ * Get session by name (returns first match)
64
+ */
65
+ getByName(sessionName: string): ConnectedSession | undefined {
66
+ for (const session of this.sessions.values()) {
67
+ if (session.sessionName === sessionName) {
68
+ return session;
69
+ }
70
+ }
71
+ return undefined;
72
+ }
73
+
74
+ /**
75
+ * Get all sessions
76
+ */
77
+ getAll(): ConnectedSession[] {
78
+ return Array.from(this.sessions.values());
79
+ }
80
+
81
+ /**
82
+ * Get count of active sessions
83
+ */
84
+ count(): number {
85
+ return this.sessions.size;
86
+ }
87
+
88
+ /**
89
+ * Update last activity timestamp
90
+ */
91
+ touch(sessionId: string): void {
92
+ const session = this.sessions.get(sessionId);
93
+ if (session) {
94
+ session.lastActivity = new Date();
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Add active chat to session
100
+ */
101
+ addChat(sessionId: string, chatId: string): void {
102
+ const session = this.sessions.get(sessionId);
103
+ if (session) {
104
+ session.activeChats.add(chatId);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Remove active chat from session
110
+ */
111
+ removeChat(sessionId: string, chatId: string): void {
112
+ const session = this.sessions.get(sessionId);
113
+ if (session) {
114
+ session.activeChats.delete(chatId);
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Find session by socket
120
+ */
121
+ findBySocket(socket: Socket): ConnectedSession | undefined {
122
+ for (const session of this.sessions.values()) {
123
+ if (session.socket === socket) {
124
+ return session;
125
+ }
126
+ }
127
+ return undefined;
128
+ }
129
+
130
+ /**
131
+ * Get most recently active session
132
+ */
133
+ getMostRecentActive(): ConnectedSession | undefined {
134
+ let mostRecent: ConnectedSession | undefined;
135
+ let latestTime = 0;
136
+
137
+ for (const session of this.sessions.values()) {
138
+ const time = session.lastActivity.getTime();
139
+ if (time > latestTime) {
140
+ latestTime = time;
141
+ mostRecent = session;
142
+ }
143
+ }
144
+
145
+ return mostRecent;
146
+ }
147
+
148
+ /**
149
+ * Find session that should receive a hook event
150
+ * Priority: explicit session name > most recently active
151
+ */
152
+ findForHook(sessionName?: string): ConnectedSession | undefined {
153
+ if (sessionName) {
154
+ return this.getByName(sessionName);
155
+ }
156
+ return this.getMostRecentActive();
157
+ }
158
+
159
+ /**
160
+ * Clean up disconnected sessions
161
+ */
162
+ cleanup(): number {
163
+ let cleaned = 0;
164
+ for (const [sessionId, session] of this.sessions.entries()) {
165
+ if (session.socket.destroyed) {
166
+ this.sessions.delete(sessionId);
167
+ cleaned++;
168
+ console.error(`[SessionManager] Cleaned up dead session: ${sessionId}`);
169
+ }
170
+ }
171
+ return cleaned;
172
+ }
173
+ }