telegram-claude-mcp 1.0.3 → 1.2.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/hooks/notify-hook.sh +76 -0
- package/hooks/permission-hook.sh +86 -0
- package/package.json +7 -4
- package/src/index.ts +172 -1
- package/src/telegram.ts +204 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
#
|
|
3
|
+
# Claude Code Notification Hook
|
|
4
|
+
#
|
|
5
|
+
# This script receives notification events from Claude Code (stop, session events)
|
|
6
|
+
# and forwards them to the Telegram MCP server.
|
|
7
|
+
#
|
|
8
|
+
# The script auto-discovers the MCP server port from session info files.
|
|
9
|
+
# No configuration needed - it finds running MCP servers automatically.
|
|
10
|
+
#
|
|
11
|
+
|
|
12
|
+
SESSION_DIR="/tmp/telegram-claude-sessions"
|
|
13
|
+
EVENT_TYPE="${TELEGRAM_HOOK_EVENT:-stop}"
|
|
14
|
+
|
|
15
|
+
# Function to find the most recent active session
|
|
16
|
+
find_active_session() {
|
|
17
|
+
local latest_file=""
|
|
18
|
+
local latest_time=0
|
|
19
|
+
|
|
20
|
+
for info_file in "$SESSION_DIR"/*.info 2>/dev/null; do
|
|
21
|
+
[ -f "$info_file" ] || continue
|
|
22
|
+
|
|
23
|
+
# Check if the process is still running
|
|
24
|
+
local pid=$(jq -r '.pid // empty' "$info_file" 2>/dev/null)
|
|
25
|
+
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
|
26
|
+
local file_time=$(stat -f %m "$info_file" 2>/dev/null || stat -c %Y "$info_file" 2>/dev/null)
|
|
27
|
+
if [ "$file_time" -gt "$latest_time" ]; then
|
|
28
|
+
latest_time=$file_time
|
|
29
|
+
latest_file=$info_file
|
|
30
|
+
fi
|
|
31
|
+
fi
|
|
32
|
+
done
|
|
33
|
+
|
|
34
|
+
echo "$latest_file"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Find active session info
|
|
38
|
+
INFO_FILE=$(find_active_session)
|
|
39
|
+
|
|
40
|
+
if [ -z "$INFO_FILE" ] || [ ! -f "$INFO_FILE" ]; then
|
|
41
|
+
echo "[notify-hook] No active session found, skipping notification" >&2
|
|
42
|
+
echo '{"continue": true}'
|
|
43
|
+
exit 0
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
# Read port from session info
|
|
47
|
+
HOOK_PORT=$(jq -r '.port' "$INFO_FILE")
|
|
48
|
+
HOOK_HOST=$(jq -r '.host // "localhost"' "$INFO_FILE")
|
|
49
|
+
HOOK_URL="http://${HOOK_HOST}:${HOOK_PORT}/notify"
|
|
50
|
+
|
|
51
|
+
echo "[notify-hook] Using session: $INFO_FILE (port $HOOK_PORT)" >&2
|
|
52
|
+
|
|
53
|
+
# Read the hook input from stdin
|
|
54
|
+
INPUT=$(cat)
|
|
55
|
+
|
|
56
|
+
# Extract relevant information
|
|
57
|
+
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty')
|
|
58
|
+
MESSAGE=$(echo "$INPUT" | jq -r '.message // .stop_reason // "Agent stopped"')
|
|
59
|
+
|
|
60
|
+
echo "[notify-hook] Event: $EVENT_TYPE, Message: $MESSAGE" >&2
|
|
61
|
+
|
|
62
|
+
# Build the request payload
|
|
63
|
+
PAYLOAD=$(jq -n \
|
|
64
|
+
--arg type "$EVENT_TYPE" \
|
|
65
|
+
--arg message "$MESSAGE" \
|
|
66
|
+
--arg session_id "$SESSION_ID" \
|
|
67
|
+
'{type: $type, message: $message, session_id: $session_id}')
|
|
68
|
+
|
|
69
|
+
# Send the notification (non-blocking)
|
|
70
|
+
curl -s -X POST "$HOOK_URL" \
|
|
71
|
+
-H "Content-Type: application/json" \
|
|
72
|
+
-d "$PAYLOAD" \
|
|
73
|
+
--max-time 10 >/dev/null 2>&1 &
|
|
74
|
+
|
|
75
|
+
# Return success immediately
|
|
76
|
+
echo '{"continue": true}'
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
#
|
|
3
|
+
# Claude Code Permission Hook
|
|
4
|
+
#
|
|
5
|
+
# This script receives permission requests from Claude Code and forwards them
|
|
6
|
+
# to the Telegram MCP server for interactive approval via Telegram.
|
|
7
|
+
#
|
|
8
|
+
# The script auto-discovers the MCP server port from session info files.
|
|
9
|
+
# No configuration needed - it finds running MCP servers automatically.
|
|
10
|
+
#
|
|
11
|
+
|
|
12
|
+
SESSION_DIR="/tmp/telegram-claude-sessions"
|
|
13
|
+
|
|
14
|
+
# Function to find the most recent active session
|
|
15
|
+
find_active_session() {
|
|
16
|
+
local latest_file=""
|
|
17
|
+
local latest_time=0
|
|
18
|
+
|
|
19
|
+
for info_file in "$SESSION_DIR"/*.info 2>/dev/null; do
|
|
20
|
+
[ -f "$info_file" ] || continue
|
|
21
|
+
|
|
22
|
+
# Check if the process is still running
|
|
23
|
+
local pid=$(jq -r '.pid // empty' "$info_file" 2>/dev/null)
|
|
24
|
+
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
|
25
|
+
local file_time=$(stat -f %m "$info_file" 2>/dev/null || stat -c %Y "$info_file" 2>/dev/null)
|
|
26
|
+
if [ "$file_time" -gt "$latest_time" ]; then
|
|
27
|
+
latest_time=$file_time
|
|
28
|
+
latest_file=$info_file
|
|
29
|
+
fi
|
|
30
|
+
fi
|
|
31
|
+
done
|
|
32
|
+
|
|
33
|
+
echo "$latest_file"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# Find active session info
|
|
37
|
+
INFO_FILE=$(find_active_session)
|
|
38
|
+
|
|
39
|
+
if [ -z "$INFO_FILE" ] || [ ! -f "$INFO_FILE" ]; then
|
|
40
|
+
echo "[permission-hook] No active session found" >&2
|
|
41
|
+
# Return empty to fall back to default behavior (terminal prompt)
|
|
42
|
+
exit 0
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
# Read port from session info
|
|
46
|
+
HOOK_PORT=$(jq -r '.port' "$INFO_FILE")
|
|
47
|
+
HOOK_HOST=$(jq -r '.host // "localhost"' "$INFO_FILE")
|
|
48
|
+
HOOK_URL="http://${HOOK_HOST}:${HOOK_PORT}/permission"
|
|
49
|
+
|
|
50
|
+
echo "[permission-hook] Using session: $INFO_FILE (port $HOOK_PORT)" >&2
|
|
51
|
+
|
|
52
|
+
# Read the hook input from stdin
|
|
53
|
+
INPUT=$(cat)
|
|
54
|
+
|
|
55
|
+
# Extract tool_name and tool_input from the Claude Code hook format
|
|
56
|
+
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
|
|
57
|
+
TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}')
|
|
58
|
+
|
|
59
|
+
# If no tool_name, try alternative paths
|
|
60
|
+
if [ -z "$TOOL_NAME" ]; then
|
|
61
|
+
TOOL_NAME=$(echo "$INPUT" | jq -r '.toolName // .name // empty')
|
|
62
|
+
TOOL_INPUT=$(echo "$INPUT" | jq -c '.toolInput // .input // .arguments // {}')
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
echo "[permission-hook] Tool: $TOOL_NAME" >&2
|
|
66
|
+
|
|
67
|
+
# Build the request payload
|
|
68
|
+
PAYLOAD=$(jq -n \
|
|
69
|
+
--arg tool_name "$TOOL_NAME" \
|
|
70
|
+
--argjson tool_input "$TOOL_INPUT" \
|
|
71
|
+
'{tool_name: $tool_name, tool_input: $tool_input}')
|
|
72
|
+
|
|
73
|
+
# Send the request to the MCP server and wait for response
|
|
74
|
+
RESPONSE=$(curl -s -X POST "$HOOK_URL" \
|
|
75
|
+
-H "Content-Type: application/json" \
|
|
76
|
+
-d "$PAYLOAD" \
|
|
77
|
+
--max-time 600)
|
|
78
|
+
|
|
79
|
+
if [ $? -ne 0 ]; then
|
|
80
|
+
echo "[permission-hook] Error: Failed to connect to hook server" >&2
|
|
81
|
+
# Return empty to fall back to default behavior
|
|
82
|
+
exit 0
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
echo "[permission-hook] Response: $RESPONSE" >&2
|
|
86
|
+
echo "$RESPONSE"
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "telegram-claude-mcp",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "MCP server that lets Claude message you on Telegram",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "MCP server that lets Claude message you on Telegram with hooks support",
|
|
5
5
|
"author": "Geravant",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
"mcp",
|
|
15
15
|
"claude-code",
|
|
16
16
|
"bot",
|
|
17
|
-
"messaging"
|
|
17
|
+
"messaging",
|
|
18
|
+
"hooks",
|
|
19
|
+
"permissions"
|
|
18
20
|
],
|
|
19
21
|
"type": "module",
|
|
20
22
|
"main": "src/index.ts",
|
|
@@ -23,7 +25,8 @@
|
|
|
23
25
|
},
|
|
24
26
|
"files": [
|
|
25
27
|
"src",
|
|
26
|
-
"bin"
|
|
28
|
+
"bin",
|
|
29
|
+
"hooks"
|
|
27
30
|
],
|
|
28
31
|
"scripts": {
|
|
29
32
|
"start": "node --import tsx src/index.ts",
|
package/src/index.ts
CHANGED
|
@@ -5,13 +5,81 @@
|
|
|
5
5
|
*
|
|
6
6
|
* A stdio-based MCP server that lets Claude message you on Telegram.
|
|
7
7
|
* Supports multiple Claude Code sessions with message tagging.
|
|
8
|
+
*
|
|
9
|
+
* Also provides an HTTP server for Claude Code hooks integration,
|
|
10
|
+
* allowing permission requests and notifications to flow through Telegram.
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
13
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
11
14
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
12
15
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
13
|
-
import { TelegramManager } from './telegram.js';
|
|
16
|
+
import { TelegramManager, type HookEvent, type PermissionDecision } from './telegram.js';
|
|
14
17
|
import { loadAppConfig, validateAppConfig } from './providers/index.js';
|
|
18
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'http';
|
|
19
|
+
import { writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
|
|
20
|
+
import { join } from 'path';
|
|
21
|
+
|
|
22
|
+
// Session info directory (shared with hooks)
|
|
23
|
+
const SESSION_DIR = '/tmp/telegram-claude-sessions';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Find an available port starting from the preferred port
|
|
27
|
+
*/
|
|
28
|
+
async function findAvailablePort(preferredPort: number): Promise<number> {
|
|
29
|
+
const net = await import('net');
|
|
30
|
+
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const tryPort = (port: number) => {
|
|
33
|
+
const server = net.createServer();
|
|
34
|
+
server.listen(port, '127.0.0.1');
|
|
35
|
+
|
|
36
|
+
server.on('listening', () => {
|
|
37
|
+
server.close(() => resolve(port));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
server.on('error', () => {
|
|
41
|
+
// Port in use, try next
|
|
42
|
+
tryPort(port + 1);
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
tryPort(preferredPort);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Write session info file for hooks to discover
|
|
52
|
+
*/
|
|
53
|
+
function writeSessionInfo(sessionName: string, port: number): string {
|
|
54
|
+
if (!existsSync(SESSION_DIR)) {
|
|
55
|
+
mkdirSync(SESSION_DIR, { recursive: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const infoFile = join(SESSION_DIR, `${sessionName}.info`);
|
|
59
|
+
const info = {
|
|
60
|
+
port,
|
|
61
|
+
host: 'localhost',
|
|
62
|
+
pid: process.pid,
|
|
63
|
+
startedAt: new Date().toISOString(),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
writeFileSync(infoFile, JSON.stringify(info, null, 2));
|
|
67
|
+
return infoFile;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Clean up session info file
|
|
72
|
+
*/
|
|
73
|
+
function cleanupSessionInfo(sessionName: string): void {
|
|
74
|
+
const infoFile = join(SESSION_DIR, `${sessionName}.info`);
|
|
75
|
+
try {
|
|
76
|
+
if (existsSync(infoFile)) {
|
|
77
|
+
unlinkSync(infoFile);
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// Ignore cleanup errors
|
|
81
|
+
}
|
|
82
|
+
}
|
|
15
83
|
|
|
16
84
|
async function main() {
|
|
17
85
|
// Load configuration
|
|
@@ -35,6 +103,105 @@ async function main() {
|
|
|
35
103
|
|
|
36
104
|
telegram.start();
|
|
37
105
|
|
|
106
|
+
// Find available port and start HTTP server for hooks
|
|
107
|
+
const actualPort = await findAvailablePort(config.sessionPort);
|
|
108
|
+
const sessionInfoFile = writeSessionInfo(config.sessionName, actualPort);
|
|
109
|
+
|
|
110
|
+
// Start HTTP server for hooks
|
|
111
|
+
const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
112
|
+
// CORS headers
|
|
113
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
114
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
115
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
116
|
+
|
|
117
|
+
if (req.method === 'OPTIONS') {
|
|
118
|
+
res.writeHead(200);
|
|
119
|
+
res.end();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (req.method !== 'POST') {
|
|
124
|
+
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
125
|
+
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Read body
|
|
130
|
+
let body = '';
|
|
131
|
+
for await (const chunk of req) {
|
|
132
|
+
body += chunk;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const data = JSON.parse(body);
|
|
137
|
+
const url = req.url || '/';
|
|
138
|
+
|
|
139
|
+
// Handle permission request
|
|
140
|
+
if (url === '/permission' || url === '/hooks/permission') {
|
|
141
|
+
const { tool_name, tool_input } = data;
|
|
142
|
+
|
|
143
|
+
if (!tool_name) {
|
|
144
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
145
|
+
res.end(JSON.stringify({ error: 'Missing tool_name' }));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
console.error(`[HTTP] Permission request for tool: ${tool_name}`);
|
|
150
|
+
|
|
151
|
+
const decision = await telegram.handlePermissionRequest(
|
|
152
|
+
tool_name,
|
|
153
|
+
tool_input || {}
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
157
|
+
res.end(JSON.stringify({
|
|
158
|
+
hookSpecificOutput: {
|
|
159
|
+
decision: {
|
|
160
|
+
behavior: decision.behavior,
|
|
161
|
+
message: decision.message,
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
}));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Handle notifications (stop, session events, etc.)
|
|
169
|
+
if (url === '/notify' || url === '/hooks/notify') {
|
|
170
|
+
const event: HookEvent = {
|
|
171
|
+
type: data.type || 'notification',
|
|
172
|
+
message: data.message,
|
|
173
|
+
session_id: data.session_id,
|
|
174
|
+
tool_name: data.tool_name,
|
|
175
|
+
tool_input: data.tool_input,
|
|
176
|
+
timestamp: data.timestamp || new Date().toISOString(),
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
console.error(`[HTTP] Notification: ${event.type}`);
|
|
180
|
+
|
|
181
|
+
await telegram.sendHookNotification(event);
|
|
182
|
+
|
|
183
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
184
|
+
res.end(JSON.stringify({ success: true }));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Unknown endpoint
|
|
189
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
190
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
191
|
+
} catch (error) {
|
|
192
|
+
console.error('[HTTP] Error:', error);
|
|
193
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
194
|
+
res.end(JSON.stringify({ error: 'Internal server error' }));
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
httpServer.listen(actualPort, () => {
|
|
199
|
+
console.error(`[HTTP] Hook server listening on port ${actualPort}`);
|
|
200
|
+
if (actualPort !== config.sessionPort) {
|
|
201
|
+
console.error(`[HTTP] (preferred port ${config.sessionPort} was in use)`);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
38
205
|
// Track active chat sessions
|
|
39
206
|
let activeSessionId: string | null = null;
|
|
40
207
|
|
|
@@ -186,11 +353,15 @@ async function main() {
|
|
|
186
353
|
console.error('Telegram Claude MCP server ready');
|
|
187
354
|
console.error(`Session: ${config.sessionName}`);
|
|
188
355
|
console.error(`Chat ID: ${config.telegramChatId}`);
|
|
356
|
+
console.error(`Hook server: http://localhost:${actualPort}`);
|
|
357
|
+
console.error(`Session info: ${sessionInfoFile}`);
|
|
189
358
|
console.error('');
|
|
190
359
|
|
|
191
360
|
// Graceful shutdown
|
|
192
361
|
const shutdown = async () => {
|
|
193
362
|
console.error('\nShutting down...');
|
|
363
|
+
cleanupSessionInfo(config.sessionName);
|
|
364
|
+
httpServer.close();
|
|
194
365
|
telegram.stop();
|
|
195
366
|
process.exit(0);
|
|
196
367
|
};
|
package/src/telegram.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Handles Telegram messaging for Claude Code with multi-session support.
|
|
5
5
|
* Multiple Claude Code instances can share one Telegram chat, with messages
|
|
6
6
|
* tagged by session name and replies routed to the correct session.
|
|
7
|
+
*
|
|
8
|
+
* Also handles Claude Code hook events (permissions, notifications, etc.)
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import TelegramBot from 'node-telegram-bot-api';
|
|
@@ -28,23 +30,48 @@ interface PendingResponse {
|
|
|
28
30
|
timestamp: number;
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
interface PendingPermission {
|
|
34
|
+
resolve: (decision: PermissionDecision) => void;
|
|
35
|
+
reject: (error: Error) => void;
|
|
36
|
+
messageId: number;
|
|
37
|
+
timestamp: number;
|
|
38
|
+
toolName: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PermissionDecision {
|
|
42
|
+
behavior: 'allow' | 'deny';
|
|
43
|
+
message?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface HookEvent {
|
|
47
|
+
type: 'permission_request' | 'stop' | 'notification' | 'session_start' | 'session_end';
|
|
48
|
+
session_id?: string;
|
|
49
|
+
tool_name?: string;
|
|
50
|
+
tool_input?: Record<string, unknown>;
|
|
51
|
+
message?: string;
|
|
52
|
+
timestamp?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
31
55
|
export interface TelegramConfig {
|
|
32
56
|
botToken: string;
|
|
33
57
|
chatId: number;
|
|
34
58
|
sessionName: string;
|
|
35
59
|
responseTimeoutMs?: number;
|
|
60
|
+
permissionTimeoutMs?: number;
|
|
36
61
|
}
|
|
37
62
|
|
|
38
63
|
export class TelegramManager {
|
|
39
64
|
private bot: TelegramBot;
|
|
40
65
|
private config: TelegramConfig;
|
|
41
66
|
private pendingResponses: Map<string, PendingResponse> = new Map();
|
|
67
|
+
private pendingPermissions: Map<string, PendingPermission> = new Map();
|
|
42
68
|
private sessionStateFile: string;
|
|
43
69
|
private isRunning = false;
|
|
44
70
|
|
|
45
71
|
constructor(config: TelegramConfig) {
|
|
46
72
|
this.config = {
|
|
47
73
|
responseTimeoutMs: 180000, // 3 minutes default
|
|
74
|
+
permissionTimeoutMs: 300000, // 5 minutes for permissions
|
|
48
75
|
...config,
|
|
49
76
|
};
|
|
50
77
|
|
|
@@ -57,6 +84,7 @@ export class TelegramManager {
|
|
|
57
84
|
}
|
|
58
85
|
|
|
59
86
|
this.setupMessageHandler();
|
|
87
|
+
this.setupCallbackHandler();
|
|
60
88
|
this.updateSessionState({ waitingForResponse: false, messageIds: [] });
|
|
61
89
|
}
|
|
62
90
|
|
|
@@ -151,6 +179,136 @@ export class TelegramManager {
|
|
|
151
179
|
this.updateSessionState({ waitingForResponse: false, messageIds: [] });
|
|
152
180
|
}
|
|
153
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Handle a permission request from Claude Code hooks
|
|
184
|
+
* Sends a message with inline buttons and waits for user decision
|
|
185
|
+
*/
|
|
186
|
+
async handlePermissionRequest(
|
|
187
|
+
toolName: string,
|
|
188
|
+
toolInput: Record<string, unknown>
|
|
189
|
+
): Promise<PermissionDecision> {
|
|
190
|
+
// Format the tool input for display
|
|
191
|
+
const inputDisplay = this.formatToolInput(toolName, toolInput);
|
|
192
|
+
|
|
193
|
+
const message = `🔐 [${this.config.sessionName}] Permission Request\n\nTool: ${toolName}\n${inputDisplay}`;
|
|
194
|
+
|
|
195
|
+
const sent = await this.bot.sendMessage(this.config.chatId, message, {
|
|
196
|
+
reply_markup: {
|
|
197
|
+
inline_keyboard: [
|
|
198
|
+
[
|
|
199
|
+
{ text: '✅ Allow', callback_data: `allow:${sent?.message_id || 'pending'}` },
|
|
200
|
+
{ text: '❌ Deny', callback_data: `deny:${sent?.message_id || 'pending'}` },
|
|
201
|
+
],
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Update callback_data with actual message ID
|
|
207
|
+
await this.bot.editMessageReplyMarkup(
|
|
208
|
+
{
|
|
209
|
+
inline_keyboard: [
|
|
210
|
+
[
|
|
211
|
+
{ text: '✅ Allow', callback_data: `allow:${sent.message_id}` },
|
|
212
|
+
{ text: '❌ Deny', callback_data: `deny:${sent.message_id}` },
|
|
213
|
+
],
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
{ chat_id: this.config.chatId, message_id: sent.message_id }
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
return this.waitForPermission(sent.message_id, toolName);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Send a hook notification (non-blocking)
|
|
224
|
+
*/
|
|
225
|
+
async sendHookNotification(event: HookEvent): Promise<void> {
|
|
226
|
+
let emoji = '📢';
|
|
227
|
+
let title = 'Notification';
|
|
228
|
+
|
|
229
|
+
switch (event.type) {
|
|
230
|
+
case 'stop':
|
|
231
|
+
emoji = '🛑';
|
|
232
|
+
title = 'Agent Stopped';
|
|
233
|
+
break;
|
|
234
|
+
case 'session_start':
|
|
235
|
+
emoji = '🚀';
|
|
236
|
+
title = 'Session Started';
|
|
237
|
+
break;
|
|
238
|
+
case 'session_end':
|
|
239
|
+
emoji = '👋';
|
|
240
|
+
title = 'Session Ended';
|
|
241
|
+
break;
|
|
242
|
+
case 'notification':
|
|
243
|
+
emoji = '📢';
|
|
244
|
+
title = 'Notification';
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const message = `${emoji} [${this.config.sessionName}] ${title}\n\n${event.message || 'No details'}`;
|
|
249
|
+
await this.bot.sendMessage(this.config.chatId, message);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Format tool input for display
|
|
254
|
+
*/
|
|
255
|
+
private formatToolInput(toolName: string, input: Record<string, unknown>): string {
|
|
256
|
+
// Special formatting for common tools
|
|
257
|
+
if (toolName === 'Bash' && input.command) {
|
|
258
|
+
return `Command: \`${input.command}\``;
|
|
259
|
+
}
|
|
260
|
+
if (toolName === 'Write' && input.file_path) {
|
|
261
|
+
const content = input.content as string;
|
|
262
|
+
const preview = content?.length > 100 ? content.slice(0, 100) + '...' : content;
|
|
263
|
+
return `File: ${input.file_path}\nContent: ${preview || '(empty)'}`;
|
|
264
|
+
}
|
|
265
|
+
if (toolName === 'Edit' && input.file_path) {
|
|
266
|
+
return `File: ${input.file_path}\nOld: ${input.old_string}\nNew: ${input.new_string}`;
|
|
267
|
+
}
|
|
268
|
+
if (toolName === 'Read' && input.file_path) {
|
|
269
|
+
return `File: ${input.file_path}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Default: JSON stringify
|
|
273
|
+
try {
|
|
274
|
+
const str = JSON.stringify(input, null, 2);
|
|
275
|
+
return str.length > 500 ? str.slice(0, 500) + '...' : str;
|
|
276
|
+
} catch {
|
|
277
|
+
return '(unable to display input)';
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Wait for a permission decision with timeout
|
|
283
|
+
*/
|
|
284
|
+
private waitForPermission(messageId: number, toolName: string): Promise<PermissionDecision> {
|
|
285
|
+
return new Promise((resolve, reject) => {
|
|
286
|
+
const key = `${messageId}`;
|
|
287
|
+
|
|
288
|
+
const timeout = setTimeout(() => {
|
|
289
|
+
this.pendingPermissions.delete(key);
|
|
290
|
+
// Default to deny on timeout for safety
|
|
291
|
+
resolve({ behavior: 'deny', message: 'Permission request timed out' });
|
|
292
|
+
}, this.config.permissionTimeoutMs);
|
|
293
|
+
|
|
294
|
+
this.pendingPermissions.set(key, {
|
|
295
|
+
resolve: (decision: PermissionDecision) => {
|
|
296
|
+
clearTimeout(timeout);
|
|
297
|
+
this.pendingPermissions.delete(key);
|
|
298
|
+
resolve(decision);
|
|
299
|
+
},
|
|
300
|
+
reject: (error: Error) => {
|
|
301
|
+
clearTimeout(timeout);
|
|
302
|
+
this.pendingPermissions.delete(key);
|
|
303
|
+
reject(error);
|
|
304
|
+
},
|
|
305
|
+
messageId,
|
|
306
|
+
timestamp: Date.now(),
|
|
307
|
+
toolName,
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
154
312
|
/**
|
|
155
313
|
* Set up message handler for incoming messages
|
|
156
314
|
*/
|
|
@@ -202,6 +360,52 @@ export class TelegramManager {
|
|
|
202
360
|
});
|
|
203
361
|
}
|
|
204
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Set up callback query handler for inline keyboard buttons
|
|
365
|
+
*/
|
|
366
|
+
private setupCallbackHandler(): void {
|
|
367
|
+
this.bot.on('callback_query', async (query) => {
|
|
368
|
+
if (!query.data || !query.message) return;
|
|
369
|
+
|
|
370
|
+
const [action, messageId] = query.data.split(':');
|
|
371
|
+
const key = messageId;
|
|
372
|
+
|
|
373
|
+
console.error(`[${this.config.sessionName}] Callback query: ${action} for message ${messageId}`);
|
|
374
|
+
|
|
375
|
+
const pending = this.pendingPermissions.get(key);
|
|
376
|
+
if (!pending) {
|
|
377
|
+
// Answer the callback to remove loading state
|
|
378
|
+
await this.bot.answerCallbackQuery(query.id, { text: 'Request expired' });
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const decision: PermissionDecision = {
|
|
383
|
+
behavior: action === 'allow' ? 'allow' : 'deny',
|
|
384
|
+
message: action === 'deny' ? 'Denied by user via Telegram' : undefined,
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
// Update the message to show the decision
|
|
388
|
+
const statusEmoji = action === 'allow' ? '✅' : '❌';
|
|
389
|
+
const statusText = action === 'allow' ? 'Allowed' : 'Denied';
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
await this.bot.editMessageReplyMarkup(
|
|
393
|
+
{ inline_keyboard: [] },
|
|
394
|
+
{ chat_id: query.message.chat.id, message_id: query.message.message_id }
|
|
395
|
+
);
|
|
396
|
+
await this.bot.editMessageText(
|
|
397
|
+
`${query.message.text}\n\n${statusEmoji} ${statusText}`,
|
|
398
|
+
{ chat_id: query.message.chat.id, message_id: query.message.message_id }
|
|
399
|
+
);
|
|
400
|
+
} catch {
|
|
401
|
+
// Ignore edit errors (message might be too old)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
await this.bot.answerCallbackQuery(query.id, { text: `${statusText}!` });
|
|
405
|
+
pending.resolve(decision);
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
205
409
|
/**
|
|
206
410
|
* Wait for a response with timeout
|
|
207
411
|
*/
|