import TelegramBot from 'node-telegram-bot-api'; import { Database } from './database'; import { logger } from './logger'; interface TelegramUser { id: number; first_name: string; last_name?: string; username?: string; } interface Message { chat: { id: number; }; from?: TelegramUser; web_app?: { data: string; }; } interface InlineQuery { id: string; query: string; } interface WebAppData { action: string; audioUrl: string; title: string; } interface SearchResult { query: string; created_at: string; } export class QuixoticBot { private bot: TelegramBot; private webAppUrl: string; private db: Database; constructor(token: string, webAppUrl: string) { // Validate token format if (!token || token.length < 40 || token === 'your_telegram_bot_token_here') { throw new Error('Invalid or placeholder TELEGRAM_BOT_TOKEN provided'); } // Use webhook in production, polling in development const useWebhook = process.env.NODE_ENV === 'production' && process.env.WEBHOOK_URL; if (useWebhook) { logger.telegram('Using webhook mode for production'); this.bot = new TelegramBot(token, { webHook: { port: 8443, host: '0.0.0.0' } }); } else { logger.telegram('Using polling mode for development'); this.bot = new TelegramBot(token, { polling: true }); } this.webAppUrl = webAppUrl; this.db = new Database(); this.init(); } private init(): void { logger.telegram('Bot initialized'); this.setupCommands(); this.setupHandlers(); } private setupCommands(): void { // Set bot commands this.bot.setMyCommands([ { command: 'start', description: 'Запустить приложение' }, { command: 'help', description: 'Помощь' }, { command: 'history', description: 'История поиска' } ]); } private setupHandlers(): void { logger.telegram('Setting up bot handlers...'); // Handle messages this.bot.on('message', (msg: any) => { // Handle web app data in regular message event if (msg.web_app?.data) { this.handleWebAppData(msg); return; // Important: don't process as regular message } }); // Start command this.bot.onText(/\/start/, async (msg: Message) => { const chatId = msg.chat.id; const user = msg.from; try { // Add user to database if (user) { await this.db.addUser(user); } const keyboard = { inline_keyboard: [[ { text: 'Открыть Quixotic', web_app: { url: this.webAppUrl } } ]] }; await this.bot.sendMessage(chatId, 'Добро пожаловать в Quixotic!\n\n' + 'Найди любую песню на SoundCloud и получи MP3 файл прямо в чат.\n\n' + 'Нажми кнопку ниже, чтобы начать поиск:', { reply_markup: keyboard } ); } catch (error) { logger.error('Start command error:', error); await this.bot.sendMessage(chatId, 'Произошла ошибка. Попробуйте позже.'); } }); // Help command this.bot.onText(/\/help/, async (msg: Message) => { const chatId = msg.chat.id; const helpText = `*Quixotic - SoundCloud to MP3* *Как пользоваться:* 1. Нажми кнопку "Открыть Quixotic" 2. Введи название песни в поисковую строку 3. Выбери нужный трек из списка 4. Получи MP3 файл в чат! *Команды:* /start - Запустить приложение /help - Эта справка /history - История поиска *Возможности:* - Поиск по SoundCloud - Высокое качество MP3 (192kbps) - Быстрая конвертация - История поиска`; await this.bot.sendMessage(chatId, helpText, { parse_mode: 'Markdown' }); }); // History command this.bot.onText(/\/history/, async (msg: Message) => { const chatId = msg.chat.id; const userId = msg.from?.id; if (!userId) return; try { const user = await this.db.getUserByTelegramId(userId); if (!user) { await this.bot.sendMessage(chatId, 'История поиска пуста.'); return; } // Get recent search history const history = await this.getSearchHistory(user.id); if (history.length === 0) { await this.bot.sendMessage(chatId, 'История поиска пуста.'); return; } let historyText = '*Последние поисковые запросы:*\n\n'; history.forEach((item, index) => { const date = new Date(item.created_at).toLocaleDateString('ru-RU'); historyText += `${index + 1}. ${item.query} _(${date})_\n`; }); await this.bot.sendMessage(chatId, historyText, { parse_mode: 'Markdown' }); } catch (error) { logger.error('History command error:', error); await this.bot.sendMessage(chatId, 'Ошибка получения истории.'); } }); // Handle web app data - primary event handler this.bot.on('web_app_data', async (msg: Message) => { this.handleWebAppData(msg); }); // Handle callback queries this.bot.on('callback_query', async (query: any) => { if (query.data) { try { const data = JSON.parse(query.data); if (data.action === 'send_audio') { await this.sendAudioFileInternal(query.message.chat.id, data.audioUrl, data.title); } } catch { // Not JSON, ignore } } await this.bot.answerCallbackQuery(query.id); }); // Handle inline queries for search this.bot.on('inline_query', async (query: InlineQuery) => { const queryId = query.id; const searchQuery = query.query; if (!searchQuery || searchQuery.length < 3) { await this.bot.answerInlineQuery(queryId, []); return; } try { const { SoundCloudService } = require('./soundcloud'); const soundcloud = new SoundCloudService(); const videos = await soundcloud.searchTracks(searchQuery, 5); const results = videos.map((video: any, index: number) => ({ type: 'article', id: `${index}`, title: video.title, description: `${video.channel} - ${this.formatDuration(video.duration)}`, thumb_url: video.thumbnail, input_message_content: { message_text: `${video.title}\n${video.url}` } })); await this.bot.answerInlineQuery(queryId, results, { cache_time: 300, is_personal: true }); } catch (error) { logger.error('Inline query error:', error); await this.bot.answerInlineQuery(queryId, []); } }); // Error handler with detailed logging this.bot.on('error', (error: any) => { logger.error('Telegram bot error:', error.message || error); logger.error('Error code:', error.code); logger.error('Full error:', error); }); // Handle polling errors specifically this.bot.on('polling_error', (error: any) => { logger.error('Telegram polling error:', error.message || error); logger.error('Error code:', error.code); // Don't crash on polling errors, just log them if (error.code === 'ETELEGRAM') { logger.warn('Telegram API error - continuing operation'); } }); logger.telegram('Bot handlers setup complete'); } private async getSearchHistory(userId: number): Promise { return this.db.getSearchHistory(userId); } // Public method for external API calls public async sendAudioFile(chatId: number, audioUrl: string, title: string, performer?: string, thumbnail?: string): Promise { logger.debug(`sendAudioFile called with performer: ${performer}, thumbnail: ${thumbnail}`); return this.sendAudioFileInternal(chatId, audioUrl, title, performer, thumbnail); } private async sendAudioFileInternal(chatId: number, audioUrlOrPath: string, title: string, performer?: string, thumbnail?: string): Promise { try { logger.telegram('Sending audio', `${title} to chat ${chatId}`); logger.debug(`File source: ${audioUrlOrPath}`); logger.debug(`Performer: ${performer || 'Not provided'}`); logger.debug(`Thumbnail: ${thumbnail || 'Not provided'}`); // Check if it's a URL or local file path const isUrl = audioUrlOrPath.startsWith('http'); let filePath = audioUrlOrPath; if (isUrl) { // Extract filename from URL and construct local path const urlParts = audioUrlOrPath.split('/'); const filename = urlParts[urlParts.length - 1]; filePath = require('path').join(process.cwd(), 'downloads', filename); logger.debug(`Converted URL to local path: ${filePath}`); } const fs = require('fs'); const path = require('path'); const https = require('https'); // Check if file exists if (!fs.existsSync(filePath)) { logger.error(`File not found: ${filePath}`); throw new Error('File not found: ' + filePath); } // Get file stats for debugging const stats = fs.statSync(filePath); logger.debug(`File size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`); // Generate custom filename for display const safeTitle = (title || 'audio').replace(/[^\w\s-]/g, '').replace(/\s+/g, '_').substring(0, 30); const safePerformer = (performer || '').replace(/[^\w\s-]/g, '').replace(/\s+/g, '_').substring(0, 20); const customFilename = safePerformer ? `${safePerformer} - ${safeTitle}.mp3` : `${safeTitle}.mp3`; logger.debug(`Sending as: ${customFilename}`); // Download thumbnail if provided let thumbnailPath: string | undefined; if (thumbnail && thumbnail.startsWith('http')) { try { logger.debug(`Downloading thumbnail from: ${thumbnail}`); const thumbnailFilename = `thumb_${Date.now()}.jpg`; thumbnailPath = path.join(process.cwd(), 'downloads', thumbnailFilename); await new Promise((resolve, reject) => { const file = fs.createWriteStream(thumbnailPath!); // Handle both http and https const protocol = thumbnail.startsWith('https') ? https : require('http'); const request = protocol.get(thumbnail, (response: any) => { // Follow redirects if (response.statusCode === 301 || response.statusCode === 302) { const redirectUrl = response.headers.location; logger.debug(`Following redirect to: ${redirectUrl}`); file.close(); fs.unlink(thumbnailPath!, () => {}); const redirectProtocol = redirectUrl.startsWith('https') ? https : require('http'); redirectProtocol.get(redirectUrl, (redirectResponse: any) => { redirectResponse.pipe(file); file.on('finish', () => { file.close(); logger.success(`Thumbnail downloaded: ${thumbnailPath} (${fs.statSync(thumbnailPath!).size} bytes)`); resolve(); }); }).on('error', (err: any) => { file.close(); fs.unlink(thumbnailPath!, () => {}); reject(err); }); } else { response.pipe(file); file.on('finish', () => { file.close(); const fileSize = fs.statSync(thumbnailPath!).size; logger.success(`Thumbnail downloaded: ${thumbnailPath} (${fileSize} bytes)`); // Check if file is valid (at least 1KB) if (fileSize < 1000) { logger.warn('Thumbnail file too small, may be invalid'); fs.unlink(thumbnailPath!, () => {}); thumbnailPath = undefined; } resolve(); }); } }); request.on('error', (err: any) => { file.close(); fs.unlink(thumbnailPath!, () => {}); reject(err); }); // Set timeout for thumbnail download request.setTimeout(10000, () => { request.destroy(); file.close(); fs.unlink(thumbnailPath!, () => {}); reject(new Error('Thumbnail download timeout')); }); }); } catch (thumbError: any) { logger.warn('Failed to download thumbnail:', thumbError.message); thumbnailPath = undefined; } } // Send file using stream (better for large files) const fileStream = fs.createReadStream(filePath); // Try sending as audio with metadata try { const options: any = { title: title, performer: performer || 'Unknown Artist', caption: undefined, parse_mode: undefined }; // Add thumbnail if downloaded if (thumbnailPath) { options.thumbnail = fs.createReadStream(thumbnailPath); } await this.bot.sendAudio(chatId, fileStream, options, { filename: customFilename, contentType: 'audio/mpeg' }); logger.success(`Audio sent successfully: ${title}`); // Clean up thumbnail file if (thumbnailPath) { fs.unlink(thumbnailPath, (err: any) => { if (err) logger.error('Failed to delete thumbnail:', err); }); } return; } catch (error: any) { logger.error('Audio send failed:', error.message); logger.error('Error code:', error.code); // Clean up thumbnail file on error if (thumbnailPath) { fs.unlink(thumbnailPath, () => {}); } // Fallback: try as document try { logger.info('Retrying as document...'); const docStream = fs.createReadStream(filePath); await this.bot.sendDocument(chatId, docStream, { caption: `${title}\n${performer || 'Unknown Artist'}`, parse_mode: undefined }, { filename: customFilename, contentType: 'audio/mpeg' }); logger.success(`Document sent successfully: ${title}`); return; } catch (documentError: any) { logger.error('Document send also failed:', documentError.message); throw documentError; } } } catch (error: any) { logger.error('Send failed completely:', error.message); logger.error('Full error:', error); // Send error message to user try { await this.bot.sendMessage(chatId, `Не удалось отправить файл.\n${title}\n\nПопробуйте другой трек.` ); } catch { logger.error('Could not even send error message'); } // Re-throw to trigger unhandled rejection handler throw error; } } private async handleWebAppData(msg: Message): Promise { const chatId = msg.chat.id; if (!msg.web_app?.data) { return; } try { const data: WebAppData = JSON.parse(msg.web_app.data); if (data.action === 'send_audio') { logger.telegram('WebApp request', data.title); await this.sendAudioFileInternal(chatId, data.audioUrl, data.title); } } catch (parseError: any) { logger.error('WebApp data parse error:', parseError.message); } } private formatDuration(seconds: number): string { if (!seconds) return ''; const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins}:${secs.toString().padStart(2, '0')}`; } } // Initialize bot if this file is run directly if (require.main === module) { const token = process.env.TELEGRAM_BOT_TOKEN; const webAppUrl = process.env.WEB_APP_URL || 'https://your-domain.com'; if (!token) { logger.error('TELEGRAM_BOT_TOKEN environment variable is required'); process.exit(1); } new QuixoticBot(token, webAppUrl); }