Files
discord-bot/index.js
T
2026-08-18 21:04:49 +02:00

557 lines
19 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.
require('dotenv').config();
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;
if (!TOKEN) {
console.error('❌ Missing DISCORD_TOKEN environment variable.');
process.exit(1);
}
const PREFIX = '!';
const VOLUME = 0.3; // 0.0 - 1.0
// Cookies: set to '' to disable, or e.g. 'firefox', 'firefox:default', etc.
const YTDL_COOKIES_FROM_BROWSER = 'firefox'; // <-- add/change this line only
// ─── Cache setup ─────────────────────────────────────────────────────────────
const CACHE_DIR = path.join(__dirname, 'cache');
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR);
const MIN_VALID_CACHE_BYTES = 4096;
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 {}
}
}
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() {
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`);
}
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 ──────────────────────────────────────────────────────
const activeDownloads = new Map();
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 ytdlpArgs = [
'-f', 'ba/b', // <-- changed from 'bestaudio'
'--no-playlist',
];
if (YTDL_COOKIES_FROM_BROWSER) {
ytdlpArgs.push('--cookies-from-browser', YTDL_COOKIES_FROM_BROWSER);
}
ytdlpArgs.push(
'-o', '-',
'--',
this.url
);
const ytdlp = spawn('yt-dlp', ytdlpArgs, { 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.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));
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;
}
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, () => {});
for (const sub of this.subscribers) {
if (!sub.destroyed) sub.destroy(err);
}
this.subscribers.clear();
activeDownloads.delete(this.url);
}
subscribe() {
const sub = new PassThrough();
this.subscribers.add(sub);
const cleanup = () => this.subscribers.delete(sub);
sub.on('close', cleanup);
sub.on('end', cleanup);
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();
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);
}
}
}
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}** [${song.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);
}
}
function buildAudioResource(url) {
const cachePath = getAudioCachePath(url);
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 {}
}
const job = getOrStartDownload(url);
const stream = job.subscribe();
return createAudioResource(stream, {
inputType: StreamType.OggOpus,
});
}
// ─── yt-dlp metadata fetch ────────────────────────────────────────────────────
const pendingMetadata = new Map();
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 ytdlpArgs = [
'--no-playlist',
];
if (YTDL_COOKIES_FROM_BROWSER) {
ytdlpArgs.push('--cookies-from-browser', YTDL_COOKIES_FROM_BROWSER);
}
ytdlpArgs.push(
'--print', '%(title)s|||%(duration_string)s|||%(webpage_url)s',
'--', ytQuery
);
const promise = new Promise((resolve, reject) => {
const proc = spawn('yt-dlp', ytdlpArgs);
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);