claude-tg 1.0.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/LICENSE +21 -0
- package/README.md +283 -0
- package/bin/claude-tg +261 -0
- package/package.json +41 -0
- package/src/config.js +38 -0
- package/src/daemon.js +1274 -0
- package/src/hooks/notification.js +92 -0
- package/src/hooks/permission-request.js +109 -0
- package/src/setup.js +221 -0
package/src/daemon.js
ADDED
|
@@ -0,0 +1,1274 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const { Telegraf, Markup } = require('telegraf');
|
|
5
|
+
const { loadConfig, LOG_PATH } = require('./config');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
|
|
8
|
+
// --- State ---
|
|
9
|
+
|
|
10
|
+
const pendingQuestions = new Map();
|
|
11
|
+
// Key: requestId (UUID)
|
|
12
|
+
// Value: { resolve, sessionId, toolName, createdAt, telegramMessageId, permissionSuggestions }
|
|
13
|
+
|
|
14
|
+
// session_id → { ttyPath, cwd, label (project name), lastActive }
|
|
15
|
+
const sessions = new Map();
|
|
16
|
+
|
|
17
|
+
// session_id → short numeric label (#1, #2, ...) for terminal identification
|
|
18
|
+
const sessionLabels = new Map();
|
|
19
|
+
let sessionCounter = 0;
|
|
20
|
+
|
|
21
|
+
// telegramMessageId → { sessionId, type: 'permission' | 'notification' }
|
|
22
|
+
const messageToSession = new Map();
|
|
23
|
+
|
|
24
|
+
// Elicitation state machine
|
|
25
|
+
// elicitationId → { sessionId, ttyPath, questions, answers, telegramMessageIds, ... }
|
|
26
|
+
const pendingElicitations = new Map();
|
|
27
|
+
|
|
28
|
+
let bot;
|
|
29
|
+
let config;
|
|
30
|
+
|
|
31
|
+
// --- Utilities ---
|
|
32
|
+
|
|
33
|
+
function log(msg) {
|
|
34
|
+
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
35
|
+
fs.appendFileSync(LOG_PATH, line);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function projectLabel(cwd) {
|
|
39
|
+
if (!cwd) return 'unknown';
|
|
40
|
+
return cwd.split('/').filter(Boolean).pop() || 'unknown';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getSessionLabel(sessionId) {
|
|
44
|
+
if (!sessionId) return '?';
|
|
45
|
+
if (!sessionLabels.has(sessionId)) {
|
|
46
|
+
sessionLabels.set(sessionId, ++sessionCounter);
|
|
47
|
+
}
|
|
48
|
+
return sessionLabels.get(sessionId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function shortId(uuid) {
|
|
52
|
+
return uuid.slice(0, 8);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function truncate(str, max = 300) {
|
|
56
|
+
if (!str) return '';
|
|
57
|
+
if (str.length <= max) return str;
|
|
58
|
+
return str.slice(0, max) + '…';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Register/update a session's TTY and metadata.
|
|
63
|
+
*/
|
|
64
|
+
function trackSession(data) {
|
|
65
|
+
const existing = sessions.get(data.session_id) || {};
|
|
66
|
+
sessions.set(data.session_id, {
|
|
67
|
+
...existing,
|
|
68
|
+
ttyPath: data.tty_path || existing.ttyPath || null,
|
|
69
|
+
cwd: data.cwd || existing.cwd,
|
|
70
|
+
label: projectLabel(data.cwd || existing.cwd),
|
|
71
|
+
lastActive: Date.now(),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Check if a TTY has any active processes (terminal is still open + something running).
|
|
77
|
+
* More reliable than fs.statSync which succeeds even after terminal closes.
|
|
78
|
+
*/
|
|
79
|
+
function isTtyAlive(ttyPath) {
|
|
80
|
+
if (!ttyPath) return false;
|
|
81
|
+
try {
|
|
82
|
+
const ttyName = ttyPath.replace('/dev/', '');
|
|
83
|
+
const result = execSync(`ps -t ${ttyName} -o pid= 2>/dev/null`, { timeout: 2000, stdio: 'pipe' }).toString().trim();
|
|
84
|
+
return result.length > 0;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Escape a string for use inside AppleScript double-quoted strings.
|
|
92
|
+
*/
|
|
93
|
+
function escapeAppleScript(str) {
|
|
94
|
+
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Send text as input to a terminal session identified by its TTY path.
|
|
99
|
+
* Uses osascript to type into the correct terminal tab/session.
|
|
100
|
+
* Tries iTerm2 first, then Terminal.app.
|
|
101
|
+
*/
|
|
102
|
+
function sendInputToTerminal(ttyPath, text) {
|
|
103
|
+
if (!ttyPath) {
|
|
104
|
+
log('sendInput: no TTY path');
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const escaped = escapeAppleScript(text.trim());
|
|
109
|
+
|
|
110
|
+
// Try iTerm2 — write text targets a specific session by TTY, no focus needed
|
|
111
|
+
try {
|
|
112
|
+
const script = `
|
|
113
|
+
tell application "iTerm2"
|
|
114
|
+
repeat with w in windows
|
|
115
|
+
repeat with t in tabs of w
|
|
116
|
+
repeat with s in sessions of t
|
|
117
|
+
if tty of s is "${ttyPath}" then
|
|
118
|
+
tell s to write text "${escaped}"
|
|
119
|
+
return "ok"
|
|
120
|
+
end if
|
|
121
|
+
end repeat
|
|
122
|
+
end repeat
|
|
123
|
+
end repeat
|
|
124
|
+
end tell`;
|
|
125
|
+
execSync(`osascript -e '${script.replace(/'/g, "'\\''")}'`, { timeout: 5000, stdio: 'pipe' });
|
|
126
|
+
log(`Sent via iTerm2 to ${ttyPath}: ${truncate(text, 80)}`);
|
|
127
|
+
return true;
|
|
128
|
+
} catch {}
|
|
129
|
+
|
|
130
|
+
// Try Terminal.app — focus the tab, type text, press Enter
|
|
131
|
+
try {
|
|
132
|
+
const script = [
|
|
133
|
+
'tell application "Terminal"',
|
|
134
|
+
' repeat with w in windows',
|
|
135
|
+
' repeat with t in tabs of w',
|
|
136
|
+
` if tty of t is "${ttyPath}" then`,
|
|
137
|
+
' set selected tab of w to t',
|
|
138
|
+
' set frontmost of w to true',
|
|
139
|
+
' delay 0.3',
|
|
140
|
+
' tell application "System Events"',
|
|
141
|
+
' tell process "Terminal"',
|
|
142
|
+
` keystroke "${escaped}"`,
|
|
143
|
+
' delay 0.2',
|
|
144
|
+
' keystroke return',
|
|
145
|
+
' end tell',
|
|
146
|
+
' end tell',
|
|
147
|
+
' return "ok"',
|
|
148
|
+
' end if',
|
|
149
|
+
' end repeat',
|
|
150
|
+
' end repeat',
|
|
151
|
+
'end tell',
|
|
152
|
+
].join('\n');
|
|
153
|
+
|
|
154
|
+
fs.writeFileSync('/tmp/claude-tg-input.scpt', script);
|
|
155
|
+
execSync('osascript /tmp/claude-tg-input.scpt', { timeout: 10000, stdio: 'pipe' });
|
|
156
|
+
log(`Sent via Terminal.app to ${ttyPath}: ${truncate(text, 80)}`);
|
|
157
|
+
return true;
|
|
158
|
+
} catch (err) {
|
|
159
|
+
log(`Terminal.app send error: ${err.message}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
log(`sendInput failed: no terminal found for ${ttyPath}`);
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- Transcript reading ---
|
|
167
|
+
|
|
168
|
+
function tailLines(filePath, n) {
|
|
169
|
+
try {
|
|
170
|
+
const stat = fs.statSync(filePath);
|
|
171
|
+
const bufSize = Math.min(stat.size, n * 2000);
|
|
172
|
+
const buf = Buffer.alloc(bufSize);
|
|
173
|
+
const fd = fs.openSync(filePath, 'r');
|
|
174
|
+
fs.readSync(fd, buf, 0, bufSize, stat.size - bufSize);
|
|
175
|
+
fs.closeSync(fd);
|
|
176
|
+
const lines = buf.toString('utf8').split('\n').filter(Boolean);
|
|
177
|
+
return lines.slice(-n);
|
|
178
|
+
} catch {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Extract conversation context from transcript JSONL.
|
|
185
|
+
* Returns: { userTask, recentContext }
|
|
186
|
+
*/
|
|
187
|
+
function extractContext(transcriptPath) {
|
|
188
|
+
if (!transcriptPath) return { userTask: '', recentContext: '' };
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
// First user message = the task
|
|
192
|
+
let userTask = '';
|
|
193
|
+
try {
|
|
194
|
+
const headBuf = Buffer.alloc(Math.min(fs.statSync(transcriptPath).size, 50000));
|
|
195
|
+
const fd = fs.openSync(transcriptPath, 'r');
|
|
196
|
+
fs.readSync(fd, headBuf, 0, headBuf.length, 0);
|
|
197
|
+
fs.closeSync(fd);
|
|
198
|
+
const headLines = headBuf.toString('utf8').split('\n').filter(Boolean);
|
|
199
|
+
for (const line of headLines) {
|
|
200
|
+
try {
|
|
201
|
+
const entry = JSON.parse(line);
|
|
202
|
+
if (entry.type === 'user' && entry.message?.role === 'user') {
|
|
203
|
+
const content = entry.message.content;
|
|
204
|
+
if (typeof content === 'string') {
|
|
205
|
+
userTask = content;
|
|
206
|
+
} else if (Array.isArray(content)) {
|
|
207
|
+
const textBlock = content.find((b) => b.type === 'text');
|
|
208
|
+
if (textBlock) userTask = textBlock.text;
|
|
209
|
+
}
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
} catch {}
|
|
213
|
+
}
|
|
214
|
+
} catch {}
|
|
215
|
+
|
|
216
|
+
// Last assistant text = what Claude was doing/said
|
|
217
|
+
let recentContext = '';
|
|
218
|
+
const tailData = tailLines(transcriptPath, 20);
|
|
219
|
+
for (let i = tailData.length - 1; i >= 0; i--) {
|
|
220
|
+
try {
|
|
221
|
+
const entry = JSON.parse(tailData[i]);
|
|
222
|
+
if (entry.type === 'assistant' && entry.message?.role === 'assistant') {
|
|
223
|
+
const content = entry.message.content;
|
|
224
|
+
if (Array.isArray(content)) {
|
|
225
|
+
const texts = content
|
|
226
|
+
.filter((b) => b.type === 'text' && b.text?.trim())
|
|
227
|
+
.map((b) => b.text.trim());
|
|
228
|
+
if (texts.length > 0) {
|
|
229
|
+
recentContext = texts.join(' ');
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
} catch {}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
userTask: truncate(userTask.trim(), 200),
|
|
239
|
+
recentContext: truncate(recentContext.trim(), 300),
|
|
240
|
+
};
|
|
241
|
+
} catch (err) {
|
|
242
|
+
log(`Context extraction error: ${err.message}`);
|
|
243
|
+
return { userTask: '', recentContext: '' };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Extract AskUserQuestion data from the transcript tail.
|
|
249
|
+
* Returns the questions array or null if not found.
|
|
250
|
+
*/
|
|
251
|
+
function extractElicitation(transcriptPath) {
|
|
252
|
+
if (!transcriptPath) return null;
|
|
253
|
+
try {
|
|
254
|
+
const lines = tailLines(transcriptPath, 30);
|
|
255
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
256
|
+
try {
|
|
257
|
+
const entry = JSON.parse(lines[i]);
|
|
258
|
+
if (entry.type === 'assistant' && entry.message?.role === 'assistant') {
|
|
259
|
+
const content = entry.message.content;
|
|
260
|
+
if (Array.isArray(content)) {
|
|
261
|
+
for (let j = content.length - 1; j >= 0; j--) {
|
|
262
|
+
const block = content[j];
|
|
263
|
+
if (block.type === 'tool_use' && block.name === 'AskUserQuestion') {
|
|
264
|
+
const questions = block.input?.questions;
|
|
265
|
+
if (questions && Array.isArray(questions) && questions.length > 0) {
|
|
266
|
+
return questions;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} catch {}
|
|
273
|
+
}
|
|
274
|
+
} catch {}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// --- Message formatting ---
|
|
279
|
+
|
|
280
|
+
function formatToolDetails(toolName, toolInput) {
|
|
281
|
+
if (!toolInput) return '';
|
|
282
|
+
if (typeof toolInput === 'string') return toolInput;
|
|
283
|
+
|
|
284
|
+
switch (toolName) {
|
|
285
|
+
case 'Bash':
|
|
286
|
+
return toolInput.command || '';
|
|
287
|
+
case 'Write':
|
|
288
|
+
return `${toolInput.file_path || ''}\n(new file, ${(toolInput.content || '').length} chars)`;
|
|
289
|
+
case 'Edit': {
|
|
290
|
+
let edit = toolInput.file_path || '';
|
|
291
|
+
if (toolInput.old_string !== undefined) {
|
|
292
|
+
edit += `\n- ${truncate(toolInput.old_string, 80)}\n+ ${truncate(toolInput.new_string, 80)}`;
|
|
293
|
+
}
|
|
294
|
+
return edit;
|
|
295
|
+
}
|
|
296
|
+
case 'Read':
|
|
297
|
+
return toolInput.file_path || '';
|
|
298
|
+
case 'Glob':
|
|
299
|
+
return `${toolInput.pattern}${toolInput.path ? ' in ' + toolInput.path : ''}`;
|
|
300
|
+
case 'Grep':
|
|
301
|
+
return `/${toolInput.pattern}/${toolInput.glob ? ' ' + toolInput.glob : ''}`;
|
|
302
|
+
case 'WebFetch':
|
|
303
|
+
return toolInput.url || '';
|
|
304
|
+
case 'WebSearch':
|
|
305
|
+
return toolInput.query || '';
|
|
306
|
+
case 'Task':
|
|
307
|
+
return `[${toolInput.subagent_type || 'agent'}] ${truncate(toolInput.description || toolInput.prompt || '', 150)}`;
|
|
308
|
+
default:
|
|
309
|
+
return truncate(JSON.stringify(toolInput, null, 2), 400);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function formatPermissionMessage(data, context) {
|
|
314
|
+
const label = projectLabel(data.cwd);
|
|
315
|
+
const sessionNum = getSessionLabel(data.session_id);
|
|
316
|
+
const tool = data.tool_name || 'Unknown';
|
|
317
|
+
const details = formatToolDetails(tool, data.tool_input);
|
|
318
|
+
|
|
319
|
+
let msg = `📋 #${sessionNum} ${label}\n`;
|
|
320
|
+
msg += `━━━━━━━━━━━━━━━━━━━━\n`;
|
|
321
|
+
|
|
322
|
+
if (context.userTask) {
|
|
323
|
+
msg += `📝 Task: ${context.userTask}\n\n`;
|
|
324
|
+
}
|
|
325
|
+
if (context.recentContext) {
|
|
326
|
+
msg += `💭 Doing: ${context.recentContext}\n\n`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
msg += `🔧 ${tool}`;
|
|
330
|
+
if (details) msg += `\n${details}`;
|
|
331
|
+
|
|
332
|
+
return msg;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function formatNotification(data, context) {
|
|
336
|
+
const label = projectLabel(data.cwd);
|
|
337
|
+
const sessionNum = getSessionLabel(data.session_id);
|
|
338
|
+
const type = data.notification_type || data.type || 'notification';
|
|
339
|
+
const session = sessions.get(data.session_id);
|
|
340
|
+
const canReply = !!(session && session.ttyPath);
|
|
341
|
+
|
|
342
|
+
let msg = '';
|
|
343
|
+
|
|
344
|
+
if (type === 'idle_prompt') {
|
|
345
|
+
msg += `⏳ #${sessionNum} ${label} — Claude is idle\n`;
|
|
346
|
+
} else if (type === 'elicitation_dialog') {
|
|
347
|
+
msg += `💬 #${sessionNum} ${label} — Claude has a question\n`;
|
|
348
|
+
} else {
|
|
349
|
+
msg += `🔔 #${sessionNum} ${label} — ${type}\n`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
msg += `━━━━━━━━━━━━━━━━━━━━\n`;
|
|
353
|
+
|
|
354
|
+
if (context.userTask) {
|
|
355
|
+
msg += `📝 Task: ${context.userTask}\n\n`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (context.recentContext) {
|
|
359
|
+
msg += `💬 Claude said:\n${context.recentContext}\n`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (canReply) {
|
|
363
|
+
msg += `\n↩️ Reply to this message to send input`;
|
|
364
|
+
} else {
|
|
365
|
+
msg += `\nOpen your terminal to respond.`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return msg;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// --- Elicitation UI ---
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Send the next unanswered question for an elicitation to Telegram.
|
|
375
|
+
*/
|
|
376
|
+
async function sendElicitationQuestion(elicId) {
|
|
377
|
+
const elic = pendingElicitations.get(elicId);
|
|
378
|
+
if (!elic) return;
|
|
379
|
+
|
|
380
|
+
const qIdx = elic.answers.size;
|
|
381
|
+
if (qIdx >= elic.questions.length) {
|
|
382
|
+
await sendElicitationSummary(elicId);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const q = elic.questions[qIdx];
|
|
387
|
+
const sessionNum = getSessionLabel(elic.sessionId);
|
|
388
|
+
const session = sessions.get(elic.sessionId);
|
|
389
|
+
const label = session?.label || 'unknown';
|
|
390
|
+
const total = elic.questions.length;
|
|
391
|
+
const eid = shortId(elicId);
|
|
392
|
+
|
|
393
|
+
let msg = `💬 #${sessionNum} ${label} — Claude has a question\n`;
|
|
394
|
+
msg += `━━━━━━━━━━━━━━━━━━━━\n`;
|
|
395
|
+
if (elic.userTask) msg += `📝 Task: ${elic.userTask}\n\n`;
|
|
396
|
+
msg += `❓ [${qIdx + 1}/${total}] ${q.question}`;
|
|
397
|
+
if (q.multiSelect) msg += ` (select multiple)`;
|
|
398
|
+
msg += `\n`;
|
|
399
|
+
|
|
400
|
+
for (const opt of q.options) {
|
|
401
|
+
if (opt.description) {
|
|
402
|
+
msg += `\n• ${opt.label}: ${opt.description}`;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const buttons = q.options.map((opt, optIdx) =>
|
|
407
|
+
Markup.button.callback(opt.label, `e:${eid}:${qIdx}:${optIdx}`)
|
|
408
|
+
);
|
|
409
|
+
buttons.push(Markup.button.callback('✏️ Custom', `e:${eid}:${qIdx}:custom`));
|
|
410
|
+
|
|
411
|
+
if (q.multiSelect) {
|
|
412
|
+
buttons.push(Markup.button.callback('Next ➡️', `e:${eid}:${qIdx}:done`));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Arrange in rows of 2
|
|
416
|
+
const rows = [];
|
|
417
|
+
for (let i = 0; i < buttons.length; i += 2) {
|
|
418
|
+
rows.push(buttons.slice(i, i + 2));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const keyboard = Markup.inlineKeyboard(rows);
|
|
422
|
+
const sent = await bot.telegram.sendMessage(config.chatId, msg, keyboard);
|
|
423
|
+
elic.telegramMessageIds.push(sent.message_id);
|
|
424
|
+
elic.currentMessageId = sent.message_id;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Send a summary of all answers with Confirm/Redo buttons.
|
|
429
|
+
*/
|
|
430
|
+
async function sendElicitationSummary(elicId) {
|
|
431
|
+
const elic = pendingElicitations.get(elicId);
|
|
432
|
+
if (!elic) return;
|
|
433
|
+
|
|
434
|
+
const sessionNum = getSessionLabel(elic.sessionId);
|
|
435
|
+
const session = sessions.get(elic.sessionId);
|
|
436
|
+
const label = session?.label || 'unknown';
|
|
437
|
+
const eid = shortId(elicId);
|
|
438
|
+
|
|
439
|
+
let msg = `📋 #${sessionNum} ${label} — Your answers\n`;
|
|
440
|
+
msg += `━━━━━━━━━━━━━━━━━━━━\n`;
|
|
441
|
+
|
|
442
|
+
for (let i = 0; i < elic.questions.length; i++) {
|
|
443
|
+
const q = elic.questions[i];
|
|
444
|
+
const a = elic.answers.get(i);
|
|
445
|
+
let answerText;
|
|
446
|
+
if (a?.isCustom) {
|
|
447
|
+
answerText = `"${a.customText}"`;
|
|
448
|
+
} else if (a?.multiSelections) {
|
|
449
|
+
answerText = a.multiSelections.map((s) => s.label).join(', ');
|
|
450
|
+
} else {
|
|
451
|
+
answerText = a?.label || '?';
|
|
452
|
+
}
|
|
453
|
+
msg += `${i + 1}. ${q.header || q.question}: ${answerText}\n`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const summaryButtons = [
|
|
457
|
+
Markup.button.callback('✅ Confirm', `e:${eid}:confirm`),
|
|
458
|
+
Markup.button.callback('🔄 Redo', `e:${eid}:redo`),
|
|
459
|
+
];
|
|
460
|
+
if (elic.isPermission) {
|
|
461
|
+
summaryButtons.push(Markup.button.callback('❌ Deny', `e:${eid}:deny`));
|
|
462
|
+
}
|
|
463
|
+
const keyboard = Markup.inlineKeyboard(summaryButtons);
|
|
464
|
+
|
|
465
|
+
const sent = await bot.telegram.sendMessage(config.chatId, msg, keyboard);
|
|
466
|
+
elic.telegramMessageIds.push(sent.message_id);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Handle an elicitation callback query (prefix "e:").
|
|
471
|
+
*/
|
|
472
|
+
async function handleElicitationCallback(ctx, cbData) {
|
|
473
|
+
const parts = cbData.split(':');
|
|
474
|
+
const eid = parts[1];
|
|
475
|
+
|
|
476
|
+
let elicId;
|
|
477
|
+
for (const [id] of pendingElicitations) {
|
|
478
|
+
if (shortId(id) === eid) {
|
|
479
|
+
elicId = id;
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (!elicId) {
|
|
485
|
+
ctx.answerCbQuery('Expired or already answered.');
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const elic = pendingElicitations.get(elicId);
|
|
490
|
+
|
|
491
|
+
// Confirm
|
|
492
|
+
if (parts[2] === 'confirm') {
|
|
493
|
+
try { await ctx.answerCbQuery('Sending answers...'); } catch {}
|
|
494
|
+
try { await ctx.editMessageReplyMarkup(undefined); } catch {}
|
|
495
|
+
|
|
496
|
+
if (elic.isPermission && elic.permissionResolve) {
|
|
497
|
+
// From permission request — allow the tool, then inject answers after UI appears
|
|
498
|
+
elic.permissionResolve({ decision: 'allow' });
|
|
499
|
+
try { await ctx.editMessageText(ctx.callbackQuery.message.text + '\n\n✅ Answers submitted — injecting...'); } catch {}
|
|
500
|
+
|
|
501
|
+
setTimeout(() => {
|
|
502
|
+
const ok = injectElicitationAnswers(elic.ttyPath, elic.questions, elic.answers);
|
|
503
|
+
log(`Elicitation ${elicId}: ${ok ? 'keystrokes injected' : 'injection failed'}`);
|
|
504
|
+
if (!ok) {
|
|
505
|
+
bot.telegram.sendMessage(config.chatId, '⚠️ Could not inject answers into terminal').catch(() => {});
|
|
506
|
+
}
|
|
507
|
+
}, 2000);
|
|
508
|
+
} else {
|
|
509
|
+
// From notification flow — inject immediately
|
|
510
|
+
const ok = injectElicitationAnswers(elic.ttyPath, elic.questions, elic.answers);
|
|
511
|
+
try {
|
|
512
|
+
await ctx.editMessageText(ctx.callbackQuery.message.text +
|
|
513
|
+
(ok ? '\n\n✅ Answers submitted' : '\n\n⚠️ Could not send to terminal'));
|
|
514
|
+
} catch {}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
pendingElicitations.delete(elicId);
|
|
518
|
+
log(`Elicitation ${elicId}: confirmed`);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Deny (for permission-based elicitations)
|
|
523
|
+
if (parts[2] === 'deny') {
|
|
524
|
+
try { await ctx.answerCbQuery('Denied'); } catch {}
|
|
525
|
+
try { await ctx.editMessageReplyMarkup(undefined); } catch {}
|
|
526
|
+
try { await ctx.editMessageText(ctx.callbackQuery.message.text + '\n\n❌ Denied'); } catch {}
|
|
527
|
+
|
|
528
|
+
if (elic.isPermission && elic.permissionResolve) {
|
|
529
|
+
elic.permissionResolve({ decision: 'deny' });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
pendingElicitations.delete(elicId);
|
|
533
|
+
log(`Elicitation ${elicId}: denied`);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Redo
|
|
538
|
+
if (parts[2] === 'redo') {
|
|
539
|
+
try { await ctx.answerCbQuery('Starting over...'); } catch {}
|
|
540
|
+
try { await ctx.editMessageReplyMarkup(undefined); } catch {}
|
|
541
|
+
elic.answers.clear();
|
|
542
|
+
if (elic.multiToggles) elic.multiToggles.clear();
|
|
543
|
+
await sendElicitationQuestion(elicId);
|
|
544
|
+
log(`Elicitation ${elicId}: redo`);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const qIdx = parseInt(parts[2]);
|
|
549
|
+
const optAction = parts[3];
|
|
550
|
+
const q = elic.questions[qIdx];
|
|
551
|
+
|
|
552
|
+
if (!q) {
|
|
553
|
+
ctx.answerCbQuery('Invalid question.');
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Custom answer
|
|
558
|
+
if (optAction === 'custom') {
|
|
559
|
+
try { await ctx.answerCbQuery('Type your answer...'); } catch {}
|
|
560
|
+
try { await ctx.editMessageReplyMarkup(undefined); } catch {}
|
|
561
|
+
try { await ctx.editMessageText(ctx.callbackQuery.message.text + '\n\n✏️ Selected: Custom'); } catch {}
|
|
562
|
+
|
|
563
|
+
const prompt = await bot.telegram.sendMessage(
|
|
564
|
+
config.chatId,
|
|
565
|
+
`✏️ Type your custom answer for: "${q.question}"\n\nReply to this message with your answer.`
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
elic.customWaitingMessageId = prompt.message_id;
|
|
569
|
+
elic.customWaitingQIdx = qIdx;
|
|
570
|
+
elic.telegramMessageIds.push(prompt.message_id);
|
|
571
|
+
log(`Elicitation ${elicId}: waiting for custom answer to q${qIdx}`);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// MultiSelect "done" — save current toggles and advance
|
|
576
|
+
if (optAction === 'done') {
|
|
577
|
+
const toggles = elic.multiToggles || new Map();
|
|
578
|
+
const selected = toggles.get(qIdx) || new Set();
|
|
579
|
+
|
|
580
|
+
if (selected.size === 0) {
|
|
581
|
+
ctx.answerCbQuery('Select at least one option.');
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const selections = [...selected].sort().map((idx) => ({
|
|
586
|
+
label: q.options[idx].label,
|
|
587
|
+
optionIndex: idx,
|
|
588
|
+
}));
|
|
589
|
+
|
|
590
|
+
elic.answers.set(qIdx, {
|
|
591
|
+
label: selections.map((s) => s.label).join(', '),
|
|
592
|
+
optionIndex: selections[0].optionIndex,
|
|
593
|
+
isCustom: false,
|
|
594
|
+
customText: null,
|
|
595
|
+
multiSelections: selections,
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
const selLabels = selections.map((s) => s.label).join(', ');
|
|
599
|
+
try { await ctx.answerCbQuery(`Selected: ${selLabels}`); } catch {}
|
|
600
|
+
try { await ctx.editMessageReplyMarkup(undefined); } catch {}
|
|
601
|
+
try { await ctx.editMessageText(ctx.callbackQuery.message.text + `\n\n✅ Selected: ${selLabels}`); } catch {}
|
|
602
|
+
|
|
603
|
+
log(`Elicitation ${elicId}: q${qIdx} multi = [${selLabels}]`);
|
|
604
|
+
await sendElicitationQuestion(elicId);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Regular option
|
|
609
|
+
const optIdx = parseInt(optAction);
|
|
610
|
+
const opt = q.options[optIdx];
|
|
611
|
+
|
|
612
|
+
if (!opt) {
|
|
613
|
+
ctx.answerCbQuery('Invalid option.');
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// MultiSelect — toggle and update buttons
|
|
618
|
+
if (q.multiSelect) {
|
|
619
|
+
if (!elic.multiToggles) elic.multiToggles = new Map();
|
|
620
|
+
if (!elic.multiToggles.has(qIdx)) elic.multiToggles.set(qIdx, new Set());
|
|
621
|
+
const selected = elic.multiToggles.get(qIdx);
|
|
622
|
+
|
|
623
|
+
if (selected.has(optIdx)) {
|
|
624
|
+
selected.delete(optIdx);
|
|
625
|
+
ctx.answerCbQuery(`Deselected: ${opt.label}`);
|
|
626
|
+
} else {
|
|
627
|
+
selected.add(optIdx);
|
|
628
|
+
ctx.answerCbQuery(`Selected: ${opt.label}`);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Rebuild buttons with selection indicators
|
|
632
|
+
const buttons = q.options.map((o, i) => {
|
|
633
|
+
const prefix = selected.has(i) ? '✅ ' : '';
|
|
634
|
+
return Markup.button.callback(`${prefix}${o.label}`, `e:${eid}:${qIdx}:${i}`);
|
|
635
|
+
});
|
|
636
|
+
buttons.push(Markup.button.callback('✏️ Custom', `e:${eid}:${qIdx}:custom`));
|
|
637
|
+
buttons.push(Markup.button.callback('Next ➡️', `e:${eid}:${qIdx}:done`));
|
|
638
|
+
|
|
639
|
+
const rows = [];
|
|
640
|
+
for (let i = 0; i < buttons.length; i += 2) {
|
|
641
|
+
rows.push(buttons.slice(i, i + 2));
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
try { await ctx.editMessageReplyMarkup(Markup.inlineKeyboard(rows).reply_markup); } catch {}
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Single select — save and advance
|
|
649
|
+
elic.answers.set(qIdx, {
|
|
650
|
+
label: opt.label,
|
|
651
|
+
optionIndex: optIdx,
|
|
652
|
+
isCustom: false,
|
|
653
|
+
customText: null,
|
|
654
|
+
multiSelections: null,
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
try { await ctx.answerCbQuery(`Selected: ${opt.label}`); } catch {}
|
|
658
|
+
try { await ctx.editMessageReplyMarkup(undefined); } catch {}
|
|
659
|
+
try { await ctx.editMessageText(ctx.callbackQuery.message.text + `\n\n✅ Selected: ${opt.label}`); } catch {}
|
|
660
|
+
|
|
661
|
+
log(`Elicitation ${elicId}: q${qIdx} = ${opt.label}`);
|
|
662
|
+
await sendElicitationQuestion(elicId);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Inject elicitation answers into the terminal via osascript keystrokes.
|
|
667
|
+
* Navigates the AskUserQuestion form using arrow keys, space, tab, and enter.
|
|
668
|
+
*/
|
|
669
|
+
function injectElicitationAnswers(ttyPath, questions, answers) {
|
|
670
|
+
if (!ttyPath) {
|
|
671
|
+
log('injectElicitation: no TTY path');
|
|
672
|
+
return false;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Build sequence of key events
|
|
676
|
+
const events = [];
|
|
677
|
+
|
|
678
|
+
for (let qIdx = 0; qIdx < questions.length; qIdx++) {
|
|
679
|
+
const q = questions[qIdx];
|
|
680
|
+
const answer = answers.get(qIdx);
|
|
681
|
+
if (!answer) continue;
|
|
682
|
+
|
|
683
|
+
if (answer.isCustom) {
|
|
684
|
+
// Navigate to "Other" (last position, after all defined options)
|
|
685
|
+
const otherPos = q.options.length;
|
|
686
|
+
for (let i = 0; i < otherPos; i++) {
|
|
687
|
+
events.push({ type: 'key_code', value: 125 }); // arrow down
|
|
688
|
+
}
|
|
689
|
+
events.push({ type: 'key_code', value: 36 }); // enter to select Other
|
|
690
|
+
events.push({ type: 'delay', value: 0.3 });
|
|
691
|
+
events.push({ type: 'keystroke', value: answer.customText }); // type custom text
|
|
692
|
+
} else if (answer.multiSelections) {
|
|
693
|
+
// MultiSelect: walk through all options, space on selected ones
|
|
694
|
+
const selectedSet = new Set(answer.multiSelections.map((s) => s.optionIndex));
|
|
695
|
+
for (let i = 0; i < q.options.length; i++) {
|
|
696
|
+
if (selectedSet.has(i)) {
|
|
697
|
+
events.push({ type: 'keystroke', value: ' ' }); // space to toggle
|
|
698
|
+
}
|
|
699
|
+
if (i < q.options.length - 1) {
|
|
700
|
+
events.push({ type: 'key_code', value: 125 }); // arrow down
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
} else {
|
|
704
|
+
// Single select: navigate to selected option
|
|
705
|
+
for (let i = 0; i < answer.optionIndex; i++) {
|
|
706
|
+
events.push({ type: 'key_code', value: 125 }); // arrow down
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Tab to next question, or nothing for the last one
|
|
711
|
+
if (qIdx < questions.length - 1) {
|
|
712
|
+
events.push({ type: 'key_code', value: 48 }); // tab
|
|
713
|
+
events.push({ type: 'delay', value: 0.15 });
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Submit the form
|
|
718
|
+
events.push({ type: 'delay', value: 0.2 });
|
|
719
|
+
events.push({ type: 'keystroke', value: 'return' });
|
|
720
|
+
|
|
721
|
+
// Build key action lines for AppleScript
|
|
722
|
+
const keyLines = events.map((e) => {
|
|
723
|
+
if (e.type === 'key_code') return ` key code ${e.value}`;
|
|
724
|
+
if (e.type === 'keystroke') {
|
|
725
|
+
if (e.value === 'return') return ' keystroke return';
|
|
726
|
+
if (e.value === ' ') return ' keystroke " "';
|
|
727
|
+
return ` keystroke "${escapeAppleScript(e.value)}"`;
|
|
728
|
+
}
|
|
729
|
+
if (e.type === 'delay') return ` delay ${e.value}`;
|
|
730
|
+
return '';
|
|
731
|
+
}).join('\n');
|
|
732
|
+
|
|
733
|
+
// Try iTerm2 first (focus + System Events)
|
|
734
|
+
try {
|
|
735
|
+
const script = [
|
|
736
|
+
'tell application "iTerm2"',
|
|
737
|
+
' activate',
|
|
738
|
+
' repeat with w in windows',
|
|
739
|
+
' repeat with t in tabs of w',
|
|
740
|
+
' repeat with s in sessions of t',
|
|
741
|
+
` if tty of s is "${ttyPath}" then`,
|
|
742
|
+
' select s',
|
|
743
|
+
' end if',
|
|
744
|
+
' end repeat',
|
|
745
|
+
' end repeat',
|
|
746
|
+
' end repeat',
|
|
747
|
+
'end tell',
|
|
748
|
+
'delay 0.5',
|
|
749
|
+
'tell application "System Events"',
|
|
750
|
+
' tell process "iTerm2"',
|
|
751
|
+
keyLines,
|
|
752
|
+
' end tell',
|
|
753
|
+
'end tell',
|
|
754
|
+
].join('\n');
|
|
755
|
+
|
|
756
|
+
fs.writeFileSync('/tmp/claude-tg-elicit.scpt', script);
|
|
757
|
+
execSync('osascript /tmp/claude-tg-elicit.scpt', { timeout: 30000, stdio: 'pipe' });
|
|
758
|
+
log(`Elicitation keystrokes sent via iTerm2 to ${ttyPath}`);
|
|
759
|
+
return true;
|
|
760
|
+
} catch {}
|
|
761
|
+
|
|
762
|
+
// Try Terminal.app
|
|
763
|
+
try {
|
|
764
|
+
const script = [
|
|
765
|
+
'tell application "Terminal"',
|
|
766
|
+
' repeat with w in windows',
|
|
767
|
+
' repeat with t in tabs of w',
|
|
768
|
+
` if tty of t is "${ttyPath}" then`,
|
|
769
|
+
' set selected tab of w to t',
|
|
770
|
+
' set frontmost of w to true',
|
|
771
|
+
' end if',
|
|
772
|
+
' end repeat',
|
|
773
|
+
' end repeat',
|
|
774
|
+
'end tell',
|
|
775
|
+
'delay 0.5',
|
|
776
|
+
'tell application "System Events"',
|
|
777
|
+
' tell process "Terminal"',
|
|
778
|
+
keyLines,
|
|
779
|
+
' end tell',
|
|
780
|
+
'end tell',
|
|
781
|
+
].join('\n');
|
|
782
|
+
|
|
783
|
+
fs.writeFileSync('/tmp/claude-tg-elicit.scpt', script);
|
|
784
|
+
execSync('osascript /tmp/claude-tg-elicit.scpt', { timeout: 30000, stdio: 'pipe' });
|
|
785
|
+
log(`Elicitation keystrokes sent via Terminal.app to ${ttyPath}`);
|
|
786
|
+
return true;
|
|
787
|
+
} catch (err) {
|
|
788
|
+
log(`Terminal.app elicitation error: ${err.message}`);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
log(`injectElicitation failed: no terminal found for ${ttyPath}`);
|
|
792
|
+
return false;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// --- Telegram bot ---
|
|
796
|
+
|
|
797
|
+
function startBot() {
|
|
798
|
+
bot = new Telegraf(config.botToken);
|
|
799
|
+
|
|
800
|
+
bot.command('start', (ctx) => {
|
|
801
|
+
const chatId = ctx.chat.id.toString();
|
|
802
|
+
ctx.reply(`Chat ID registered: ${chatId}\n\nThis chat will receive Claude Code permission requests.`);
|
|
803
|
+
log(`/start from chat ${chatId}`);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
bot.command('status', (ctx) => {
|
|
807
|
+
const pendingCount = pendingQuestions.size;
|
|
808
|
+
const activeSessions = [...sessions.entries()].filter(([, s]) => {
|
|
809
|
+
if (s.ttyPath) return isTtyAlive(s.ttyPath);
|
|
810
|
+
return Date.now() - s.lastActive < 60 * 60 * 1000;
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
let msg = '';
|
|
814
|
+
if (activeSessions.length === 0 && pendingCount === 0) {
|
|
815
|
+
ctx.reply('No active sessions or pending requests.');
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (activeSessions.length > 0) {
|
|
820
|
+
msg += `${activeSessions.length} active session(s):\n`;
|
|
821
|
+
for (const [sid, s] of activeSessions) {
|
|
822
|
+
const num = getSessionLabel(sid);
|
|
823
|
+
const tty = s.ttyPath ? s.ttyPath.split('/').pop() : 'no tty';
|
|
824
|
+
msg += ` #${num} ${s.label} (${tty})\n`;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (pendingCount > 0) {
|
|
829
|
+
msg += `\n${pendingCount} pending permission(s):\n`;
|
|
830
|
+
for (const [id, q] of pendingQuestions) {
|
|
831
|
+
const age = Math.round((Date.now() - q.createdAt) / 1000 / 60);
|
|
832
|
+
const sLabel = getSessionLabel(q.sessionId);
|
|
833
|
+
msg += ` #${sLabel} ${q.toolName} (${age}m ago)\n`;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (pendingElicitations.size > 0) {
|
|
838
|
+
msg += `\n${pendingElicitations.size} pending elicitation(s):\n`;
|
|
839
|
+
for (const [, elic] of pendingElicitations) {
|
|
840
|
+
const sLabel = getSessionLabel(elic.sessionId);
|
|
841
|
+
msg += ` #${sLabel} ${elic.answers.size}/${elic.questions.length} answered\n`;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
ctx.reply(msg);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
// Handle inline keyboard button presses (permission decisions + elicitations)
|
|
849
|
+
bot.on('callback_query', async (ctx) => {
|
|
850
|
+
const data = ctx.callbackQuery.data;
|
|
851
|
+
if (!data) return;
|
|
852
|
+
|
|
853
|
+
// Route elicitation callbacks
|
|
854
|
+
if (data.startsWith('e:')) {
|
|
855
|
+
await handleElicitationCallback(ctx, data);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const [rid, action] = data.split(':');
|
|
860
|
+
let requestId;
|
|
861
|
+
for (const [id] of pendingQuestions) {
|
|
862
|
+
if (shortId(id) === rid) {
|
|
863
|
+
requestId = id;
|
|
864
|
+
break;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (!requestId) {
|
|
869
|
+
ctx.answerCbQuery('Request expired or already answered.');
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const pending = pendingQuestions.get(requestId);
|
|
874
|
+
pendingQuestions.delete(requestId);
|
|
875
|
+
|
|
876
|
+
let label;
|
|
877
|
+
if (action === 'allow') {
|
|
878
|
+
label = '✅ Allowed';
|
|
879
|
+
pending.resolve({ decision: 'allow' });
|
|
880
|
+
} else if (action === 'deny') {
|
|
881
|
+
label = '❌ Denied';
|
|
882
|
+
pending.resolve({ decision: 'deny' });
|
|
883
|
+
} else if (action === 'always') {
|
|
884
|
+
label = '✅ Always Allowed';
|
|
885
|
+
pending.resolve({ decision: 'always' });
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
try { await ctx.answerCbQuery(label); } catch {}
|
|
889
|
+
try { await ctx.editMessageReplyMarkup(undefined); } catch {}
|
|
890
|
+
try { await ctx.editMessageText(ctx.callbackQuery.message.text + `\n\n${label}`); } catch {}
|
|
891
|
+
log(`Answered ${requestId}: ${action}`);
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
// Handle text messages — route as user input to a terminal
|
|
895
|
+
bot.on('text', async (ctx) => {
|
|
896
|
+
// Ignore commands
|
|
897
|
+
if (ctx.message.text.startsWith('/')) return;
|
|
898
|
+
|
|
899
|
+
const text = ctx.message.text;
|
|
900
|
+
const replyTo = ctx.message.reply_to_message?.message_id;
|
|
901
|
+
|
|
902
|
+
// Check if this is a reply to a custom elicitation answer prompt
|
|
903
|
+
if (replyTo) {
|
|
904
|
+
for (const [elicId, elic] of pendingElicitations) {
|
|
905
|
+
if (elic.customWaitingMessageId === replyTo) {
|
|
906
|
+
const qIdx = elic.customWaitingQIdx;
|
|
907
|
+
const q = elic.questions[qIdx];
|
|
908
|
+
|
|
909
|
+
elic.answers.set(qIdx, {
|
|
910
|
+
label: text,
|
|
911
|
+
optionIndex: q.options.length, // "Other" position
|
|
912
|
+
isCustom: true,
|
|
913
|
+
customText: text,
|
|
914
|
+
multiSelections: null,
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
elic.customWaitingMessageId = null;
|
|
918
|
+
elic.customWaitingQIdx = null;
|
|
919
|
+
|
|
920
|
+
ctx.reply(`✅ Custom answer for "${q.question}": ${text}`);
|
|
921
|
+
log(`Elicitation ${elicId}: q${qIdx} custom = "${text}"`);
|
|
922
|
+
|
|
923
|
+
await sendElicitationQuestion(elicId);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
let targetSessionId = null;
|
|
930
|
+
|
|
931
|
+
// If replying to a specific message, route to that session
|
|
932
|
+
if (replyTo) {
|
|
933
|
+
const mapping = messageToSession.get(replyTo);
|
|
934
|
+
if (mapping) {
|
|
935
|
+
if (mapping.type === 'permission') {
|
|
936
|
+
ctx.reply('⚠️ That was a permission request — use the buttons above.\nTo send text input, reply to a notification message.');
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
targetSessionId = mapping.sessionId;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// If no reply-to, try auto-routing
|
|
944
|
+
if (!targetSessionId) {
|
|
945
|
+
// Find sessions that have notifications and are still alive (TTY exists)
|
|
946
|
+
const recentNotifications = [...messageToSession.entries()]
|
|
947
|
+
.filter(([, m]) => {
|
|
948
|
+
if (m.type !== 'notification') return false;
|
|
949
|
+
const s = sessions.get(m.sessionId);
|
|
950
|
+
if (s?.ttyPath) return isTtyAlive(s.ttyPath);
|
|
951
|
+
return Date.now() - m.createdAt < 60 * 60 * 1000;
|
|
952
|
+
})
|
|
953
|
+
.map(([, m]) => m.sessionId);
|
|
954
|
+
|
|
955
|
+
const uniqueSessions = [...new Set(recentNotifications)];
|
|
956
|
+
|
|
957
|
+
if (uniqueSessions.length === 1) {
|
|
958
|
+
targetSessionId = uniqueSessions[0];
|
|
959
|
+
} else if (uniqueSessions.length === 0) {
|
|
960
|
+
ctx.reply('No idle Claude sessions to send input to.');
|
|
961
|
+
return;
|
|
962
|
+
} else {
|
|
963
|
+
// Multiple sessions — ask user to be specific
|
|
964
|
+
const labels = uniqueSessions.map((sid) => {
|
|
965
|
+
const s = sessions.get(sid);
|
|
966
|
+
const num = getSessionLabel(sid);
|
|
967
|
+
return ` #${num} ${s?.label || 'unknown'}`;
|
|
968
|
+
}).join('\n');
|
|
969
|
+
ctx.reply(`Multiple sessions are waiting. Reply to a specific notification message to choose:\n\n${labels}`);
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Send the text to the terminal
|
|
975
|
+
const session = sessions.get(targetSessionId);
|
|
976
|
+
if (!session || !session.ttyPath) {
|
|
977
|
+
ctx.reply(`⚠️ No TTY for session #${getSessionLabel(targetSessionId)}. Open the terminal to respond.`);
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const ok = sendInputToTerminal(session.ttyPath, text);
|
|
982
|
+
if (ok) {
|
|
983
|
+
const num = getSessionLabel(targetSessionId);
|
|
984
|
+
ctx.reply(`➡️ Sent to #${num} ${session.label}`);
|
|
985
|
+
} else {
|
|
986
|
+
ctx.reply(`⚠️ Could not send to terminal. Session may have ended, or terminal app not recognized.`);
|
|
987
|
+
}
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
bot.catch((err) => {
|
|
991
|
+
log(`Bot error: ${err.message}`);
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
bot.launch({ dropPendingUpdates: true });
|
|
995
|
+
log('Telegram bot started');
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// --- HTTP handlers ---
|
|
999
|
+
|
|
1000
|
+
async function sendPermissionRequest(data) {
|
|
1001
|
+
trackSession(data);
|
|
1002
|
+
|
|
1003
|
+
// AskUserQuestion — show actual questions with option buttons instead of Allow/Deny
|
|
1004
|
+
if (data.tool_name === 'AskUserQuestion' && data.tool_input?.questions?.length > 0) {
|
|
1005
|
+
return handleElicitationPermission(data);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const requestId = crypto.randomUUID();
|
|
1009
|
+
const rid = shortId(requestId);
|
|
1010
|
+
const context = extractContext(data.transcript_path);
|
|
1011
|
+
const msg = formatPermissionMessage(data, context);
|
|
1012
|
+
|
|
1013
|
+
const keyboard = Markup.inlineKeyboard([
|
|
1014
|
+
Markup.button.callback('Allow', `${rid}:allow`),
|
|
1015
|
+
Markup.button.callback('Deny', `${rid}:deny`),
|
|
1016
|
+
Markup.button.callback('Always Allow', `${rid}:always`),
|
|
1017
|
+
]);
|
|
1018
|
+
|
|
1019
|
+
const sent = await bot.telegram.sendMessage(config.chatId, msg, keyboard);
|
|
1020
|
+
|
|
1021
|
+
// Track this message for reply routing
|
|
1022
|
+
messageToSession.set(sent.message_id, {
|
|
1023
|
+
sessionId: data.session_id,
|
|
1024
|
+
type: 'permission',
|
|
1025
|
+
createdAt: Date.now(),
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
return new Promise((resolve) => {
|
|
1029
|
+
pendingQuestions.set(requestId, {
|
|
1030
|
+
resolve,
|
|
1031
|
+
sessionId: data.session_id,
|
|
1032
|
+
toolName: data.tool_name || 'Unknown',
|
|
1033
|
+
createdAt: Date.now(),
|
|
1034
|
+
telegramMessageId: sent.message_id,
|
|
1035
|
+
permissionSuggestions: data.permission_suggestions,
|
|
1036
|
+
});
|
|
1037
|
+
log(`Permission request ${requestId} for ${data.tool_name}`);
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Handle AskUserQuestion as an interactive elicitation via Telegram.
|
|
1043
|
+
* Returns a promise that resolves with { decision: 'allow' | 'deny' } when user confirms/denies.
|
|
1044
|
+
*/
|
|
1045
|
+
function handleElicitationPermission(data) {
|
|
1046
|
+
const elicId = crypto.randomUUID();
|
|
1047
|
+
const context = extractContext(data.transcript_path);
|
|
1048
|
+
const questions = data.tool_input.questions;
|
|
1049
|
+
|
|
1050
|
+
pendingElicitations.set(elicId, {
|
|
1051
|
+
sessionId: data.session_id,
|
|
1052
|
+
ttyPath: data.tty_path || sessions.get(data.session_id)?.ttyPath,
|
|
1053
|
+
questions,
|
|
1054
|
+
answers: new Map(),
|
|
1055
|
+
multiToggles: new Map(),
|
|
1056
|
+
telegramMessageIds: [],
|
|
1057
|
+
currentMessageId: null,
|
|
1058
|
+
customWaitingMessageId: null,
|
|
1059
|
+
customWaitingQIdx: null,
|
|
1060
|
+
userTask: context.userTask,
|
|
1061
|
+
createdAt: Date.now(),
|
|
1062
|
+
isPermission: true,
|
|
1063
|
+
permissionResolve: null,
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
log(`Elicitation (permission) ${elicId}: ${questions.length} question(s)`);
|
|
1067
|
+
sendElicitationQuestion(elicId).catch((e) => log(`Elicitation send error: ${e.message}`));
|
|
1068
|
+
|
|
1069
|
+
return new Promise((resolve) => {
|
|
1070
|
+
const elic = pendingElicitations.get(elicId);
|
|
1071
|
+
elic.permissionResolve = resolve;
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
async function sendNotification(data) {
|
|
1076
|
+
trackSession(data);
|
|
1077
|
+
|
|
1078
|
+
const notifType = data.notification_type || data.type;
|
|
1079
|
+
|
|
1080
|
+
// Check if this is an elicitation
|
|
1081
|
+
if (notifType === 'elicitation_dialog') {
|
|
1082
|
+
// Skip if already handling this session's elicitation via permission request
|
|
1083
|
+
for (const [, elic] of pendingElicitations) {
|
|
1084
|
+
if (elic.sessionId === data.session_id) {
|
|
1085
|
+
log(`Skipping elicitation_dialog — already handling for session ${data.session_id}`);
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const questions = extractElicitation(data.transcript_path);
|
|
1091
|
+
if (questions && questions.length > 0) {
|
|
1092
|
+
const elicId = crypto.randomUUID();
|
|
1093
|
+
const context = extractContext(data.transcript_path);
|
|
1094
|
+
|
|
1095
|
+
pendingElicitations.set(elicId, {
|
|
1096
|
+
sessionId: data.session_id,
|
|
1097
|
+
ttyPath: data.tty_path || sessions.get(data.session_id)?.ttyPath,
|
|
1098
|
+
questions,
|
|
1099
|
+
answers: new Map(),
|
|
1100
|
+
multiToggles: new Map(),
|
|
1101
|
+
telegramMessageIds: [],
|
|
1102
|
+
currentMessageId: null,
|
|
1103
|
+
customWaitingMessageId: null,
|
|
1104
|
+
customWaitingQIdx: null,
|
|
1105
|
+
userTask: context.userTask,
|
|
1106
|
+
createdAt: Date.now(),
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
log(`Elicitation ${elicId}: ${questions.length} question(s) from session ${data.session_id}`);
|
|
1110
|
+
await sendElicitationQuestion(elicId);
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Default notification flow
|
|
1116
|
+
const context = extractContext(data.transcript_path);
|
|
1117
|
+
const msg = formatNotification(data, context);
|
|
1118
|
+
const sent = await bot.telegram.sendMessage(config.chatId, msg);
|
|
1119
|
+
|
|
1120
|
+
messageToSession.set(sent.message_id, {
|
|
1121
|
+
sessionId: data.session_id,
|
|
1122
|
+
type: 'notification',
|
|
1123
|
+
createdAt: Date.now(),
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
log(`Notification sent: ${notifType} (msg ${sent.message_id})`);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function readBody(req) {
|
|
1130
|
+
return new Promise((resolve, reject) => {
|
|
1131
|
+
let body = '';
|
|
1132
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
1133
|
+
req.on('end', () => {
|
|
1134
|
+
try { resolve(JSON.parse(body)); }
|
|
1135
|
+
catch (e) { reject(e); }
|
|
1136
|
+
});
|
|
1137
|
+
req.on('error', reject);
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function startServer() {
|
|
1142
|
+
const server = http.createServer(async (req, res) => {
|
|
1143
|
+
try {
|
|
1144
|
+
if (req.method === 'GET' && req.url === '/api/health') {
|
|
1145
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1146
|
+
res.end(JSON.stringify({
|
|
1147
|
+
status: 'ok',
|
|
1148
|
+
pending: pendingQuestions.size,
|
|
1149
|
+
sessions: sessions.size,
|
|
1150
|
+
}));
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
if (req.method === 'POST' && req.url === '/api/permission') {
|
|
1155
|
+
const data = await readBody(req);
|
|
1156
|
+
const result = await sendPermissionRequest(data);
|
|
1157
|
+
|
|
1158
|
+
let response;
|
|
1159
|
+
if (result.decision === 'allow') {
|
|
1160
|
+
response = { decision: { behavior: 'allow' } };
|
|
1161
|
+
} else if (result.decision === 'deny') {
|
|
1162
|
+
response = { decision: { behavior: 'deny', message: 'Denied via Telegram' } };
|
|
1163
|
+
} else if (result.decision === 'always') {
|
|
1164
|
+
response = {
|
|
1165
|
+
decision: {
|
|
1166
|
+
behavior: 'allow',
|
|
1167
|
+
updatedPermissions: data.permission_suggestions,
|
|
1168
|
+
},
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1173
|
+
res.end(JSON.stringify(response));
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
if (req.method === 'POST' && req.url === '/api/notify') {
|
|
1178
|
+
const data = await readBody(req);
|
|
1179
|
+
sendNotification(data).catch((e) => log(`Notify error: ${e.message}`));
|
|
1180
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1181
|
+
res.end(JSON.stringify({ status: 'ok' }));
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
res.writeHead(404);
|
|
1186
|
+
res.end('Not found');
|
|
1187
|
+
} catch (err) {
|
|
1188
|
+
log(`Server error: ${err.message}`);
|
|
1189
|
+
res.writeHead(500);
|
|
1190
|
+
res.end('Internal error');
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
server.listen(config.port, '127.0.0.1', () => {
|
|
1195
|
+
log(`HTTP server listening on 127.0.0.1:${config.port}`);
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
return server;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// --- Cleanup stale state periodically ---
|
|
1202
|
+
|
|
1203
|
+
function startCleanup() {
|
|
1204
|
+
setInterval(() => {
|
|
1205
|
+
const now = Date.now();
|
|
1206
|
+
const staleThreshold = 60 * 60 * 1000; // 1 hour
|
|
1207
|
+
|
|
1208
|
+
// Clean message mappings — keep if session's TTY is still alive
|
|
1209
|
+
for (const [msgId, m] of messageToSession) {
|
|
1210
|
+
const s = sessions.get(m.sessionId);
|
|
1211
|
+
if (s?.ttyPath && isTtyAlive(s.ttyPath)) continue;
|
|
1212
|
+
if (now - m.createdAt > staleThreshold) {
|
|
1213
|
+
messageToSession.delete(msgId);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// Clean stale sessions — only if terminal is gone
|
|
1218
|
+
for (const [sid, s] of sessions) {
|
|
1219
|
+
if (s.ttyPath) {
|
|
1220
|
+
if (!isTtyAlive(s.ttyPath)) {
|
|
1221
|
+
sessions.delete(sid);
|
|
1222
|
+
log(`Cleaned session ${sid} (TTY gone: ${s.ttyPath})`);
|
|
1223
|
+
}
|
|
1224
|
+
} else if (now - s.lastActive > staleThreshold) {
|
|
1225
|
+
sessions.delete(sid);
|
|
1226
|
+
log(`Cleaned stale session ${sid} (no TTY, inactive)`);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// Clean stale elicitations (30 min timeout)
|
|
1231
|
+
for (const [elicId, elic] of pendingElicitations) {
|
|
1232
|
+
if (now - elic.createdAt > 30 * 60 * 1000) {
|
|
1233
|
+
pendingElicitations.delete(elicId);
|
|
1234
|
+
log(`Cleaned stale elicitation ${elicId}`);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
}, 10 * 60 * 1000); // every 10 min
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// --- Main ---
|
|
1241
|
+
|
|
1242
|
+
function main() {
|
|
1243
|
+
config = loadConfig();
|
|
1244
|
+
if (!config.botToken || !config.chatId) {
|
|
1245
|
+
console.error('Missing botToken or chatId. Run: claude-tg setup');
|
|
1246
|
+
process.exit(1);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
log('Daemon starting...');
|
|
1250
|
+
startBot();
|
|
1251
|
+
startServer();
|
|
1252
|
+
startCleanup();
|
|
1253
|
+
|
|
1254
|
+
process.on('unhandledRejection', (err) => {
|
|
1255
|
+
log(`Unhandled rejection: ${err?.message || err}`);
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
process.on('SIGTERM', () => {
|
|
1259
|
+
log('Received SIGTERM, shutting down');
|
|
1260
|
+
bot.stop('SIGTERM');
|
|
1261
|
+
process.exit(0);
|
|
1262
|
+
});
|
|
1263
|
+
process.on('SIGINT', () => {
|
|
1264
|
+
log('Received SIGINT, shutting down');
|
|
1265
|
+
bot.stop('SIGINT');
|
|
1266
|
+
process.exit(0);
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
if (require.main === module) {
|
|
1271
|
+
main();
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
module.exports = { main };
|