This commit is contained in:
Andrey Kondratev
2025-08-27 18:57:09 +05:00
parent 98787a382e
commit 57f0519a32
13 changed files with 829 additions and 550 deletions

View File

@@ -1,223 +0,0 @@
const TelegramBot = require('node-telegram-bot-api');
const Database = require('./database');
class QuixoticBot {
constructor(token, webAppUrl) {
this.bot = new TelegramBot(token, { polling: true });
this.webAppUrl = webAppUrl;
this.db = new Database();
this.init();
}
init() {
console.log('> Telegram bot initialized');
this.setupCommands();
this.setupHandlers();
}
setupCommands() {
// Set bot commands
this.bot.setMyCommands([
{ command: 'start', description: '0?CAB8BL ?@8;>65=85' },
{ command: 'help', description: '><>IL' },
{ command: 'history', description: 'AB>@8O ?>8A:0' }
]);
}
setupHandlers() {
// Start command
this.bot.onText(/\/start/, async (msg) => {
const chatId = msg.chat.id;
const user = msg.from;
try {
// Add user to database
await this.db.addUser(user);
const keyboard = {
inline_keyboard: [[
{
text: '<µ B:@KBL Quixotic',
web_app: { url: this.webAppUrl }
}
]]
};
await this.bot.sendMessage(chatId,
'<µ >1@> ?>60;>20BL 2 Quixotic!\n\n' +
'0948 ;N1CN ?5A=N =0 YouTube 8 ?>;CG8 MP3 D09; ?@O<> 2 G0B.\n\n' +
'06<8 :=>?:C =865, GB>1K =0G0BL ?>8A::',
{ reply_markup: keyboard }
);
} catch (error) {
console.error('Start command error:', error);
await this.bot.sendMessage(chatId, '@>87>H;0 >H81:0. >?@>1C9B5 ?>765.');
}
});
// Help command
this.bot.onText(/\/help/, async (msg) => {
const chatId = msg.chat.id;
const helpText = `<µ *Quixotic - YouTube to MP3*
*0: ?>;L7>20BLAO:*
1ã 06<8 :=>?:C "B:@KBL Quixotic"
2ã 2548 =0720=85 ?5A=8 2 ?>8A:>2CN AB@>:C
3ã K15@8 =C6=K9 B@5: 87 A?8A:0
4ã >;CG8 MP3 D09; 2 G0B!
*><0=4K:*
/start - 0?CAB8BL ?@8;>65=85
/help - -B0 A?@02:0
/history - AB>@8O ?>8A:0
*>7<>6=>AB8:*
" >8A: ?> YouTube
" KA>:>5 :0G5AB2> MP3 (128kbps)
" KAB@0O :>=25@B0F8O
" AB>@8O ?>8A:0`;
await this.bot.sendMessage(chatId, helpText, { parse_mode: 'Markdown' });
});
// History command
this.bot.onText(/\/history/, async (msg) => {
const chatId = msg.chat.id;
const userId = msg.from.id;
try {
const user = await this.db.getUserByTelegramId(userId);
if (!user) {
await this.bot.sendMessage(chatId, 'AB>@8O ?>8A:0 ?CAB0.');
return;
}
// Get recent search history
const history = await this.db.db.all(
`SELECT query, created_at FROM search_history
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 10`,
[user.id]
);
if (history.length === 0) {
await this.bot.sendMessage(chatId, 'AB>@8O ?>8A:0 ?CAB0.');
return;
}
let historyText = '=È *>A;54=85 ?>8A:>2K5 70?@>AK:*\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, 'H81:0 ?>;CG5=8O 8AB>@88.');
}
});
// Handle web app data
this.bot.on('web_app_data', async (msg) => {
const chatId = msg.chat.id;
const data = JSON.parse(msg.web_app.data);
try {
if (data.action === 'send_audio') {
await this.sendAudioFile(chatId, data.audioUrl, data.title);
}
} catch (error) {
console.error('Web app data error:', error);
await this.bot.sendMessage(chatId, 'H81:0 >1@01>B:8 40==KE.');
}
});
// Handle inline queries for search
this.bot.on('inline_query', async (query) => {
const queryId = query.id;
const searchQuery = query.query;
if (!searchQuery || searchQuery.length < 3) {
await this.bot.answerInlineQuery(queryId, []);
return;
}
try {
const YouTubeService = require('./youtube');
const youtube = new YouTubeService();
const videos = await youtube.searchVideos(searchQuery, 5);
const results = videos.map((video, index) => ({
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
this.bot.on('error', (error) => {
console.error('Telegram bot error:', error);
});
console.log(' Bot handlers setup complete');
}
async sendAudioFile(chatId, audioUrl, title) {
try {
await this.bot.sendMessage(chatId, >43>B02;820N MP3 D09;...');
// Send audio file
await this.bot.sendAudio(chatId, audioUrl, {
title: title,
performer: 'Quixotic',
caption: `${title}`
});
} catch (error) {
console.error('Send audio error:', error);
await this.bot.sendMessage(chatId,
'L 5 C40;>AL >B?@028BL 0C48>D09;. >?@>1C9B5 5I5 @07.\n\n' +
`@O<0O AAK;:0: ${audioUrl}`
);
}
}
formatDuration(seconds) {
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('L TELEGRAM_BOT_TOKEN environment variable is required');
process.exit(1);
}
new QuixoticBot(token, webAppUrl);
}
module.exports = QuixoticBot;

275
src/bot.ts Normal file
View File

@@ -0,0 +1,275 @@
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 {
// 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' +
'Найди любую песню на YouTube и получи 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 - YouTube to MP3*
*Как пользоваться:*
1⃣ Нажми кнопку "Открыть Quixotic"
2⃣ Введи название песни в поисковую строку
3⃣ Выбери нужный трек из списка
4⃣ Получи MP3 файл в чат!
*Команды:*
/start - Запустить приложение
/help - Эта справка
/history - История поиска
*Возможности:*
✅ Поиск по YouTube
✅ Высокое качество MP3 (128kbps)
✅ Быстрая конвертация
✅ История поиска`;
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
this.bot.on('web_app_data', async (msg: Message) => {
const chatId = msg.chat.id;
if (!msg.web_app?.data) return;
const data: WebAppData = JSON.parse(msg.web_app.data);
try {
if (data.action === 'send_audio') {
await this.sendAudioFile(chatId, data.audioUrl, data.title);
}
} catch (error) {
console.error('Web app data error:', error);
await this.bot.sendMessage(chatId, '❌ Ошибка обработки данных.');
}
});
// 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
this.bot.on('error', (error: Error) => {
console.error('Telegram bot error:', error);
});
console.log('✅ Bot handlers setup complete');
}
private async getSearchHistory(userId: number): Promise<SearchResult[]> {
return new Promise((resolve, reject) => {
this.db['db'].all(
`SELECT query, created_at FROM search_history
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 10`,
[userId],
(err: Error | null, rows: SearchResult[]) => {
if (err) reject(err);
else resolve(rows || []);
}
);
});
}
private async sendAudioFile(chatId: number, audioUrl: string, title: string): Promise<void> {
try {
await this.bot.sendMessage(chatId, '⏳ Подготавливаю MP3 файл...');
// Send audio file
await this.bot.sendAudio(chatId, audioUrl, {
title: title,
performer: 'Quixotic',
caption: `🎵 ${title}`
});
} catch (error) {
console.error('Send audio error:', error);
await this.bot.sendMessage(chatId,
'❌ Не удалось отправить аудиофайл. Попробуйте еще раз.\n\n' +
`Прямая ссылка: ${audioUrl}`
);
}
}
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);
}

View File

@@ -1,14 +1,33 @@
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
import sqlite3 from 'sqlite3';
import path from 'path';
interface TelegramUser {
id: number;
username?: string;
first_name?: string;
last_name?: string;
}
interface User {
id: number;
telegram_id: number;
username?: string;
first_name?: string;
last_name?: string;
created_at: string;
}
export class Database {
private dbPath: string;
private db: sqlite3.Database;
class Database {
constructor() {
this.dbPath = path.join(__dirname, '../database/quixotic.db');
this.db = new sqlite3.Database(this.dbPath);
this.init();
}
init() {
private init(): void {
this.db.serialize(() => {
// Users table
this.db.run(`CREATE TABLE IF NOT EXISTS users (
@@ -42,14 +61,14 @@ class Database {
});
}
addUser(telegramUser) {
addUser(telegramUser: TelegramUser): Promise<number> {
return new Promise((resolve, reject) => {
const { id, username, first_name, last_name } = telegramUser;
this.db.run(
`INSERT OR REPLACE INTO users (telegram_id, username, first_name, last_name)
VALUES (?, ?, ?, ?)`,
[id, username, first_name, last_name],
function(err) {
function(err: Error | null) {
if (err) reject(err);
else resolve(this.lastID);
}
@@ -57,12 +76,12 @@ class Database {
});
}
addSearchHistory(userId, query) {
addSearchHistory(userId: number, query: string): Promise<number> {
return new Promise((resolve, reject) => {
this.db.run(
'INSERT INTO search_history (user_id, query) VALUES (?, ?)',
[userId, query],
function(err) {
function(err: Error | null) {
if (err) reject(err);
else resolve(this.lastID);
}
@@ -70,12 +89,12 @@ class Database {
});
}
addDownload(userId, youtubeId, title, filePath) {
addDownload(userId: number, youtubeId: string, title: string, filePath: string): Promise<number> {
return new Promise((resolve, reject) => {
this.db.run(
'INSERT INTO downloads (user_id, youtube_id, title, file_path) VALUES (?, ?, ?, ?)',
[userId, youtubeId, title, filePath],
function(err) {
function(err: Error | null) {
if (err) reject(err);
else resolve(this.lastID);
}
@@ -83,12 +102,12 @@ class Database {
});
}
getUserByTelegramId(telegramId) {
getUserByTelegramId(telegramId: string | number): Promise<User | undefined> {
return new Promise((resolve, reject) => {
this.db.get(
'SELECT * FROM users WHERE telegram_id = ?',
[telegramId],
(err, row) => {
(err: Error | null, row: User) => {
if (err) reject(err);
else resolve(row);
}
@@ -96,9 +115,7 @@ class Database {
});
}
close() {
close(): void {
this.db.close();
}
}
module.exports = Database;
}

View File

@@ -1,9 +1,9 @@
const express = require('express');
const path = require('path');
const fs = require('fs');
const ffmpeg = require('fluent-ffmpeg');
const Database = require('./database');
const SoundCloudService = require('./soundcloud');
import express, { Request, Response, NextFunction } from 'express';
import path from 'path';
import fs from 'fs';
import ffmpeg from 'fluent-ffmpeg';
import { Database } from './database';
import { SoundCloudService } from './soundcloud';
const app = express();
const port = process.env.PORT || 3000;
@@ -23,14 +23,14 @@ if (!fs.existsSync(downloadsDir)) {
}
// Routes
app.get('/', (req, res) => {
app.get('/', (req: Request, res: Response) => {
res.sendFile(path.join(__dirname, '../public/index.html'));
});
// Search videos
app.post('/api/search', async (req, res) => {
app.post('/api/search', async (req: Request, res: Response) => {
try {
const { query, userId } = req.body;
const { query, userId }: { query?: string; userId?: string } = req.body;
if (!query || query.trim().length === 0) {
return res.status(400).json({ error: 'Query is required' });
@@ -58,9 +58,9 @@ app.post('/api/search', async (req, res) => {
});
// Convert video to MP3
app.post('/api/convert', async (req, res) => {
app.post('/api/convert', async (req: Request, res: Response) => {
try {
const { videoId, title, userId } = req.body;
const { videoId, title, userId }: { videoId?: string; title?: string; userId?: string } = req.body;
console.log('Convert request received:', { videoId, title, userId });
if (!videoId) {
@@ -68,7 +68,7 @@ app.post('/api/convert', async (req, res) => {
}
// Generate safe filename
const safeTitle = title.replace(/[^\w\s-]/g, '').replace(/\s+/g, '_').substring(0, 50);
const safeTitle = (title || '').replace(/[^\w\s-]/g, '').replace(/\s+/g, '_').substring(0, 50);
const filename = `${videoId}_${safeTitle}.mp3`;
const outputPath = path.join(downloadsDir, filename);
@@ -88,7 +88,7 @@ app.post('/api/convert', async (req, res) => {
console.log('Audio stream obtained, starting FFmpeg conversion...');
// Convert to MP3 using ffmpeg
await new Promise((resolve, reject) => {
await new Promise<void>((resolve, reject) => {
const conversion = ffmpeg(audioStream)
.audioCodec('libmp3lame')
.audioBitrate('192k')
@@ -96,10 +96,10 @@ app.post('/api/convert', async (req, res) => {
.audioFrequency(44100)
.format('mp3')
.output(outputPath)
.on('start', (command) => {
.on('start', (command: string) => {
console.log('FFmpeg started:', command);
})
.on('progress', (progress) => {
.on('progress', (progress: any) => {
if (progress.percent) {
console.log(`Conversion progress: ${Math.round(progress.percent)}%`);
}
@@ -108,7 +108,7 @@ app.post('/api/convert', async (req, res) => {
console.log('MP3 conversion completed successfully');
resolve();
})
.on('error', (err) => {
.on('error', (err: Error) => {
console.error('FFmpeg error:', err.message);
reject(err);
});
@@ -121,7 +121,7 @@ app.post('/api/convert', async (req, res) => {
try {
const user = await db.getUserByTelegramId(userId);
if (user) {
await db.addDownload(user.id, videoId, title, outputPath);
await db.addDownload(user.id, videoId, title || '', outputPath);
}
} catch (dbError) {
console.error('Database error:', dbError);
@@ -132,7 +132,7 @@ app.post('/api/convert', async (req, res) => {
console.log('Conversion successful, file available at:', audioUrl);
res.json({ audioUrl, title });
} catch (conversionError) {
} catch (conversionError: any) {
console.error('Conversion failed for video:', videoId);
console.error('Error details:', conversionError.message);
console.error('Full error:', conversionError);
@@ -155,12 +155,12 @@ app.post('/api/convert', async (req, res) => {
app.use('/downloads', express.static(downloadsDir));
// Health check
app.get('/health', (req, res) => {
app.get('/health', (req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Error handler
app.use((err, req, res, next) => {
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(err.stack);
res.status(500).json({ error: 'Something went wrong!' });
});
@@ -196,4 +196,4 @@ app.listen(port, () => {
console.log(`Open in browser: http://localhost:${port}`);
});
module.exports = app;
export default app;

View File

@@ -1,12 +1,44 @@
const scdl = require('soundcloud-downloader').default;
import scdl from 'soundcloud-downloader';
import { Readable } from 'stream';
interface SearchTrack {
id: number;
title: string;
user?: {
username: string;
avatar_url?: string;
};
artwork_url?: string;
duration: number;
permalink_url: string;
streamable: boolean;
downloadable: boolean;
}
class SoundCloudService {
interface TrackResult {
id: number;
title: string;
channel: string;
thumbnail: string;
duration: number;
url: string;
streamable: boolean;
downloadable: boolean;
}
interface TrackInfo {
title: string;
author: string;
length: number;
available: boolean;
}
export class SoundCloudService {
constructor() {
console.log('SoundCloud service initialized');
}
async searchTracks(query, maxResults = 10) {
async searchTracks(query: string, maxResults: number = 10): Promise<TrackResult[]> {
try {
console.log(`Searching SoundCloud for: ${query}`);
@@ -15,14 +47,14 @@ class SoundCloudService {
query: query,
limit: maxResults,
resourceType: 'tracks'
});
}) as any;
if (!tracks || tracks.length === 0) {
console.log('No tracks found');
return [];
}
const trackResults = tracks.map(track => ({
const trackResults: TrackResult[] = tracks.map(track => ({
id: track.id,
title: track.title,
channel: track.user?.username || 'Unknown Artist',
@@ -36,15 +68,15 @@ class SoundCloudService {
console.log(`Found ${trackResults.length} tracks on SoundCloud`);
return trackResults;
} catch (error) {
} catch (error: any) {
console.error('SoundCloud search error:', error.message);
return [];
}
}
async getTrackInfo(trackId) {
async getTrackInfo(trackId: string | number): Promise<TrackInfo> {
try {
const track = await scdl.getInfo(trackId);
const track = await scdl.getInfo(String(trackId)) as SearchTrack;
return {
title: track.title,
author: track.user?.username || 'Unknown',
@@ -57,12 +89,12 @@ class SoundCloudService {
}
}
async getAudioStream(trackId) {
async getAudioStream(trackId: string | number): Promise<Readable> {
try {
console.log(`Getting audio stream for track: ${trackId}`);
// Get track info first
const trackInfo = await scdl.getInfo(trackId);
const trackInfo = await scdl.getInfo(String(trackId)) as SearchTrack;
if (!trackInfo.streamable) {
throw new Error('Track is not streamable');
@@ -73,12 +105,12 @@ class SoundCloudService {
console.log(`Duration: ${Math.floor(trackInfo.duration / 1000)}s`);
// Get audio stream
const stream = await scdl.download(trackId);
const stream = await scdl.download(String(trackId));
console.log('Audio stream obtained successfully from SoundCloud');
return stream;
} catch (error) {
} catch (error: any) {
console.error('SoundCloud download failed:', error.message);
// Try alternative approach
@@ -90,12 +122,10 @@ class SoundCloudService {
console.log('Audio stream obtained with alternative method');
return stream;
} catch (fallbackError) {
} catch (fallbackError: any) {
console.error('Alternative method also failed:', fallbackError.message);
throw new Error(`SoundCloud download failed: ${error.message}`);
}
}
}
}
module.exports = SoundCloudService;
}