telegram-claude-mcp 1.4.1 → 1.6.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/README.md CHANGED
@@ -20,6 +20,7 @@ Then:
20
20
 
21
21
  - **Permission buttons** - Allow/Deny tool usage from Telegram
22
22
  - **Interactive stop** - Reply to continue Claude's work after it stops
23
+ - **Auto-retry with reminders** - Get reminders every 2 minutes if you miss a message
23
24
  - **Notifications** - Get notified about Claude events
24
25
  - **Multi-session** - Run multiple Claude instances with message tagging
25
26
 
@@ -225,12 +226,14 @@ Replace `YOUR_BOT_TOKEN` and `YOUR_CHAT_ID` in `~/.claude/settings.json`
225
226
 
226
227
  ## Environment Variables
227
228
 
228
- | Variable | Description | Required |
229
- |----------|-------------|----------|
230
- | `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes |
231
- | `TELEGRAM_CHAT_ID` | Your Telegram chat ID | Yes |
232
- | `SESSION_NAME` | Session identifier (for multi-instance) | No (default: "default") |
233
- | `SESSION_PORT` | Preferred HTTP port | No (default: 3333, auto-finds available) |
229
+ | Variable | Description | Default |
230
+ |----------|-------------|---------|
231
+ | `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Required |
232
+ | `TELEGRAM_CHAT_ID` | Your Telegram chat ID | Required |
233
+ | `SESSION_NAME` | Session identifier (for multi-instance) | "default" |
234
+ | `SESSION_PORT` | Preferred HTTP port (auto-finds if busy) | 3333 |
235
+ | `CHAT_RESPONSE_TIMEOUT_MS` | Timeout for message responses | 600000 (10 min) |
236
+ | `PERMISSION_TIMEOUT_MS` | Timeout for permission decisions | 600000 (10 min) |
234
237
 
235
238
  ## How It Works
236
239
 
package/bin/setup.js CHANGED
@@ -22,23 +22,35 @@ const CLAUDE_DIR = join(homedir(), '.claude');
22
22
  const HOOKS_DIR = join(CLAUDE_DIR, 'hooks');
23
23
  const SETTINGS_FILE = join(CLAUDE_DIR, 'settings.json');
24
24
 
25
- // Hook scripts content
25
+ // Hook scripts content - with SESSION_NAME support for multi-session setups
26
26
  const PERMISSION_HOOK = `#!/bin/bash
27
27
  #
28
28
  # Claude Code Permission Hook - forwards permission requests to Telegram
29
+ # Set SESSION_NAME env var to target a specific session (for multi-session setups)
29
30
  #
30
31
 
31
32
  SESSION_DIR="/tmp/telegram-claude-sessions"
32
33
 
33
- find_active_session() {
34
+ find_session() {
35
+ if [ -n "$SESSION_NAME" ]; then
36
+ local specific_file="$SESSION_DIR/\${SESSION_NAME}.info"
37
+ if [ -f "$specific_file" ]; then
38
+ local pid
39
+ pid=$(jq -r '.pid // empty' "$specific_file" 2>/dev/null)
40
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
41
+ echo "$specific_file"
42
+ return
43
+ fi
44
+ fi
45
+ return
46
+ fi
47
+
34
48
  local latest_file=""
35
49
  local latest_time=0
36
-
37
50
  [ -d "$SESSION_DIR" ] || return
38
51
 
39
52
  for info_file in "$SESSION_DIR"/*.info; do
40
53
  [ -e "$info_file" ] || continue
41
-
42
54
  local pid
43
55
  pid=$(jq -r '.pid // empty' "$info_file" 2>/dev/null)
44
56
  if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
@@ -50,15 +62,11 @@ find_active_session() {
50
62
  fi
51
63
  fi
52
64
  done
53
-
54
65
  echo "$latest_file"
55
66
  }
56
67
 
57
- INFO_FILE=$(find_active_session)
58
-
59
- if [ -z "$INFO_FILE" ] || [ ! -f "$INFO_FILE" ]; then
60
- exit 0
61
- fi
68
+ INFO_FILE=$(find_session)
69
+ [ -z "$INFO_FILE" ] || [ ! -f "$INFO_FILE" ] && exit 0
62
70
 
63
71
  HOOK_PORT=$(jq -r '.port' "$INFO_FILE")
64
72
  HOOK_HOST=$(jq -r '.host // "localhost"' "$INFO_FILE")
@@ -5,25 +5,41 @@
5
5
  # This script receives notification events from Claude Code (stop, session events)
6
6
  # and forwards them to the Telegram MCP server.
7
7
  #
8
- # The script auto-discovers the MCP server port from session info files.
8
+ # Set SESSION_NAME env var to target a specific session (for multi-session setups).
9
+ # Without SESSION_NAME, falls back to the most recently started session.
9
10
  #
10
11
 
11
12
  SESSION_DIR="/tmp/telegram-claude-sessions"
12
13
  EVENT_TYPE="${TELEGRAM_HOOK_EVENT:-stop}"
13
14
 
14
- # Function to find the most recent active session
15
- find_active_session() {
15
+ # Function to find session info file
16
+ # If SESSION_NAME is set, use that specific session
17
+ # Otherwise, find the most recent active session
18
+ find_session() {
19
+ # If SESSION_NAME is set, use specific session
20
+ if [ -n "$SESSION_NAME" ]; then
21
+ local specific_file="$SESSION_DIR/${SESSION_NAME}.info"
22
+ if [ -f "$specific_file" ]; then
23
+ local pid
24
+ pid=$(jq -r '.pid // empty' "$specific_file" 2>/dev/null)
25
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
26
+ echo "$specific_file"
27
+ return
28
+ fi
29
+ fi
30
+ echo "[notify-hook] Session '$SESSION_NAME' not found or not running" >&2
31
+ return
32
+ fi
33
+
34
+ # Fallback: find most recent active session
16
35
  local latest_file=""
17
36
  local latest_time=0
18
37
 
19
- # Check if directory exists
20
38
  [ -d "$SESSION_DIR" ] || return
21
39
 
22
40
  for info_file in "$SESSION_DIR"/*.info; do
23
- # Skip if no matches (glob didn't expand)
24
41
  [ -e "$info_file" ] || continue
25
42
 
26
- # Check if the process is still running
27
43
  local pid
28
44
  pid=$(jq -r '.pid // empty' "$info_file" 2>/dev/null)
29
45
  if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
@@ -39,8 +55,8 @@ find_active_session() {
39
55
  echo "$latest_file"
40
56
  }
41
57
 
42
- # Find active session info
43
- INFO_FILE=$(find_active_session)
58
+ # Find session info
59
+ INFO_FILE=$(find_session)
44
60
 
45
61
  if [ -z "$INFO_FILE" ] || [ ! -f "$INFO_FILE" ]; then
46
62
  echo "[notify-hook] No active session found, skipping notification" >&2
@@ -5,24 +5,40 @@
5
5
  # This script receives permission requests from Claude Code and forwards them
6
6
  # to the Telegram MCP server for interactive approval via Telegram.
7
7
  #
8
- # The script auto-discovers the MCP server port from session info files.
8
+ # Set SESSION_NAME env var to target a specific session (for multi-session setups).
9
+ # Without SESSION_NAME, falls back to the most recently started session.
9
10
  #
10
11
 
11
12
  SESSION_DIR="/tmp/telegram-claude-sessions"
12
13
 
13
- # Function to find the most recent active session
14
- find_active_session() {
14
+ # Function to find session info file
15
+ # If SESSION_NAME is set, use that specific session
16
+ # Otherwise, find the most recent active session
17
+ find_session() {
18
+ # If SESSION_NAME is set, use specific session
19
+ if [ -n "$SESSION_NAME" ]; then
20
+ local specific_file="$SESSION_DIR/${SESSION_NAME}.info"
21
+ if [ -f "$specific_file" ]; then
22
+ local pid
23
+ pid=$(jq -r '.pid // empty' "$specific_file" 2>/dev/null)
24
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
25
+ echo "$specific_file"
26
+ return
27
+ fi
28
+ fi
29
+ echo "[permission-hook] Session '$SESSION_NAME' not found or not running" >&2
30
+ return
31
+ fi
32
+
33
+ # Fallback: find most recent active session
15
34
  local latest_file=""
16
35
  local latest_time=0
17
36
 
18
- # Check if directory exists
19
37
  [ -d "$SESSION_DIR" ] || return
20
38
 
21
39
  for info_file in "$SESSION_DIR"/*.info; do
22
- # Skip if no matches (glob didn't expand)
23
40
  [ -e "$info_file" ] || continue
24
41
 
25
- # Check if the process is still running
26
42
  local pid
27
43
  pid=$(jq -r '.pid // empty' "$info_file" 2>/dev/null)
28
44
  if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
@@ -38,8 +54,8 @@ find_active_session() {
38
54
  echo "$latest_file"
39
55
  }
40
56
 
41
- # Find active session info
42
- INFO_FILE=$(find_active_session)
57
+ # Find session info
58
+ INFO_FILE=$(find_session)
43
59
 
44
60
  if [ -z "$INFO_FILE" ] || [ ! -f "$INFO_FILE" ]; then
45
61
  echo "[permission-hook] No active session found" >&2
@@ -6,11 +6,32 @@
6
6
  # If you reply with instructions, Claude continues working on them.
7
7
  # If you reply "done" or don't reply, Claude stops.
8
8
  #
9
+ # Set SESSION_NAME env var to target a specific session (for multi-session setups).
10
+ # Without SESSION_NAME, falls back to the most recently started session.
11
+ #
9
12
 
10
13
  SESSION_DIR="/tmp/telegram-claude-sessions"
11
14
 
12
- # Function to find the most recent active session
13
- find_active_session() {
15
+ # Function to find session info file
16
+ # If SESSION_NAME is set, use that specific session
17
+ # Otherwise, find the most recent active session
18
+ find_session() {
19
+ # If SESSION_NAME is set, use specific session
20
+ if [ -n "$SESSION_NAME" ]; then
21
+ local specific_file="$SESSION_DIR/${SESSION_NAME}.info"
22
+ if [ -f "$specific_file" ]; then
23
+ local pid
24
+ pid=$(jq -r '.pid // empty' "$specific_file" 2>/dev/null)
25
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
26
+ echo "$specific_file"
27
+ return
28
+ fi
29
+ fi
30
+ echo "[stop-hook] Session '$SESSION_NAME' not found or not running" >&2
31
+ return
32
+ fi
33
+
34
+ # Fallback: find most recent active session
14
35
  local latest_file=""
15
36
  local latest_time=0
16
37
 
@@ -44,8 +65,8 @@ if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
44
65
  exit 0
45
66
  fi
46
67
 
47
- # Find active session
48
- INFO_FILE=$(find_active_session)
68
+ # Find session info
69
+ INFO_FILE=$(find_session)
49
70
 
50
71
  if [ -z "$INFO_FILE" ] || [ ! -f "$INFO_FILE" ]; then
51
72
  echo "[stop-hook] No active session found" >&2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "telegram-claude-mcp",
3
- "version": "1.4.1",
3
+ "version": "1.6.0",
4
4
  "description": "MCP server that lets Claude message you on Telegram with hooks support",
5
5
  "author": "Geravant",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -99,6 +99,7 @@ async function main() {
99
99
  chatId: config.telegramChatId,
100
100
  sessionName: config.sessionName,
101
101
  responseTimeoutMs: config.responseTimeoutMs,
102
+ permissionTimeoutMs: config.permissionTimeoutMs,
102
103
  });
103
104
 
104
105
  telegram.start();
@@ -25,6 +25,7 @@ export interface AppConfig {
25
25
 
26
26
  // Chat settings
27
27
  responseTimeoutMs: number;
28
+ permissionTimeoutMs: number;
28
29
  }
29
30
 
30
31
  export function loadAppConfig(): AppConfig {
@@ -48,7 +49,8 @@ export function loadAppConfig(): AppConfig {
48
49
  sessionPort: parseInt(sessionPort, 10),
49
50
  openrouterApiKey: process.env.OPENROUTER_API_KEY,
50
51
  openrouterModel: process.env.OPENROUTER_MODEL || 'openai/gpt-oss-120b',
51
- responseTimeoutMs: parseInt(process.env.CHAT_RESPONSE_TIMEOUT_MS || '180000', 10),
52
+ responseTimeoutMs: parseInt(process.env.CHAT_RESPONSE_TIMEOUT_MS || '600000', 10),
53
+ permissionTimeoutMs: parseInt(process.env.PERMISSION_TIMEOUT_MS || '600000', 10),
52
54
  };
53
55
  }
54
56
 
package/src/telegram.ts CHANGED
@@ -70,8 +70,8 @@ export class TelegramManager {
70
70
 
71
71
  constructor(config: TelegramConfig) {
72
72
  this.config = {
73
- responseTimeoutMs: 180000, // 3 minutes default
74
- permissionTimeoutMs: 300000, // 5 minutes for permissions
73
+ responseTimeoutMs: 600000, // 10 minutes default
74
+ permissionTimeoutMs: 600000, // 10 minutes for permissions
75
75
  ...config,
76
76
  };
77
77
 
@@ -182,6 +182,7 @@ export class TelegramManager {
182
182
  /**
183
183
  * Handle a permission request from Claude Code hooks
184
184
  * Sends a message with inline buttons and waits for user decision
185
+ * Includes auto-retry with reminders if user doesn't respond
185
186
  */
186
187
  async handlePermissionRequest(
187
188
  toolName: string,
@@ -208,7 +209,94 @@ export class TelegramManager {
208
209
  { chat_id: this.config.chatId, message_id: sent.message_id }
209
210
  );
210
211
 
211
- return this.waitForPermission(sent.message_id, toolName);
212
+ // Try with reminders
213
+ const reminderIntervalMs = 120000; // 2 minutes between reminders
214
+ const maxReminders = 4; // Up to 4 reminders (total ~10 min with initial wait)
215
+
216
+ for (let attempt = 0; attempt <= maxReminders; attempt++) {
217
+ try {
218
+ const decision = await this.waitForPermissionWithTimeout(sent.message_id, toolName, reminderIntervalMs);
219
+ return decision;
220
+ } catch (err) {
221
+ if (attempt < maxReminders) {
222
+ // Send reminder
223
+ await this.bot.sendMessage(
224
+ this.config.chatId,
225
+ `ā° [${this.config.sessionName}] Reminder: Still waiting for permission\n\nTool: ${toolName}\n\nšŸ‘† Please respond to the message above`,
226
+ { reply_to_message_id: sent.message_id }
227
+ );
228
+ }
229
+ }
230
+ }
231
+
232
+ // Final attempt: ask if they want to retry
233
+ const retryMsg = await this.bot.sendMessage(
234
+ this.config.chatId,
235
+ `āš ļø [${this.config.sessionName}] Permission request timed out\n\nTool: ${toolName}\n\nClick below to respond now:`,
236
+ );
237
+
238
+ await this.bot.editMessageReplyMarkup(
239
+ {
240
+ inline_keyboard: [
241
+ [
242
+ { text: 'āœ… Allow Now', callback_data: `allow:${sent.message_id}` },
243
+ { text: 'āŒ Deny', callback_data: `deny:${sent.message_id}` },
244
+ ],
245
+ ],
246
+ },
247
+ { chat_id: this.config.chatId, message_id: retryMsg.message_id }
248
+ );
249
+
250
+ // One more wait with the original timeout
251
+ try {
252
+ return await this.waitForPermissionWithTimeout(sent.message_id, toolName, this.config.permissionTimeoutMs!);
253
+ } catch {
254
+ // Truly timed out
255
+ return { behavior: 'deny', message: 'Permission request timed out after multiple reminders' };
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Wait for permission with a specific timeout (throws on timeout)
261
+ */
262
+ private waitForPermissionWithTimeout(
263
+ messageId: number,
264
+ toolName: string,
265
+ timeoutMs: number
266
+ ): Promise<PermissionDecision> {
267
+ return new Promise((resolve, reject) => {
268
+ const key = `${messageId}`;
269
+
270
+ // Check if already resolved
271
+ const existing = this.pendingPermissions.get(key);
272
+ if (existing) {
273
+ // Already waiting, just update the callbacks
274
+ existing.resolve = resolve;
275
+ existing.reject = reject;
276
+ return;
277
+ }
278
+
279
+ const timeout = setTimeout(() => {
280
+ this.pendingPermissions.delete(key);
281
+ reject(new Error('Timeout'));
282
+ }, timeoutMs);
283
+
284
+ this.pendingPermissions.set(key, {
285
+ resolve: (decision: PermissionDecision) => {
286
+ clearTimeout(timeout);
287
+ this.pendingPermissions.delete(key);
288
+ resolve(decision);
289
+ },
290
+ reject: (error: Error) => {
291
+ clearTimeout(timeout);
292
+ this.pendingPermissions.delete(key);
293
+ reject(error);
294
+ },
295
+ messageId,
296
+ timestamp: Date.now(),
297
+ toolName,
298
+ });
299
+ });
212
300
  }
213
301
 
214
302
  /**
@@ -245,6 +333,7 @@ export class TelegramManager {
245
333
  * Handle interactive stop - send message and wait for user to reply with instructions
246
334
  * Returns { decision: "block", reason: "..." } if user wants to continue
247
335
  * Returns {} if user is done
336
+ * Includes auto-retry with reminders if user doesn't respond
248
337
  */
249
338
  async handleInteractiveStop(transcriptPath?: string): Promise<Record<string, unknown>> {
250
339
  // Try to get last assistant message from transcript
@@ -287,28 +376,106 @@ export class TelegramManager {
287
376
  messageIds: [...this.getSessionState().messageIds, sent.message_id],
288
377
  });
289
378
 
290
- // Wait for response with longer timeout for interactive stop
379
+ // Try with reminders
380
+ const reminderIntervalMs = 120000; // 2 minutes between reminders
381
+ const maxReminders = 4; // Up to 4 reminders
382
+
383
+ for (let attempt = 0; attempt <= maxReminders; attempt++) {
384
+ try {
385
+ const response = await this.waitForResponseWithTimeout(sent.message_id, reminderIntervalMs);
386
+
387
+ // Check if user wants to stop
388
+ const lowerResponse = response.toLowerCase().trim();
389
+ if (lowerResponse === 'done' || lowerResponse === 'stop' || lowerResponse === 'finish' || lowerResponse === 'ok') {
390
+ return {};
391
+ }
392
+
393
+ // User provided instructions - continue
394
+ return {
395
+ decision: 'block',
396
+ reason: response,
397
+ };
398
+ } catch (err) {
399
+ if (attempt < maxReminders) {
400
+ // Send reminder
401
+ await this.bot.sendMessage(
402
+ this.config.chatId,
403
+ `ā° [${this.config.sessionName}] Reminder: Claude is waiting for your response\n\nšŸ’¬ Reply with instructions to continue, or "done" to let Claude stop`,
404
+ { reply_to_message_id: sent.message_id }
405
+ );
406
+ }
407
+ }
408
+ }
409
+
410
+ // Final attempt with longer timeout
291
411
  try {
292
- const response = await this.waitForResponse(sent.message_id);
412
+ await this.bot.sendMessage(
413
+ this.config.chatId,
414
+ `āš ļø [${this.config.sessionName}] Last chance! Claude will stop in ${Math.round(this.config.responseTimeoutMs! / 60000)} minutes if no response.\n\nšŸ’¬ Reply now to continue working.`,
415
+ { reply_to_message_id: sent.message_id }
416
+ );
417
+
418
+ const response = await this.waitForResponseWithTimeout(sent.message_id, this.config.responseTimeoutMs!);
293
419
 
294
- // Check if user wants to stop
295
420
  const lowerResponse = response.toLowerCase().trim();
296
421
  if (lowerResponse === 'done' || lowerResponse === 'stop' || lowerResponse === 'finish' || lowerResponse === 'ok') {
297
422
  return {};
298
423
  }
299
424
 
300
- // User provided instructions - continue
301
425
  return {
302
426
  decision: 'block',
303
427
  reason: response,
304
428
  };
305
429
  } catch (err) {
306
- // Timeout or error - allow stop
307
- console.error('[Telegram] Interactive stop timeout or error:', err);
430
+ // Truly timed out - notify and allow stop
431
+ await this.bot.sendMessage(
432
+ this.config.chatId,
433
+ `😓 [${this.config.sessionName}] Claude stopped (no response received)\n\nStart a new conversation to continue.`
434
+ );
435
+ console.error('[Telegram] Interactive stop timeout after reminders');
308
436
  return {};
309
437
  }
310
438
  }
311
439
 
440
+ /**
441
+ * Wait for response with a specific timeout (throws on timeout)
442
+ */
443
+ private waitForResponseWithTimeout(messageId: number, timeoutMs: number): Promise<string> {
444
+ return new Promise((resolve, reject) => {
445
+ const key = `${messageId}`;
446
+
447
+ // Check if already has pending response
448
+ const existing = this.pendingResponses.get(key);
449
+ if (existing) {
450
+ existing.resolve = resolve;
451
+ existing.reject = reject;
452
+ return;
453
+ }
454
+
455
+ const timeout = setTimeout(() => {
456
+ this.pendingResponses.delete(key);
457
+ this.updateSessionState({ waitingForResponse: false });
458
+ reject(new Error('Timeout'));
459
+ }, timeoutMs);
460
+
461
+ this.pendingResponses.set(key, {
462
+ resolve: (response: string) => {
463
+ clearTimeout(timeout);
464
+ this.pendingResponses.delete(key);
465
+ this.updateSessionState({ waitingForResponse: false });
466
+ resolve(response);
467
+ },
468
+ reject: (error: Error) => {
469
+ clearTimeout(timeout);
470
+ this.pendingResponses.delete(key);
471
+ reject(error);
472
+ },
473
+ messageId,
474
+ timestamp: Date.now(),
475
+ });
476
+ });
477
+ }
478
+
312
479
  /**
313
480
  * Format tool input for display
314
481
  */
@@ -338,37 +505,6 @@ export class TelegramManager {
338
505
  }
339
506
  }
340
507
 
341
- /**
342
- * Wait for a permission decision with timeout
343
- */
344
- private waitForPermission(messageId: number, toolName: string): Promise<PermissionDecision> {
345
- return new Promise((resolve, reject) => {
346
- const key = `${messageId}`;
347
-
348
- const timeout = setTimeout(() => {
349
- this.pendingPermissions.delete(key);
350
- // Default to deny on timeout for safety
351
- resolve({ behavior: 'deny', message: 'Permission request timed out' });
352
- }, this.config.permissionTimeoutMs);
353
-
354
- this.pendingPermissions.set(key, {
355
- resolve: (decision: PermissionDecision) => {
356
- clearTimeout(timeout);
357
- this.pendingPermissions.delete(key);
358
- resolve(decision);
359
- },
360
- reject: (error: Error) => {
361
- clearTimeout(timeout);
362
- this.pendingPermissions.delete(key);
363
- reject(error);
364
- },
365
- messageId,
366
- timestamp: Date.now(),
367
- toolName,
368
- });
369
- });
370
- }
371
-
372
508
  /**
373
509
  * Set up message handler for incoming messages
374
510
  */