484 lines
18 KiB
TypeScript
484 lines
18 KiB
TypeScript
import TelegramBot from 'node-telegram-bot-api';
|
||
import { Database } from './database';
|
||
|
||
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) {
|
||
this.bot = new TelegramBot(token, { polling: true });
|
||
this.webAppUrl = webAppUrl;
|
||
this.db = new Database();
|
||
this.init();
|
||
}
|
||
|
||
private init(): void {
|
||
console.log('🤖 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 {
|
||
console.log('🔧 Setting up bot handlers...');
|
||
|
||
// Log all incoming messages for debugging
|
||
this.bot.on('message', (msg: any) => {
|
||
console.log('📨 Received message:', {
|
||
type: msg.web_app ? 'web_app_data' : (msg.text ? 'text' : 'other'),
|
||
from: msg.from?.id,
|
||
chat: msg.chat?.id,
|
||
hasWebAppData: !!msg.web_app?.data,
|
||
webAppDataLength: msg.web_app?.data?.length || 0
|
||
});
|
||
|
||
// Handle web app data in regular message event
|
||
if (msg.web_app?.data) {
|
||
console.log('🔍 Web app data found in message:', 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) {
|
||
console.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) {
|
||
console.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) => {
|
||
console.log('🔍 Web app data received via web_app_data event:', msg.web_app?.data);
|
||
this.handleWebAppData(msg);
|
||
});
|
||
|
||
// Additional handler for callback queries (sometimes WebApp data comes here)
|
||
this.bot.on('callback_query', async (query: any) => {
|
||
console.log('📞 Callback query received:', {
|
||
id: query.id,
|
||
data: query.data,
|
||
from: query.from?.id
|
||
});
|
||
|
||
if (query.data) {
|
||
try {
|
||
const data = JSON.parse(query.data);
|
||
if (data.action === 'send_audio') {
|
||
console.log('🎵 Audio action from callback query');
|
||
await this.sendAudioFileInternal(query.message.chat.id, data.audioUrl, data.title);
|
||
}
|
||
} catch (e) {
|
||
console.log('Callback query data is not JSON, ignoring');
|
||
}
|
||
}
|
||
|
||
// Answer callback query to remove loading state
|
||
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) {
|
||
console.error('Inline query error:', error);
|
||
await this.bot.answerInlineQuery(queryId, []);
|
||
}
|
||
});
|
||
|
||
// Error handler with detailed logging
|
||
this.bot.on('error', (error: any) => {
|
||
console.error('🚨 Telegram bot error:', error.message || error);
|
||
console.error('Error code:', error.code);
|
||
console.error('Full error:', error);
|
||
});
|
||
|
||
// Handle polling errors specifically
|
||
this.bot.on('polling_error', (error: any) => {
|
||
console.error('🚨 Telegram polling error:', error.message || error);
|
||
console.error('Error code:', error.code);
|
||
|
||
// Don't crash on polling errors, just log them
|
||
if (error.code === 'ETELEGRAM') {
|
||
console.warn('⚠️ Telegram API error - continuing operation');
|
||
}
|
||
});
|
||
|
||
// Add universal event logger for debugging
|
||
this.bot.on('edited_message', (msg: any) => {
|
||
console.log('✏️ Edited message received, checking for web app data:', !!msg.web_app?.data);
|
||
if (msg.web_app?.data) {
|
||
this.handleWebAppData(msg);
|
||
}
|
||
});
|
||
|
||
this.bot.on('channel_post', (msg: any) => {
|
||
console.log('📢 Channel post received, checking for web app data:', !!msg.web_app?.data);
|
||
if (msg.web_app?.data) {
|
||
this.handleWebAppData(msg);
|
||
}
|
||
});
|
||
|
||
// Log all update types
|
||
this.bot.on('update', (update: any) => {
|
||
const updateTypes = Object.keys(update).filter(key => key !== 'update_id');
|
||
console.log('🔄 Update received:', {
|
||
types: updateTypes,
|
||
update_id: update.update_id
|
||
});
|
||
|
||
// Check for web_app_data in any part of the update
|
||
const checkForWebAppData = (obj: any, path = ''): any => {
|
||
if (!obj || typeof obj !== 'object') return null;
|
||
|
||
if (obj.web_app?.data) {
|
||
console.log(`🎯 Found web_app_data at ${path}:`, obj.web_app.data);
|
||
return obj;
|
||
}
|
||
|
||
for (const [key, value] of Object.entries(obj)) {
|
||
const result = checkForWebAppData(value, `${path}.${key}`);
|
||
if (result) return result;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const msgWithData = checkForWebAppData(update);
|
||
if (msgWithData && msgWithData.chat?.id) {
|
||
console.log('🚀 Processing web app data found in update');
|
||
this.handleWebAppData(msgWithData);
|
||
}
|
||
});
|
||
|
||
console.log('✅ 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): Promise<void> {
|
||
return this.sendAudioFileInternal(chatId, audioUrl, title);
|
||
}
|
||
|
||
private async sendAudioFileInternal(chatId: number, audioUrl: string, title: string): Promise<void> {
|
||
try {
|
||
console.log(`🎵 Starting sendAudioFile to chat ${chatId}`);
|
||
console.log(`🔗 Audio URL: ${audioUrl}`);
|
||
console.log(`📝 Title: ${title}`);
|
||
|
||
// Send initial status message
|
||
const statusMsg = await this.bot.sendMessage(chatId, '⏳ Подготавливаю MP3 файл...');
|
||
|
||
// Validate audio URL
|
||
if (!audioUrl.startsWith('http')) {
|
||
throw new Error(`Invalid audio URL: ${audioUrl}`);
|
||
}
|
||
|
||
// Try sending as audio with proper error handling
|
||
try {
|
||
console.log('🚀 Attempting sendAudio...');
|
||
const audioResult = await this.bot.sendAudio(chatId, audioUrl, {
|
||
title: title,
|
||
performer: 'SoundCloud',
|
||
caption: `🎵 ${title}\n\n🤖 Загружено через Quixotic`,
|
||
parse_mode: undefined,
|
||
protect_content: false
|
||
});
|
||
|
||
console.log(`✅ Audio sent successfully! Message ID: ${audioResult.message_id}`);
|
||
|
||
// Delete status message after success
|
||
try {
|
||
await this.bot.deleteMessage(chatId, statusMsg.message_id);
|
||
} catch (delError) {
|
||
console.log('Could not delete status message (not critical)');
|
||
}
|
||
|
||
return;
|
||
|
||
} catch (audioError: any) {
|
||
console.error('❌ SendAudio failed:', audioError.message);
|
||
console.error('Audio error details:', audioError);
|
||
|
||
// Update status message
|
||
await this.bot.editMessageText('📄 Отправляю как документ...', {
|
||
chat_id: chatId,
|
||
message_id: statusMsg.message_id
|
||
});
|
||
|
||
// Fallback: try sending as document
|
||
try {
|
||
console.log('🚀 Attempting sendDocument fallback...');
|
||
const docResult = await this.bot.sendDocument(chatId, audioUrl, {
|
||
caption: `🎵 ${title}\n\n🤖 Загружено через Quixotic`,
|
||
parse_mode: undefined
|
||
});
|
||
|
||
console.log(`✅ Document sent successfully! Message ID: ${docResult.message_id}`);
|
||
|
||
// Delete status message after success
|
||
try {
|
||
await this.bot.deleteMessage(chatId, statusMsg.message_id);
|
||
} catch (delError) {
|
||
console.log('Could not delete status message (not critical)');
|
||
}
|
||
|
||
return;
|
||
|
||
} catch (documentError: any) {
|
||
console.error('❌ SendDocument also failed:', documentError.message);
|
||
console.error('Document error details:', documentError);
|
||
throw documentError;
|
||
}
|
||
}
|
||
|
||
} catch (error: any) {
|
||
console.error('💥 Complete send audio failure:', error.message);
|
||
console.error('Full error object:', error);
|
||
|
||
// Send fallback message with direct link
|
||
try {
|
||
await this.bot.sendMessage(chatId,
|
||
`❌ Не удалось отправить файл автоматически.\n\n` +
|
||
`🎵 *${title}*\n\n` +
|
||
`📥 Скачайте напрямую: [Ссылка на MP3](${audioUrl})\n\n` +
|
||
`_Ошибка: ${error.message}_`,
|
||
{
|
||
parse_mode: 'Markdown',
|
||
disable_web_page_preview: false
|
||
}
|
||
);
|
||
} catch (msgError: any) {
|
||
console.error('💥 Failed to send error message:', msgError.message);
|
||
// Last resort - try without markdown
|
||
try {
|
||
await this.bot.sendMessage(chatId,
|
||
`❌ Ошибка отправки файла.\n🎵 ${title}\n🔗 ${audioUrl}`
|
||
);
|
||
} catch (lastError) {
|
||
console.error('💥 All fallback methods failed:', lastError);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private async handleWebAppData(msg: Message): Promise<void> {
|
||
const chatId = msg.chat.id;
|
||
const userId = msg.from?.id;
|
||
|
||
console.log('🔧 HandleWebAppData called with:', {
|
||
chatId,
|
||
userId,
|
||
hasWebAppData: !!msg.web_app?.data,
|
||
dataLength: msg.web_app?.data?.length || 0
|
||
});
|
||
|
||
if (!msg.web_app?.data) {
|
||
console.log('❌ No web app data found in message');
|
||
await this.bot.sendMessage(chatId, '❌ Данные не получены. Попробуйте еще раз.');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
console.log('📝 Raw web app data:', msg.web_app.data);
|
||
const data: WebAppData = JSON.parse(msg.web_app.data);
|
||
console.log('✅ Parsed data successfully:', data);
|
||
|
||
if (data.action === 'send_audio') {
|
||
console.log(`🎵 Processing audio request for user ${userId}, chat ${chatId}: ${data.title}`);
|
||
console.log(`🔗 Audio URL: ${data.audioUrl}`);
|
||
|
||
// Send immediate confirmation
|
||
await this.bot.sendMessage(chatId, '⏳ Получил запрос, отправляю аудио...');
|
||
|
||
await this.sendAudioFileInternal(chatId, data.audioUrl, data.title);
|
||
} else {
|
||
console.log('⚠️ Unknown action:', data.action);
|
||
await this.bot.sendMessage(chatId, `❌ Неизвестное действие: ${data.action}`);
|
||
}
|
||
} catch (parseError: any) {
|
||
console.error('❌ Web app data parse error:', parseError.message);
|
||
console.error('Raw data that failed to parse:', msg.web_app?.data);
|
||
await this.bot.sendMessage(chatId, `❌ Ошибка обработки данных: ${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) {
|
||
console.error('❌ TELEGRAM_BOT_TOKEN environment variable is required');
|
||
process.exit(1);
|
||
}
|
||
|
||
new QuixoticBot(token, webAppUrl);
|
||
}
|