/** * Telegram Webhook Handler * Handles incoming Telegram messages and commands */ const express = require('express'); const crypto = require('crypto'); const axios = require('axios'); const path = require('path'); const fs = require('fs'); const Logger = require('../../core/logger'); const ControllerInjector = require('../../utils/controller-injector'); class TelegramWebhookHandler { constructor(config = {}) { this.config = config; this.logger = new Logger('TelegramWebhook'); this.sessionsDir = path.join(__dirname, '../../data/sessions'); this.injector = new ControllerInjector(); this.app = express(); this.apiBaseUrl = 'https://api.telegram.org'; this.botUsername = null; // Cache for bot username this._setupMiddleware(); this._setupRoutes(); } _setupMiddleware() { // Parse JSON for all requests this.app.use(express.json()); } _setupRoutes() { // Telegram webhook endpoint this.app.post('/webhook/telegram', this._handleWebhook.bind(this)); // Health check endpoint this.app.get('/health', (req, res) => { res.json({ status: 'ok', service: 'telegram-webhook' }); }); } /** * Generate network options for axios requests * @returns {Object} Network options object */ _getNetworkOptions() { const options = {}; if (this.config.forceIPv4) { options.family = 4; } return options; } async _handleWebhook(req, res) { try { const update = req.body; // Handle different update types if (update.message) { await this._handleMessage(update.message); } else if (update.callback_query) { await this._handleCallbackQuery(update.callback_query); } res.status(200).send('OK'); } catch (error) { this.logger.error('Webhook handling error:', error.message); res.status(500).send('Internal Server Error'); } } async _handleMessage(message) { const chatId = message.chat.id; const userId = message.from.id; const messageText = message.text?.trim(); if (!messageText) return; // Check if user is authorized if (!this._isAuthorized(userId, chatId)) { this.logger.warn(`Unauthorized user/chat: ${userId}/${chatId}`); await this._sendMessage(chatId, '⚠️ You are not authorized to use this bot.'); return; } // Handle /start command if (messageText === '/start') { await this._sendWelcomeMessage(chatId); return; } // Handle /help command if (messageText === '/help') { await this._sendHelpMessage(chatId); return; } // Parse command const commandMatch = messageText.match(/^\/cmd\s+([A-Z0-9]{8})\s+(.+)$/i); if (!commandMatch) { // Check if it's a direct command without /cmd prefix const directMatch = messageText.match(/^([A-Z0-9]{8})\s+(.+)$/); if (directMatch) { await this._processCommand(chatId, directMatch[1], directMatch[2]); } else { await this._sendMessage(chatId, '❌ Invalid format. Use:\n`/cmd `\n\nExample:\n`/cmd ABC12345 analyze this code`', { parse_mode: 'Markdown' }); } return; } const token = commandMatch[1].toUpperCase(); const command = commandMatch[2]; await this._processCommand(chatId, token, command); } async _processCommand(chatId, token, command) { // Find session by token const session = await this._findSessionByToken(token); if (!session) { await this._sendMessage(chatId, '❌ Invalid or expired token. Please wait for a new task notification.', { parse_mode: 'Markdown' }); return; } // Check if session is expired if (session.expiresAt < Math.floor(Date.now() / 1000)) { await this._sendMessage(chatId, '❌ Token has expired. Please wait for a new task notification.', { parse_mode: 'Markdown' }); await this._removeSession(session.id); return; } try { // Inject command into tmux session const tmuxSession = session.tmuxSession || 'default'; await this.injector.injectCommand(command, tmuxSession); // Send confirmation await this._sendMessage(chatId, `✅ *Command sent successfully*\n\n📝 *Command:* ${command}\n🖥️ *Session:* ${tmuxSession}\n\nClaude is now processing your request...`, { parse_mode: 'Markdown' }); // Log command execution this.logger.info(`Command injected - User: ${chatId}, Token: ${token}, Command: ${command}`); } catch (error) { this.logger.error('Command injection failed:', error.message); await this._sendMessage(chatId, `❌ *Command execution failed:* ${error.message}`, { parse_mode: 'Markdown' }); } } async _handleCallbackQuery(callbackQuery) { const chatId = callbackQuery.message.chat.id; const data = callbackQuery.data; // Answer callback query to remove loading state await this._answerCallbackQuery(callbackQuery.id); if (data.startsWith('personal:')) { const token = data.split(':')[1]; // Send personal chat command format await this._sendMessage(chatId, `📝 *Personal Chat Command Format:*\n\n\`/cmd ${token} \`\n\n*Example:*\n\`/cmd ${token} please analyze this code\`\n\n💡 *Copy and paste the format above, then add your command!*`, { parse_mode: 'Markdown' }); } else if (data.startsWith('group:')) { const token = data.split(':')[1]; // Send group chat command format with @bot_name const botUsername = await this._getBotUsername(); await this._sendMessage(chatId, `👥 *Group Chat Command Format:*\n\n\`@${botUsername} /cmd ${token} \`\n\n*Example:*\n\`@${botUsername} /cmd ${token} please analyze this code\`\n\n💡 *Copy and paste the format above, then add your command!*`, { parse_mode: 'Markdown' }); } else if (data.startsWith('session:')) { const token = data.split(':')[1]; // For backward compatibility - send help message for old callback buttons await this._sendMessage(chatId, `📝 *How to send a command:*\n\nType:\n\`/cmd ${token} \`\n\nExample:\n\`/cmd ${token} please analyze this code\`\n\n💡 *Tip:* New notifications have a button that auto-fills the command for you!`, { parse_mode: 'Markdown' }); } } async _sendWelcomeMessage(chatId) { const message = `🤖 *Welcome to Claude Code Remote Bot!*\n\n` + `I'll notify you when Claude completes tasks or needs input.\n\n` + `When you receive a notification with a token, you can send commands back using:\n` + `\`/cmd \`\n\n` + `Type /help for more information.`; await this._sendMessage(chatId, message, { parse_mode: 'Markdown' }); } async _sendHelpMessage(chatId) { const message = `📚 *Claude Code Remote Bot Help*\n\n` + `*Commands:*\n` + `• \`/start\` - Welcome message\n` + `• \`/help\` - Show this help\n` + `• \`/cmd \` - Send command to Claude\n\n` + `*Example:*\n` + `\`/cmd ABC12345 analyze the performance of this function\`\n\n` + `*Tips:*\n` + `• Tokens are case-insensitive\n` + `• Tokens expire after 24 hours\n` + `• You can also just type \`TOKEN command\` without /cmd`; await this._sendMessage(chatId, message, { parse_mode: 'Markdown' }); } _isAuthorized(userId, chatId) { // Check whitelist const whitelist = this.config.whitelist || []; if (whitelist.includes(String(chatId)) || whitelist.includes(String(userId))) { return true; } // If no whitelist configured, allow configured chat/user if (whitelist.length === 0) { const configuredChatId = this.config.chatId || this.config.groupId; if (configuredChatId && String(chatId) === String(configuredChatId)) { return true; } } return false; } async _getBotUsername() { if (this.botUsername) { return this.botUsername; } try { const response = await axios.get( `${this.apiBaseUrl}/bot${this.config.botToken}/getMe`, this._getNetworkOptions() ); if (response.data.ok && response.data.result.username) { this.botUsername = response.data.result.username; return this.botUsername; } } catch (error) { this.logger.error('Failed to get bot username:', error.message); } // Fallback to configured username or default return this.config.botUsername || 'claude_remote_bot'; } async _findSessionByToken(token) { const files = fs.readdirSync(this.sessionsDir); for (const file of files) { if (!file.endsWith('.json')) continue; const sessionPath = path.join(this.sessionsDir, file); try { const session = JSON.parse(fs.readFileSync(sessionPath, 'utf8')); if (session.token === token) { return session; } } catch (error) { this.logger.error(`Failed to read session file ${file}:`, error.message); } } return null; } async _removeSession(sessionId) { const sessionFile = path.join(this.sessionsDir, `${sessionId}.json`); if (fs.existsSync(sessionFile)) { fs.unlinkSync(sessionFile); this.logger.debug(`Session removed: ${sessionId}`); } } async _sendMessage(chatId, text, options = {}) { try { await axios.post( `${this.apiBaseUrl}/bot${this.config.botToken}/sendMessage`, { chat_id: chatId, text: text, ...options }, this._getNetworkOptions() ); } catch (error) { this.logger.error('Failed to send message:', error.response?.data || error.message); } } async _answerCallbackQuery(callbackQueryId, text = '') { try { await axios.post( `${this.apiBaseUrl}/bot${this.config.botToken}/answerCallbackQuery`, { callback_query_id: callbackQueryId, text: text }, this._getNetworkOptions() ); } catch (error) { this.logger.error('Failed to answer callback query:', error.response?.data || error.message); } } async setWebhook(webhookUrl) { try { const response = await axios.post( `${this.apiBaseUrl}/bot${this.config.botToken}/setWebhook`, { url: webhookUrl, allowed_updates: ['message', 'callback_query'] }, this._getNetworkOptions() ); this.logger.info('Webhook set successfully:', response.data); return response.data; } catch (error) { this.logger.error('Failed to set webhook:', error.response?.data || error.message); throw error; } } start(port = 3000) { this.app.listen(port, () => { this.logger.info(`Telegram webhook server started on port ${port}`); }); } } module.exports = TelegramWebhookHandler;