claude-tg 1.0.6 → 1.0.8

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/bin/claude-tg.js CHANGED
@@ -276,15 +276,21 @@ program
276
276
  const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
277
277
  const hooks = settings.hooks || {};
278
278
 
279
+ // Extract script path from hook command (handles optional env prefix)
280
+ function extractScriptPath(cmd) {
281
+ const match = cmd.match(/node\s+(.+)$/);
282
+ return match ? match[1].trim() : null;
283
+ }
284
+
279
285
  const permHooks = hooks.PermissionRequest || [];
280
286
  const permCmd = permHooks[0]?.hooks?.[0]?.command || 'NOT FOUND';
281
287
  console.log(` PermissionRequest: ${permCmd}`);
282
288
  if (permCmd !== 'NOT FOUND') {
283
- const scriptPath = permCmd.replace(/^node\s+/, '');
284
- if (fs.existsSync(scriptPath)) {
289
+ const scriptPath = extractScriptPath(permCmd);
290
+ if (scriptPath && fs.existsSync(scriptPath)) {
285
291
  console.log(' File exists: YES');
286
292
  } else {
287
- console.log(` File exists: NO — ${scriptPath}`);
293
+ console.log(` File exists: NO — ${scriptPath || permCmd}`);
288
294
  ok = false;
289
295
  }
290
296
  }
@@ -293,11 +299,11 @@ program
293
299
  const notifCmd = notifHooks[0]?.hooks?.[0]?.command || 'NOT FOUND';
294
300
  console.log(` Notification: ${notifCmd}`);
295
301
  if (notifCmd !== 'NOT FOUND') {
296
- const scriptPath = notifCmd.replace(/^node\s+/, '');
297
- if (fs.existsSync(scriptPath)) {
302
+ const scriptPath = extractScriptPath(notifCmd);
303
+ if (scriptPath && fs.existsSync(scriptPath)) {
298
304
  console.log(' File exists: YES');
299
305
  } else {
300
- console.log(` File exists: NO — ${scriptPath}`);
306
+ console.log(` File exists: NO — ${scriptPath || notifCmd}`);
301
307
  ok = false;
302
308
  }
303
309
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-tg",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "Control Claude Code from Telegram — approve permissions, answer questions, reply to idle sessions, send files, all from your phone",
5
5
  "bin": {
6
6
  "claude-tg": "./bin/claude-tg.js"
package/src/daemon.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const http = require('http');
2
2
  const crypto = require('crypto');
3
- const { execSync, exec } = require('child_process');
3
+ const { exec } = require('child_process');
4
4
  const util = require('util');
5
5
  const execAsync = util.promisify(exec);
6
6
  const { Telegraf, Markup } = require('telegraf');
@@ -32,9 +32,12 @@ let config;
32
32
 
33
33
  // --- Utilities ---
34
34
 
35
+ // Non-blocking log using a write stream
36
+ const logStream = fs.createWriteStream(LOG_PATH, { flags: 'a' });
37
+ logStream.on('error', () => {}); // prevent stream errors from crashing
38
+
35
39
  function log(msg) {
36
- const line = `[${new Date().toISOString()}] ${msg}\n`;
37
- fs.appendFileSync(LOG_PATH, line);
40
+ logStream.write(`[${new Date().toISOString()}] ${msg}\n`);
38
41
  }
39
42
 
40
43
  function projectLabel(cwd) {
@@ -76,14 +79,14 @@ function trackSession(data) {
76
79
 
77
80
  /**
78
81
  * Check if a TTY has any active processes (terminal is still open + something running).
79
- * More reliable than fs.statSync which succeeds even after terminal closes.
82
+ * Non-blocking async version to avoid blocking the event loop.
80
83
  */
81
- function isTtyAlive(ttyPath) {
84
+ async function isTtyAlive(ttyPath) {
82
85
  if (!ttyPath) return false;
83
86
  try {
84
87
  const ttyName = ttyPath.replace('/dev/', '');
85
- const result = execSync(`ps -t ${ttyName} -o pid= 2>/dev/null`, { timeout: 2000, stdio: 'pipe' }).toString().trim();
86
- return result.length > 0;
88
+ const { stdout } = await execAsync(`ps -t ${ttyName} -o pid= 2>/dev/null`, { timeout: 2000 });
89
+ return stdout.trim().length > 0;
87
90
  } catch {
88
91
  return false;
89
92
  }
@@ -93,7 +96,12 @@ function isTtyAlive(ttyPath) {
93
96
  * Escape a string for use inside AppleScript double-quoted strings.
94
97
  */
95
98
  function escapeAppleScript(str) {
96
- return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
99
+ return str
100
+ .replace(/\\/g, '\\\\')
101
+ .replace(/"/g, '\\"')
102
+ .replace(/\n/g, '\\n')
103
+ .replace(/\r/g, '\\r')
104
+ .replace(/\t/g, '\\t');
97
105
  }
98
106
 
99
107
  /**
@@ -450,9 +458,13 @@ async function sendElicitationQuestion(elicId) {
450
458
  }
451
459
 
452
460
  const keyboard = Markup.inlineKeyboard(rows);
453
- const sent = await bot.telegram.sendMessage(config.chatId, msg, keyboard);
454
- elic.telegramMessageIds.push(sent.message_id);
455
- elic.currentMessageId = sent.message_id;
461
+ try {
462
+ const sent = await bot.telegram.sendMessage(config.chatId, msg, keyboard);
463
+ elic.telegramMessageIds.push(sent.message_id);
464
+ elic.currentMessageId = sent.message_id;
465
+ } catch (err) {
466
+ log(`Elicitation question send failed: ${err.message}`);
467
+ }
456
468
  }
457
469
 
458
470
  /**
@@ -493,8 +505,12 @@ async function sendElicitationSummary(elicId) {
493
505
  }
494
506
  const keyboard = Markup.inlineKeyboard(summaryButtons);
495
507
 
496
- const sent = await bot.telegram.sendMessage(config.chatId, msg, keyboard);
497
- elic.telegramMessageIds.push(sent.message_id);
508
+ try {
509
+ const sent = await bot.telegram.sendMessage(config.chatId, msg, keyboard);
510
+ elic.telegramMessageIds.push(sent.message_id);
511
+ } catch (err) {
512
+ log(`Elicitation summary send failed: ${err.message}`);
513
+ }
498
514
  }
499
515
 
500
516
  /**
@@ -591,14 +607,18 @@ async function handleElicitationCallback(ctx, cbData) {
591
607
  try { await ctx.editMessageReplyMarkup(undefined); } catch {}
592
608
  try { await ctx.editMessageText(ctx.callbackQuery.message.text + '\n\n✏️ Selected: Custom'); } catch {}
593
609
 
594
- const prompt = await bot.telegram.sendMessage(
595
- config.chatId,
596
- `✏️ Type your custom answer for: "${q.question}"\n\nReply to this message with your answer.`
597
- );
598
-
599
- elic.customWaitingMessageId = prompt.message_id;
600
- elic.customWaitingQIdx = qIdx;
601
- elic.telegramMessageIds.push(prompt.message_id);
610
+ try {
611
+ const prompt = await bot.telegram.sendMessage(
612
+ config.chatId,
613
+ `✏️ Type your custom answer for: "${q.question}"\n\nReply to this message with your answer.`
614
+ );
615
+
616
+ elic.customWaitingMessageId = prompt.message_id;
617
+ elic.customWaitingQIdx = qIdx;
618
+ elic.telegramMessageIds.push(prompt.message_id);
619
+ } catch (err) {
620
+ log(`Elicitation custom prompt send failed: ${err.message}`);
621
+ }
602
622
  log(`Elicitation ${elicId}: waiting for custom answer to q${qIdx}`);
603
623
  return;
604
624
  }
@@ -831,10 +851,15 @@ function startBot() {
831
851
 
832
852
  bot.command('status', async (ctx) => {
833
853
  const pendingCount = pendingQuestions.size;
834
- const activeSessions = [...sessions.entries()].filter(([, s]) => {
835
- if (s.ttyPath) return isTtyAlive(s.ttyPath);
836
- return Date.now() - s.lastActive < 60 * 60 * 1000;
837
- });
854
+ const allSessions = [...sessions.entries()];
855
+ const activeSessions = [];
856
+ for (const [sid, s] of allSessions) {
857
+ if (s.ttyPath) {
858
+ if (await isTtyAlive(s.ttyPath)) activeSessions.push([sid, s]);
859
+ } else if (Date.now() - s.lastActive < 60 * 60 * 1000) {
860
+ activeSessions.push([sid, s]);
861
+ }
862
+ }
838
863
 
839
864
  let msg = '';
840
865
  if (activeSessions.length === 0 && pendingCount === 0) {
@@ -909,6 +934,9 @@ function startBot() {
909
934
  } else if (action === 'always') {
910
935
  label = '✅ Always Allowed';
911
936
  pending.resolve({ decision: 'always' });
937
+ } else {
938
+ label = '❌ Denied';
939
+ pending.resolve({ decision: 'deny' });
912
940
  }
913
941
 
914
942
  try { await ctx.answerCbQuery(label); } catch {}
@@ -983,14 +1011,16 @@ function startBot() {
983
1011
  // If no reply-to, try auto-routing
984
1012
  if (!targetSessionId) {
985
1013
  // Find sessions that have notifications and are still alive (TTY exists)
986
- const recentNotifications = [...messageToSession.entries()]
987
- .filter(([, m]) => {
988
- if (m.type !== 'notification') return false;
989
- const s = sessions.get(m.sessionId);
990
- if (s?.ttyPath) return isTtyAlive(s.ttyPath);
991
- return Date.now() - m.createdAt < 60 * 60 * 1000;
992
- })
993
- .map(([, m]) => m.sessionId);
1014
+ const allNotifs = [...messageToSession.entries()].filter(([, m]) => m.type === 'notification');
1015
+ const recentNotifications = [];
1016
+ for (const [, m] of allNotifs) {
1017
+ const s = sessions.get(m.sessionId);
1018
+ if (s?.ttyPath) {
1019
+ if (await isTtyAlive(s.ttyPath)) recentNotifications.push(m.sessionId);
1020
+ } else if (Date.now() - m.createdAt < 60 * 60 * 1000) {
1021
+ recentNotifications.push(m.sessionId);
1022
+ }
1023
+ }
994
1024
 
995
1025
  const uniqueSessions = [...new Set(recentNotifications)];
996
1026
 
@@ -1028,11 +1058,47 @@ function startBot() {
1028
1058
  });
1029
1059
 
1030
1060
  bot.catch((err) => {
1031
- log(`Bot error: ${err.message}`);
1061
+ log(`Bot error: ${err?.message || err}`);
1032
1062
  });
1033
1063
 
1064
+ // Launch polling in background — don't block the HTTP server
1065
+ // Note: Telegraf's launch() promise never settles in v4.16 — that's OK,
1066
+ // polling starts immediately in the background.
1034
1067
  bot.launch({ dropPendingUpdates: true });
1035
- log('Telegram bot started');
1068
+ log('Telegram bot polling started');
1069
+
1070
+ // Send startup notification after brief delay (let polling initialize)
1071
+ setTimeout(async () => {
1072
+ try {
1073
+ await bot.telegram.sendMessage(config.chatId, '🟢 claude-tg daemon started');
1074
+ log('Startup notification sent to Telegram');
1075
+ } catch (e) {
1076
+ log(`Startup notification failed: ${e?.message || e}`);
1077
+ }
1078
+ }, 2000);
1079
+ }
1080
+
1081
+ /**
1082
+ * Periodic health check — verify Telegram connectivity.
1083
+ * If bot polling dies silently, restart it.
1084
+ */
1085
+ let healthCheckRestarting = false;
1086
+ function startHealthCheck() {
1087
+ setInterval(async () => {
1088
+ if (healthCheckRestarting) return;
1089
+ try {
1090
+ await bot.telegram.getMe();
1091
+ } catch (err) {
1092
+ log(`Telegram health check failed: ${err?.message || err}. Restarting bot...`);
1093
+ healthCheckRestarting = true;
1094
+ try { bot.stop('health_restart'); } catch {}
1095
+ // Wait for old polling to fully stop before starting new instance
1096
+ setTimeout(() => {
1097
+ startBot();
1098
+ healthCheckRestarting = false;
1099
+ }, 3000);
1100
+ }
1101
+ }, 3 * 60 * 1000); // every 3 minutes
1036
1102
  }
1037
1103
 
1038
1104
  // --- HTTP handlers ---
@@ -1056,7 +1122,13 @@ async function sendPermissionRequest(data) {
1056
1122
  Markup.button.callback('Always Allow', `${rid}:always`),
1057
1123
  ]);
1058
1124
 
1059
- const sent = await bot.telegram.sendMessage(config.chatId, msg, keyboard);
1125
+ let sent;
1126
+ try {
1127
+ sent = await bot.telegram.sendMessage(config.chatId, msg, keyboard);
1128
+ } catch (err) {
1129
+ log(`Permission send failed: ${err.message}`);
1130
+ return { decision: 'allow' }; // Fallback: allow if Telegram unreachable
1131
+ }
1060
1132
 
1061
1133
  // Track this message for reply routing
1062
1134
  messageToSession.set(sent.message_id, {
@@ -1245,14 +1317,14 @@ function startServer() {
1245
1317
  // --- Cleanup stale state periodically ---
1246
1318
 
1247
1319
  function startCleanup() {
1248
- setInterval(() => {
1320
+ setInterval(async () => {
1249
1321
  const now = Date.now();
1250
1322
  const staleThreshold = 60 * 60 * 1000; // 1 hour
1251
1323
 
1252
1324
  // Clean message mappings — keep if session's TTY is still alive
1253
1325
  for (const [msgId, m] of messageToSession) {
1254
1326
  const s = sessions.get(m.sessionId);
1255
- if (s?.ttyPath && isTtyAlive(s.ttyPath)) continue;
1327
+ if (s?.ttyPath && await isTtyAlive(s.ttyPath)) continue;
1256
1328
  if (now - m.createdAt > staleThreshold) {
1257
1329
  messageToSession.delete(msgId);
1258
1330
  }
@@ -1261,7 +1333,7 @@ function startCleanup() {
1261
1333
  // Clean stale sessions — only if terminal is gone
1262
1334
  for (const [sid, s] of sessions) {
1263
1335
  if (s.ttyPath) {
1264
- if (!isTtyAlive(s.ttyPath)) {
1336
+ if (!(await isTtyAlive(s.ttyPath))) {
1265
1337
  sessions.delete(sid);
1266
1338
  log(`Cleaned session ${sid} (TTY gone: ${s.ttyPath})`);
1267
1339
  }
@@ -1291,9 +1363,10 @@ function main() {
1291
1363
  }
1292
1364
 
1293
1365
  log('Daemon starting...');
1294
- startBot();
1295
- startServer();
1366
+ startServer(); // HTTP server first — hooks must be able to connect immediately
1367
+ startBot(); // Telegram polling in background
1296
1368
  startCleanup();
1369
+ startHealthCheck();
1297
1370
 
1298
1371
  process.on('unhandledRejection', (err) => {
1299
1372
  log(`Unhandled rejection: ${err?.message || err}`);