telegram-claude-mcp 1.0.2 → 1.1.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.
@@ -0,0 +1,46 @@
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
+ # Usage: Configure this script in your Claude Code hooks settings for Stop event
9
+ #
10
+ # Environment variables:
11
+ # TELEGRAM_HOOK_PORT - Port of the MCP server (default: 3333)
12
+ # TELEGRAM_HOOK_HOST - Host of the MCP server (default: localhost)
13
+ # TELEGRAM_HOOK_EVENT - Event type override (default: from input or 'stop')
14
+ #
15
+
16
+ HOOK_PORT="${TELEGRAM_HOOK_PORT:-3333}"
17
+ HOOK_HOST="${TELEGRAM_HOOK_HOST:-localhost}"
18
+ HOOK_URL="http://${HOOK_HOST}:${HOOK_PORT}/notify"
19
+ EVENT_TYPE="${TELEGRAM_HOOK_EVENT:-stop}"
20
+
21
+ # Read the hook input from stdin
22
+ INPUT=$(cat)
23
+
24
+ # Extract relevant information
25
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty')
26
+ MESSAGE=$(echo "$INPUT" | jq -r '.message // .stop_reason // "Agent stopped"')
27
+
28
+ # Log for debugging
29
+ echo "[notify-hook] Event: $EVENT_TYPE" >&2
30
+ echo "[notify-hook] Message: $MESSAGE" >&2
31
+
32
+ # Build the request payload
33
+ PAYLOAD=$(jq -n \
34
+ --arg type "$EVENT_TYPE" \
35
+ --arg message "$MESSAGE" \
36
+ --arg session_id "$SESSION_ID" \
37
+ '{type: $type, message: $message, session_id: $session_id}')
38
+
39
+ # Send the notification (non-blocking, we don't need to wait)
40
+ curl -s -X POST "$HOOK_URL" \
41
+ -H "Content-Type: application/json" \
42
+ -d "$PAYLOAD" \
43
+ --max-time 10 >/dev/null 2>&1 &
44
+
45
+ # Return success immediately (hook doesn't need to block)
46
+ echo '{"continue": true}'
@@ -0,0 +1,61 @@
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
+ # Usage: Configure this script in your Claude Code hooks settings
9
+ #
10
+ # Environment variables:
11
+ # TELEGRAM_HOOK_PORT - Port of the MCP server (default: 3333)
12
+ # TELEGRAM_HOOK_HOST - Host of the MCP server (default: localhost)
13
+ #
14
+
15
+ HOOK_PORT="${TELEGRAM_HOOK_PORT:-3333}"
16
+ HOOK_HOST="${TELEGRAM_HOOK_HOST:-localhost}"
17
+ HOOK_URL="http://${HOOK_HOST}:${HOOK_PORT}/permission"
18
+
19
+ # Read the hook input from stdin
20
+ INPUT=$(cat)
21
+
22
+ # Extract tool_name and tool_input from the Claude Code hook format
23
+ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
24
+ TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}')
25
+
26
+ # If no tool_name, this might be a different format - try to extract from the input
27
+ if [ -z "$TOOL_NAME" ]; then
28
+ # Claude Code might send the data differently, try alternative paths
29
+ TOOL_NAME=$(echo "$INPUT" | jq -r '.toolName // .name // empty')
30
+ TOOL_INPUT=$(echo "$INPUT" | jq -c '.toolInput // .input // .arguments // {}')
31
+ fi
32
+
33
+ # Log for debugging (goes to stderr, not stdout)
34
+ echo "[permission-hook] Tool: $TOOL_NAME" >&2
35
+ echo "[permission-hook] Sending to: $HOOK_URL" >&2
36
+
37
+ # Build the request payload
38
+ PAYLOAD=$(jq -n \
39
+ --arg tool_name "$TOOL_NAME" \
40
+ --argjson tool_input "$TOOL_INPUT" \
41
+ '{tool_name: $tool_name, tool_input: $tool_input}')
42
+
43
+ # Send the request to the MCP server and wait for response
44
+ RESPONSE=$(curl -s -X POST "$HOOK_URL" \
45
+ -H "Content-Type: application/json" \
46
+ -d "$PAYLOAD" \
47
+ --max-time 600)
48
+
49
+ # Check if curl succeeded
50
+ if [ $? -ne 0 ]; then
51
+ echo "[permission-hook] Error: Failed to connect to hook server" >&2
52
+ # Return deny on error for safety
53
+ echo '{"hookSpecificOutput":{"decision":{"behavior":"deny","message":"Failed to connect to Telegram hook server"}}}'
54
+ exit 0
55
+ fi
56
+
57
+ # Log the response
58
+ echo "[permission-hook] Response: $RESPONSE" >&2
59
+
60
+ # Output the response (this goes back to Claude Code)
61
+ echo "$RESPONSE"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "telegram-claude-mcp",
3
- "version": "1.0.2",
4
- "description": "MCP server that lets Claude message you on Telegram",
3
+ "version": "1.1.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,17 @@
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';
15
19
 
16
20
  async function main() {
17
21
  // Load configuration
@@ -35,6 +39,98 @@ async function main() {
35
39
 
36
40
  telegram.start();
37
41
 
42
+ // Start HTTP server for hooks
43
+ const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
44
+ // CORS headers
45
+ res.setHeader('Access-Control-Allow-Origin', '*');
46
+ res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
47
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
48
+
49
+ if (req.method === 'OPTIONS') {
50
+ res.writeHead(200);
51
+ res.end();
52
+ return;
53
+ }
54
+
55
+ if (req.method !== 'POST') {
56
+ res.writeHead(405, { 'Content-Type': 'application/json' });
57
+ res.end(JSON.stringify({ error: 'Method not allowed' }));
58
+ return;
59
+ }
60
+
61
+ // Read body
62
+ let body = '';
63
+ for await (const chunk of req) {
64
+ body += chunk;
65
+ }
66
+
67
+ try {
68
+ const data = JSON.parse(body);
69
+ const url = req.url || '/';
70
+
71
+ // Handle permission request
72
+ if (url === '/permission' || url === '/hooks/permission') {
73
+ const { tool_name, tool_input } = data;
74
+
75
+ if (!tool_name) {
76
+ res.writeHead(400, { 'Content-Type': 'application/json' });
77
+ res.end(JSON.stringify({ error: 'Missing tool_name' }));
78
+ return;
79
+ }
80
+
81
+ console.error(`[HTTP] Permission request for tool: ${tool_name}`);
82
+
83
+ const decision = await telegram.handlePermissionRequest(
84
+ tool_name,
85
+ tool_input || {}
86
+ );
87
+
88
+ res.writeHead(200, { 'Content-Type': 'application/json' });
89
+ res.end(JSON.stringify({
90
+ hookSpecificOutput: {
91
+ decision: {
92
+ behavior: decision.behavior,
93
+ message: decision.message,
94
+ },
95
+ },
96
+ }));
97
+ return;
98
+ }
99
+
100
+ // Handle notifications (stop, session events, etc.)
101
+ if (url === '/notify' || url === '/hooks/notify') {
102
+ const event: HookEvent = {
103
+ type: data.type || 'notification',
104
+ message: data.message,
105
+ session_id: data.session_id,
106
+ tool_name: data.tool_name,
107
+ tool_input: data.tool_input,
108
+ timestamp: data.timestamp || new Date().toISOString(),
109
+ };
110
+
111
+ console.error(`[HTTP] Notification: ${event.type}`);
112
+
113
+ await telegram.sendHookNotification(event);
114
+
115
+ res.writeHead(200, { 'Content-Type': 'application/json' });
116
+ res.end(JSON.stringify({ success: true }));
117
+ return;
118
+ }
119
+
120
+ // Unknown endpoint
121
+ res.writeHead(404, { 'Content-Type': 'application/json' });
122
+ res.end(JSON.stringify({ error: 'Not found' }));
123
+ } catch (error) {
124
+ console.error('[HTTP] Error:', error);
125
+ res.writeHead(500, { 'Content-Type': 'application/json' });
126
+ res.end(JSON.stringify({ error: 'Internal server error' }));
127
+ }
128
+ });
129
+
130
+ httpServer.listen(config.sessionPort, () => {
131
+ console.error(`[HTTP] Hook server listening on port ${config.sessionPort}`);
132
+ });
133
+
38
134
  // Track active chat sessions
39
135
  let activeSessionId: string | null = null;
40
136
 
@@ -186,11 +282,13 @@ async function main() {
186
282
  console.error('Telegram Claude MCP server ready');
187
283
  console.error(`Session: ${config.sessionName}`);
188
284
  console.error(`Chat ID: ${config.telegramChatId}`);
285
+ console.error(`Hook server: http://localhost:${config.sessionPort}`);
189
286
  console.error('');
190
287
 
191
288
  // Graceful shutdown
192
289
  const shutdown = async () => {
193
290
  console.error('\nShutting down...');
291
+ httpServer.close();
194
292
  telegram.stop();
195
293
  process.exit(0);
196
294
  };
@@ -31,10 +31,20 @@ export function loadAppConfig(): AppConfig {
31
31
  const chatId = process.env.TELEGRAM_CHAT_ID;
32
32
  const sessionPort = process.env.SESSION_PORT || '3333';
33
33
 
34
+ // Auto-detect project name from current working directory
35
+ const cwd = process.cwd();
36
+ const projectName = cwd.split('/').pop() || 'unknown';
37
+
38
+ // Build session name: SESSION_NAME:projectName or just projectName
39
+ const baseSessionName = process.env.SESSION_NAME;
40
+ const sessionName = baseSessionName
41
+ ? `${baseSessionName}:${projectName}`
42
+ : projectName;
43
+
34
44
  return {
35
45
  telegramBotToken: process.env.TELEGRAM_BOT_TOKEN || '',
36
46
  telegramChatId: chatId ? parseInt(chatId, 10) : 0,
37
- sessionName: process.env.SESSION_NAME || 'default',
47
+ sessionName,
38
48
  sessionPort: parseInt(sessionPort, 10),
39
49
  openrouterApiKey: process.env.OPENROUTER_API_KEY,
40
50
  openrouterModel: process.env.OPENROUTER_MODEL || 'openai/gpt-oss-120b',
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
  */