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.
@@ -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.3",
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
  */