diff --git a/bot.js b/bot.js new file mode 100644 index 0000000..f120dac --- /dev/null +++ b/bot.js @@ -0,0 +1,652 @@ +import 'dotenv/config'; +import dns from 'node:dns'; +import dgram from 'node:dgram'; +// IPv6 sortant cassé sur ce serveur. Force IPv4 partout : +// - DNS lookup order +// - dgram sockets (Discord voice UDP) — patch createSocket pour forcer udp4 +dns.setDefaultResultOrder('ipv4first'); +const _origCreateSocket = dgram.createSocket; +dgram.createSocket = function(opts, cb) { + if (typeof opts === 'string') { + if (opts === 'udp6') opts = 'udp4'; + } else if (opts && typeof opts === 'object') { + if (opts.type === 'udp6') opts = { ...opts, type: 'udp4' }; + } + return _origCreateSocket.call(this, opts, cb); +}; +import { + Client, GatewayIntentBits, Events, REST, Routes, + SlashCommandBuilder, EmbedBuilder, MessageFlags, ActivityType, +} from 'discord.js'; +import { + joinVoiceChannel, createAudioPlayer, createAudioResource, + AudioPlayerStatus, VoiceConnectionStatus, NoSubscriberBehavior, + StreamType, entersState, getVoiceConnection, +} from '@discordjs/voice'; +import { spawn } from 'node:child_process'; + +const TOKEN = process.env.DISCORD_TOKEN; +if (!TOKEN) { console.error('DISCORD_TOKEN manquant dans .env'); process.exit(1); } + +const ALONE_TIMEOUT_MS = 180_000; +const YTDLP = process.env.YTDLP_BIN || '/home/yass/.local/bin/yt-dlp'; + +const C = { + PRIMARY: 0x5865F2, SUCCESS: 0x57F287, ERROR: 0xED4245, + WARNING: 0xFEE75C, MUSIC: 0x9B59B6, INFO: 0x00B0F4, +}; + +const log = (lvl, ...m) => + console.log(`[${new Date().toISOString().replace('T',' ').slice(0,19)}] ${lvl.padEnd(8)} musicbot:`, ...m); +const info = (...m) => log('INFO', ...m); +const warn = (...m) => log('WARNING', ...m); +const err = (...m) => log('ERROR', ...m); + +const fmt = (sec) => { + if (!sec) return 'Live'; + sec = Math.floor(sec); + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = sec % 60; + return h ? `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}` + : `${m}:${String(s).padStart(2,'0')}`; +}; + +const Loop = { OFF: 0, ONE: 1, QUEUE: 2 }; + +class Player { + constructor(guildId) { + this.guildId = guildId; + this.queue = []; + this.current = null; + this.connection = null; + this.audioPlayer = null; + this.volume = 0.5; + this.loop = Loop.OFF; + this.textChannel = null; + this.aloneSince = null; + this.skipFlag = false; + this.currentResource = null; + this.currentProc = null; + } + reset() { + this.queue = []; + this.current = null; + this.loop = Loop.OFF; + this.skipFlag = false; + this.aloneSince = null; + this._killProc(); + } + _killProc() { + if (this.currentProc) { + try { this.currentProc.kill('SIGKILL'); } catch {} + this.currentProc = null; + } + } +} + +const players = new Map(); +const getPlayer = (gid) => { + let p = players.get(gid); + if (!p) { p = new Player(gid); players.set(gid, p); } + return p; +}; + +const trim = (s, n = 128) => { + s = String(s ?? ''); + return s.length > n ? s.slice(0, n - 1) + '…' : s; +}; + +function setIdlePresence() { + client.user?.setPresence({ + activities: [{ name: '/play', type: ActivityType.Listening }], + status: 'online', + }); +} + +function setPlayingPresence(track) { + if (!track) return setIdlePresence(); + const now = Date.now(); + const activity = { + name: trim(track.title || 'Musique'), + type: ActivityType.Listening, + details: trim(track.title || 'Musique'), + state: trim(`par ${track.uploader || 'Inconnu'}`), + }; + // Timeline live façon Spotify (uniquement si on connaît la durée) + if (track.duration && track.duration > 0) { + activity.timestamps = { start: now, end: now + Math.floor(track.duration * 1000) }; + } else { + // Live → compteur ascendant + activity.timestamps = { start: now }; + } + if (track.webpage_url) activity.url = track.webpage_url; + if (track.thumbnail) { + activity.assets = { + large_image: track.thumbnail, + large_text: trim(track.title || 'Musique'), + small_text: 'Music Bot', + }; + } + client.user?.setPresence({ activities: [activity], status: 'online' }); +} + +// ── yt-dlp helpers ─────────────────────────────────────────────────────────── +function ytSearchInfo(query) { + return new Promise((resolve, reject) => { + const isUrl = /^https?:\/\//i.test(query); + const target = isUrl ? query : `ytsearch1:${query}`; + const args = [ + '-J', '--no-playlist', '--no-warnings', '--quiet', + '--default-search', 'ytsearch', + '-f', 'bestaudio/best', + target, + ]; + const p = spawn(YTDLP, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let out = '', errBuf = ''; + p.stdout.on('data', (d) => out += d); + p.stderr.on('data', (d) => errBuf += d); + p.on('error', reject); + p.on('close', (code) => { + if (code !== 0) return reject(new Error(errBuf.trim() || `yt-dlp exit ${code}`)); + try { + const j = JSON.parse(out); + const e = (j.entries && j.entries[0]) ? j.entries[0] : j; + resolve({ + title: e.title ?? 'Titre inconnu', + url: e.url, + webpage_url: e.webpage_url ?? e.original_url ?? '', + duration: e.duration ?? null, + thumbnail: e.thumbnail ?? null, + uploader: e.uploader ?? e.channel ?? 'Inconnu', + }); + } catch (e) { reject(e); } + }); + }); +} + +function spawnAudioStream(track) { + const args = [ + '-o', '-', + '-f', 'bestaudio/best', + '--no-playlist', '--no-warnings', '--quiet', + '--no-part', + track.webpage_url || track.url, + ]; + const p = spawn(YTDLP, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + p.stderr.on('data', (d) => warn('yt-dlp:', d.toString().trim())); + p.on('error', (e) => err('yt-dlp spawn:', e.message)); + return p; +} + +// ── Embeds ─────────────────────────────────────────────────────────────────── +const footer = (e) => e.setFooter({ text: 'Music Bot' }).setTimestamp(); +const mk = (title, desc, color = C.PRIMARY) => { + const e = new EmbedBuilder().setTitle(title).setColor(color); + if (desc) e.setDescription(desc); + return footer(e); +}; +const nowPlayingEmbed = (t, queueLen, loop, vol) => { + const loopIcon = { [Loop.OFF]: '', [Loop.ONE]: ' • Loop one', [Loop.QUEUE]: ' • Loop queue' }[loop]; + const e = new EmbedBuilder() + .setTitle(`Lecture en cours${loopIcon}`) + .setDescription(`**[${t.title}](${t.webpage_url || ''})**`) + .setColor(C.MUSIC) + .addFields( + { name: 'Durée', value: fmt(t.duration), inline: true }, + { name: 'Artiste', value: String(t.uploader), inline: true }, + { name: 'Volume', value: `${Math.round(vol * 100)}%`, inline: true }, + { name: 'File', value: `${queueLen} titre(s)`, inline: true }, + ); + if (t.thumbnail) e.setImage(t.thumbnail); + return footer(e); +}; + +// ── Voice / playback ───────────────────────────────────────────────────────── +function sendVoiceStateLeave(guild) { + try { + guild.shard.send({ + op: 4, + d: { guild_id: guild.id, channel_id: null, self_mute: false, self_deaf: false }, + }); + info(`VOICE_STATE_UPDATE leave envoyé → ${guild.name}`); + } catch (e) { warn('clear voice state:', e.message); } +} + +async function ensureConnection(interaction) { + const member = interaction.member; + const ch = member?.voice?.channel; + if (!ch) throw new Error('Tu dois être dans un salon vocal.'); + + const p = getPlayer(interaction.guildId); + let conn = getVoiceConnection(interaction.guildId); + + if (conn && conn.joinConfig.channelId !== ch.id) { + conn.destroy(); + conn = null; + p.connection = null; + } + + // Si Discord pense que le bot est déjà dans un salon vocal mais on n'a pas + // de connection active, c'est une session zombie — on envoie un leave et on attend. + const me = interaction.guild.members.me; + if (!conn && me?.voice?.channelId) { + info(`État vocal zombie détecté dans ${interaction.guild.name}, nettoyage…`); + sendVoiceStateLeave(interaction.guild); + await new Promise(r => setTimeout(r, 1000)); + } + + if (!conn) { + conn = joinVoiceChannel({ + channelId: ch.id, + guildId: interaction.guildId, + adapterCreator: interaction.guild.voiceAdapterCreator, + selfDeaf: true, + selfMute: false, + }); + + conn.on('debug', (msg) => info('voice debug:', msg)); + conn.on('stateChange', (oldS, newS) => { + const extra = newS.reason ? ` reason=${newS.reason}` : ''; + const closeCode = newS.closeCode ?? oldS.closeCode; + const cc = closeCode ? ` close=${closeCode}` : ''; + info(`voice state: ${oldS.status} → ${newS.status}${extra}${cc}`); + if (newS.networking) { + newS.networking.on('debug', (m) => info('net debug:', m)); + newS.networking.on('error', (e) => err('net error:', e?.message || e)); + newS.networking.on('close', (c) => err(`voice WS closed code=${c}`)); + newS.networking.on('stateChange', (a, b) => info(`net state: ${a.code} → ${b.code}`)); + } + if (newS.status === VoiceConnectionStatus.Disconnected) { + Promise.race([ + entersState(conn, VoiceConnectionStatus.Signalling, 5_000), + entersState(conn, VoiceConnectionStatus.Connecting, 5_000), + ]).catch(() => { + info(`Connexion vocale perdue (guild ${interaction.guildId})`); + try { conn.destroy(); } catch {} + const pp = getPlayer(interaction.guildId); + pp.connection = null; + pp._killProc(); + }); + } + }); + conn.on('error', (e) => err('voice connection error:', e?.message || e)); + + try { + await entersState(conn, VoiceConnectionStatus.Ready, 30_000); + } catch (e) { + const finalState = conn.state.status; + try { conn.destroy(); } catch {} + // Cleanup zombie session côté Discord pour la prochaine tentative + sendVoiceStateLeave(interaction.guild); + throw new Error(`Connexion vocale échouée (état final: ${finalState}): ${e.message}`); + } + + p.connection = conn; + } + + if (!p.audioPlayer) { + p.audioPlayer = createAudioPlayer({ + behaviors: { noSubscriber: NoSubscriberBehavior.Pause }, + }); + p.audioPlayer.on('error', (e) => err('audio player error:', e.message)); + p.audioPlayer.on(AudioPlayerStatus.Idle, () => playNext(interaction.guildId)); + conn.subscribe(p.audioPlayer); + } else { + conn.subscribe(p.audioPlayer); + } + + return p; +} + +function startTrack(p, track) { + p._killProc(); + const proc = spawnAudioStream(track); + p.currentProc = proc; + const resource = createAudioResource(proc.stdout, { + inputType: StreamType.Arbitrary, + inlineVolume: true, + }); + resource.volume?.setVolume(p.volume); + p.currentResource = resource; + p.audioPlayer.play(resource); + setPlayingPresence(track); +} + +async function playNext(guildId) { + const p = getPlayer(guildId); + if (!p.connection || !p.audioPlayer) { p.current = null; return; } + + const skip = p.skipFlag; + p.skipFlag = false; + + if (!skip && p.loop === Loop.ONE && p.current) { + try { startTrack(p, p.current); return; } + catch (e) { err('Loop ONE:', e.message); } + } + if (!skip && p.loop === Loop.QUEUE && p.current) { + p.queue.push(p.current); + } + + if (p.queue.length === 0) { + p.current = null; + setIdlePresence(); + if (p.textChannel) { + try { await p.textChannel.send({ embeds: [mk('File terminée', 'Utilisez `/play` pour ajouter des musiques.', C.INFO)] }); } catch {} + } + return; + } + + p.current = p.queue.shift(); + try { + startTrack(p, p.current); + if (p.textChannel) { + await p.textChannel.send({ embeds: [nowPlayingEmbed(p.current, p.queue.length, p.loop, p.volume)] }); + } + } catch (e) { + err('startTrack:', e.message); + setTimeout(() => playNext(guildId), 500); + } +} + +// ── Bot ────────────────────────────────────────────────────────────────────── +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.GuildMessages, + ], +}); + +const commands = [ + new SlashCommandBuilder().setName('join').setDescription('Rejoint votre salon vocal'), + new SlashCommandBuilder().setName('play').setDescription('Joue une musique (nom ou URL YouTube)') + .addStringOption(o => o.setName('query').setDescription('Nom ou URL').setRequired(true)), + new SlashCommandBuilder().setName('playtop').setDescription('Ajoute une musique en tête de file') + .addStringOption(o => o.setName('query').setDescription('Nom ou URL').setRequired(true)), + new SlashCommandBuilder().setName('skip').setDescription('Passe à la musique suivante'), + new SlashCommandBuilder().setName('pause').setDescription('Met la lecture en pause'), + new SlashCommandBuilder().setName('resume').setDescription('Reprend la lecture'), + new SlashCommandBuilder().setName('stop').setDescription('Arrête la musique et vide la file'), + new SlashCommandBuilder().setName('leave').setDescription('Déconnecte le bot du salon vocal'), + new SlashCommandBuilder().setName('queue').setDescription('Affiche la file d\'attente'), + new SlashCommandBuilder().setName('nowplaying').setDescription('Affiche la musique en cours'), + new SlashCommandBuilder().setName('volume').setDescription('Règle le volume (0–100)') + .addIntegerOption(o => o.setName('niveau').setDescription('0 à 100').setRequired(true).setMinValue(0).setMaxValue(100)), + new SlashCommandBuilder().setName('clear').setDescription('Vide la file d\'attente'), + new SlashCommandBuilder().setName('remove').setDescription('Supprime un titre de la file par sa position') + .addIntegerOption(o => o.setName('position').setDescription('1 = premier').setRequired(true).setMinValue(1)), + new SlashCommandBuilder().setName('loop').setDescription('Gère la répétition') + .addStringOption(o => o.setName('mode').setDescription('off, one ou queue').setRequired(true) + .addChoices( + { name: 'off — Pas de répétition', value: 'off' }, + { name: 'one — Répète le titre', value: 'one' }, + { name: 'queue — Répète la file', value: 'queue' }, + )), + new SlashCommandBuilder().setName('shuffle').setDescription('Mélange la file d\'attente'), +].map(c => c.toJSON()); + +client.once(Events.ClientReady, async (c) => { + info(`Connecté: ${c.user.tag} | ${c.guilds.cache.size} serveur(s)`); + setIdlePresence(); + + // Nettoyage des sessions vocales zombie après redémarrage + for (const [, guild] of c.guilds.cache) { + const me = guild.members.me; + if (me?.voice?.channelId) { + info(`Nettoyage état vocal zombie → ${guild.name}`); + sendVoiceStateLeave(guild); + } + } + + try { + const rest = new REST({ version: '10' }).setToken(TOKEN); + const data = await rest.put(Routes.applicationCommands(c.user.id), { body: commands }); + info(`${data.length} commande(s) synchronisée(s)`); + } catch (e) { err('Sync slash:', e.message); } +}); + +client.on(Events.VoiceStateUpdate, (oldS, newS) => { + const guild = newS.guild ?? oldS.guild; + const conn = getVoiceConnection(guild.id); + if (!conn) return; + const p = getPlayer(guild.id); + + // Bot kick/leave + if (newS.id === client.user.id && oldS.channelId && !newS.channelId) { + info(`Bot déconnecté vocal → ${guild.name}`); + p.reset(); + p.connection = null; + p.audioPlayer?.stop(true); + p.audioPlayer = null; + setIdlePresence(); + return; + } + + const channelId = conn.joinConfig.channelId; + const channel = guild.channels.cache.get(channelId); + if (!channel) return; + const humans = channel.members.filter(m => !m.user.bot).size; + if (humans === 0) { + if (!p.aloneSince) { p.aloneSince = Date.now(); info(`Seul dans ${channel.name} (${guild.name}), timer démarré`); } + } else if (p.aloneSince) { + p.aloneSince = null; + } +}); + +setInterval(() => { + for (const [gid, p] of players) { + if (!p.aloneSince) continue; + const conn = getVoiceConnection(gid); + if (!conn) { p.aloneSince = null; continue; } + if (Date.now() - p.aloneSince >= ALONE_TIMEOUT_MS) { + const guild = client.guilds.cache.get(gid); + info(`Auto-disconnect ${guild?.name ?? gid} (seul depuis ${Math.round((Date.now()-p.aloneSince)/1000)}s)`); + const ch = p.textChannel; + p.reset(); + try { conn.destroy(); } catch {} + p.connection = null; + p.audioPlayer?.stop(true); + p.audioPlayer = null; + setIdlePresence(); + if (ch) ch.send({ embeds: [mk('Déconnexion automatique', + `Seul dans le salon depuis **${Math.round(ALONE_TIMEOUT_MS/60000)} min**, je me déconnecte.`, C.INFO)] }).catch(() => {}); + } + } +}, 30_000); + +// ── Slash handlers ─────────────────────────────────────────────────────────── +async function withVoice(interaction, fn) { + if (!interaction.member?.voice?.channel) { + return interaction.reply({ embeds: [mk('Erreur', 'Rejoignez un salon vocal d\'abord !', C.ERROR)], flags: MessageFlags.Ephemeral }); + } + await interaction.deferReply(); + try { + const p = await ensureConnection(interaction); + p.textChannel = interaction.channel; + await fn(interaction, p); + } catch (e) { + err('cmd error:', e.message); + const payload = { embeds: [mk('Erreur', `\`\`\`${e.message}\`\`\``, C.ERROR)] }; + if (interaction.deferred) await interaction.editReply(payload).catch(() => {}); + else await interaction.reply({ ...payload, flags: MessageFlags.Ephemeral }).catch(() => {}); + } +} + +client.on(Events.InteractionCreate, async (i) => { + if (!i.isChatInputCommand()) return; + const name = i.commandName; + + try { + if (name === 'join') { + return withVoice(i, async (i, p) => { + const ch = i.member.voice.channel; + await i.editReply({ embeds: [mk('Connecté', `Maintenant dans **${ch.name}** !`, C.SUCCESS)] }); + }); + } + + if (name === 'play' || name === 'playtop') { + return withVoice(i, async (i, p) => { + const query = i.options.getString('query', true); + await i.editReply({ embeds: [mk('Recherche…', `**${query}**`, C.WARNING)] }); + let track; + try { track = await ytSearchInfo(query); } + catch (e) { + return i.editReply({ embeds: [mk('Erreur', `Impossible de trouver ce titre.\n\`\`\`${e.message}\`\`\``, C.ERROR)] }); + } + if (name === 'playtop') p.queue.unshift(track); else p.queue.push(track); + + const e = mk(name === 'playtop' ? 'Ajouté en tête de file' : 'Ajouté à la file', + `**[${track.title}](${track.webpage_url})**`, C.SUCCESS) + .addFields( + { name: 'Durée', value: fmt(track.duration), inline: true }, + { name: 'Artiste', value: String(track.uploader), inline: true }, + { name: 'Position', value: name === 'playtop' ? '#1' : `#${p.queue.length}`, inline: true }, + ); + if (track.thumbnail) e.setThumbnail(track.thumbnail); + await i.editReply({ embeds: [e] }); + + if (p.audioPlayer.state.status === AudioPlayerStatus.Idle) playNext(i.guildId); + }); + } + + if (name === 'skip') { + const p = getPlayer(i.guildId); + if (!p.audioPlayer || p.audioPlayer.state.status === AudioPlayerStatus.Idle) { + return i.reply({ embeds: [mk('Erreur', 'Aucune musique en cours.', C.ERROR)], flags: MessageFlags.Ephemeral }); + } + const title = p.current?.title ?? '?'; + p.skipFlag = true; + p.audioPlayer.stop(true); + return i.reply({ embeds: [mk('Skippé', `**${title}** passée.`, C.INFO)] }); + } + + if (name === 'pause') { + const p = getPlayer(i.guildId); + if (p.audioPlayer?.state.status === AudioPlayerStatus.Playing) { + p.audioPlayer.pause(); + return i.reply({ embeds: [mk('Pause', 'Musique mise en pause.', C.WARNING)] }); + } + return i.reply({ embeds: [mk('Erreur', 'Aucune musique en cours.', C.ERROR)], flags: MessageFlags.Ephemeral }); + } + + if (name === 'resume') { + const p = getPlayer(i.guildId); + if (p.audioPlayer?.state.status === AudioPlayerStatus.Paused) { + p.audioPlayer.unpause(); + return i.reply({ embeds: [mk('Reprise', 'La musique a repris !', C.SUCCESS)] }); + } + return i.reply({ embeds: [mk('Erreur', 'La musique n\'est pas en pause.', C.ERROR)], flags: MessageFlags.Ephemeral }); + } + + if (name === 'stop') { + const p = getPlayer(i.guildId); + const count = p.queue.length; + p.reset(); + p.audioPlayer?.stop(true); + setIdlePresence(); + return i.reply({ embeds: [mk('Stop', `Arrêté. **${count}** titre(s) supprimé(s) de la file.`, C.INFO)] }); + } + + if (name === 'leave') { + const conn = getVoiceConnection(i.guildId); + if (!conn) return i.reply({ embeds: [mk('Erreur', 'Je ne suis pas dans un salon vocal.', C.ERROR)], flags: MessageFlags.Ephemeral }); + const p = getPlayer(i.guildId); + p.reset(); + p.audioPlayer?.stop(true); + p.audioPlayer = null; + try { conn.destroy(); } catch {} + p.connection = null; + setIdlePresence(); + return i.reply({ embeds: [mk('Déconnecté', 'À bientôt !', C.INFO)] }); + } + + if (name === 'queue') { + const p = getPlayer(i.guildId); + if (!p.current && p.queue.length === 0) { + return i.reply({ embeds: [mk('File vide', 'Utilisez `/play` pour ajouter des musiques !', C.INFO)] }); + } + const loopLabel = { [Loop.OFF]: 'Off', [Loop.ONE]: 'Titre', [Loop.QUEUE]: 'File' }[p.loop]; + const e = new EmbedBuilder().setTitle('File d\'attente').setColor(C.MUSIC).setTimestamp(); + if (p.current) e.addFields({ + name: 'En cours', + value: `**[${p.current.title}](${p.current.webpage_url})** — ${fmt(p.current.duration)}`, + inline: false, + }); + if (p.queue.length) { + const lines = p.queue.slice(0, 10).map((s, i) => `\`${i+1}.\` **${s.title}** — ${fmt(s.duration)}`).join('\n'); + const more = p.queue.length > 10 ? `\n*… et ${p.queue.length - 10} autre(s)*` : ''; + e.addFields({ name: `Prochaines (${p.queue.length})`, value: lines + more, inline: false }); + } + e.setFooter({ text: `Music Bot • Loop : ${loopLabel}` }); + return i.reply({ embeds: [e] }); + } + + if (name === 'nowplaying') { + const p = getPlayer(i.guildId); + if (!p.current) return i.reply({ embeds: [mk('Erreur', 'Aucune musique en cours.', C.ERROR)], flags: MessageFlags.Ephemeral }); + return i.reply({ embeds: [nowPlayingEmbed(p.current, p.queue.length, p.loop, p.volume)] }); + } + + if (name === 'volume') { + const niveau = i.options.getInteger('niveau', true); + const p = getPlayer(i.guildId); + p.volume = niveau / 100; + p.currentResource?.volume?.setVolume(p.volume); + const bar = '█'.repeat(Math.floor(niveau / 10)) + '░'.repeat(10 - Math.floor(niveau / 10)); + return i.reply({ embeds: [mk('Volume', `**${niveau}%**\n${bar}`, C.SUCCESS)] }); + } + + if (name === 'clear') { + const p = getPlayer(i.guildId); + if (!p.queue.length) return i.reply({ embeds: [mk('Déjà vide', 'La file est déjà vide.', C.INFO)], flags: MessageFlags.Ephemeral }); + const count = p.queue.length; + p.queue = []; + return i.reply({ embeds: [mk('Vidé', `**${count}** titre(s) supprimé(s).`, C.SUCCESS)] }); + } + + if (name === 'remove') { + const pos = i.options.getInteger('position', true); + const p = getPlayer(i.guildId); + if (pos < 1 || pos > p.queue.length) { + return i.reply({ embeds: [mk('Erreur', `Position invalide (1–${p.queue.length}).`, C.ERROR)], flags: MessageFlags.Ephemeral }); + } + const removed = p.queue.splice(pos - 1, 1)[0]; + return i.reply({ embeds: [mk('Supprimé', `**${removed.title}** retiré de la file.`, C.INFO)] }); + } + + if (name === 'loop') { + const mode = i.options.getString('mode', true); + const p = getPlayer(i.guildId); + p.loop = { off: Loop.OFF, one: Loop.ONE, queue: Loop.QUEUE }[mode]; + const labels = { + off: ['Répétition désactivée', C.INFO], + one: ['Répétition — titre en cours', C.SUCCESS], + queue: ['Répétition — file entière', C.SUCCESS], + }; + const [t, col] = labels[mode]; + return i.reply({ embeds: [mk(t, null, col)] }); + } + + if (name === 'shuffle') { + const p = getPlayer(i.guildId); + if (p.queue.length < 2) return i.reply({ embeds: [mk('Erreur', 'Pas assez de titres dans la file (minimum 2).', C.ERROR)], flags: MessageFlags.Ephemeral }); + for (let k = p.queue.length - 1; k > 0; k--) { + const j = Math.floor(Math.random() * (k + 1)); + [p.queue[k], p.queue[j]] = [p.queue[j], p.queue[k]]; + } + return i.reply({ embeds: [mk('Mélangé', `**${p.queue.length}** titres mélangés.`, C.SUCCESS)] }); + } + } catch (e) { + err(`Slash ${name}:`, e.message); + const payload = { embeds: [mk('Erreur inattendue', `\`\`\`${e.message}\`\`\``, C.ERROR)], flags: MessageFlags.Ephemeral }; + if (i.deferred || i.replied) await i.followUp(payload).catch(() => {}); + else await i.reply(payload).catch(() => {}); + } +}); + +process.on('unhandledRejection', (r) => err('unhandledRejection:', r)); +process.on('uncaughtException', (e) => err('uncaughtException:', e?.stack || e)); + +info('Démarrage du bot…'); +client.login(TOKEN);