Files
quixotic/src/bot.ts
Andrey Kondratev 21a32ffc79 fix authors?
2025-11-10 16:51:57 +05:00

513 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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\опробуйте другой трек.`
);
} 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);
}