musicbot-js/bot.js

652 lines
25 KiB
JavaScript
Raw Permalink 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.

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);