Files
discord-bot/index.js
T
2026-08-05 14:35:21 +00:00

371 lines
13 KiB
JavaScript
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.
const { Client, GatewayIntentBits } = require('discord.js');
const {
joinVoiceChannel,
createAudioPlayer,
createAudioResource,
AudioPlayerStatus,
VoiceConnectionStatus,
entersState,
StreamType,
} = require('@discordjs/voice');
const { spawn } = require('child_process');
const { PassThrough } = require('stream');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
// ─── Config ──────────────────────────────────────────────────────────────────
const TOKEN = process.env.DISCORD_TOKEN || 'MTI4NzgyMDY5MjY2MTg2MjQ3MQ.GT9ssR.TXNSDgclq8JcLYpGcE8YUEqQy99Dg7jvW4hTQA';
const PREFIX = '!';
const VOLUME = 0.3; // 0.0 - 1.0
// ─── Cache setup ─────────────────────────────────────────────────────────────
const CACHE_DIR = path.join(__dirname, 'cache');
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR);
// Metadata cache: maps query/url → { title, duration, url }
// Saved to disk so it survives bot restarts
const META_FILE = path.join(CACHE_DIR, 'metadata.json');
let metaCache = {};
if (fs.existsSync(META_FILE)) {
try { metaCache = JSON.parse(fs.readFileSync(META_FILE, 'utf8')); }
catch { metaCache = {}; }
}
function saveMetaCache() {
fs.writeFileSync(META_FILE, JSON.stringify(metaCache, null, 2));
}
function getAudioCachePath(url) {
const hash = crypto.createHash('md5').update(url).digest('hex');
return path.join(CACHE_DIR, `${hash}.opus`);
}
// ─── State (per guild) ────────────────────────────────────────────────────────
const guilds = new Map();
function getState(guildId) {
if (!guilds.has(guildId)) {
guilds.set(guildId, {
connection: null,
player: null,
queue: [],
playing: false,
textChannel: null,
});
}
return guilds.get(guildId);
}
// ─── Discord client ───────────────────────────────────────────────────────────
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
client.once('ready', () => {
console.log(`✅ Logged in as ${client.user.tag}`);
});
client.on('messageCreate', async (message) => {
if (message.author.bot) return;
if (!message.content.startsWith(PREFIX)) return;
const args = message.content.slice(PREFIX.length).trim().split(/ +/);
const command = args.shift().toLowerCase();
try {
if (command === 'play') {
const query = args.join(' ');
if (!query) return message.channel.send('❌ Podaj link lub frazę do wyszukania!');
await cmdPlay(message, query);
} else if (command === 'skip') {
await cmdSkip(message);
} else if (command === 'queue') {
await cmdQueue(message);
} else if (command === 'stop') {
await cmdStop(message);
} else if (command === 'leave') {
await cmdLeave(message);
} else if (command === 'help') {
await message.channel.send(
'🎵 **Komendy:**\n' +
'`!play <url/szukaj>` dodaj utwór do kolejki\n' +
'`!skip` pomiń aktualny utwór\n' +
'`!queue` pokaż kolejkę\n' +
'`!stop` zatrzymaj i wyczyść kolejkę\n' +
'`!leave` rozłącz bota'
);
}
} catch (err) {
console.error(`[ERROR] command=${command}`, err);
message.channel.send(`⚠️ Błąd: ${err.message}`);
}
});
// ─── Commands ─────────────────────────────────────────────────────────────────
async function cmdPlay(message, query) {
const voiceChannel = message.member?.voice?.channel;
if (!voiceChannel) return message.channel.send('❌ Dołącz do kanału głosowego!');
const state = getState(message.guildId);
state.textChannel = message.channel;
// Join voice if not already connected
if (!state.connection ||
state.connection.state.status === VoiceConnectionStatus.Destroyed ||
state.connection.state.status === VoiceConnectionStatus.Disconnected) {
console.log(`[Voice] Connecting to: ${voiceChannel.name}`);
state.connection = joinVoiceChannel({
channelId: voiceChannel.id,
guildId: message.guildId,
adapterCreator: message.guild.voiceAdapterCreator,
selfDeaf: true,
});
state.connection.on('stateChange', (o, n) => console.log(`[Voice] ${o.status}${n.status}`));
state.connection.on('error', (err) => console.error('[Voice error]', err.message));
try {
await entersState(state.connection, VoiceConnectionStatus.Ready, 30_000);
console.log('[Voice] Ready!');
} catch (err) {
console.error('[Voice] Connect failed:', err.message);
state.connection.destroy();
state.connection = null;
return message.channel.send('❌ Nie udało się połączyć z kanałem głosowym.');
}
}
// Create player if needed
if (!state.player) {
state.player = createAudioPlayer();
state.connection.subscribe(state.player);
state.player.on(AudioPlayerStatus.Idle, () => {
state.playing = false;
playNext(message.guildId);
});
state.player.on('error', (err) => {
console.error('[Player error]', err.message);
state.playing = false;
state.textChannel?.send(`⚠️ Błąd odtwarzacza: ${err.message}`);
playNext(message.guildId);
});
}
// Check metadata cache first — if known, skip the yt-dlp lookup entirely
const cacheKey = query.toLowerCase().trim();
if (metaCache[cacheKey]) {
const song = metaCache[cacheKey];
const audioCached = fs.existsSync(getAudioCachePath(song.url));
console.log(`[Meta] HIT: "${query}" → ${song.url} (audio cached: ${audioCached})`);
state.queue.push(song);
await message.channel.send(
`✅ ${audioCached ? '⚡ Błyskawicznie z cache: ' : 'Dodano do kolejki: '}**${song.title}** [${song.duration}]`
);
if (!state.playing) playNext(message.guildId);
return;
}
// Not in cache — fetch metadata with yt-dlp
await message.channel.send(`🔍 Szukam: **${query}**…`);
const song = await resolveSong(query);
// Save to metadata cache under both the original query and the resolved URL
metaCache[cacheKey] = song;
metaCache[song.url.toLowerCase()] = song;
saveMetaCache();
console.log(`[Meta] Cached: "${query}" → ${song.url}`);
state.queue.push(song);
await message.channel.send(`✅ Dodano do kolejki: **${song.title}** [${song.duration}]`);
if (!state.playing) playNext(message.guildId);
}
async function cmdSkip(message) {
const state = getState(message.guildId);
if (!state.playing || !state.player) {
return message.channel.send('❌ Nic nie jest teraz odtwarzane.');
}
state.player.stop();
await message.channel.send('⏭️ Pominięto utwór.');
}
async function cmdQueue(message) {
const state = getState(message.guildId);
if (state.queue.length === 0) {
return message.channel.send('📭 Kolejka jest pusta.');
}
const list = state.queue
.map((s, i) => `${i + 1}. **${s.title}** [${s.duration}]`)
.join('\n');
await message.channel.send(`📜 **Kolejka:**\n${list}`);
}
async function cmdStop(message) {
const state = getState(message.guildId);
state.queue = [];
state.player?.stop();
state.playing = false;
await message.channel.send('⏹️ Zatrzymano i wyczyszczono kolejkę.');
}
async function cmdLeave(message) {
const state = getState(message.guildId);
state.queue = [];
state.player?.stop();
state.playing = false;
state.connection?.destroy();
state.connection = null;
state.player = null;
await message.channel.send('👋 Rozłączono.');
}
// ─── Playback ─────────────────────────────────────────────────────────────────
function playNext(guildId) {
const state = getState(guildId);
if (state.queue.length === 0) {
state.playing = false;
state.textChannel?.send('📭 Kolejka zakończona.');
return;
}
const song = state.queue.shift();
state.playing = true;
state.textChannel?.send(`▶️ Odtwarzam: **${song.title}** [${song.duration}]`);
try {
const resource = buildAudioResource(song.url);
state.player.play(resource);
} catch (err) {
console.error('[playNext error]', err);
state.textChannel?.send(`⚠️ Nie można odtworzyć: ${err.message}`);
state.playing = false;
playNext(guildId);
}
}
// ─── Audio resource with caching ─────────────────────────────────────────────
function buildAudioResource(url) {
const cachePath = getAudioCachePath(url);
// Audio cache HIT — instant play from disk
if (fs.existsSync(cachePath)) {
console.log(`[Cache] HIT: ${path.basename(cachePath)}`);
return createAudioResource(fs.createReadStream(cachePath), {
inputType: StreamType.OggOpus,
});
}
// Audio cache MISS — stream + save to disk simultaneously
console.log(`[Cache] MISS: downloading ${url}`);
const ytdlp = spawn('yt-dlp', [
'-f', 'bestaudio',
'--no-playlist',
'-o', '-',
'--', url,
], { stdio: ['ignore', 'pipe', 'pipe'] });
ytdlp.stderr.on('data', (d) => console.error('[yt-dlp]', d.toString().trim()));
const ffmpeg = spawn('ffmpeg', [
'-hide_banner', '-loglevel', 'error',
'-i', 'pipe:0',
'-af', `volume=${VOLUME}`,
'-c:a', 'libopus',
'-b:a', '128k',
'-f', 'opus',
'pipe:1',
], { stdio: ['pipe', 'pipe', 'pipe'] });
ffmpeg.stderr.on('data', (d) => console.error('[ffmpeg]', d.toString().trim()));
ytdlp.stdout.pipe(ffmpeg.stdin);
ytdlp.on('exit', (code) => {
if (code !== 0 && code !== null) {
console.error(`[yt-dlp] exited with code ${code}`);
ffmpeg.stdin.destroy();
}
});
// Tee: Discord stream AND cache file at the same time
const tee = new PassThrough();
const cacheStream = fs.createWriteStream(cachePath);
ffmpeg.stdout.pipe(tee);
tee.pipe(cacheStream);
cacheStream.on('finish', () => console.log(`[Cache] Saved: ${path.basename(cachePath)}`));
cacheStream.on('error', (err) => {
console.error('[Cache] Write error:', err.message);
if (fs.existsSync(cachePath)) fs.unlinkSync(cachePath);
});
const resource = createAudioResource(tee, {
inputType: StreamType.OggOpus,
});
resource.playStream.on('close', () => {
if (!ytdlp.killed) ytdlp.kill();
if (!ffmpeg.killed) ffmpeg.kill();
});
return resource;
}
// ─── yt-dlp metadata fetch ────────────────────────────────────────────────────
function resolveSong(query) {
const isUrl = query.startsWith('http://') || query.startsWith('https://');
const ytQuery = isUrl ? query : `ytsearch1:${query}`;
return new Promise((resolve, reject) => {
const proc = spawn('yt-dlp', [
'--no-playlist',
'--print', '%(title)s|||%(duration_string)s|||%(webpage_url)s',
'--', ytQuery,
]);
let stdout = '';
let stderr = '';
proc.stdout.on('data', (d) => stdout += d.toString());
proc.stderr.on('data', (d) => stderr += d.toString());
proc.on('close', (code) => {
const line = stdout.split('\n').find(l => l.includes('|||')) || '';
const parts = line.split('|||');
if (parts.length >= 3) {
resolve({
title: parts[0].trim(),
duration: parts[1].trim(),
url: parts[2].trim(),
});
} else if (code !== 0) {
reject(new Error(stderr.trim() || `yt-dlp exited with code ${code}`));
} else {
resolve({ title: query, duration: '?:??', url: query });
}
});
});
}
// ─── Start ────────────────────────────────────────────────────────────────────
client.login(TOKEN);