Files
discord-bot/index.js
T
2026-08-08 16:44:05 +00:00

473 lines
17 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 ──────────────────────────────────────────────────────────────────
// SECURITY: never hardcode a bot token. This file previously had one baked in
// as a fallback — that token is now exposed and should be rotated in the
// Discord Developer Portal ASAP. Only read from the environment.
const TOKEN = process.env.DISCORD_TOKEN;
if (!TOKEN) {
console.error('❌ Missing DISCORD_TOKEN environment variable.');
process.exit(1);
}
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);
// On startup, remove any leftover .tmp files from downloads that never
// finished (e.g. the process was killed mid-write). These are never valid
// cache entries, so there's no reason to keep them around.
for (const f of fs.readdirSync(CACHE_DIR)) {
if (f.endsWith('.tmp')) {
try { fs.unlinkSync(path.join(CACHE_DIR, f)); } catch {}
}
}
// Metadata cache: maps query/url → { title, duration, url }
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);
}
// ─── Download manager ──────────────────────────────────────────────────────
// Downloads are tracked globally (not per-guild), keyed by URL. This means:
// - If guild A and guild B both request the same song at the same time,
// only ONE yt-dlp/ffmpeg pair is spawned; both playbacks tap into it.
// - If a listener disconnects (skip/leave/stop), the download itself is
// NOT killed — it keeps writing to disk and completes normally, so the
// cache is populated correctly for next time instead of ending up with
// a truncated file.
// - The cache file only ever appears at its final path once fully written
// (write to .tmp, then atomic rename on success).
const activeDownloads = new Map(); // url -> DownloadJob
class DownloadJob {
constructor(url) {
this.url = url;
this.cachePath = getAudioCachePath(url);
this.tmpPath = this.cachePath + '.tmp';
this.subscribers = new Set();
this.source = new PassThrough(); // fan-out: ffmpeg output -> all subscribers + cache file
this.failed = false;
this._start();
}
_start() {
const ytdlp = spawn('yt-dlp', [
'-f', 'bestaudio',
'--no-playlist',
'-o', '-',
'--', this.url,
], { stdio: ['ignore', 'pipe', 'pipe'] });
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'] });
this.ytdlp = ytdlp;
this.ffmpeg = ffmpeg;
ytdlp.stderr.on('data', (d) => console.error('[yt-dlp]', d.toString().trim()));
ffmpeg.stderr.on('data', (d) => console.error('[ffmpeg]', d.toString().trim()));
ytdlp.stdout.pipe(ffmpeg.stdin);
ytdlp.on('error', (err) => this._fail(err));
ffmpeg.on('error', (err) => this._fail(err));
ytdlp.on('exit', (code) => {
if (code !== 0 && code !== null) {
console.error(`[yt-dlp] exited with code ${code}`);
try { ffmpeg.stdin.destroy(); } catch {}
}
});
const cacheStream = fs.createWriteStream(this.tmpPath);
this.cacheStream = cacheStream;
ffmpeg.stdout.pipe(this.source);
this.source.pipe(cacheStream);
ffmpeg.on('close', (code) => {
if (code !== 0 && code !== null) {
this._fail(new Error(`ffmpeg exited with code ${code}`));
}
});
cacheStream.on('error', (err) => this._fail(err));
cacheStream.on('finish', () => {
if (this.failed) return; // already handled via _fail cleanup
fs.rename(this.tmpPath, this.cachePath, (err) => {
if (err) {
console.error('[Cache] Rename failed:', err.message);
} else {
console.log(`[Cache] Saved: ${path.basename(this.cachePath)}`);
}
activeDownloads.delete(this.url);
});
});
}
_fail(err) {
if (this.failed) return;
this.failed = true;
console.error(`[Download] Failed for ${this.url}:`, err.message);
try { this.ytdlp.kill(); } catch {}
try { this.ffmpeg.kill(); } catch {}
try { this.cacheStream.destroy(); } catch {}
fs.unlink(this.tmpPath, () => {}); // never leave a partial file behind
for (const sub of this.subscribers) sub.destroy(err);
activeDownloads.delete(this.url);
}
// Each consumer (a guild's playback) gets its own tap on the shared
// source. Closing this tap (e.g. because the guild skipped the track)
// only removes that listener — it does not affect the download itself.
subscribe() {
const sub = new PassThrough();
this.subscribers.add(sub);
this.source.pipe(sub);
const cleanup = () => this.subscribers.delete(sub);
sub.on('close', cleanup);
sub.on('end', cleanup);
return sub;
}
}
function getOrStartDownload(url) {
let job = activeDownloads.get(url);
if (!job) {
console.log(`[Download] Starting: ${url}`);
job = new DownloadJob(url);
activeDownloads.set(url, job);
} else {
console.log(`[Download] Joining in-progress download: ${url}`);
}
return job;
}
// ─── 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;
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.');
}
}
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);
});
}
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;
}
await message.channel.send(`🔍 Szukam: **${query}**…`);
const song = await resolveSong(query);
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);
// Cache HIT — instant play from disk. Because writes are atomic
// (tmp file + rename), a file only exists here if it's complete.
if (fs.existsSync(cachePath)) {
console.log(`[Cache] HIT: ${path.basename(cachePath)}`);
return createAudioResource(fs.createReadStream(cachePath), {
inputType: StreamType.OggOpus,
});
}
// Cache MISS — join (or start) a shared download for this URL.
// Skipping/stopping playback later will NOT interrupt this download;
// it keeps running and completes the cache file in the background.
const job = getOrStartDownload(url);
const stream = job.subscribe();
return createAudioResource(stream, {
inputType: StreamType.OggOpus,
});
}
// ─── yt-dlp metadata fetch ────────────────────────────────────────────────────
// Dedupe concurrent identical metadata lookups too (e.g. two people paste
// the same fresh link in the same second) so we don't spawn yt-dlp twice.
const pendingMetadata = new Map(); // cacheKey/query -> Promise
function resolveSong(query) {
if (pendingMetadata.has(query)) {
return pendingMetadata.get(query);
}
const isUrl = query.startsWith('http://') || query.startsWith('https://');
const ytQuery = isUrl ? query : `ytsearch1:${query}`;
const promise = 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 });
}
});
}).finally(() => {
pendingMetadata.delete(query);
});
pendingMetadata.set(query, promise);
return promise;
}
// ─── Start ────────────────────────────────────────────────────────────────────
client.login(TOKEN);