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

617 lines
24 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);
// A real encoded opus file is never this small. Anything under this size is
// treated as a corrupt/truncated download rather than a usable cache entry —
// used both when finalizing a fresh download and when reading an existing
// cache file back off disk.
const MIN_VALID_CACHE_BYTES = 4096;
// Startup cleanup pass:
// - any leftover .tmp files are from downloads that never finished (e.g.
// the process was killed mid-write) — never valid, always safe to remove.
// - any .opus file smaller than MIN_VALID_CACHE_BYTES is a corrupt/truncated
// cache entry that could have been left behind by an older version of
// this bot (or a crash right at the rename boundary) — remove it so it
// gets cleanly redownloaded next time it's requested instead of being
// served as broken audio.
for (const f of fs.readdirSync(CACHE_DIR)) {
const full = path.join(CACHE_DIR, f);
if (f.endsWith('.tmp')) {
try { fs.unlinkSync(full); } catch {}
} else if (f.endsWith('.opus')) {
try {
if (fs.statSync(full).size < MIN_VALID_CACHE_BYTES) {
fs.unlinkSync(full);
console.log(`[Cache] Removed corrupt cache file on startup: ${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() {
// Write via tmp+rename (same pattern as the audio cache) so a process
// kill mid-write can never leave metadata.json half-written/corrupt
// JSON that fails to parse on next boot.
const tmp = META_FILE + '.tmp';
try {
fs.writeFileSync(tmp, JSON.stringify(metaCache, null, 2));
fs.renameSync(tmp, META_FILE);
} catch (err) {
console.error('[Meta] Failed to save metadata cache:', err.message);
}
}
function getAudioCachePath(url) {
const hash = crypto.createHash('md5').update(url).digest('hex');
return path.join(CACHE_DIR, `${hash}.opus`);
}
// A cache file only counts as usable if it exists AND is large enough to be
// real audio. Anything smaller gets deleted on sight so a corrupt entry is
// never served twice.
function isCacheFileValid(cachePath) {
try {
return fs.statSync(cachePath).size >= MIN_VALID_CACHE_BYTES;
} catch {
return false;
}
}
function evictIfInvalid(cachePath) {
if (fs.existsSync(cachePath) && !isCacheFileValid(cachePath)) {
console.warn(`[Cache] Discarding corrupt/empty cache file: ${path.basename(cachePath)}`);
try { fs.unlinkSync(cachePath); } catch {}
}
}
// ─── 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
// AND large enough to be real audio (write to .tmp, size-check, then
// atomic rename on success).
//
// IMPORTANT — multi-guild fan-out:
// The shared ffmpeg output is forwarded to the cache file and to every
// subscribed guild MANUALLY (not via stream.pipe()), and a subscriber's
// write() return value is ignored. This is deliberate: with .pipe(), Node
// ties the production rate of the single shared stream to whichever
// destination is slowest to drain. One guild with a stalled voice
// connection (or a paused player) would backpressure the shared download
// and stall the cache write *and every other guild listening to the same
// song*. Writing manually means a stuck subscriber can only ever hurt
// itself. Subscribers also swallow their own 'error'/'close' quietly —
// previously a subscriber being destroyed (e.g. on skip) while still
// attached to the shared stream could throw an unhandled 'error', which
// crashes the entire Node process and kills playback in every guild, not
// just the one that skipped.
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.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()));
// Child-process pipe streams can each emit their own 'error'
// (e.g. EPIPE when one side of the pipe dies before the other).
// Without listeners here that's an unhandled exception that
// crashes the whole bot — this is the fix for downloads breaking
// whenever multiple guilds/voice channels were involved.
ytdlp.stdout.on('error', () => {});
ffmpeg.stdin.on('error', () => {});
ffmpeg.stdout.on('error', () => {});
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;
cacheStream.on('error', (err) => this._fail(err));
// Manual fan-out — see class-level comment for why this replaces
// ffmpeg.stdout.pipe(...) to the cache file and every subscriber.
ffmpeg.stdout.on('data', (chunk) => {
if (this.failed) return;
if (!cacheStream.destroyed) cacheStream.write(chunk);
for (const sub of this.subscribers) {
if (!sub.destroyed) sub.write(chunk);
}
});
ffmpeg.stdout.on('end', () => {
if (this.failed) return;
if (!cacheStream.destroyed) cacheStream.end();
for (const sub of this.subscribers) {
if (!sub.destroyed) sub.end();
}
});
ffmpeg.on('close', (code) => {
if (code !== 0 && code !== null) {
this._fail(new Error(`ffmpeg exited with code ${code}`));
}
});
cacheStream.on('finish', () => {
if (this.failed) return;
this._finalizeCache();
});
}
_finalizeCache() {
fs.stat(this.tmpPath, (err, stats) => {
activeDownloads.delete(this.url);
if (err) {
console.error('[Cache] Stat failed:', err.message);
fs.unlink(this.tmpPath, () => {});
return;
}
// Guard against corrupt/empty cache files — e.g. yt-dlp or
// ffmpeg exited "cleanly" but produced little/no real audio.
// Never let a file this small become a permanent cache entry.
if (stats.size < MIN_VALID_CACHE_BYTES) {
console.error(`[Cache] Discarding suspiciously small file (${stats.size}B): ${this.url}`);
fs.unlink(this.tmpPath, () => {});
return;
}
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)}`);
}
});
});
}
_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) {
if (!sub.destroyed) sub.destroy(err);
}
this.subscribers.clear();
activeDownloads.delete(this.url);
}
// Each consumer (a guild's playback) gets its own independent tap on
// the shared download. Destroying this tap (e.g. because that guild
// skipped/stopped/left) must ONLY remove that one listener — it must
// never affect the shared child processes, the cache write, or any
// other guild's tap.
subscribe() {
const sub = new PassThrough();
this.subscribers.add(sub);
const cleanup = () => this.subscribers.delete(sub);
sub.on('close', cleanup);
sub.on('end', cleanup);
// Swallow subscriber-side errors so a torn-down voice stream can
// never surface as an unhandled 'error' and crash the process.
sub.on('error', 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 === 'fplay') {
const query = args.join(' ');
if (!query) return message.channel.send('❌ Podaj link lub frazę do wyszukania!');
await cmdPlay(message, query, { force: true });
} 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' +
'`!fplay <url/szukaj>` – wymuś świeże pobranie (ignoruje cache)\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, { force = false } = {}) {
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();
// Force play skips the metadata-cache shortcut entirely: always do a
// fresh yt-dlp lookup, and below, always wipe any existing audio cache
// for the resolved URL so playback can't come from a stale/corrupt file.
if (!force && metaCache[cacheKey]) {
const song = metaCache[cacheKey];
evictIfInvalid(getAudioCachePath(song.url));
const audioCached = isCacheFileValid(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(force ? `🔁 Wymuszam ponowne pobranie: **${query}**…` : `🔍 Szukam: **${query}**…`);
const song = await resolveSong(query);
metaCache[cacheKey] = song;
metaCache[song.url.toLowerCase()] = song;
saveMetaCache();
console.log(`[Meta] Cached: "${query}" → ${song.url}`);
if (force) {
const cachePath = getAudioCachePath(song.url);
if (fs.existsSync(cachePath)) {
try {
fs.unlinkSync(cachePath);
console.log(`[Cache] Force-cleared: ${path.basename(cachePath)}`);
} catch (err) {
console.error('[Cache] Force-clear failed:', err.message);
}
}
// If a download for this URL happens to already be in flight
// (e.g. another guild requested it a moment ago), that download is
// already fresh, not served from cache — buildAudioResource will
// simply join it like any other cache miss, so nothing more to do.
}
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, but only if the file is actually
// large enough to be real audio. Because writes are atomic (tmp file +
// size-check + rename), a file should only ever exist here if it's
// complete and valid — this check is a last-line-of-defense safety net
// in case an older/corrupt file is still sitting on disk.
if (fs.existsSync(cachePath)) {
if (isCacheFileValid(cachePath)) {
console.log(`[Cache] HIT: ${path.basename(cachePath)}`);
return createAudioResource(fs.createReadStream(cachePath), {
inputType: StreamType.OggOpus,
});
}
console.warn(`[Cache] Corrupt/empty cache file, discarding and redownloading: ${path.basename(cachePath)}`);
try { fs.unlinkSync(cachePath); } catch {}
}
// 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);