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,611 @@
1
+ /**
2
+ * Multi-Session Telegram Manager for the Daemon
3
+ *
4
+ * Unlike the per-session TelegramManager, this one handles all sessions
5
+ * with proper routing based on session IDs.
6
+ */
7
+
8
+ import TelegramBot from 'node-telegram-bot-api';
9
+ import type { SessionManager, ConnectedSession } from './session-manager.js';
10
+
11
+ export interface PermissionDecision {
12
+ behavior: 'allow' | 'deny';
13
+ message?: string;
14
+ }
15
+
16
+ export interface HookEvent {
17
+ type: 'permission_request' | 'stop' | 'notification' | 'session_start' | 'session_end';
18
+ session_id?: string;
19
+ tool_name?: string;
20
+ tool_input?: Record<string, unknown>;
21
+ message?: string;
22
+ timestamp?: string;
23
+ }
24
+
25
+ interface PendingResponse {
26
+ sessionId: string;
27
+ resolve: (response: string) => void;
28
+ reject: (error: Error) => void;
29
+ messageId: number;
30
+ timestamp: number;
31
+ }
32
+
33
+ interface PendingPermission {
34
+ sessionId: string;
35
+ resolve: (decision: PermissionDecision) => void;
36
+ reject: (error: Error) => void;
37
+ messageId: number;
38
+ timestamp: number;
39
+ toolName: string;
40
+ }
41
+
42
+ export interface MultiTelegramConfig {
43
+ botToken: string;
44
+ chatId: number;
45
+ responseTimeoutMs?: number;
46
+ permissionTimeoutMs?: number;
47
+ }
48
+
49
+ export class MultiTelegramManager {
50
+ private bot: TelegramBot;
51
+ private config: MultiTelegramConfig;
52
+ private sessionManager: SessionManager;
53
+ private pendingResponses: Map<string, PendingResponse> = new Map();
54
+ private pendingPermissions: Map<string, PendingPermission> = new Map();
55
+ private messageToSession: Map<number, string> = new Map(); // messageId -> sessionId
56
+ private isRunning = false;
57
+
58
+ constructor(config: MultiTelegramConfig, sessionManager: SessionManager) {
59
+ this.config = {
60
+ responseTimeoutMs: 600000,
61
+ permissionTimeoutMs: 600000,
62
+ ...config,
63
+ };
64
+ this.sessionManager = sessionManager;
65
+ this.bot = new TelegramBot(config.botToken, { polling: true });
66
+
67
+ this.setupMessageHandler();
68
+ this.setupCallbackHandler();
69
+ }
70
+
71
+ start(): void {
72
+ this.isRunning = true;
73
+ console.error('[MultiTelegram] Bot started');
74
+ }
75
+
76
+ stop(): void {
77
+ this.isRunning = false;
78
+ this.bot.stopPolling();
79
+ console.error('[MultiTelegram] Bot stopped');
80
+ }
81
+
82
+ /**
83
+ * Send a message for a specific session and wait for response
84
+ */
85
+ async sendMessageAndWait(
86
+ sessionId: string,
87
+ message: string
88
+ ): Promise<{ chatId: string; response: string }> {
89
+ const session = this.sessionManager.get(sessionId);
90
+ if (!session) {
91
+ throw new Error(`Session not found: ${sessionId}`);
92
+ }
93
+
94
+ const taggedMessage = `[${session.sessionName}] ${message}`;
95
+ const sent = await this.bot.sendMessage(this.config.chatId, taggedMessage);
96
+
97
+ // Track message ownership
98
+ this.messageToSession.set(sent.message_id, sessionId);
99
+ this.sessionManager.touch(sessionId);
100
+
101
+ // Wait for response
102
+ const response = await this.waitForResponse(sessionId, sent.message_id);
103
+
104
+ return {
105
+ chatId: `${session.sessionName}:${this.config.chatId}`,
106
+ response,
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Continue a chat for a specific session
112
+ */
113
+ async continueChat(sessionId: string, message: string): Promise<string> {
114
+ const session = this.sessionManager.get(sessionId);
115
+ if (!session) {
116
+ throw new Error(`Session not found: ${sessionId}`);
117
+ }
118
+
119
+ const taggedMessage = `[${session.sessionName}] ${message}`;
120
+ const sent = await this.bot.sendMessage(this.config.chatId, taggedMessage);
121
+
122
+ this.messageToSession.set(sent.message_id, sessionId);
123
+ this.sessionManager.touch(sessionId);
124
+
125
+ return this.waitForResponse(sessionId, sent.message_id);
126
+ }
127
+
128
+ /**
129
+ * Send a notification without waiting
130
+ */
131
+ async notify(sessionId: string, message: string): Promise<void> {
132
+ const session = this.sessionManager.get(sessionId);
133
+ if (!session) {
134
+ throw new Error(`Session not found: ${sessionId}`);
135
+ }
136
+
137
+ const taggedMessage = `[${session.sessionName}] ${message}`;
138
+ const sent = await this.bot.sendMessage(this.config.chatId, taggedMessage);
139
+ this.messageToSession.set(sent.message_id, sessionId);
140
+ }
141
+
142
+ /**
143
+ * End a chat session
144
+ */
145
+ async endChat(sessionId: string, message?: string): Promise<void> {
146
+ const session = this.sessionManager.get(sessionId);
147
+ if (!session) {
148
+ return;
149
+ }
150
+
151
+ if (message) {
152
+ const taggedMessage = `[${session.sessionName}] ${message}`;
153
+ await this.bot.sendMessage(this.config.chatId, taggedMessage);
154
+ }
155
+
156
+ // Clean up pending responses for this session
157
+ for (const [key, pending] of this.pendingResponses) {
158
+ if (pending.sessionId === sessionId) {
159
+ pending.reject(new Error('Chat ended'));
160
+ this.pendingResponses.delete(key);
161
+ }
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Handle permission request for a session (called by hook)
167
+ */
168
+ async handlePermissionRequest(
169
+ sessionName: string,
170
+ toolName: string,
171
+ toolInput: Record<string, unknown>
172
+ ): Promise<PermissionDecision> {
173
+ const session = this.sessionManager.getByName(sessionName);
174
+ const displayName = session?.sessionName || sessionName;
175
+
176
+ const inputDisplay = this.formatToolInput(toolName, toolInput);
177
+ const message = `🔐 [${displayName}] Permission Request\n\nTool: ${toolName}\n${inputDisplay}`;
178
+
179
+ const sent = await this.bot.sendMessage(this.config.chatId, message);
180
+
181
+ if (session) {
182
+ this.messageToSession.set(sent.message_id, session.sessionId);
183
+ }
184
+
185
+ await this.bot.editMessageReplyMarkup(
186
+ {
187
+ inline_keyboard: [
188
+ [
189
+ { text: '✅ Allow', callback_data: `allow:${sent.message_id}` },
190
+ { text: '❌ Deny', callback_data: `deny:${sent.message_id}` },
191
+ ],
192
+ ],
193
+ },
194
+ { chat_id: this.config.chatId, message_id: sent.message_id }
195
+ );
196
+
197
+ // Wait with reminders
198
+ const reminderIntervalMs = 120000;
199
+ const maxReminders = 4;
200
+
201
+ for (let attempt = 0; attempt <= maxReminders; attempt++) {
202
+ try {
203
+ return await this.waitForPermissionWithTimeout(
204
+ sent.message_id,
205
+ toolName,
206
+ session?.sessionId || 'hook',
207
+ reminderIntervalMs
208
+ );
209
+ } catch {
210
+ if (attempt < maxReminders) {
211
+ await this.bot.sendMessage(
212
+ this.config.chatId,
213
+ `⏰ [${displayName}] Reminder: Still waiting for permission\n\nTool: ${toolName}`,
214
+ { reply_to_message_id: sent.message_id }
215
+ );
216
+ }
217
+ }
218
+ }
219
+
220
+ // Final timeout message with buttons
221
+ const retryMsg = await this.bot.sendMessage(
222
+ this.config.chatId,
223
+ `⚠️ [${displayName}] Permission request timed out\n\nTool: ${toolName}\n\nClick below to respond:`
224
+ );
225
+
226
+ await this.bot.editMessageReplyMarkup(
227
+ {
228
+ inline_keyboard: [
229
+ [
230
+ { text: '✅ Allow Now', callback_data: `allow:${sent.message_id}` },
231
+ { text: '❌ Deny', callback_data: `deny:${sent.message_id}` },
232
+ ],
233
+ ],
234
+ },
235
+ { chat_id: this.config.chatId, message_id: retryMsg.message_id }
236
+ );
237
+
238
+ try {
239
+ return await this.waitForPermissionWithTimeout(
240
+ sent.message_id,
241
+ toolName,
242
+ session?.sessionId || 'hook',
243
+ this.config.permissionTimeoutMs!
244
+ );
245
+ } catch {
246
+ return { behavior: 'deny', message: 'Permission request timed out' };
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Handle interactive stop for a session (called by hook)
252
+ */
253
+ async handleInteractiveStop(
254
+ sessionName: string,
255
+ transcriptPath?: string
256
+ ): Promise<Record<string, unknown>> {
257
+ const session = this.sessionManager.getByName(sessionName);
258
+ const displayName = session?.sessionName || sessionName;
259
+
260
+ let lastMessage = 'Claude has finished working.';
261
+ if (transcriptPath) {
262
+ lastMessage = await this.extractLastMessage(transcriptPath);
263
+ }
264
+
265
+ const message = `🏁 [${displayName}] Claude stopped\n\n${lastMessage}\n\n💬 Reply with instructions to continue, or "done" to finish.`;
266
+ const sent = await this.bot.sendMessage(this.config.chatId, message);
267
+
268
+ if (session) {
269
+ this.messageToSession.set(sent.message_id, session.sessionId);
270
+ }
271
+
272
+ const reminderIntervalMs = 120000;
273
+ const maxReminders = 4;
274
+
275
+ for (let attempt = 0; attempt <= maxReminders; attempt++) {
276
+ try {
277
+ const response = await this.waitForResponseWithTimeout(
278
+ session?.sessionId || 'hook',
279
+ sent.message_id,
280
+ reminderIntervalMs
281
+ );
282
+
283
+ const lower = response.toLowerCase().trim();
284
+ if (['done', 'stop', 'finish', 'ok'].includes(lower)) {
285
+ return {};
286
+ }
287
+
288
+ return { decision: 'block', reason: response };
289
+ } catch {
290
+ if (attempt < maxReminders) {
291
+ await this.bot.sendMessage(
292
+ this.config.chatId,
293
+ `⏰ [${displayName}] Reminder: Claude is waiting for your response`,
294
+ { reply_to_message_id: sent.message_id }
295
+ );
296
+ }
297
+ }
298
+ }
299
+
300
+ // Final attempt
301
+ try {
302
+ await this.bot.sendMessage(
303
+ this.config.chatId,
304
+ `⚠️ [${displayName}] Last chance! Claude will stop soon.\n\n💬 Reply now to continue.`,
305
+ { reply_to_message_id: sent.message_id }
306
+ );
307
+
308
+ const response = await this.waitForResponseWithTimeout(
309
+ session?.sessionId || 'hook',
310
+ sent.message_id,
311
+ this.config.responseTimeoutMs!
312
+ );
313
+
314
+ const lower = response.toLowerCase().trim();
315
+ if (['done', 'stop', 'finish', 'ok'].includes(lower)) {
316
+ return {};
317
+ }
318
+
319
+ return { decision: 'block', reason: response };
320
+ } catch {
321
+ await this.bot.sendMessage(
322
+ this.config.chatId,
323
+ `😴 [${displayName}] Claude stopped (no response received)`
324
+ );
325
+ return {};
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Send hook notification
331
+ */
332
+ async sendHookNotification(sessionName: string, event: HookEvent): Promise<void> {
333
+ let emoji = '📢';
334
+ let title = 'Notification';
335
+
336
+ switch (event.type) {
337
+ case 'stop':
338
+ emoji = '🛑';
339
+ title = 'Agent Stopped';
340
+ break;
341
+ case 'session_start':
342
+ emoji = '🚀';
343
+ title = 'Session Started';
344
+ break;
345
+ case 'session_end':
346
+ emoji = '👋';
347
+ title = 'Session Ended';
348
+ break;
349
+ }
350
+
351
+ const message = `${emoji} [${sessionName}] ${title}\n\n${event.message || 'No details'}`;
352
+ await this.bot.sendMessage(this.config.chatId, message);
353
+ }
354
+
355
+ private async extractLastMessage(transcriptPath: string): Promise<string> {
356
+ try {
357
+ const fs = await import('fs');
358
+ if (!fs.existsSync(transcriptPath)) {
359
+ return 'Claude has finished working.';
360
+ }
361
+
362
+ const content = fs.readFileSync(transcriptPath, 'utf-8');
363
+ const lines = content.trim().split('\n');
364
+
365
+ for (let i = lines.length - 1; i >= 0; i--) {
366
+ try {
367
+ const entry = JSON.parse(lines[i]);
368
+ if (entry.type === 'assistant' && entry.message?.content) {
369
+ const textContent = entry.message.content.find((c: any) => c.type === 'text');
370
+ if (textContent?.text) {
371
+ return this.truncateMiddle(textContent.text, 600);
372
+ }
373
+ }
374
+ } catch {
375
+ // Skip invalid lines
376
+ }
377
+ }
378
+ } catch (err) {
379
+ console.error('[MultiTelegram] Error reading transcript:', err);
380
+ }
381
+
382
+ return 'Claude has finished working.';
383
+ }
384
+
385
+ private truncateMiddle(text: string, maxLength: number): string {
386
+ if (text.length <= maxLength) return text;
387
+
388
+ const startLength = Math.floor(maxLength * 0.3);
389
+ const endLength = Math.floor(maxLength * 0.6);
390
+
391
+ return `${text.slice(0, startLength)}\n\n[...truncated...]\n\n${text.slice(-endLength)}`;
392
+ }
393
+
394
+ private formatToolInput(toolName: string, input: Record<string, unknown>): string {
395
+ if (toolName === 'Bash' && input.command) {
396
+ return `Command: \`${input.command}\``;
397
+ }
398
+ if (toolName === 'Write' && input.file_path) {
399
+ const content = input.content as string;
400
+ const preview = content?.length > 100 ? content.slice(0, 100) + '...' : content;
401
+ return `File: ${input.file_path}\nContent: ${preview || '(empty)'}`;
402
+ }
403
+ if (toolName === 'Edit' && input.file_path) {
404
+ return `File: ${input.file_path}\nOld: ${input.old_string}\nNew: ${input.new_string}`;
405
+ }
406
+ if (toolName === 'Read' && input.file_path) {
407
+ return `File: ${input.file_path}`;
408
+ }
409
+
410
+ try {
411
+ const str = JSON.stringify(input, null, 2);
412
+ return str.length > 500 ? str.slice(0, 500) + '...' : str;
413
+ } catch {
414
+ return '(unable to display input)';
415
+ }
416
+ }
417
+
418
+ private waitForResponse(sessionId: string, messageId: number): Promise<string> {
419
+ return this.waitForResponseWithTimeout(sessionId, messageId, this.config.responseTimeoutMs!);
420
+ }
421
+
422
+ private waitForResponseWithTimeout(
423
+ sessionId: string,
424
+ messageId: number,
425
+ timeoutMs: number
426
+ ): Promise<string> {
427
+ return new Promise((resolve, reject) => {
428
+ const key = `${messageId}`;
429
+
430
+ const existing = this.pendingResponses.get(key);
431
+ if (existing) {
432
+ existing.resolve = resolve;
433
+ existing.reject = reject;
434
+ return;
435
+ }
436
+
437
+ const timeout = setTimeout(() => {
438
+ this.pendingResponses.delete(key);
439
+ reject(new Error('Timeout'));
440
+ }, timeoutMs);
441
+
442
+ this.pendingResponses.set(key, {
443
+ sessionId,
444
+ resolve: (response: string) => {
445
+ clearTimeout(timeout);
446
+ this.pendingResponses.delete(key);
447
+ resolve(response);
448
+ },
449
+ reject: (error: Error) => {
450
+ clearTimeout(timeout);
451
+ this.pendingResponses.delete(key);
452
+ reject(error);
453
+ },
454
+ messageId,
455
+ timestamp: Date.now(),
456
+ });
457
+ });
458
+ }
459
+
460
+ private waitForPermissionWithTimeout(
461
+ messageId: number,
462
+ toolName: string,
463
+ sessionId: string,
464
+ timeoutMs: number
465
+ ): Promise<PermissionDecision> {
466
+ return new Promise((resolve, reject) => {
467
+ const key = `${messageId}`;
468
+
469
+ const existing = this.pendingPermissions.get(key);
470
+ if (existing) {
471
+ existing.resolve = resolve;
472
+ existing.reject = reject;
473
+ return;
474
+ }
475
+
476
+ const timeout = setTimeout(() => {
477
+ this.pendingPermissions.delete(key);
478
+ reject(new Error('Timeout'));
479
+ }, timeoutMs);
480
+
481
+ this.pendingPermissions.set(key, {
482
+ sessionId,
483
+ resolve: (decision: PermissionDecision) => {
484
+ clearTimeout(timeout);
485
+ this.pendingPermissions.delete(key);
486
+ resolve(decision);
487
+ },
488
+ reject: (error: Error) => {
489
+ clearTimeout(timeout);
490
+ this.pendingPermissions.delete(key);
491
+ reject(error);
492
+ },
493
+ messageId,
494
+ timestamp: Date.now(),
495
+ toolName,
496
+ });
497
+ });
498
+ }
499
+
500
+ private setupMessageHandler(): void {
501
+ this.bot.on('message', (msg) => {
502
+ if (msg.chat.id !== this.config.chatId) return;
503
+ if (msg.from?.is_bot) return;
504
+
505
+ const text = msg.text || '';
506
+ console.error(`[MultiTelegram] Received: "${text}"`);
507
+
508
+ // Check if reply to known message
509
+ if (msg.reply_to_message) {
510
+ const replyToId = msg.reply_to_message.message_id;
511
+ const sessionId = this.messageToSession.get(replyToId);
512
+
513
+ if (sessionId) {
514
+ console.error(`[MultiTelegram] Is reply to session ${sessionId}`);
515
+ this.resolveResponseForSession(sessionId, text);
516
+ return;
517
+ }
518
+ }
519
+
520
+ // Check for @sessionName prefix
521
+ for (const session of this.sessionManager.getAll()) {
522
+ const prefix = `@${session.sessionName}`;
523
+ if (text.toLowerCase().startsWith(prefix.toLowerCase())) {
524
+ const response = text.slice(prefix.length).trim();
525
+ this.resolveResponseForSession(session.sessionId, response);
526
+ return;
527
+ }
528
+ }
529
+
530
+ // Route to most recently active waiting session
531
+ const mostRecent = this.findMostRecentWaitingSession();
532
+ if (mostRecent) {
533
+ console.error(`[MultiTelegram] Routing to most recent: ${mostRecent}`);
534
+ this.resolveResponseForSession(mostRecent, text);
535
+ }
536
+ });
537
+
538
+ this.bot.on('polling_error', (error) => {
539
+ console.error('[MultiTelegram] Polling error:', error.message);
540
+ });
541
+ }
542
+
543
+ private setupCallbackHandler(): void {
544
+ this.bot.on('callback_query', async (query) => {
545
+ if (!query.data || !query.message) return;
546
+
547
+ const [action, messageId] = query.data.split(':');
548
+ const key = messageId;
549
+
550
+ const pending = this.pendingPermissions.get(key);
551
+ if (!pending) {
552
+ await this.bot.answerCallbackQuery(query.id, { text: 'Request expired' });
553
+ return;
554
+ }
555
+
556
+ const decision: PermissionDecision = {
557
+ behavior: action === 'allow' ? 'allow' : 'deny',
558
+ message: action === 'deny' ? 'Denied by user via Telegram' : undefined,
559
+ };
560
+
561
+ const statusEmoji = action === 'allow' ? '✅' : '❌';
562
+ const statusText = action === 'allow' ? 'Allowed' : 'Denied';
563
+
564
+ try {
565
+ await this.bot.editMessageReplyMarkup(
566
+ { inline_keyboard: [] },
567
+ { chat_id: query.message.chat.id, message_id: query.message.message_id }
568
+ );
569
+ await this.bot.editMessageText(
570
+ `${query.message.text}\n\n${statusEmoji} ${statusText}`,
571
+ { chat_id: query.message.chat.id, message_id: query.message.message_id }
572
+ );
573
+ } catch {
574
+ // Ignore edit errors
575
+ }
576
+
577
+ await this.bot.answerCallbackQuery(query.id, { text: `${statusText}!` });
578
+ pending.resolve(decision);
579
+ });
580
+ }
581
+
582
+ private resolveResponseForSession(sessionId: string, text: string): void {
583
+ // Find pending response for this session
584
+ for (const [key, pending] of this.pendingResponses) {
585
+ if (pending.sessionId === sessionId) {
586
+ pending.resolve(text);
587
+ return;
588
+ }
589
+ }
590
+
591
+ // Also check hook-based pending responses
592
+ for (const [key, pending] of this.pendingResponses) {
593
+ if (pending.sessionId === 'hook') {
594
+ pending.resolve(text);
595
+ return;
596
+ }
597
+ }
598
+ }
599
+
600
+ private findMostRecentWaitingSession(): string | null {
601
+ let mostRecent: PendingResponse | null = null;
602
+
603
+ for (const pending of this.pendingResponses.values()) {
604
+ if (!mostRecent || pending.timestamp > mostRecent.timestamp) {
605
+ mostRecent = pending;
606
+ }
607
+ }
608
+
609
+ return mostRecent?.sessionId || null;
610
+ }
611
+ }