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