Correctif 4017 : passerelle vocale v8
This commit is contained in:
parent
449856de81
commit
e5059a603e
1 changed files with 652 additions and 0 deletions
652
bot.js
Normal file
652
bot.js
Normal file
|
|
@ -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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue