513 lines
20 KiB
TypeScript
513 lines
20 KiB
TypeScript
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<SearchResult[]> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void>((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<void> {
|
||
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);
|
||
}
|