Initial commit: Complete Quixotic Telegram MiniApp implementation

- Set up Express.js server with YouTube search and MP3 conversion API
- Created Telegram Web App frontend with responsive design
- Implemented SQLite database for user management and history
- Added Telegram Bot integration with commands and Web App support
- Configured FFmpeg-based audio conversion pipeline
- Added comprehensive documentation and deployment guides

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Andrey Kondratev
2025-08-25 10:37:07 +05:00
commit 2bfb456cf3
9 changed files with 968 additions and 0 deletions

223
src/bot.js Normal file
View File

@@ -0,0 +1,223 @@
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;

104
src/database.js Normal file
View File

@@ -0,0 +1,104 @@
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
class Database {
constructor() {
this.dbPath = path.join(__dirname, '../database/quixotic.db');
this.db = new sqlite3.Database(this.dbPath);
this.init();
}
init() {
this.db.serialize(() => {
// Users table
this.db.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
telegram_id INTEGER UNIQUE NOT NULL,
username TEXT,
first_name TEXT,
last_name TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Search history table
this.db.run(`CREATE TABLE IF NOT EXISTS search_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
query TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
)`);
// Downloaded files table
this.db.run(`CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
youtube_id TEXT NOT NULL,
title TEXT NOT NULL,
file_path TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
)`);
});
}
addUser(telegramUser) {
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) {
if (err) reject(err);
else resolve(this.lastID);
}
);
});
}
addSearchHistory(userId, query) {
return new Promise((resolve, reject) => {
this.db.run(
`INSERT INTO search_history (user_id, query) VALUES (?, ?)`,
[userId, query],
function(err) {
if (err) reject(err);
else resolve(this.lastID);
}
);
});
}
addDownload(userId, youtubeId, title, filePath) {
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) {
if (err) reject(err);
else resolve(this.lastID);
}
);
});
}
getUserByTelegramId(telegramId) {
return new Promise((resolve, reject) => {
this.db.get(
`SELECT * FROM users WHERE telegram_id = ?`,
[telegramId],
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
}
close() {
this.db.close();
}
}
module.exports = Database;

166
src/server.js Normal file
View File

@@ -0,0 +1,166 @@
const express = require('express');
const path = require('path');
const fs = require('fs');
const ffmpeg = require('fluent-ffmpeg');
const Database = require('./database');
const YouTubeService = require('./youtube');
const app = express();
const port = process.env.PORT || 3000;
// Initialize services
const db = new Database();
const youtube = new YouTubeService();
// Middleware
app.use(express.json());
app.use(express.static('public'));
// Ensure downloads directory exists
const downloadsDir = path.join(__dirname, '../downloads');
if (!fs.existsSync(downloadsDir)) {
fs.mkdirSync(downloadsDir, { recursive: true });
}
// Routes
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '../public/index.html'));
});
// Search videos
app.post('/api/search', async (req, res) => {
try {
const { query, userId } = req.body;
if (!query || query.trim().length === 0) {
return res.status(400).json({ error: 'Query is required' });
}
// Save search history
if (userId && userId !== 'demo') {
try {
const user = await db.getUserByTelegramId(userId);
if (user) {
await db.addSearchHistory(user.id, query);
}
} catch (dbError) {
console.error('Database error:', dbError);
}
}
const videos = await youtube.searchVideos(query.trim());
res.json({ videos });
} catch (error) {
console.error('Search error:', error);
res.status(500).json({ error: 'Failed to search videos' });
}
});
// Convert video to MP3
app.post('/api/convert', async (req, res) => {
try {
const { videoId, title, userId } = req.body;
if (!videoId) {
return res.status(400).json({ error: 'Video ID is required' });
}
// Generate safe filename
const safeTitle = title.replace(/[^\w\s-]/g, '').replace(/\s+/g, '_').substring(0, 50);
const filename = `${videoId}_${safeTitle}.mp3`;
const outputPath = path.join(downloadsDir, filename);
// Check if file already exists
if (fs.existsSync(outputPath)) {
const audioUrl = `${req.protocol}://${req.get('host')}/downloads/${filename}`;
return res.json({ audioUrl, title });
}
// Get audio stream from YouTube
const audioStream = await youtube.getAudioStream(videoId);
// Convert to MP3 using ffmpeg
await new Promise((resolve, reject) => {
ffmpeg(audioStream)
.audioCodec('libmp3lame')
.audioBitrate(128)
.format('mp3')
.output(outputPath)
.on('error', (err) => {
console.error('FFmpeg error:', err);
reject(err);
})
.on('end', () => {
console.log('Conversion finished:', filename);
resolve();
})
.run();
});
// Save download record
if (userId && userId !== 'demo') {
try {
const user = await db.getUserByTelegramId(userId);
if (user) {
await db.addDownload(user.id, videoId, title, outputPath);
}
} catch (dbError) {
console.error('Database error:', dbError);
}
}
const audioUrl = `${req.protocol}://${req.get('host')}/downloads/${filename}`;
res.json({ audioUrl, title });
} catch (error) {
console.error('Conversion error:', error);
res.status(500).json({ error: 'Failed to convert video' });
}
});
// Serve download files
app.use('/downloads', express.static(downloadsDir));
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Error handler
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Something went wrong!' });
});
// Cleanup old files periodically (every hour)
setInterval(() => {
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
const now = Date.now();
fs.readdir(downloadsDir, (err, files) => {
if (err) return;
files.forEach(file => {
const filePath = path.join(downloadsDir, file);
fs.stat(filePath, (err, stats) => {
if (err) return;
if (now - stats.mtime.getTime() > maxAge) {
fs.unlink(filePath, (err) => {
if (!err) {
console.log('Deleted old file:', file);
}
});
}
});
});
});
}, 60 * 60 * 1000); // Run every hour
app.listen(port, () => {
console.log(`<µ Quixotic server running on port ${port}`);
console.log(`=Á Downloads directory: ${downloadsDir}`);
});
module.exports = app;

173
src/youtube.js Normal file
View File

@@ -0,0 +1,173 @@
const ytdl = require('ytdl-core');
const axios = require('axios');
class YouTubeService {
constructor() {
// Using YouTube's internal API endpoint for search (no API key needed)
this.searchEndpoint = 'https://www.youtube.com/youtubei/v1/search?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8';
}
async searchVideos(query, maxResults = 10) {
try {
const response = await axios.post(this.searchEndpoint, {
context: {
client: {
clientName: 'WEB',
clientVersion: '2.20230728.00.00'
}
},
query: query
});
const contents = response.data?.contents?.twoColumnSearchResultsRenderer?.primaryContents?.sectionListRenderer?.contents;
if (!contents) return [];
const videoResults = [];
for (const section of contents) {
const itemSection = section?.itemSectionRenderer?.contents;
if (!itemSection) continue;
for (const item of itemSection) {
const videoRenderer = item?.videoRenderer;
if (!videoRenderer) continue;
const video = this.parseVideoRenderer(videoRenderer);
if (video && videoResults.length < maxResults) {
videoResults.push(video);
}
}
}
return videoResults;
} catch (error) {
console.error('YouTube search error:', error.message);
// Fallback to alternative method
return this.searchWithYtdl(query, maxResults);
}
}
parseVideoRenderer(videoRenderer) {
try {
const videoId = videoRenderer.videoId;
if (!videoId) return null;
const title = videoRenderer.title?.runs?.[0]?.text || 'Unknown Title';
const channel = videoRenderer.ownerText?.runs?.[0]?.text || 'Unknown Channel';
// Get thumbnail
const thumbnails = videoRenderer.thumbnail?.thumbnails || [];
const thumbnail = thumbnails[thumbnails.length - 1]?.url ||
`https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
// Parse duration
let duration = 0;
const durationText = videoRenderer.lengthText?.simpleText;
if (durationText) {
duration = this.parseDuration(durationText);
}
return {
id: videoId,
title,
channel,
thumbnail: thumbnail.startsWith('//') ? 'https:' + thumbnail : thumbnail,
duration,
url: `https://www.youtube.com/watch?v=${videoId}`
};
} catch (error) {
console.error('Error parsing video:', error);
return null;
}
}
async searchWithYtdl(query, maxResults = 10) {
// Fallback method using ytdl-core with search
try {
const searchUrl = `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`;
const response = await axios.get(searchUrl);
const videoIds = this.extractVideoIds(response.data);
const results = [];
for (let i = 0; i < Math.min(videoIds.length, maxResults); i++) {
try {
const info = await ytdl.getBasicInfo(videoIds[i]);
const videoDetails = info.videoDetails;
results.push({
id: videoDetails.videoId,
title: videoDetails.title,
channel: videoDetails.ownerChannelName || 'Unknown Channel',
thumbnail: videoDetails.thumbnails?.[0]?.url ||
`https://img.youtube.com/vi/${videoDetails.videoId}/maxresdefault.jpg`,
duration: parseInt(videoDetails.lengthSeconds) || 0,
url: videoDetails.video_url
});
} catch (error) {
console.error(`Error getting info for video ${videoIds[i]}:`, error.message);
}
}
return results;
} catch (error) {
console.error('Fallback search error:', error.message);
return [];
}
}
extractVideoIds(html) {
const videoIdRegex = /"videoId":"([^"]+)"/g;
const videoIds = [];
let match;
while ((match = videoIdRegex.exec(html)) !== null) {
if (!videoIds.includes(match[1])) {
videoIds.push(match[1]);
}
}
return videoIds;
}
parseDuration(durationText) {
const parts = durationText.split(':').reverse();
let seconds = 0;
for (let i = 0; i < parts.length; i++) {
seconds += parseInt(parts[i]) * Math.pow(60, i);
}
return seconds;
}
async getVideoInfo(videoId) {
try {
const info = await ytdl.getBasicInfo(videoId);
return {
title: info.videoDetails.title,
author: info.videoDetails.author.name,
length: info.videoDetails.lengthSeconds,
formats: info.formats
};
} catch (error) {
console.error('Error getting video info:', error);
throw error;
}
}
async getAudioStream(videoId) {
try {
const stream = ytdl(videoId, {
quality: 'highestaudio',
filter: 'audioonly'
});
return stream;
} catch (error) {
console.error('Error getting audio stream:', error);
throw error;
}
}
}
module.exports = YouTubeService;